feat(streaming): shadow-publish flat collision assets

Carry one immutable prepared collision closure with each accepted near-tier generation and install graph plus flat views through the same retained publication receipt. Apply the same strict package-only rule to live entities, add exact sampled graph-authoritative comparison artifacts and lifecycle counters, and prove cancellation, demotion, rehydrate, revisit, teardown, reconnect, and the nine-stop route with 14,064 zero-mismatch samples.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-25 16:38:54 +02:00
parent f7ff9f4eea
commit d9446030e6
32 changed files with 2777 additions and 92 deletions

View file

@ -410,6 +410,12 @@ public sealed class ContentEffectsAudioCompositionTests
(LiveEntityCollisionBuilder)RuntimeHelpers.GetUninitializedObject(
typeof(LiveEntityCollisionBuilder));
public LiveCollisionAssetPublisher CreateLiveCollisionAssetPublisher(
PhysicsDataCache physicsData,
IPreparedAssetSource preparedAssets) =>
(LiveCollisionAssetPublisher)RuntimeHelpers.GetUninitializedObject(
typeof(LiveCollisionAssetPublisher));
public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) => new();
public ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters) => new(emitters);
public ParticleHookSink CreateParticleSink(

View file

@ -381,6 +381,7 @@ public sealed class WorldLifecycleAutomationControllerTests
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L,
0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L,
0, 0, 0, 0, 0, 0, 0, default,
default, default, 0d, 0d, null);
private static string NewDirectory()

View file

@ -0,0 +1,160 @@
using System.Collections.Immutable;
using AcDream.App.Physics;
using AcDream.Content;
using AcDream.Content.Pak;
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Physics;
public sealed class LiveCollisionAssetPublisherTests
{
[Fact]
public void LiveGfxAndSetup_PublishExactPreparedViewOnce()
{
const uint GfxId = 0x0100_0042u;
const uint SetupId = 0x0200_0042u;
var cache = new PhysicsDataCache();
var source = new RecordingSource();
var publisher = new LiveCollisionAssetPublisher(cache, source);
GfxObj gfx = PhysicsGfx();
var setup = new Setup();
publisher.CacheGfxObj(GfxId, gfx);
publisher.CacheGfxObj(GfxId, gfx);
publisher.CacheSetup(SetupId, setup);
publisher.CacheSetup(SetupId, setup);
Assert.Same(source.Gfx, cache.GetFlatGfxObj(GfxId));
Assert.Same(
source.Gfx.PhysicsBsp,
cache.GetGfxObj(GfxId)!.FlatPhysicsBsp);
Assert.Same(source.Setup, cache.GetFlatSetup(SetupId));
Assert.Same(
source.Setup,
cache.GetSetup(SetupId)!.FlatCollision);
Assert.Equal(1, source.GfxReads);
Assert.Equal(1, source.SetupReads);
}
[Fact]
public void VisualOnlyGfx_RemembersPreparedEmptyAssetWithoutGraphRecord()
{
const uint GfxId = 0x0100_0043u;
var cache = new PhysicsDataCache();
var source = new RecordingSource();
var publisher = new LiveCollisionAssetPublisher(cache, source);
var gfx = new GfxObj();
publisher.CacheGfxObj(GfxId, gfx);
publisher.CacheGfxObj(GfxId, gfx);
Assert.Null(cache.GetGfxObj(GfxId));
Assert.Same(source.Gfx, cache.GetFlatGfxObj(GfxId));
Assert.Equal(1, source.GfxReads);
}
[Fact]
public void MissingPreparedAsset_RejectsGraphOnlyLivePublication()
{
var cache = new PhysicsDataCache();
var source = new RecordingSource
{
GfxResult =
PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
.Missing,
};
var publisher = new LiveCollisionAssetPublisher(cache, source);
InvalidDataException fault = Assert.Throws<InvalidDataException>(
() => publisher.CacheGfxObj(0x0100_0044u, PhysicsGfx()));
Assert.Contains("Missing", fault.Message, StringComparison.Ordinal);
Assert.Equal(0, cache.GfxObjCount);
Assert.Equal(0, cache.FlatGfxObjCount);
}
private static GfxObj PhysicsGfx() => new()
{
Flags = GfxObjFlags.HasPhysics,
PhysicsBSP = new PhysicsBSPTree
{
Root = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
},
},
VertexArray = new VertexArray(),
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
};
private sealed class RecordingSource : IPreparedCollisionSource
{
private static readonly FlatPhysicsBsp EmptyPhysics = new(
-1,
ImmutableArray<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
FlatPolygonTable.Empty);
public FlatGfxObjCollisionAsset Gfx { get; } =
new(EmptyPhysics, null, null);
public FlatSetupCollision Setup { get; } = new(
ImmutableArray<FlatCollisionCylinder>.Empty,
ImmutableArray<FlatCollisionSphere>.Empty,
0f,
0f,
0f,
0f);
public int GfxReads { get; private set; }
public int SetupReads { get; private set; }
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>?
GfxResult { get; init; }
public PreparedAssetPresence ProbeCollision(
PakAssetType type,
uint sourceFileId) => PreparedAssetPresence.Available;
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default)
{
GfxReads++;
return GfxResult ??
PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
.Loaded(Gfx);
}
public PreparedCollisionReadResult<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default)
{
SetupReads++;
return PreparedCollisionReadResult<FlatSetupCollision>
.Loaded(Setup);
}
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
.Missing;
public PreparedCollisionReadResult<FlatEnvCellTopology>
ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
PreparedCollisionReadResult<FlatEnvCellTopology>.Missing;
public PreparedCollisionSourceStats CollisionStats => default;
public void Dispose()
{
}
}
}

