From 068a06518dd710ca5e7f166754cbb7789ca1ed0f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 17:00:04 +0200 Subject: [PATCH] perf(physics): cut production traversal to flat assets Make prepared flat BSP data authoritative for gameplay while retaining the parsed graph only as an exact sampled referee. Fail production publication when collision package data is genuinely absent, keep idempotent already-cached publication valid, and move cell membership, floor lookup, camera diagnostics, and live/static shape bounds onto the flat representation. Validated by 8,402 Release tests, a strict dense-Arwic connected gate with 46,309/46,309 exact referee matches, and graceful shutdown. Co-authored-by: Codex --- .../Physics/LiveEntityCollisionBuilder.cs | 11 +- src/AcDream.App/Rendering/GameWindow.cs | 3 +- .../Rendering/PhysicsCameraCollisionProbe.cs | 4 +- .../Physics/CollisionShadowVerifier.cs | 37 +- .../Physics/CollisionTraversal.cs | 444 +++++++++++++++++- .../Physics/FlatCollisionAssets.cs | 4 +- src/AcDream.Core/Physics/PhysicsDataCache.cs | 71 ++- src/AcDream.Core/Physics/PhysicsEngine.cs | 55 +++ .../Physics/ShadowShapeBuilder.cs | 10 +- src/AcDream.Core/World/Cells/EnvCell.cs | 18 +- .../Physics/CollisionShadowVerifierTests.cs | 30 ++ .../PhysicsDataCacheProductionTests.cs | 61 +++ .../World/Cells/EnvCellTests.cs | 38 +- 13 files changed, 743 insertions(+), 43 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs 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)); + } }