refactor(app): compose live presentation startup

This commit is contained in:
Erik 2026-07-22 17:55:15 +02:00
parent aa6ffa5176
commit 88f32dc4e2
23 changed files with 1767 additions and 626 deletions

View file

@ -64,6 +64,36 @@ public sealed class AnimationHookRegistrationSetTests
registrations.Register(new Sink("late")));
}
[Fact]
public void OwnedRegistrationReleasesExactlyAndRetriesItsOwnFailure()
{
int adds = 0;
int removes = 0;
bool fail = true;
var sink = new Sink("owned");
var registrations = new AnimationHookRegistrationSet(
_ => adds++,
_ =>
{
removes++;
if (fail)
throw new InvalidOperationException("retry");
});
IDisposable binding = registrations.RegisterOwned(sink);
Assert.Throws<InvalidOperationException>(() =>
registrations.RegisterOwned(sink));
Assert.Throws<InvalidOperationException>(binding.Dispose);
fail = false;
binding.Dispose();
binding.Dispose();
registrations.Dispose();
Assert.Equal(1, adds);
Assert.Equal(2, removes);
Assert.True(registrations.IsCleanupComplete);
}
private sealed class Sink(string name) : IAnimationHookSink
{
public string Name { get; } = name;

View file

@ -0,0 +1,200 @@
using AcDream.App.Composition;
using AcDream.App.Physics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
namespace AcDream.App.Tests.Composition;
public sealed class LivePresentationCompositionTests
{
[Fact]
public void RuntimeBindingsReleaseInReverseAndRetryOnlyFailedEdges()
{
var calls = new List<string>();
var bindings = new LivePresentationRuntimeBindings();
var first = new RetryBinding("first", calls, failures: 0);
var second = new RetryBinding("second", calls, failures: 1);
bindings.Adopt("first", first);
bindings.Adopt("second", second);
Assert.Throws<AggregateException>(bindings.Dispose);
Assert.Equal(["second", "first"], calls);
bindings.Dispose();
bindings.Dispose();
Assert.Equal(["second", "first", "second"], calls);
Assert.Throws<ObjectDisposedException>(() =>
bindings.Adopt("late", new RetryBinding("late", calls, 0)));
}
[Fact]
public void CanonicalRuntimeSlotUsesExactOwnerBinding()
{
var slot = new LiveEntityRuntimeSlot();
LiveEntityRuntime first = Runtime();
LiveEntityRuntime second = Runtime();
IDisposable stale = slot.BindOwned(first);
Assert.Same(first, slot.Current);
Assert.Throws<InvalidOperationException>(() => slot.BindOwned(second));
stale.Dispose();
IDisposable current = slot.BindOwned(second);
stale.Dispose();
Assert.Same(second, slot.Current);
current.Dispose();
Assert.Null(slot.Current);
}
[Fact]
public void MotionBindingCannotBeClearedByAStaleOwner()
{
var source = new DeferredLiveEntityMotionRuntimeBindings();
var first = new MotionRuntime(1f);
var second = new MotionRuntime(2f);
var entity = new WorldEntity
{
Id = 1u,
SourceGfxObjOrSetupId = 1u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
IDisposable stale = source.BindOwned(first);
Assert.Equal(1f, source.GetSetupCylinder(1u, entity).Radius);
Assert.Throws<InvalidOperationException>(() => source.BindOwned(second));
stale.Dispose();
IDisposable current = source.BindOwned(second);
stale.Dispose();
Assert.Equal(2f, source.GetSetupCylinder(1u, entity).Radius);
current.Dispose();
Assert.Throws<InvalidOperationException>(() =>
source.GetSetupCylinder(1u, entity));
}
[Fact]
public void LandblockLoadedBridgeIsInertUntilBoundAndDeactivationIsTerminal()
{
var source = new DeferredLiveEntityLandblockLoadedSink();
var first = new LandblockSink();
var second = new LandblockSink();
source.OnLandblockLoaded(1u);
IDisposable stale = source.Bind(first);
source.OnLandblockLoaded(2u);
Assert.Equal([2u], first.Landblocks);
Assert.Throws<InvalidOperationException>(() => source.Bind(second));
stale.Dispose();
IDisposable current = source.Bind(second);
stale.Dispose();
source.OnLandblockLoaded(3u);
Assert.Equal([3u], second.Landblocks);
source.Deactivate();
current.Dispose();
source.OnLandblockLoaded(4u);
Assert.Equal([3u], second.Landblocks);
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
}
[Fact]
public void GameWindowUsesLivePhaseAndContainsNoPhaseSixConstructionBody()
{
string root = FindRepoRoot();
string window = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
string phase = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
Assert.Contains("new LivePresentationCompositionPhase(", window);
Assert.DoesNotContain("new AcDream.App.World.LiveEntityRuntime(", window);
Assert.DoesNotContain("new AcDream.App.Rendering.Wb.WbDrawDispatcher(", window);
Assert.DoesNotContain("new AcDream.App.Streaming.LandblockRenderPublisher(", window);
Assert.DoesNotContain("_portalTunnelFallback.AcquirePrepared(", window);
Assert.Contains("new LiveEntityRuntime(", phase);
Assert.Contains("new WbDrawDispatcher(", phase);
Assert.Contains("new LandblockRenderPublisher(", phase);
Assert.Contains("d.PortalTunnelFallback.AcquirePrepared(", phase);
}
private static LiveEntityRuntime Runtime() => new(
new GpuWorldState(),
new DelegateLiveEntityResourceLifecycle(static _ => { }, static _ => { }));
private sealed class MotionRuntime(float radius)
: ILiveEntityMotionRuntimeBindings
{
public (float Radius, float Height) GetSetupCylinder(
uint serverGuid,
WorldEntity entity) => (radius, 1f);
public bool RouteServerMoveTo(
MovementManager movement,
uint cellId,
WorldSession.EntityMotionUpdate update) => false;
public void StickToObjectFromWire(
IPhysicsObjHost? host,
uint targetGuid)
{
}
public void ClearTargetForHiddenEntity(uint serverGuid)
{
}
public IPhysicsObjHost? ResolvePhysicsHost(
uint serverGuid) => null;
}
private sealed class LandblockSink : ILiveEntityLandblockLoadedSink
{
public List<uint> Landblocks { get; } = [];
public void OnLandblockLoaded(uint landblockId) => Landblocks.Add(landblockId);
}
private sealed class RetryBinding(
string name,
List<string> calls,
int failures) : IDisposable
{
private int _failures = failures;
public void Dispose()
{
calls.Add(name);
if (_failures > 0)
{
_failures--;
throw new InvalidOperationException("retry");
}
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -93,7 +93,7 @@ public sealed class GameWindowRenderLeafCompositionTests
foreach (string identifier in removed)
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source);
Assert.Contains("new PaperdollFramePresenter(", LivePresentationSource());
string settingsComposition = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
@ -193,13 +193,13 @@ public sealed class GameWindowRenderLeafCompositionTests
[Fact]
public void PaperdollComposition_SkipsEitherMissingOptionalUiSurface()
{
string source = GameWindowSource();
string source = LivePresentationSource();
AssertAppearsInOrder(
source,
"PaperdollViewportWidget is { } paperdollViewport",
"InventoryFrame is { } paperdollInventoryFrame",
"new AcDream.App.Rendering.PaperdollFramePresenter(");
"PaperdollViewportWidget is { } viewport",
"InventoryFrame is { } inventoryFrame",
"new PaperdollFramePresenter(");
Assert.DoesNotContain("Paperdoll inventory frame is required.", source);
}
@ -223,6 +223,13 @@ public sealed class GameWindowRenderLeafCompositionTests
"Rendering",
"GameWindow.cs"));
private static string LivePresentationSource() => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
private static string Source(string fileName) => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",

View file

@ -61,11 +61,9 @@ public sealed class GameWindowSlice8BoundaryTests
"InteractionRetainedUiResult interactionUi =",
"new InteractionRetainedUiCompositionPhase(",
"this).Compose(",
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
"_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(",
"_retainedUiGameplayBinding =",
"AcDream.App.Input.RetainedUiGameplayBinding.Create(",
"_retainedUiGameplayBinding.Attach();",
"LivePresentationResult livePresentation =",
"new LivePresentationCompositionPhase(",
"this).Compose(",
"var renderFrameOrchestrator =",
"var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
"_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);",
@ -193,11 +191,11 @@ public sealed class GameWindowSlice8BoundaryTests
"AcDream.App.Input.GameplayInputActionRouter.Create("));
Assert.Equal(1, CountOccurrences(load, "_gameplayInputActions.Attach();"));
Assert.Equal(1, CountOccurrences(
load,
"AcDream.App.Input.RetainedUiGameplayBinding.Create("));
LivePresentationSource(),
"RetainedUiGameplayBinding.Create("));
Assert.Equal(1, CountOccurrences(
load,
"_retainedUiGameplayBinding.Attach();"));
LivePresentationSource(),
"retainedGameplayLease.Resource.Attach();"));
AssertAppearsInOrder(
shutdown,
"_liveCombatModeCommands.Deactivate",
@ -377,10 +375,9 @@ public sealed class GameWindowSlice8BoundaryTests
source,
"private ResourceShutdownTransaction CreateShutdownTransaction()",
"private void OnFocusChanged(bool focused)");
string dispose = Slice(
source,
"public void Dispose()",
"private sealed class NullAnimLoader");
int disposeStart = source.IndexOf("public void Dispose()", StringComparison.Ordinal);
Assert.True(disposeStart >= 0);
string dispose = source[disposeStart..];
string[] stages =
[
@ -474,14 +471,18 @@ public sealed class GameWindowSlice8BoundaryTests
"AcDream.App",
"Composition",
"WorldRenderComposition.cs"));
string livePhase = LivePresentationSource();
AssertAppearsInOrder(
livePhase,
"d.PortalTunnelFallback.AcquirePrepared(",
"static tunnel => tunnel.PrepareResources());",
"d.RenderResourceLifetime.AcquireSkyShader(",
"() => new SkyRenderer(");
AssertAppearsInOrder(
load,
"new WorldRenderCompositionPhase(",
"_portalTunnelFallback.AcquirePrepared(",
"static portalTunnel => portalTunnel.PrepareResources());",
"_renderResourceLifetime.AcquireSkyShader(",
"_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(",
"new LivePresentationCompositionPhase(",
"_portalTunnelFallback.Transfer(",
"_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);");
AssertAppearsInOrder(
@ -566,6 +567,13 @@ public sealed class GameWindowSlice8BoundaryTests
"Rendering",
"GameWindow.cs"));
private static string LivePresentationSource() => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);

