refactor(streaming): extract landblock build factory

This commit is contained in:
Erik 2026-07-21 20:30:32 +02:00
parent e4a9664f09
commit 5af101b2f1
4 changed files with 1361 additions and 794 deletions

View file

@ -0,0 +1,509 @@
using System.Reflection;
using System.Numerics;
using AcDream.App.Streaming;
using AcDream.Content;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Xunit.Sdk;
namespace AcDream.App.Tests.Streaming;
public sealed class LandblockBuildFactoryTests
{
private const uint LandblockId = 0xA9B4FFFFu;
private const uint AdjacentLandblockId = 0xA9B5FFFFu;
private static readonly LandblockBuildOrigin HoltburgOrigin = new(0xA9, 0xB4);
[Fact]
public void BuildFar_ReturnsOnlyHeightmapAndCapturedOrigin()
{
var dat = CreateDat(out RecordingDatProxy proxy);
var heightmap = new LandBlock { Id = LandblockId };
proxy.Add(LandblockId, heightmap);
var factory = Factory(dat, new object());
LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadFar));
Assert.NotNull(result);
Assert.Same(heightmap, result.Landblock.Heightmap);
Assert.Empty(result.Landblock.Entities);
Assert.Same(PhysicsDatBundle.Empty, result.Landblock.PhysicsDats);
Assert.Null(result.EnvCells);
Assert.Equal(HoltburgOrigin, result.Origin);
Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads);
}
[Fact]
public void Build_RejectsUnspecifiedOriginBeforeReadingDat()
{
var dat = CreateDat(out RecordingDatProxy proxy);
var factory = Factory(dat, new object());
Assert.Throws<ArgumentException>(() => factory.Build(
new LandblockBuildRequest(
LandblockId,
LandblockStreamJobKind.LoadFar,
Generation: 1,
Origin: default)));
Assert.Empty(proxy.Reads);
}
[Fact]
public void Build_MissingHeightmapReturnsNullWithoutPartialPayload()
{
var dat = CreateDat(out RecordingDatProxy proxy);
var factory = Factory(dat, new object());
LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadFar));
Assert.Null(result);
Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads);
}
[Fact]
public void BuildNear_MissingLandblockInfoReturnsCompleteEmptyNearPayload()
{
var dat = CreateDat(out RecordingDatProxy proxy);
proxy.Add(LandblockId, new LandBlock { Id = LandblockId });
var factory = Factory(dat, new object());
LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadNear));
Assert.NotNull(result);
Assert.Empty(result.Landblock.Entities);
var envCells = Assert.IsType<AcDream.App.Rendering.Wb.EnvCellLandblockBuild>(
result.EnvCells);
Assert.Empty(envCells.VisibilityCells);
Assert.Empty(envCells.Shells);
var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats);
Assert.Null(physics.Info);
Assert.Empty(physics.EnvCells);
}
[Fact]
public void BuildNear_MissingEnvCellSkipsOnlyThatCellWithoutPartialVisibilityEntry()
{
var dat = CreateDat(out RecordingDatProxy proxy);
AddNearFixture(proxy, LandblockId, environmentId: 1, cellCount: 2);
uint missingCell = (LandblockId & 0xFFFF0000u) | 0x0101u;
proxy.Remove<EnvCell>(missingCell);
var factory = Factory(dat, new object());
LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadNear));
Assert.NotNull(result);
var envCells = Assert.IsType<AcDream.App.Rendering.Wb.EnvCellLandblockBuild>(
result.EnvCells);
Assert.Equal(
[(LandblockId & 0xFFFF0000u) | 0x0100u],
envCells.VisibilityCells.Select(cell => cell.CellId));
var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats);
Assert.Single(physics.EnvCells);
Assert.DoesNotContain(missingCell, physics.EnvCells.Keys);
}
[Fact]
public async Task Build_SerializesCompleteNearTransactionsOnSharedGate()
{
var dat = CreateDat(out RecordingDatProxy proxy);
AddNearFixture(proxy, LandblockId, environmentId: 1);
AddNearFixture(proxy, AdjacentLandblockId, environmentId: 2);
object gate = new();
var factory = Factory(dat, gate);
Assert.NotNull(factory.Build(Request(
LandblockId,
LandblockStreamJobKind.LoadNear,
generation: 1,
HoltburgOrigin)));
(Type Type, uint Id)[] firstSequence = proxy.Reads.ToArray();
proxy.ClearReads();
Assert.NotNull(factory.Build(Request(
AdjacentLandblockId,
LandblockStreamJobKind.LoadNear,
generation: 1,
new LandblockBuildOrigin(0xA9, 0xB5))));
(Type Type, uint Id)[] secondSequence = proxy.Reads.ToArray();
proxy.ClearReads();
proxy.ReadDelay = TimeSpan.FromMilliseconds(5);
Task<LandblockBuild?> first = Task.Run(() =>
factory.Build(Request(
LandblockId,
LandblockStreamJobKind.LoadNear,
generation: 2,
HoltburgOrigin)));
Task<LandblockBuild?> second = Task.Run(() =>
factory.Build(Request(
AdjacentLandblockId,
LandblockStreamJobKind.LoadNear,
generation: 3,
new LandblockBuildOrigin(0xA9, 0xB5))));
LandblockBuild?[] results = await Task.WhenAll(first, second);
Assert.All(results, Assert.NotNull);
Assert.Equal(1, proxy.MaxConcurrentReads);
(Type Type, uint Id)[] combined = proxy.Reads.ToArray();
Assert.True(
combined.SequenceEqual(firstSequence.Concat(secondSequence))
|| combined.SequenceEqual(secondSequence.Concat(firstSequence)),
"Near-build DAT reads interleaved instead of remaining one serialized transaction.");
}
[Fact]
public async Task Build_UsesTheSuppliedSharedReaderGate()
{
var dat = CreateDat(out RecordingDatProxy proxy);
proxy.Add(LandblockId, new LandBlock { Id = LandblockId });
object gate = new();
var factory = Factory(dat, gate);
using var started = new ManualResetEventSlim();
Monitor.Enter(gate);
Task<LandblockBuild?> build;
try
{
build = Task.Run(() =>
{
started.Set();
return factory.Build(Request(LandblockStreamJobKind.LoadFar));
});
Assert.True(started.Wait(TimeSpan.FromSeconds(2)));
Assert.False(proxy.ReadObserved.Wait(TimeSpan.FromMilliseconds(100)));
}
finally
{
Monitor.Exit(gate);
}
Assert.NotNull(await build);
Assert.True(proxy.ReadObserved.IsSet);
}
[Theory]
[InlineData(LandblockStreamJobKind.LoadNear)]
[InlineData(LandblockStreamJobKind.PromoteToNear)]
public void Build_NearAndPromoteIncludeEveryValidZeroSubmeshVisibilityCell(
LandblockStreamJobKind kind)
{
var dat = CreateDat(out RecordingDatProxy proxy);
AddNearFixture(proxy, LandblockId, environmentId: 1, cellCount: 2);
var factory = Factory(dat, new object());
LandblockBuild? result = factory.Build(Request(kind));
Assert.NotNull(result);
var envCells = Assert.IsType<AcDream.App.Rendering.Wb.EnvCellLandblockBuild>(
result.EnvCells);
var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats);
Assert.Equal(
[(LandblockId & 0xFFFF0000u) | 0x0100u, (LandblockId & 0xFFFF0000u) | 0x0101u],
envCells.VisibilityCells.Select(cell => cell.CellId));
Assert.Empty(envCells.Shells);
Assert.NotNull(physics.Info);
Assert.Equal(2, physics.EnvCells.Count);
Assert.Single(physics.Environments);
}
[Fact]
public void ConstructorSnapshotsTheRetailHeightTable()
{
var dat = CreateDat(out _);
var heights = Enumerable.Range(0, 256).Select(value => (float)value).ToArray();
var factory = new LandblockBuildFactory(
dat,
new object(),
heights,
new PhysicsDataCache());
heights[42] = -1f;
var snapshot = Assert.IsType<float[]>(typeof(LandblockBuildFactory)
.GetField("_heightTable", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(factory));
Assert.NotSame(heights, snapshot);
Assert.Equal(42f, snapshot[42]);
}
[Fact]
public void ConstructorRejectsIncompleteRetailHeightTable()
{
var dat = CreateDat(out _);
Assert.Throws<ArgumentException>(() => new LandblockBuildFactory(
dat,
new object(),
new float[255],
new PhysicsDataCache()));
}
[Fact]
public void Build_ExceptionReleasesSharedGateForTheNextTransaction()
{
var dat = CreateDat(out RecordingDatProxy proxy);
proxy.Add(LandblockId, new LandBlock { Id = LandblockId });
proxy.ExceptionToThrow = new InvalidDataException("fixture read failure");
var factory = Factory(dat, new object());
Assert.Throws<InvalidDataException>(() =>
factory.Build(Request(LandblockStreamJobKind.LoadFar)));
proxy.ExceptionToThrow = null;
Assert.NotNull(factory.Build(Request(LandblockStreamJobKind.LoadFar)));
}
[Fact]
public void FactorySurface_HasNoRendererWorldStateOrLiveOriginDependency()
{
Type type = typeof(LandblockBuildFactory);
Type[] dependencyTypes = type
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic)
.Select(field => field.FieldType)
.Concat(type.GetConstructors().SelectMany(ctor =>
ctor.GetParameters().Select(parameter => parameter.ParameterType)))
.ToArray();
Assert.DoesNotContain(dependencyTypes, dependency =>
dependency.FullName?.Contains("GameWindow", StringComparison.Ordinal) == true);
Assert.DoesNotContain(dependencyTypes, dependency =>
dependency.FullName?.Contains("GpuWorldState", StringComparison.Ordinal) == true);
Assert.DoesNotContain(dependencyTypes, dependency =>
dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true);
Assert.DoesNotContain(dependencyTypes, dependency =>
dependency.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true);
}
[Fact]
public void InstalledDatNearBuild_IsDeterministicAndClosesEntityPhysicsPayload()
{
string? datDirectory = ResolveDatDirectory();
if (datDirectory is null)
throw SkipException.ForSkip("Installed retail DATs are required.");
using var dat = new BoundedTestDatCollection(datDirectory);
var bounded = (IDatReaderWriter)dat;
Region? region = bounded.Get<Region>(0x13000000u);
float[]? heights = region?.LandDefs.LandHeightTable;
Assert.NotNull(heights);
Assert.True(heights.Length >= 256);
var factory = new LandblockBuildFactory(
bounded,
new object(),
heights,
new PhysicsDataCache());
LandblockBuildRequest request = Request(LandblockStreamJobKind.LoadNear, generation: 4);
LandblockBuild? first = factory.Build(request);
LandblockBuild? second = factory.Build(request);
Assert.NotNull(first);
Assert.NotNull(second);
Assert.Equal(HoltburgOrigin, first.Origin);
Assert.NotNull(first.EnvCells);
Assert.NotEmpty(first.Landblock.Entities);
AssertEntityBuildsEqual(first.Landblock.Entities, second.Landblock.Entities);
Assert.Equal(
first.EnvCells.VisibilityCells.Select(cell => cell.CellId),
second.EnvCells!.VisibilityCells.Select(cell => cell.CellId));
Assert.Equal(
first.EnvCells.Shells.Select(shell => shell.GeometryId),
second.EnvCells.Shells.Select(shell => shell.GeometryId));
LandBlockInfo? info = bounded.Get<LandBlockInfo>(
(LandblockId & 0xFFFF0000u) | 0xFFFEu);
Assert.NotNull(info);
var expectedVisibilityCells = new List<uint>();
uint firstCellId = (LandblockId & 0xFFFF0000u) | 0x0100u;
for (uint offset = 0; offset < info.NumCells; offset++)
{
uint cellId = firstCellId + offset;
EnvCell? cell = bounded.Get<EnvCell>(cellId);
if (cell is null || cell.EnvironmentId == 0)
continue;
DatReaderWriter.DBObjs.Environment? environment =
bounded.Get<DatReaderWriter.DBObjs.Environment>(
0x0D000000u | cell.EnvironmentId);
if (environment?.Cells.ContainsKey(cell.CellStructure) == true)
expectedVisibilityCells.Add(cellId);
}
Assert.Equal(
expectedVisibilityCells,
first.EnvCells.VisibilityCells.Select(cell => cell.CellId));
PhysicsDatBundle physics = Assert.IsType<PhysicsDatBundle>(
first.Landblock.PhysicsDats);
Assert.NotNull(physics.Info);
foreach (WorldEntity entity in first.Landblock.Entities)
{
if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
Assert.Contains(entity.SourceGfxObjOrSetupId, physics.Setups.Keys);
foreach (MeshRef mesh in entity.MeshRefs)
Assert.Contains(mesh.GfxObjId, physics.GfxObjs.Keys);
}
}
private static LandblockBuildFactory Factory(IDatReaderWriter dat, object gate) =>
new(dat, gate, new float[256], new PhysicsDataCache());
private static LandblockBuildRequest Request(
LandblockStreamJobKind kind,
ulong generation = 1) =>
new(LandblockId, kind, generation, HoltburgOrigin);
private static LandblockBuildRequest Request(
uint landblockId,
LandblockStreamJobKind kind,
ulong generation,
LandblockBuildOrigin origin) =>
new(landblockId, kind, generation, origin);
private static void AddNearFixture(
RecordingDatProxy proxy,
uint landblockId,
ushort environmentId,
uint cellCount = 1)
{
proxy.Add(landblockId, new LandBlock { Id = landblockId });
proxy.Add(
(landblockId & 0xFFFF0000u) | 0xFFFEu,
new LandBlockInfo { NumCells = cellCount });
var environment = new DatReaderWriter.DBObjs.Environment
{
Id = 0x0D000000u | environmentId,
};
for (uint offset = 0; offset < cellCount; offset++)
{
ushort structure = checked((ushort)(offset + 1));
uint cellId = (landblockId & 0xFFFF0000u) | (0x0100u + offset);
proxy.Add(cellId, new EnvCell
{
Id = cellId,
EnvironmentId = environmentId,
CellStructure = structure,
Position = new Frame
{
Orientation = Quaternion.Identity,
},
});
environment.Cells[structure] = EmptyCellStruct();
}
proxy.Add(environment.Id, environment);
}
private static CellStruct EmptyCellStruct() => new()
{
VertexArray = new VertexArray
{
Vertices = new Dictionary<ushort, SWVertex>(),
},
};
private static IDatReaderWriter CreateDat(out RecordingDatProxy proxy)
{
IDatReaderWriter dat = DispatchProxy.Create<IDatReaderWriter, RecordingDatProxy>();
proxy = (RecordingDatProxy)(object)dat;
return dat;
}
private static void AssertEntityBuildsEqual(
IReadOnlyList<WorldEntity> expected,
IReadOnlyList<WorldEntity> actual)
{
Assert.Equal(expected.Count, actual.Count);
for (int i = 0; i < expected.Count; i++)
{
WorldEntity left = expected[i];
WorldEntity right = actual[i];
Assert.Equal(left.Id, right.Id);
Assert.Equal(left.SourceGfxObjOrSetupId, right.SourceGfxObjOrSetupId);
Assert.Equal(left.Position, right.Position);
Assert.Equal(left.Rotation, right.Rotation);
Assert.Equal(left.ParentCellId, right.ParentCellId);
Assert.Equal(left.EffectCellId, right.EffectCellId);
Assert.Equal(left.MeshRefs.Count, right.MeshRefs.Count);
for (int part = 0; part < left.MeshRefs.Count; part++)
Assert.Equal(left.MeshRefs[part], right.MeshRefs[part]);
}
}
private static string? ResolveDatDirectory()
{
string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
return configured;
const string installed = @"C:\Turbine\Asheron's Call";
if (Directory.Exists(installed))
return installed;
string documents = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
return Directory.Exists(documents) ? documents : null;
}
private class RecordingDatProxy : DispatchProxy
{
private readonly Dictionary<(Type Type, uint Id), object> _objects = new();
private readonly object _gate = new();
private int _activeReads;
internal List<(Type Type, uint Id)> Reads { get; } = new();
internal int MaxConcurrentReads { get; private set; }
internal TimeSpan ReadDelay { get; set; }
internal Exception? ExceptionToThrow { get; set; }
internal ManualResetEventSlim ReadObserved { get; } = new();
internal void Add<T>(uint id, T value) where T : class =>
_objects[(typeof(T), id)] = value;
internal void Remove<T>(uint id) where T : class =>
_objects.Remove((typeof(T), id));
internal void ClearReads()
{
lock (_gate)
{
Reads.Clear();
MaxConcurrentReads = 0;
}
}
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
if (targetMethod?.Name == "Get" && args is [uint id])
{
Type type = targetMethod.ReturnType;
int active = Interlocked.Increment(ref _activeReads);
ReadObserved.Set();
lock (_gate)
{
Reads.Add((type, id));
MaxConcurrentReads = Math.Max(MaxConcurrentReads, active);
}
try
{
if (ReadDelay > TimeSpan.Zero)
Thread.Sleep(ReadDelay);
if (ExceptionToThrow is { } error)
throw error;
return _objects.TryGetValue((type, id), out object? value)
? value
: null;
}
finally
{
Interlocked.Decrement(ref _activeReads);
}
}
if (targetMethod?.ReturnType == typeof(void))
return null;
if (targetMethod?.ReturnType.IsValueType == true)
return Activator.CreateInstance(targetMethod.ReturnType);
return null;
}
}
}

