using System.Reflection; using System.Numerics; using System.Collections.Immutable; 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.Null(result.Collisions); 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(() => 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( result.EnvCells); Assert.Empty(envCells.VisibilityCells); Assert.Empty(envCells.Shells); var physics = Assert.IsType(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(() => factory.Build(Request(LandblockStreamJobKind.LoadNear))); Assert.Contains( "complete near-tier generation", failure.Message, StringComparison.Ordinal); Assert.True(source.Reads > 0); } [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(missingCell); var factory = Factory(dat, new object()); LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadNear)); Assert.NotNull(result); var envCells = Assert.IsType( result.EnvCells); Assert.Equal( [(LandblockId & 0xFFFF0000u) | 0x0100u], envCells.VisibilityCells.Select(cell => cell.CellId)); var physics = Assert.IsType(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] 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 first = Task.Run(() => factory.Build(Request( LandblockId, LandblockStreamJobKind.LoadNear, generation: 2, HoltburgOrigin))); Task 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 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( result.EnvCells); var physics = Assert.IsType(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.Empty(physics.Environments); Assert.Equal(2, result.Collisions!.CellStructures.Count); Assert.Equal(2, result.Collisions.EnvCells.Count); } [Fact] public void ConstructorSnapshotsTheRetailHeightTable() { var dat = CreateDat(out _); var heights = Enumerable.Range(0, 256).Select(value => (float)value).ToArray(); var factory = new LandblockBuildFactory( dat, TestPreparedCollisionSource.Instance, new object(), heights); heights[42] = -1f; var snapshot = Assert.IsType(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(() => new LandblockBuildFactory( dat, TestPreparedCollisionSource.Instance, new object(), new float[255])); } [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(() => 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); Assert.DoesNotContain(typeof(PhysicsDataCache), dependencyTypes); } [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(0x13000000u); 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); 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.NotNull(first.Collisions); 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( (LandblockId & 0xFFFF0000u) | 0xFFFEu); Assert.NotNull(info); var expectedVisibilityCells = new List(); uint firstCellId = (LandblockId & 0xFFFF0000u) | 0x0100u; for (uint offset = 0; offset < info.NumCells; offset++) { uint cellId = firstCellId + offset; EnvCell? cell = bounded.Get(cellId); if (cell is null || cell.EnvironmentId == 0) continue; DatReaderWriter.DBObjs.Environment? environment = bounded.Get( 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( 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.Empty(physics.Environments); Assert.Empty(physics.GfxObjs); uint[] expectedGfxObjIds = first.Landblock.Entities .SelectMany(static entity => entity.MeshRefs) .Select(static mesh => mesh.GfxObjId) .Distinct() .Order() .ToArray(); Assert.Equal( expectedGfxObjIds, first.Collisions.GfxObjs.Keys.Order()); 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, first.Collisions.GfxObjs.Keys); } } private static LandblockBuildFactory Factory(IDatReaderWriter dat, object gate) => new( dat, TestPreparedCollisionSource.Instance, gate, new float[256]); 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(), }, }; private static IDatReaderWriter CreateDat(out RecordingDatProxy proxy) { IDatReaderWriter dat = DispatchProxy.Create(); proxy = (RecordingDatProxy)(object)dat; return dat; } private static void AssertEntityBuildsEqual( IReadOnlyList expected, IReadOnlyList 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 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(); 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(uint id, T value) where T : class => _objects[(typeof(T), id)] = value; internal void Remove(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; } } private sealed class TestPreparedCollisionSource : IPreparedCollisionSource { private static readonly FlatPhysicsBsp EmptyPhysics = new( -1, ImmutableArray.Empty, ImmutableArray.Empty, FlatPolygonTable.Empty); private static readonly FlatCellStructureCollisionAsset EmptyCell = new( EmptyPhysics, new FlatCellContainmentBsp( -1, ImmutableArray.Empty), FlatPolygonTable.Empty); private static readonly FlatEnvCellTopology EmptyTopology = new( ImmutableArray.Empty, ImmutableArray.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 ReadGfxObjCollision( uint sourceFileId, CancellationToken cancellationToken = default) => Result( new FlatGfxObjCollisionAsset( EmptyPhysics, null, null)); public PreparedCollisionReadResult ReadSetupCollision( uint sourceFileId, CancellationToken cancellationToken = default) => Result( new FlatSetupCollision( ImmutableArray.Empty, ImmutableArray.Empty, 0f, 0f, 0f, 0f)); public PreparedCollisionReadResult ReadCellStructureCollision( uint sourceFileId, CancellationToken cancellationToken = default) => Result(EmptyCell); public PreparedCollisionReadResult ReadEnvCellTopology( uint sourceFileId, CancellationToken cancellationToken = default) => Result(EmptyTopology); public PreparedCollisionSourceStats CollisionStats => default; public void Dispose() { } private PreparedCollisionReadResult Result(T value) where T : class { Reads++; return _status switch { PreparedAssetReadStatus.Loaded => PreparedCollisionReadResult.Loaded(value), PreparedAssetReadStatus.Missing => PreparedCollisionReadResult.Missing, _ => PreparedCollisionReadResult.Corrupt, }; } } }