feat(headless): observe canonical placement receipts

This commit is contained in:
Erik 2026-08-01 15:10:03 +02:00
parent 74c9b155bd
commit 378ca95a67
5 changed files with 445 additions and 23 deletions

View file

@ -0,0 +1,76 @@
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
using AcDream.Runtime.World;
namespace AcDream.Headless.Hosting;
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -0,0 +1,67 @@
using AcDream.Runtime;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
namespace AcDream.Headless.Hosting;
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -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(

View file

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

View file

@ -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<IOException>(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<RuntimeEntityKey>(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");
}
}
}
}