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