feat(render): implement Campaign AR and terrain fidelity

This commit is contained in:
Erik 2026-08-22 13:13:29 +02:00
parent 99cf26e00c
commit 7a5f96ede5
368 changed files with 50611 additions and 950 deletions

View file

@ -1,6 +1,7 @@
using AcDream.Core.Plugins;
using AcDream.Core.Selection;
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Tests.Plugins;
@ -69,6 +70,36 @@ public class PluginLoaderTests
}
}
private sealed class RecordingRenderPackRegistry : IRenderPackRegistry, IDisposable
{
private readonly List<Registration> _registrations = [];
public int ActiveCount => _registrations.Count(static item => item.Active);
public RenderPackDescriptor? Descriptor { get; private set; }
public IDisposable Register(
RenderPackDescriptor descriptor,
IRenderPackAssets assets)
{
Descriptor = descriptor;
var registration = new Registration();
_registrations.Add(registration);
return registration;
}
public void Dispose()
{
foreach (Registration registration in _registrations)
registration.Dispose();
}
private sealed class Registration : IDisposable
{
public bool Active { get; private set; } = true;
public void Dispose() => Active = false;
}
}
[Fact]
public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize()
{
@ -89,13 +120,42 @@ public class PluginLoaderTests
manifest: manifest,
host: host);
Assert.True(loaded.Success);
Assert.True(loaded.Success, loaded.Error?.ToString());
Assert.NotNull(loaded.Plugin);
Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
loaded.Plugin.Disable();
loaded.LoadContext!.Unload();
}
[Fact]
public void Load_RenderPackOnlyFixture_RegistersWithoutGameplayEntrypointRequirement()
{
string dllPath = FixturePluginPath();
var host = new StubHost();
using var registry = new RecordingRenderPackRegistry();
var manifest = new PluginManifest(
Id: "acdream.test.render-pack",
DisplayName: "Render pack",
Version: "1.0.0",
EntryDll: Path.GetFileName(dllPath),
ApiVersion: 1,
Dependencies: [],
Kinds: [PluginKind.RenderPack]);
LoadedPlugin loaded = PluginLoader.Load(
Path.GetDirectoryName(dllPath)!,
manifest,
host,
registry);
Assert.True(loaded.Success, loaded.Error?.ToString());
Assert.Null(loaded.Plugin);
Assert.NotNull(loaded.RenderPackPlugin);
Assert.Equal(1, registry.ActiveCount);
Assert.Equal("acdream.test.noop-pack", registry.Descriptor?.Id);
loaded.LoadContext!.Unload();
}
[Fact]
public void Load_UnsupportedApiVersion_IsRefusedBeforeAnyCodeLoads()
{

View file

@ -24,6 +24,7 @@ public class PluginManifestTests
Assert.Equal("0.1.0", manifest.Version);
Assert.Equal("AcDream.Plugins.Smoke.dll", manifest.EntryDll);
Assert.Equal(1, manifest.ApiVersion);
Assert.Equal([PluginKind.Gameplay], manifest.Kinds);
}
[Fact]
@ -59,4 +60,49 @@ public class PluginManifestTests
var manifest = PluginManifest.Parse(json);
Assert.Empty(manifest.Dependencies);
}
[Fact]
public void Parse_RenderPackAndHybridKinds_AreExplicitAndDeduplicated()
{
const string json = """
{
"id": "x",
"displayName": "X",
"version": "1.0.0",
"entryDll": "x.dll",
"apiVersion": 1,
"kinds": ["renderPack", "gameplay", "RENDERPACK"]
}
""";
PluginManifest manifest = PluginManifest.Parse(json);
Assert.Equal(
[PluginKind.RenderPack, PluginKind.Gameplay],
manifest.Kinds);
Assert.True(manifest.Declares(PluginKind.RenderPack));
Assert.True(manifest.Declares(PluginKind.Gameplay));
}
[Theory]
[InlineData("[]", "kinds must contain at least one entry")]
[InlineData("[\"nativeCode\"]", "unknown plugin kind: nativeCode")]
public void Parse_InvalidKinds_Throws(string kindsJson, string expected)
{
string json = $$"""
{
"id": "x",
"displayName": "X",
"version": "1.0.0",
"entryDll": "x.dll",
"apiVersion": 1,
"kinds": {{kindsJson}}
}
""";
PluginManifestException error = Assert.Throws<PluginManifestException>(
() => PluginManifest.Parse(json));
Assert.Equal(expected, error.Message);
}
}