View file

@ -241,6 +241,12 @@ public sealed class LandblockBuildOriginTests
"AcDream.App",
"Rendering",
"GameWindow.cs"));
string livePresentationSource = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
string buildSource = File.ReadAllText(Path.Combine(
root,
"src",
@ -307,16 +313,16 @@ public sealed class LandblockBuildOriginTests
gameWindowSource,
StringComparison.Ordinal);
Assert.Contains(
"new AcDream.App.Streaming.LandblockRenderPublisher(",
gameWindowSource,
"new LandblockRenderPublisher(",
livePresentationSource,
StringComparison.Ordinal);
Assert.Contains(
"new AcDream.App.Streaming.LandblockPhysicsPublisher(",
gameWindowSource,
"new LandblockPhysicsPublisher(",
livePresentationSource,
StringComparison.Ordinal);
Assert.Contains(
"new AcDream.App.Streaming.LandblockStaticPresentationPublisher(",
gameWindowSource,
"new LandblockStaticPresentationPublisher(",
livePresentationSource,
StringComparison.Ordinal);
Assert.DoesNotContain("applyTerrain:", gameWindowSource, StringComparison.Ordinal);
Assert.DoesNotContain("demoteNearLayer:", gameWindowSource, StringComparison.Ordinal);

View file

@ -158,6 +158,12 @@ public sealed class GameWindowLiveEntityCompositionTests
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs"));
string livePresentation = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
Assert.DoesNotContain(
"new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(() =>",
source,
@ -166,7 +172,7 @@ public sealed class GameWindowLiveEntityCompositionTests
"new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() =>",
source,
StringComparison.Ordinal);
Assert.Contains("new AcDream.App.Physics.DatProjectileSetupResolver", source);
Assert.Contains("new DatProjectileSetupResolver", livePresentation);
}
[Fact]