View file

@ -1,5 +1,6 @@
using System.Reflection;
using System.Numerics;
using System.Collections.Immutable;
using AcDream.App.Streaming;
using AcDream.Content;
using AcDream.Core.Physics;
@ -32,6 +33,7 @@ public sealed class LandblockBuildFactoryTests
Assert.Empty(result.Landblock.Entities);
Assert.Same(PhysicsDatBundle.Empty, result.Landblock.PhysicsDats);
Assert.Null(result.EnvCells);
Assert.Null(result.Collisions);
Assert.Equal(HoltburgOrigin, result.Origin);
Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads);
}
@ -81,6 +83,40 @@ public sealed class LandblockBuildFactoryTests
var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats);
Assert.Null(physics.Info);
Assert.Empty(physics.EnvCells);
Assert.NotNull(result.Collisions);
Assert.Empty(result.Collisions.GfxObjs);
Assert.Empty(result.Collisions.Setups);
Assert.Empty(result.Collisions.CellStructures);
Assert.Empty(result.Collisions.EnvCells);
}
[Fact]
public void NearPreparedFaultRejectsWholeGenerationWhileFarNeverReadsCollision()
{
var dat = CreateDat(out RecordingDatProxy proxy);
AddNearFixture(proxy, LandblockId, environmentId: 1);
var source = new TestPreparedCollisionSource(
PreparedAssetReadStatus.Missing);
var factory = new LandblockBuildFactory(
dat,
source,
new object(),
new float[256]);
LandblockBuild? far = factory.Build(
Request(LandblockStreamJobKind.LoadFar));
Assert.NotNull(far);
Assert.Null(far.Collisions);
Assert.Equal(0, source.Reads);
InvalidDataException failure =
Assert.Throws<InvalidDataException>(() =>
factory.Build(Request(LandblockStreamJobKind.LoadNear)));
Assert.Contains(
"complete near-tier generation",
failure.Message,
StringComparison.Ordinal);
Assert.True(source.Reads > 0);
}
[Fact]
@ -103,6 +139,9 @@ public sealed class LandblockBuildFactoryTests
var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats);
Assert.Single(physics.EnvCells);
Assert.DoesNotContain(missingCell, physics.EnvCells.Keys);
Assert.Single(result.Collisions!.CellStructures);
Assert.Single(result.Collisions.EnvCells);
Assert.DoesNotContain(missingCell, result.Collisions.EnvCells.Keys);
}
[Fact]
@ -215,9 +254,9 @@ public sealed class LandblockBuildFactoryTests
var heights = Enumerable.Range(0, 256).Select(value => (float)value).ToArray();
var factory = new LandblockBuildFactory(
dat,
TestPreparedCollisionSource.Instance,
new object(),
heights,
new PhysicsDataCache());
heights);
heights[42] = -1f;
var snapshot = Assert.IsType<float[]>(typeof(LandblockBuildFactory)
@ -234,9 +273,9 @@ public sealed class LandblockBuildFactoryTests
Assert.Throws<ArgumentException>(() => new LandblockBuildFactory(
dat,
TestPreparedCollisionSource.Instance,
new object(),
new float[255],
new PhysicsDataCache()));
new float[255]));
}
[Fact]
@ -273,6 +312,7 @@ public sealed class LandblockBuildFactoryTests
dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true);
Assert.DoesNotContain(dependencyTypes, dependency =>
dependency.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true);
Assert.DoesNotContain(typeof(PhysicsDataCache), dependencyTypes);
}
[Fact]
@ -288,11 +328,15 @@ public sealed class LandblockBuildFactoryTests
float[]? heights = region?.LandDefs.LandHeightTable;
Assert.NotNull(heights);
Assert.True(heights.Length >= 256);
string? pakPath = ResolvePreparedPackagePath(datDirectory);
if (pakPath is null)
throw SkipException.ForSkip("Installed acdream.pak is required.");
using var prepared = new PakPreparedAssetSource(pakPath, bounded);
var factory = new LandblockBuildFactory(
bounded,
prepared,
new object(),
heights,
new PhysicsDataCache());
heights);
LandblockBuildRequest request = Request(LandblockStreamJobKind.LoadNear, generation: 4);
LandblockBuild? first = factory.Build(request);
@ -302,6 +346,7 @@ public sealed class LandblockBuildFactoryTests
Assert.NotNull(second);
Assert.Equal(HoltburgOrigin, first.Origin);
Assert.NotNull(first.EnvCells);
Assert.NotNull(first.Collisions);
Assert.NotEmpty(first.Landblock.Entities);
AssertEntityBuildsEqual(first.Landblock.Entities, second.Landblock.Entities);
Assert.Equal(
@ -335,6 +380,12 @@ public sealed class LandblockBuildFactoryTests
PhysicsDatBundle physics = Assert.IsType<PhysicsDatBundle>(
first.Landblock.PhysicsDats);
Assert.NotNull(physics.Info);
Assert.Equal(physics.EnvCells.Keys.Order(), first.Collisions.EnvCells.Keys.Order());
Assert.Equal(
physics.EnvCells.Keys.Order(),
first.Collisions.CellStructures.Keys.Order());
Assert.Equal(physics.Setups.Keys.Order(), first.Collisions.Setups.Keys.Order());
Assert.Equal(physics.GfxObjs.Keys.Order(), first.Collisions.GfxObjs.Keys.Order());
foreach (WorldEntity entity in first.Landblock.Entities)
{
if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
@ -345,7 +396,11 @@ public sealed class LandblockBuildFactoryTests
}
private static LandblockBuildFactory Factory(IDatReaderWriter dat, object gate) =>
new(dat, gate, new float[256], new PhysicsDataCache());
new(
dat,
TestPreparedCollisionSource.Instance,
gate,
new float[256]);
private static LandblockBuildRequest Request(
LandblockStreamJobKind kind,
@ -444,6 +499,26 @@ public sealed class LandblockBuildFactoryTests
return Directory.Exists(documents) ? documents : null;
}
private static string? ResolvePreparedPackagePath(string datDirectory)
{
string? configured =
System.Environment.GetEnvironmentVariable("ACDREAM_PAK_PATH");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
return configured;
string besideDats = Path.Combine(datDirectory, "acdream.pak");
if (File.Exists(besideDats))
return besideDats;
string documents = Path.Combine(
System.Environment.GetFolderPath(
System.Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call",
"acdream.pak");
return File.Exists(documents) ? documents : null;
}
private class RecordingDatProxy : DispatchProxy
{
private readonly Dictionary<(Type Type, uint Id), object> _objects = new();
@ -506,4 +581,97 @@ public sealed class LandblockBuildFactoryTests
return null;
}
}
private sealed class TestPreparedCollisionSource :
IPreparedCollisionSource
{
private static readonly FlatPhysicsBsp EmptyPhysics = new(
-1,
ImmutableArray<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
FlatPolygonTable.Empty);
private static readonly FlatCellStructureCollisionAsset EmptyCell =
new(
EmptyPhysics,
new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.Empty),
FlatPolygonTable.Empty);
private static readonly FlatEnvCellTopology EmptyTopology = new(
ImmutableArray<FlatEnvCellPortal>.Empty,
ImmutableArray<uint>.Empty,
seenOutside: false);
private readonly PreparedAssetReadStatus _status;
internal TestPreparedCollisionSource(
PreparedAssetReadStatus status =
PreparedAssetReadStatus.Loaded)
{
_status = status;
}
internal static TestPreparedCollisionSource Instance { get; } = new();
internal int Reads { get; private set; }
public PreparedAssetPresence ProbeCollision(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Available;
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Result(
new FlatGfxObjCollisionAsset(
EmptyPhysics,
null,
null));
public PreparedCollisionReadResult<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Result(
new FlatSetupCollision(
ImmutableArray<FlatCollisionCylinder>.Empty,
ImmutableArray<FlatCollisionSphere>.Empty,
0f,
0f,
0f,
0f));
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Result(EmptyCell);
public PreparedCollisionReadResult<FlatEnvCellTopology>
ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Result(EmptyTopology);
public PreparedCollisionSourceStats CollisionStats => default;
public void Dispose()
{
}
private PreparedCollisionReadResult<T> Result<T>(T value)
where T : class
{
Reads++;
return _status switch
{
PreparedAssetReadStatus.Loaded =>
PreparedCollisionReadResult<T>.Loaded(value),
PreparedAssetReadStatus.Missing =>
PreparedCollisionReadResult<T>.Missing,
_ => PreparedCollisionReadResult<T>.Corrupt,
};
}
}
}