View file

@ -2,6 +2,7 @@ using System.Text.Json;
using AcDream.Core.Plugins;
using AcDream.Core.Selection;
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Tests.Plugins;
@ -73,11 +74,118 @@ public sealed class PluginSessionTests
Assert.Empty(statuses);
}
[Fact]
public void GraphicalKindSet_RegistersRenderPackAndWithdrawsBeforeUnload()
{
using var temporary = new TemporaryDirectory();
InstallFixture(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
var statuses = new List<PluginSessionStatus>();
var registry = new RecordingRenderPackRegistry();
var plugins = new PluginSession(
new StubHost(),
statuses.Add,
registry,
[PluginKind.Gameplay, PluginKind.RenderPack]);
plugins.Start([temporary.Path], allowList: null);
Assert.Equal(1, plugins.LoadedCount);
Assert.Equal(1, registry.ActiveCount);
Assert.Equal("acdream.test.noop-pack", registry.Descriptor?.Id);
IReadOnlyList<WeakReference> contexts =
plugins.CaptureLoadContextWeakReferences();
plugins.Dispose();
Assert.Equal(0, registry.ActiveCount);
Collect(contexts);
}
[Fact]
public void GameplayOnlyHost_SkipsUnrequestedRenderPackBeforeDllProbe()
{
using var temporary = new TemporaryDirectory();
InstallBroken(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
var statuses = new List<PluginSessionStatus>();
using var plugins = new PluginSession(new StubHost(), statuses.Add);
plugins.Start([temporary.Path], allowList: null);
Assert.Equal(0, plugins.LoadedCount);
Assert.Empty(statuses);
Assert.Empty(plugins.CaptureLoadContextWeakReferences());
}
[Fact]
public void RenderPackRegisterFailure_WithdrawsPartialRegistrationBeforeUnload()
{
using var temporary = new TemporaryDirectory();
InstallFixture(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
File.WriteAllText(
Path.Combine(temporary.Path, "render", "throw-after-render-register"),
string.Empty);
var registry = new RecordingRenderPackRegistry();
var statuses = new List<PluginSessionStatus>();
var plugins = new PluginSession(
new StubHost(),
statuses.Add,
registry,
[PluginKind.Gameplay, PluginKind.RenderPack]);
plugins.Start([temporary.Path], allowList: null);
Assert.Equal(0, plugins.LoadedCount);
Assert.Equal(0, registry.ActiveCount);
PluginSessionStatus status = Assert.Single(statuses);
Assert.Equal(PluginSessionStatusKind.Failed, status.Kind);
Assert.Contains("failed after publishing", status.Error);
IReadOnlyList<WeakReference> contexts =
plugins.CaptureLoadContextWeakReferences();
plugins.Dispose();
Collect(contexts);
}
[Fact]
public void GameplayOnlyHost_ExplicitRenderPackReportsKindWithoutDllProbe()
{
using var temporary = new TemporaryDirectory();
InstallBroken(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
var statuses = new List<PluginSessionStatus>();
using var plugins = new PluginSession(new StubHost(), statuses.Add);
plugins.Start([temporary.Path], ["acdream.test.render"]);
PluginSessionStatus status = Assert.Single(statuses);
Assert.Equal(PluginSessionStatusKind.Failed, status.Kind);
Assert.Contains("does not support", status.Error);
Assert.DoesNotContain("entry dll", status.Error);
Assert.Empty(plugins.CaptureLoadContextWeakReferences());
}
private static void ReleaseAndCollect(PluginSession plugins)
{
IReadOnlyList<WeakReference> contexts =
plugins.CaptureLoadContextWeakReferences();
plugins.Dispose();
Collect(contexts);
}
private static void Collect(IReadOnlyList<WeakReference> contexts)
{
for (int attempt = 0;
attempt < 10 && contexts.Any(static context => context.IsAlive);
attempt++)
@ -90,6 +198,13 @@ public sealed class PluginSessionTests
}
private static void InstallFixture(string root, string folder, string id)
=> InstallFixture(root, folder, id, kinds: null);
private static void InstallFixture(
string root,
string folder,
string id,
IReadOnlyList<PluginKind>? kinds)
{
string source = FixturePluginPath();
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
@ -97,20 +212,25 @@ public sealed class PluginSessionTests
Directory.CreateDirectory(pluginDirectory);
string fileName = Path.GetFileName(source);
File.Copy(source, Path.Combine(pluginDirectory, fileName));
WriteManifest(pluginDirectory, id, fileName);
WriteManifest(pluginDirectory, id, fileName, kinds);
}
private static void InstallBroken(string root, string folder, string id)
private static void InstallBroken(
string root,
string folder,
string id,
IReadOnlyList<PluginKind>? kinds = null)
{
string pluginDirectory = Path.Combine(root, folder);
Directory.CreateDirectory(pluginDirectory);
WriteManifest(pluginDirectory, id, "missing.dll");
WriteManifest(pluginDirectory, id, "missing.dll", kinds);
}
private static void WriteManifest(
string directory,
string id,
string entryDll) =>
string entryDll,
IReadOnlyList<PluginKind>? kinds = null) =>
File.WriteAllText(
Path.Combine(directory, "plugin.json"),
JsonSerializer.Serialize(new
@ -120,8 +240,46 @@ public sealed class PluginSessionTests
version = "1.0.0",
entryDll,
apiVersion = 1,
kinds = kinds?.Select(static kind => kind.ToString()),
}));
private sealed class RecordingRenderPackRegistry : IRenderPackRegistry
{
private readonly List<Registration> _registrations = [];
internal int ActiveCount => _registrations.Count;
internal RenderPackDescriptor? Descriptor { get; private set; }
public IDisposable Register(
RenderPackDescriptor descriptor,
IRenderPackAssets assets)
{
Descriptor = descriptor;
var registration = new Registration(this, assets);
_registrations.Add(registration);
return registration;
}
private void Remove(Registration registration) =>
_registrations.Remove(registration);
private sealed class Registration(
RecordingRenderPackRegistry owner,
IRenderPackAssets assets) : IDisposable
{
private RecordingRenderPackRegistry? _owner = owner;
private IRenderPackAssets? _assets = assets;
public void Dispose()
{
RecordingRenderPackRegistry? current =
Interlocked.Exchange(ref _owner, null);
_assets = null;
current?.Remove(this);
}
}
}
private static string FixturePluginPath()
{
string configuration = new DirectoryInfo(AppContext.BaseDirectory)

View file

@ -149,4 +149,26 @@ public sealed class TranslucencyFadeManagerTests
Assert.Equal(1f, part0); // part 0's 1s ramp is done
Assert.Equal(0.5f, part1, 5); // part 1's 2s ramp is halfway
}
[Fact]
public void Revision_AdvancesOnlyWhenCommittedCasterOpacityChanges()
{
var mgr = new TranslucencyFadeManager();
ulong initial = mgr.Revision;
mgr.StartPartFade(1, 0, start: 0f, end: 1f, time: 1f);
ulong started = mgr.Revision;
Assert.True(started > initial);
mgr.AdvanceAll(0f);
Assert.Equal(started, mgr.Revision);
mgr.AdvanceAll(0.5f);
Assert.True(mgr.Revision > started);
ulong advanced = mgr.Revision;
mgr.ClearEntity(999);
Assert.Equal(advanced, mgr.Revision);
mgr.ClearEntity(1);
Assert.True(mgr.Revision > advanced);
}
}

View file

@ -124,6 +124,112 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
Assert.Equal(0, result.TransparentCount);
}
[Fact]
public void EveryBuiltMeshMaterialSubsetIsDetailEligible()
{
TranslucencyKind[] kinds =
[
TranslucencyKind.Opaque,
TranslucencyKind.ClipMap,
TranslucencyKind.AlphaBlend,
TranslucencyKind.Additive,
TranslucencyKind.InvAlpha,
];
var groups = kinds.Select((kind, index) => new WbDrawDispatcher.IndirectGroupInput(
IndexCount: 3,
FirstIndex: (uint)(index * 3),
BaseVertex: 0,
InstanceCount: 1,
FirstInstance: index,
TextureIndex: (uint)index,
TextureLayer: 0,
Translucency: kind)).ToList();
var indirect = new DrawElementsIndirectCommand[kinds.Length];
var batches = new WbDrawDispatcher.BatchDataPublic[kinds.Length];
WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batches);
Assert.All(batches, batch => Assert.Equal(1u, batch.Flags));
}
[Fact]
public void DetailCategoryPredicateCoversEveryInstanceInAnIndirectCommand()
{
var command = new DrawElementsIndirectCommand
{
BaseInstance = 2,
InstanceCount = 3,
};
Assert.True(WbDrawDispatcher.CommandContainsDetailCategory(
command,
[0u, 0u, 0u, 1u, 0u]));
Assert.False(WbDrawDispatcher.CommandContainsDetailCategory(
command,
[1u, 1u, 0u, 0u, 0u]));
Assert.Throws<ArgumentOutOfRangeException>(() =>
WbDrawDispatcher.CommandContainsDetailCategory(
command,
[0u, 0u, 0u, 1u]));
}
[Fact]
public void OpaqueDetailRunsSkipNonbuildingCommandsAndKeepMixedCommands()
{
DrawElementsIndirectCommand[] commands =
[
new() { BaseInstance = 0, InstanceCount = 2 },
new() { BaseInstance = 2, InstanceCount = 2 },
new() { BaseInstance = 4, InstanceCount = 1 },
];
// Command 1 is mixed: instance 2 is ordinary and instance 3 is a
// building. It must be submitted once, then mesh_detail filters the
// ordinary instance. Commands 0 and 2 must never reach the detail pipe.
uint[] mixedCategories = [0u, 0u, 0u, 1u, 0u];
Assert.True(WbDrawDispatcher.TryGetNextDetailCommandRun(
commands,
mixedCategories,
searchStart: 0,
exclusiveEnd: commands.Length,
out WbDrawDispatcher.DetailCommandRun mixedRun));
Assert.Equal(new WbDrawDispatcher.DetailCommandRun(1, 1), mixedRun);
Assert.False(WbDrawDispatcher.TryGetNextDetailCommandRun(
commands,
mixedCategories,
searchStart: mixedRun.FirstCommand + mixedRun.CommandCount,
exclusiveEnd: commands.Length,
out _));
Assert.False(WbDrawDispatcher.TryGetNextDetailCommandRun(
commands,
new uint[5],
searchStart: 0,
exclusiveEnd: commands.Length,
out _));
}
[Fact]
public void OpaqueDetailRunsCoalesceConsecutiveEligibleCommands()
{
DrawElementsIndirectCommand[] commands =
[
new() { BaseInstance = 0, InstanceCount = 1 },
new() { BaseInstance = 1, InstanceCount = 1 },
new() { BaseInstance = 2, InstanceCount = 1 },
new() { BaseInstance = 3, InstanceCount = 1 },
];
uint[] categories = [0u, 1u, 1u, 0u];
Assert.True(WbDrawDispatcher.TryGetNextDetailCommandRun(
commands,
categories,
searchStart: 0,
exclusiveEnd: commands.Length,
out WbDrawDispatcher.DetailCommandRun run));
Assert.Equal(new WbDrawDispatcher.DetailCommandRun(1, 2), run);
}
[Fact]
public void BatchDataPublic_LayoutMatchesPrivateBatchData()
{

View file

@ -226,4 +226,148 @@ public class LandblockMeshTests
Assert.Equal(10.0f, atX48Y0.Position.Z);
Assert.Equal(0.0f, atX0Y48.Position.Z);
}
[Fact]
public void Build_NormalsMatchRetailIncidentFaceAverages_NotCentralDifferences()
{
// A deliberately non-planar surface makes retail's split-aware
// incident-plane average observably different from the former
// central-difference approximation.
var block = BuildFlatLandBlock();
for (int x = 0; x < LandblockMesh.HeightmapSide; x++)
for (int y = 0; y < LandblockMesh.HeightmapSide; y++)
block.Height[x * LandblockMesh.HeightmapSide + y] =
(byte)((x * x * 3 + y * y * 5 + x * y * 11 + x * 7 + y * 13) % 96);
const uint landblockX = 0xA9;
const uint landblockY = 0xB4;
var mesh = LandblockMesh.Build(
block,
landblockX,
landblockY,
IdentityHeightTable,
MakeContext(),
new Dictionary<uint, SurfaceInfo>());
// Independent geometry oracle: derive each polygon plane from the
// actual emitted positions/indices, accumulate it at the shared
// position, and normalize only after every incident polygon is seen.
var incidentNormalSums = new Dictionary<Vector3, Vector3>();
for (int i = 0; i < mesh.Indices.Length; i += 3)
{
Vector3 p0 = mesh.Vertices[mesh.Indices[i]].Position;
Vector3 p1 = mesh.Vertices[mesh.Indices[i + 1]].Position;
Vector3 p2 = mesh.Vertices[mesh.Indices[i + 2]].Position;
Vector3 planeNormal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
AddNormal(incidentNormalSums, p0, planeNormal);
AddNormal(incidentNormalSums, p1, planeNormal);
AddNormal(incidentNormalSums, p2, planeNormal);
}
foreach (TerrainVertex vertex in mesh.Vertices)
{
Vector3 expected = Vector3.Normalize(incidentNormalSums[vertex.Position]);
AssertVectorNear(expected, vertex.Normal, 1e-6f);
Assert.InRange(vertex.Normal.Length(), 1f - 1e-6f, 1f + 1e-6f);
}
bool differsFromCentralDifferences = false;
for (int x = 0; x < LandblockMesh.HeightmapSide; x++)
{
for (int y = 0; y < LandblockMesh.HeightmapSide; y++)
{
int xL = Math.Max(x - 1, 0);
int xR = Math.Min(x + 1, LandblockMesh.HeightmapSide - 1);
int yD = Math.Max(y - 1, 0);
int yU = Math.Min(y + 1, LandblockMesh.HeightmapSide - 1);
float dx = (HeightAt(block, xR, y) - HeightAt(block, xL, y)) /
((xR - xL) * LandblockMesh.CellSize);
float dy = (HeightAt(block, x, yU) - HeightAt(block, x, yD)) /
((yU - yD) * LandblockMesh.CellSize);
Vector3 oldApproximation = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
Vector3 position = new(
x * LandblockMesh.CellSize,
y * LandblockMesh.CellSize,
HeightAt(block, x, y));
Vector3 actual = mesh.Vertices.First(vertex => vertex.Position == position).Normal;
differsFromCentralDifferences |= Vector3.Distance(oldApproximation, actual) > 1e-4f;
}
}
Assert.True(
differsFromCentralDifferences,
"Synthetic terrain failed to distinguish retail incident-face averaging from central differences.");
}
[Theory]
[InlineData(0u, 0u)]
[InlineData(0xA9u, 0xB4u)]
public void Build_RetailNormalChange_PreservesExactSplitAwarePositionsAndIndices(
uint landblockX,
uint landblockY)
{
var block = BuildFlatLandBlock();
for (int x = 0; x < LandblockMesh.HeightmapSide; x++)
for (int y = 0; y < LandblockMesh.HeightmapSide; y++)
block.Height[x * LandblockMesh.HeightmapSide + y] =
(byte)((x * 17 + y * 29 + x * y * 3) % 80);
var mesh = LandblockMesh.Build(
block,
landblockX,
landblockY,
IdentityHeightTable,
MakeContext(),
new Dictionary<uint, SurfaceInfo>());
Assert.Equal(
Enumerable.Range(0, LandblockMesh.VerticesPerLandblock).Select(i => (uint)i),
mesh.Indices);
int vertexIndex = 0;
for (int cy = 0; cy < LandblockMesh.CellsPerSide; cy++)
{
for (int cx = 0; cx < LandblockMesh.CellsPerSide; cx++)
{
Vector3 bl = PositionAt(block, cx, cy);
Vector3 br = PositionAt(block, cx + 1, cy);
Vector3 tr = PositionAt(block, cx + 1, cy + 1);
Vector3 tl = PositionAt(block, cx, cy + 1);
Vector3[] expected = TerrainBlending.CalculateSplitDirection(
landblockX, (uint)cx, landblockY, (uint)cy) == CellSplitDirection.SWtoNE
? [bl, br, tr, bl, tr, tl]
: [bl, br, tl, br, tr, tl];
foreach (Vector3 position in expected)
Assert.Equal(position, mesh.Vertices[vertexIndex++].Position);
}
}
Assert.Equal(LandblockMesh.VerticesPerLandblock, vertexIndex);
}
private static float HeightAt(LandBlock block, int x, int y) =>
IdentityHeightTable[block.Height[x * LandblockMesh.HeightmapSide + y]];
private static Vector3 PositionAt(LandBlock block, int x, int y) => new(
x * LandblockMesh.CellSize,
y * LandblockMesh.CellSize,
HeightAt(block, x, y));
private static void AddNormal(
IDictionary<Vector3, Vector3> sums,
Vector3 position,
Vector3 normal)
{
sums.TryGetValue(position, out Vector3 sum);
sums[position] = sum + normal;
}
private static void AssertVectorNear(Vector3 expected, Vector3 actual, float epsilon)
{
Assert.InRange(actual.X, expected.X - epsilon, expected.X + epsilon);
Assert.InRange(actual.Y, expected.Y - epsilon, expected.Y + epsilon);
Assert.InRange(actual.Z, expected.Z - epsilon, expected.Z + epsilon);
}
}

