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)); + } }