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

@ -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()