View file

@ -1,3 +1,4 @@
using System.Collections.Immutable;
using System.Numerics;
using System.Reflection;
using AcDream.App.Streaming;
@ -94,6 +95,254 @@ public sealed class LandblockPhysicsPublisherTests
Assert.Equal(0x01000077u, building.ModelId);
}
[Fact]
public void DualPublication_InstallsExactPreparedClosureAndReleasesCellOwner()
{
const uint envCellId = 0xA9B40100u;
const uint gfxObjId = 0x01000077u;
var fixture = Fixture();
PhysicsDatBundle baseBundle =
CellPortalAndBuildingBundle(envCellId);
DatReaderWriter.DBObjs.Environment environment =
Assert.Single(baseBundle.Environments.Values);
CellStruct cellStruct = Assert.Single(environment.Cells.Values);
cellStruct.PhysicsBSP = new PhysicsBSPTree
{
Root = new PhysicsBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = Vector3.Zero,
Radius = 10f,
},
},
};
cellStruct.CellBSP = new CellBSPTree
{
Root = new CellBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
LeafIndex = 1,
},
};
EnvCell envCell = baseBundle.EnvCells[envCellId];
var setup = new Setup();
setup.CylSpheres.Add(new CylSphere
{
Origin = new Vector3(0f, 0f, 0.6f),
Radius = 0.4f,
Height = 1.2f,
});
GfxObj gfx = PhysicsGfx();
var bundle = new PhysicsDatBundle(
baseBundle.Info,
baseBundle.EnvCells,
baseBundle.Environments,
new Dictionary<uint, Setup> { [SetupId] = setup },
new Dictionary<uint, GfxObj> { [gfxObjId] = gfx });
FlatGfxObjCollisionAsset flatGfx =
FlatCollisionAssetBuilder.FlattenGfxObj(gfx);
FlatSetupCollision flatSetup =
FlatCollisionAssetBuilder.FlattenSetup(setup);
FlatCellStructureCollisionAsset flatCell =
FlatCollisionAssetBuilder.FlattenCellStructure(cellStruct);
FlatEnvCellTopology flatTopology =
FlatCollisionAssetBuilder.FlattenEnvCellTopology(
envCellId,
envCell,
flatCell.PortalPolygons);
var collisions = new LandblockCollisionBuild(
ImmutableDictionary<uint, FlatGfxObjCollisionAsset>.Empty
.Add(gfxObjId, flatGfx),
ImmutableDictionary<uint, FlatSetupCollision>.Empty
.Add(SetupId, flatSetup),
ImmutableDictionary<uint, FlatCellStructureCollisionAsset>.Empty
.Add(envCellId, flatCell),
ImmutableDictionary<uint, FlatEnvCellTopology>.Empty
.Add(envCellId, flatTopology),
[gfxObjId],
[SetupId],
[envCellId]);
WorldEntity entity = new()
{
Id = 0x80A9B401u,
SourceGfxObjOrSetupId = SetupId,
Position = new Vector3(12f, 12f, 0f),
Rotation = Quaternion.Identity,
MeshRefs = [new MeshRef(gfxObjId, Matrix4x4.Identity)],
};
Publish(
fixture.Publisher,
Build(
FirstLandblock,
[entity],
bundle,
collisions: collisions));
Assert.Same(flatGfx, fixture.Cache.GetFlatGfxObj(gfxObjId));
Assert.Same(
flatGfx.PhysicsBsp,
fixture.Cache.GetGfxObj(gfxObjId)!.FlatPhysicsBsp);
Assert.Same(flatSetup, fixture.Cache.GetFlatSetup(SetupId));
Assert.Same(
flatSetup,
fixture.Cache.GetSetup(SetupId)!.FlatCollision);
Assert.Same(flatCell, fixture.Cache.GetFlatCellStruct(envCellId));
Assert.Same(flatTopology, fixture.Cache.GetFlatEnvCell(envCellId));
Assert.Same(
flatCell.PhysicsBsp,
fixture.Cache.GetCellStruct(envCellId)!.FlatPhysicsBsp);
fixture.Publisher.RemoveLandblock(FirstLandblock);
Assert.Equal(0, fixture.Cache.CellStructCount);
Assert.Equal(0, fixture.Cache.FlatCellStructCount);
Assert.Equal(0, fixture.Cache.FlatEnvCellCount);
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
}
[Fact]
public void DualPublication_DemotionRehydrateRevisitAndRemovalOwnFlatCellsExactlyOnce()
{
const uint firstCellId = 0xA9B40100u;
const uint adjacentCellId = 0xAAB40100u;
var fixture = Fixture();
PhysicsDatBundle firstBundle =
CellPortalAndBuildingBundle(firstCellId);
PhysicsDatBundle adjacentBundle =
CellPortalAndBuildingBundle(adjacentCellId);
LandblockCollisionBuild firstCollision =
FlatCellClosure(firstBundle, firstCellId);
LandblockCollisionBuild adjacentCollision =
FlatCellClosure(adjacentBundle, adjacentCellId);
Publish(
fixture.Publisher,
Build(
FirstLandblock,
bundle: firstBundle,
collisions: firstCollision));
Publish(
fixture.Publisher,
Build(
AdjacentLandblock,
bundle: adjacentBundle,
collisions: adjacentCollision));
Assert.Same(
firstCollision.CellStructures[firstCellId],
fixture.Cache.GetFlatCellStruct(firstCellId));
Assert.Same(
adjacentCollision.CellStructures[adjacentCellId],
fixture.Cache.GetFlatCellStruct(adjacentCellId));
Assert.Equal(2, fixture.Cache.FlatCellStructCount);
Assert.Equal(2, fixture.Cache.FlatEnvCellCount);
fixture.Publisher.DemoteToTerrain(FirstLandblock);
Assert.Null(fixture.Cache.GetFlatCellStruct(firstCellId));
Assert.Null(fixture.Cache.GetFlatEnvCell(firstCellId));
Assert.Same(
adjacentCollision.CellStructures[adjacentCellId],
fixture.Cache.GetFlatCellStruct(adjacentCellId));
Assert.Equal(1, fixture.Cache.FlatCellStructCount);
Assert.Equal(1, fixture.Cache.FlatEnvCellCount);
LandblockCollisionBuild rehydratedCollision =
FlatCellClosure(firstBundle, firstCellId);
Publish(
fixture.Publisher,
Build(
FirstLandblock,
bundle: firstBundle,
collisions: rehydratedCollision));
Assert.Same(
rehydratedCollision.CellStructures[firstCellId],
fixture.Cache.GetFlatCellStruct(firstCellId));
Assert.Equal(2, fixture.Cache.FlatCellStructCount);
Assert.Equal(2, fixture.Cache.FlatEnvCellCount);
LandblockCollisionBuild revisitCollision =
FlatCellClosure(firstBundle, firstCellId);
Publish(
fixture.Publisher,
Build(
FirstLandblock,
bundle: firstBundle,
collisions: revisitCollision));
Assert.Same(
revisitCollision.CellStructures[firstCellId],
fixture.Cache.GetFlatCellStruct(firstCellId));
Assert.Equal(2, fixture.Cache.FlatCellStructCount);
Assert.Equal(2, fixture.Cache.FlatEnvCellCount);
fixture.Publisher.RemoveLandblock(FirstLandblock);
fixture.Publisher.RemoveLandblock(AdjacentLandblock);
Assert.Equal(0, fixture.Cache.CellStructCount);
Assert.Equal(0, fixture.Cache.FlatCellStructCount);
Assert.Equal(0, fixture.Cache.FlatEnvCellCount);
Assert.Equal(0, fixture.Engine.LandblockCount);
}
[Fact]
public void DualPublication_CancelledPreparedReceiptPublishesNeitherView()
{
const uint envCellId = 0xA9B40100u;
var fixture = Fixture();
PhysicsDatBundle bundle =
CellPortalAndBuildingBundle(envCellId);
CellStruct sourceCell =
bundle.Environments.Values.Single().Cells.Values.Single();
sourceCell.PhysicsBSP = new PhysicsBSPTree
{
Root = new PhysicsBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = Vector3.Zero,
Radius = 10f,
},
},
};
LandblockCollisionBuild collision =
FlatCellClosure(bundle, envCellId);
LandblockPhysicsPublication cancelled =
fixture.Publisher.PreparePublication(
RenderReceipt(Build(
FirstLandblock,
bundle: bundle,
collisions: collision)));
Assert.Equal(0, fixture.Engine.LandblockCount);
Assert.Equal(0, fixture.Cache.CellStructCount);
Assert.Equal(0, fixture.Cache.FlatCellStructCount);
Assert.Equal(0, fixture.Cache.FlatEnvCellCount);
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
LandblockPhysicsPublication accepted =
fixture.Publisher.PreparePublication(
RenderReceipt(Build(
FirstLandblock,
bundle: bundle,
collisions: collision)));
fixture.Publisher.BeginPublication(accepted);
fixture.Publisher.CompletePublication(accepted);
Assert.Same(
collision.CellStructures[envCellId],
fixture.Cache.GetFlatCellStruct(envCellId));
Assert.Equal(1, fixture.Cache.CellStructCount);
Assert.Equal(1, fixture.Cache.FlatCellStructCount);
Assert.Equal(1, fixture.Cache.FlatEnvCellCount);
}
[Fact]
public void CompletePublication_PhysicsBspSuppressesSetupCylinderFallback()
{
@ -592,6 +841,25 @@ public sealed class LandblockPhysicsPublisherTests
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
private static GfxObj PhysicsGfx() => new()
{
Flags = DatReaderWriter.Enums.GfxObjFlags.HasPhysics,
PhysicsBSP = new PhysicsBSPTree
{
Root = new PhysicsBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = Vector3.Zero,
Radius = 1.25f,
},
},
},
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
VertexArray = new VertexArray(),
};
private static PhysicsDatBundle CellPortalAndBuildingBundle(uint envCellId)
{
var cellStruct = new CellStruct
@ -658,6 +926,33 @@ public sealed class LandblockPhysicsPublisherTests
new Dictionary<uint, GfxObj>());
}
private static LandblockCollisionBuild FlatCellClosure(
PhysicsDatBundle bundle,
uint envCellId)
{
EnvCell envCell = bundle.EnvCells[envCellId];
DatReaderWriter.DBObjs.Environment environment =
bundle.Environments.Values.Single();
CellStruct cellStruct = environment.Cells[envCell.CellStructure];
FlatCellStructureCollisionAsset flatCell =
FlatCollisionAssetBuilder.FlattenCellStructure(cellStruct);
FlatEnvCellTopology topology =
FlatCollisionAssetBuilder.FlattenEnvCellTopology(
envCellId,
envCell,
flatCell.PortalPolygons);
return new LandblockCollisionBuild(
ImmutableDictionary<uint, FlatGfxObjCollisionAsset>.Empty,
ImmutableDictionary<uint, FlatSetupCollision>.Empty,
ImmutableDictionary<uint, FlatCellStructureCollisionAsset>.Empty
.Add(envCellId, flatCell),
ImmutableDictionary<uint, FlatEnvCellTopology>.Empty
.Add(envCellId, topology),
[],
[],
[envCellId]);
}
private static WorldEntity CylinderEntity(
uint id,
Vector3 position,
@ -674,7 +969,8 @@ public sealed class LandblockPhysicsPublisherTests
uint landblockId,
IReadOnlyList<WorldEntity>? entities = null,
PhysicsDatBundle? bundle = null,
LandblockBuildOrigin? origin = null)
LandblockBuildOrigin? origin = null,
LandblockCollisionBuild? collisions = null)
{
var landblockInfo = new LandBlockInfo();
PhysicsDatBundle physics = bundle ?? new PhysicsDatBundle(
@ -689,7 +985,8 @@ public sealed class LandblockPhysicsPublisherTests
FlatHeightmap(),
entities ?? Array.Empty<WorldEntity>(),
physics),
Origin: origin ?? new LandblockBuildOrigin(0xA9, 0xB4));
Origin: origin ?? new LandblockBuildOrigin(0xA9, 0xB4),
Collisions: collisions);
}
private static LandBlock FlatHeightmap() => new()