View file

@ -232,27 +232,45 @@ public sealed class LandblockBuildOriginTests
}
[Fact]
public void GameWindowBuildAndPublication_UseCapturedOriginInsteadOfLiveCenter()
public void BuildFactoryAndGameWindowPublication_UseCapturedOriginInsteadOfLiveCenter()
{
string root = FindRepoRoot();
string source = File.ReadAllText(Path.Combine(
string gameWindowSource = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
string buildSection = Slice(
source,
"private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(",
"private void AdvanceLandblockPresentationRetirement(");
string buildSource = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Streaming",
"LandblockBuildFactory.cs"));
string publicationSection = Slice(
source,
gameWindowSource,
"private void ApplyLoadedTerrainLocked(",
"private void OnUpdate(double dt)");
Assert.Contains("request.Origin", buildSection, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterX", buildSection, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterY", buildSection, StringComparison.Ordinal);
Assert.Contains("request.Origin", buildSource, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterX", buildSource, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterY", buildSource, StringComparison.Ordinal);
Assert.DoesNotContain(
"BuildLandblockForStreaming",
gameWindowSource,
StringComparison.Ordinal);
Assert.DoesNotContain(
"BuildSceneryEntitiesForStreaming",
gameWindowSource,
StringComparison.Ordinal);
Assert.DoesNotContain(
"BuildInteriorEntitiesForStreaming",
gameWindowSource,
StringComparison.Ordinal);
Assert.DoesNotContain(
"BuildPhysicsDatBundle",
gameWindowSource,
StringComparison.Ordinal);
Assert.Contains("build.Origin.CenterX", publicationSection, StringComparison.Ordinal);
Assert.Contains("build.Origin.CenterY", publicationSection, StringComparison.Ordinal);
Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal);