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

@ -97,16 +97,26 @@ internal sealed class HeadlessRuntimePlacementProjectionSink
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(
if (record.PositionAuthorityVersion != token.PositionAuthorityVersion
|| record.SpatialAuthorityVersion != token.SpatialAuthorityVersion
|| record.PlacementCommitVersion != token.PlacementCommitVersion
|| record.FullCellId != token.ExactCellId)
{
return false;
}
if (!_runtime.TransitOwner.IsCurrentPlacementAuthority(
token.Portal,
token.ExactCellId);
token.ExactCellId))
{
// B2 review fix (2026-08-05): acknowledge-and-ignore, the same
// shape and reasoning as the graphical sink's identical fix
// (RuntimePlacementPresentationSink.TryApply) — a stale portal
// authority must not wedge the ordered FIFO for every entity.
return true;
}
return true;
}
private static bool HasValidPortalShape(

View file

@ -3,6 +3,7 @@ using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics;
using AcDream.Headless.Policies;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
@ -143,6 +144,16 @@ internal sealed class HeadlessSessionHost : IDisposable
private RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
private AcDream.Core.Net.WorldSession? _currentSession;
private HeadlessSessionWorldProjection? _worldProjection;
/// <summary>
/// A1/A3 review fix (2026-08-05): retained so <see cref="Tick"/> can
/// pump <see cref="RuntimeLiveEntitySessionController.PumpPortalCompletion"/>
/// alongside <see cref="_worldProjection"/>'s own
/// <c>PumpFirstEntry</c> — a parked portal placement must retry on the
/// host's own per-tick cadence rather than the completion sequence
/// running unconditionally the instant it is first attempted. Reassigned
/// on every reconnect exactly like <see cref="_worldProjection"/>.
/// </summary>
private RuntimeLiveEntitySessionController? _entities;
/// <summary>C4 route 4b-1 (N3): the exact route <see cref="CreateEventRoute"/>
/// last constructed, so <see cref="Tick"/> can republish the canonical
/// placement FIFO every tick — mirrors the graphical host's per-frame
@ -332,6 +343,12 @@ internal sealed class HeadlessSessionHost : IDisposable
// collision-generation progress and freshly accepted Creates both
// surface here, mirroring the graphical per-frame retry phase.
_worldProjection?.PumpFirstEntry();
// A1/A3 review fix (2026-08-05): retry a parked portal completion
// (see RuntimeLiveEntitySessionController.PumpPortalCompletion) on
// the SAME per-tick cadence, after first-entry so a DeferredCell
// wake first-entry's own pump just resolved is picked up the same
// tick.
_entities?.PumpPortalCompletion();
// C4 route 4b-1 (N3): republish the canonical placement FIFO LAST,
// same order as the graphical host's retry-lease callback (drives
// first, retry last) — a declined Place left at the FIFO head by
@ -621,6 +638,13 @@ internal sealed class HeadlessSessionHost : IDisposable
Radius: 0.48f,
Height: 1.835f,
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
// D-T8 (temporary probe): labels every subsequent
// PhysicsDiagnostics.LogLocalTeleportArrival line from THIS
// process as headless — Runtime itself has no host-kind concept
// (Slice K keeps it presentation-agnostic), so this is a
// diagnostics-only label set once at composition time, not a
// Runtime dependency.
PhysicsDiagnostics.LocalTeleportHostKind = "headless";
// C4 route 2: one drive controller per host, mirroring
// _firstEntryDrive exactly — same persistent Runtime lifetime,
// collision source, and clock.
@ -633,7 +657,20 @@ internal sealed class HeadlessSessionHost : IDisposable
() => Runtime.PlayerIdentity.ServerGuid,
() => Runtime.MovementOwner.Controller,
() => Runtime.CharacterOwner.UsePositionFromServer,
() => _currentSession);
() => _currentSession,
// C4 route 3: the portal arm's PlayerTeleported port needs
// the J5.4 autorun latch owner, one level above the raw
// controller.
() => Runtime.MovementOwner,
// A2/D-T2.4 review fix (2026-08-05): same wiring as the
// graphical composition (SessionPlayerComposition.cs) — the
// SAME idempotent query TryCompletePortal/PrepareDestination
// themselves use.
isPortalAuthorityCurrent: portal => Runtime.TransitOwner
.CanPlacePortalDestination(
portal.RevealGeneration,
portal.TeleportSequence,
portal.Projection.DestinationCell));
var projection = new HeadlessSessionWorldProjection(
Runtime,
content,
@ -651,6 +688,7 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime.Generation.Value),
worldProjection,
_acceptedPositionDrive);
_entities = entities;
var route = new LiveSessionEventRouter(
session,
entities.CreateSink(),

View file

@ -8,6 +8,7 @@ using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
namespace AcDream.Headless.Hosting;
@ -585,9 +586,6 @@ internal sealed class HeadlessCollisionNeighborhood
internal sealed class HeadlessSessionWorldProjection
: IRuntimeDirectWorldProjection
{
private const float DefaultRadius = 0.48f;
private const float DefaultHeight = 1.835f;
private readonly GameRuntime _runtime;
private readonly IHeadlessCollisionNeighborhood _collision;
private readonly RuntimeFirstEntryDriveController? _firstEntry;
@ -749,90 +747,228 @@ internal sealed class HeadlessSessionWorldProjection
controller.State = PlayerState.PortalSpace;
}
/// <summary>
/// A1/A3 review fix (2026-08-05): true while a <c>DeferredCell</c> park
/// from a PRIOR call to this method is outstanding for the local
/// player's one possible pending drive operation. Mirrors
/// <c>LocalPlayerTeleportController._awaitingDeferredWake</c> on the
/// graphical side — avoids re-attempting
/// <c>TryExecuteAcceptedPortalArrival</c> while parked (Runtime's own
/// Begin would just refuse a second overlapping attempt with
/// <c>Contention</c>) by polling
/// <see cref="RuntimeAcceptedPositionDriveController.PendingCount"/>
/// instead.
/// </summary>
private bool _awaitingPortalWake;
/// <summary>
/// B3 review fix (2026-08-05): keys <see cref="_awaitingPortalWake"/> to
/// the exact reveal it was armed for. The graphical twin
/// (<c>_awaitingDeferredWake</c>) is naturally reset per teleport via
/// <c>ResetTransit</c>; this class is constructed per SESSION, not per
/// teleport, so without this a stale latch from reveal N could silently
/// steal reveal N+1's <c>PrepareDestination</c> call into polling a park
/// that belongs to a different, already-abandoned reveal — skipping the
/// new reveal's placement attempt entirely.
/// </summary>
private long _awaitingPortalWakeGeneration;
private ushort _awaitingPortalWakeSequence;
/// <summary>
/// N3 review fix (2026-08-05): bounds how many consecutive
/// <c>NotApplicable</c> attempts this host tolerates before treating the
/// condition as unrecoverable. <c>NotApplicable</c> covers hydration-race
/// transients (no canonical body yet, an active initial-Create residence
/// still owning the record) as well as a genuinely stale reveal — unlike
/// <c>Rejected</c>, it is not established to be permanent, and this host
/// must survive K4's 30-session / two-hour endurance profile without a
/// transient becoming fatal.
/// </summary>
private int _notApplicableRetryCount;
private const int NotApplicableRetryBudget = 50;
/// <summary>
/// C4 route 3 (D-T6): the portal-arrival placement runs through the
/// SAME canonical Runtime portal arm the graphical host drives
/// (<see cref="RuntimeAcceptedPositionDriveController.TryExecuteAcceptedPortalArrival"/>),
/// retiring the duplicate Resolve/ResolvePlacement/SetPosition authority
/// this method used to own directly (D2;
/// docs/research/2026-08-04-c4-route-3-contract.md D-T6).
///
/// <para>
/// A1/A3 review fix (2026-08-05): the first pass discarded the arm's
/// returned status entirely (<c>_ = ...</c>) and always reported
/// success, so <c>RuntimeLiveEntitySessionController.TryCompletePortal</c>
/// acknowledged a materialization that never happened on ANY refusal
/// (architecture review A3). This method now:
/// </para>
/// <list type="bullet">
/// <item><description>throws if no drive controller was wired — a
/// composition regression must not silently disable placement, never
/// pretend success (A3's second finding);</description></item>
/// <item><description>does not even ATTEMPT the placement until
/// <see cref="_collision"/> reports the destination resident — this
/// host's narrow collision window makes a premature attempt a
/// guaranteed <c>DeferredCell</c>, and <see cref="_collision"/>'s own
/// readiness IS the signal <see cref="IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition"/>'s
/// doc names as the precondition for a park to ever resolve;</description></item>
/// <item><description>reports <c>IsCollisionReady: false</c> — never
/// success — for every non-<c>Committed</c> outcome, so the caller's
/// retry loop (<c>RuntimeLiveEntitySessionController.PumpPortalCompletion</c>,
/// A1's headless-side fix) keeps calling this method instead of the
/// completion sequence running against an unplaced body; a genuine
/// <c>DeferredCell</c> is therefore never an error, only a wait — and
/// throws only for the two statuses that mean something is actually
/// wrong (<c>Rejected</c>/<c>NotApplicable</c> — the reveal itself is
/// stale, or the local player has no canonical body, neither of which
/// a headless bot can recover from by waiting).</description></item>
/// </list>
/// </summary>
public RuntimeDestinationReadiness PrepareDestination(
long revealGeneration,
RuntimeTeleportDestination destination)
RuntimeTeleportDestination destination,
RuntimeWorldHostProjectionToken portal)
{
_collision.CenterOn(destination.CellId);
if (_runtime.EntityObjects.Entities.TryGetActive(
destination.EntityGuid,
out RuntimeEntityRecord record))
if (_acceptedPositionDrive is null)
{
ResynchronizeLocalPlayerForPortalArrival(record);
throw new InvalidOperationException(
"Headless portal placement requires a wired "
+ "RuntimeAcceptedPositionDriveController - a composition "
+ "regression must not silently disable placement (A3).");
}
if (_runtime.MovementOwner.Controller is { } controller)
// B3: a latch armed for a DIFFERENT reveal must not be consulted
// for this one — fall through to a fresh attempt below instead.
if (_awaitingPortalWake
&& (_awaitingPortalWakeGeneration != revealGeneration
|| _awaitingPortalWakeSequence != destination.TeleportSequence))
{
_awaitingPortalWake = false;
}
bool committed;
if (_awaitingPortalWake)
{
if (_acceptedPositionDrive.PendingCount != 0)
{
committed = false;
}
else
{
// B1 review fix (2026-08-05): "not pending" is not
// "committed" — the drive's own doc names a merge-time
// Forget (an ordinary ACE broadcast arriving mid-park) as
// the EXPECTED way a park resolves without committing.
// TryConsumePortalCommit is the drive's OWN record of
// whether ITS commit actually happened for this exact
// reveal/sequence, latched only inside a real
// ReconcileAndAcknowledgePortal call — never inferred.
_awaitingPortalWake = false;
committed = _acceptedPositionDrive.TryConsumePortalCommit(
revealGeneration, destination.TeleportSequence);
// A "no" here falls through to committed=false below; the
// NEXT PrepareDestination call re-attempts fresh since
// _awaitingPortalWake is now false and nothing is pending.
}
}
else if (!_collision.IsReady(destination.CellId))
{
committed = false;
}
else
{
var authority = new RuntimePortalPlacementAuthority(
Present: true,
RevealGeneration: revealGeneration,
TeleportSequence: destination.TeleportSequence,
Projection: portal);
RuntimeAcceptedPositionExecutionStatus status =
_acceptedPositionDrive.TryExecuteAcceptedPortalArrival(
destination,
authority);
switch (status)
{
case RuntimeAcceptedPositionExecutionStatus.Committed:
committed = true;
_notApplicableRetryCount = 0;
break;
case RuntimeAcceptedPositionExecutionStatus.DeferredCell:
_awaitingPortalWake = true;
_awaitingPortalWakeGeneration = revealGeneration;
_awaitingPortalWakeSequence = destination.TeleportSequence;
committed = false;
break;
case RuntimeAcceptedPositionExecutionStatus.Contention:
// Transient - some other operation still owns the
// entity's placement token. Retried next pump; never a
// hard error, matching the graphical arm's D-T5
// refusal shape.
committed = false;
break;
case RuntimeAcceptedPositionExecutionStatus.NotApplicable:
// N3 review fix (2026-08-05): NotApplicable covers
// hydration-race transients (record.PhysicsBody is
// null, or an active initial-Create residence still
// owns the record — RuntimeAcceptedPositionDriveController's
// own guard) as well as a genuinely stale reveal; unlike
// Rejected it is not established to be permanent.
// Bounded, loud retry rather than an immediate throw —
// this host must survive K4's 30-session/two-hour
// endurance profile without a transient becoming fatal.
_notApplicableRetryCount++;
PhysicsDiagnostics.LogTeleport(
"REFUSED",
destination.CellId,
$"cause=NotApplicable attempt={_notApplicableRetryCount}");
if (_notApplicableRetryCount > NotApplicableRetryBudget)
{
throw new InvalidOperationException(
"Headless portal placement stayed NotApplicable "
+ $"for {_notApplicableRetryCount} consecutive "
+ "attempts (no canonical body, or an active "
+ "initial-Create residence still owns the "
+ "record) - exceeded the bounded retry budget.");
}
committed = false;
break;
default:
throw new InvalidOperationException(
$"Headless portal placement refused with "
+ $"status={status} even though the destination's "
+ "collision neighborhood reported ready - the "
+ "reveal itself is stale, not recoverable by "
+ "waiting (contract §4 item 5 forbids "
+ "acknowledging a materialization that did not "
+ "happen).");
}
}
if (committed && _runtime.MovementOwner.Controller is { } controller)
controller.State = PlayerState.InWorld;
bool ready = _collision.IsReady(destination.CellId);
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
return new RuntimeDestinationReadiness(
revealGeneration,
destination.CellId,
indoor,
IsUnhydratable: !ready,
// N2 review fix (2026-08-05): hardcoded false, NOT DERIVED.
// AD-2's "loud unhydratable-placement path" (a claim beyond
// NumCells) is a graphical-only concept today —
// WorldRevealReadinessBarrier's render/composite-texture domains
// have no headless analogue, so there is no local predicate this
// no-window host could evaluate. A genuinely unhydratable
// destination therefore reports IsCollisionReady=false forever
// (via the bounded DeferredCell retry above) rather than taking
// AD-2's loud path — headless does not model unhydratable
// claims. If headless ever gains its own resident-cell-set
// concept, derive the real predicate here instead of leaving
// this hardcoded.
IsUnhydratable: false,
RequiredRenderRadius: indoor ? 0 : 1,
IsRenderNeighborhoodReady: true,
AreCompositeTexturesReady: true,
IsCollisionReady: ready);
}
/// <summary>
/// TODO-C4 (route 3): portal-arrival re-synchronization only. The
/// route-1/8 initial-entry hand-copy (controller construction + first
/// resolve/placement) was deleted at C3c — the first-entry conductor's
/// publication chain owns it — but the portal route is unflipped, so its
/// arrival re-resolve keeps today's exact behavior against the
/// already-published controller until C4 routes it through
/// RuntimePortalPlacementAuthority.
/// </summary>
private void ResynchronizeLocalPlayerForPortalArrival(
RuntimeEntityRecord record)
{
if (record.ServerGuid
!= _runtime.PlayerIdentity.ServerGuid
|| record.Snapshot.Position is not { } position
|| _runtime.MovementOwner.Controller is not { } controller)
{
return;
}
_collision.CenterOn(position.LandblockId);
Vector3 wirePosition = new(
position.PositionX,
position.PositionY,
position.PositionZ);
Quaternion orientation = new(
position.RotationX,
position.RotationY,
position.RotationZ,
position.RotationW);
ResolveResult resolved =
_runtime.EntityObjects.Physics.Engine.Resolve(
wirePosition,
position.LandblockId,
Vector3.Zero,
100f);
ResolveResult placement =
_runtime.EntityObjects.Physics.Engine.ResolvePlacement(
resolved.Position,
resolved.CellId,
DefaultRadius,
DefaultHeight,
controller.StepUpHeight,
controller.StepDownHeight,
ObjectInfoState.IsPlayer
| ObjectInfoState.EdgeSlide,
record.LocalEntityId ?? 0u);
if (placement.Ok)
resolved = placement;
controller.LocalEntityId = record.LocalEntityId ?? 0u;
controller.SetPosition(
resolved.Position,
resolved.CellId,
wirePosition);
controller.SetBodyOrientation(orientation);
IsCollisionReady: committed);
}
}