refactor(runtime): extract the live object frame

This commit is contained in:
Erik 2026-07-22 00:42:26 +02:00
parent 99a3e819c4
commit 4e4aac2c5a
27 changed files with 1217 additions and 371 deletions

View file

@ -0,0 +1,27 @@
using AcDream.App.Update;
namespace AcDream.App.Tests;
internal sealed class TestLiveObjectFramePhase(Action<float> tick)
: ILiveObjectFramePhase
{
public void Tick(float deltaSeconds) => tick(deltaSeconds);
}
internal sealed class TestLiveSessionFramePhase(Action tick)
: ILiveSessionFramePhase
{
public void Tick() => tick();
}
internal sealed class TestPostNetworkCommandFramePhase(Action run)
: IPostNetworkCommandFramePhase
{
public void RunPostNetworkCommandPhase() => run();
}
internal sealed class TestLiveSpatialReconcilePhase(Action reconcile)
: ILiveSpatialReconcilePhase
{
public void Reconcile() => reconcile();
}

View file

@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Physics;
@ -33,22 +34,23 @@ public sealed class RetailLocalPlayerFrameControllerTests
postNetwork.Add(owner.State);
});
var live = new RetailLiveFrameCoordinator(
dt =>
new TestLiveObjectFramePhase(dt =>
{
calls.Add("objects");
local.AdvanceBeforeNetwork(dt);
},
() =>
}),
new GpuWorldState(),
new TestLiveSessionFramePhase(() =>
{
calls.Add("f751");
controller.State = PlayerState.PortalSpace;
},
() =>
}),
new TestPostNetworkCommandFramePhase(() =>
{
calls.Add("commands");
local.RunPostNetworkCommandPhase();
},
() => calls.Add("spatial"));
}),
new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")));
// CPhysicsObj::update_object admits manager time only after the strict
// minimum quantum. Use one admitted object tick; the test's concern is
@ -118,10 +120,12 @@ public sealed class RetailLocalPlayerFrameControllerTests
sendPreNetwork: (_, _, _) => { },
sendPostNetwork: (_, _) => { });
var live = new RetailLiveFrameCoordinator(
local.AdvanceBeforeNetwork,
() => projectedRoot = new Vector3(999f, 999f, 999f),
local.RunPostNetworkCommandPhase,
() => { });
new TestLiveObjectFramePhase(local.AdvanceBeforeNetwork),
new GpuWorldState(),
new TestLiveSessionFramePhase(
() => projectedRoot = new Vector3(999f, 999f, 999f)),
local,
new TestLiveSpatialReconcilePhase(() => { }));
live.Tick(1f / 60f);
float timeAfterOneTick = controller.SimTimeSeconds;

View file

