458 lines
17 KiB
C#
458 lines
17 KiB
C#
using System.Reflection;
|
|
using AcDream.App.Composition;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.Tests.Architecture;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Terrain;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Session;
|
|
using DatReaderWriter.DBObjs;
|
|
|
|
namespace AcDream.App.Tests.Streaming;
|
|
|
|
public sealed class LandblockBuildOriginTests
|
|
{
|
|
private const int SpinTimeoutMs = 2000;
|
|
private const int SpinStepMs = 10;
|
|
|
|
[Fact]
|
|
public void ExplicitMapZeroOrigin_IsDistinctFromUnspecifiedCompatibilityOrigin()
|
|
{
|
|
var mapZero = new LandblockBuildOrigin(0, 0);
|
|
|
|
Assert.True(mapZero.IsSpecified);
|
|
Assert.False(default(LandblockBuildOrigin).IsSpecified);
|
|
Assert.NotEqual(default, mapZero);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestAwareLoad_CarriesExplicitMapZeroOriginThroughFactoryAndCompletion()
|
|
{
|
|
const uint landblockId = 0x0000FFFFu;
|
|
var capturedOrigin = new LandblockBuildOrigin(0, 0);
|
|
LandblockBuildRequest? observedRequest = null;
|
|
|
|
using var streamer = LandblockStreamer.CreateForRequests(
|
|
loadLandblock: request =>
|
|
{
|
|
observedRequest = request;
|
|
return EmptyBuild(request.LandblockId, request.Origin);
|
|
},
|
|
buildMeshOrNull: (_, _) => EmptyMesh());
|
|
streamer.EnqueueLoad(new LandblockBuildRequest(
|
|
landblockId,
|
|
LandblockStreamJobKind.LoadNear,
|
|
Generation: 77,
|
|
capturedOrigin));
|
|
streamer.Start();
|
|
|
|
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(
|
|
await DrainFirstAsync(streamer));
|
|
|
|
Assert.Equal(
|
|
new LandblockBuildRequest(
|
|
landblockId,
|
|
LandblockStreamJobKind.LoadNear,
|
|
77,
|
|
capturedOrigin),
|
|
observedRequest);
|
|
Assert.Equal(capturedOrigin, loaded.Build.Origin);
|
|
Assert.Equal(77ul, loaded.Generation);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestAwareLoad_WhenFactoryReturnsUnspecifiedOrigin_FailsAtMapZero()
|
|
{
|
|
using var streamer = LandblockStreamer.CreateForRequests(
|
|
loadLandblock: request => EmptyBuild(request.LandblockId, default));
|
|
streamer.EnqueueLoad(new LandblockBuildRequest(
|
|
0x0000FFFFu,
|
|
LandblockStreamJobKind.LoadNear,
|
|
Generation: 78,
|
|
new LandblockBuildOrigin(0, 0)));
|
|
streamer.Start();
|
|
|
|
var failed = Assert.IsType<LandblockStreamResult.Failed>(
|
|
await DrainFirstAsync(streamer));
|
|
|
|
Assert.Contains("origin", failed.Error, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Equal(78ul, failed.Generation);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompatibilityLoader_RejectsEvenExplicitMapZeroOriginBeforeQueueing()
|
|
{
|
|
using var streamer = new LandblockStreamer(loadLandblock: _ => null);
|
|
|
|
Assert.Throws<InvalidOperationException>(() => streamer.EnqueueLoad(
|
|
new LandblockBuildRequest(
|
|
0x0000FFFFu,
|
|
LandblockStreamJobKind.LoadNear,
|
|
Generation: 79,
|
|
new LandblockBuildOrigin(0, 0))));
|
|
}
|
|
|
|
[Fact]
|
|
public void RequestAwareLoader_RejectsOriginlessCompatibilityEnqueue()
|
|
{
|
|
using var streamer = LandblockStreamer.CreateForRequests(
|
|
loadLandblock: request => EmptyBuild(request.LandblockId, request.Origin));
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
streamer.EnqueueLoad(0xA9B4FFFFu));
|
|
}
|
|
|
|
[Fact]
|
|
public void RequestAwareLoader_RejectsDirectRequestWithUnspecifiedOrigin()
|
|
{
|
|
using var streamer = LandblockStreamer.CreateForRequests(
|
|
loadLandblock: request => EmptyBuild(request.LandblockId, request.Origin));
|
|
|
|
Assert.Throws<InvalidOperationException>(() => streamer.EnqueueLoad(
|
|
new LandblockBuildRequest(
|
|
0xA9B4FFFFu,
|
|
LandblockStreamJobKind.LoadNear,
|
|
Generation: 80,
|
|
Origin: default)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TwoQueuedLoads_RetainTheirDistinctOriginAndGeneration()
|
|
{
|
|
var observed = new List<LandblockBuildRequest>();
|
|
var firstOrigin = new LandblockBuildOrigin(0xA9, 0xB4);
|
|
var secondOrigin = new LandblockBuildOrigin(0x71, 0xEC);
|
|
// workerCount: 1 — the ordered `observed`/completion asserts span two
|
|
// DIFFERENT landblock ids; only the serial degenerate pool (#418)
|
|
// guarantees that global order (per-id order is the pool contract).
|
|
using var streamer = LandblockStreamer.CreateForRequests(
|
|
loadLandblock: request =>
|
|
{
|
|
observed.Add(request);
|
|
return EmptyBuild(request.LandblockId, request.Origin);
|
|
},
|
|
buildMeshOrNull: (_, _) => EmptyMesh(),
|
|
workerCount: 1);
|
|
|
|
streamer.EnqueueLoad(new LandblockBuildRequest(
|
|
0xA9B4FFFFu,
|
|
LandblockStreamJobKind.LoadNear,
|
|
Generation: 80,
|
|
firstOrigin));
|
|
streamer.EnqueueLoad(new LandblockBuildRequest(
|
|
0x71ECFFFFu,
|
|
LandblockStreamJobKind.LoadNear,
|
|
Generation: 81,
|
|
secondOrigin));
|
|
streamer.Start();
|
|
|
|
IReadOnlyList<LandblockStreamResult> completions =
|
|
await DrainCountAsync(streamer, 2);
|
|
|
|
Assert.Equal([firstOrigin, secondOrigin], observed.Select(request => request.Origin));
|
|
Assert.Equal([80ul, 81ul], completions.Select(result => result.Generation));
|
|
Assert.Equal(
|
|
[firstOrigin, secondOrigin],
|
|
completions
|
|
.Cast<LandblockStreamResult.Loaded>()
|
|
.Select(result => result.Build.Origin));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueuedPromotion_SupersedesOldFarWithItsOwnOriginAndGeneration()
|
|
{
|
|
var observed = new List<LandblockBuildRequest>();
|
|
var oldOrigin = new LandblockBuildOrigin(0xA9, 0xB4);
|
|
var newOrigin = new LandblockBuildOrigin(0x71, 0xEC);
|
|
const uint landblockId = 0x71ECFFFFu;
|
|
using var streamer = LandblockStreamer.CreateForRequests(
|
|
loadLandblock: request =>
|
|
{
|
|
observed.Add(request);
|
|
return EmptyBuild(request.LandblockId, request.Origin);
|
|
},
|
|
buildMeshOrNull: (_, _) => EmptyMesh());
|
|
|
|
streamer.EnqueueLoad(new LandblockBuildRequest(
|
|
landblockId,
|
|
LandblockStreamJobKind.LoadFar,
|
|
Generation: 82,
|
|
oldOrigin));
|
|
streamer.EnqueueLoad(new LandblockBuildRequest(
|
|
landblockId,
|
|
LandblockStreamJobKind.PromoteToNear,
|
|
Generation: 83,
|
|
newOrigin));
|
|
streamer.Start();
|
|
|
|
var promoted = Assert.IsType<LandblockStreamResult.Promoted>(
|
|
await DrainFirstAsync(streamer));
|
|
|
|
Assert.Equal([new LandblockBuildRequest(
|
|
landblockId,
|
|
LandblockStreamJobKind.PromoteToNear,
|
|
83,
|
|
newOrigin)], observed);
|
|
Assert.Equal(newOrigin, promoted.Build.Origin);
|
|
Assert.Equal(83ul, promoted.Generation);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FarLoad_StripsEnvCellsAndPhysicsEvenWhenEntityListIsAlreadyEmpty()
|
|
{
|
|
const uint landblockId = 0xA9B4FFFFu;
|
|
var origin = new LandblockBuildOrigin(0xA9, 0xB4);
|
|
var physics = new PhysicsDatBundle(
|
|
new LandBlockInfo(),
|
|
new Dictionary<uint, EnvCell>(),
|
|
new Dictionary<uint, DatReaderWriter.DBObjs.Environment>(),
|
|
new Dictionary<uint, Setup>(),
|
|
new Dictionary<uint, GfxObj>());
|
|
var envCells = new EnvCellLandblockBuild(
|
|
landblockId,
|
|
Array.Empty<AcDream.App.Rendering.LoadedCell>(),
|
|
Array.Empty<EnvCellShellPlacement>());
|
|
using var streamer = LandblockStreamer.CreateForRequests(
|
|
loadLandblock: request => new LandblockBuild(
|
|
new LoadedLandblock(
|
|
request.LandblockId,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>(),
|
|
physics),
|
|
envCells,
|
|
request.Origin),
|
|
buildMeshOrNull: (_, _) => EmptyMesh());
|
|
streamer.EnqueueLoad(new LandblockBuildRequest(
|
|
landblockId,
|
|
LandblockStreamJobKind.LoadFar,
|
|
Generation: 84,
|
|
origin));
|
|
streamer.Start();
|
|
|
|
#if DEBUG
|
|
// The near-payload tripwire is config-divergent by design ("fail loud
|
|
// in Debug builds and strip in Release" — LandblockStreamer.HandleJob):
|
|
// in Debug the Debug.Assert fires and the VSTest host translates it
|
|
// into a thrown DebugAssertException, which the worker's catch folds
|
|
// into a Failed completion (#351).
|
|
var failed = Assert.IsType<LandblockStreamResult.Failed>(
|
|
await DrainFirstAsync(streamer));
|
|
|
|
Assert.Contains(
|
|
"Far-tier factory returned Near payload",
|
|
failed.Error,
|
|
StringComparison.Ordinal);
|
|
Assert.Equal(84ul, failed.Generation);
|
|
#else
|
|
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(
|
|
await DrainFirstAsync(streamer));
|
|
|
|
Assert.Equal(LandblockStreamTier.Far, loaded.Tier);
|
|
Assert.Empty(loaded.Landblock.Entities);
|
|
Assert.Same(PhysicsDatBundle.Empty, loaded.Landblock.PhysicsDats);
|
|
Assert.Null(loaded.Build.EnvCells);
|
|
Assert.Equal(origin, loaded.Build.Origin);
|
|
#endif
|
|
}
|
|
|
|
[Fact]
|
|
public void ProductionCompositionOwnsPublishersWithoutGameWindowFacade()
|
|
{
|
|
MethodInfo[] windowMethods = typeof(GameWindow).GetMethods(
|
|
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
|
|
string[] extractedMethodNames =
|
|
[
|
|
"BuildLandblockForStreaming",
|
|
"BuildSceneryEntitiesForStreaming",
|
|
"BuildInteriorEntitiesForStreaming",
|
|
"BuildPhysicsDatBundle",
|
|
"ApplyLoadedTerrain",
|
|
"PublishLandblockStaticLightingBeforeCollision",
|
|
];
|
|
Assert.DoesNotContain(
|
|
windowMethods,
|
|
method => extractedMethodNames.Contains(method.Name, StringComparer.Ordinal));
|
|
|
|
FieldInfo[] windowFields = typeof(GameWindow).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.Single(
|
|
windowFields,
|
|
field => field.FieldType == typeof(LandblockPresentationPipeline));
|
|
Assert.DoesNotContain(
|
|
windowFields,
|
|
field => field.FieldType == typeof(LandblockRenderPublisher)
|
|
|| field.FieldType == typeof(LandblockPhysicsPublisher)
|
|
|| field.FieldType == typeof(LandblockStaticPresentationPublisher));
|
|
|
|
IReadOnlyList<CompiledCall> windowCalls =
|
|
CompiledCallGraph.ReadDeclared(typeof(GameWindow));
|
|
Assert.DoesNotContain(
|
|
windowCalls,
|
|
call => call.Target.DeclaringType == typeof(LiveWorldOriginState)
|
|
&& call.Target.Name == nameof(LiveWorldOriginState.Recenter));
|
|
|
|
MethodInfo compose = typeof(LivePresentationCompositionPhase).GetMethod(
|
|
"CompletePresentation",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
IReadOnlyList<CompiledCall> composeCalls = CompiledCallGraph.Read(compose);
|
|
int renderPublisher = RequiredCallIndex(
|
|
composeCalls,
|
|
typeof(LandblockRenderPublisher),
|
|
".ctor");
|
|
int physicsPublisher = RequiredCallIndex(
|
|
composeCalls,
|
|
typeof(LandblockPhysicsPublisher),
|
|
".ctor");
|
|
int staticPublisher = RequiredCallIndex(
|
|
composeCalls,
|
|
typeof(LandblockStaticPresentationPublisher),
|
|
".ctor");
|
|
int pipeline = RequiredCallIndex(
|
|
composeCalls,
|
|
typeof(LandblockPresentationPipeline),
|
|
".ctor");
|
|
Assert.True(renderPublisher < physicsPublisher);
|
|
Assert.True(physicsPublisher < staticPublisher);
|
|
Assert.True(staticPublisher < pipeline);
|
|
|
|
// Captured-origin flow itself is behavioral: BuildFar exercises the
|
|
// real LandblockBuildFactory and BeginPublication exercises the real
|
|
// render publisher. This metadata check protects only production
|
|
// ownership and composition, which those fixtures cannot observe.
|
|
}
|
|
|
|
[Fact]
|
|
public void OriginRecenterWaitsForRetirementBeforeOriginAndDestinationCommits()
|
|
{
|
|
MethodInfo advance = typeof(StreamingOriginRecenterCoordinator).GetMethod(
|
|
nameof(StreamingOriginRecenterCoordinator.Advance))!;
|
|
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(advance);
|
|
int retirement = RequiredCallIndex(
|
|
calls,
|
|
typeof(StreamingController),
|
|
"IsOriginRecenterRetirementComplete");
|
|
int origin = RequiredCallIndex(
|
|
calls,
|
|
typeof(LiveWorldOriginState),
|
|
nameof(LiveWorldOriginState.Recenter));
|
|
int destination = RequiredCallIndex(
|
|
calls,
|
|
typeof(StreamingController),
|
|
"TryCommitOriginRecenter");
|
|
|
|
Assert.True(retirement < origin);
|
|
Assert.True(origin < destination);
|
|
}
|
|
|
|
[Fact]
|
|
public void GameWindowShutdownKeepsStreamerAliveUntilSessionResetConverges()
|
|
{
|
|
MethodInfo manifest = typeof(GameWindowShutdownManifest).GetMethod(
|
|
nameof(GameWindowShutdownManifest.Create))!;
|
|
IReadOnlyList<string> labels = CompiledCallGraph.ReadStringLiterals(manifest);
|
|
AssertAppearsInOrder(
|
|
labels,
|
|
"host and session barriers",
|
|
"game runtime session",
|
|
"session dependents",
|
|
"streamer");
|
|
|
|
IReadOnlyList<CompiledCall> references =
|
|
CompiledCallGraph.ReadMethodReferences(manifest);
|
|
int stopSession = RequiredCallIndex(
|
|
references,
|
|
typeof(GameRuntime),
|
|
nameof(GameRuntime.StopSession));
|
|
int streamerDispose = Enumerable.Range(stopSession + 1, references.Count - stopSession - 1)
|
|
.FirstOrDefault(
|
|
index => Calls(
|
|
references[index].Target,
|
|
typeof(LandblockStreamer),
|
|
nameof(LandblockStreamer.Dispose)),
|
|
-1);
|
|
Assert.True(streamerDispose > stopSession, "Missing later streamer-disposal operation.");
|
|
|
|
MethodInfo runtimeStop = typeof(GameRuntime).GetMethod(nameof(GameRuntime.StopSession))!;
|
|
IReadOnlyList<CompiledCall> runtimeCalls = CompiledCallGraph.Read(runtimeStop);
|
|
int sessionDispose = RequiredCallIndex(
|
|
runtimeCalls,
|
|
typeof(LiveSessionController),
|
|
nameof(LiveSessionController.Dispose));
|
|
int completionBarrier = RequiredCallIndex(
|
|
runtimeCalls,
|
|
typeof(LiveSessionController),
|
|
"get_IsDisposalComplete");
|
|
Assert.True(sessionDispose < completionBarrier);
|
|
Assert.Contains(
|
|
"The Runtime session shutdown was deferred by a re-entrant callback.",
|
|
CompiledCallGraph.ReadStringLiterals(runtimeStop));
|
|
}
|
|
|
|
private static LandblockBuild EmptyBuild(uint landblockId, LandblockBuildOrigin origin) =>
|
|
new(
|
|
new LoadedLandblock(
|
|
landblockId,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()),
|
|
Origin: origin);
|
|
|
|
private static LandblockMeshData EmptyMesh() =>
|
|
new(Array.Empty<TerrainVertex>(), Array.Empty<uint>());
|
|
|
|
private static async Task<LandblockStreamResult> DrainFirstAsync(
|
|
LandblockStreamer streamer) =>
|
|
(await DrainCountAsync(streamer, 1))[0];
|
|
|
|
private static async Task<IReadOnlyList<LandblockStreamResult>> DrainCountAsync(
|
|
LandblockStreamer streamer,
|
|
int count)
|
|
{
|
|
var results = new List<LandblockStreamResult>(count);
|
|
for (int i = 0; i < SpinTimeoutMs / SpinStepMs && results.Count < count; i++)
|
|
{
|
|
results.AddRange(streamer.DrainCompletions(count - results.Count));
|
|
if (results.Count < count)
|
|
await Task.Delay(SpinStepMs);
|
|
}
|
|
|
|
Assert.Equal(count, results.Count);
|
|
return results;
|
|
}
|
|
|
|
private static int RequiredCallIndex(
|
|
IReadOnlyList<CompiledCall> calls,
|
|
Type declaringType,
|
|
string methodName)
|
|
{
|
|
int index = CompiledCallGraph.IndexOf(calls, declaringType, methodName);
|
|
Assert.True(index >= 0, $"Missing compiled call: {declaringType.Name}.{methodName}");
|
|
return index;
|
|
}
|
|
|
|
private static bool Calls(
|
|
MethodBase method,
|
|
Type declaringType,
|
|
string methodName) =>
|
|
method.GetMethodBody() is not null
|
|
&& CompiledCallGraph.IndexOf(
|
|
CompiledCallGraph.Read(method),
|
|
declaringType,
|
|
methodName) >= 0;
|
|
|
|
private static void AssertAppearsInOrder(
|
|
IReadOnlyList<string> values,
|
|
params string[] expected)
|
|
{
|
|
int cursor = -1;
|
|
foreach (string value in expected)
|
|
{
|
|
int next = Enumerable.Range(cursor + 1, values.Count - cursor - 1)
|
|
.FirstOrDefault(index => values[index] == value, -1);
|
|
Assert.True(next > cursor, $"Missing or out-of-order metadata string: {value}");
|
|
cursor = next;
|
|
}
|
|
}
|
|
}
|