refactor(world): establish live entity composition seams

This commit is contained in:
Erik 2026-07-21 13:09:58 +02:00
parent db5a11707d
commit d68c83d1a5
5 changed files with 357 additions and 21 deletions

View file

@ -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);

View file

@ -80,6 +80,64 @@ public interface ILiveEntityResourceLifecycle
void Unregister(WorldEntity entity);
}
/// <summary>
/// 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 <see cref="ILiveEntityResourceLifecycle"/>.
/// </summary>
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<LiveEntityRecord> tearDown) : ILiveEntityRuntimeComponentLifecycle
{
private readonly Action<LiveEntityRecord> _tearDown =
tearDown ?? throw new ArgumentNullException(nameof(tearDown));
public void TearDown(LiveEntityRecord record) => _tearDown(record);
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>Delegate adapter used by the App composition root.</summary>
public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle
{
@ -300,7 +358,7 @@ public sealed class LiveEntityRuntime
private readonly GpuWorldState _spatial;
private readonly ILiveEntityResourceLifecycle _resources;
private readonly Action<LiveEntityRecord>? _tearDownRuntimeComponents;
private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle;
private readonly InboundPhysicsStateController _inbound = new();
private readonly Dictionary<uint, LiveEntityRecord> _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<LiveEntityRecord>? tearDownRuntimeComponents = null,
uint firstLocalEntityId = FirstLiveEntityId)
: this(
spatial,
resources,
NullLiveEntityRuntimeComponentLifecycle.Instance,
firstLocalEntityId)
{
}
public LiveEntityRuntime(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
Action<LiveEntityRecord> 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)

View file

@ -0,0 +1,49 @@
namespace AcDream.App.World;
/// <summary>
/// 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;
/// <see cref="IsKnown"/> distinguishes it from a server-confirmed origin.
/// </summary>
/// <remarks>
/// This owner is update/render-thread confined with the rest of live world
/// mutation. It intentionally exposes no cross-thread snapshot contract.
/// </remarks>
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;
}
/// <summary>
/// Ends the accepted-origin lifetime while retaining the last coordinates
/// as the next placeholder. Consumers must gate them on
/// <see cref="IsKnown"/> until the next accepted player position.
/// </summary>
public void Reset() => IsKnown = false;
}

View file

@ -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<InvalidOperationException>(() => 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<InvalidOperationException>(() => bridge.Bind(replacement));
}
[Fact]
public void BindRejectsNullAndSelf()
{
var bridge = new DeferredLiveEntityRuntimeComponentLifecycle();
Assert.Throws<ArgumentNullException>(() => bridge.Bind(null!));
Assert.Throws<ArgumentException>(() => 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<bool>[] 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<AggregateException>(() =>
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<CreateObject.AnimPartChange>(),
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: null,
Name: "test",
ItemType: null,
MotionState: null,
MotionTableId: null,
InstanceSequence: 1);
}

View file

@ -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);
}
}