using System.Collections.Concurrent; using System.Numerics; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; using Plane = System.Numerics.Plane; using UcgEnvCell = AcDream.Core.World.Cells.EnvCell; using UcgCellGraph = AcDream.Core.World.Cells.CellGraph; using PreparedCellGraphLandblock = AcDream.Core.World.Cells.PreparedCellGraphLandblock; namespace AcDream.Core.Physics; /// /// Thread-safe cache of physics-relevant data extracted from GfxObj and Setup /// dat objects during streaming and live-object materialization. Prepared /// payloads are built/read off the hot path; one update-thread publisher /// installs prepared production records without retaining the parsed DAT /// collision graph. The public constructor remains the graph-oracle seam for /// conformance fixtures and bake/equivalence tooling. ConcurrentDictionary /// keeps diagnostics and tooling reads safe without a global lock. /// public sealed class PhysicsDataCache { private readonly bool _requirePreparedCollision; private PhysicsDataCache? _readFallback; private readonly CollisionWorldStateSlot _collisionWorld; private readonly ConcurrentDictionary _gfxObj = new(); private readonly ConcurrentDictionary _visualBounds = new(); private readonly ConcurrentDictionary _setup = new(); private ConcurrentDictionary _cellStruct => _collisionWorld.Current.CellStruct; private readonly ConcurrentDictionary _flatGfxObj = new(); private readonly ConcurrentDictionary _flatSetup = new(); private ConcurrentDictionary _flatCellStruct => _collisionWorld.Current.FlatCellStruct; private ConcurrentDictionary _flatEnvCell => _collisionWorld.Current.FlatEnvCell; public PhysicsDataCache() : this(requirePreparedCollision: false) { } private PhysicsDataCache(bool requirePreparedCollision) : this(requirePreparedCollision, new CollisionWorldStateSlot()) { } private PhysicsDataCache( bool requirePreparedCollision, CollisionWorldStateSlot collisionWorld) { _requirePreparedCollision = requirePreparedCollision; _collisionWorld = collisionWorld ?? throw new ArgumentNullException(nameof(collisionWorld)); CellGraph = new UcgCellGraph(_collisionWorld); if (!requirePreparedCollision && PhysicsDiagnostics.CollisionShadowSampleEvery > 0) { CollisionShadow = new CollisionShadowVerifier( PhysicsDiagnostics.CollisionShadowSampleEvery, PhysicsDiagnostics.CollisionShadowArtifactDirectory); } } /// /// Creates the gameplay cache with Slice I6's prepared flat collision /// representation authoritative. The public constructor deliberately /// remains the graph-oracle fixture seam for conformance and bake tooling. /// public static PhysicsDataCache CreateProduction() { var cache = new PhysicsDataCache(requirePreparedCollision: true) { CollisionTraversalMode = CollisionTraversalMode.Flat, }; return cache; } internal static PhysicsDataCache CreateProduction( CollisionWorldStateSlot collisionWorld) { var cache = new PhysicsDataCache( requirePreparedCollision: true, collisionWorld) { CollisionTraversalMode = CollisionTraversalMode.Flat, }; return cache; } internal CollisionShadowVerifier? CollisionShadow { get; set; } public CollisionShadowStats CollisionShadowStats => CollisionShadow?.Stats ?? default; /// /// Slice I graph/flat differential selector. Production is permanently /// flat-authoritative. Graph remains an explicit fixture/oracle mode. /// internal CollisionTraversalMode CollisionTraversalMode { get; set; } = CollisionTraversalMode.Graph; // ── Phase 2: building portal cache for outdoor→indoor entry ─────────── private ConcurrentDictionary _buildings => _collisionWorld.Current.Buildings; /// /// The unified cell graph (UCG): the active id->cell resolver and registry. /// Populated by for cells with valid /// containment (including cells with no physics root), and /// consumed across the engine: the player render/lighting root /// (CellGraph.CurrCell, written at the player chokepoint /// PhysicsEngine.UpdatePlayerCurrCell and read by the renderer), the /// universal id->cell lookup (GetVisible), the 3rd-person camera cell /// (FindVisibleChildCell), and the block-local terrain origin /// (TryGetTerrainOrigin, read by CellTransit's pick + transit /// paths). No longer inert. /// public UcgCellGraph CellGraph { get; } internal CollisionWorldStateSlot CollisionWorld => _collisionWorld; /// /// Creates the empty off-side facade used by Runtime's metered root /// materializer. Global immutable catalogs fall through to this cache; /// mutable world topology is installed one leaf per host step. /// internal PhysicsDataCache CreateEmptyCollisionStaging( CollisionWorldStateSlot collisionWorld) { return new PhysicsDataCache(_requirePreparedCollision, collisionWorld) { CollisionTraversalMode = CollisionTraversalMode, _readFallback = this, }; } internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( PhysicsDataCache staging, uint landblockId, uint[] gfxObjectIds, uint[] setupIds) => new( this, staging, landblockId, gfxObjectIds, setupIds); /// /// Extract and cache the physics BSP + polygon data from a GfxObj, /// PLUS always cache a visual AABB from the vertex data regardless of /// the HasPhysics flag. The visual AABB is used as a collision fallback /// for entities whose Setup has no retail physics data — it lets the /// user collide with decorative meshes that don't have a CylSphere or /// per-part BSP. /// public void CacheGfxObj( uint gfxObjId, GfxObj gfxObj, FlatGfxObjCollisionAsset? prepared = null) { ArgumentNullException.ThrowIfNull(gfxObj); if (_requirePreparedCollision && prepared is null && _flatGfxObj.TryGetValue(gfxObjId, out var retainedPrepared)) { prepared = retainedPrepared; } if (_requirePreparedCollision && gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics) && gfxObj.PhysicsBSP?.Root is not null && prepared?.PhysicsBsp is null && !_flatGfxObj.ContainsKey(gfxObjId)) { throw MissingPreparedCollision("GfxObj", gfxObjId); } if (_requirePreparedCollision) { if (prepared is not null) CachePreparedGfxObj(gfxObjId, prepared); return; } if (prepared is not null) _flatGfxObj.TryAdd(gfxObjId, prepared); // Always cache a visual AABB from the mesh vertices — this is cheap // and fed by the mesh data that's already loaded. It serves as the // fallback collision shape for pure-visual entities. if (!_visualBounds.ContainsKey(gfxObjId) && gfxObj.VertexArray != null) { _visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray); } GfxObjVisualBounds? parsedBounds = _visualBounds.TryGetValue(gfxObjId, out var cachedBounds) ? cachedBounds : null; if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing)) { if (prepared is not null) existing.FlatPhysicsBsp ??= prepared.PhysicsBsp; return; } if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return; if (gfxObj.PhysicsBSP?.Root is null) return; if (gfxObj.VertexArray is null) return; var physics = new GfxObjPhysics { SourceId = gfxObjId, BSP = gfxObj.PhysicsBSP, PhysicsPolygons = gfxObj.PhysicsPolygons, BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere, Vertices = gfxObj.VertexArray, Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray), FlatPhysicsBsp = prepared?.PhysicsBsp, VisualBounds = prepared?.VisualBounds ?? (parsedBounds is { } pb ? new FlatGfxObjVisualBounds( pb.Min, pb.Max, pb.Center, pb.Radius, pb.HalfExtents) : null), }; _gfxObj[gfxObjId] = physics; if (PhysicsDiagnostics.ProbeDumpGfxObjsEnabled && PhysicsDiagnostics.ProbeDumpGfxObjIds.Contains(gfxObjId)) { try { var dump = GfxObjDumpSerializer.Capture(gfxObjId, physics); var path = System.IO.Path.Combine( PhysicsDiagnostics.ProbeDumpGfxObjsPath, System.FormattableString.Invariant($"0x{gfxObjId:X8}.gfxobj.json")); GfxObjDumpSerializer.Write(dump, path); Console.WriteLine(System.FormattableString.Invariant( $"[gfxobj-dump] wrote 0x{gfxObjId:X8} polys={dump.ResolvedPolygons.Count} → {path}")); } catch (Exception ex) { Console.WriteLine(System.FormattableString.Invariant( $"[gfxobj-dump] FAILED to dump 0x{gfxObjId:X8}: {ex.GetType().Name}: {ex.Message}")); } } } /// /// Publishes one package-prepared GfxObj without retaining its parsed DAT /// BSP, polygon dictionary, or vertex array. /// public void CacheGfxObj( uint gfxObjId, FlatGfxObjCollisionAsset prepared) { ArgumentNullException.ThrowIfNull(prepared); CachePreparedGfxObj(gfxObjId, prepared); } private void CachePreparedGfxObj( uint gfxObjId, FlatGfxObjCollisionAsset prepared) { _flatGfxObj.TryAdd(gfxObjId, prepared); if (prepared.VisualBounds is { } bounds) { _visualBounds.TryAdd(gfxObjId, new GfxObjVisualBounds { Min = bounds.Min, Max = bounds.Max, Center = bounds.Center, Radius = bounds.Radius, HalfExtents = bounds.HalfExtents, }); } if (prepared.PhysicsBsp.RootIndex < 0) return; FlatCollisionSphere root = prepared.PhysicsBsp.Nodes[prepared.PhysicsBsp.RootIndex] .BoundingSphere; _gfxObj.TryAdd(gfxObjId, new GfxObjPhysics { SourceId = gfxObjId, BoundingSphere = new Sphere { Origin = root.Origin, Radius = root.Radius, }, FlatPhysicsBsp = prepared.PhysicsBsp, VisualBounds = prepared.VisualBounds, }); } /// /// Get the cached visual AABB for a GfxObj, or null if not cached. /// public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) => _visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : _readFallback?.GetVisualBounds(gfxObjId); /// /// Compute a tight axis-aligned bounding box over all vertices in the mesh. /// Used as a fallback collision shape for entities whose Setup has no /// physics data — we approximate collision using the visual extent. /// internal static GfxObjVisualBounds ComputeVisualBounds(VertexArray vertexArray) { if (vertexArray.Vertices == null || vertexArray.Vertices.Count == 0) { return new GfxObjVisualBounds { Min = Vector3.Zero, Max = Vector3.Zero, Center = Vector3.Zero, Radius = 0f, HalfExtents = Vector3.Zero, }; } var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); foreach (var kv in vertexArray.Vertices) { var p = kv.Value.Origin; if (p.X < min.X) min.X = p.X; if (p.Y < min.Y) min.Y = p.Y; if (p.Z < min.Z) min.Z = p.Z; if (p.X > max.X) max.X = p.X; if (p.Y > max.Y) max.Y = p.Y; if (p.Z > max.Z) max.Z = p.Z; } var center = (min + max) * 0.5f; var halfExt = (max - min) * 0.5f; float radius = halfExt.Length(); return new GfxObjVisualBounds { Min = min, Max = max, Center = center, Radius = radius, HalfExtents = halfExt, }; } /// /// Extract and cache the collision shape data from a Setup. /// No-ops if the id is already cached. /// public void CacheSetup( uint setupId, Setup setup, FlatSetupCollision? prepared = null) { ArgumentNullException.ThrowIfNull(setup); if (_requirePreparedCollision && prepared is null && _flatSetup.TryGetValue(setupId, out var retainedPrepared)) { prepared = retainedPrepared; } if (_requirePreparedCollision && prepared is null && !_flatSetup.ContainsKey(setupId)) throw MissingPreparedCollision("Setup", setupId); if (_requirePreparedCollision) { CachePreparedSetup( setupId, prepared ?? throw MissingPreparedCollision("Setup", setupId)); return; } if (prepared is not null) _flatSetup.TryAdd(setupId, prepared); if (_setup.TryGetValue(setupId, out SetupPhysics? existing)) { if (prepared is not null) existing.FlatCollision ??= prepared; return; } _setup[setupId] = new SetupPhysics { SourceId = setupId, CylSpheres = setup.CylSpheres ?? new(), Spheres = setup.Spheres ?? new(), Height = setup.Height, Radius = setup.Radius, StepUpHeight = setup.StepUpHeight, StepDownHeight = setup.StepDownHeight, FlatCollision = prepared, }; } /// /// Publishes one package-prepared Setup without retaining parsed DBObj /// cylinder/sphere lists. /// public void CacheSetup(uint setupId, FlatSetupCollision prepared) { ArgumentNullException.ThrowIfNull(prepared); CachePreparedSetup(setupId, prepared); } private void CachePreparedSetup(uint setupId, FlatSetupCollision prepared) { _flatSetup.TryAdd(setupId, prepared); _setup.TryAdd(setupId, new SetupPhysics { SourceId = setupId, Height = prepared.Height, Radius = prepared.Radius, StepUpHeight = prepared.StepUpHeight, StepDownHeight = prepared.StepDownHeight, FlatCollision = prepared, }); } /// /// Extract and cache an authored CellStruct payload (indoor room geometry). /// A missing physics root is valid (the cell can still own containment and /// portals); a missing containment root is not a loadable CEnvCell and is /// rejected before either the graph or collision record is published. /// public void CacheCellStruct( uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell, CellStruct cellStruct, Matrix4x4 worldTransform, FlatCellStructureCollisionAsset? preparedStructure = null, FlatEnvCellTopology? preparedTopology = null) { ArgumentNullException.ThrowIfNull(envCell); ArgumentNullException.ThrowIfNull(cellStruct); if (_requirePreparedCollision) { if (preparedStructure is null) _flatCellStruct.TryGetValue(envCellId, out preparedStructure); if (preparedTopology is null) _flatEnvCell.TryGetValue(envCellId, out preparedTopology); } if (_requirePreparedCollision && preparedStructure is null && !_flatCellStruct.ContainsKey(envCellId)) throw MissingPreparedCollision("CellStruct", envCellId); if (_requirePreparedCollision && preparedTopology is null && !_flatEnvCell.ContainsKey(envCellId)) throw MissingPreparedCollision("EnvCell topology", envCellId); if (_requirePreparedCollision) { CachePreparedCellStruct( envCellId, envCell, worldTransform, preparedStructure ?? throw MissingPreparedCollision("CellStruct", envCellId), preparedTopology ?? throw MissingPreparedCollision("EnvCell topology", envCellId)); return; } // CCellStruct::point_in_cell dereferences cell_bsp->root_node before // entering BSPNODE::point_inside_cell_bsp. A null ROOT is therefore // not the recursive missing-positive-child "inside" sentinel. The // installed 2013 catalog contains zero such payloads; quarantine one // rather than publishing a cell that claims the whole world. if (cellStruct.CellBSP?.Root is null) return; // A malformed optional prepared shadow must not attach to an otherwise // valid raw cell. Production takes the prepared-only overload below. if (preparedStructure?.ContainmentBsp.RootIndex < 0) { preparedStructure = null; preparedTopology = null; } if (preparedStructure is not null) _collisionWorld.Current.TryAddFlatCellStruct(envCellId, preparedStructure); if (preparedTopology is not null) _collisionWorld.Current.TryAddFlatEnvCell(envCellId, preparedTopology); // UCG Stage 1: register only a loadable authored cell. if (!CellGraph.Contains(envCellId)) { CellGraph.Add(UcgEnvCell.FromDat( envCellId, envCell, cellStruct, worldTransform, preparedStructure?.ContainmentBsp)); } if (_cellStruct.ContainsKey(envCellId)) return; Matrix4x4.Invert(worldTransform, out var inverseTransform); var resolved = cellStruct.PhysicsPolygons is null ? new Dictionary() : ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray); // Visible polygons — portals reference these (NOT PhysicsPolygons). var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray); // Portal list from envCell.CellPortals. var portals = new System.Collections.Generic.List(envCell.CellPortals.Count); foreach (var p in envCell.CellPortals) { portals.Add(new PortalInfo( otherCellId: p.OtherCellId, polygonId: p.PolygonId, flags: (ushort)p.Flags)); } // VisibleCells set — populated for future use; not consulted this phase. // envCell.VisibleCells is List per the DatReaderWriter shape — iterate directly, no .Keys. var visibleCellIds = new System.Collections.Generic.HashSet(); if (envCell.VisibleCells is not null) { uint lbPrefix = envCellId & 0xFFFF0000u; foreach (var lowId in envCell.VisibleCells) visibleCellIds.Add(lbPrefix | lowId); } var cellPhysics = new CellPhysics { SourceId = envCellId, BSP = cellStruct.PhysicsBSP, PhysicsPolygons = cellStruct.PhysicsPolygons, Vertices = cellStruct.VertexArray, WorldTransform = worldTransform, InverseWorldTransform = inverseTransform, Resolved = resolved, FlatPhysicsBsp = preparedStructure?.PhysicsBsp, FlatContainmentBsp = preparedStructure?.ContainmentBsp, FlatPortalPolygons = preparedStructure?.PortalPolygons, FlatTopology = preparedTopology, // ── Phase 2 portal fields ── CellBSP = cellStruct.CellBSP, Portals = portals, PortalPolygons = portalPolygons, VisibleCellIds = visibleCellIds, // #107: retail CEnvCell.seen_outside — consumed by AdjustPosition's // indoor not-found fallback (acclient :280037). SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside), // AP-71 (Campaign P Slice P4, 2026-07-30): retail CObjCell:: // restriction_obj — a DAT-baked uint32 gated by EnvCellFlags. // HasRestrictionObj (0x8), confirmed via ACE.DatLoader.FileTypes. // EnvCell.cs:66-67 and Chorizite.DatReaderWriter's own EnvCell // field of the same name (reflection-confirmed 2026-07-30). Zero // for every ordinary (non-house-barrier) cell. RestrictionObj = envCell.RestrictionObj, }; _collisionWorld.Current.SetCellStruct(envCellId, cellPhysics); if (PhysicsDiagnostics.ProbeDumpCellsEnabled && PhysicsDiagnostics.ProbeDumpCellIds.Contains(envCellId)) { try { var dump = CellDumpSerializer.Capture(envCellId, cellPhysics); var path = System.IO.Path.Combine( PhysicsDiagnostics.ProbeDumpCellsPath, System.FormattableString.Invariant($"0x{envCellId:X8}.json")); CellDumpSerializer.Write(dump, path); Console.WriteLine(System.FormattableString.Invariant( $"[cell-dump] wrote 0x{envCellId:X8} polys={dump.ResolvedPolygons.Count} portals={dump.Portals.Count} → {path}")); } catch (Exception ex) { Console.WriteLine(System.FormattableString.Invariant( $"[cell-dump] FAILED to dump 0x{envCellId:X8}: {ex.GetType().Name}: {ex.Message}")); } } if (PhysicsDiagnostics.ProbeCellCacheEnabled) { var root = cellStruct.PhysicsBSP?.Root; int bspRootPolyCount = root?.Polygons?.Count ?? 0; bool bspRootHasChildren = root?.PosNode is not null || root?.NegNode is not null; int bspTotalLeafPolys = 0; int bspUnmatchedIds = 0; if (root is not null) { var stack = new System.Collections.Generic.Stack(); stack.Push(root); while (stack.Count > 0) { var n = stack.Pop(); if (n.Polygons is not null) { foreach (var pid in n.Polygons) { bspTotalLeafPolys++; if (!resolved.ContainsKey(pid)) bspUnmatchedIds++; } } if (n.PosNode is not null) stack.Push(n.PosNode); if (n.NegNode is not null) stack.Push(n.NegNode); } } var bs = root?.BoundingSphere; string bsStr = bs is null ? "bsphere=n/a" : System.FormattableString.Invariant( $"bsphere=({bs.Origin.X:F2},{bs.Origin.Y:F2},{bs.Origin.Z:F2}) r={bs.Radius:F2}"); var worldOrigin = Vector3.Transform(Vector3.Zero, worldTransform); // A6.P3 slice 4 (2026-05-22): also dump portal targets so we // can see which cells the player should be able to transition // to (issue #98 investigation: cellar-up stuck at top of ramp). string portalTargets; if (portals.Count == 0) { portalTargets = "portalTargets=[]"; } else { var sb = new System.Text.StringBuilder("portalTargets=["); for (int i = 0; i < portals.Count; i++) { if (i > 0) sb.Append(','); sb.Append(System.FormattableString.Invariant( $"(cell=0x{portals[i].OtherCellId:X4},poly=0x{portals[i].PolygonId:X4},flags=0x{portals[i].Flags:X4})")); } sb.Append(']'); portalTargets = sb.ToString(); } Console.WriteLine(System.FormattableString.Invariant( $"[cell-cache] envCellId=0x{envCellId:X8} physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} resolvedCount={resolved.Count} bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} {bsStr} portalCount={portals.Count} visibleCells={visibleCellIds.Count} cellBspRoot={(cellStruct.CellBSP?.Root is null ? "null" : "ok")} worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2}) {portalTargets}")); } if (PhysicsDiagnostics.ProbeWalkMissEnabled) { int walkableCount = 0; foreach (var entry in WalkMissDiagnostic.EnumerateWalkable( resolved, PhysicsGlobals.FloorZ)) walkableCount++; Console.Write(System.FormattableString.Invariant( $"[floor-polys] cellId=0x{envCellId:X8} walkableCount={walkableCount}")); foreach (var entry in WalkMissDiagnostic.EnumerateWalkable( resolved, PhysicsGlobals.FloorZ)) { Console.Write(System.FormattableString.Invariant( $" [id=0x{entry.PolyId:X4} nz={entry.NormalZ:F3} bbox=({entry.BboxMin.X:F2},{entry.BboxMin.Y:F2})..({entry.BboxMax.X:F2},{entry.BboxMax.Y:F2}) planeZ@center={entry.PlaneZAtBboxCenter:F3}]")); } Console.WriteLine(); } } /// /// Publishes one package-prepared EnvCell without retaining its parsed DAT /// CellStruct, BSP trees, polygon dictionaries, or vertex arrays. /// public void CacheCellStruct( uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell, Matrix4x4 worldTransform, FlatCellStructureCollisionAsset preparedStructure, FlatEnvCellTopology preparedTopology) { ArgumentNullException.ThrowIfNull(envCell); ArgumentNullException.ThrowIfNull(preparedStructure); ArgumentNullException.ThrowIfNull(preparedTopology); CachePreparedCellStruct( envCellId, envCell, worldTransform, preparedStructure, preparedTopology); } private void CachePreparedCellStruct( uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell, Matrix4x4 worldTransform, FlatCellStructureCollisionAsset preparedStructure, FlatEnvCellTopology preparedTopology) { // Same invariant as the raw loader. RootIndex -1 is the flattened // encoding of a missing ROOT, not a recursive positive-child sentinel. // Reject it atomically so graph, collision, and prepared caches agree // that this cell is unavailable and a later valid hydration may retry. if (preparedStructure.ContainmentBsp.RootIndex < 0) return; _collisionWorld.Current.TryAddFlatCellStruct(envCellId, preparedStructure); _collisionWorld.Current.TryAddFlatEnvCell(envCellId, preparedTopology); if (!CellGraph.Contains(envCellId)) { CellGraph.Add(UcgEnvCell.FromPrepared( envCellId, worldTransform, preparedStructure, preparedTopology)); } // Physics may be rootless even though the cell's containment and // topology are valid; preserve that loaded, non-colliding cell. if (_cellStruct.ContainsKey(envCellId)) return; Matrix4x4.Invert(worldTransform, out Matrix4x4 inverseTransform); var portals = new List(preparedTopology.Portals.Length); for (int i = 0; i < preparedTopology.Portals.Length; i++) { FlatEnvCellPortal portal = preparedTopology.Portals[i]; portals.Add(new PortalInfo( portal.OtherCellId, portal.PolygonId, portal.Flags)); } _collisionWorld.Current.TryAddCellStruct(envCellId, new CellPhysics { SourceId = envCellId, WorldTransform = worldTransform, InverseWorldTransform = inverseTransform, Resolved = new Dictionary(), FlatPhysicsBsp = preparedStructure.PhysicsBsp, FlatContainmentBsp = preparedStructure.ContainmentBsp, FlatPortalPolygons = preparedStructure.PortalPolygons, FlatTopology = preparedTopology, Portals = portals, VisibleCellIds = new HashSet( preparedTopology.VisibleCellIds), SeenOutside = preparedTopology.SeenOutside, // AP-71 (Campaign P Slice P4, 2026-07-30): read straight from the // locally-parsed live envCell, same as SeenOutside historically // was before the bake pipeline existed — RestrictionObj is NOT // (and does not need to be) part of the baked FlatEnvCellTopology // payload; the caller (LandblockPhysicsPublisher.PublishCell) // already has a real parsed DatReaderWriter.DBObjs.EnvCell in // hand for Position/EnvironmentId, so this is free. RestrictionObj = envCell.RestrictionObj, }); } /// /// Pre-resolve all physics polygons: lookup vertex positions from VertexArray /// and compute the face plane. Matches ACE's Polygon constructor which calls /// make_plane() and resolves Vertices from VertexIDs at load time. /// internal static Dictionary ResolvePolygons( Dictionary polys, VertexArray vertexArray) { var resolved = new Dictionary(polys.Count); foreach (var (id, poly) in polys) { int numVerts = poly.VertexIds.Count; if (numVerts < 3) continue; var verts = new Vector3[numVerts]; bool valid = true; for (int i = 0; i < numVerts; i++) { ushort vid = (ushort)poly.VertexIds[i]; if (!vertexArray.Vertices.TryGetValue(vid, out var sv)) { valid = false; break; } verts[i] = sv.Origin; } if (!valid) continue; // Compute plane normal using ACE's make_plane algorithm: // fan cross-product accumulation + normalization. var normal = Vector3.Zero; for (int i = 1; i < numVerts - 1; i++) { var v1 = verts[i] - verts[0]; var v2 = verts[i + 1] - verts[0]; normal += Vector3.Cross(v1, v2); } float len = normal.Length(); if (len < 1e-8f) continue; normal /= len; // D = -(average dot(normal, vertex)) float dotSum = 0f; for (int i = 0; i < numVerts; i++) dotSum += Vector3.Dot(normal, verts[i]); float d = -(dotSum / numVerts); resolved[id] = new ResolvedPolygon { Vertices = verts, Plane = new Plane(normal, d), NumPoints = numVerts, SidesType = poly.SidesType, Id = id, }; } return resolved; } private static InvalidOperationException MissingPreparedCollision( string kind, uint sourceId) => new( $"Production {kind} 0x{sourceId:X8} has no prepared collision asset. " + "Gameplay must not extract or fall back to a parsed DAT graph."); public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : _readFallback?.GetGfxObj(id); public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : _readFallback?.GetSetup(id); public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null; public FlatGfxObjCollisionAsset? GetFlatGfxObj(uint id) => _flatGfxObj.TryGetValue(id, out var value) ? value : _readFallback?.GetFlatGfxObj(id); public FlatSetupCollision? GetFlatSetup(uint id) => _flatSetup.TryGetValue(id, out var value) ? value : _readFallback?.GetFlatSetup(id); public FlatCellStructureCollisionAsset? GetFlatCellStruct(uint id) => _flatCellStruct.TryGetValue(id, out var value) ? value : null; public FlatEnvCellTopology? GetFlatEnvCell(uint id) => _flatEnvCell.TryGetValue(id, out var value) ? value : null; public int GfxObjCount => _gfxObj.Count; public int SetupCount => _setup.Count; public int CellStructCount => _cellStruct.Count; public int FlatGfxObjCount => _flatGfxObj.Count; public int FlatSetupCount => _flatSetup.Count; public int FlatCellStructCount => _flatCellStruct.Count; public int FlatEnvCellCount => _flatEnvCell.Count; public int GraphGfxObjCount => CountRetainedGfxObjGraphs(); public int GraphSetupCount => CountRetainedSetupGraphs(); public int GraphCellStructCount => CountRetainedCellGraphs(); private int CountRetainedGfxObjGraphs() { int count = 0; foreach (GfxObjPhysics value in _gfxObj.Values) { if (value.BSP is not null || value.PhysicsPolygons is not null || value.Vertices is not null || value.Resolved.Count != 0) { count++; } } return count; } private int CountRetainedSetupGraphs() { int count = 0; foreach (SetupPhysics value in _setup.Values) { if (value.CylSpheres.Count != 0 || value.Spheres.Count != 0) count++; } return count; } private int CountRetainedCellGraphs() { int count = 0; foreach (CellPhysics value in _cellStruct.Values) { if (value.BSP is not null || value.CellBSP is not null || value.PhysicsPolygons is not null || value.Vertices is not null || value.Resolved.Count != 0 || value.PortalPolygons is not null) { count++; } } return count; } /// /// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached /// EnvCell ids — used by /// to enumerate occluder candidates without exposing the underlying /// dictionary. Returns the live key-set; callers should snapshot the /// collection if they need stability across frames. /// public IReadOnlyCollection CellStructIds => (IReadOnlyCollection)_cellStruct.Keys; /// /// Register a pre-built directly. /// Intended for unit-test fixtures that construct synthetic BSP trees /// without needing real DAT content. /// public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics) => _gfxObj[gfxObjId] = physics; /// /// Register a pre-built directly. Intended for /// unit-test fixtures that construct synthetic cells without going through /// dat-driven . /// public void RegisterCellStructForTest(uint envCellId, CellPhysics physics) => _collisionWorld.Current.SetCellStruct(envCellId, physics); /// /// Indoor walking Phase 2 (2026-05-19). Cache the building portal list /// for an outdoor landcell that contains a building stab. Used by /// . /// public void CacheBuilding(uint landcellId, IReadOnlyList portals, Matrix4x4 worldTransform, uint modelId = 0u) { if (_buildings.ContainsKey(landcellId)) return; Matrix4x4.Invert(worldTransform, out var inverse); _collisionWorld.Current.SetBuilding(landcellId, new BuildingPhysics { WorldTransform = worldTransform, InverseWorldTransform = inverse, Portals = portals, // BR-7: first-wins per cell mirrors retail CSortCell::add_building // (0x00534030) — and one building per origin landcell mirrors // CLandBlock::init_buildings (0x0052fd80). ModelId = modelId, }); } /// /// #146 (2026-06-24): drop every cached building belonging to a landblock so /// the next pass re-bases their STREAMING-RELATIVE /// WorldTransform against the CURRENT _liveCenter. Without this, /// CacheBuilding's per-cell first-wins guard LOCKS the transform at whatever /// frame it was first cached with; a teleport recenter then leaves the shell /// BSP at a stale world offset (~the source↔dest landblock distance), so the /// foot-sphere walks through where the wall visually is and collision is lost /// (login at Arwic → portal to Holtburg → house walls clip; bldOrigin probed /// ~5.5 km off). Terrain avoids this because AddLandblock overwrites its /// WorldOffset every apply; buildings get the equivalent per-apply re-base via /// the caller clearing-then-repopulating (which keeps first-wins within each /// fresh pass — retail CSortCell::add_building semantics preserved). /// public void RemoveBuildingsForLandblock(uint landblockId) { CollisionWorldState world = _collisionWorld.Current; RemovePrefixKeys( world.BuildingKeys, landblockId & 0xFFFF0000u, world.RemoveBuilding); } /// /// Retires one landblock prefix's installed keys through the O1 ledger: /// O(prefix keys), never a whole-map scan. Removal tombstones the captured /// slot list, so index iteration over the captured reference stays exact. /// private static void RemovePrefixKeys( PrefixKeyIndex ledger, uint prefix, Func remove) { List? slots = ledger.SlotsForPrefix(prefix); if (slots is null) return; int limit = slots.Count; for (int index = 0; index < limit; index++) { uint key = slots[index]; if (key != 0u) remove(key); } } /// /// D8 (2026-06-24): drop every cached cell struct belonging to a landblock so /// the next re-bases its STREAMING-RELATIVE /// WorldTransform against the current _liveCenter — symmetric with /// (#146). Without this, the /// _cellStruct first-wins guard LOCKS a dungeon cell's transform at its /// first streaming frame; a teleport recenter then leaves the cell BSP at a stale /// world offset (~the source↔dest landblock distance), so foot-sphere collision /// queries walk through where the wall geometry is. /// public void RemoveCellsForLandblock(uint landblockId) { uint prefix = landblockId & 0xFFFF0000u; CollisionWorldState world = _collisionWorld.Current; RemovePrefixKeys(world.CellStructKeys, prefix, world.RemoveCellStruct); RemovePrefixKeys( world.FlatCellStructKeys, prefix, world.RemoveFlatCellStruct); RemovePrefixKeys(world.FlatEnvCellKeys, prefix, world.RemoveFlatEnvCell); } public BuildingPhysics? GetBuilding(uint landcellId) => _buildings.TryGetValue(landcellId, out var b) ? b : null; public IReadOnlyCollection BuildingIds => (IReadOnlyCollection)_buildings.Keys; /// Test helper, mirrors . public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _collisionWorld.Current.SetBuilding(landcellId, b); internal sealed class LandblockReplacementBuilder : IDisposable { private readonly PhysicsDataCache _active; private readonly PhysicsDataCache _staging; private readonly uint _prefix; private readonly uint[] _gfxIds; private readonly uint[] _setupIds; private readonly List> _gfx = new(); private readonly List> _bounds = new(); private readonly List> _flatGfx = new(); private readonly List> _setups = new(); private readonly List> _flatSetups = new(); private readonly List> _cells = new(); private readonly List> _flatCells = new(); private readonly List> _flatEnvCells = new(); private readonly List> _buildings = new(); private readonly HashSet _cellIds = new(); private readonly HashSet _flatCellIds = new(); private readonly HashSet _flatEnvCellIds = new(); private readonly HashSet _buildingIds = new(); private readonly List _removeCells = new(); private readonly List _removeFlatCells = new(); private readonly List _removeFlatEnvCells = new(); private readonly List _removeBuildings = new(); private readonly UcgCellGraph.LandblockReplacementBuilder _cellGraph; private List? _keySlots; private int _keySlotLimit; private bool _keySlotsCaptured; private int _phase; private int _cursor; internal LandblockReplacementBuilder( PhysicsDataCache active, PhysicsDataCache staging, uint landblockId, uint[] gfxIds, uint[] setupIds) { _active = active; _staging = staging; _prefix = landblockId & 0xFFFF0000u; _gfxIds = gfxIds; _setupIds = setupIds; _cellGraph = active.CellGraph.CreateLandblockReplacementBuilder( staging.CellGraph, _prefix); } internal int WorkUnits { get; private set; } internal PreparedPhysicsDataCacheLandblock? Prepared { get; private set; } internal bool Advance() { switch (_phase) { case 0: if (_cursor < _gfxIds.Length) { uint id = _gfxIds[_cursor++]; Preinstall(_staging._gfxObj, _active._gfxObj, id); Preinstall(_staging._visualBounds, _active._visualBounds, id); Preinstall(_staging._flatGfxObj, _active._flatGfxObj, id); Capture(_staging._gfxObj, id, _gfx); Capture(_staging._visualBounds, id, _bounds); Capture(_staging._flatGfxObj, id, _flatGfx); WorkUnits++; return false; } _cursor = 0; _phase++; return false; case 1: if (_cursor < _setupIds.Length) { uint id = _setupIds[_cursor++]; Preinstall(_staging._setup, _active._setup, id); Preinstall(_staging._flatSetup, _active._flatSetup, id); Capture(_staging._setup, id, _setups); Capture(_staging._flatSetup, id, _flatSetups); WorkUnits++; return false; } _phase++; return false; case 2: { // O1: enumerate the staging root's installed target-prefix // keys instead of scanning the whole staging map, one key // per advance. if (TryTakeNextPrefixKey( StagingWorld.CellStructKeys, out uint id)) { CaptureInstall(_staging._cellStruct, id, _cells, _cellIds); WorkUnits++; return false; } _phase++; return false; } case 3: { // O1: enumerate the active root's installed target-prefix // keys for removal capture. This also removes the previous // cross-frame live enumerator over the active map. if (TryTakeNextPrefixKey( ActiveWorld.CellStructKeys, out uint id)) { CaptureRemoval(_active._cellStruct, id, _cellIds, _removeCells); WorkUnits++; return false; } _phase++; return false; } case 4: { if (TryTakeNextPrefixKey( StagingWorld.FlatCellStructKeys, out uint id)) { CaptureInstall( _staging._flatCellStruct, id, _flatCells, _flatCellIds); WorkUnits++; return false; } _phase++; return false; } case 5: { if (TryTakeNextPrefixKey( ActiveWorld.FlatCellStructKeys, out uint id)) { CaptureRemoval( _active._flatCellStruct, id, _flatCellIds, _removeFlatCells); WorkUnits++; return false; } _phase++; return false; } case 6: { if (TryTakeNextPrefixKey( StagingWorld.FlatEnvCellKeys, out uint id)) { CaptureInstall( _staging._flatEnvCell, id, _flatEnvCells, _flatEnvCellIds); WorkUnits++; return false; } _phase++; return false; } case 7: { if (TryTakeNextPrefixKey( ActiveWorld.FlatEnvCellKeys, out uint id)) { CaptureRemoval( _active._flatEnvCell, id, _flatEnvCellIds, _removeFlatEnvCells); WorkUnits++; return false; } _phase++; return false; } case 8: { if (TryTakeNextPrefixKey( StagingWorld.BuildingKeys, out uint id)) { CaptureInstall( _staging._buildings, id, _buildings, _buildingIds); WorkUnits++; return false; } _phase++; return false; } case 9: { if (TryTakeNextPrefixKey( ActiveWorld.BuildingKeys, out uint id)) { CaptureRemoval( _active._buildings, id, _buildingIds, _removeBuildings); WorkUnits++; return false; } _phase++; return false; } case 10: WorkUnits++; if (!_cellGraph.Advance()) return false; Prepared = new PreparedPhysicsDataCacheLandblock( _prefix, _gfx, _bounds, _flatGfx, _setups, _flatSetups, _removeCells, _cells, _removeFlatCells, _flatCells, _removeFlatEnvCells, _flatEnvCells, _removeBuildings, _buildings, _cellGraph.Prepared!); _phase++; return true; default: return true; } } private static void Capture( ConcurrentDictionary source, uint id, List> destination) { if (source.TryGetValue(id, out T? value)) destination.Add(new KeyValuePair(id, value)); } private static void Preinstall( ConcurrentDictionary source, ConcurrentDictionary destination, uint id) { if (source.TryGetValue(id, out T? value)) destination.TryAdd(id, value); } private CollisionWorldState StagingWorld => _staging._collisionWorld.Current; private CollisionWorldState ActiveWorld => _active._collisionWorld.Current; /// /// O1 metered prefix-key cursor. The first call of a phase captures /// the ledger's live slot-list reference and count; later calls /// iterate by index, skipping tombstones (key 0). Removal only ever /// tombstones a slot, so a captured reference stays exact across /// frames without holding a map enumerator. /// private bool TryTakeNextPrefixKey(PrefixKeyIndex ledger, out uint key) { if (!_keySlotsCaptured) { _keySlots = ledger.SlotsForPrefix(_prefix); _keySlotLimit = _keySlots?.Count ?? 0; _keySlotsCaptured = true; _cursor = 0; } while (_cursor < _keySlotLimit) { uint candidate = _keySlots![_cursor++]; if (candidate != 0u) { key = candidate; return true; } } key = 0u; _keySlots = null; _keySlotsCaptured = false; return false; } private static void CaptureInstall( ConcurrentDictionary source, uint id, List> destination, HashSet ids) { if (source.TryGetValue(id, out T? value)) { destination.Add(new KeyValuePair(id, value)); ids.Add(id); } } private static void CaptureRemoval( ConcurrentDictionary source, uint id, HashSet retained, List destination) { if (!retained.Contains(id) && source.ContainsKey(id)) destination.Add(id); } public void Dispose() { _keySlots = null; _keySlotsCaptured = false; _cellGraph.Dispose(); } } } internal sealed record PreparedPhysicsDataCacheLandblock( uint LandblockPrefix, IReadOnlyList> GfxObjects, IReadOnlyList> VisualBounds, IReadOnlyList> FlatGfxObjects, IReadOnlyList> Setups, IReadOnlyList> FlatSetups, IReadOnlyList CellIdsToRemove, IReadOnlyList> Cells, IReadOnlyList FlatCellIdsToRemove, IReadOnlyList> FlatCells, IReadOnlyList FlatEnvCellIdsToRemove, IReadOnlyList> FlatEnvCells, IReadOnlyList BuildingIdsToRemove, IReadOnlyList> Buildings, PreparedCellGraphLandblock CellGraph); /// /// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless /// of whether it has physics data. Used as a collision fallback shape for /// entities whose Setup has no CylSpheres/Spheres/Radius (pure decorative /// meshes). Provides an approximate cylinder matching the visible mesh extent. /// public sealed class GfxObjVisualBounds { /// Local-space minimum corner of the mesh AABB. public required Vector3 Min { get; init; } /// Local-space maximum corner of the mesh AABB. public required Vector3 Max { get; init; } /// Center of the local-space AABB. public required Vector3 Center { get; init; } /// Local-space radius (diagonal half-length) — loose bound. public required float Radius { get; init; } /// Local-space half-extents ((Max - Min) * 0.5). public required Vector3 HalfExtents { get; init; } } /// /// A physics polygon with pre-resolved vertex positions and pre-computed plane. /// ACE pre-computes these in its Polygon constructor; we do it at cache time /// to avoid per-collision-test vertex lookups. /// public sealed class ResolvedPolygon { public required Vector3[] Vertices { get; init; } public required Plane Plane { get; init; } public required int NumPoints { get; init; } public required CullMode SidesType { get; init; } /// /// Polygon index within its parent (cell or GfxObj). Used by the /// `ACDREAM_PROBE_POLY_DUMP` probe (A6.P3 slice 4 investigation, /// 2026-05-22) to identify which dat polygon a push-back hit so we /// can compare our extracted vertices/plane against WorldBuilder's /// straight-from-dat read. Defaults to 0 for test fixtures that /// don't care about polygon identity. /// public ushort Id { get; init; } } /// Cached physics data for a single GfxObj part. public sealed class GfxObjPhysics { public uint SourceId { get; init; } public PhysicsBSPTree? BSP { get; init; } public Dictionary? PhysicsPolygons { get; init; } public Sphere? BoundingSphere { get; init; } public VertexArray? Vertices { get; init; } /// /// Pre-resolved polygon data with vertex positions and computed planes. /// Populated once at cache time so BSP queries don't pay per-test lookup cost. /// public Dictionary Resolved { get; init; } = new(); /// /// Prepared integer-indexed representation. Slice I5 guarantees this for /// production-published collision objects; graph-only test fixtures may /// omit it. /// public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; } /// /// Retail CGfxObj::gfx_bound_box — the AABB of this GfxObj's vertex /// array, filled by CGfxObj::init_end @0x00534200 and returned by /// CPhysicsPart::GetBoundingBox @0x0050d600. Cached beside /// so the single /// ShadowShapeBuilder.FromLandblockBspParts resolver answers both of /// retail's cell-membership questions from one lookup (#334). /// public FlatGfxObjVisualBounds? VisualBounds { get; init; } } /// Cached collision shape data for a Setup (character/creature capsule). public sealed class SetupPhysics { public uint SourceId { get; init; } public List CylSpheres { get; init; } = new(); public List Spheres { get; init; } = new(); public float Height { get; init; } public float Radius { get; init; } public float StepUpHeight { get; init; } public float StepDownHeight { get; init; } public FlatSetupCollision? FlatCollision { get; internal set; } } /// /// Cached physics data for an indoor cell's room geometry (CellStruct). /// Used for wall/floor/ceiling collision in EnvCells. /// ACE: EnvCell.find_env_collisions queries CellStructure.PhysicsBSP. /// public sealed class CellPhysics { public uint SourceId { get; init; } /// /// The physics BSP tree for this cell. Nullable so that test fixtures /// can construct a from /// alone without needing a real DAT BSP object. Production code must /// null-check before traversal: cell.BSP?.Root is not null. /// public PhysicsBSPTree? BSP { get; init; } public Dictionary? PhysicsPolygons { get; init; } public VertexArray? Vertices { get; init; } public Matrix4x4 WorldTransform { get; init; } public Matrix4x4 InverseWorldTransform { get; init; } /// /// Pre-resolved polygon data with vertex positions and computed planes. /// public required Dictionary Resolved { get; init; } /// /// Prepared integer-indexed physics BSP. Slice I5 guarantees this for /// production-published collision cells; graph-only test fixtures may /// omit it. /// public FlatPhysicsBsp? FlatPhysicsBsp { get; init; } // ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ─────── /// /// The cell BSP used for /// (point-in-cell tests). Separate tree from /// (collision) and from the renderer's drawing-BSP. /// Source: cellStruct.CellBSP at cache time. /// Root presence is required for a published cell. Missing positive /// children inside a valid tree are the retail inside base case; a missing /// root is rejected by . /// public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; } /// /// Prepared integer-indexed cell-containment BSP. Slice I5 guarantees this /// for production-published cells; graph-only test fixtures may omit it. /// public FlatCellContainmentBsp? FlatContainmentBsp { get; init; } /// /// Prepared visible-polygon table addressed by /// . /// public FlatPolygonTable? FlatPortalPolygons { get; init; } /// /// Prepared per-EnvCell portal/visibility state. Placement remains on this /// runtime record's world transforms. /// public FlatEnvCellTopology? FlatTopology { get; init; } /// /// Portal connections to neighbouring cells, in cell-local space. /// Default: empty list. Source: envCell.CellPortals. /// public IReadOnlyList Portals { get; init; } = System.Array.Empty(); /// /// Resolved VISIBLE polygons (from cellStruct.Polygons), /// keyed by polygon id. Distinct from which /// holds PhysicsPolygons. Portal lookup via /// resolves through this dict. /// Nullable when the cell has no visible polys (rare). /// public Dictionary? PortalPolygons { get; init; } /// /// The full cell ids visible from this cell (with landblock prefix). /// Populated from envCell.VisibleCells at cache time. Unused /// this phase; reserved for the optional find_cell_list /// visibility filter. /// public IReadOnlySet VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet(); /// /// #107: retail CEnvCell.seen_outside (dat EnvCellFlags.SeenOutside). /// True for interiors with outdoor-visible portals (ground-floor cottage rooms). /// PhysicsEngine.AdjustPosition's indoor branch falls back to the outdoor /// landcell under the point when the claimed cell's visible graph does not /// contain it AND this flag is set (retail acclient :280037-280046). /// public bool SeenOutside { get; init; } /// /// AP-71 (Campaign P Slice P4, 2026-07-30): retail CObjCell:: /// restriction_obj — the DAT-baked object IID (gated by /// EnvCellFlags.HasRestrictionObj) that CObjCell:: /// check_entry_restrictions (0x0052b6d0) resolves and consults /// before letting a player into this cell. Zero (the default) for /// every ordinary cell — only house-barrier EnvCells set it. Consumed /// by via /// . Resolving the value into /// a live restriction weenie (for the owner/guest-list /// CanMoveInto check) is NOT modeled — see the AP-71 register /// history for the narrower unfed-input row. /// public uint RestrictionObj { get; init; } }