using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
///
/// Cell-based spatial index for object collision. Each entity registers
/// into the outdoor terrain cells (24m × 24m) it overlaps. The Transition
/// system queries this to find nearby objects during collision detection.
///
/// Retail AC uses the same cell-based approach (no k-d tree / octree).
/// Outdoor cells are 24×24m (8 cells per 192m landblock, 64 cells per lb).
/// Cell ID = landblock high 16 bits | (cellX * 8 + cellY + 1) in low 16.
///
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();
///
/// Register an entity into the cells it overlaps based on world position + radius.
///
///
/// The optional +
/// parameters carry retail PhysicsState bits and decoded
/// respectively, so the
/// FindObjCollisions retail-faithful exemption block (PvP rule,
/// ETHEREAL skip, viewer-vs-creature) can short-circuit without an
/// extra lookup. Default state=0 + flags=None preserves
/// the original "static decoration" behavior — the existing 5
/// landblock-entity registration sites pass nothing.
///
///
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 cellScope = 0u)
{
Deregister(entityId);
var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius,
collisionType, cylHeight, scale, state, flags);
// ISSUES #83 / Phase A1.5 (2026-05-21): if the caller passed a
// cellScope (typically the entity's ParentCellId for an interior
// EnvCell static), scope the shadow to ONLY that cell instead of
// computing outdoor-landcell occupancy from XY. Without this,
// interior statics (a fireplace inside cell 0xA9B40121) get
// registered into the outdoor landcell whose XY they overlap
// (e.g. 0xA9B40029) and fire collisions when the player is OUTSIDE
// the building — the user-reported "thin air" collision outdoors.
if (cellScope != 0u)
{
if (!_cells.TryGetValue(cellScope, out var scopedList))
{
scopedList = new List();
_cells[cellScope] = scopedList;
}
scopedList.Add(entry);
_entityToCells[entityId] = new List { cellScope };
return;
}
// The radius parameter should already be the WORLD-SPACE bounding
// radius (i.e., already multiplied by scale) so the broad-phase cell
// occupancy is correct. Callers are responsible for that.
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
int minCx = Math.Max(0, (int)((localX - radius) / 24f));
int maxCx = Math.Min(7, (int)((localX + radius) / 24f));
int minCy = Math.Max(0, (int)((localY - radius) / 24f));
int maxCy = Math.Min(7, (int)((localY + radius) / 24f));
var cellIds = new List();
uint lbPrefix = landblockId & 0xFFFF0000u;
for (int cx = minCx; cx <= maxCx; cx++)
{
for (int cy = minCy; cy <= maxCy; cy++)
{
uint cellId = lbPrefix | (uint)(cx * 8 + cy + 1);
cellIds.Add(cellId);
if (!_cells.TryGetValue(cellId, out var list))
{
list = new List();
_cells[cellId] = list;
}
list.Add(entry);
}
}
_entityToCells[entityId] = cellIds;
}
///
/// A6.P4 door fix (2026-05-24): register one logical entity composed of
/// multiple collision shapes. All emitted rows
/// share , so
/// propagates an ETHEREAL flip to every part (the existing per-entityId
/// iteration handles this naturally). The shape list is cached in
/// so can
/// recompose part world-transforms when the entity moves.
///
///
/// Retail anchor: CPhysicsObj::FindObjCollisions →
/// CPartArray::FindObjCollisions at
/// acclient_2013_pseudo_c.txt:276961-286250. One PhysicsObj per
/// entity, parts iterated for collision testing.
///
///
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 cellScope = 0u)
{
Deregister(entityId);
if (shapes.Count == 0) return;
_entityShapes[entityId] = shapes;
var allCells = new List();
var seenCells = new HashSet();
uint lbPrefix = landblockId & 0xFFFF0000u;
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);
if (cellScope != 0u)
{
AddEntryToCell(entry, cellScope);
if (seenCells.Add(cellScope)) allCells.Add(cellScope);
continue;
}
float localX = partWorldPos.X - worldOffsetX;
float localY = partWorldPos.Y - worldOffsetY;
float r = shape.Radius;
int minCx = Math.Max(0, (int)((localX - r) / 24f));
int maxCx = Math.Min(7, (int)((localX + r) / 24f));
int minCy = Math.Max(0, (int)((localY - r) / 24f));
int maxCy = Math.Min(7, (int)((localY + r) / 24f));
for (int cx = minCx; cx <= maxCx; cx++)
{
for (int cy = minCy; cy <= maxCy; cy++)
{
uint cellId = lbPrefix | (uint)(cx * 8 + cy + 1);
AddEntryToCell(entry, cellId);
if (seenCells.Add(cellId)) allCells.Add(cellId);
}
}
}
_entityToCells[entityId] = allCells;
}
/// 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.
///
///
/// Cheaper than + for
/// the 5–10 Hz UpdatePosition (0xF748) stream the server emits
/// per visible entity: this is the path retail's
/// CPhysicsObj::SetPosition takes (cited at
/// acclient_2013_pseudo_c.txt:284276) — same shape, new cell
/// membership. If the entity isn't already registered, this is a
/// no-op so callers don't have to gate.
///
///
public void UpdatePosition(uint entityId, Vector3 worldPos, Quaternion rotation,
float worldOffsetX, float worldOffsetY, uint landblockId)
{
// A6.P4 door fix (2026-05-24): if the entity was registered via
// RegisterMultiPart, we have its full shape list cached. Use that
// to recompose all part transforms instead of finding one template entry.
if (_entityShapes.TryGetValue(entityId, out var shapes))
{
// Pull the entity-scoped state + flags from the first matching entry
// (they're shared across all parts of a logical entity).
uint state = 0u;
EntityCollisionFlags flags = EntityCollisionFlags.None;
if (_entityToCells.TryGetValue(entityId, out var existingCells)
&& existingCells.Count > 0
&& _cells.TryGetValue(existingCells[0], out var firstList))
{
foreach (var e in firstList)
{
if (e.EntityId == entityId)
{
state = e.State;
flags = e.Flags;
break;
}
}
}
RegisterMultiPart(entityId, worldPos, rotation, shapes,
state, flags, worldOffsetX, worldOffsetY, landblockId);
return;
}
// Single-shape path (legacy compat for tests + entities that never
// went through RegisterMultiPart).
if (!_entityToCells.TryGetValue(entityId, out var oldCells) || oldCells.Count == 0)
return;
ShadowEntry? template = null;
foreach (var oldCellId in oldCells)
{
if (_cells.TryGetValue(oldCellId, out var list))
{
foreach (var e in list)
{
if (e.EntityId == entityId)
{
template = e;
break;
}
}
}
if (template is not null) break;
}
if (template is null) return;
var t = template.Value;
Register(entityId, t.GfxObjId, worldPos, rotation, t.Radius,
worldOffsetX, worldOffsetY, landblockId,
t.CollisionType, t.CylHeight, t.Scale,
t.State, t.Flags);
}
///
/// 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 };
}
}
}
/// Remove an entity from all cells it was registered in.
public void Deregister(uint entityId)
{
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
return;
foreach (var cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(e => e.EntityId == entityId);
}
_entityToCells.Remove(entityId);
_entityShapes.Remove(entityId);
}
/// Remove all entities belonging to a landblock.
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);
}
/// Get all objects registered in a specific cell.
public IReadOnlyList GetObjectsInCell(uint cellId)
{
if (_cells.TryGetValue(cellId, out var list))
return list;
return Array.Empty();
}
///
/// Get all objects near a world position. Searches the given landblock plus
/// all 8 adjacent landblocks to handle objects near cell/landblock boundaries.
/// Within each landblock, queries only the cells the query sphere overlaps.
///
///
/// Issue #91 (2026-05-20): the optional
/// parameter is the candidate set returned by .
/// When supplied, indoor shadows registered via 's
/// cellScope parameter (A1.5 fix at `4d3bf6f`) are ALSO included in
/// the result. Without this, interior statics (fireplaces, tables, chests)
/// registered against e.g. `0xA9B40121` are stored under that key but the
/// outdoor-grid lookup (cell ids like `0xA9B40029`) never queries the
/// indoor key. Net effect pre-fix: interior items don't block movement.
///
///
///
/// Issue #98 (2026-05-24): the optional
/// parameter gates the outdoor radial sweep on the SPHERE's primary cell
/// type. Mirrors retail's CObjCell::find_cell_list at
/// acclient_2013_pseudo_c.txt:308751-308769: when the sphere's
/// position is in an indoor cell (id ≥ 0x0100), retail only adds THAT
/// cell + portal-visible neighbors to the cell array — never outdoor
/// cells (except via portal traversal — see #99 below). Combined with
/// CEnvCell::find_collisions only iterating
/// this->shadow_object_list, retail's indoor cells never test
/// against outdoor statics like landblock-baked buildings.
///
///
///
/// Pre-fix bug shape (issue #98): the cellar EnvCell's player sphere
/// queried the outdoor 24-m grid via the radial sweep and picked up the
/// landblock-wide cottage GfxObj (registered with cellScope=0);
/// the head sphere bumped the cottage's downward-facing floor poly from
/// below at world Z=94 and capped the ascent. With this gate, the
/// outdoor sweep is skipped when the primary cell is indoor, so the
/// cottage is only seen from outdoor primary cells (the building's
/// own outdoor footprint). Default primaryCellId=0 preserves
/// the pre-fix radial-only behavior for callers that don't know /
/// care about cell type (existing tests).
///
///
///
/// A6.P4 slice 1 / issue #99 (2026-05-24): the
/// loop no longer filters out
/// outdoor cell ids. already adds
/// outdoor cells to the candidate set when the sphere straddles an
/// indoor cell's exit portal (OtherCellId=0xFFFF) via
/// .
/// Pre-slice-1, the explicit
/// "skip outdoor ids" filter combined with #98's indoor-primary gate
/// meant doors registered at outdoor cells (default cellScope=0
/// for server-spawned doors at GameWindow.cs:3139) were invisible to
/// spheres on the indoor side of the doorway — walk-through. Iterating
/// those outdoor cells from the portal-reachable set lets the indoor
/// query reach them without re-enabling the full 24-m radial sweep
/// (which is what #98 closed).
///
///
public void GetNearbyObjects(Vector3 worldPos, float queryRadius,
float worldOffsetX, float worldOffsetY, uint landblockId,
List results,
System.Collections.Generic.IReadOnlyCollection? portalReachableCells = null,
uint primaryCellId = 0u,
bool isViewer = false)
{
results.Clear();
// A6.P4 door fix (2026-05-24): dedup on the full ShadowEntry rather
// than entity id. Pre-RegisterMultiPart each entity had exactly one
// shadow, so dedup-by-entityId correctly suppressed multi-cell
// duplication. With multi-part entities (a door has 1 Sphere + 1
// per-Part-BSP = 2 entries with the same EntityId; creatures can
// have more), an entityId dedup silently dropped every shape after
// the first — the door's BSP slab never reached BSPQuery in the
// 2026-05-24 apparatus reproduction. ShadowEntry's record-struct
// equality compares all fields (incl. GfxObjId, LocalPosition,
// CollisionType) so distinct shapes of the same entity make it
// through, while a single shape registered across multiple cells
// (its position + radius equal across calls) deduplicates exactly
// as before.
var seen = new HashSet();
// Cells reachable from the sphere's primary cell via the portal graph
// (output of CellTransit.FindCellSet). This set holds the primary
// cell, any indoor neighbours the sphere overlaps via portals, and —
// when the sphere straddles an exit portal (0xFFFF) — outdoor cells
// added by AddAllOutsideCells. Iterate all of them so cellScope-
// registered indoor statics AND outdoor-scope shadows reachable
// through a doorway are both visible.
if (portalReachableCells is not null)
{
foreach (uint cellId in portalReachableCells)
{
if (!_cells.TryGetValue(cellId, out var list)) continue;
foreach (var entry in list)
{
if (seen.Add(entry))
results.Add(entry);
}
}
}
// Issue #98 (2026-05-24): when the sphere is in an INDOOR cell, skip
// the outdoor radial sweep entirely — retail's CEnvCell::find_collisions
// only iterates this->shadow_object_list, never outdoor cells'. Indoor
// statics are reached via indoorCellIds above. This closes the
// cottage-cellar Z-cap (head sphere bumping cottage floor from below
// because the landblock-wide cottage GfxObj was returned by the
// unconditional radial sweep). Callers that don't pass primaryCellId
// (or pass 0) keep the pre-fix radial-only behavior.
//
// M1.5 / Phase U (2026-05-31): exempt the camera viewer (isViewer=true) from
// this gate. The camera probe (ObjectInfoState.IsViewer) sweeps up+back from the
// player pivot through the cottage exterior shell, which is a landblock-baked
// GfxObj registered cellScope=0 (outdoor shadow list). Retail's
// SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761) bounds the viewer
// via the player's cell enclosure — in retail, interior EnvCells are
// self-enclosing (walls in the cell's own geometry). In acdream's data model
// the enclosure is the exterior-shell GfxObj (issue #98 established this); the
// viewer must be able to reach it. Retail's find_obj_collisions at :308918 has
// NO indoor-cell gate — the gate is acdream-specific. The #98 protection is
// correct only for the player foot/head capsule (IsPlayer), NOT for IsViewer.
// Spec: docs/superpowers/specs/2026-05-31-camera-collision-indoor-engagement-design.md
if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer)
return;
// Extract landblock X/Y from the ID.
int lbX = (int)((landblockId >> 24) & 0xFF);
int lbY = (int)((landblockId >> 16) & 0xFF);
// Search the player's landblock and all 8 neighbors.
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
int nx = lbX + dx;
int ny = lbY + dy;
if (nx < 0 || nx > 255 || ny < 0 || ny > 255) continue;
uint neighborLb = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu;
uint nbPrefix = neighborLb & 0xFFFF0000u;
// Compute local position relative to this neighbor landblock.
float nbOffX = worldOffsetX + dx * 192f;
float nbOffY = worldOffsetY + dy * 192f;
float localX = worldPos.X - nbOffX;
float localY = worldPos.Y - nbOffY;
int minCx = Math.Max(0, (int)((localX - queryRadius) / 24f));
int maxCx = Math.Min(7, (int)((localX + queryRadius) / 24f));
int minCy = Math.Max(0, (int)((localY - queryRadius) / 24f));
int maxCy = Math.Min(7, (int)((localY + queryRadius) / 24f));
for (int cx = minCx; cx <= maxCx; cx++)
{
for (int cy = minCy; cy <= maxCy; cy++)
{
uint cellId = nbPrefix | (uint)(cx * 8 + cy + 1);
if (!_cells.TryGetValue(cellId, out var list)) continue;
foreach (var entry in list)
{
if (seen.Add(entry))
results.Add(entry);
}
}
}
}
}
}
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);