fix(physics): C4 route 3 — portal placement authority (local player)

Removes a duplicate placement authority for local-player portal arrival.
Portalling worked before this change and works after it — this is not a
bug fix, EXCEPT that it found and fixed one dead-code production bug.

THE PRODUCTION BUG: TryExecuteCanonicalPortalPlacement re-read the
accepted destination at Place time, but TryBeginPortalReveal already
consumes that slot at Aim time — so the arm was 100% dead code and every
real portal Place refused with host-token-unavailable. Found only
because we refused to accept 7 skipped tests instead of chasing the
count to zero.

RETAIL IS THE GENERIC PATH FOR THE THIRD ROUTE RUNNING:
SmartBox::TeleportPlayer @0x00453910 = SetPositionSimple(dest, 1) with
flags 0x1012, followed by PlayerPositionUpdated.

BOTH INVERSIONS, WITH THEIR ANCHORS: unlike route 2, the leash IS armed
here (ConstrainTo @0x0045418A) and velocity is zeroed
(set_velocity @0x004541B4); unlike route 4b-3, the local teleport_hook
runs AFTER placement (@0x004538AE).

THE THREE-ROUND DEFECT CHAIN, HONESTLY:
- Round 1 released the player at the pre-teleport position while the
  anim stream marched on — the contract wrongly assumed Place re-fires
  (process rule 1's third occurrence this campaign).
- Round 2's fix inferred commit from a global PendingCount, which three
  non-committing paths also clear — making the SAME bug complete
  cleanly and silently. Strictly worse than round 1: round 1 at least
  tripped portal-complete-before-materialized.
- Round 3 latches the commit where it actually happens
  (ReconcileAndAcknowledgePortal), keyed on reveal generation and
  teleport sequence, via TryConsumePortalCommit. Two of the three
  required regression tests landed and are sabotage-verified on both
  hosts (ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted /
  HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted).
  The third (force-arm-takes-the-slot) was judged unnecessary on review:
  with the inference gone, PendingCount is only a "don't ask yet" guard
  at both gates, so a force operation occupying or vacating the slot no
  longer changes an input the commit decision reads — the case collapses
  into what the landed test already discriminates.

THE B2/P3 RESOLUTION: both round-2 reviews were right about different
branches of the same synchronous call. RuntimePlacementProjectionSubscription
.OnPlacement acknowledges the FIFO head only when TryApply returns true;
a Place whose portal authority went stale (transit ended/superseded
while parked) used to return false, wedging every later entity's
placement receipt behind it forever. Both sinks
(RuntimePlacementPresentationSink, HeadlessRuntimePlacementProjectionSink)
now acknowledge-and-ignore a stale-authority Place instead of refusing
it. The regression test (RuntimePlacementPresentationSinkTests
.PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored) had
been asserting the old, wrong `false` behaviour; it now asserts and
sabotage-verifies the fix.

Also lands: AP-144 (register discipline — the portal movement-event
send reuses the stricter UsePositionFromServer gate where retail's
SendMovementEvent is the looser autonomy_level != 0 test, diverging
only at level 1, currently unreachable), AP-145 + issue #318 (the
local-player collision-shadow presentation write bypasses its own
publisher's ShadowObjects write via a direct cache .Set(), self-healing
only once dedup diverges — filed, not fixed, pending a composition
test), AD-42 deleted (its last citation retired by the canonical portal
arm), AD-2 updated (the wait-cue's trigger predicate now covers a
second cause), and two documentation corrections: the enter_world
misattribution (both call sites are in SmartBox::HandleCreateObject,
only one in the player branch — portal arrival is TeleportPlayer, not
enter_world) and the stale "local player never reaches this path"
comment on the generic-remote-render-pose write.

Suite: 11,090 passed / 4 skipped / 0 failed. No new skips, nothing
weakened.

STILL OWED: the connected two-client gate, with
ACDREAM_PROBE_LOCAL_TELEPORT=1, scored only if [local-tp] lines
actually appear in the capture — and explicitly NOT scored as covering
issue #318 (no composition test yet asserts PhysicsEngine.ShadowObjects
directly).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-05 03:57:37 +02:00
parent cd3129e9d6
commit e0f96a55bf
24 changed files with 5261 additions and 243 deletions

