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; 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 readonly ConcurrentDictionary _gfxObj = new(); private readonly ConcurrentDictionary _visualBounds = new(); private readonly ConcurrentDictionary _setup = new(); private readonly ConcurrentDictionary _cellStruct = new(); private readonly ConcurrentDictionary _flatGfxObj = new(); private readonly ConcurrentDictionary _flatSetup = new(); private readonly ConcurrentDictionary _flatCellStruct = new(); private readonly ConcurrentDictionary _flatEnvCell = new(); public PhysicsDataCache() : this(requirePreparedCollision: false) { } private PhysicsDataCache(bool requirePreparedCollision) { _requirePreparedCollision = requirePreparedCollision; 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 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 readonly ConcurrentDictionary _buildings = new(); /// /// The unified cell graph (UCG): the active id->cell resolver and registry. /// Populated unconditionally in — BEFORE the /// idempotency + null-BSP guards, so BSP-less cells are registered too — 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; } = new(); /// /// 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); } 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, }; _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, }); } /// /// 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 : null; /// /// 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 the physics BSP + polygon data from a CellStruct /// (indoor room geometry). No-ops if the id is already cached or the /// CellStruct has no physics BSP. /// 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 (preparedStructure is not null) _flatCellStruct.TryAdd(envCellId, preparedStructure); if (preparedTopology is not null) _flatEnvCell.TryAdd(envCellId, preparedTopology); if (_requirePreparedCollision) { CachePreparedCellStruct( envCellId, envCell, worldTransform, preparedStructure ?? throw MissingPreparedCollision("CellStruct", envCellId), preparedTopology ?? throw MissingPreparedCollision("EnvCell topology", envCellId)); return; } // UCG Stage 1: register in the unified graph for ALL cells — before the // idempotency + null-BSP guards below, so BSP-less cells are still included. if (!CellGraph.Contains(envCellId)) { CellGraph.Add(UcgEnvCell.FromDat( envCellId, envCell, cellStruct, worldTransform, preparedStructure?.ContainmentBsp)); } if (_cellStruct.ContainsKey(envCellId)) return; if (cellStruct.PhysicsBSP?.Root is null) return; Matrix4x4.Invert(worldTransform, out var inverseTransform); var resolved = 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), }; _cellStruct[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) { _flatCellStruct.TryAdd(envCellId, preparedStructure); _flatEnvCell.TryAdd(envCellId, preparedTopology); if (!CellGraph.Contains(envCellId)) { CellGraph.Add(UcgEnvCell.FromPrepared( envCellId, worldTransform, preparedStructure, preparedTopology)); } // Preserve CacheCellStruct's existing distinction: BSP-less cells // participate in the cell graph but do not masquerade as hydrated // collision cells. if (preparedStructure.PhysicsBsp.RootIndex < 0) return; 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)); } _cellStruct.TryAdd(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, }); } /// /// 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 : null; public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null; 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 : null; public FlatSetupCollision? GetFlatSetup(uint id) => _flatSetup.TryGetValue(id, out var value) ? value : null; 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) => _cellStruct[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); _buildings[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) { uint prefix = landblockId & 0xFFFF0000u; foreach (var key in _buildings.Keys) if ((key & 0xFFFF0000u) == prefix) _buildings.TryRemove(key, out _); } /// /// 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; foreach (var key in _cellStruct.Keys) if ((key & 0xFFFF0000u) == prefix) _cellStruct.TryRemove(key, out _); foreach (var key in _flatCellStruct.Keys) if ((key & 0xFFFF0000u) == prefix) _flatCellStruct.TryRemove(key, out _); foreach (var key in _flatEnvCell.Keys) if ((key & 0xFFFF0000u) == prefix) _flatEnvCell.TryRemove(key, out _); } 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) => _buildings[landcellId] = b; } /// /// 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; } } /// 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. /// Nullable: cells without a CellBSP cannot participate in portal /// containment and are skipped 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; } }