acdream/src/AcDream.Core/Physics/PhysicsDataCache.cs
Erik 82f8d4f82e refactor(physics): remove parsed collision graphs
Publish prepared GfxObj, Setup, CellStruct, and EnvCell collision records without retaining their parsed DAT BSP, polygon, vertex, or shape graphs. Strip temporary physics bundles at stable world commit, keep graph traversal only as an explicit test/tooling oracle, and report graph residency from actual retained fields.

Validated by 115 focused collision/streaming tests, a zero-warning Release build, and 8,413 passing Release tests with five pre-existing skips.

Co-authored-by: Codex <codex@openai.com>
2026-07-25 18:21:19 +02:00

1065 lines
43 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public sealed class PhysicsDataCache
{
private readonly bool _requirePreparedCollision;
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
private readonly ConcurrentDictionary<uint, FlatGfxObjCollisionAsset>
_flatGfxObj = new();
private readonly ConcurrentDictionary<uint, FlatSetupCollision>
_flatSetup = new();
private readonly ConcurrentDictionary<uint, FlatCellStructureCollisionAsset>
_flatCellStruct = new();
private readonly ConcurrentDictionary<uint, FlatEnvCellTopology>
_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);
}
}
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Slice I graph/flat differential selector. Production is permanently
/// flat-authoritative. Graph remains an explicit fixture/oracle mode.
/// </summary>
internal CollisionTraversalMode CollisionTraversalMode { get; set; } =
CollisionTraversalMode.Graph;
// ── Phase 2: building portal cache for outdoor→indoor entry ───────────
private readonly ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();
/// <summary>
/// The unified cell graph (UCG): the active id-&gt;cell resolver and registry.
/// Populated unconditionally in <see cref="CacheCellStruct"/> — BEFORE the
/// idempotency + null-BSP guards, so BSP-less cells are registered too — and
/// consumed across the engine: the player render/lighting root
/// (<c>CellGraph.CurrCell</c>, written at the player chokepoint
/// <c>PhysicsEngine.UpdatePlayerCurrCell</c> and read by the renderer), the
/// universal id-&gt;cell lookup (<c>GetVisible</c>), the 3rd-person camera cell
/// (<c>FindVisibleChildCell</c>), and the block-local terrain origin
/// (<c>TryGetTerrainOrigin</c>, read by <c>CellTransit</c>'s pick + transit
/// paths). No longer inert.
/// </summary>
public UcgCellGraph CellGraph { get; } = new();
/// <summary>
/// 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.
/// </summary>
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}"));
}
}
}
/// <summary>
/// Publishes one package-prepared GfxObj without retaining its parsed DAT
/// BSP, polygon dictionary, or vertex array.
/// </summary>
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,
});
}
/// <summary>
/// Get the cached visual AABB for a GfxObj, or null if not cached.
/// </summary>
public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) =>
_visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null;
/// <summary>
/// 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.
/// </summary>
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,
};
}
/// <summary>
/// Extract and cache the collision shape data from a Setup.
/// No-ops if the id is already cached.
/// </summary>
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,
};
}
/// <summary>
/// Publishes one package-prepared Setup without retaining parsed DBObj
/// cylinder/sphere lists.
/// </summary>
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,
});
}
/// <summary>
/// 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.
/// </summary>
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<PortalInfo>(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<UInt16> per the DatReaderWriter shape — iterate directly, no .Keys.
var visibleCellIds = new System.Collections.Generic.HashSet<uint>();
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<DatReaderWriter.Types.PhysicsBSPNode>();
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();
}
}
/// <summary>
/// Publishes one package-prepared EnvCell without retaining its parsed DAT
/// CellStruct, BSP trees, polygon dictionaries, or vertex arrays.
/// </summary>
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<PortalInfo>(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<ushort, ResolvedPolygon>(),
FlatPhysicsBsp = preparedStructure.PhysicsBsp,
FlatContainmentBsp = preparedStructure.ContainmentBsp,
FlatPortalPolygons = preparedStructure.PortalPolygons,
FlatTopology = preparedTopology,
Portals = portals,
VisibleCellIds = new HashSet<uint>(
preparedTopology.VisibleCellIds),
SeenOutside = preparedTopology.SeenOutside,
});
}
/// <summary>
/// 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.
/// </summary>
internal static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
VertexArray vertexArray)
{
var resolved = new Dictionary<ushort, ResolvedPolygon>(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;
}
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
/// EnvCell ids — used by <see cref="AcDream.Core.Selection.WorldPicker"/>
/// 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.
/// </summary>
public IReadOnlyCollection<uint> CellStructIds => (IReadOnlyCollection<uint>)_cellStruct.Keys;
/// <summary>
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
/// Intended for unit-test fixtures that construct synthetic BSP trees
/// without needing real DAT content.
/// </summary>
public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics)
=> _gfxObj[gfxObjId] = physics;
/// <summary>
/// Register a pre-built <see cref="CellPhysics"/> directly. Intended for
/// unit-test fixtures that construct synthetic cells without going through
/// dat-driven <see cref="CacheCellStruct"/>.
/// </summary>
public void RegisterCellStructForTest(uint envCellId, CellPhysics physics)
=> _cellStruct[envCellId] = physics;
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list
/// for an outdoor landcell that contains a building stab. Used by
/// <see cref="CellTransit.CheckBuildingTransit"/>.
/// </summary>
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> 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,
};
}
/// <summary>
/// #146 (2026-06-24): drop every cached building belonging to a landblock so
/// the next <see cref="CacheBuilding"/> pass re-bases their STREAMING-RELATIVE
/// <c>WorldTransform</c> against the CURRENT <c>_liveCenter</c>. 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 <c>AddLandblock</c> 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 <c>CSortCell::add_building</c> semantics preserved).
/// </summary>
public void RemoveBuildingsForLandblock(uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
foreach (var key in _buildings.Keys)
if ((key & 0xFFFF0000u) == prefix)
_buildings.TryRemove(key, out _);
}
/// <summary>
/// D8 (2026-06-24): drop every cached cell struct belonging to a landblock so
/// the next <see cref="CacheCellStruct"/> re-bases its STREAMING-RELATIVE
/// <c>WorldTransform</c> against the current <c>_liveCenter</c> — symmetric with
/// <see cref="RemoveBuildingsForLandblock"/> (#146). Without this, the
/// <c>_cellStruct</c> 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.
/// </summary>
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<uint> BuildingIds => (IReadOnlyCollection<uint>)_buildings.Keys;
/// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary>
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b;
}
/// <summary>
/// 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.
/// </summary>
public sealed class GfxObjVisualBounds
{
/// <summary>Local-space minimum corner of the mesh AABB.</summary>
public required Vector3 Min { get; init; }
/// <summary>Local-space maximum corner of the mesh AABB.</summary>
public required Vector3 Max { get; init; }
/// <summary>Center of the local-space AABB.</summary>
public required Vector3 Center { get; init; }
/// <summary>Local-space radius (diagonal half-length) — loose bound.</summary>
public required float Radius { get; init; }
/// <summary>Local-space half-extents ((Max - Min) * 0.5).</summary>
public required Vector3 HalfExtents { get; init; }
}
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>
/// 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.
/// </summary>
public ushort Id { get; init; }
}
/// <summary>Cached physics data for a single GfxObj part.</summary>
public sealed class GfxObjPhysics
{
public uint SourceId { get; init; }
public PhysicsBSPTree? BSP { get; init; }
public Dictionary<ushort, Polygon>? PhysicsPolygons { get; init; }
public Sphere? BoundingSphere { get; init; }
public VertexArray? Vertices { get; init; }
/// <summary>
/// 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.
/// </summary>
public Dictionary<ushort, ResolvedPolygon> Resolved { get; init; } = new();
/// <summary>
/// Prepared integer-indexed representation. Slice I5 guarantees this for
/// production-published collision objects; graph-only test fixtures may
/// omit it.
/// </summary>
public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; }
}
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
public sealed class SetupPhysics
{
public uint SourceId { get; init; }
public List<CylSphere> CylSpheres { get; init; } = new();
public List<Sphere> 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; }
}
/// <summary>
/// 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.
/// </summary>
public sealed class CellPhysics
{
public uint SourceId { get; init; }
/// <summary>
/// The physics BSP tree for this cell. Nullable so that test fixtures
/// can construct a <see cref="CellPhysics"/> from <see cref="Resolved"/>
/// alone without needing a real DAT BSP object. Production code must
/// null-check before traversal: <c>cell.BSP?.Root is not null</c>.
/// </summary>
public PhysicsBSPTree? BSP { get; init; }
public Dictionary<ushort, Polygon>? PhysicsPolygons { get; init; }
public VertexArray? Vertices { get; init; }
public Matrix4x4 WorldTransform { get; init; }
public Matrix4x4 InverseWorldTransform { get; init; }
/// <summary>
/// Pre-resolved polygon data with vertex positions and computed planes.
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
/// <summary>
/// Prepared integer-indexed physics BSP. Slice I5 guarantees this for
/// production-published collision cells; graph-only test fixtures may
/// omit it.
/// </summary>
public FlatPhysicsBsp? FlatPhysicsBsp { get; init; }
// ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
/// <summary>
/// The cell BSP used for <see cref="BSPQuery.PointInsideCellBsp"/>
/// (point-in-cell tests). Separate tree from <see cref="BSP"/>
/// (collision) and from the renderer's drawing-BSP.
/// Source: <c>cellStruct.CellBSP</c> at cache time.
/// Nullable: cells without a CellBSP cannot participate in portal
/// containment and are skipped by <see cref="CellTransit"/>.
/// </summary>
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
/// <summary>
/// Prepared integer-indexed cell-containment BSP. Slice I5 guarantees this
/// for production-published cells; graph-only test fixtures may omit it.
/// </summary>
public FlatCellContainmentBsp? FlatContainmentBsp { get; init; }
/// <summary>
/// Prepared visible-polygon table addressed by
/// <see cref="FlatEnvCellTopology.Portals"/>.
/// </summary>
public FlatPolygonTable? FlatPortalPolygons { get; init; }
/// <summary>
/// Prepared per-EnvCell portal/visibility state. Placement remains on this
/// runtime record's world transforms.
/// </summary>
public FlatEnvCellTopology? FlatTopology { get; init; }
/// <summary>
/// Portal connections to neighbouring cells, in cell-local space.
/// Default: empty list. Source: <c>envCell.CellPortals</c>.
/// </summary>
public IReadOnlyList<PortalInfo> Portals { get; init; } = System.Array.Empty<PortalInfo>();
/// <summary>
/// Resolved VISIBLE polygons (from <c>cellStruct.Polygons</c>),
/// keyed by polygon id. Distinct from <see cref="Resolved"/> which
/// holds <c>PhysicsPolygons</c>. Portal lookup via
/// <see cref="PortalInfo.PolygonId"/> resolves through this dict.
/// Nullable when the cell has no visible polys (rare).
/// </summary>
public Dictionary<ushort, ResolvedPolygon>? PortalPolygons { get; init; }
/// <summary>
/// The full cell ids visible from this cell (with landblock prefix).
/// Populated from <c>envCell.VisibleCells</c> at cache time. Unused
/// this phase; reserved for the optional <c>find_cell_list</c>
/// visibility filter.
/// </summary>
public IReadOnlySet<uint> VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet<uint>();
/// <summary>
/// #107: retail <c>CEnvCell.seen_outside</c> (dat <c>EnvCellFlags.SeenOutside</c>).
/// True for interiors with outdoor-visible portals (ground-floor cottage rooms).
/// <c>PhysicsEngine.AdjustPosition</c>'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).
/// </summary>
public bool SeenOutside { get; init; }
}