View file

@ -383,10 +383,20 @@ public sealed class HeadlessSessionHostTests
record.Snapshot,
replaceGeneration: false));
var collision = new FixtureCollisionNeighborhood();
// R5/A7 review fix (2026-08-05): the drive controller is now wired
// (was omitted in the first pass, leaving the canonical portal arm
// a no-op by construction here and dual-host parity with zero
// coverage). Mirrors production composition
// (HeadlessSessionHost.cs's own construction order): the SAME
// RuntimeAcceptedPositionDriveController drives both hosts through
// the identical TryExecuteAcceptedPortalArrival entry point.
RuntimeAcceptedPositionDriveController acceptedPositionDrive =
CreateAcceptedPositionDrive(runtime);
var projection = new HeadlessSessionWorldProjection(
runtime,
collision,
firstEntry);
firstEntry,
acceptedPositionDrive);
projection.ProjectSpawn(record, isLocalPlayer: true);
PlayerMovementController controller =
@ -410,6 +420,15 @@ public sealed class HeadlessSessionHostTests
projection.BeginTeleport();
Assert.Equal(PlayerState.PortalSpace, controller.State);
// R5/A7: the destination cell 0xA9B40001 is in the SAME landblock
// (0xA9B40000) whose collision generation the test already
// committed above, so the canonical portal arm resolves
// Committed synchronously - no DeferredCell park needed to exercise
// the real headless placement path. A1's headless retry loop
// (RuntimeLiveEntitySessionController.PumpPortalCompletion) is
// covered separately by
// HeadlessPortalDeferredCellCommitsOnPumpAfterCollisionGenerationWake
// below.
RuntimeDestinationReadiness readiness =
projection.PrepareDestination(
revealGeneration: 7,
@ -422,13 +441,393 @@ public sealed class HeadlessSessionHostTests
new Position(
0xA9B40001u,
new Vector3(96f, 97f, 50f),
Quaternion.Identity)));
Quaternion.Identity)),
// R5/A7: a real, valid token - `default` was fine for the
// old no-op arm but RuntimePortalPlacementAuthority.IsValid
// now genuinely gates TryExecuteAcceptedPortalArrival on it.
new RuntimeWorldHostProjectionToken(7, 0xA9B40001u));
Assert.True(readiness.IsCollisionReady);
Assert.False(readiness.IsUnhydratable);
Assert.Equal(PlayerState.InWorld, controller.State);
Assert.Equal(3, collision.CenterCount);
Assert.Equal(2, collision.CenterCount);
Assert.Equal(0xA9B40001u, collision.LastCell);
// A4/dual-host parity: the canonical placement actually committed -
// the body moved to the destination Position, not just the
// collision-neighborhood bookkeeping that CenterCount/LastCell
// alone would have proven even with the earlier no-op arm. Z
// settles 0.005 above the wire value (the foot sphere's bottom
// sits at origin + 0.475 - 0.48, LoadedSetupCollisionSource's own
// doc comment, ISSUES.md #285) - X/Y are exact, Z is asserted
// within that settle tolerance.
Assert.Equal(96f, controller.Position.X);
Assert.Equal(97f, controller.Position.Y);
Assert.Equal(50f, controller.Position.Z, 0.01f);
// The resolved outdoor sub-cell index is derived from X/Y within
// the landblock (not the wire placeholder 0xA9B40001), same as the
// FIRST ProjectPosition assertion above (":406-409") only checks
// landblock+indoor-vs-outdoor, not the exact sub-cell.
Assert.Equal(0xA9B40000u, controller.CellId & 0xFFFF0000u);
Assert.True((controller.CellId & 0xFFFFu) < 0x0100u);
}
/// <summary>
/// A1/R5/A7 review fix (2026-08-05): headless has no per-frame anim
/// sequencer the way the graphical host does, so its OWN equivalent of
/// A1's "hold until committed" mechanism is
/// <c>HeadlessSessionWorldProjection.PrepareDestination</c>'s
/// <c>_awaitingPortalWake</c> polling. This proves it end to end: a
/// destination in a landblock whose collision generation is NOT yet
/// committed parks (<c>IsCollisionReady: false</c>, body unmoved, no
/// throw — DeferredCell is a normal headless outcome per
/// <c>PrepareDestination</c>'s own doc), and once the destination
/// landblock's collision generation commits, the SAME park resolves on
/// a later attempt WITHOUT a second concurrent Begin (Runtime's own
/// Begin would refuse that with Contention if this class re-attempted
/// blindly instead of polling <c>PendingCount</c>).
/// </summary>
[Fact]
public void HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake()
{
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;
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
const uint player = 0x50000009u;
const uint destinationLandblock = 0xAAB40000u;
runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
0xA9B40000u, 1UL);
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
0xA9B40000u, 1UL, ready: true);
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
CreateFirstEntryDrive(runtime);
RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
.Canonical!;
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
record,
record.CreateIntegrationVersion,
record.Snapshot,
replaceGeneration: false));
var collision = new FixtureCollisionNeighborhood();
RuntimeAcceptedPositionDriveController acceptedPositionDrive =
CreateAcceptedPositionDrive(runtime);
var projection = new HeadlessSessionWorldProjection(
runtime,
collision,
firstEntry,
acceptedPositionDrive);
projection.ProjectSpawn(record, isLocalPlayer: true);
PlayerMovementController controller =
Assert.IsType<PlayerMovementController>(
runtime.MovementOwner.Controller);
controller.SetPosition(
new Vector3(48f, 49f, 50f),
0xA9B40001u);
projection.ProjectPosition(
record,
isLocalPlayer: true,
PositionTimestampDisposition.Apply);
projection.BeginTeleport();
var destination = new RuntimeTeleportDestination(
player,
InstanceSequence: 1,
PositionSequence: 2,
TeleportSequence: 1,
ForcePositionSequence: 0,
new Position(
destinationLandblock | 0x0001u,
new Vector3(10f, 10f, 50f),
Quaternion.Identity));
var projectionToken = new RuntimeWorldHostProjectionToken(
7, destinationLandblock | 0x0001u);
// Begin's own portal-vs-latest-cell gate (D-T5) requires the
// destination's landblock to match the record's LATEST MERGED
// Position, not just the transit's retained destination - mirror
// what the real inbound Position handler already does before
// TryCompletePortal ever runs (LiveEntityNetworkUpdateController's
// App-side equivalent).
Assert.True(runtime.EntityObjects.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
player,
new CreateObject.ServerPosition(
destination.Position.ObjCellId,
destination.Position.Frame.Origin.X,
destination.Position.Frame.Origin.Y,
destination.Position.Frame.Origin.Z,
destination.Position.Frame.Orientation.W,
destination.Position.Frame.Orientation.X,
destination.Position.Frame.Orientation.Y,
destination.Position.Frame.Orientation.Z),
Velocity: null,
PlacementId: null,
IsGrounded: true,
InstanceSequence: 1,
PositionSequence: 3,
TeleportSequence: destination.TeleportSequence,
ForcePositionSequence: 0),
isLocalPlayer: true,
forcePositionRotation: Quaternion.Identity,
currentLocalVelocity: Vector3.Zero,
acknowledgeProjection: null,
out _,
out _,
out _));
// First attempt: destinationLandblock's collision generation was
// never begun/committed, so the canonical arm parks DeferredCell.
// Must NOT throw (a park is normal, not an error). The dormant
// stage (RuntimeSetPositionState's SubmitPreparedPlacementCore
// deferred-commit path) already stages the body's Position/CellId
// at the destination while it waits (StageDormantCellFrame,
// body.InWorld=false) - the reader-visible Position moving early is
// that mechanism, not evidence the placement committed; only
// PlayerState/IsCollisionReady distinguish "staged" from
// "committed" here.
RuntimeDestinationReadiness parked = projection.PrepareDestination(
revealGeneration: 7, destination, projectionToken);
Assert.False(parked.IsCollisionReady);
Assert.Equal(PlayerState.PortalSpace, controller.State);
// A SECOND attempt while still parked must not double-Begin -
// Runtime's own Begin would refuse a genuine second attempt with
// Contention, but PrepareDestination's _awaitingPortalWake polls
// PendingCount instead of re-attempting, so this must also report
// not-ready without throwing.
RuntimeDestinationReadiness stillParked =
projection.PrepareDestination(
revealGeneration: 7, destination, projectionToken);
Assert.False(stillParked.IsCollisionReady);
// Commit the destination landblock's collision generation and pump
// the drive's wake (mirrors HeadlessSessionHost.Tick's own
// PumpFirstEntry -> _acceptedPositionDrive.Advance() ordering).
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
destinationLandblock, 1UL);
var heights = new byte[81];
Array.Fill(heights, (byte)50);
var heightTable = new float[256];
for (int index = 0; index < heightTable.Length; index++)
heightTable[index] = index;
runtime.EntityObjects.Physics.Engine.AddLandblock(
destinationLandblock,
new TerrainSurface(heights, heightTable),
[],
[],
worldOffsetX: 0f,
worldOffsetY: 0f);
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
destinationLandblock, 1UL, ready: true);
// RuntimeAcceptedPositionDriveControllerTests.CommitLandblockCollision's
// exact proven-working shape: the wake path's
// resolveWorldOffsetFromRuntimeFrame requires the destination
// landblock's world-frame offset to already be resolvable.
runtime.EntityObjects.Physics.ObserveLocalWorldFrame(
destinationLandblock | 0x0001u,
teleportAdvanced: false);
// Drain the placement projection FIFO AFTER committing (exact order
// from RuntimeAcceptedPositionDriveControllerTests.DrainPlacementFifo's
// call site: commit collision -> drain FIFO -> Advance) - the
// deferred park's Withdraw notification is published as part of the
// collision-generation commit, not before it.
while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection(
out RuntimePlacementProjectionSnapshot head))
{
if (!runtime.EntityObjects.Physics.SetPosition
.AcknowledgeProjection(head.Token))
{
break;
}
}
acceptedPositionDrive.Advance();
RuntimeDestinationReadiness committed =
projection.PrepareDestination(
revealGeneration: 7, destination, projectionToken);
Assert.True(committed.IsCollisionReady);
Assert.Equal(PlayerState.InWorld, controller.State);
Assert.Equal(10f, controller.Position.X);
Assert.Equal(10f, controller.Position.Y);
Assert.Equal(destinationLandblock, controller.CellId & 0xFFFF0000u);
}
/// <summary>
/// B1 review fix (2026-08-05): headless's required test #2 — the same
/// unsound-commit-inference defect
/// <see cref="HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake"/>
/// proves the HAPPY path for, exercised on the FORGOTTEN path instead. A
/// DeferredCell park killed by an ordinary, unrelated accepted Position
/// merge (exactly the ACE 5-10 Hz broadcast <c>RuntimeSetPositionState</c>'s
/// own doc names as the expected way a far-destination park resolves
/// without committing) must leave <c>PrepareDestination</c> reporting
/// NOT ready, <see cref="PlayerState.PortalSpace"/> unchanged, and the
/// body never moved to the destination - before the fix,
/// <c>PendingCount</c> hitting 0 made <c>PrepareDestination</c> infer
/// "committed" and run the full readiness/materialize/LoginComplete
/// sequence against an unmoved body.
/// </summary>
[Fact]
public void HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted()
{
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;
Assert.Equal(
RuntimeSessionStartStatus.Connected,
host.Start().Status);
const uint player = 0x50000009u;
const uint destinationLandblock = 0xAAB40000u;
runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
0xA9B40000u, 1UL);
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
0xA9B40000u, 1UL, ready: true);
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
CreateFirstEntryDrive(runtime);
RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
.Canonical!;
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
record,
record.CreateIntegrationVersion,
record.Snapshot,
replaceGeneration: false));
var collision = new FixtureCollisionNeighborhood();
RuntimeAcceptedPositionDriveController acceptedPositionDrive =
CreateAcceptedPositionDrive(runtime);
var projection = new HeadlessSessionWorldProjection(
runtime,
collision,
firstEntry,
acceptedPositionDrive);
projection.ProjectSpawn(record, isLocalPlayer: true);
PlayerMovementController controller =
Assert.IsType<PlayerMovementController>(
runtime.MovementOwner.Controller);
controller.SetPosition(
new Vector3(48f, 49f, 50f),
0xA9B40001u);
projection.ProjectPosition(
record,
isLocalPlayer: true,
PositionTimestampDisposition.Apply);
projection.BeginTeleport();
var destination = new RuntimeTeleportDestination(
player,
InstanceSequence: 1,
PositionSequence: 2,
TeleportSequence: 1,
ForcePositionSequence: 0,
new Position(
destinationLandblock | 0x0001u,
new Vector3(10f, 10f, 50f),
Quaternion.Identity));
var projectionToken = new RuntimeWorldHostProjectionToken(
7, destinationLandblock | 0x0001u);
Assert.True(runtime.EntityObjects.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
player,
new CreateObject.ServerPosition(
destination.Position.ObjCellId,
destination.Position.Frame.Origin.X,
destination.Position.Frame.Origin.Y,
destination.Position.Frame.Origin.Z,
destination.Position.Frame.Orientation.W,
destination.Position.Frame.Orientation.X,
destination.Position.Frame.Orientation.Y,
destination.Position.Frame.Orientation.Z),
Velocity: null,
PlacementId: null,
IsGrounded: true,
InstanceSequence: 1,
PositionSequence: 3,
TeleportSequence: destination.TeleportSequence,
ForcePositionSequence: 0),
isLocalPlayer: true,
forcePositionRotation: Quaternion.Identity,
currentLocalVelocity: Vector3.Zero,
acknowledgeProjection: null,
out _,
out _,
out _));
// First attempt: destinationLandblock's collision generation was
// never begun/committed, so the canonical arm parks DeferredCell.
RuntimeDestinationReadiness parked = projection.PrepareDestination(
revealGeneration: 7, destination, projectionToken);
Assert.False(parked.IsCollisionReady);
Assert.Equal(PlayerState.PortalSpace, controller.State);
Assert.Equal(1, acceptedPositionDrive.PendingCount);
// An ordinary, UNRELATED accepted Position for the same entity - no
// new teleport, just a normal broadcast at the SAME already-accepted
// teleport sequence - Forgets the parked operation the same way
// ACE's 5-10 Hz cadence would (RuntimeSetPositionState.Forget, called
// from TryApplyPosition for every accepted, non-Rejected Position).
Assert.True(runtime.EntityObjects.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
player,
new CreateObject.ServerPosition(
0x20210001u, 48f, 49f, 50f, 1f, 0f, 0f, 0f),
Velocity: null,
PlacementId: null,
IsGrounded: true,
InstanceSequence: 1,
PositionSequence: 4,
TeleportSequence: destination.TeleportSequence,
ForcePositionSequence: 0),
isLocalPlayer: true,
forcePositionRotation: Quaternion.Identity,
currentLocalVelocity: Vector3.Zero,
acknowledgeProjection: null,
out _,
out _,
out _));
// Forget (inside TryApplyPosition) cancels the underlying
// RuntimeSetPositionState operation immediately, but the drive's OWN
// _pending cache only notices on its next Advance() pump - the real
// host does this every HeadlessSessionHost.Tick via PumpFirstEntry;
// the test drives it explicitly, same as the App-level equivalent.
acceptedPositionDrive.Advance();
Assert.Equal(0, acceptedPositionDrive.PendingCount);
// Drive well past where the pre-fix inference would have latched
// "committed" on the very next PrepareDestination call and then
// marched to the full readiness/materialize/LoginComplete sequence.
for (int i = 0; i < 10; i++)
{
RuntimeDestinationReadiness stillNotReady =
projection.PrepareDestination(
revealGeneration: 7, destination, projectionToken);
Assert.False(stillNotReady.IsCollisionReady);
}
Assert.Equal(PlayerState.PortalSpace, controller.State);
}
[Fact]