diff --git a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs
new file mode 100644
index 00000000..51f3d156
--- /dev/null
+++ b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs
@@ -0,0 +1,76 @@
+using AcDream.Runtime;
+using AcDream.Runtime.Entities;
+using AcDream.Runtime.Physics;
+using AcDream.Runtime.World;
+
+namespace AcDream.Headless.Hosting;
+
+///
+/// Validation-only no-window observer for canonical Runtime SetPosition
+/// receipts. A headless host has no graphical sidecar to move or hide, so a
+/// valid receipt is acknowledged without re-running placement or mutating
+/// Runtime's body, controller, shadows, clocks, or worksets.
+///
+internal sealed class HeadlessRuntimePlacementProjectionSink
+ : IRuntimePlacementProjectionSink
+{
+ private readonly GameRuntime _runtime;
+
+ internal HeadlessRuntimePlacementProjectionSink(GameRuntime runtime)
+ {
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ }
+
+ public bool TryApply(
+ in RuntimePlacementProjectionSnapshot projection)
+ {
+ if (projection.Kind is RuntimePlacementProjectionKind.Discard)
+ {
+ // Discard cancels only an unacknowledged observation. It is valid
+ // even after its entity/session authority has been superseded.
+ return true;
+ }
+
+ RuntimePlacementProjectionToken token = projection.Token;
+ RuntimeEntityDirectory directory = _runtime.EntityObjects.Entities;
+ if (!token.IsValid
+ || token.SessionLifetimeVersion
+ != directory.SessionLifetimeVersion
+ || !directory.TryGetByLocalId(
+ token.Entity.LocalEntityId,
+ out RuntimeEntityRecord record)
+ || !directory.IsCurrent(record)
+ || record.Key != token.Entity
+ || !HasValidPortalShape(token))
+ {
+ return false;
+ }
+
+ if (projection.Kind is RuntimePlacementProjectionKind.Withdraw)
+ return true;
+ if (projection.Kind is not RuntimePlacementProjectionKind.Place)
+ return false;
+
+ return record.PositionAuthorityVersion
+ == token.PositionAuthorityVersion
+ && record.SpatialAuthorityVersion
+ == token.SpatialAuthorityVersion
+ && record.PlacementCommitVersion
+ == token.PlacementCommitVersion
+ && record.FullCellId == token.ExactCellId
+ && _runtime.TransitOwner.IsCurrentPlacementAuthority(
+ token.Portal,
+ token.ExactCellId);
+ }
+
+ private static bool HasValidPortalShape(
+ in RuntimePlacementProjectionToken token)
+ {
+ RuntimePortalPlacementAuthority portal = token.Portal;
+ if (!portal.Present)
+ return portal.IsEmpty;
+
+ return portal.IsValid
+ && portal.Projection.DestinationCell == token.ExactCellId;
+ }
+}
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs
new file mode 100644
index 00000000..a07d56a9
--- /dev/null
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs
@@ -0,0 +1,67 @@
+using AcDream.Runtime;
+using AcDream.Runtime.Physics;
+using AcDream.Runtime.Session;
+
+namespace AcDream.Headless.Hosting;
+
+///
+/// Owns the inbound network route and the canonical placement observer for
+/// one exact headless session lifetime. Placement detaches first so no
+/// receipt can reach a retiring no-window projection while the network route
+/// is being removed or Runtime is being reset.
+///
+internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
+{
+ private readonly ILiveSessionEventRouting _events;
+ private readonly GameRuntime _runtime;
+ private readonly IRuntimePlacementProjectionSink _placements;
+ private RuntimePlacementProjectionSubscription? _subscription;
+ private bool _attachStarted;
+ private bool _eventsDisposed;
+ private bool _disposed;
+
+ internal HeadlessSessionEventRoute(
+ ILiveSessionEventRouting events,
+ GameRuntime runtime,
+ IRuntimePlacementProjectionSink placements)
+ {
+ _events = events ?? throw new ArgumentNullException(nameof(events));
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ _placements = placements
+ ?? throw new ArgumentNullException(nameof(placements));
+ }
+
+ public void Attach()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_attachStarted)
+ return;
+
+ // Mark the attempt before the fallible call. If Attach partially
+ // succeeds and throws, LiveSessionHost's retryable rollback still
+ // invokes Dispose on the underlying route.
+ _attachStarted = true;
+ _events.Attach();
+ _subscription = new RuntimePlacementProjectionSubscription(
+ _runtime,
+ _placements);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ // Subscription disposal is idempotent and deliberately precedes the
+ // network route. A still-pending FIFO head remains Runtime-owned for
+ // the replacement route to drain.
+ Interlocked.Exchange(ref _subscription, null)?.Dispose();
+ if (!_eventsDisposed)
+ {
+ _events.Dispose();
+ _eventsDisposed = true;
+ }
+
+ _disposed = true;
+ }
+}
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
index 8378933c..5c55b088 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
@@ -4,6 +4,7 @@ using AcDream.Headless.Diagnostics;
using AcDream.Headless.Policies;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
+using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
namespace AcDream.Headless.Hosting;
@@ -521,7 +522,7 @@ internal sealed class HeadlessSessionHost : IDisposable
}
}
- private LiveSessionEventRouter CreateEventRoute(
+ private ILiveSessionEventRouting CreateEventRoute(
AcDream.Core.Net.WorldSession session)
{
IRuntimeDirectWorldProjection? worldProjection =
@@ -536,7 +537,7 @@ internal sealed class HeadlessSessionHost : IDisposable
message,
Runtime.Generation.Value),
worldProjection);
- return new LiveSessionEventRouter(
+ var route = new LiveSessionEventRouter(
session,
entities.CreateSink(),
new LiveEnvironmentSessionSink(
@@ -579,6 +580,10 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime.CommunicationOwner.TurbineChat,
Runtime.CommunicationOwner.Friends,
Runtime.CommunicationOwner.Squelch));
+ return new HeadlessSessionEventRoute(
+ route,
+ Runtime,
+ new HeadlessRuntimePlacementProjectionSink(Runtime));
}
private static LiveSessionCharacterSelector MapCharacterSelector(
diff --git a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs
index 5c64367d..5ac5862f 100644
--- a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs
+++ b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs
@@ -5,30 +5,28 @@ namespace AcDream.App.Tests.Runtime;
public sealed class RuntimePhysicsOwnershipTests
{
[Fact]
- public void PlacementProjectionChannelRemainsDormantInProductionHosts()
+ public void GraphicalPlacementProjectionRemainsDormantUntilCoordinatedCutover()
{
string root = FindRepositoryRoot();
- foreach (string relative in new[]
- {
- Path.Combine("src", "AcDream.App"),
- Path.Combine("src", "AcDream.Headless"),
- })
+ string relative = Path.Combine("src", "AcDream.App");
+ foreach (string file in Directory.EnumerateFiles(
+ Path.Combine(root, relative),
+ "*.cs",
+ SearchOption.AllDirectories))
{
- foreach (string file in Directory.EnumerateFiles(
- Path.Combine(root, relative),
- "*.cs",
- SearchOption.AllDirectories))
- {
- string source = File.ReadAllText(file);
- Assert.DoesNotContain(
- ".Placements.",
- source,
- StringComparison.Ordinal);
- Assert.DoesNotContain(
- "RuntimePlacementProjectionChannel",
- source,
- StringComparison.Ordinal);
- }
+ string source = File.ReadAllText(file);
+ Assert.DoesNotContain(
+ ".Placements.",
+ source,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "RuntimePlacementProjectionChannel",
+ source,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "RuntimePlacementProjectionSubscription",
+ source,
+ StringComparison.Ordinal);
}
}
diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
index ffb9e650..7bcb7114 100644
--- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
@@ -374,6 +374,231 @@ public sealed class HeadlessSessionHostTests
Assert.Equal(2, collision.CenterCount);
}
+ [Fact]
+ public void PlacementReceiptValidationDoesNotRegainMovementOrPhysicsAuthority()
+ {
+ var operations = new FixtureSessionOperations();
+ using var credential = new HeadlessCredentialSecret(
+ "fixture",
+ "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ GameRuntime runtime = host.Runtime;
+ const uint player = 0x50000004u;
+ runtime.PlayerIdentity.ServerGuid = player;
+ AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
+ RuntimeEntityRecord record = runtime.EntityObjects
+ .RegisterEntity(Spawn(player))
+ .Canonical!;
+ Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
+ record,
+ record.CreateIntegrationVersion,
+ record.Snapshot,
+ replaceGeneration: false));
+ var directProjection = new HeadlessSessionWorldProjection(
+ runtime,
+ new FixtureCollisionNeighborhood());
+ directProjection.ProjectSpawn(record, isLocalPlayer: true);
+ PlayerMovementController controller = Assert.IsType<
+ PlayerMovementController>(runtime.MovementOwner.Controller);
+ Vector3 positionBefore = controller.Position;
+ Quaternion orientationBefore = controller.BodyOrientation;
+ RuntimePhysicsOwnershipSnapshot physicsBefore =
+ runtime.EntityObjects.Physics.CaptureOwnership();
+ RuntimePlacementProjectionSnapshot receipt = Placement(
+ runtime,
+ record,
+ RuntimePlacementProjectionKind.Place,
+ new Vector3(600f, 601f, 602f),
+ Quaternion.CreateFromAxisAngle(Vector3.UnitY, 1.2f));
+
+ var receiptSink = new HeadlessRuntimePlacementProjectionSink(runtime);
+ Assert.True(receiptSink.TryApply(in receipt));
+
+ Assert.Equal(positionBefore, controller.Position);
+ Assert.Equal(orientationBefore, controller.BodyOrientation);
+ Assert.Equal(
+ physicsBefore,
+ runtime.EntityObjects.Physics.CaptureOwnership());
+ }
+
+ [Fact]
+ public void PlacementReceiptUsesExactIncarnationAndDiscardIsAckOnly()
+ {
+ var operations = new FixtureSessionOperations();
+ using var credential = new HeadlessCredentialSecret(
+ "fixture",
+ "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ GameRuntime runtime = host.Runtime;
+ RuntimeEntityRecord record = runtime.EntityObjects
+ .RegisterEntity(Spawn(0x50000005u))
+ .Canonical!;
+ Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
+ record,
+ record.CreateIntegrationVersion,
+ record.Snapshot,
+ replaceGeneration: false));
+ var projection = new HeadlessRuntimePlacementProjectionSink(runtime);
+ RuntimePlacementProjectionSnapshot place = Placement(
+ runtime,
+ record,
+ RuntimePlacementProjectionKind.Place,
+ Vector3.One,
+ Quaternion.Identity);
+ RuntimePlacementProjectionSnapshot stale = place with
+ {
+ Token = place.Token with
+ {
+ Entity = place.Token.Entity with
+ {
+ Incarnation = unchecked((ushort)(
+ place.Token.Entity.Incarnation + 1)),
+ },
+ },
+ };
+ RuntimePlacementProjectionSnapshot discard = stale with
+ {
+ Kind = RuntimePlacementProjectionKind.Discard,
+ Token = stale.Token with
+ {
+ SessionLifetimeVersion = ulong.MaxValue,
+ },
+ };
+
+ Assert.False(projection.TryApply(in stale));
+ Assert.True(projection.TryApply(in discard));
+ }
+
+ [Fact]
+ public void SessionEventRouteOwnsOneObserverAndUnsubscribesBeforeNetworkDetach()
+ {
+ var operations = new FixtureSessionOperations();
+ using var credential = new HeadlessCredentialSecret(
+ "fixture",
+ "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ GameRuntime runtime = host.Runtime;
+ int subscriberCountDuringDetach = -1;
+ var inner = new FixtureEventRoute(
+ onDispose: () => subscriberCountDuringDetach =
+ runtime.EntityObjects.Events.PlacementSubscriberCount);
+ var placements = new HeadlessRuntimePlacementProjectionSink(runtime);
+ var route = new HeadlessSessionEventRoute(
+ inner,
+ runtime,
+ placements);
+
+ Assert.Equal(
+ 0,
+ runtime.EntityObjects.Events.PlacementSubscriberCount);
+ route.Attach();
+ route.Attach();
+ Assert.Equal(
+ 1,
+ runtime.EntityObjects.Events.PlacementSubscriberCount);
+ Assert.Equal(1, inner.AttachCount);
+
+ route.Dispose();
+ route.Dispose();
+
+ Assert.Equal(0, subscriberCountDuringDetach);
+ Assert.Equal(
+ 0,
+ runtime.EntityObjects.Events.PlacementSubscriberCount);
+ Assert.Equal(1, inner.DisposeCount);
+ }
+
+ [Fact]
+ public void ReplacementSessionEventRouteGetsOneFreshObserver()
+ {
+ var operations = new FixtureSessionOperations();
+ using var credential = new HeadlessCredentialSecret(
+ "fixture",
+ "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ GameRuntime runtime = host.Runtime;
+ var placements = new HeadlessRuntimePlacementProjectionSink(runtime);
+
+ var first = new HeadlessSessionEventRoute(
+ new FixtureEventRoute(),
+ runtime,
+ placements);
+ first.Attach();
+ Assert.Equal(
+ 1,
+ runtime.EntityObjects.Events.PlacementSubscriberCount);
+ first.Dispose();
+ Assert.Equal(
+ 0,
+ runtime.EntityObjects.Events.PlacementSubscriberCount);
+
+ var replacement = new HeadlessSessionEventRoute(
+ new FixtureEventRoute(),
+ runtime,
+ placements);
+ replacement.Attach();
+ Assert.Equal(
+ 1,
+ runtime.EntityObjects.Events.PlacementSubscriberCount);
+ replacement.Dispose();
+ Assert.Equal(
+ 0,
+ runtime.EntityObjects.Events.PlacementSubscriberCount);
+ }
+
+ [Fact]
+ public void SessionEventRouteRetryDoesNotRestorePlacementObserver()
+ {
+ var operations = new FixtureSessionOperations();
+ using var credential = new HeadlessCredentialSecret(
+ "fixture",
+ "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ GameRuntime runtime = host.Runtime;
+ var inner = new FixtureEventRoute
+ {
+ DisposeFailuresRemaining = 1,
+ };
+ var route = new HeadlessSessionEventRoute(
+ inner,
+ runtime,
+ new HeadlessRuntimePlacementProjectionSink(runtime));
+ route.Attach();
+
+ Assert.Throws(route.Dispose);
+ Assert.Equal(
+ 0,
+ runtime.EntityObjects.Events.PlacementSubscriberCount);
+ Assert.Equal(1, inner.DisposeCount);
+
+ route.Dispose();
+
+ Assert.Equal(2, inner.DisposeCount);
+ Assert.Equal(
+ 0,
+ runtime.EntityObjects.Events.PlacementSubscriberCount);
+ }
+
[Fact]
public void CollisionTransactionCancelsPostAdmissionFaultWithoutWithdrawingActiveWorld()
{
@@ -587,6 +812,36 @@ public sealed class HeadlessSessionHostTests
Physics: physics);
}
+ private static RuntimePlacementProjectionSnapshot Placement(
+ GameRuntime runtime,
+ RuntimeEntityRecord record,
+ RuntimePlacementProjectionKind kind,
+ Vector3 position,
+ Quaternion orientation)
+ {
+ RuntimeEntityKey key = Assert.IsType(record.Key);
+ var token = new RuntimePlacementProjectionToken(
+ Sequence: 1,
+ Revision: 1,
+ Entity: key,
+ PositionAuthorityVersion: record.PositionAuthorityVersion,
+ SpatialAuthorityVersion: record.SpatialAuthorityVersion,
+ PlacementCommitVersion: record.PlacementCommitVersion,
+ SessionLifetimeVersion:
+ runtime.EntityObjects.Entities.SessionLifetimeVersion,
+ ExactCellId: record.FullCellId,
+ CollisionGeneration: 1,
+ Portal: default);
+ return new RuntimePlacementProjectionSnapshot(
+ token,
+ kind,
+ position,
+ orientation,
+ CellLocalPosition: position,
+ InContact: false,
+ OnWalkable: false);
+ }
+
private static uint ActionOpcode(byte[] body) =>
BinaryPrimitives.ReadUInt32LittleEndian(
body.AsSpan(8, sizeof(uint)));
@@ -687,4 +942,25 @@ public sealed class HeadlessSessionHostTests
fullCellId == LastCell;
}
+ private sealed class FixtureEventRoute(
+ Action? onDispose = null) : ILiveSessionEventRouting
+ {
+ public int AttachCount { get; private set; }
+ public int DisposeCount { get; private set; }
+ public int DisposeFailuresRemaining { get; set; }
+
+ public void Attach() => AttachCount++;
+
+ public void Dispose()
+ {
+ DisposeCount++;
+ onDispose?.Invoke();
+ if (DisposeFailuresRemaining > 0)
+ {
+ DisposeFailuresRemaining--;
+ throw new IOException("fixture route detach failure");
+ }
+ }
+ }
+
}