feat(physics): add differential flat BSP traversal

Port every current containment, overlap, walkable, and six-path moving-collision query to immutable integer-indexed assets behind a graph-authoritative referee. Exact synthetic, installed-DAT, complete-resolver, and zero-allocation gates prove bit-identical behavior before connected dual publication.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-25 15:47:58 +02:00
parent 9cd42417a8
commit f7ff9f4eea
13 changed files with 3907 additions and 46 deletions

View file

@ -96,7 +96,7 @@ public static class BSPQuery
///
/// <para>ACE: Polygon.cs polygon_hits_sphere_precise.</para>
/// </summary>
private static bool PolygonHitsSpherePrecise(
internal static bool PolygonHitsSpherePrecise(
in Plane polyPlane,
ReadOnlySpan<Vector3> verts,
Vector3 sphereCenter,
@ -2339,6 +2339,31 @@ public static class BSPQuery
ref hitPolyId, ref hitNormal);
}
/// <summary>
/// Allocation-free graph-oracle seam used by the Slice I flat traversal
/// differential harness.
/// </summary>
internal static bool SphereIntersectsPoly(
PhysicsBSPNode? node,
Dictionary<ushort, ResolvedPolygon> resolved,
Vector3 sphereCenter,
float sphereRadius,
out ushort hitPolyId,
out Vector3 hitNormal)
{
hitPolyId = 0;
hitNormal = Vector3.Zero;
if (node is null) return false;
return SphereIntersectsPolyStaticRecurse(
node,
resolved,
sphereCenter,
sphereRadius,
ref hitPolyId,
ref hitNormal);
}
private static bool SphereIntersectsPolyStaticRecurse(
PhysicsBSPNode? node,
Dictionary<ushort, ResolvedPolygon> resolved,
@ -2431,6 +2456,38 @@ public static class BSPQuery
return hitTime < float.MaxValue;
}
/// <summary>
/// Allocation-free graph-oracle seam used by the Slice I flat traversal
/// differential harness.
/// </summary>
internal static bool SphereIntersectsPolyWithTime(
PhysicsBSPNode? node,
Dictionary<ushort, ResolvedPolygon> resolved,
Vector3 sphereCenter,
float sphereRadius,
Vector3 movement,
out ushort hitPolyId,
out Vector3 hitNormal,
out float hitTime)
{
hitPolyId = 0;
hitNormal = Vector3.Zero;
hitTime = float.MaxValue;
if (node is null) return false;
SphereIntersectsPolyWithTimeRecurse(
node,
resolved,
sphereCenter,
sphereRadius,
movement,
ref hitPolyId,
ref hitNormal,
ref hitTime);
return hitTime < float.MaxValue;
}
private static void SphereIntersectsPolyWithTimeRecurse(
PhysicsBSPNode? node,
Dictionary<ushort, ResolvedPolygon> resolved,

View file

@ -159,15 +159,19 @@ public static class CellTransit
// neighbour cell whether the sphere intersects its CellBSP.
// The portal-plane side test is only the unloaded-cell load hint.
var otherCell = cache.GetCellStruct(otherId);
if (otherCell?.CellBSP?.Root is not null)
if (otherCell is not null &&
CollisionTraversal.HasCellContainment(cache, otherCell))
{
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
var otherLocalCenter = Vector3.Transform(
sphere.Origin, otherCell.InverseWorldTransform);
bool hit = BSPQuery.SphereIntersectsCellBsp(
otherCell.CellBSP.Root, otherLocalCenter, sphere.Radius);
bool hit = CollisionTraversal.SphereIntersectsCell(
cache,
otherCell,
otherLocalCenter,
sphere.Radius);
if (hit)
{
candidates.Add(otherId);
@ -372,7 +376,8 @@ public static class CellTransit
continue;
var otherCell = cache.GetCellStruct(portal.OtherCellId);
if (otherCell?.CellBSP?.Root is null)
if (otherCell is null ||
!CollisionTraversal.HasCellContainment(cache, otherCell))
{
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
@ -398,8 +403,11 @@ public static class CellTransit
{
var sphere = worldSpheres[i];
var localCenter = Vector3.Transform(sphere.Origin, otherCell.InverseWorldTransform);
inside = BSPQuery.SphereIntersectsCellBsp(
otherCell.CellBSP.Root, localCenter, sphere.Radius);
inside = CollisionTraversal.SphereIntersectsCell(
cache,
otherCell,
localCenter,
sphere.Radius);
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
@ -599,19 +607,25 @@ public static class CellTransit
if (start is null) return 0u;
// this->point_in_cell(point) → return this (:311402-311405)
if (PointInCell(start, worldPoint)) return startCellId;
if (PointInCell(cache, start, worldPoint)) return startCellId;
if (useStabList)
{
// arg3 != 0 → iterate stab_list, GetVisible + point_in_cell (:311444-311465)
foreach (uint id in start.VisibleCellIds)
if (PointInCell(cache.GetCellStruct(id), worldPoint)) return id;
if (PointInCell(cache, cache.GetCellStruct(id), worldPoint)) return id;
}
else
{
// arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434)
foreach (var portal in start.Portals)
if (PointInCell(cache.GetCellStruct(portal.OtherCellId), worldPoint)) return portal.OtherCellId;
if (PointInCell(
cache,
cache.GetCellStruct(portal.OtherCellId),
worldPoint))
{
return portal.OtherCellId;
}
}
return 0u;
@ -623,11 +637,19 @@ public static class CellTransit
/// A cell with no hydrated <see cref="CellPhysics.CellBSP"/> returns false (see
/// <see cref="FindVisibleChildCell"/>'s adaptation note).
/// </summary>
private static bool PointInCell(CellPhysics? cell, Vector3 worldPoint)
private static bool PointInCell(
PhysicsDataCache cache,
CellPhysics? cell,
Vector3 worldPoint)
{
if (cell?.CellBSP?.Root is null) return false;
if (cell is null ||
!CollisionTraversal.HasCellContainment(cache, cell))
{
return false;
}
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
return BSPQuery.PointInsideCellBsp(cell.CellBSP.Root, local);
return CollisionTraversal.PointInsideCell(cache, cell, local);
}
/// <summary>
@ -901,9 +923,14 @@ public static class CellTransit
{
// Interior candidate — point_in_cell via the cell BSP (vtable[0x84]).
var cand = cache.GetCellStruct(candId);
if (cand?.CellBSP?.Root is null) continue;
if (cand is null ||
!CollisionTraversal.HasCellContainment(cache, cand))
{
continue;
}
var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform);
if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
if (CollisionTraversal.PointInsideCell(cache, cand, local))
return candId; // interior-wins, stop (pseudo_c:308819)
}
else if (outdoorResult == 0u && containingOutdoorId != 0u && outdoorPickAllowed)
@ -950,10 +977,15 @@ public static class CellTransit
if (currentLow >= 0x0100u)
{
var cur = cache.GetCellStruct(currentCellId);
if (cur?.CellBSP?.Root is not null)
if (cur is not null &&
CollisionTraversal.HasCellContainment(cache, cur))
{
var curLocal = Vector3.Transform(worldSphereCenter, cur.InverseWorldTransform);
if (!BSPQuery.SphereIntersectsCellBsp(cur.CellBSP.Root, curLocal, sphereRadius))
if (!CollisionTraversal.SphereIntersectsCell(
cache,
cur,
curLocal,
sphereRadius))
{
uint recovered = FindVisibleChildCell(
cache, currentCellId, worldSphereCenter, useStabList: true);

View file

@ -0,0 +1,226 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Temporary Slice I referee mode. Graph remains production-authoritative;
/// Flat is enabled only by differential tests until dual publication and the
/// connected shadow gate complete.
/// </summary>
internal enum CollisionTraversalMode
{
Graph,
Flat,
}
/// <summary>
/// Narrow representation switch used while the graph and flat collision
/// worlds coexist. I6 removes the graph branch after acceptance.
/// </summary>
internal static class CollisionTraversal
{
internal static bool HasCellContainment(
PhysicsDataCache cache,
CellPhysics cell)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
return flat.RootIndex >= 0;
}
return cell.CellBSP?.Root is not null;
}
internal static bool HasPhysics(
PhysicsDataCache cache,
CellPhysics cell)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
return flat.RootIndex >= 0;
}
return cell.BSP?.Root is not null;
}
internal static bool HasPhysics(
PhysicsDataCache cache,
GfxObjPhysics gfxObject)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics");
return flat.RootIndex >= 0;
}
return gfxObject.BSP.Root is not null;
}
internal static FlatCollisionSphere RootBoundingSphere(
PhysicsDataCache cache,
CellPhysics cell)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
return flat.Nodes[flat.RootIndex].BoundingSphere;
}
DatReaderWriter.Types.Sphere sphere = cell.BSP!.Root!.BoundingSphere;
return new FlatCollisionSphere(sphere.Origin, sphere.Radius);
}
internal static bool PointInsideCell(
PhysicsDataCache cache,
CellPhysics cell,
Vector3 localPoint)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
return FlatBspQuery.PointInsideCellBsp(flat, localPoint);
}
return BSPQuery.PointInsideCellBsp(cell.CellBSP?.Root, localPoint);
}
internal static bool SphereIntersectsCell(
PhysicsDataCache cache,
CellPhysics cell,
Vector3 localCenter,
float radius)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
return FlatBspQuery.SphereIntersectsCellBsp(
flat,
localCenter,
radius);
}
return BSPQuery.SphereIntersectsCellBsp(
cell.CellBSP?.Root,
localCenter,
radius);
}
internal static TransitionState FindCollisions(
PhysicsDataCache cache,
CellPhysics cell,
Transition transition,
Vector3 localSphereCenter,
float localSphereRadius,
bool hasLocalSphere1,
Vector3 localSphere1Center,
float localSphere1Radius,
Vector3 localCurrentCenter,
Vector3 localSpaceZ,
float scale,
Quaternion localToWorld,
PhysicsEngine? engine,
Vector3 worldOrigin)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
return FlatBspQuery.FindCollisions(
flat,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
return BSPQuery.FindCollisions(
cell.BSP?.Root,
cell.Resolved,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
internal static TransitionState FindCollisions(
PhysicsDataCache cache,
GfxObjPhysics gfxObject,
Transition transition,
Vector3 localSphereCenter,
float localSphereRadius,
bool hasLocalSphere1,
Vector3 localSphere1Center,
float localSphere1Radius,
Vector3 localCurrentCenter,
Vector3 localSpaceZ,
float scale,
Quaternion localToWorld,
PhysicsEngine? engine,
Vector3 worldOrigin)
{
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat)
{
FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics");
return FlatBspQuery.FindCollisions(
flat,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
return BSPQuery.FindCollisions(
gfxObject.BSP.Root,
gfxObject.Resolved,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
private static InvalidOperationException MissingFlat(string kind) =>
new(
$"Flat collision traversal requires a prepared {kind} asset. " +
"Gameplay must not fall back silently.");
}

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,14 @@ public sealed class PhysicsDataCache
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
/// <summary>
/// Slice I graph/flat differential selector. Production remains on the
/// graph oracle until I6; tests use the flat value to run the same complete
/// resolver from cloned inputs.
/// </summary>
internal CollisionTraversalMode CollisionTraversalMode { get; set; } =
CollisionTraversalMode.Graph;
// ── Phase 2: building portal cache for outdoor→indoor entry ───────────
private readonly ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();
@ -541,6 +549,12 @@ public sealed class GfxObjPhysics
/// Populated once at cache time so BSP queries don't pay per-test lookup cost.
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
/// <summary>
/// Prepared integer-indexed shadow representation. Optional until Slice I5
/// dual publication is complete.
/// </summary>
public FlatPhysicsBsp? FlatPhysicsBsp { get; init; }
}
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
@ -578,6 +592,11 @@ public sealed class CellPhysics
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
/// <summary>
/// Prepared integer-indexed physics BSP shadow. Optional until Slice I5.
/// </summary>
public FlatPhysicsBsp? FlatPhysicsBsp { get; init; }
// ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
/// <summary>
@ -590,6 +609,12 @@ public sealed class CellPhysics
/// </summary>
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
/// <summary>
/// Prepared integer-indexed cell-containment BSP shadow. Optional until
/// Slice I5.
/// </summary>
public FlatCellContainmentBsp? FlatContainmentBsp { get; init; }
/// <summary>
/// Portal connections to neighbouring cells, in cell-local space.
/// Default: empty list. Source: <c>envCell.CellPortals</c>.

View file

@ -2298,7 +2298,11 @@ public sealed class Transition
var cell = engine.DataCache.GetCellStruct(cellId);
// R2 guard: stale CellPhysics loaded for render but not physics.
if (cell?.BSP?.Root is null) continue;
if (cell is null ||
!CollisionTraversal.HasPhysics(engine.DataCache, cell))
{
continue;
}
// Transform sphere into THIS cell's local space. Mirrors the
// primary-cell pattern at TransitionTypes.cs (FindEnvCollisions,
@ -2330,8 +2334,8 @@ public sealed class Transition
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = null;
var result = BSPQuery.FindCollisions(
cell.BSP.Root, cell.Resolved, this,
var result = CollisionTraversal.FindCollisions(
engine.DataCache!, cell, this,
localCenter,
sphereRadius,
hasLocalSphere1,
@ -2348,7 +2352,10 @@ public sealed class Transition
? "poly=n/a"
: System.FormattableString.Invariant(
$"poly=0x{hit.Id:X4} n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) d={hit.Plane.D:F3} sides={hit.SidesType}");
var bs = cell.BSP.Root.BoundingSphere;
FlatCollisionSphere bs =
CollisionTraversal.RootBoundingSphere(
engine.DataCache,
cell);
string bsDesc = System.FormattableString.Invariant(
$"bs=({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) br={bs.Radius:F3}");
Console.WriteLine(System.FormattableString.Invariant(
@ -2620,7 +2627,8 @@ public sealed class Transition
if (cellLow >= 0x0100 && engine.DataCache is not null)
{
var cellPhysics = engine.DataCache.GetCellStruct(sp.CheckCellId);
if (cellPhysics?.BSP?.Root is not null)
if (cellPhysics is not null &&
CollisionTraversal.HasPhysics(engine.DataCache!, cellPhysics))
{
// Transform player sphere to cell-local space.
var localCenter = Vector3.Transform(footCenter, cellPhysics.InverseWorldTransform);
@ -2677,9 +2685,9 @@ public sealed class Transition
cellOrigin = cellPhysics.WorldTransform.Translation;
}
var cellState = BSPQuery.FindCollisions(
cellPhysics.BSP.Root,
cellPhysics.Resolved,
var cellState = CollisionTraversal.FindCollisions(
engine.DataCache!,
cellPhysics,
this,
localCenter,
sphereRadius,
@ -3134,12 +3142,14 @@ public sealed class Transition
{
Vector3 dxy = obj.Position - sp.GlobalCurrCenter[0].Origin;
float distXY = MathF.Sqrt(dxy.X * dxy.X + dxy.Y * dxy.Y);
bool cacheHit = physics?.BSP?.Root is not null;
bool cacheHit = physics is not null &&
CollisionTraversal.HasPhysics(engine.DataCache!, physics);
Console.WriteLine(System.FormattableString.Invariant(
$"[bsp-test] obj=0x{obj.EntityId:X8} gfx=0x{obj.GfxObjId:X8} state=0x{obj.State:X8} radius={obj.Radius:F3} pos=({obj.Position.X:F2},{obj.Position.Y:F2},{obj.Position.Z:F2}) distXY={distXY:F3} cacheHit={cacheHit}"));
}
if (physics?.BSP?.Root is null)
if (physics is null ||
!CollisionTraversal.HasPhysics(engine.DataCache!, physics))
{
// Clear obstruction_ethereal before skipping — retail's per-object
// clear (pc:276989) fires after shape tests; we clear early here to
@ -3179,9 +3189,9 @@ public sealed class Transition
// Use the retail 6-path dispatcher with pre-resolved polygons.
// Pass the object's scale so collision response offsets (in
// unscaled local space) are multiplied back to world space.
result = BSPQuery.FindCollisions(
physics.BSP.Root,
physics.Resolved,
result = CollisionTraversal.FindCollisions(
engine.DataCache!,
physics,
this,
localSphere0Center,
localSphere0Radius,
@ -3476,7 +3486,11 @@ public sealed class Transition
// CacheBuilding site resolves 0x02 Setup models to their first part
// — retail tests part_array->parts[0] only, 0x006b5320).
var physics = engine.DataCache.GetGfxObj(building.ModelId);
if (physics?.BSP?.Root is null) return TransitionState.OK;
if (physics is null ||
!CollisionTraversal.HasPhysics(engine.DataCache, physics))
{
return TransitionState.OK;
}
var sp = SpherePath;
var ci = CollisionInfo;
@ -3509,9 +3523,9 @@ public sealed class Transition
TransitionState result;
try
{
result = BSPQuery.FindCollisions(
physics.BSP.Root,
physics.Resolved,
result = CollisionTraversal.FindCollisions(
engine.DataCache!,
physics,
this,
localSphere0Center,
localSphere0Radius,