diff --git a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
index 8da45062..8d9bece5 100644
--- a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
+++ b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
@@ -43,8 +43,15 @@ internal sealed class LiveEntityCollisionBuilder
PhysicsDataCache physicsData,
LiveEntityDefaultPoseResolver defaultPose)
: this(
- id => physicsData.GetGfxObj(id)?.BSP?.Root is not null,
- id => physicsData.GetGfxObj(id)?.BoundingSphere?.Radius,
+ id => physicsData.GetGfxObj(id)?.FlatPhysicsBsp?.RootIndex >= 0,
+ id =>
+ {
+ FlatPhysicsBsp? flat =
+ physicsData.GetGfxObj(id)?.FlatPhysicsBsp;
+ return flat is { RootIndex: >= 0 }
+ ? flat.Nodes[flat.RootIndex].BoundingSphere.Radius
+ : null;
+ },
defaultPose)
{
ArgumentNullException.ThrowIfNull(physicsData);
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index c38499b6..97e7d927 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -144,7 +144,8 @@ public sealed class GameWindow :
// Task 4: physics data cache — BSP trees + collision shapes extracted from
// GfxObj/Setup dats during streaming. Populated on the worker thread;
// ConcurrentDictionary inside makes cross-thread access safe.
- private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = new();
+ private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache =
+ AcDream.Core.Physics.PhysicsDataCache.CreateProduction();
// #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the
// >10k-line TickAnimations (Code Structure Rule 1). Reusable setup/motion
diff --git a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs
index ac72462c..23662168 100644
--- a/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs
+++ b/src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs
@@ -77,7 +77,9 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
{
var cp = _physics.DataCache?.GetCellStruct(startCell);
- string bsp = cp?.BSP is null ? "nobsp" : (cp.BSP.Root is null ? "noroot" : "ok");
+ string bsp = cp?.FlatPhysicsBsp is null
+ ? "noflatbsp"
+ : (cp.FlatPhysicsBsp.RootIndex < 0 ? "noroot" : "ok");
float desiredBack = Vector3.Distance(pivot, desiredEye);
float eyeBack = Vector3.Distance(pivot, eye);
System.Console.WriteLine(
diff --git a/src/AcDream.Core/Physics/CollisionShadowVerifier.cs b/src/AcDream.Core/Physics/CollisionShadowVerifier.cs
index 2cf8eaed..56942dd7 100644
--- a/src/AcDream.Core/Physics/CollisionShadowVerifier.cs
+++ b/src/AcDream.Core/Physics/CollisionShadowVerifier.cs
@@ -13,9 +13,9 @@ public readonly record struct CollisionShadowStats(
long Faults);
///
-/// Slice I5 sampled graph/flat referee. The graph result always remains
-/// authoritative. One retained clone receives the flat query first; the
-/// canonical transition is then evaluated by the graph path and compared
+/// Slice I5/I6 sampled graph/flat referee. The authority is selected by
+/// . One retained clone
+/// receives the non-authoritative query and the two results are compared
/// field-for-field with exact float bits.
///
internal sealed class CollisionShadowVerifier
@@ -29,6 +29,7 @@ internal sealed class CollisionShadowVerifier
private long _mismatches;
private long _faults;
private bool _flatPass;
+ private bool _graphPass;
public CollisionShadowVerifier(
int sampleEvery,
@@ -42,6 +43,7 @@ internal sealed class CollisionShadowVerifier
}
internal bool IsFlatPass => _flatPass;
+ internal bool IsGraphPass => _graphPass;
internal CollisionShadowStats Stats => new(
_queries,
@@ -52,7 +54,7 @@ internal sealed class CollisionShadowVerifier
internal bool TrySample(out long sample)
{
- if (_flatPass)
+ if (_flatPass || _graphPass)
{
sample = 0;
return false;
@@ -77,9 +79,9 @@ internal sealed class CollisionShadowVerifier
internal void BeginFlatPass()
{
- if (_flatPass)
+ if (_flatPass || _graphPass)
throw new InvalidOperationException(
- "Collision shadow flat passes cannot overlap.");
+ "Collision shadow passes cannot overlap.");
_flatPass = true;
}
@@ -91,6 +93,22 @@ internal sealed class CollisionShadowVerifier
_flatPass = false;
}
+ internal void BeginGraphPass()
+ {
+ if (_flatPass || _graphPass)
+ throw new InvalidOperationException(
+ "Collision shadow passes cannot overlap.");
+ _graphPass = true;
+ }
+
+ internal void EndGraphPass()
+ {
+ if (!_graphPass)
+ throw new InvalidOperationException(
+ "No collision shadow graph pass is active.");
+ _graphPass = false;
+ }
+
internal void RecordBoolean(
long sample,
string kind,
@@ -188,7 +206,8 @@ internal sealed class CollisionShadowVerifier
string kind,
uint sourceId,
Exception fault,
- string input)
+ string input,
+ string authority = "graph")
{
_faults++;
WriteArtifact(new CollisionShadowMismatchArtifact(
@@ -196,8 +215,8 @@ internal sealed class CollisionShadowVerifier
sample,
kind,
$"0x{sourceId:X8}",
- "FlatFault",
- "graph-authoritative",
+ authority == "flat" ? "GraphFault" : "FlatFault",
+ $"{authority}-authoritative",
$"{fault.GetType().FullName}: {fault.Message}",
input));
}
diff --git a/src/AcDream.Core/Physics/CollisionTraversal.cs b/src/AcDream.Core/Physics/CollisionTraversal.cs
index c6d3d347..a054b3e3 100644
--- a/src/AcDream.Core/Physics/CollisionTraversal.cs
+++ b/src/AcDream.Core/Physics/CollisionTraversal.cs
@@ -3,9 +3,9 @@ using System.Numerics;
namespace AcDream.Core.Physics;
///
-/// 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.
+/// Temporary Slice I6 authority/referee selector. Gameplay uses
+/// ; remains only for the old-oracle
+/// differential fixtures until the accepted cutover deletes it.
///
internal enum CollisionTraversalMode
{
@@ -14,8 +14,8 @@ internal enum CollisionTraversalMode
}
///
-/// Narrow representation switch used while the graph and flat collision
-/// worlds coexist. I6 removes the graph branch after acceptance.
+/// Narrow representation switch used while flat production and the graph
+/// referee coexist. I6 removes the graph branch after acceptance.
///
internal static class CollisionTraversal
{
@@ -27,7 +27,49 @@ internal static class CollisionTraversal
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
- return flat.RootIndex >= 0;
+ bool flatAuthorityResult = flat.RootIndex >= 0;
+ CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
+ if (flatShadow is null ||
+ !flatShadow.TrySample(out long flatAuthoritySample))
+ return flatAuthorityResult;
+
+ bool graphRefereeResult = false;
+ Exception? graphRefereeFault = null;
+ flatShadow.BeginGraphPass();
+ try
+ {
+ graphRefereeResult = cell.CellBSP?.Root is not null;
+ }
+ catch (Exception fault)
+ {
+ graphRefereeFault = fault;
+ }
+ finally
+ {
+ flatShadow.EndGraphPass();
+ }
+
+ if (graphRefereeFault is null)
+ {
+ flatShadow.RecordBoolean(
+ flatAuthoritySample,
+ "HasCellContainment",
+ cell.SourceId,
+ graphRefereeResult,
+ flatAuthorityResult,
+ string.Empty);
+ }
+ else
+ {
+ flatShadow.RecordFault(
+ flatAuthoritySample,
+ "HasCellContainment",
+ cell.SourceId,
+ graphRefereeFault,
+ string.Empty,
+ "flat");
+ }
+ return flatAuthorityResult;
}
CollisionShadowVerifier? shadow = cache.CollisionShadow;
@@ -82,7 +124,49 @@ internal static class CollisionTraversal
{
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
- return flat.RootIndex >= 0;
+ bool flatAuthorityResult = flat.RootIndex >= 0;
+ CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
+ if (flatShadow is null ||
+ !flatShadow.TrySample(out long flatAuthoritySample))
+ return flatAuthorityResult;
+
+ bool graphRefereeResult = false;
+ Exception? graphRefereeFault = null;
+ flatShadow.BeginGraphPass();
+ try
+ {
+ graphRefereeResult = cell.BSP?.Root is not null;
+ }
+ catch (Exception fault)
+ {
+ graphRefereeFault = fault;
+ }
+ finally
+ {
+ flatShadow.EndGraphPass();
+ }
+
+ if (graphRefereeFault is null)
+ {
+ flatShadow.RecordBoolean(
+ flatAuthoritySample,
+ "HasCellPhysics",
+ cell.SourceId,
+ graphRefereeResult,
+ flatAuthorityResult,
+ string.Empty);
+ }
+ else
+ {
+ flatShadow.RecordFault(
+ flatAuthoritySample,
+ "HasCellPhysics",
+ cell.SourceId,
+ graphRefereeFault,
+ string.Empty,
+ "flat");
+ }
+ return flatAuthorityResult;
}
CollisionShadowVerifier? shadow = cache.CollisionShadow;
@@ -137,7 +221,49 @@ internal static class CollisionTraversal
{
FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics");
- return flat.RootIndex >= 0;
+ bool flatAuthorityResult = flat.RootIndex >= 0;
+ CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
+ if (flatShadow is null ||
+ !flatShadow.TrySample(out long flatAuthoritySample))
+ return flatAuthorityResult;
+
+ bool graphRefereeResult = false;
+ Exception? graphRefereeFault = null;
+ flatShadow.BeginGraphPass();
+ try
+ {
+ graphRefereeResult = gfxObject.BSP.Root is not null;
+ }
+ catch (Exception fault)
+ {
+ graphRefereeFault = fault;
+ }
+ finally
+ {
+ flatShadow.EndGraphPass();
+ }
+
+ if (graphRefereeFault is null)
+ {
+ flatShadow.RecordBoolean(
+ flatAuthoritySample,
+ "HasGfxObjPhysics",
+ gfxObject.SourceId,
+ graphRefereeResult,
+ flatAuthorityResult,
+ string.Empty);
+ }
+ else
+ {
+ flatShadow.RecordFault(
+ flatAuthoritySample,
+ "HasGfxObjPhysics",
+ gfxObject.SourceId,
+ graphRefereeFault,
+ string.Empty,
+ "flat");
+ }
+ return flatAuthorityResult;
}
CollisionShadowVerifier? shadow = cache.CollisionShadow;
@@ -192,7 +318,43 @@ internal static class CollisionTraversal
{
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
- return flat.Nodes[flat.RootIndex].BoundingSphere;
+ FlatCollisionSphere flatAuthorityResult =
+ flat.Nodes[flat.RootIndex].BoundingSphere;
+ CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
+ if (flatShadow is null ||
+ !flatShadow.TrySample(out long flatAuthoritySample))
+ return flatAuthorityResult;
+
+ try
+ {
+ flatShadow.BeginGraphPass();
+ DatReaderWriter.Types.Sphere graphSphere =
+ cell.BSP!.Root!.BoundingSphere;
+ var graphRefereeResult =
+ new FlatCollisionSphere(
+ graphSphere.Origin,
+ graphSphere.Radius);
+ flatShadow.EndGraphPass();
+ flatShadow.RecordSphere(
+ flatAuthoritySample,
+ "RootBoundingSphere",
+ cell.SourceId,
+ graphRefereeResult,
+ flatAuthorityResult);
+ }
+ catch (Exception fault)
+ {
+ if (flatShadow.IsGraphPass)
+ flatShadow.EndGraphPass();
+ flatShadow.RecordFault(
+ flatAuthoritySample,
+ "RootBoundingSphere",
+ cell.SourceId,
+ fault,
+ string.Empty,
+ "flat");
+ }
+ return flatAuthorityResult;
}
DatReaderWriter.Types.Sphere sphere = cell.BSP!.Root!.BoundingSphere;
@@ -240,7 +402,54 @@ internal static class CollisionTraversal
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
- return FlatBspQuery.PointInsideCellBsp(flat, localPoint);
+ bool flatAuthorityResult =
+ FlatBspQuery.PointInsideCellBsp(flat, localPoint);
+ CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
+ if (flatShadow is null ||
+ !flatShadow.TrySample(out long flatAuthoritySample))
+ return flatAuthorityResult;
+
+ bool graphRefereeResult = false;
+ Exception? graphRefereeFault = null;
+ flatShadow.BeginGraphPass();
+ try
+ {
+ graphRefereeResult = BSPQuery.PointInsideCellBsp(
+ cell.CellBSP?.Root,
+ localPoint);
+ }
+ catch (Exception fault)
+ {
+ graphRefereeFault = fault;
+ }
+ finally
+ {
+ flatShadow.EndGraphPass();
+ }
+ string flatAuthorityInput = CollisionShadowVerifier.FormatInput(
+ localPoint,
+ 0f);
+ if (graphRefereeFault is null)
+ {
+ flatShadow.RecordBoolean(
+ flatAuthoritySample,
+ "PointInsideCell",
+ cell.SourceId,
+ graphRefereeResult,
+ flatAuthorityResult,
+ flatAuthorityInput);
+ }
+ else
+ {
+ flatShadow.RecordFault(
+ flatAuthoritySample,
+ "PointInsideCell",
+ cell.SourceId,
+ graphRefereeFault,
+ flatAuthorityInput,
+ "flat");
+ }
+ return flatAuthorityResult;
}
CollisionShadowVerifier? shadow = cache.CollisionShadow;
@@ -305,10 +514,57 @@ internal static class CollisionTraversal
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
- return FlatBspQuery.SphereIntersectsCellBsp(
+ bool flatAuthorityResult = FlatBspQuery.SphereIntersectsCellBsp(
flat,
localCenter,
radius);
+ CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
+ if (flatShadow is null ||
+ !flatShadow.TrySample(out long flatAuthoritySample))
+ return flatAuthorityResult;
+
+ bool graphRefereeResult = false;
+ Exception? graphRefereeFault = null;
+ flatShadow.BeginGraphPass();
+ try
+ {
+ graphRefereeResult = BSPQuery.SphereIntersectsCellBsp(
+ cell.CellBSP?.Root,
+ localCenter,
+ radius);
+ }
+ catch (Exception fault)
+ {
+ graphRefereeFault = fault;
+ }
+ finally
+ {
+ flatShadow.EndGraphPass();
+ }
+ string flatAuthorityInput = CollisionShadowVerifier.FormatInput(
+ localCenter,
+ radius);
+ if (graphRefereeFault is null)
+ {
+ flatShadow.RecordBoolean(
+ flatAuthoritySample,
+ "SphereIntersectsCell",
+ cell.SourceId,
+ graphRefereeResult,
+ flatAuthorityResult,
+ flatAuthorityInput);
+ }
+ else
+ {
+ flatShadow.RecordFault(
+ flatAuthoritySample,
+ "SphereIntersectsCell",
+ cell.SourceId,
+ graphRefereeFault,
+ flatAuthorityInput,
+ "flat");
+ }
+ return flatAuthorityResult;
}
CollisionShadowVerifier? shadow = cache.CollisionShadow;
@@ -386,9 +642,17 @@ internal static class CollisionTraversal
{
if (UseFlat(cache))
{
+ CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
+ long flatAuthoritySample = 0;
+ bool sampled =
+ flatShadow is not null &&
+ flatShadow.TrySample(out flatAuthoritySample);
+ Transition? graphTransition = sampled
+ ? flatShadow!.PrepareShadow(transition)
+ : null;
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
- return FlatBspQuery.FindCollisions(
+ TransitionState flatAuthorityState = FlatBspQuery.FindCollisions(
flat,
transition,
localSphereCenter,
@@ -402,6 +666,73 @@ internal static class CollisionTraversal
localToWorld,
engine,
worldOrigin);
+ if (!sampled)
+ return flatAuthorityState;
+
+ TransitionState graphRefereeState = TransitionState.Invalid;
+ Exception? graphRefereeFault = null;
+ flatShadow!.BeginGraphPass();
+ try
+ {
+ graphRefereeState = BSPQuery.FindCollisions(
+ cell.BSP?.Root,
+ cell.Resolved,
+ graphTransition!,
+ localSphereCenter,
+ localSphereRadius,
+ hasLocalSphere1,
+ localSphere1Center,
+ localSphere1Radius,
+ localCurrentCenter,
+ localSpaceZ,
+ scale,
+ localToWorld,
+ engine,
+ worldOrigin);
+ }
+ catch (Exception fault)
+ {
+ graphRefereeFault = fault;
+ }
+ finally
+ {
+ flatShadow.EndGraphPass();
+ }
+
+ string flatAuthorityInput = CollisionShadowVerifier.FormatInput(
+ localSphereCenter,
+ localSphereRadius,
+ hasLocalSphere1,
+ localSphere1Center,
+ localSphere1Radius,
+ localCurrentCenter,
+ localSpaceZ,
+ scale,
+ localToWorld,
+ worldOrigin);
+ if (graphRefereeFault is null)
+ {
+ flatShadow.RecordTransition(
+ flatAuthoritySample,
+ "FindCellCollisions",
+ cell.SourceId,
+ graphRefereeState,
+ flatAuthorityState,
+ graphTransition!,
+ transition,
+ flatAuthorityInput);
+ }
+ else
+ {
+ flatShadow.RecordFault(
+ flatAuthoritySample,
+ "FindCellCollisions",
+ cell.SourceId,
+ graphRefereeFault,
+ flatAuthorityInput,
+ "flat");
+ }
+ return flatAuthorityState;
}
CollisionShadowVerifier? shadow = cache.CollisionShadow;
@@ -523,9 +854,17 @@ internal static class CollisionTraversal
{
if (UseFlat(cache))
{
+ CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
+ long flatAuthoritySample = 0;
+ bool sampled =
+ flatShadow is not null &&
+ flatShadow.TrySample(out flatAuthoritySample);
+ Transition? graphTransition = sampled
+ ? flatShadow!.PrepareShadow(transition)
+ : null;
FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics");
- return FlatBspQuery.FindCollisions(
+ TransitionState flatAuthorityState = FlatBspQuery.FindCollisions(
flat,
transition,
localSphereCenter,
@@ -539,6 +878,73 @@ internal static class CollisionTraversal
localToWorld,
engine,
worldOrigin);
+ if (!sampled)
+ return flatAuthorityState;
+
+ TransitionState graphRefereeState = TransitionState.Invalid;
+ Exception? graphRefereeFault = null;
+ flatShadow!.BeginGraphPass();
+ try
+ {
+ graphRefereeState = BSPQuery.FindCollisions(
+ gfxObject.BSP.Root,
+ gfxObject.Resolved,
+ graphTransition!,
+ localSphereCenter,
+ localSphereRadius,
+ hasLocalSphere1,
+ localSphere1Center,
+ localSphere1Radius,
+ localCurrentCenter,
+ localSpaceZ,
+ scale,
+ localToWorld,
+ engine,
+ worldOrigin);
+ }
+ catch (Exception fault)
+ {
+ graphRefereeFault = fault;
+ }
+ finally
+ {
+ flatShadow.EndGraphPass();
+ }
+
+ string flatAuthorityInput = CollisionShadowVerifier.FormatInput(
+ localSphereCenter,
+ localSphereRadius,
+ hasLocalSphere1,
+ localSphere1Center,
+ localSphere1Radius,
+ localCurrentCenter,
+ localSpaceZ,
+ scale,
+ localToWorld,
+ worldOrigin);
+ if (graphRefereeFault is null)
+ {
+ flatShadow.RecordTransition(
+ flatAuthoritySample,
+ "FindGfxObjCollisions",
+ gfxObject.SourceId,
+ graphRefereeState,
+ flatAuthorityState,
+ graphTransition!,
+ transition,
+ flatAuthorityInput);
+ }
+ else
+ {
+ flatShadow.RecordFault(
+ flatAuthoritySample,
+ "FindGfxObjCollisions",
+ gfxObject.SourceId,
+ graphRefereeFault,
+ flatAuthorityInput,
+ "flat");
+ }
+ return flatAuthorityState;
}
CollisionShadowVerifier? shadow = cache.CollisionShadow;
@@ -642,9 +1048,15 @@ internal static class CollisionTraversal
return graphState;
}
- private static bool UseFlat(PhysicsDataCache cache) =>
- cache.CollisionTraversalMode == CollisionTraversalMode.Flat ||
- cache.CollisionShadow?.IsFlatPass == true;
+ private static bool UseFlat(PhysicsDataCache cache)
+ {
+ CollisionShadowVerifier? shadow = cache.CollisionShadow;
+ if (shadow?.IsGraphPass == true)
+ return false;
+ if (shadow?.IsFlatPass == true)
+ return true;
+ return cache.CollisionTraversalMode == CollisionTraversalMode.Flat;
+ }
private static InvalidOperationException MissingFlat(string kind) =>
new(
diff --git a/src/AcDream.Core/Physics/FlatCollisionAssets.cs b/src/AcDream.Core/Physics/FlatCollisionAssets.cs
index 90fa50ed..4239a066 100644
--- a/src/AcDream.Core/Physics/FlatCollisionAssets.cs
+++ b/src/AcDream.Core/Physics/FlatCollisionAssets.cs
@@ -143,8 +143,8 @@ public readonly record struct FlatPhysicsBspNode(
///
/// Immutable, deterministic pre-order representation of a retail physics BSP.
-/// It changes source storage only; traversal order and behavior remain defined
-/// by the graph oracle until Slice I6.
+/// It changes source storage only; traversal order and behavior remain the
+/// retail port pinned by the graph differential oracle.
///
public sealed class FlatPhysicsBsp
{
diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs
index f1f03ffa..3f6a7ba2 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -13,11 +13,13 @@ 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 each graph/flat pair. ConcurrentDictionary keeps diagnostics and
-/// tooling reads safe without a global lock.
+/// installs prepared production records and, during the I6 acceptance window,
+/// their graph referee. 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();
@@ -32,7 +34,13 @@ public sealed class PhysicsDataCache
_flatEnvCell = new();
public PhysicsDataCache()
+ : this(requirePreparedCollision: false)
{
+ }
+
+ private PhysicsDataCache(bool requirePreparedCollision)
+ {
+ _requirePreparedCollision = requirePreparedCollision;
if (PhysicsDiagnostics.CollisionShadowSampleEvery > 0)
{
CollisionShadow = new CollisionShadowVerifier(
@@ -41,15 +49,29 @@ public sealed class PhysicsDataCache
}
}
+ ///
+ /// Creates the gameplay cache with Slice I6's prepared flat collision
+ /// representation authoritative. The public constructor deliberately
+ /// remains the graph-oracle fixture seam until the I6 referee is removed.
+ ///
+ 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 remains on the
- /// graph oracle until I6; tests use the flat value to run the same complete
- /// resolver from cloned inputs.
+ /// Slice I graph/flat differential selector. Production is flat-authoritative
+ /// from I6 onward. Graph remains an explicit fixture/oracle mode until the
+ /// referee is removed after acceptance.
///
internal CollisionTraversalMode CollisionTraversalMode { get; set; } =
CollisionTraversalMode.Graph;
@@ -84,6 +106,15 @@ public sealed class PhysicsDataCache
GfxObj gfxObj,
FlatGfxObjCollisionAsset? prepared = null)
{
+ 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 (prepared is not null)
_flatGfxObj.TryAdd(gfxObjId, prepared);
@@ -199,6 +230,11 @@ public sealed class PhysicsDataCache
Setup setup,
FlatSetupCollision? prepared = null)
{
+ if (_requirePreparedCollision &&
+ prepared is null &&
+ !_flatSetup.ContainsKey(setupId))
+ throw MissingPreparedCollision("Setup", setupId);
+
if (prepared is not null)
_flatSetup.TryAdd(setupId, prepared);
@@ -234,6 +270,15 @@ public sealed class PhysicsDataCache
FlatCellStructureCollisionAsset? preparedStructure = null,
FlatEnvCellTopology? preparedTopology = null)
{
+ 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)
@@ -242,7 +287,14 @@ public sealed class PhysicsDataCache
// 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));
+ {
+ CellGraph.Add(UcgEnvCell.FromDat(
+ envCellId,
+ envCell,
+ cellStruct,
+ worldTransform,
+ preparedStructure?.ContainmentBsp));
+ }
if (_cellStruct.ContainsKey(envCellId)) return;
if (cellStruct.PhysicsBSP?.Root is null) return;
@@ -455,6 +507,13 @@ public sealed class PhysicsDataCache
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;
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index fce435a6..17e7becc 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -509,6 +509,39 @@ public sealed class PhysicsEngine
float? best = null;
float bestDist = float.MaxValue;
+ FlatPhysicsBsp? flat = cp.FlatPhysicsBsp;
+ if (flat is not null)
+ {
+ FlatPolygonTable table = flat.PolygonTable;
+ for (int i = 0; i < table.Polygons.Length; i++)
+ {
+ FlatCollisionPolygon poly = table.Polygons[i];
+ Vector3 n = poly.Plane.Normal;
+ if (n.Z < PhysicsGlobals.FloorZ) continue;
+ if (!PointInPolygonXY(table, poly.VertexRange, local.X, local.Y))
+ continue;
+ float lz =
+ -(n.X * local.X + n.Y * local.Y + poly.Plane.D) / n.Z;
+ float wz = Vector3.Transform(
+ new Vector3(local.X, local.Y, lz),
+ cp.WorldTransform).Z;
+ float dist = MathF.Abs(wz - referenceZ);
+ if (dist < bestDist)
+ {
+ bestDist = dist;
+ best = wz;
+ }
+ }
+ return best;
+ }
+
+ if (DataCache!.CollisionTraversalMode == CollisionTraversalMode.Flat)
+ {
+ throw new InvalidOperationException(
+ $"Production CellStruct 0x{cellId:X8} has no prepared physics BSP.");
+ }
+
+ // Explicit graph-oracle fixture path. Production returns above.
foreach (var kv in cp.Resolved)
{
var poly = kv.Value;
@@ -524,6 +557,28 @@ public sealed class PhysicsEngine
return best;
}
+ private static bool PointInPolygonXY(
+ FlatPolygonTable table,
+ FlatIndexRange range,
+ float x,
+ float y)
+ {
+ bool inside = false;
+ int end = range.EndExclusive;
+ for (int i = range.Start, j = end - 1; i < end; j = i++)
+ {
+ Vector3 vi = table.Vertices[i];
+ Vector3 vj = table.Vertices[j];
+ if ((vi.Y > y) != (vj.Y > y)
+ && x < (vj.X - vi.X) * (y - vi.Y) /
+ (vj.Y - vi.Y) + vi.X)
+ {
+ inside = !inside;
+ }
+ }
+ return inside;
+ }
+
/// Even-odd XY-projection point-in-polygon test (cell-local frame).
private static bool PointInPolygonXY(IReadOnlyList verts, float x, float y)
{
diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
index fcf3ec80..81dec813 100644
--- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
+++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
@@ -191,7 +191,11 @@ public static class ShadowShapeBuilder
foreach (var meshRef in meshRefs)
{
var phys = getGfxObj(meshRef.GfxObjId);
- if (phys?.BSP?.Root is null) continue; // no physics BSP → nothing to collide
+ if (phys is null) continue;
+ FlatPhysicsBsp? flat = phys.FlatPhysicsBsp;
+ bool hasFlat = flat is { RootIndex: >= 0 };
+ if (!hasFlat && phys.BSP?.Root is null)
+ continue; // graph-only fixture seam until I6 referee removal
// PartTransform is root-relative; decompose to local pos/rot/scale.
if (!Matrix4x4.Decompose(meshRef.PartTransform,
@@ -205,7 +209,9 @@ public static class ShadowShapeBuilder
}
float partScale = pScale.X > 0f ? pScale.X : 1f; // AC objects are uniformly scaled
- float localRadius = phys.BoundingSphere?.Radius ?? 1f;
+ float localRadius = hasFlat
+ ? flat!.Nodes[flat.RootIndex].BoundingSphere.Radius
+ : phys.BoundingSphere?.Radius ?? 1f;
shapes.Add(new ShadowShape(
GfxObjId: meshRef.GfxObjId,
diff --git a/src/AcDream.Core/World/Cells/EnvCell.cs b/src/AcDream.Core/World/Cells/EnvCell.cs
index cb24f034..eb14030b 100644
--- a/src/AcDream.Core/World/Cells/EnvCell.cs
+++ b/src/AcDream.Core/World/Cells/EnvCell.cs
@@ -13,19 +13,30 @@ public sealed class EnvCell : ObjCell
/// Cell-containment BSP (retail CellStruct.CellBSP). Null => AABB fallback.
public CellBSPTree? ContainmentBsp { get; }
+ ///
+ /// Prepared production containment tree. Slice I6 makes this path
+ /// authoritative while remains only as the
+ /// temporary graph referee/test fixture seam.
+ ///
+ public FlatCellContainmentBsp? FlatContainmentBsp { get; }
+
public EnvCell(uint id, Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform,
Vector3 localBoundsMin, Vector3 localBoundsMax,
IReadOnlyList portals, IReadOnlyList stabList,
- bool seenOutside, CellBSPTree? containmentBsp)
+ bool seenOutside, CellBSPTree? containmentBsp,
+ FlatCellContainmentBsp? flatContainmentBsp = null)
: base(id, worldTransform, inverseWorldTransform, localBoundsMin, localBoundsMax,
portals, stabList, seenOutside)
{
ContainmentBsp = containmentBsp;
+ FlatContainmentBsp = flatContainmentBsp;
}
public override bool PointInCell(Vector3 worldPoint)
{
var local = Vector3.Transform(worldPoint, InverseWorldTransform);
+ if (FlatContainmentBsp is not null)
+ return FlatBspQuery.PointInsideCellBsp(FlatContainmentBsp, local);
if (ContainmentBsp?.Root is not null)
return BSPQuery.PointInsideCellBsp(ContainmentBsp.Root, local); // BSPQuery.cs:1034
return local.X >= LocalBoundsMin.X && local.X <= LocalBoundsMax.X
@@ -40,7 +51,8 @@ public sealed class EnvCell : ObjCell
/// (no +2 cm render lift).
///
public static EnvCell FromDat(uint id, DatReaderWriter.DBObjs.EnvCell datCell,
- CellStruct cellStruct, Matrix4x4 worldTransform)
+ CellStruct cellStruct, Matrix4x4 worldTransform,
+ FlatCellContainmentBsp? flatContainmentBsp = null)
{
Matrix4x4.Invert(worldTransform, out var inverse);
@@ -72,7 +84,7 @@ public sealed class EnvCell : ObjCell
bool seenOutside = datCell.Flags.HasFlag(EnvCellFlags.SeenOutside);
return new EnvCell(id, worldTransform, inverse, min, max, portals, stab,
- seenOutside, cellStruct.CellBSP);
+ seenOutside, cellStruct.CellBSP, flatContainmentBsp);
}
private static IReadOnlyList ResolvePortalPolygon(CellStruct cellStruct, ushort polygonId)
diff --git a/tests/AcDream.Core.Tests/Physics/CollisionShadowVerifierTests.cs b/tests/AcDream.Core.Tests/Physics/CollisionShadowVerifierTests.cs
index ec8f69a0..f8237ab4 100644
--- a/tests/AcDream.Core.Tests/Physics/CollisionShadowVerifierTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CollisionShadowVerifierTests.cs
@@ -92,6 +92,36 @@ public sealed class CollisionShadowVerifierTests
Assert.Equal("False", root.GetProperty("Flat").GetString());
}
+ [Fact]
+ public void FlatAuthority_MismatchKeepsFlatResultAndUsesGraphOnlyAsReferee()
+ {
+ string directory = NewArtifactDirectory();
+ PhysicsDataCache cache = CacheWithShadow(directory);
+ cache.CollisionTraversalMode = CollisionTraversalMode.Flat;
+ CellPhysics matching = MatchingCell(0xA9B4_0100u);
+ CellPhysics mismatched = WithFlatPhysics(
+ matching,
+ new FlatPhysicsBsp(
+ -1,
+ ImmutableArray.Empty,
+ ImmutableArray.Empty,
+ FlatPolygonTable.Empty));
+
+ bool flatResult = CollisionTraversal.HasPhysics(cache, mismatched);
+
+ Assert.False(flatResult);
+ CollisionShadowStats stats = cache.CollisionShadowStats;
+ Assert.Equal(1, stats.Queries);
+ Assert.Equal(1, stats.Samples);
+ Assert.Equal(1, stats.Mismatches);
+ Assert.Equal(0, stats.Faults);
+ string artifact = Assert.Single(Directory.EnumerateFiles(directory));
+ using JsonDocument json = JsonDocument.Parse(File.ReadAllText(artifact));
+ JsonElement root = json.RootElement;
+ Assert.Equal("True", root.GetProperty("Graph").GetString());
+ Assert.Equal("False", root.GetProperty("Flat").GetString());
+ }
+
[Fact]
public void MissingFlat_IsRecordedAsFaultAndGraphRemainsAuthoritative()
{
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs
new file mode 100644
index 00000000..1a3560c9
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs
@@ -0,0 +1,61 @@
+using System.Collections.Immutable;
+using AcDream.Core.Physics;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.Core.Tests.Physics;
+
+public sealed class PhysicsDataCacheProductionTests
+{
+ [Fact]
+ public void CreateProduction_SelectsFlatTraversal()
+ {
+ PhysicsDataCache cache = PhysicsDataCache.CreateProduction();
+
+ Assert.Equal(
+ CollisionTraversalMode.Flat,
+ cache.CollisionTraversalMode);
+ }
+
+ [Fact]
+ public void ProductionSetupPublication_RejectsMissingPreparedAsset()
+ {
+ PhysicsDataCache cache = PhysicsDataCache.CreateProduction();
+
+ InvalidOperationException fault = Assert.Throws(
+ () => cache.CacheSetup(0x0200_0001u, new Setup()));
+
+ Assert.Contains(
+ "no prepared collision asset",
+ fault.Message,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GraphOracleFixture_StillAcceptsSourceSetup()
+ {
+ var cache = new PhysicsDataCache();
+
+ cache.CacheSetup(0x0200_0001u, new Setup());
+
+ Assert.NotNull(cache.GetSetup(0x0200_0001u));
+ }
+
+ [Fact]
+ public void ProductionSetupPublication_AllowsIdempotentCachedFlatAsset()
+ {
+ PhysicsDataCache cache = PhysicsDataCache.CreateProduction();
+ var setup = new Setup();
+ var prepared = new FlatSetupCollision(
+ ImmutableArray.Empty,
+ ImmutableArray.Empty,
+ 0f,
+ 0f,
+ 0f,
+ 0f);
+
+ cache.CacheSetup(0x0200_0001u, setup, prepared);
+ cache.CacheSetup(0x0200_0001u, setup);
+
+ Assert.Same(prepared, cache.GetFlatSetup(0x0200_0001u));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs b/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs
index 8dd2408f..7c1ddda0 100644
--- a/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs
+++ b/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs
@@ -1,6 +1,10 @@
using System.Numerics;
+using AcDream.Core.Physics;
using AcDream.Core.World.Cells;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
using Xunit;
+using UcgCellPortal = AcDream.Core.World.Cells.CellPortal;
namespace AcDream.Core.Tests.World.Cells;
@@ -11,7 +15,7 @@ public class EnvCellTests
var t = transform ?? Matrix4x4.Identity;
Matrix4x4.Invert(t, out var inv);
return new EnvCell(0xA9B40174u, t, inv, min, max,
- System.Array.Empty(), System.Array.Empty(),
+ System.Array.Empty(), System.Array.Empty(),
seenOutside: false, containmentBsp: null);
}
@@ -30,4 +34,36 @@ public class EnvCellTests
Assert.True(c.PointInCell(new Vector3(105,5,5)));
Assert.False(c.PointInCell(new Vector3(5,5,5)));
}
+
+ [Fact]
+ public void PointInCell_PreparedContainmentIsAuthoritative()
+ {
+ var graphRoot = new CellBSPNode
+ {
+ Type = BSPNodeType.BPIn,
+ SplittingPlane = new Plane(Vector3.UnitX, -1f),
+ PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
+ };
+ var flatRoot = new CellBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ LeafIndex = 1,
+ };
+ FlatCellContainmentBsp flat =
+ FlatCollisionAssetBuilder.FlattenCellContainmentBsp(flatRoot);
+ var cell = new EnvCell(
+ 0xA9B4_0174u,
+ Matrix4x4.Identity,
+ Matrix4x4.Identity,
+ -Vector3.One,
+ Vector3.One,
+ Array.Empty(),
+ Array.Empty(),
+ seenOutside: false,
+ containmentBsp: new CellBSPTree { Root = graphRoot },
+ flatContainmentBsp: flat);
+
+ Assert.True(cell.PointInCell(Vector3.Zero));
+ Assert.False(BSPQuery.PointInsideCellBsp(graphRoot, Vector3.Zero));
+ }
}