@ -1430,6 +1430,24 @@ public sealed class ProjectileControllerTests
private sealed class Fixture
{
private sealed class SetupResolver : IProjectileSetupResolver
{
private readonly Func<Setup?> _resolve;
internal SetupResolver(Func<Setup?> resolve) => _resolve = resolve;
public Setup? Resolve(uint setupId) => _resolve();
}
private sealed class RootPosePublisher : IEntityRootPosePublisher
{
private readonly Action<WorldEntity> _publish;
internal RootPosePublisher(Action<WorldEntity> publish) => _publish = publish;
public void UpdateRoot(WorldEntity entity) => _publish(entity);
}
internal Fixture(
bool loadInitialLandblock = true,
PhysicsEngine? physicsEngine = null,
@ -1439,12 +1457,15 @@ public sealed class ProjectileControllerTests
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
Live = new LiveEntityRuntime(Spatial, Resources);
Engine = physicsEngine ?? new PhysicsEngine();
Origin.Recenter(1, 1);
Controller = new ProjectileController(
Live,
Engine,
_ => ResolvedSetup,
publishRootPose ?? (entity => Poses.UpdateRoot(entity)),
() => LiveCenter);
new SetupResolver(() => ResolvedSetup),
new RootPosePublisher(
publishRootPose ?? new Action<WorldEntity>(
entity => { _ = Poses.UpdateRoot(entity); })),
Origin);
}
internal GpuWorldState Spatial { get; } = new();
@ -1452,8 +1473,8 @@ public sealed class ProjectileControllerTests
internal LiveEntityRuntime Live { get; }
internal PhysicsEngine Engine { get; }
internal EntityEffectPoseRegistry Poses { get; } = new();
internal LiveWorldOriginState Origin { get; } = new();
internal Setup ResolvedSetup { get; set; } = ProjectileSetup();
internal (int X, int Y) LiveCenter { get; set; } = (1, 1);
internal ProjectileController Controller { get; }
internal LiveEntityRecord Spawn(

View file

@ -364,13 +364,29 @@ public sealed class LiveEntityAnimationPresenterTests
LiveEntityRuntime live,
EntityEffectPoseRegistry poses,
Context context) => new(
() => live,
() => null,
live,
new EmptyStaticPartFrameSource(),
poses,
context,
AnimationPresentationDiagnostics.Disabled,
hidePartIndex: -1);
private sealed class EmptyStaticPartFrameSource : ILiveStaticPartFrameSource
{
public bool TryTakeLivePartFrames(
LiveEntityRecord record,
WorldEntity entity,
LiveEntityAnimationState animation,
ulong objectClockEpoch,
ulong projectionMutationVersion,
ulong presentationRevision,
out IReadOnlyList<PartTransform> partFrames)
{
partFrames = Array.Empty<PartTransform>();
return false;
}
}
private static IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> Schedules(
Fixture fixture,
IReadOnlyList<PartTransform> frames) =>

View file

@ -1,12 +1,15 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Core.Vfx;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
@ -629,14 +632,42 @@ public sealed class LiveEntityAnimationSchedulerTests
var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater(
physics,
(_, _) => (0.48f, 1.835f));
var identity = new LocalPlayerIdentityState { ServerGuid = localPlayerGuid };
var poses = new EntityEffectPoseRegistry();
foreach (WorldEntity entity in live.MaterializedWorldEntities.Values)
poses.PublishMeshRefs(entity);
IAnimationHookCaptureSink animationHooks = captureHooks is null
? new AnimationHookCaptureSink(new AnimationHookFrameQueue(
new AnimationHookRouter(),
poses))
: new TestAnimationHookCaptureSink(captureHooks);
projectiles ??= new ProjectileController(live, physics);
return new LiveEntityAnimationScheduler(
() => live,
() => localPlayerGuid,
live,
identity,
remotePhysics,
ordinaryPhysics,
() => projectiles,
publishRootPose ?? (_ => { }),
captureHooks ?? ((_, _) => { }));
projectiles,
new TestRootPosePublisher(poses, publishRootPose),
animationHooks);
}
private sealed class TestAnimationHookCaptureSink(
Action<uint, AnimationSequencer> capture) : IAnimationHookCaptureSink
{
public void Capture(uint ownerLocalId, AnimationSequencer sequencer) =>
capture(ownerLocalId, sequencer);
}
private sealed class TestRootPosePublisher(
EntityEffectPoseRegistry poses,
Action<WorldEntity>? published) : IEntityRootPosePublisher
{
public void UpdateRoot(WorldEntity entity)
{
poses.UpdateRoot(entity);
published?.Invoke(entity);
}
}
private static (LiveEntityRuntime Live, LiveEntityRecord Record,

View file

@ -47,6 +47,10 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData("DispatchRemoteInboundMotion")]
[InlineData("CreateRemoteMotion")]
[InlineData("WillAdvanceRemoteMotion")]
[InlineData("AdvanceLiveObjectRuntime")]
[InlineData("AdvanceLiveObjectRuntimeCore")]
[InlineData("ReconcileLiveObjectSpatialPresentation")]
[InlineData("CaptureAnimationHooks")]
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
{
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
@ -73,6 +77,13 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData(typeof(LiveEntityMotionRuntimeController))]
[InlineData(typeof(LiveEntityInboundAuthorityGate))]
[InlineData(typeof(DeferredLiveEntityMotionRuntimeBindings))]
[InlineData(typeof(AcDream.App.Update.LiveObjectFrameController))]
[InlineData(typeof(AcDream.App.Update.LiveEffectFrameController))]
[InlineData(typeof(AcDream.App.Update.LiveSpatialPresentationReconciler))]
[InlineData(typeof(LiveEntityAnimationPresenter))]
[InlineData(typeof(LiveAnimationPresentationContext))]
[InlineData(typeof(StaticLiveRootCommitter))]
[InlineData(typeof(RetailLiveFrameCoordinator))]
public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
{
foreach (FieldInfo field in helperType.GetFields(
@ -83,6 +94,89 @@ public sealed class GameWindowLiveEntityCompositionTests
string typeName = field.FieldType.FullName ?? field.FieldType.Name;
Assert.DoesNotContain("Silk.NET", typeName, StringComparison.Ordinal);
Assert.DoesNotContain("OpenGL", typeName, StringComparison.OrdinalIgnoreCase);
Assert.NotEqual(typeof(GameWindow), field.FieldType);
}
}
[Fact]
public void ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks()
{
Type[] owners =
[
typeof(AcDream.App.Update.LiveObjectFrameController),
typeof(AcDream.App.Update.LiveEffectFrameController),
typeof(AcDream.App.Update.LiveSpatialPresentationReconciler),
typeof(LiveEntityAnimationScheduler),
typeof(LiveEntityAnimationPresenter),
typeof(LiveAnimationPresentationContext),
typeof(StaticLiveRootCommitter),
typeof(RetailLiveFrameCoordinator),
];
foreach (Type owner in owners)
{
Assert.DoesNotContain(
owner.GetFields(BindingFlags.Instance | BindingFlags.NonPublic),
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
}
Assert.False(typeof(ILiveAnimationPresentationContext).IsAssignableFrom(
typeof(GameWindow)));
}
[Fact]
public void RuntimeViewsAndProjectileController_RetainTypedSourcesNotWindowClosures()
{
FieldInfo animationRuntime = Assert.Single(
typeof(LiveEntityAnimationRuntimeView<LiveEntityAnimationState>)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic),
field => field.Name == "_runtime");
FieldInfo remoteRuntime = Assert.Single(
typeof(LiveEntityRemoteMotionRuntimeView<RemoteMotion>)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic),
field => field.Name == "_runtime");
Assert.Equal(typeof(ILiveEntityRuntimeSource), animationRuntime.FieldType);
Assert.Equal(typeof(ILiveEntityRuntimeSource), remoteRuntime.FieldType);
FieldInfo[] projectileFields = typeof(ProjectileController).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.Equal(
typeof(IProjectileSetupResolver),
Assert.Single(projectileFields, field => field.Name == "_setupResolver").FieldType);
Assert.Equal(
typeof(IEntityRootPosePublisher),
Assert.Single(projectileFields, field => field.Name == "_rootPoses").FieldType);
Assert.Equal(
typeof(LiveWorldOriginState),
Assert.Single(projectileFields, field => field.Name == "_origin").FieldType);
Assert.DoesNotContain(
projectileFields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType)
&& !field.Name.Contains("DiagnosticSink", StringComparison.Ordinal));
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs"));
Assert.DoesNotContain(
"new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(() =>",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() =>",
source,
StringComparison.Ordinal);
Assert.Contains("new AcDream.App.Physics.DatProjectileSetupResolver", source);
}
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

@ -202,10 +202,7 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests
lifecycle);
Projectiles = new ProjectileController(
Live,
Physics,
_ => null,
_ => { },
() => (0, 0));
Physics);
Controller = new LiveEntityProjectionWithdrawalController(
Live,
Projectiles,

View file

@ -17,6 +17,20 @@ namespace AcDream.App.Tests.World;
public sealed class LiveEntityRuntimeTests
{
[Fact]
public void RuntimeSlot_BindsExactlyOnceWithoutOwningEntityState()
{
var spatial = new GpuWorldState();
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
var slot = new LiveEntityRuntimeSlot();
Assert.Null(slot.Current);
slot.Bind(runtime);
Assert.Same(runtime, slot.Current);
Assert.Throws<InvalidOperationException>(() => slot.Bind(runtime));
}
private sealed class RecordingResources : ILiveEntityResourceLifecycle
{
public int RegisterCount { get; private set; }
@ -245,11 +259,18 @@ public sealed class LiveEntityRuntimeTests
landblockId: 0x01010000u,
seedCellId: 0x01010001u);
int poseUpdates = 0;
var runtimeSlot = new LiveEntityRuntimeSlot();
runtimeSlot.Bind(runtime);
var origin = new LiveWorldOriginState();
origin.Recenter(1, 1);
var poses = new EntityEffectPoseRegistry();
poses.PublishMeshRefs(entity);
poses.EffectPoseChanged += _ => poseUpdates++;
var committer = new StaticLiveRootCommitter(
() => runtime,
runtimeSlot,
shadows,
() => (1, 1),
_ => poseUpdates++);
origin,
poses);
body.SetFrameInCurrentCell(
body.Position,
@ -282,13 +303,14 @@ public sealed class LiveEntityRuntimeTests
[Fact]
public void StaticRootCommit_BeforeLiveRuntimeCompositionIsSafeNoOp()
{
LiveEntityRuntime? runtime = null;
int poseUpdates = 0;
var poses = new EntityEffectPoseRegistry();
poses.EffectPoseChanged += _ => poseUpdates++;
var committer = new StaticLiveRootCommitter(
() => runtime,
new LiveEntityRuntimeSlot(),
new ShadowObjectRegistry(),
() => (0, 0),
_ => poseUpdates++);
new LiveWorldOriginState(),
poses);
Assert.False(committer.Commit(
Entity(0x7F000001u, 0x70000001u),
@ -347,26 +369,33 @@ public sealed class LiveEntityRuntimeTests
worldOffsetY: 0f,
landblockId: 0x01010000u,
seedCellId: 0x01010001u);
var runtimeSlot = new LiveEntityRuntimeSlot();
runtimeSlot.Bind(runtime);
var origin = new LiveWorldOriginState();
origin.Recenter(1, 1);
var poses = new EntityEffectPoseRegistry();
poses.PublishMeshRefs(oldEntity);
poses.EffectPoseChanged += _ =>
{
Assert.True(shadows.Suspend(oldEntity.Id));
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
runtime.RegisterLiveEntity(Spawn(
guid,
instance: 2,
positionSequence: 1,
cell: 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
};
var committer = new StaticLiveRootCommitter(
() => runtime,
runtimeSlot,
shadows,
() => (1, 1),
_ =>
{
Assert.True(shadows.Suspend(oldEntity.Id));
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
runtime.RegisterLiveEntity(Spawn(
guid,
instance: 2,
positionSequence: 1,
cell: 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
});
origin,
poses);
oldBody.SetFrameInCurrentCell(
oldBody.Position,
@ -453,7 +482,7 @@ public sealed class LiveEntityRuntimeTests
id => Entity(id, guid))!;
var animation = new AnimationRuntime(entity);
runtime.SetAnimationRuntime(guid, animation);
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(() => runtime);
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(BoundSlot(runtime));
Assert.Equal(1, view.Count);
Assert.Equal(entity.Id, Assert.Single(view.Keys));
@ -489,7 +518,7 @@ public sealed class LiveEntityRuntimeTests
id => Entity(id, guid))!;
runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity));
}
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(() => runtime);
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(BoundSlot(runtime));
var visited = new List<uint>();
foreach (KeyValuePair<uint, AnimationRuntime> pair in view)
@ -519,7 +548,7 @@ public sealed class LiveEntityRuntimeTests
id => Entity(id, guid))!;
runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity));
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(
() => runtime);
BoundSlot(runtime));
var ids = new HashSet<uint>();
view.CopySpatialIdsTo(ids);
@ -2803,6 +2832,13 @@ public sealed class LiveEntityRuntimeTests
private static LoadedLandblock EmptyLandblock(uint canonicalId) =>
new(canonicalId, new LandBlock(), Array.Empty<WorldEntity>());
private static LiveEntityRuntimeSlot BoundSlot(LiveEntityRuntime runtime)
{
var slot = new LiveEntityRuntimeSlot();
slot.Bind(runtime);
return slot;
}
private static void ResolveParent(LiveEntityRuntime runtime, uint childGuid) =>
runtime.ParentAttachments.Resolve(
childGuid,

View file

@ -0,0 +1,84 @@
using AcDream.App.Rendering.Vfx;
using AcDream.App.Update;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings;
using Silk.NET.Input;
namespace AcDream.App.Tests.World;
public sealed class LiveObjectFrameControllerTests
{
[Theory]
[InlineData(ParticleRange.Retail, 1f)]
[InlineData(
ParticleRange.Extended,
ParticleVisibilityController.ExtendedRangeMultiplier)]
public void ParticleRange_NullSettingsUsesConfiguredFallback(
ParticleRange fallback,
float expected)
{
var source = new SettingsParticleRangeSource(null, fallback);
Assert.Equal(expected, source.RangeMultiplier);
}
[Fact]
public void ParticleRange_LiveSettingsDraftOverridesFallbackAndUpdatesImmediately()
{
SettingsVM settings = CreateSettings();
var source = new SettingsParticleRangeSource(settings, ParticleRange.Retail);
settings.SetDisplay(settings.DisplayDraft with { ParticleRange = ParticleRange.Retail });
Assert.Equal(1f, source.RangeMultiplier);
settings.SetDisplay(settings.DisplayDraft with { ParticleRange = ParticleRange.Extended });
Assert.Equal(
ParticleVisibilityController.ExtendedRangeMultiplier,
source.RangeMultiplier);
}
private static SettingsVM CreateSettings()
{
var dispatcher = new InputDispatcher(
new NullKeyboardSource(),
new NullMouseSource(),
new KeyBindings());
return new SettingsVM(
new KeyBindings(),
dispatcher,
static _ => { },
DisplaySettings.Default,
static _ => { },
AudioSettings.Default,
static _ => { },
GameplaySettings.Default,
static _ => { },
ChatSettings.Default,
static _ => { },
CharacterSettings.Default,
static _ => { });
}
private sealed class NullKeyboardSource : IKeyboardSource
{
#pragma warning disable CS0067
public event Action<Key, ModifierMask>? KeyDown;
public event Action<Key, ModifierMask>? KeyUp;
#pragma warning restore CS0067
public bool IsHeld(Key key) => false;
public ModifierMask CurrentModifiers => ModifierMask.None;
}
private sealed class NullMouseSource : IMouseSource
{
#pragma warning disable CS0067
public event Action<MouseButton, ModifierMask>? MouseDown;
public event Action<MouseButton, ModifierMask>? MouseUp;
public event Action<float, float>? MouseMove;
public event Action<float>? Scroll;
#pragma warning restore CS0067
public bool IsHeld(MouseButton button) => false;
public bool WantCaptureMouse => false;
public bool WantCaptureKeyboard => false;
}
}

View file

@ -1,9 +1,11 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Streaming;
using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Content.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
@ -25,10 +27,11 @@ public sealed class RecallTeleportAnimationTests
{
var calls = new List<string>();
var frame = new RetailLiveFrameCoordinator(
_ => calls.Add("objects"),
() => calls.Add("network"),
() => calls.Add("commands"),
() => calls.Add("spatial"));
new TestLiveObjectFramePhase(_ => calls.Add("objects")),
new GpuWorldState(),
new TestLiveSessionFramePhase(() => calls.Add("network")),
new TestPostNetworkCommandFramePhase(() => calls.Add("commands")),
new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")));
frame.Tick(1f / 60f);
@ -39,25 +42,96 @@ public sealed class RecallTeleportAnimationTests
public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch()
{
var deltas = new List<float>();
int networkDispatches = 0;
var calls = new List<string>();
var frame = new RetailLiveFrameCoordinator(
deltas.Add,
() => networkDispatches++,
() => { },
() => { });
new TestLiveObjectFramePhase(delta =>
{
deltas.Add(delta);
calls.Add("objects");
}),
new GpuWorldState(),
new TestLiveSessionFramePhase(() => calls.Add("network")),
new TestPostNetworkCommandFramePhase(() => calls.Add("commands")),
new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")));
frame.Tick(float.NaN);
frame.Tick(float.PositiveInfinity);
frame.Tick(-1f);
Assert.Equal([0f, 0f, 0f], deltas);
Assert.Equal(3, networkDispatches);
Assert.Equal(
Enumerable.Repeat(new[] { "objects", "network", "commands", "spatial" }, 3)
.SelectMany(static frameCalls => frameCalls),
calls);
Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NaN));
Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NegativeInfinity));
Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.PositiveInfinity));
Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.MaxValue));
}
[Fact]
public void LiveFrame_CommitsTheInboundMutationBatchBeforeCommandsAndReconcile()
{
const uint landblock = 0x0101FFFFu;
var calls = new List<string>();
var world = new GpuWorldState();
long before = world.VisibilityCommitCount;
var frame = new RetailLiveFrameCoordinator(
new TestLiveObjectFramePhase(_ => calls.Add("objects")),
world,
new TestLiveSessionFramePhase(() =>
{
calls.Add("network");
world.PlaceLiveEntityProjection(landblock, Entity(1));
Assert.Equal(before, world.VisibilityCommitCount);
}),
new TestPostNetworkCommandFramePhase(() =>
{
calls.Add("commands");
Assert.Equal(before + 1, world.VisibilityCommitCount);
}),
new TestLiveSpatialReconcilePhase(() =>
{
calls.Add("spatial");
Assert.Equal(before + 1, world.VisibilityCommitCount);
}));
frame.Tick(1f / 60f);
Assert.Equal(["objects", "network", "commands", "spatial"], calls);
Assert.Equal(before + 1, world.VisibilityCommitCount);
Assert.Equal(1, world.PendingLiveEntityCount);
}
[Fact]
public void LiveFrame_SessionFailureDisposesMutationBatchAndSuppressesLaterPhases()
{
const uint landblock = 0x0101FFFFu;
var calls = new List<string>();
var world = new GpuWorldState();
long before = world.VisibilityCommitCount;
var frame = new RetailLiveFrameCoordinator(
new TestLiveObjectFramePhase(_ => calls.Add("objects")),
world,
new TestLiveSessionFramePhase(() =>
{
calls.Add("network");
world.PlaceLiveEntityProjection(landblock, Entity(2));
Assert.Equal(before, world.VisibilityCommitCount);
throw new InvalidOperationException("inbound failure");
}),
new TestPostNetworkCommandFramePhase(() => calls.Add("commands")),
new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")));
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
() => frame.Tick(1f / 60f));
Assert.Equal("inbound failure", error.Message);
Assert.Equal(["objects", "network"], calls);
Assert.Equal(before + 1, world.VisibilityCommitCount);
Assert.Equal(1, world.PendingLiveEntityCount);
}
[Fact]
public void InstalledRecall_HiddenEnterWorldPublishesFinishedCyclicPoseWithoutAdvancingTime()
{
@ -108,4 +182,14 @@ public sealed class RecallTeleportAnimationTests
Quaternion.Normalize(recallTail[index].Orientation),
Quaternion.Normalize(finishedPose[index].Orientation))) < 0.999999f);
}
private static WorldEntity Entity(uint id) => new()
{
Id = id,
ServerGuid = 0x70000000u + id,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
}

View file

@ -228,6 +228,16 @@ public sealed class UpdateFrameOrchestratorTests
FieldInfo[] ownerFields = owner.GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.DoesNotContain(ownerFields, field => field.FieldType == typeof(GameWindow));
if (owner == typeof(AcDream.App.Input.RetailLocalPlayerFrameController))
{
// Checkpoint B deliberately leaves this single legacy callback
// composition for D/F, which own input capture and the mutable
// player-mode/controller slot. No other phase owner may add one.
Assert.Contains(
ownerFields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
continue;
}
Assert.DoesNotContain(
ownerFields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
@ -274,6 +284,71 @@ public sealed class UpdateFrameOrchestratorTests
Assert.Equal(1, source.Split("PublishTime(", StringSplitOptions.None).Length - 1);
}
[Fact]
public void ExtractedLiveObjectSource_PinsRetailAndRegisteredAdaptationOrder()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Update",
"LiveObjectFrameController.cs"));
AssertAppearsInOrder(
source,
"_localPlayerFrame.AdvanceBeforeNetwork",
"_selectionInteractions?.DrainOutbound",
"_animations.Tick",
"_staticAnimations.Tick",
"_animationPresenter.Present",
"_equippedChildren.Tick",
"_staticAnimations.ProcessHooks",
"_effects.Tick");
AssertAppearsInOrder(
source,
"_translucencyFades.AdvanceAll",
"_animationHooks.Drain",
"_entityEffects.RefreshLiveOwnerPoses",
"_particleSink.RefreshAttachedEmitters",
"_lights.Refresh",
"_particleVisibility.Apply",
"_particles.Tick",
"_scripts.Tick");
AssertAppearsInOrder(
source,
"public void Reconcile()",
"_entityEffects.RefreshLiveOwnerPoses",
"_equippedChildren.Tick",
"_particleSink.RefreshAttachedEmitters",
"_lights.Refresh");
Assert.Equal(1, CountOccurrences(source, "_staticAnimations.Tick("));
Assert.Equal(1, CountOccurrences(source, "_staticAnimations.ProcessHooks("));
Assert.Equal(1, CountOccurrences(source, "_effects.Tick("));
Assert.Equal(1, CountOccurrences(source, "_particles.Tick("));
Assert.Equal(1, CountOccurrences(source, "_scripts.Tick("));
}
[Fact]
public void GameWindow_ComposesTheLiveFrameOwnersWithoutOwningTheirBodies()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
Assert.Contains("new AcDream.App.Update.LiveObjectFrameController(", source);
Assert.Contains("new AcDream.App.Update.LiveSpatialPresentationReconciler(", source);
Assert.Contains("new AcDream.App.World.RetailLiveFrameCoordinator(", source);
Assert.DoesNotContain("AdvanceLiveObjectRuntime", source, StringComparison.Ordinal);
Assert.DoesNotContain("ReconcileLiveObjectSpatialPresentation", source,
StringComparison.Ordinal);
Assert.DoesNotContain("ILiveAnimationPresentationContext", source,
StringComparison.Ordinal);
}
private static UpdateFrameOrchestrator Create(
List<string> calls,
RecordingTeardown? teardown = null,
@ -432,4 +507,19 @@ public sealed class UpdateFrameOrchestratorTests
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
private static void AssertAppearsInOrder(string source, params string[] markers)
{
int previous = -1;
foreach (string marker in markers)
{
int current = source.IndexOf(marker, previous + 1, StringComparison.Ordinal);
Assert.True(current >= 0, $"Missing source marker: {marker}");
Assert.True(current > previous, $"Out-of-order source marker: {marker}");
previous = current;
}
}
private static int CountOccurrences(string source, string marker) =>
source.Split(marker, StringSplitOptions.None).Length - 1;
}