View file

@ -1,8 +1,10 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Content;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Types;
using Xunit;
@ -126,6 +128,55 @@ public sealed class SkyDescLoaderTests
Assert.Equal(0x01004C44u, obj.GfxObjId);
Assert.Equal(0x3300042Cu, obj.PesObjectId);
Assert.True(obj.IsPostScene);
Assert.Equal(Vector3.Zero, obj.AuthoredSortCenter);
}
[Fact]
public void LoadFromRegion_WithDatSourceCarriesDefaultAndReplacementSortCenters()
{
const uint defaultId = 0x01001348u;
const uint replacementId = 0x01001F6Au;
Vector3 defaultCenter = new(1050f, 0f, 0f);
Vector3 replacementCenter = new(2066.82f, 552.99f, 0f);
Region region = MakeRegion(dirBright: 1f, rBgrOrder: 255);
DayGroup group = region.SkyInfo!.DayGroups[0];
group.SkyObjects.Add(new SkyObject
{
DefaultGfxObjectId = defaultId,
});
group.SkyTime[0].SkyObjReplace.Add(new SkyObjectReplace
{
ObjectIndex = 0,
GfxObjId = replacementId,
});
var dats = new FakeDatObjectSource();
dats.Add(defaultId, new GfxObj { SortCenter = defaultCenter });
dats.Add(replacementId, new GfxObj { SortCenter = replacementCenter });
LoadedSkyDesc loaded = Assert.IsType<LoadedSkyDesc>(
SkyDescLoader.LoadFromRegion(region, dats));
Assert.Equal(defaultCenter,
Assert.Single(loaded.DayGroups[0].SkyObjects).AuthoredSortCenter);
Assert.Equal(replacementCenter,
Assert.Single(loaded.DayGroups[0].SkyTimes[0].Replaces).AuthoredSortCenter);
}
[Fact]
public void LoadFromRegion_FailedOptionalSortCenterLookupCannotFailSkyLoading()
{
Region region = MakeRegion(dirBright: 1f, rBgrOrder: 255);
region.SkyInfo!.DayGroups[0].SkyObjects.Add(new SkyObject
{
DefaultGfxObjectId = 0x01001348u,
});
LoadedSkyDesc loaded = Assert.IsType<LoadedSkyDesc>(
SkyDescLoader.LoadFromRegion(region, new ThrowingDatObjectSource()));
Assert.Equal(
Vector3.Zero,
Assert.Single(loaded.DayGroups[0].SkyObjects).AuthoredSortCenter);
}
[Fact]
@ -201,4 +252,31 @@ public sealed class SkyDescLoaderTests
// At begin → begin angle.
Assert.Equal(0f, obj.CurrentAngle(0.25f), precision: 2);
}
private sealed class FakeDatObjectSource : IDatObjectSource
{
private readonly Dictionary<uint, IDBObj> _objects = [];
internal void Add(uint id, IDBObj value) => _objects[id] = value;
public T Get<T>(uint fileId) where T : IDBObj =>
_objects.TryGetValue(fileId, out IDBObj? value) && value is T typed
? typed
: default!;
public bool TryGet<T>(uint fileId, out T value) where T : IDBObj
{
value = Get<T>(fileId);
return value is not null;
}
}
private sealed class ThrowingDatObjectSource : IDatObjectSource
{
public T Get<T>(uint fileId) where T : IDBObj =>
throw new InvalidOperationException("synthetic DAT lookup failure");
public bool TryGet<T>(uint fileId, out T value) where T : IDBObj =>
throw new InvalidOperationException("synthetic DAT lookup failure");
}
}