From 378ca95a6772f4a78785ef4ca25bb372f1712d17 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 15:10:03 +0200 Subject: [PATCH] feat(headless): observe canonical placement receipts --- .../HeadlessRuntimePlacementProjectionSink.cs | 76 +++++ .../Hosting/HeadlessSessionEventRoute.cs | 67 +++++ .../Hosting/HeadlessSessionHost.cs | 9 +- .../Runtime/RuntimePhysicsOwnershipTests.cs | 40 ++- .../HeadlessSessionHostTests.cs | 276 ++++++++++++++++++ 5 files changed, 445 insertions(+), 23 deletions(-) create mode 100644 src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs create mode 100644 src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs 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"); + } + } + } + }