From d68c83d1a551a9bfc57b675d5a9ba2afca40bff1 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 13:09:58 +0200 Subject: [PATCH] refactor(world): establish live entity composition seams --- src/AcDream.App/Rendering/GameWindow.cs | 37 +++-- src/AcDream.App/World/LiveEntityRuntime.cs | 93 +++++++++++- src/AcDream.App/World/LiveWorldOriginState.cs | 49 +++++++ ...iveEntityRuntimeComponentLifecycleTests.cs | 137 ++++++++++++++++++ .../World/LiveWorldOriginStateTests.cs | 62 ++++++++ 5 files changed, 357 insertions(+), 21 deletions(-) create mode 100644 src/AcDream.App/World/LiveWorldOriginState.cs create mode 100644 tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs create mode 100644 tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 57f81411..fb194182 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1007,8 +1007,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.Net.LiveSessionController? _liveSessionController; private AcDream.Core.Net.WorldSession? LiveSession => _liveSessionController?.CurrentSession; - private int _liveCenterX; - private int _liveCenterY; + private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new(); + private int _liveCenterX => _liveWorldOrigin.CenterX; + private int _liveCenterY => _liveWorldOrigin.CenterY; // #192 (2026-07-09): true once the player's first canonical Position has // been accepted — i.e. _liveCenterX/Y reflects a real, server-confirmed // position, not the Holtburg startup placeholder. Renderability is not a @@ -1019,7 +1020,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // AcDream.Core.World.StreamingReadinessGate for the full mechanism this // closes (stale-centered geometry built during the InWorld-but-not-yet- // located window, applied late once its build finished). - private bool _liveCenterKnown; + private bool _liveCenterKnown => _liveWorldOrigin.IsKnown; // K-fix1 (2026-04-26): cached at startup so per-frame branches are // single-flag reads instead of env-var lookups. True iff @@ -2535,6 +2536,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _worldState, AdvanceLandblockPresentationRetirement); + var liveEntityComponentLifecycle = + new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle(); _liveEntities = new AcDream.App.World.LiveEntityRuntime( _worldState, new AcDream.App.World.CompositeLiveEntityResourceLifecycle( @@ -2544,7 +2547,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext new( entityScriptActivator.OnCreate, entityScriptActivator.OnRemove)), - TearDownLiveEntityRuntimeComponents); + liveEntityComponentLifecycle); + liveEntityComponentLifecycle.Bind( + new AcDream.App.World.DelegateLiveEntityRuntimeComponentLifecycle( + TearDownLiveEntityRuntimeComponents)); _liveEntities.ProjectionVisibilityChanged += (record, visible) => { if (record.WorldEntity is { } entity) @@ -2844,8 +2850,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // enter the world as the first character on the account, and stream // CreateObject messages into _worldGameState as they arrive. Entirely // gated behind ACDREAM_LIVE=1 so the default run path is unchanged. - _liveCenterX = centerX; - _liveCenterY = centerY; + _liveWorldOrigin.SetPlaceholder(centerX, centerY); _liveSessionController = new AcDream.App.Net.LiveSessionController(); AcDream.App.Net.LiveSessionStartResult liveStart = _liveSessionController.Start( @@ -3010,7 +3015,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // X/Y are ignored until the next logical player CreateObject claims a // new origin. Keeping the last values avoids inventing a synthetic // landblock while still forcing first-spawn initialization. - _liveCenterKnown = false; + _liveWorldOrigin.Reset(); } private void ResetSessionLiveRuntime() @@ -3409,21 +3414,22 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private void TryInitializeLiveCenter( AcDream.Core.Net.WorldSession.EntitySpawn spawn) { - if (_liveCenterKnown - || spawn.Guid != _playerServerGuid + if (spawn.Guid != _playerServerGuid || spawn.Position is not { } position) return; int lbX = (int)((position.LandblockId >> 24) & 0xFFu); int lbY = (int)((position.LandblockId >> 16) & 0xFFu); - _liveCenterKnown = true; - if (lbX != _liveCenterX || lbY != _liveCenterY) + int oldCenterX = _liveWorldOrigin.CenterX; + int oldCenterY = _liveWorldOrigin.CenterY; + if (!_liveWorldOrigin.TryInitialize(lbX, lbY)) + return; + + if (lbX != oldCenterX || lbY != oldCenterY) { Console.WriteLine( - $"live: first player position — recentering streaming from ({_liveCenterX},{_liveCenterY}) " + + $"live: first player position — recentering streaming from ({oldCenterX},{oldCenterY}) " + $"to ({lbX},{lbY}) @0x{position.LandblockId:X8}"); - _liveCenterX = lbX; - _liveCenterY = lbY; } if (_streamingController is not null && _dats is not null) @@ -7337,8 +7343,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // can be the abandoned first destination, not the unplaced player's source. _physicsEngine.RemoveLandblock(streamingOriginLandblockId); - _liveCenterX = lbX; - _liveCenterY = lbY; + _liveWorldOrigin.Recenter(lbX, lbY); newWorldPos = new System.Numerics.Vector3( p.PositionX, p.PositionY, p.PositionZ); diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 77400276..7b064a2a 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -80,6 +80,64 @@ public interface ILiveEntityResourceLifecycle void Unregister(WorldEntity entity); } +/// +/// Exact-incarnation App component cleanup invoked by the canonical runtime +/// after it has retired the active identity. Mesh/script resource lifetime is +/// owned separately by . +/// +internal interface ILiveEntityRuntimeComponentLifecycle +{ + void TearDown(LiveEntityRecord record); +} + +internal sealed class NullLiveEntityRuntimeComponentLifecycle : + ILiveEntityRuntimeComponentLifecycle +{ + public static NullLiveEntityRuntimeComponentLifecycle Instance { get; } = new(); + private NullLiveEntityRuntimeComponentLifecycle() { } + public void TearDown(LiveEntityRecord record) { } +} + +internal sealed class DelegateLiveEntityRuntimeComponentLifecycle( + Action tearDown) : ILiveEntityRuntimeComponentLifecycle +{ + private readonly Action _tearDown = + tearDown ?? throw new ArgumentNullException(nameof(tearDown)); + + public void TearDown(LiveEntityRecord record) => _tearDown(record); +} + +/// +/// Composition bridge for the runtime-to-hydration construction cycle. It is +/// deliberately fail-fast and single-assignment: a live session must not start +/// before the exact component owner is bound, and ownership cannot be replaced +/// during a session. +/// +internal sealed class DeferredLiveEntityRuntimeComponentLifecycle : + ILiveEntityRuntimeComponentLifecycle +{ + private ILiveEntityRuntimeComponentLifecycle? _inner; + + public bool IsBound => Volatile.Read(ref _inner) is not null; + + public void Bind(ILiveEntityRuntimeComponentLifecycle lifecycle) + { + ArgumentNullException.ThrowIfNull(lifecycle); + if (ReferenceEquals(lifecycle, this)) + throw new ArgumentException("A lifecycle bridge cannot bind to itself.", nameof(lifecycle)); + if (Interlocked.CompareExchange(ref _inner, lifecycle, null) is not null) + throw new InvalidOperationException("The live-entity runtime component lifecycle is already bound."); + } + + public void TearDown(LiveEntityRecord record) + { + ILiveEntityRuntimeComponentLifecycle lifecycle = Volatile.Read(ref _inner) + ?? throw new InvalidOperationException( + "The live-entity runtime component lifecycle has not been bound."); + lifecycle.TearDown(record); + } +} + /// Delegate adapter used by the App composition root. public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle { @@ -300,7 +358,7 @@ public sealed class LiveEntityRuntime private readonly GpuWorldState _spatial; private readonly ILiveEntityResourceLifecycle _resources; - private readonly Action? _tearDownRuntimeComponents; + private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle; private readonly InboundPhysicsStateController _inbound = new(); private readonly Dictionary _recordsByGuid = new(); // Records leave the active gameplay map before arbitrary teardown callbacks @@ -338,12 +396,38 @@ public sealed class LiveEntityRuntime public LiveEntityRuntime( GpuWorldState spatial, ILiveEntityResourceLifecycle resources, - Action? tearDownRuntimeComponents = null, + uint firstLocalEntityId = FirstLiveEntityId) + : this( + spatial, + resources, + NullLiveEntityRuntimeComponentLifecycle.Instance, + firstLocalEntityId) + { + } + + public LiveEntityRuntime( + GpuWorldState spatial, + ILiveEntityResourceLifecycle resources, + Action tearDownRuntimeComponents, + uint firstLocalEntityId = FirstLiveEntityId) + : this( + spatial, + resources, + new DelegateLiveEntityRuntimeComponentLifecycle(tearDownRuntimeComponents), + firstLocalEntityId) + { + } + + internal LiveEntityRuntime( + GpuWorldState spatial, + ILiveEntityResourceLifecycle resources, + ILiveEntityRuntimeComponentLifecycle? runtimeComponentLifecycle, uint firstLocalEntityId = FirstLiveEntityId) { _spatial = spatial ?? throw new ArgumentNullException(nameof(spatial)); _resources = resources ?? throw new ArgumentNullException(nameof(resources)); - _tearDownRuntimeComponents = tearDownRuntimeComponents; + _runtimeComponentLifecycle = runtimeComponentLifecycle + ?? throw new ArgumentNullException(nameof(runtimeComponentLifecycle)); if (firstLocalEntityId is < FirstLiveEntityId or > LastLiveEntityId) throw new ArgumentOutOfRangeException( nameof(firstLocalEntityId), @@ -2239,8 +2323,7 @@ public sealed class LiveEntityRuntime if (!record.RuntimeComponentsTeardownCompleted) { record.RuntimeComponentsTeardownCompleted = - _tearDownRuntimeComponents is null - || TryCleanup(() => _tearDownRuntimeComponents(record)); + TryCleanup(() => _runtimeComponentLifecycle.TearDown(record)); } if (record.WorldEntity is { } entity) diff --git a/src/AcDream.App/World/LiveWorldOriginState.cs b/src/AcDream.App/World/LiveWorldOriginState.cs new file mode 100644 index 00000000..370e411f --- /dev/null +++ b/src/AcDream.App/World/LiveWorldOriginState.cs @@ -0,0 +1,49 @@ +namespace AcDream.App.World; + +/// +/// Session-scoped owner of the landblock origin used to translate retail +/// full-cell coordinates into the current streamed world coordinate system. +/// A placeholder can exist before the first authoritative player position; +/// distinguishes it from a server-confirmed origin. +/// +/// +/// This owner is update/render-thread confined with the rest of live world +/// mutation. It intentionally exposes no cross-thread snapshot contract. +/// +internal sealed class LiveWorldOriginState +{ + public int CenterX { get; private set; } + public int CenterY { get; private set; } + public bool IsKnown { get; private set; } + + public void SetPlaceholder(int centerX, int centerY) + { + CenterX = centerX; + CenterY = centerY; + IsKnown = false; + } + + public bool TryInitialize(int centerX, int centerY) + { + if (IsKnown) + return false; + + CenterX = centerX; + CenterY = centerY; + IsKnown = true; + return true; + } + + public void Recenter(int centerX, int centerY) + { + CenterX = centerX; + CenterY = centerY; + } + + /// + /// Ends the accepted-origin lifetime while retaining the last coordinates + /// as the next placeholder. Consumers must gate them on + /// until the next accepted player position. + /// + public void Reset() => IsKnown = false; +} diff --git a/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs new file mode 100644 index 00000000..ec816fdb --- /dev/null +++ b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs @@ -0,0 +1,137 @@ +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Tests.World; + +public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests +{ + [Fact] + public void TearDownBeforeBind_FailsFast() + { + var bridge = new DeferredLiveEntityRuntimeComponentLifecycle(); + + Assert.False(bridge.IsBound); + Assert.Throws(() => bridge.TearDown(null!)); + } + + [Fact] + public void Bind_IsSingleAssignmentAndForwardsExactRecord() + { + var bridge = new DeferredLiveEntityRuntimeComponentLifecycle(); + LiveEntityRecord? observed = null; + var owner = new DelegateLiveEntityRuntimeComponentLifecycle(record => observed = record); + var replacement = new DelegateLiveEntityRuntimeComponentLifecycle(_ => { }); + LiveEntityRecord record = CreateRecord(); + + bridge.Bind(owner); + bridge.TearDown(record); + + Assert.True(bridge.IsBound); + Assert.Same(record, observed); + Assert.Throws(() => bridge.Bind(replacement)); + } + + [Fact] + public void BindRejectsNullAndSelf() + { + var bridge = new DeferredLiveEntityRuntimeComponentLifecycle(); + + Assert.Throws(() => bridge.Bind(null!)); + Assert.Throws(() => bridge.Bind(bridge)); + } + + [Fact] + public async Task ConcurrentBind_HasOneWinnerAndPublishesThatOwner() + { + var bridge = new DeferredLiveEntityRuntimeComponentLifecycle(); + int firstCalls = 0; + int secondCalls = 0; + var owners = new ILiveEntityRuntimeComponentLifecycle[] + { + new DelegateLiveEntityRuntimeComponentLifecycle(_ => Interlocked.Increment(ref firstCalls)), + new DelegateLiveEntityRuntimeComponentLifecycle(_ => Interlocked.Increment(ref secondCalls)), + }; + using var ready = new CountdownEvent(2); + using var start = new ManualResetEventSlim(); + + Task[] attempts = owners.Select(owner => Task.Run(() => + { + ready.Signal(); + start.Wait(); + try + { + bridge.Bind(owner); + return true; + } + catch (InvalidOperationException) + { + return false; + } + })).ToArray(); + + ready.Wait(); + start.Set(); + bool[] results = await Task.WhenAll(attempts); + LiveEntityRecord record = CreateRecord(); + bridge.TearDown(record); + + Assert.Single(results, value => value); + Assert.True(bridge.IsBound); + Assert.Equal(1, firstCalls + secondCalls); + } + + [Fact] + public void RuntimeTeardownBeforeBind_RetainsTombstoneAndRetryConverges() + { + const uint guid = 0x70000002u; + var bridge = new DeferredLiveEntityRuntimeComponentLifecycle(); + var runtime = new LiveEntityRuntime( + new GpuWorldState(), + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), + bridge); + WorldSession.EntitySpawn spawn = CreateSpawn(guid); + LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!; + var delete = new DeleteObject.Parsed(guid, InstanceSequence: 1); + + Assert.Throws(() => + runtime.UnregisterLiveEntity(delete, isLocalPlayer: false)); + Assert.Equal(1, runtime.PendingTeardownCount); + + int calls = 0; + bridge.Bind(new DelegateLiveEntityRuntimeComponentLifecycle(exact => + { + Assert.Same(record, exact); + calls++; + })); + + Assert.True(runtime.UnregisterLiveEntity(delete, isLocalPlayer: false)); + Assert.Equal(1, calls); + Assert.Equal(0, runtime.PendingTeardownCount); + } + + private static LiveEntityRecord CreateRecord() + { + var runtime = new LiveEntityRuntime( + new GpuWorldState(), + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + return runtime.RegisterLiveEntity(CreateSpawn(0x70000001u)).Record!; + } + + private static WorldSession.EntitySpawn CreateSpawn(uint guid) => + new( + Guid: guid, + Position: null, + SetupTableId: null, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "test", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: 1); +} diff --git a/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs b/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs new file mode 100644 index 00000000..3303e781 --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs @@ -0,0 +1,62 @@ +using AcDream.App.World; + +namespace AcDream.App.Tests.World; + +public sealed class LiveWorldOriginStateTests +{ + [Fact] + public void Placeholder_IsNotAuthoritativeUntilFirstInitialization() + { + var state = new LiveWorldOriginState(); + + state.SetPlaceholder(42, 43); + + Assert.Equal(42, state.CenterX); + Assert.Equal(43, state.CenterY); + Assert.False(state.IsKnown); + Assert.True(state.TryInitialize(10, 11)); + Assert.Equal(10, state.CenterX); + Assert.Equal(11, state.CenterY); + Assert.True(state.IsKnown); + Assert.False(state.TryInitialize(20, 21)); + Assert.Equal(10, state.CenterX); + Assert.Equal(11, state.CenterY); + } + + [Fact] + public void Recenter_ReplacesKnownOriginAndResetRetainsItAsPlaceholder() + { + var state = new LiveWorldOriginState(); + Assert.True(state.TryInitialize(1, 2)); + + state.Recenter(3, 4); + state.Reset(); + + Assert.Equal(3, state.CenterX); + Assert.Equal(4, state.CenterY); + Assert.False(state.IsKnown); + Assert.True(state.TryInitialize(5, 6)); + } + + [Fact] + public void RecenterBeforeInitialization_PreservesUnknownAndResetIsIdempotent() + { + var state = new LiveWorldOriginState(); + state.SetPlaceholder(20, 21); + + state.Recenter(30, 31); + state.Reset(); + state.Reset(); + + Assert.Equal(30, state.CenterX); + Assert.Equal(31, state.CenterY); + Assert.False(state.IsKnown); + + Assert.True(state.TryInitialize(40, 41)); + state.Recenter(50, 51); + + Assert.True(state.IsKnown); + Assert.Equal(50, state.CenterX); + Assert.Equal(51, state.CenterY); + } +}