View file

@ -0,0 +1,294 @@
using System.Collections.Immutable;
using System.Numerics;
using System.Text.Json;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Physics;
public sealed class CollisionShadowVerifierTests
{
[Fact]
public void MatchingDualView_RefereesEveryTraversalWithoutChangingGraph()
{
string directory = NewArtifactDirectory();
PhysicsDataCache cache = CacheWithShadow(directory);
CellPhysics cell = MatchingCell(0xA9B4_0100u);
cache.RegisterCellStructForTest(cell.SourceId, cell);
var transition = SeedTransition();
Assert.True(CollisionTraversal.HasCellContainment(cache, cell));
Assert.True(CollisionTraversal.HasPhysics(cache, cell));
FlatCollisionSphere root =
CollisionTraversal.RootBoundingSphere(cache, cell);
Assert.Equal(10f, root.Radius);
Assert.True(CollisionTraversal.PointInsideCell(
cache,
cell,
Vector3.Zero));
Assert.True(CollisionTraversal.SphereIntersectsCell(
cache,
cell,
Vector3.Zero,
0.48f));
TransitionState state = CollisionTraversal.FindCollisions(
cache,
cell,
transition,
Vector3.Zero,
0.48f,
false,
Vector3.Zero,
0f,
Vector3.Zero,
Vector3.UnitZ,
1f,
Quaternion.Identity,
engine: null,
worldOrigin: Vector3.Zero);
Assert.Equal(TransitionState.OK, state);
CollisionShadowStats stats = cache.CollisionShadowStats;
Assert.Equal(6, stats.Queries);
Assert.Equal(6, stats.Samples);
Assert.Equal(6, stats.Matches);
Assert.Equal(0, stats.Mismatches);
Assert.Equal(0, stats.Faults);
Assert.Empty(Directory.EnumerateFiles(directory));
}
[Fact]
public void Mismatch_WritesDeterministicArtifactAndReturnsGraphResult()
{
string directory = NewArtifactDirectory();
PhysicsDataCache cache = CacheWithShadow(directory);
CellPhysics matching = MatchingCell(0xA9B4_0100u);
CellPhysics mismatched = WithFlatPhysics(
matching,
new FlatPhysicsBsp(
-1,
ImmutableArray<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
FlatPolygonTable.Empty));
bool graphResult = CollisionTraversal.HasPhysics(cache, mismatched);
Assert.True(graphResult);
CollisionShadowStats stats = cache.CollisionShadowStats;
Assert.Equal(1, stats.Mismatches);
Assert.Equal(0, stats.Faults);
string artifact = Assert.Single(Directory.EnumerateFiles(directory));
Assert.Equal(
"collision-shadow-00000001.json",
Path.GetFileName(artifact));
using JsonDocument json = JsonDocument.Parse(File.ReadAllText(artifact));
JsonElement root = json.RootElement;
Assert.Equal(1, root.GetProperty("SchemaVersion").GetInt32());
Assert.Equal("HasCellPhysics", root.GetProperty("Kind").GetString());
Assert.Equal("0xA9B40100", root.GetProperty("SourceId").GetString());
Assert.Equal("Result", root.GetProperty("Difference").GetString());
Assert.Equal("True", root.GetProperty("Graph").GetString());
Assert.Equal("False", root.GetProperty("Flat").GetString());
}
[Fact]
public void MissingFlat_IsRecordedAsFaultAndGraphRemainsAuthoritative()
{
string directory = NewArtifactDirectory();
PhysicsDataCache cache = CacheWithShadow(directory);
CellPhysics cell = WithoutFlatContainment(
MatchingCell(0xA9B4_0100u));
bool graphResult = CollisionTraversal.PointInsideCell(
cache,
cell,
Vector3.Zero);
Assert.True(graphResult);
Assert.Equal(1, cache.CollisionShadowStats.Faults);
string artifact = Assert.Single(Directory.EnumerateFiles(directory));
Assert.Contains(
"FlatFault",
File.ReadAllText(artifact),
StringComparison.Ordinal);
}
[Fact]
public void TransitionCopyAndComparer_CoverCompleteLogicalState()
{
Transition source = SeedTransition();
source.ObjectInfo.State =
ObjectInfoState.Contact | ObjectInfoState.IsPlayer;
source.ObjectInfo.TargetId = 0x5000_0001u;
source.ObjectInfo.VelocityKilled = true;
source.SpherePath.NumSphere = 2;
source.SpherePath.LocalSphere[1].Origin = Vector3.UnitZ;
source.SpherePath.LocalSphere[1].Radius = 0.48f;
source.SpherePath.CarriedBlockOrigin = new Vector3(192f, -192f, 0f);
source.SpherePath.SetWalkable(
new Plane(Vector3.UnitZ, -1f),
[
Vector3.Zero,
Vector3.UnitX,
Vector3.UnitY,
],
Vector3.UnitZ);
source.SpherePath.CellCandidates.Add(0xA9B4_0001u);
source.SpherePath.CellCandidates.Add(0xA9B4_0100u);
source.CollisionInfo.SetContactPlane(
new Plane(Vector3.UnitZ, -1f),
0xA9B4_0100u);
source.CollisionInfo.SetSlidingNormal(Vector3.UnitX);
source.CollisionInfo.CollideObjectGuids.Add(0x8000_0001u);
source.CollisionInfo.LastCollidedObjectGuid = 0x8000_0001u;
var clone = new Transition();
clone.CopyFrom(source);
Assert.False(TransitionExactComparer.TryFindDifference(
source,
clone,
out _,
out _,
out _));
clone.SpherePath.WalkableVertices![1].X =
BitConverter.Int32BitsToSingle(
BitConverter.SingleToInt32Bits(
clone.SpherePath.WalkableVertices[1].X) + 1);
Assert.True(TransitionExactComparer.TryFindDifference(
source,
clone,
out string path,
out _,
out _));
Assert.Equal("SpherePath.WalkableVertices[1]", path);
}
[Fact]
public void DisabledGraphTraversal_AllocatesZeroBytes()
{
var cache = new PhysicsDataCache
{
CollisionShadow = null,
};
CellPhysics cell = MatchingCell(0xA9B4_0100u);
for (int i = 0; i < 100; i++)
_ = CollisionTraversal.HasPhysics(cache, cell);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++)
_ = CollisionTraversal.HasPhysics(cache, cell);
long allocated =
GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
}
private static PhysicsDataCache CacheWithShadow(string directory)
{
var cache = new PhysicsDataCache();
cache.CollisionShadow = new CollisionShadowVerifier(1, directory);
return cache;
}
private static CellPhysics MatchingCell(uint sourceId)
{
var physicsRoot = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = Vector3.Zero,
Radius = 10f,
},
};
var containmentRoot = new CellBSPNode
{
Type = BSPNodeType.Leaf,
LeafIndex = 1,
};
var resolved = new Dictionary<ushort, ResolvedPolygon>();
return new CellPhysics
{
SourceId = sourceId,
BSP = new PhysicsBSPTree { Root = physicsRoot },
Resolved = resolved,
CellBSP = new CellBSPTree { Root = containmentRoot },
FlatPhysicsBsp =
FlatCollisionAssetBuilder.FlattenPhysicsBsp(
physicsRoot,
resolved),
FlatContainmentBsp =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(
containmentRoot),
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
}
private static Transition SeedTransition()
{
var transition = new Transition();
transition.SpherePath.InitPath(
Vector3.Zero,
Vector3.Zero,
0xA9B4_0100u,
0.48f);
return transition;
}
private static CellPhysics WithFlatPhysics(
CellPhysics source,
FlatPhysicsBsp flat) => new()
{
SourceId = source.SourceId,
BSP = source.BSP,
PhysicsPolygons = source.PhysicsPolygons,
Vertices = source.Vertices,
WorldTransform = source.WorldTransform,
InverseWorldTransform = source.InverseWorldTransform,
Resolved = source.Resolved,
FlatPhysicsBsp = flat,
CellBSP = source.CellBSP,
FlatContainmentBsp = source.FlatContainmentBsp,
FlatPortalPolygons = source.FlatPortalPolygons,
FlatTopology = source.FlatTopology,
Portals = source.Portals,
PortalPolygons = source.PortalPolygons,
VisibleCellIds = source.VisibleCellIds,
SeenOutside = source.SeenOutside,
};
private static CellPhysics WithoutFlatContainment(
CellPhysics source) => new()
{
SourceId = source.SourceId,
BSP = source.BSP,
PhysicsPolygons = source.PhysicsPolygons,
Vertices = source.Vertices,
WorldTransform = source.WorldTransform,
InverseWorldTransform = source.InverseWorldTransform,
Resolved = source.Resolved,
FlatPhysicsBsp = source.FlatPhysicsBsp,
CellBSP = source.CellBSP,
FlatContainmentBsp = null,
FlatPortalPolygons = source.FlatPortalPolygons,
FlatTopology = source.FlatTopology,
Portals = source.Portals,
PortalPolygons = source.PortalPolygons,
VisibleCellIds = source.VisibleCellIds,
SeenOutside = source.SeenOutside,
};
private static string NewArtifactDirectory()
{
string directory = Path.Combine(
Path.GetTempPath(),
"acdream-collision-shadow-tests",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return directory;
}
}