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