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