View file

@ -337,6 +337,12 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App",
"Rendering",
"GameWindow.cs"));
string livePresentation = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
Assert.Contains("new AcDream.App.Update.LiveObjectFrameController(", source);
Assert.Contains("new AcDream.App.Update.LiveSpatialPresentationReconciler(", source);
@ -771,6 +777,12 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App",
"Rendering",
"GameWindow.cs"));
string livePresentation = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
Assert.DoesNotContain("PublishLocalPhysicsTimestamps", source,
StringComparison.Ordinal);
Assert.DoesNotContain("LoginWorldReady", source,
@ -783,9 +795,9 @@ public sealed class UpdateFrameOrchestratorTests
StringComparison.Ordinal);
Assert.DoesNotContain("OnPlayScriptReceived", source,
StringComparison.Ordinal);
Assert.Contains("_liveWorldOrigin.GetCenter", source,
Assert.Contains("d.WorldOrigin.GetCenter", livePresentation,
StringComparison.Ordinal);
Assert.Contains("_liveWorldOrigin.CellLocalForSeed", source,
Assert.Contains("d.WorldOrigin.CellLocalForSeed", livePresentation,
StringComparison.Ordinal);
Assert.Contains("_liveEntitySessionEvents.CreateSink()", source,
StringComparison.Ordinal);