feat(physics): C4 route 2 — ForcePosition through the canonical placement
A local-player ForcePosition had TWO independent writers for one accepted
packet: LocalForcePositionTransaction snapped the physics body
(PlayerMovementController.BlipPosition, a raw SnapToCell with no collision
resolve), while LiveEntityNetworkUpdateController's generic tail separately
wrote position/cell/rotation to the render WorldEntity from the raw wire and
rebucketed it. Two stores, one packet — the divergence class 670f307c fixed on
the remote path. The outbound AutonomousPosition ack also fired BEFORE any
canonical commit existed: we told ACE "got it, I'm here" before deciding where
"here" was, and the trailing isCurrent() could only suppress the continuation,
never recall the packet.
RuntimeAcceptedPositionDriveController is now the one Runtime-owned seam. Both
hosts call the identical TryExecuteAcceptedLocalPosition; App and headless
project the committed result through the existing placement projection sink
(LiveEntityRuntime.TryApplyRuntimePlacementPlace already performed the same
four writes, from committed state rather than a wire guess).
Retail: SmartBox::HandleReceivedPosition @0x00453FD0's FORCE_POSITION branch is
get_heading -> Frame::set_heading -> SmartBox::BlipPlayer @0x00453940 -> stamp
POSITION_TS -> SendPositionEvent @0x00454091 -> return @0x0045409D. BlipPlayer
is CPhysicsObj::SetPositionSimple @0x005162B0 with flags 0x1012
(Teleport|Slide|SendPositionEvent) — a real collision-resolving SetPosition,
not a snap. The pinned classifier already encoded this exactly.
Named behaviour changes:
* The ack is now an OUTPUT of the committed route, fired strictly after the
canonical commit and exactly once per accepted force packet.
* The ForcePosition route no longer re-arms the constraint leash. The force
branch returns at 0x0045409D, ahead of all three ConstrainTo sites
(0x00454272, 0x0045418A, 0x004541EC); the old re-arm cited retail's "Player,
normal" branch, which BlipPlayer is not on. The teleport, CommitPreparedPosition
and first-entry callers legitimately still constrain and are untouched.
* A force correction that terminates WITHOUT committing still sends its
position event and is not retried — retail's BlipPlayer discards
SetPositionSimple's SetPositionError return and acks unconditionally.
A single _pending funnel owns the in-flight placement, deciding on the token's
PositionAuthorityVersion against the record's: equal -> clear; advanced with the
newest accepted event still a force -> re-issue, re-classified; advanced to an
ordinary Apply -> clear, since newer server truth owns that pose. This closes a
double-apply/double-ack and a silently-dropped correction that two earlier
iterations of this slice each introduced.
AD-62 records the residual: a ForcePosition our async collision publication
cannot carry to a committed placement is not re-applied. Retail has no park —
its world is fully resident and its placement synchronous — so the state is
unreachable there. AP-131 is NOT retired; its legacy Position caller is route 4.
Deleted: LocalForcePositionTransaction, PlayerMovementController.BlipPosition,
HeadlessSessionWorldProjection.BlipLocalPlayer.
Gates: complete Release solution 10,858 passed / 4 skipped / 0 failed (baseline
10,844/4/0). Two independent Opus reviews (retail-conformance and
architecture/adversarial) PASS on the final diff after three FAIL rounds; every
intermediate state was fully green, so the suite caught none of the four real
defects. Connected acceptance is NOT run: nothing a user can do makes ACE emit
a ForcePosition without retail's @pklite, which acdream does not implement — see
docs/research/2026-08-03-c4-route-2-visual-gate.md.
Known gap, recorded not claimed: the plan's acceptance item 2 is unmet. The App
double-write check is a source pin, and "the committed projection moves the
render entity" is uncovered at any layer (#292). Filed alongside: #286-#291,
#293-#296.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
22a5c95400
commit
9966b53174
25 changed files with 4292 additions and 195 deletions
|
|
@ -573,6 +573,21 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
.RegisteredAuthoredPayload
|
||||
: RuntimeLocalPlayerShadowDisposition.ProvenShapeless);
|
||||
});
|
||||
// C4 route 2 (2026-08-03): the graphical accepted-Position drive
|
||||
// controller — the Runtime-owned execution seam for a ForcePosition
|
||||
// on the already-live local player. Constructed once per session
|
||||
// route alongside firstEntryDrive; both share the SAME entity
|
||||
// lifetime, collision source, and clock.
|
||||
var acceptedPositionDrive = new RuntimeAcceptedPositionDriveController(
|
||||
d.EntityObjects,
|
||||
d.Runtime.Clock,
|
||||
firstEntryCollision,
|
||||
d.PlayerOutbound,
|
||||
() => d.Runtime.Generation,
|
||||
() => d.PlayerIdentity.ServerGuid,
|
||||
() => d.PlayerController.Controller,
|
||||
() => d.Character.UsePositionFromServer,
|
||||
() => liveSessionSource.CurrentSession);
|
||||
var hydration = new LiveEntityHydrationController(
|
||||
live.LiveEntities,
|
||||
d.EntityObjects,
|
||||
|
|
@ -591,7 +606,8 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
deletion,
|
||||
dormantLiveEntities,
|
||||
d.Options.DumpLiveSpawns ? d.Log : null,
|
||||
firstEntryDrive);
|
||||
firstEntryDrive,
|
||||
acceptedPositionDrive);
|
||||
bindings.Adopt(
|
||||
"landblock-loaded hydration",
|
||||
live.LandblockLoaded.Bind(hydration));
|
||||
|
|
@ -637,6 +653,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
liveSessionSource,
|
||||
localPhysicsTimestamps.Publish,
|
||||
d.MovementDiagnostics,
|
||||
acceptedPositionDrive,
|
||||
worldDropProjection);
|
||||
var liveness = new LiveEntityLivenessController(
|
||||
live.LiveEntities,
|
||||
|
|
@ -959,7 +976,8 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
live.RenderSceneShadow,
|
||||
live.PlacementProjection,
|
||||
placementProjectionRetry,
|
||||
firstEntryDrive),
|
||||
firstEntryDrive,
|
||||
acceptedPositionDrive),
|
||||
liveSessionCommands,
|
||||
d.Log);
|
||||
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
private readonly Func<RuntimeGenerationToken> _generation;
|
||||
private readonly RuntimePlacementProjectionRetrySlot _retries;
|
||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||
private readonly Action<RuntimeEntityRecord>? _localPlayerCompleted;
|
||||
private RuntimePlacementProjectionSubscription? _subscription;
|
||||
private IDisposable? _retryLease;
|
||||
|
|
@ -30,7 +31,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
IRuntimePlacementProjectionSink placements,
|
||||
RuntimePlacementProjectionRetrySlot retries,
|
||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
|
||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null,
|
||||
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||
: this(
|
||||
events,
|
||||
() => new RuntimePlacementProjectionSubscription(
|
||||
|
|
@ -40,7 +42,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
() => runtime.Generation,
|
||||
retries,
|
||||
firstEntry,
|
||||
localPlayerCompleted)
|
||||
localPlayerCompleted,
|
||||
acceptedPositionDrive)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(placements);
|
||||
|
|
@ -52,7 +55,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
Func<RuntimeGenerationToken> generation,
|
||||
RuntimePlacementProjectionRetrySlot retries,
|
||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
|
||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null,
|
||||
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||
{
|
||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||
_createSubscription = createSubscription
|
||||
|
|
@ -62,6 +66,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
_retries = retries ?? throw new ArgumentNullException(nameof(retries));
|
||||
_firstEntry = firstEntry;
|
||||
_localPlayerCompleted = localPlayerCompleted;
|
||||
_acceptedPositionDrive = acceptedPositionDrive;
|
||||
}
|
||||
|
||||
public void Attach()
|
||||
|
|
@ -75,6 +80,10 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
// detached — session reset precedes a new route — before this route
|
||||
// takes ownership of the shared drive controller's tracked entries.
|
||||
_firstEntry?.AttachRoute(this, _localPlayerCompleted);
|
||||
// C4 route 2: same one-route-at-a-time latch for the accepted-
|
||||
// Position drive controller (RuntimeAcceptedPositionDriveController
|
||||
// .AttachRoute's doc comment).
|
||||
_acceptedPositionDrive?.AttachRoute(this);
|
||||
_events.Attach();
|
||||
|
||||
RuntimePlacementProjectionSubscription? subscription = null;
|
||||
|
|
@ -90,10 +99,13 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
// republishing the pending FIFO head — a conductor's own
|
||||
// Advance is what consumes conductor-owned receipts, and the
|
||||
// subsequent RetryPending lets the presentation sink apply
|
||||
// whatever new head the drive surfaced.
|
||||
// whatever new head the drive surfaced. C4 route 2: the
|
||||
// accepted-Position drive's own Advance resolves a parked
|
||||
// DeferredCell ForcePosition the same way.
|
||||
() =>
|
||||
{
|
||||
_firstEntry?.DriveAll();
|
||||
_acceptedPositionDrive?.Advance();
|
||||
return boundSubscription.RetryPending();
|
||||
});
|
||||
_subscription = subscription;
|
||||
|
|
@ -123,6 +135,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
|
|||
// route-scoped — a route that never attached cannot clear a live
|
||||
// route's entries.
|
||||
_firstEntry?.DetachRoute(this);
|
||||
_acceptedPositionDrive?.DetachRoute(this);
|
||||
if (!_eventsDisposed)
|
||||
{
|
||||
_events.Dispose();
|
||||
|
|
|
|||
|
|
@ -87,7 +87,8 @@ internal sealed record LiveSessionWorldRuntime(
|
|||
RenderSceneShadowRuntime? RenderSceneShadow,
|
||||
RuntimePlacementPresentationSink PlacementProjection,
|
||||
RuntimePlacementProjectionRetrySlot PlacementRetries,
|
||||
RuntimeFirstEntryDriveController FirstEntryDrive);
|
||||
RuntimeFirstEntryDriveController FirstEntryDrive,
|
||||
RuntimeAcceptedPositionDriveController AcceptedPositionDrive);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the exact per-generation route/reset graph for the canonical live
|
||||
|
|
@ -264,7 +265,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_world.PlacementProjection,
|
||||
_world.PlacementRetries,
|
||||
_world.FirstEntryDrive,
|
||||
_ => session.SendGameAction(GameActionLoginComplete.Build()));
|
||||
_ => session.SendGameAction(GameActionLoginComplete.Build()),
|
||||
_world.AcceptedPositionDrive);
|
||||
}
|
||||
|
||||
private LiveInventorySessionBindings CreateInventoryBindings() => new(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ using AcDream.Core.Items;
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter;
|
||||
|
|
@ -63,6 +64,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
|
||||
private readonly InventoryWorldDropProjectionController?
|
||||
_worldDropProjection;
|
||||
private readonly RuntimeAcceptedPositionDriveController _acceptedPositionDrive;
|
||||
|
||||
private PlayerMovementController? _playerController => _playerControllerSource.Controller;
|
||||
private EntityPhysicsHost? _playerHost => _playerHostSource.Host;
|
||||
|
|
@ -103,6 +105,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
ILiveWorldSessionSource session,
|
||||
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
|
||||
IMovementTruthDiagnosticSink movementTruthDiagnostics,
|
||||
RuntimeAcceptedPositionDriveController acceptedPositionDrive,
|
||||
InventoryWorldDropProjectionController? worldDropProjection = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
|
|
@ -138,6 +141,8 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
publishTimestamps);
|
||||
_movementTruthDiagnostics = movementTruthDiagnostics
|
||||
?? throw new ArgumentNullException(nameof(movementTruthDiagnostics));
|
||||
_acceptedPositionDrive = acceptedPositionDrive
|
||||
?? throw new ArgumentNullException(nameof(acceptedPositionDrive));
|
||||
_worldDropProjection = worldDropProjection;
|
||||
}
|
||||
|
||||
|
|
@ -1113,20 +1118,61 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
bool forceLocal = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
|
||||
&& update.Guid == _playerServerGuid
|
||||
&& _playerController is not null;
|
||||
if (!LocalForcePositionTransaction.Apply(
|
||||
forceLocal,
|
||||
() => IsCurrentPositionOwner(),
|
||||
() => _playerController!.BlipPosition(
|
||||
worldPos,
|
||||
p.LandblockId,
|
||||
new System.Numerics.Vector3(
|
||||
p.PositionX,
|
||||
p.PositionY,
|
||||
p.PositionZ)),
|
||||
() => _localPlayerOutbound.SendImmediatePosition(
|
||||
_session.CurrentSession,
|
||||
_playerController)))
|
||||
return;
|
||||
if (forceLocal)
|
||||
{
|
||||
if (!IsCurrentPositionOwner())
|
||||
return;
|
||||
|
||||
// C4 route 2 (2026-08-03): the Runtime-owned accepted-Position
|
||||
// execution seam replaces the deleted LocalForcePositionTransaction.
|
||||
// Ownership validation is the operation's own currency check,
|
||||
// the body commit is Runtime's canonical SetPosition transaction
|
||||
// (retail CPhysicsObj::SetPositionSimple @0x005162B0, called from
|
||||
// SmartBox::BlipPlayer @0x00453940), and the outbound ack is an
|
||||
// OUTPUT of that committed route, fired strictly after it.
|
||||
RuntimeAcceptedPositionExecutionStatus forceStatus =
|
||||
_acceptedPositionDrive.TryExecuteAcceptedLocalPosition(
|
||||
acceptedPositionCanonical,
|
||||
update,
|
||||
timestampDisposition,
|
||||
timestamps,
|
||||
timestamps.PreviousTeleport);
|
||||
if (forceStatus is RuntimeAcceptedPositionExecutionStatus.Committed
|
||||
or RuntimeAcceptedPositionExecutionStatus.DeferredCell)
|
||||
{
|
||||
// App projects the committed (or parked-toward) result
|
||||
// through the existing Runtime placement sink
|
||||
// (RuntimePlacementPresentationSink), which observes the
|
||||
// SAME Runtime SetPosition FIFO every other placement uses —
|
||||
// it must not ALSO independently mutate the render-facing
|
||||
// WorldEntity here (the retired duplicate-write authority:
|
||||
// the generic tail below). The two local-player side effects
|
||||
// this neighbourhood still owns independently of WorldEntity
|
||||
// position (owned-VFX pose-dirty tracking and the
|
||||
// pre-player-mode streaming landblock tracker) are
|
||||
// preserved — DeferredCell included, since following the
|
||||
// destination is what lets its streaming/collision window
|
||||
// eventually publish the generation the park is waiting on.
|
||||
_entityEffects?.MarkLiveOwnerPoseDirty(update.Guid);
|
||||
_authorityGate.ObserveAcceptedLocalPosition(
|
||||
update.Position.LandblockId);
|
||||
return;
|
||||
}
|
||||
if (forceStatus is not RuntimeAcceptedPositionExecutionStatus.NotApplicable)
|
||||
{
|
||||
// R9 review fix (2026-08-03): Rejected/Contention — no
|
||||
// correction was applied or parked for this exact packet.
|
||||
// Do NOT move the streaming observer or mark the render pose
|
||||
// dirty for a landblock this route explicitly declined to
|
||||
// place into, and do not fall through to the generic tail
|
||||
// below either (that would resurrect the retired duplicate-
|
||||
// write authority this whole route exists to remove).
|
||||
return;
|
||||
}
|
||||
// NotApplicable — e.g. an initial-Create residence still owns
|
||||
// this record (route 1's job). Fall through to the pre-existing
|
||||
// path unchanged, exactly as every other disposition does.
|
||||
}
|
||||
|
||||
// A leave-world transition deliberately retains WorldEntity as the
|
||||
// logical/render-resource owner while IsSpatiallyProjected is false.
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Preserves retail ForcePosition's atomic local order: validate the accepted
|
||||
/// Position authority, blip once, acknowledge once, then stop if the
|
||||
/// acknowledgement synchronously displaced that authority.
|
||||
/// </summary>
|
||||
internal static class LocalForcePositionTransaction
|
||||
{
|
||||
internal static bool Apply(
|
||||
bool isForcePosition,
|
||||
Func<bool> isCurrent,
|
||||
Action blip,
|
||||
Action acknowledge)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(isCurrent);
|
||||
ArgumentNullException.ThrowIfNull(blip);
|
||||
ArgumentNullException.ThrowIfNull(acknowledge);
|
||||
|
||||
if (!isForcePosition)
|
||||
return true;
|
||||
if (!isCurrent())
|
||||
return false;
|
||||
|
||||
blip();
|
||||
acknowledge();
|
||||
return isCurrent();
|
||||
}
|
||||
}
|
||||
|
|
@ -189,6 +189,13 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
|||
/// controller without one.
|
||||
/// </summary>
|
||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||
/// <summary>
|
||||
/// C4 route 2: the graphical accepted-Position drive controller — pumped
|
||||
/// alongside <see cref="_firstEntry"/> so a ForcePosition parked awaiting
|
||||
/// its destination collision generation resolves promptly. Optional for
|
||||
/// the same reason <see cref="_firstEntry"/> is.
|
||||
/// </summary>
|
||||
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||
private readonly Dictionary<RuntimeEntityRecord, CanonicalProjectionOperation>
|
||||
_projectionOperations =
|
||||
new(ReferenceEqualityComparer.Instance);
|
||||
|
|
@ -213,7 +220,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
|||
LiveEntityDeletionController deletion,
|
||||
DormantLiveEntityStore? dormant = null,
|
||||
Action<string>? diagnostic = null,
|
||||
RuntimeFirstEntryDriveController? firstEntry = null)
|
||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_entityObjects = entityObjects
|
||||
|
|
@ -230,6 +238,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
|||
_dormant = dormant ?? new DormantLiveEntityStore();
|
||||
_diagnostic = diagnostic;
|
||||
_firstEntry = firstEntry;
|
||||
_acceptedPositionDrive = acceptedPositionDrive;
|
||||
}
|
||||
|
||||
internal event Action<uint>? AppearanceApplied;
|
||||
|
|
@ -403,6 +412,8 @@ AppearanceSynchronization:
|
|||
// destination cell, FIFO ahead of us) is retried by the
|
||||
// per-frame placement retry phase.
|
||||
_firstEntry?.DriveAll();
|
||||
// C4 route 2: same pump for a parked ForcePosition.
|
||||
_acceptedPositionDrive?.Advance();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
|||
private readonly GameRuntime _runtime;
|
||||
private readonly IRuntimePlacementProjectionSink _placements;
|
||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||
private readonly Action<RuntimeEntityRecord>? _localPlayerCompleted;
|
||||
private RuntimePlacementProjectionSubscription? _subscription;
|
||||
private bool _attachStarted;
|
||||
|
|
@ -28,7 +29,8 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
|||
GameRuntime runtime,
|
||||
IRuntimePlacementProjectionSink placements,
|
||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null)
|
||||
Action<RuntimeEntityRecord>? localPlayerCompleted = null,
|
||||
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||
{
|
||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
|
|
@ -36,6 +38,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
|||
?? throw new ArgumentNullException(nameof(placements));
|
||||
_firstEntry = firstEntry;
|
||||
_localPlayerCompleted = localPlayerCompleted;
|
||||
_acceptedPositionDrive = acceptedPositionDrive;
|
||||
}
|
||||
|
||||
public void Attach()
|
||||
|
|
@ -52,6 +55,9 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
|||
// detached — session reset precedes a new route — before this route
|
||||
// takes ownership of the shared drive controller's tracked entries.
|
||||
_firstEntry?.AttachRoute(this, _localPlayerCompleted);
|
||||
// C4 route 2: same one-route-at-a-time latch for the accepted-
|
||||
// Position drive controller.
|
||||
_acceptedPositionDrive?.AttachRoute(this);
|
||||
_events.Attach();
|
||||
_subscription = new RuntimePlacementProjectionSubscription(
|
||||
_runtime,
|
||||
|
|
@ -73,6 +79,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
|
|||
// route-scoped — a route that never attached cannot clear a live
|
||||
// route's entries.
|
||||
_firstEntry?.DetachRoute(this);
|
||||
_acceptedPositionDrive?.DetachRoute(this);
|
||||
if (!_eventsDisposed)
|
||||
{
|
||||
_events.Dispose();
|
||||
|
|
|
|||
|
|
@ -126,6 +126,11 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
/// Runtime lifetime) plus the active world projection it pumps
|
||||
/// through.</summary>
|
||||
private RuntimeFirstEntryDriveController? _firstEntryDrive;
|
||||
/// <summary>C4 route 2 (2026-08-03): see the ctor comment in
|
||||
/// <see cref="CreateEventRoute"/> — cached across reconnects exactly
|
||||
/// like <see cref="_firstEntryDrive"/>.</summary>
|
||||
private RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||
private AcDream.Core.Net.WorldSession? _currentSession;
|
||||
private HeadlessSessionWorldProjection? _worldProjection;
|
||||
private int _disposeStage;
|
||||
private long _reconnectDeadline;
|
||||
|
|
@ -311,8 +316,20 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_policy.Tick(Runtime, Commands);
|
||||
}
|
||||
|
||||
internal RuntimeTeardownAcknowledgement Stop() =>
|
||||
Commands.Session.Stop(Runtime.Generation);
|
||||
internal RuntimeTeardownAcknowledgement Stop()
|
||||
{
|
||||
RuntimeTeardownAcknowledgement result =
|
||||
Commands.Session.Stop(Runtime.Generation);
|
||||
// R9 review fix (2026-08-03): _currentSession is cached across
|
||||
// reconnects (see CreateEventRoute's comment) so the accepted-
|
||||
// position drive controller's outbound-ack accessor always reads the
|
||||
// CURRENT session, never one captured at first construction. Without
|
||||
// clearing it here, that accessor would keep returning a stopped
|
||||
// (possibly disposed) WorldSession in the window between this Stop
|
||||
// and the next CreateEventRoute call.
|
||||
_currentSession = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
internal void Quarantine(Exception error)
|
||||
{
|
||||
|
|
@ -536,6 +553,25 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private ILiveSessionEventRouting CreateEventRoute(
|
||||
AcDream.Core.Net.WorldSession session)
|
||||
{
|
||||
// C4 route 2 (2026-08-03): reconnects construct a FRESH WorldSession
|
||||
// each call, but the accepted-Position drive controller below is
|
||||
// cached across reconnects (`??=`) exactly like _firstEntryDrive —
|
||||
// its ack-firing accessor must therefore read the CURRENT session
|
||||
// through this field, never one captured at first construction.
|
||||
_currentSession = session;
|
||||
// R9 review note (2026-08-03): a content-less host (_contentLease is
|
||||
// null — a validated-legal headless configuration, see
|
||||
// RuntimeLiveEntitySessionController.OnSpawned's own R3 comment)
|
||||
// never constructs _acceptedPositionDrive OR worldProjection below,
|
||||
// so a ForcePosition on such a host resolves NotApplicable with no
|
||||
// ProjectPosition fallback either. This is NOT a behaviour change:
|
||||
// a content-less host never registers a first-entry residence, so
|
||||
// Runtime.MovementOwner.Controller is always null there too, and
|
||||
// LocalPlayerOutboundController.SendImmediatePosition's own
|
||||
// controller-null guard already made the PRE-route-2 unconditional
|
||||
// ack call a no-op in this exact configuration. There is no live
|
||||
// controller for either the old or the new path to place or
|
||||
// acknowledge.
|
||||
IRuntimeDirectWorldProjection? worldProjection = null;
|
||||
if (_contentLease is { } content)
|
||||
{
|
||||
|
|
@ -557,10 +593,24 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
Radius: 0.48f,
|
||||
Height: 1.835f,
|
||||
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
||||
// C4 route 2: one drive controller per host, mirroring
|
||||
// _firstEntryDrive exactly — same persistent Runtime lifetime,
|
||||
// collision source, and clock.
|
||||
_acceptedPositionDrive ??= new RuntimeAcceptedPositionDriveController(
|
||||
Runtime.EntityObjects,
|
||||
Runtime.Clock,
|
||||
content.PreparedCollision,
|
||||
new LocalPlayerOutboundController((_, _, _, _, _, _) => { }),
|
||||
() => Runtime.Generation,
|
||||
() => Runtime.PlayerIdentity.ServerGuid,
|
||||
() => Runtime.MovementOwner.Controller,
|
||||
() => Runtime.CharacterOwner.UsePositionFromServer,
|
||||
() => _currentSession);
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
Runtime,
|
||||
content,
|
||||
_firstEntryDrive);
|
||||
_firstEntryDrive,
|
||||
_acceptedPositionDrive);
|
||||
_worldProjection = projection;
|
||||
worldProjection = projection;
|
||||
}
|
||||
|
|
@ -571,7 +621,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_descriptor.Id,
|
||||
message,
|
||||
Runtime.Generation.Value),
|
||||
worldProjection);
|
||||
worldProjection,
|
||||
_acceptedPositionDrive);
|
||||
var route = new LiveSessionEventRouter(
|
||||
session,
|
||||
entities.CreateSink(),
|
||||
|
|
@ -620,7 +671,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
Runtime,
|
||||
new HeadlessRuntimePlacementProjectionSink(Runtime),
|
||||
_firstEntryDrive,
|
||||
_ => session.SendGameAction(GameActionLoginComplete.Build()));
|
||||
_ => session.SendGameAction(GameActionLoginComplete.Build()),
|
||||
_acceptedPositionDrive);
|
||||
}
|
||||
|
||||
private static LiveSessionCharacterSelector MapCharacterSelector(
|
||||
|
|
|
|||
|
|
@ -504,29 +504,39 @@ internal sealed class HeadlessSessionWorldProjection
|
|||
private readonly GameRuntime _runtime;
|
||||
private readonly IHeadlessCollisionNeighborhood _collision;
|
||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||
/// <summary>
|
||||
/// C4 route 2 (2026-08-03): pumped alongside <see cref="_firstEntry"/> so
|
||||
/// a ForcePosition parked awaiting its destination collision generation
|
||||
/// resolves promptly.
|
||||
/// </summary>
|
||||
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||
private uint _requestedLocalPlayerCell;
|
||||
|
||||
internal HeadlessSessionWorldProjection(
|
||||
GameRuntime runtime,
|
||||
HeadlessProcessContentOwner.HeadlessProcessContentLease content,
|
||||
RuntimeFirstEntryDriveController? firstEntry = null)
|
||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||
: this(
|
||||
runtime,
|
||||
new HeadlessCollisionNeighborhood(runtime, content),
|
||||
firstEntry)
|
||||
firstEntry,
|
||||
acceptedPositionDrive)
|
||||
{
|
||||
}
|
||||
|
||||
internal HeadlessSessionWorldProjection(
|
||||
GameRuntime runtime,
|
||||
IHeadlessCollisionNeighborhood collision,
|
||||
RuntimeFirstEntryDriveController? firstEntry = null)
|
||||
RuntimeFirstEntryDriveController? firstEntry = null,
|
||||
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||
{
|
||||
_runtime = runtime
|
||||
?? throw new ArgumentNullException(nameof(runtime));
|
||||
_collision = collision
|
||||
?? throw new ArgumentNullException(nameof(collision));
|
||||
_firstEntry = firstEntry;
|
||||
_acceptedPositionDrive = acceptedPositionDrive;
|
||||
}
|
||||
|
||||
public void ProjectSpawn(
|
||||
|
|
@ -569,6 +579,7 @@ internal sealed class HeadlessSessionWorldProjection
|
|||
.TryConvertInitialResidenceToCellessRoute(record);
|
||||
}
|
||||
_firstEntry?.DriveAll();
|
||||
_acceptedPositionDrive?.Advance();
|
||||
}
|
||||
|
||||
public void ProjectPosition(
|
||||
|
|
@ -592,11 +603,43 @@ internal sealed class HeadlessSessionWorldProjection
|
|||
_collision.CenterOn(position.LandblockId);
|
||||
}
|
||||
_firstEntry?.DriveAll();
|
||||
_acceptedPositionDrive?.Advance();
|
||||
}
|
||||
// C4 route 2 (2026-08-03): a ForcePosition on the local player is
|
||||
// dispatched directly to RuntimeAcceptedPositionDriveController
|
||||
// instead of running the branch below this comment — the deleted
|
||||
// BlipLocalPlayer's body-commit/controller-reconciliation/ack job is
|
||||
// that controller's now (CenterOnAcceptedForcePosition covers the
|
||||
// re-centering half). R3 review fix (2026-08-03): when that call
|
||||
// returns NotApplicable — most commonly THIS controller-null branch,
|
||||
// reached while route 1 hasn't published a controller yet —
|
||||
// RuntimeLiveEntitySessionController.OnPositionUpdated still falls
|
||||
// back to calling this method, exactly like every other disposition.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2 review fix (2026-08-03): the centering half of the deleted
|
||||
/// <c>BlipLocalPlayer</c> — <c>_collision.CenterOn(position.LandblockId)</c>
|
||||
/// plus the <see cref="_requestedLocalPlayerCell"/> update
|
||||
/// <see cref="PumpFirstEntry"/> polls. Without this, a ForcePosition to a
|
||||
/// destination outside the neighborhood's current 3x3 window
|
||||
/// (<c>HeadlessCollisionNeighborhood.BuildPublicationPlan</c>) would open
|
||||
/// a <c>DeferredCell</c> park this host's window can never publish —
|
||||
/// see <see cref="AcDream.Runtime.Session.RuntimeAcceptedPositionDriveController.Advance"/>'s
|
||||
/// R1 doc comment. Called BEFORE the drive controller submits so the
|
||||
/// destination is already inside the window by the time it decides
|
||||
/// whether to park.
|
||||
/// </summary>
|
||||
public void CenterOnAcceptedForcePosition(RuntimeEntityRecord record)
|
||||
{
|
||||
if (record.ServerGuid != _runtime.PlayerIdentity.ServerGuid
|
||||
|| record.Snapshot.Position is not { LandblockId: not 0u } position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposition is PositionTimestampDisposition.ForcePosition)
|
||||
BlipLocalPlayer(record);
|
||||
_requestedLocalPlayerCell = position.LandblockId;
|
||||
_collision.CenterOn(position.LandblockId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -610,6 +653,7 @@ internal sealed class HeadlessSessionWorldProjection
|
|||
if (_requestedLocalPlayerCell != 0u)
|
||||
_ = _collision.IsReady(_requestedLocalPlayerCell);
|
||||
_firstEntry?.DriveAll();
|
||||
_acceptedPositionDrive?.Advance();
|
||||
}
|
||||
|
||||
public void BeginTeleport()
|
||||
|
|
@ -704,26 +748,4 @@ internal sealed class HeadlessSessionWorldProjection
|
|||
controller.SetBodyOrientation(orientation);
|
||||
}
|
||||
|
||||
private void BlipLocalPlayer(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);
|
||||
controller.LocalEntityId = record.LocalEntityId ?? 0u;
|
||||
controller.BlipPosition(
|
||||
wirePosition,
|
||||
position.LandblockId,
|
||||
wirePosition);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,16 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
|
|||
/// Previously outside every ledger; gated by <see cref="IsConverged"/>
|
||||
/// like the conductor counts it pumps.
|
||||
/// </summary>
|
||||
int FirstEntryDrivePendingCount = 0)
|
||||
int FirstEntryDrivePendingCount = 0,
|
||||
/// <summary>
|
||||
/// C4 route 2: outstanding host <c>RuntimeAcceptedPositionDriveController</c>
|
||||
/// pending operations (a not-yet-committed or not-yet-acknowledged
|
||||
/// ForcePosition on the local player), summed over every drive
|
||||
/// registered against this lifetime via
|
||||
/// <see cref="RuntimeEntityObjectLifetime.RegisterAcceptedPositionDriveOwnership"/>.
|
||||
/// Gated by <see cref="IsConverged"/> — a leaked pending ack cannot hide.
|
||||
/// </summary>
|
||||
int AcceptedPositionDrivePendingCount = 0)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
|
|
@ -104,6 +113,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
|
|||
&& LocalPlayerFirstEntryActiveCount == 0
|
||||
&& RemoteFirstEntryActiveCount == 0
|
||||
&& FirstEntryDrivePendingCount == 0
|
||||
&& AcceptedPositionDrivePendingCount == 0
|
||||
&& StreamSubscriberCount == 0
|
||||
&& PlacementStreamSubscriberCount == 0
|
||||
&& PendingDispatchCount == 0
|
||||
|
|
@ -151,6 +161,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
private Action<RuntimeEntityRecord>? _initialResidenceBegan;
|
||||
/// <summary>C3c-R1 review F5: see <see cref="RegisterFirstEntryDriveOwnership"/>.</summary>
|
||||
private readonly List<Func<int>> _firstEntryDriveOwnership = [];
|
||||
/// <summary>C4 route 2: see <see cref="RegisterAcceptedPositionDriveOwnership"/>.</summary>
|
||||
private readonly List<Func<int>> _acceptedPositionDriveOwnership = [];
|
||||
|
||||
public RuntimeEntityObjectLifetime(
|
||||
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
|
||||
|
|
@ -452,7 +464,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
InitialCreateExecution.PendingCompletionReceiptCount,
|
||||
LocalPlayerFirstEntry.CaptureOwnership().ActiveCount,
|
||||
RemoteFirstEntry.CaptureOwnership().ActiveCount,
|
||||
CaptureFirstEntryDrivePendingCount());
|
||||
CaptureFirstEntryDrivePendingCount(),
|
||||
CaptureAcceptedPositionDrivePendingCount());
|
||||
}
|
||||
|
||||
private int CaptureFirstEntryDrivePendingCount()
|
||||
|
|
@ -463,6 +476,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
return total;
|
||||
}
|
||||
|
||||
private int CaptureAcceptedPositionDrivePendingCount()
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < _acceptedPositionDriveOwnership.Count; i++)
|
||||
total = checked(total + _acceptedPositionDriveOwnership[i]());
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-R1 review F5: registers one host first-entry drive controller's
|
||||
/// pending-count provider into this lifetime's ownership snapshot, so
|
||||
|
|
@ -478,6 +499,21 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
_firstEntryDriveOwnership.Add(pendingCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 2: registers one host <c>RuntimeAcceptedPositionDriveController</c>'s
|
||||
/// pending-count provider into this lifetime's ownership snapshot,
|
||||
/// mirroring <see cref="RegisterFirstEntryDriveOwnership"/> — a leaked
|
||||
/// pending ForcePosition ack must not sit outside every ledger. The
|
||||
/// drive controller registers itself at construction; multiple
|
||||
/// registrations sum (one per host route sharing this lifetime).
|
||||
/// </summary>
|
||||
public void RegisterAcceptedPositionDriveOwnership(Func<int> pendingCount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pendingCount);
|
||||
EnsureNotDisposed();
|
||||
_acceptedPositionDriveOwnership.Add(pendingCount);
|
||||
}
|
||||
|
||||
public void BindEventContext(
|
||||
Func<RuntimeGenerationToken> generation,
|
||||
Func<ulong> frameNumber)
|
||||
|
|
|
|||
|
|
@ -629,8 +629,13 @@ public sealed class PlayerMovementController
|
|||
/// stick down (retail <c>teleport_hook</c> @0x00514eee) and, as of
|
||||
/// Campaign P P5 (#167), also tears down and immediately re-arms the
|
||||
/// constraint leash (<c>UnConstrain</c> then <c>ConstrainTo</c>);
|
||||
/// <see cref="BlipPosition"/> arms the leash without tearing it down first
|
||||
/// (retail <c>SmartBox::BlipPlayer</c> survives motion/velocity/stick).
|
||||
/// <see cref="CommitCanonicalForcePositionFrame"/>'s ForcePosition route
|
||||
/// does NOT touch the leash at all — retail's FORCE_POSITION branch of
|
||||
/// <c>SmartBox::HandleReceivedPosition</c> (@0x00453FD0) returns at
|
||||
/// 0x0045409D, before every <c>ConstrainTo</c> call (C4 route 2 review
|
||||
/// fix, 2026-08-03 — the deleted <c>BlipPosition</c>'s unconditional
|
||||
/// leash re-arm here was an unbacked deviation for that exact branch;
|
||||
/// see docs/research/2026-08-03-c4-route-2-implementation-plan.md §1b).
|
||||
/// </summary>
|
||||
public AcDream.Core.Physics.Motion.PositionManager? PositionManager
|
||||
{
|
||||
|
|
@ -1915,25 +1920,33 @@ public sealed class PlayerMovementController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>SmartBox::BlipPlayer</c> (0x00453940): apply a server
|
||||
/// FORCE_POSITION correction through <c>CPhysicsObj::SetPositionSimple</c>
|
||||
/// without the teleport hook. Active motion, velocity, contact state, and
|
||||
/// PositionManager stick relationships deliberately survive the blip.
|
||||
/// C4 route 2: the controller-local half of a ForcePosition commit whose
|
||||
/// body write already happened inside Runtime's canonical
|
||||
/// <c>RuntimeSetPositionState.CommitCanonical</c> (retail
|
||||
/// <c>CPhysicsObj::SetPositionSimple</c> @0x005162B0 with flags
|
||||
/// <c>0x1012</c>, called from <c>SmartBox::BlipPlayer</c> @0x00453940,
|
||||
/// acclient_2013_pseudo_c.txt:284276/92528). Resets the render-lerp
|
||||
/// anchors and republishes the render-root cell — the same two
|
||||
/// controller-local jobs the deleted <c>BlipPosition</c> performed after
|
||||
/// its own (now-Runtime-owned) body snap.
|
||||
///
|
||||
/// Deliberately does NOT call <see cref="RearmConstraintLeashAtCurrentPosition"/>:
|
||||
/// retail's FORCE_POSITION branch of <c>SmartBox::HandleReceivedPosition</c>
|
||||
/// (@0x00453FD0) returns at 0x0045409D, before every
|
||||
/// <c>CPhysicsObj::ConstrainTo</c> call (0x00454272/0x0045418A/
|
||||
/// 0x004541EC) — the deleted <c>BlipPosition</c>'s re-arm here was an
|
||||
/// unbacked deviation (docs/research/2026-08-03-c4-route-2-implementation-plan.md
|
||||
/// §1b); this route retires it. Active motion, velocity, contact state,
|
||||
/// and PositionManager stick relationships are untouched, matching
|
||||
/// retail's BlipPlayer path exactly (Runtime's canonical commit — not
|
||||
/// this method — is what already wrote the body).
|
||||
/// </summary>
|
||||
public void BlipPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
|
||||
internal void CommitCanonicalForcePositionFrame()
|
||||
{
|
||||
EnsurePublishedForRuntimeOperation();
|
||||
_body.SnapToCell(cellId, pos, cellLocal);
|
||||
_prevPhysicsPos = pos;
|
||||
_currPhysicsPos = pos;
|
||||
_prevPhysicsPos = _body.Position;
|
||||
_currPhysicsPos = _body.Position;
|
||||
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
|
||||
// #167 (Campaign P P5): retail "Player, normal" branch of
|
||||
// SmartBox::HandleReceivedPosition (0x00453fd0) — ConstrainTo anchored
|
||||
// to the received position, with NO teardown call (this is the
|
||||
// BlipPlayer path: motion, velocity, and PositionManager stick
|
||||
// relationships all deliberately survive the blip per the class
|
||||
// comment above, and the leash is no different).
|
||||
RearmConstraintLeashAtCurrentPosition();
|
||||
}
|
||||
|
||||
private Vector3 ComputeRenderPosition()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,906 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Typed yields for
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition"/>.
|
||||
/// </summary>
|
||||
internal enum RuntimeAcceptedPositionExecutionStatus : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// Out of this route's scope (not a ForcePosition, not the local player,
|
||||
/// no canonical body, or an initial-Create residence still owns the
|
||||
/// record). The caller's own pre-flip path runs unchanged.
|
||||
/// </summary>
|
||||
NotApplicable,
|
||||
|
||||
/// <summary>
|
||||
/// Either (a) the classifier itself rejected this exact frame (non-finite
|
||||
/// data, an invalid entity kind) before any SetPosition ran and no ack was
|
||||
/// sent, or (b) a SetPosition WAS begun and submitted but was then
|
||||
/// rejected/cancelled by <see cref="RuntimeSetPositionState.TryPrepareAndSubmitAuthoredPlacement"/>
|
||||
/// (invalid prepared data, authority displaced mid-submit) — R5 review
|
||||
/// fix (2026-08-03): the two call sites inside <c>SubmitAndResolve</c>
|
||||
/// both reach this status AFTER a SetPosition ran, so "no SetPosition
|
||||
/// ran" is only true for case (a).
|
||||
///
|
||||
/// Round 3 (2026-08-03): the two statuses differ in acknowledgement. Case
|
||||
/// (a) sends nothing — a classifier-rejected frame is not an accepted
|
||||
/// authority. Case (b) IS an accepted force packet whose placement was
|
||||
/// begun and failed, so <c>SettlePending</c> sends its retail position
|
||||
/// event carrying the body's unchanged pose (<c>SmartBox::BlipPlayer</c>
|
||||
/// @0x00453940 discards <c>SetPositionSimple</c>'s error and
|
||||
/// <c>SmartBox::HandleReceivedPosition</c> @0x00453FD0 acks
|
||||
/// unconditionally @0x00454091).
|
||||
/// </summary>
|
||||
Rejected,
|
||||
|
||||
/// <summary>
|
||||
/// The entity already holds an active SetPosition operation (or an
|
||||
/// unacknowledged completion); begin failed on transient contention, not
|
||||
/// staleness. This packet's classified force route was still recorded as
|
||||
/// the drive's newest accepted force observation BEFORE the failed begin
|
||||
/// (see <c>_newestForce</c>), so when the contending operation is this
|
||||
/// controller's OWN tracked <c>_pending</c> — the B1 case: a park that
|
||||
/// woke and had its <c>Place</c> ACCEPTED, whose retained completion
|
||||
/// survives <c>RuntimeSetPositionState.Forget</c>'s early return and
|
||||
/// blocks the next begin — the single <c>_pending</c> funnel
|
||||
/// (<c>SettlePending</c>) re-issues THIS packet's correction the moment
|
||||
/// that older completion is consumed. When it is a genuinely EXTERNAL
|
||||
/// operation (a concurrent portal/teleport placement on the same entity)
|
||||
/// and nothing of this route's own is pending, this route correctly
|
||||
/// yields — a later accepted Position re-attempts once that other
|
||||
/// placement completes, exactly like every other route yields to a
|
||||
/// concurrent placement authority.
|
||||
/// </summary>
|
||||
Contention,
|
||||
|
||||
/// <summary>
|
||||
/// The destination landblock's collision generation was not ready; the
|
||||
/// operation parked. The ack fires later, once
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.Advance"/> observes
|
||||
/// the deferred commit.
|
||||
/// </summary>
|
||||
DeferredCell,
|
||||
|
||||
/// <summary>
|
||||
/// The canonical SetPosition committed synchronously (retail
|
||||
/// <c>CPhysicsObj::SetPositionSimple</c> @0x005162B0, called from
|
||||
/// <c>SmartBox::BlipPlayer</c> @0x00453940). The controller-local
|
||||
/// reconciliation ran and the outbound ack (if any) already went out.
|
||||
/// </summary>
|
||||
Committed,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 2: the Runtime-owned accepted-Position execution seam for a
|
||||
/// ForcePosition on an already-live local player. Retail
|
||||
/// <c>SmartBox::HandleReceivedPosition</c> (@0x00453FD0,
|
||||
/// acclient_2013_pseudo_c.txt:92896) FORCE_POSITION branch:
|
||||
///
|
||||
/// <code>
|
||||
/// get_heading(player);
|
||||
/// Frame::set_heading(&dest, heading); // 00454068 preserve OUR heading
|
||||
/// SmartBox::BlipPlayer(this, &dest); // 00454074
|
||||
/// player->update_times[0] = arg7; // 00454079 stamp POSITION_TS
|
||||
/// cmdinterp->SendPositionEvent(); // 00454091 ack AFTER the commit
|
||||
/// return; // 0045409d
|
||||
/// </code>
|
||||
///
|
||||
/// <c>SmartBox::BlipPlayer</c> (@0x00453940, line 92528) calls
|
||||
/// <c>CPhysicsObj::SetPositionSimple</c> (@0x005162B0, line 284276), which
|
||||
/// with a non-null destination frame builds a <c>SetPositionStruct</c> with
|
||||
/// flags <c>0x1012</c> (<c>Teleport|Slide|SendPositionEvent</c> —
|
||||
/// <see cref="RuntimeAuthoritativePositionRouteClassifier"/>'s
|
||||
/// <c>AuthoritativeTeleportFlags</c>) and calls
|
||||
/// <c>CPhysicsObj::SetPosition</c>. This class is the Runtime consumer the
|
||||
/// classifier's ForcePosition branch never had: it drives
|
||||
/// <see cref="RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement"/> +
|
||||
/// <see cref="RuntimeSetPositionState.TryPrepareAndSubmitAuthoredPlacement"/>
|
||||
/// exactly like <see cref="RuntimeFirstEntryDriveController"/>'s continuation
|
||||
/// completion, then fires the ack strictly AFTER the canonical commit.
|
||||
///
|
||||
/// Two named behaviour changes versus the deleted App/no-window authorities
|
||||
/// this replaces:
|
||||
/// (1) the outbound <c>AutonomousPosition</c> ack now fires only after the
|
||||
/// canonical commit (previously it left before any Runtime commit existed —
|
||||
/// see the deleted <c>LocalForcePositionTransaction</c>/
|
||||
/// <c>HeadlessSessionWorldProjection.BlipLocalPlayer</c> pair);
|
||||
/// (2) the constraint leash is NOT re-armed here — every
|
||||
/// <c>CPhysicsObj::ConstrainTo</c> call in <c>HandleReceivedPosition</c>
|
||||
/// (@0x00454272/0x0045418A/0x004541EC) is on a branch the FORCE_POSITION
|
||||
/// early return (@0x0045409D) never reaches; the deleted
|
||||
/// <c>PlayerMovementController.BlipPosition</c>'s re-arm was an unbacked
|
||||
/// deviation this route retires (docs/research/2026-08-03-c4-route-2-implementation-plan.md §1b).
|
||||
///
|
||||
/// One instance per host session route (graphical/headless), constructed
|
||||
/// once per host process and reused across reconnects exactly like
|
||||
/// <see cref="RuntimeFirstEntryDriveController"/> — <see cref="AttachRoute"/>/
|
||||
/// <see cref="DetachRoute"/> assert the same "session reset precedes a new
|
||||
/// route" ordering and clear any pending operation left by a torn-down
|
||||
/// session. The controller tracks at most one pending operation (the local
|
||||
/// player is the only entity this route ever touches).
|
||||
///
|
||||
/// R9 review note (2026-08-03): kept <c>public</c> — unlike its template
|
||||
/// <see cref="RuntimeFirstEntryDriveController"/> (<c>internal sealed</c>),
|
||||
/// this class is a required parameter type on
|
||||
/// <see cref="RuntimeLiveEntitySessionController"/>'s own <c>public</c>
|
||||
/// constructor (<c>src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs</c>),
|
||||
/// so C# accessibility rules (CS0051) require it. Every constructor
|
||||
/// parameter and member below is <c>internal</c>; only the type name itself
|
||||
/// is public, and only because the class it is threaded through already is.
|
||||
/// Narrowing <see cref="RuntimeLiveEntitySessionController"/> itself is a
|
||||
/// separate, broader change outside this fix's scope.
|
||||
/// </summary>
|
||||
public sealed class RuntimeAcceptedPositionDriveController
|
||||
{
|
||||
private sealed class Pending
|
||||
{
|
||||
internal required RuntimeEntityRecord Record { get; init; }
|
||||
internal required RuntimeEntityPlacementToken Token { get; init; }
|
||||
internal required RuntimeAuthoritativePositionRoute Route { get; init; }
|
||||
internal required bool AwaitingCommitWake { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 (2026-08-03): true while this descriptor stands for an
|
||||
/// accepted force packet whose placement WAS begun and whose retail
|
||||
/// position event (<c>CommandInterpreter::SendPositionEvent</c>
|
||||
/// @0x006B4770, called unconditionally at
|
||||
/// <c>SmartBox::HandleReceivedPosition</c> @0x00454091) has not gone
|
||||
/// out yet. It is <c>false</c> only for the re-issue retry marker
|
||||
/// <see cref="SettlePending"/> parks when a re-issue cannot even
|
||||
/// begin: that marker stands for a packet whose placement was never
|
||||
/// begun, so its ack is owed by the eventual re-issue's own terminal
|
||||
/// outcome, not by the marker.
|
||||
/// </summary>
|
||||
internal required bool PositionEventOwed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round 2 unified mechanism (2026-08-03): the newest accepted local-player
|
||||
/// ForcePosition this drive has been handed, stamped with the canonical
|
||||
/// record's <c>PositionAuthorityVersion</c> as observed at that packet's
|
||||
/// own merge. It is the drive's ONLY knowledge of "what disposition was
|
||||
/// the newest accepted position event", because
|
||||
/// <see cref="TryExecuteAcceptedLocalPosition"/> is dispatched by both
|
||||
/// hosts for ForcePosition and only for ForcePosition — an ordinary
|
||||
/// <c>Apply</c> merges (advancing <c>PositionAuthorityVersion</c>) without
|
||||
/// ever reaching this class.
|
||||
///
|
||||
/// Round 3 correction (2026-08-03): this is a ONE-WAY test, not a
|
||||
/// biconditional. If the live record's current
|
||||
/// <c>PositionAuthorityVersion</c> differs from
|
||||
/// <see cref="PositionAuthorityVersion"/>, then some other authority
|
||||
/// advance has happened since this force was recorded and the funnel must
|
||||
/// not re-issue it. The converse does NOT hold: equality does not prove
|
||||
/// the newest accepted event was this ForcePosition, because
|
||||
/// <c>RuntimeEntityRecordTable.AdvancePositionAuthority</c> has four call
|
||||
/// sites, not one — the ordinary accepted-Position merge
|
||||
/// (<c>RuntimeEntityObjectLifetime.cs:1647</c>) plus
|
||||
/// <c>TryApplyPickup</c> (<c>:1116</c>),
|
||||
/// <c>CommitPositionChannelUpdate</c> (<c>:2041</c>) and
|
||||
/// <c>AdvanceCreateAuthority</c> (<c>:2466</c>). The latter three are
|
||||
/// effectively unreachable for a live local player, but the funnel's
|
||||
/// safety does not depend on that: an unnoticed advance can only make the
|
||||
/// funnel decline a re-issue it might have made (register row
|
||||
/// <b>AD-62</b>), never make it re-issue a stale pose.
|
||||
/// </summary>
|
||||
private readonly record struct AcceptedForceObservation(
|
||||
RuntimeEntityKey Entity,
|
||||
ulong PositionAuthorityVersion,
|
||||
RuntimeAuthoritativePositionRoute Route);
|
||||
|
||||
private readonly RuntimeEntityObjectLifetime _entityObjects;
|
||||
private readonly IGameRuntimeClock _clock;
|
||||
private readonly IPreparedCollisionSource _collisionSource;
|
||||
private readonly LocalPlayerOutboundController _localPlayerOutbound;
|
||||
private readonly Func<RuntimeGenerationToken> _generation;
|
||||
private readonly Func<uint> _localPlayerServerGuid;
|
||||
private readonly Func<PlayerMovementController?> _localController;
|
||||
private readonly Func<bool> _usePositionFromServer;
|
||||
private readonly Func<WorldSession?> _session;
|
||||
|
||||
/// <summary>
|
||||
/// The drive's at-most-one in-flight placement for the local player.
|
||||
/// Round 2 unified mechanism (2026-08-03): exactly THREE members write
|
||||
/// this field — <see cref="RetainPending"/> (the operation is still
|
||||
/// outstanding), <see cref="SettlePending"/> (the single terminal-outcome
|
||||
/// funnel), and <see cref="AbandonPending"/> (route teardown, which is
|
||||
/// outside the operation lifecycle entirely). No branch of
|
||||
/// <see cref="TryExecuteAcceptedLocalPosition"/>,
|
||||
/// <see cref="SubmitAndResolve"/> or <see cref="Advance"/> assigns or
|
||||
/// clears it directly. Round 1 spread that ownership across the
|
||||
/// individual branches, which is the shared root cause of B1, N1 and N2
|
||||
/// in docs/research/2026-08-03-c4-route-2-review-findings.md.
|
||||
/// </summary>
|
||||
private Pending? _pending;
|
||||
private AcceptedForceObservation? _newestForce;
|
||||
private object? _routeOwner;
|
||||
|
||||
internal RuntimeAcceptedPositionDriveController(
|
||||
RuntimeEntityObjectLifetime entityObjects,
|
||||
IGameRuntimeClock clock,
|
||||
IPreparedCollisionSource collisionSource,
|
||||
LocalPlayerOutboundController localPlayerOutbound,
|
||||
Func<RuntimeGenerationToken> generation,
|
||||
Func<uint> localPlayerServerGuid,
|
||||
Func<PlayerMovementController?> localController,
|
||||
Func<bool> usePositionFromServer,
|
||||
Func<WorldSession?> session)
|
||||
{
|
||||
_entityObjects = entityObjects
|
||||
?? throw new ArgumentNullException(nameof(entityObjects));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_collisionSource = collisionSource
|
||||
?? throw new ArgumentNullException(nameof(collisionSource));
|
||||
_localPlayerOutbound = localPlayerOutbound
|
||||
?? throw new ArgumentNullException(nameof(localPlayerOutbound));
|
||||
_generation = generation
|
||||
?? throw new ArgumentNullException(nameof(generation));
|
||||
_localPlayerServerGuid = localPlayerServerGuid
|
||||
?? throw new ArgumentNullException(nameof(localPlayerServerGuid));
|
||||
_localController = localController
|
||||
?? throw new ArgumentNullException(nameof(localController));
|
||||
_usePositionFromServer = usePositionFromServer
|
||||
?? throw new ArgumentNullException(nameof(usePositionFromServer));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_entityObjects.RegisterAcceptedPositionDriveOwnership(
|
||||
() => _pending is null ? 0 : 1);
|
||||
}
|
||||
|
||||
internal int PendingCount => _pending is null ? 0 : 1;
|
||||
|
||||
/// <summary>
|
||||
/// C3c-R1-style one-route-at-a-time latch (mirrors
|
||||
/// <see cref="RuntimeFirstEntryDriveController.AttachRoute"/>): this
|
||||
/// controller outlives its session routes (hosts reuse it across
|
||||
/// reconnects), so the "session reset precedes a new route" ordering is
|
||||
/// asserted, not assumed.
|
||||
/// </summary>
|
||||
internal void AttachRoute(object route)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(route);
|
||||
if (_routeOwner is not null && !ReferenceEquals(_routeOwner, route))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"An accepted-position drive controller serves one session "
|
||||
+ "route at a time; the prior route must be disposed "
|
||||
+ "(session reset precedes a new route) before a "
|
||||
+ "replacement attaches.");
|
||||
}
|
||||
_routeOwner = route;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Route-scoped teardown: abandons any pending operation, but ONLY when
|
||||
/// <paramref name="route"/> is the attached owner.
|
||||
/// </summary>
|
||||
internal void DetachRoute(object route)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(route);
|
||||
if (!ReferenceEquals(_routeOwner, route))
|
||||
return;
|
||||
_routeOwner = null;
|
||||
AbandonPending();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Route teardown — the one <see cref="_pending"/> write that is NOT a
|
||||
/// terminal operation outcome. The torn-down route's newest accepted
|
||||
/// force observation dies with it: a reconnect re-merges its own
|
||||
/// positions, and a stale observation must never survive to authorize a
|
||||
/// re-issue against a later session's record.
|
||||
/// </summary>
|
||||
private void AbandonPending()
|
||||
{
|
||||
_newestForce = null;
|
||||
if (_pending is not { } pending)
|
||||
return;
|
||||
_pending = null;
|
||||
RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition;
|
||||
setPosition.ForgetPlacementCompletion(pending.Token);
|
||||
RuntimePlacementCancellationReceipt cancellation =
|
||||
setPosition.ForgetExactPlacement(pending.Token);
|
||||
if (cancellation.IsValid)
|
||||
setPosition.PublishCancellation(cancellation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a ForcePosition Position update against the canonical
|
||||
/// Runtime SetPosition owner. <paramref name="record"/>'s Snapshot,
|
||||
/// timestamps, and PositionAuthorityVersion must already reflect the
|
||||
/// merge <see cref="RuntimeEntityObjectLifetime.TryApplyPosition"/>
|
||||
/// performed for this exact <paramref name="update"/> — this method
|
||||
/// never re-merges the wire frame; it only routes the already-accepted
|
||||
/// pose through the canonical placement transaction.
|
||||
/// </summary>
|
||||
internal RuntimeAcceptedPositionExecutionStatus TryExecuteAcceptedLocalPosition(
|
||||
RuntimeEntityRecord record,
|
||||
in WorldSession.EntityPositionUpdate update,
|
||||
PositionTimestampDisposition disposition,
|
||||
in AcceptedPhysicsTimestamps timestamps,
|
||||
ushort previousTeleportSequence)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
if (disposition is not PositionTimestampDisposition.ForcePosition
|
||||
|| record.ServerGuid != _localPlayerServerGuid()
|
||||
|| record.PhysicsBody is null
|
||||
|| record.Key is not { } key
|
||||
// Route 1 owns an active initial-Create residence: the
|
||||
// executor already retains the Position as its own tail
|
||||
// action (RuntimeInitialCreateContinuationExecutor
|
||||
// .ApplyPositionAction) and already carries
|
||||
// SendPositionImmediately. Route 2 must not double-drive it.
|
||||
|| _entityObjects.TryGetInitialCreateResidence(record, out _))
|
||||
{
|
||||
return RuntimeAcceptedPositionExecutionStatus.NotApplicable;
|
||||
}
|
||||
|
||||
RuntimeAuthoritativePositionRoute route = ClassifyForcePosition(
|
||||
record, key, update, disposition, timestamps, previousTeleportSequence);
|
||||
if (!route.Accepted)
|
||||
{
|
||||
// The wire's accepted timestamp/position channels were already
|
||||
// merged into record.Snapshot upstream
|
||||
// (RuntimeEntityObjectLifetime.TryApplyPosition) unconditionally
|
||||
// on the disposition being non-Rejected — unlike the initial-
|
||||
// Create continuation flow (whose merge happens INSIDE the
|
||||
// route.Accepted branch), there is no separate "stamp-only"
|
||||
// write left to perform here. A classifier rejection at this
|
||||
// point is a data-validity failure (non-finite frame), not a
|
||||
// staleness one; the caller's own currency re-check already
|
||||
// covers staleness upstream.
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
|
||||
// Round 2 unified mechanism: record THIS packet as the newest accepted
|
||||
// force BEFORE attempting to begin, so a failed begin (Contention)
|
||||
// still leaves the funnel able to re-issue this exact correction. A
|
||||
// classifier-rejected frame deliberately never gets here: it is not an
|
||||
// accepted authority and must never authorize a re-issue.
|
||||
ulong acceptedVersion = record.PositionAuthorityVersion;
|
||||
_newestForce = new AcceptedForceObservation(key, acceptedVersion, route);
|
||||
|
||||
RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition;
|
||||
RuntimeEntityPlacementToken token =
|
||||
setPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
acceptedVersion,
|
||||
route.OperationKind);
|
||||
if (!token.IsValid)
|
||||
{
|
||||
// The entity already owns an active operation (or an
|
||||
// unacknowledged completion). Not staleness — see the Contention
|
||||
// status doc.
|
||||
return RuntimeAcceptedPositionExecutionStatus.Contention;
|
||||
}
|
||||
|
||||
return SubmitAndResolve(record, token, route);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host cadence pump: resolves a parked DeferredCell operation once its
|
||||
/// destination landblock's collision generation eventually commits it
|
||||
/// (<c>RuntimeSetPositionState.CommitCollisionGeneration</c> →
|
||||
/// <c>RetryDeferred</c> → <c>CommitCanonical</c>, driven entirely by
|
||||
/// unrelated collision/streaming machinery — this pump never re-submits
|
||||
/// the operation itself). Retries a preparation-only retry status
|
||||
/// (<c>RetrySetupUnavailable</c>/<c>RetryWorldFrameUnavailable</c>) by
|
||||
/// re-calling the SAME prepare+submit pair, exactly like
|
||||
/// <see cref="RuntimeFirstEntryDriveController"/>'s own continuation
|
||||
/// completion. Safe to call from any host cadence point; a no-op when
|
||||
/// nothing is pending.
|
||||
///
|
||||
/// R1 review fix (2026-08-03): <c>RuntimeEntityObjectLifetime.TryApplyPosition</c>
|
||||
/// calls <c>RuntimeSetPositionState.Forget</c> on EVERY accepted Position
|
||||
/// for this entity — any disposition, not only ForcePosition — which
|
||||
/// unconditionally cancels whatever operation this controller has
|
||||
/// in-flight (<c>Forget</c> → <c>CancelCoreDeferred</c> →
|
||||
/// <c>ForgetPlacementCompletionCore</c>, which drops the token from both
|
||||
/// <c>_placementCompletionWatches</c> and
|
||||
/// <c>_acknowledgedPlacementCompletions</c> with no trace). ACE broadcasts
|
||||
/// at 5-10 Hz, so a <see cref="RuntimeAcceptedPositionExecutionStatus.DeferredCell"/>
|
||||
/// park surviving past one broadcast interval is cancelled before its
|
||||
/// collision generation can ever commit it — the exact far-destination
|
||||
/// case the park exists to serve. The SAME cancellation path is also how
|
||||
/// supersession, the lost-cell deadline, <c>ParkCollisionResidents</c>,
|
||||
/// and a generation change retire an operation
|
||||
/// (<c>ForgetPlacementCompletionCore</c> is their common funnel too).
|
||||
///
|
||||
/// Retail <c>SmartBox::BlipPlayer</c> (@0x00453940) has no "give up
|
||||
/// quietly" state — every accepted Position it sees gets applied. So
|
||||
/// rather than silently leaking <see cref="_pending"/> forever (which
|
||||
/// would also pin <c>AcceptedPositionDrivePendingCount</c> non-zero for
|
||||
/// the rest of the session — <c>GameWindowLifetime.DisposeGameRuntime</c>
|
||||
/// throws on non-convergence), this pump detects the cancellation via the
|
||||
/// existing read-only <c>RuntimeSetPositionState.IsPlacementCompletionTracked</c>
|
||||
/// query (a watched-and-not-yet-cancelled DeferredCell park) or
|
||||
/// <c>IsPlacementCurrent</c> (a still-in-flight prepare retry) and, when
|
||||
/// neither holds, hands the dead operation to the single
|
||||
/// <see cref="SettlePending"/> funnel, which decides on ONE input whether
|
||||
/// a re-issue is owed.
|
||||
/// </summary>
|
||||
internal void Advance()
|
||||
{
|
||||
if (_pending is not { } pending)
|
||||
return;
|
||||
|
||||
RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition;
|
||||
if (pending.AwaitingCommitWake)
|
||||
{
|
||||
if (setPosition.TryPeekAcknowledgedPlacement(
|
||||
pending.Token,
|
||||
out RuntimePlacementProjectionToken projection))
|
||||
{
|
||||
if (!setPosition.ConsumeAcknowledgedPlacement(
|
||||
pending.Token, projection))
|
||||
{
|
||||
// Raced against a concurrent consumer; retry next pump.
|
||||
return;
|
||||
}
|
||||
// Retail order: the deferred commit's own reconciliation and
|
||||
// ack come first, THEN the funnel decides whether a newer
|
||||
// accepted force is still owed a placement (B1).
|
||||
ReconcileAndAcknowledge(pending.Record, pending.Route);
|
||||
SettlePending(
|
||||
pending.Record,
|
||||
pending.Token,
|
||||
pending.Route,
|
||||
positionEventOwed: false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (setPosition.IsPlacementCompletionTracked(pending.Token))
|
||||
{
|
||||
// Still parked, watch alive. Nothing more this pump can do.
|
||||
return;
|
||||
}
|
||||
|
||||
// The watch died — most likely a subsequent accepted Position's
|
||||
// merge-time Forget. The funnel owns what happens next, including
|
||||
// this packet's still-unsent position event (retail acks whether
|
||||
// or not the placement took — see SettlePending).
|
||||
SettlePending(
|
||||
pending.Record,
|
||||
pending.Token,
|
||||
pending.Route,
|
||||
pending.PositionEventOwed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (setPosition.IsPlacementCurrent(pending.Token))
|
||||
{
|
||||
_ = SubmitAndResolve(pending.Record, pending.Token, pending.Route);
|
||||
return;
|
||||
}
|
||||
|
||||
// The prepare-retry operation died the same way. (A re-issue retry
|
||||
// marker also lands here, carrying PositionEventOwed: false — its
|
||||
// packet's placement was never begun, so its ack belongs to the
|
||||
// eventual re-issue's terminal outcome.)
|
||||
SettlePending(
|
||||
pending.Record,
|
||||
pending.Token,
|
||||
pending.Route,
|
||||
pending.PositionEventOwed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round 2 unified mechanism (2026-08-03) — the SINGLE terminal-outcome
|
||||
/// funnel for <see cref="_pending"/>. Every branch that ends an operation
|
||||
/// (committed, rejected, cancelled, watch died, key released) calls this
|
||||
/// and nothing else touches the field. One decision input: the terminal
|
||||
/// operation's own <c>PositionAuthorityVersion</c> (carried on its
|
||||
/// <see cref="RuntimeEntityPlacementToken"/>,
|
||||
/// <c>RuntimeSetPositionState.cs:50</c>) compared against the live
|
||||
/// canonical record's CURRENT <c>PositionAuthorityVersion</c>:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Equal</b> — the canonical accepted authority has
|
||||
/// not moved since this operation began, so nothing is outstanding. Clear
|
||||
/// and do not re-issue. This is what makes one server correction produce
|
||||
/// exactly ONE canonical placement and ONE outbound
|
||||
/// <c>AutonomousPosition</c> even when a stale dead entry was still parked
|
||||
/// in <see cref="_pending"/> when the fresh packet committed (N1 — the
|
||||
/// double-apply/double-ack class <c>670f307c</c> deleted).</description></item>
|
||||
/// <item><description><b>Advanced, newest accepted event still a
|
||||
/// ForcePosition</b> — a newer force arrived while we were in flight and
|
||||
/// could not begin (see the Contention status doc). Re-issue it,
|
||||
/// re-classified from the CURRENT record via <see cref="_newestForce"/> —
|
||||
/// never the terminal operation's own route (B1).</description></item>
|
||||
/// <item><description><b>Advanced, newest accepted event is an ordinary
|
||||
/// Apply</b> — clear and do not re-issue. The correction was superseded by
|
||||
/// newer server truth and the ordinary route owns that pose. Re-issuing
|
||||
/// here would apply the force route's <c>Teleport|Slide</c> flags to an
|
||||
/// ordinary pose, send an ack retail never sends on that branch, and skip
|
||||
/// the <c>ConstrainTo</c> the ordinary branch runs
|
||||
/// (<c>RuntimeAuthoritativePositionRouteClassifier.cs:368-388</c>) — N2.
|
||||
/// This is not a silent drop: retail applies each event as it arrives, and
|
||||
/// a force overtaken by a newer position is moot.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// Recursion is bounded at one level: a re-issue always begins at the
|
||||
/// record's CURRENT version, so its own terminal settle necessarily takes
|
||||
/// the Equal branch (nothing can advance the authority between a begin and
|
||||
/// its synchronous submit — only an inbound merge does, and inbound
|
||||
/// dispatch is what called us).
|
||||
///
|
||||
/// <para><b>Round 3 (2026-08-03) — the terminal-without-commit ack.</b>
|
||||
/// Retail acknowledges an accepted force packet whether or not its
|
||||
/// placement took. <c>SmartBox::BlipPlayer</c> @0x00453940 calls
|
||||
/// <c>CPhysicsObj::SetPositionSimple</c> @0x005162B0 — which returns an
|
||||
/// <c>enum SetPositionError</c> that other retail call sites DO test
|
||||
/// (<c>== OK_SPE</c> @0x0055605D, @0x00556021) — and DISCARDS it;
|
||||
/// <c>BlipPlayer</c> itself returns <c>void</c>. Its caller
|
||||
/// <c>SmartBox::HandleReceivedPosition</c> @0x00453FD0 then runs
|
||||
/// <c>cmdinterp->SendPositionEvent()</c> @0x00454091 unconditionally and
|
||||
/// returns @0x0045409D. So retail's semantics are: attempt the placement;
|
||||
/// if it fails the body simply does not move; acknowledge regardless;
|
||||
/// never retry. This funnel therefore sends the position event for any
|
||||
/// terminal outcome whose placement was begun and did NOT commit
|
||||
/// (<paramref name="positionEventOwed"/>), carrying the body's UNCHANGED
|
||||
/// pose — which is exactly what retail's ack carries after a failed
|
||||
/// <c>SetPositionSimple</c>, and is informative to the server: its force
|
||||
/// did not take. It does NOT re-issue that correction, because retail
|
||||
/// never retries. The commit paths pass <c>false</c>: their ack already
|
||||
/// left through <see cref="ReconcileAndAcknowledge"/>, so exactly one
|
||||
/// position event goes out per begun placement THAT REACHES ITS OWN
|
||||
/// TERMINAL SETTLE — never two.</para>
|
||||
///
|
||||
/// <para>It is deliberately NOT "never zero per begun placement". This
|
||||
/// method opens by nulling <c>_pending</c> without reading it, so a
|
||||
/// descriptor still carrying <c>PositionEventOwed</c> is discarded when a
|
||||
/// NEWER ForcePosition displaces it (the merge-time
|
||||
/// <c>Forget</c> clears the block, the newer packet begins cleanly, and
|
||||
/// its terminal settle nulls the field). That older packet's owed ack is
|
||||
/// lost — AD-62 shape (v), pinned by
|
||||
/// <c>OneServerCorrectionProducesExactlyOnePlacementAndOneAck</c>, which
|
||||
/// feeds two force packets and asserts a single ack. Replaying it would be
|
||||
/// worse: the message would carry a stale sequence against the newer
|
||||
/// packet's committed pose. The displacing packet always acks, so ACE
|
||||
/// always receives a report for the NEWEST force.</para>
|
||||
///
|
||||
/// <para>Divergence: the two non-reissuing branches mean a ForcePosition
|
||||
/// retired without committing is never re-applied —
|
||||
/// <c>docs/architecture/retail-divergence-register.md</c> row <b>AD-62</b>
|
||||
/// (the park itself is our async collision-publication adaptation; retail
|
||||
/// <c>SmartBox::BlipPlayer</c> @0x00453940 is synchronous against a fully
|
||||
/// resident world and cannot reach this state). The ack is no longer part
|
||||
/// of that loss for a begun placement; AD-62 names the narrower shapes
|
||||
/// where the packet's placement was never begun at all and the ack is
|
||||
/// still lost.</para>
|
||||
/// </summary>
|
||||
private void SettlePending(
|
||||
RuntimeEntityRecord terminalRecord,
|
||||
in RuntimeEntityPlacementToken terminalToken,
|
||||
in RuntimeAuthoritativePositionRoute terminalRoute,
|
||||
bool positionEventOwed)
|
||||
{
|
||||
_pending = null;
|
||||
|
||||
if (!_entityObjects.Entities.TryGetActive(
|
||||
terminalRecord.ServerGuid, out RuntimeEntityRecord record)
|
||||
|| record.ServerGuid != _localPlayerServerGuid()
|
||||
|| record.PhysicsBody is null
|
||||
|| record.Key is not { } key
|
||||
|| key != terminalToken.Entity)
|
||||
{
|
||||
// The entity departed the world, is no longer the local player,
|
||||
// has no canonical body, or released/replaced the incarnation this
|
||||
// operation belonged to (the post-teardown key release). Nothing
|
||||
// left in the world for this operation to place — and nothing left
|
||||
// to acknowledge a position for either.
|
||||
return;
|
||||
}
|
||||
|
||||
// Retail order: the packet's own position event first (@0x00454091),
|
||||
// THEN whatever the next accepted force is owed. No reconciliation
|
||||
// runs here — the body did not move, so there is no committed frame to
|
||||
// reconcile; only the ack is owed.
|
||||
if (positionEventOwed && _localController() is { } terminalController)
|
||||
SendPositionEvent(terminalController, terminalRoute);
|
||||
|
||||
ulong current = record.PositionAuthorityVersion;
|
||||
if (terminalToken.PositionAuthorityVersion == current)
|
||||
return;
|
||||
|
||||
if (_newestForce is not { } newest
|
||||
|| newest.Entity != key
|
||||
|| newest.PositionAuthorityVersion != current)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition;
|
||||
RuntimeEntityPlacementToken token =
|
||||
setPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
current,
|
||||
newest.Route.OperationKind);
|
||||
if (!token.IsValid)
|
||||
{
|
||||
// Could not even begin the re-issue (a concurrent placement
|
||||
// authority still owns the entity). The correction is still owed,
|
||||
// so keep the terminal descriptor as the retry marker: the next
|
||||
// Advance() pump re-enters this funnel and re-evaluates the SAME
|
||||
// decision against the record as it then stands.
|
||||
//
|
||||
// PositionEventOwed: false — the marker stands for the NEWEST
|
||||
// accepted force, whose placement was never begun. Its ack belongs
|
||||
// to that re-issue's own terminal outcome, so re-entering this
|
||||
// funnel through the marker must never fire a second position
|
||||
// event for a packet the re-issue will ack itself.
|
||||
_pending = new Pending
|
||||
{
|
||||
Record = terminalRecord,
|
||||
Token = terminalToken,
|
||||
Route = terminalRoute,
|
||||
AwaitingCommitWake = false,
|
||||
PositionEventOwed = false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
_ = SubmitAndResolve(record, token, newest.Route);
|
||||
}
|
||||
|
||||
private RuntimeAcceptedPositionExecutionStatus SubmitAndResolve(
|
||||
RuntimeEntityRecord record,
|
||||
in RuntimeEntityPlacementToken token,
|
||||
in RuntimeAuthoritativePositionRoute route)
|
||||
{
|
||||
RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition;
|
||||
RuntimeSetPositionMoverPreparationStatus status =
|
||||
setPosition.TryPrepareAndSubmitAuthoredPlacement(
|
||||
record,
|
||||
token,
|
||||
route.OperationKind,
|
||||
route.SetPositionFlags,
|
||||
_collisionSource,
|
||||
_clock.SimulationTimeSeconds,
|
||||
out RuntimeSetPositionOutcome outcome,
|
||||
resolveWorldOffsetFromRuntimeFrame: true);
|
||||
|
||||
if (status != RuntimeSetPositionMoverPreparationStatus.Prepared)
|
||||
{
|
||||
if (status.IsRetryable())
|
||||
{
|
||||
RetainPending(setPosition, new Pending
|
||||
{
|
||||
Record = record,
|
||||
Token = token,
|
||||
Route = route,
|
||||
AwaitingCommitWake = false,
|
||||
PositionEventOwed = true,
|
||||
});
|
||||
return RuntimeAcceptedPositionExecutionStatus.Contention;
|
||||
}
|
||||
|
||||
CancelToken(setPosition, token);
|
||||
SettlePending(record, token, route, positionEventOwed: true);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
|
||||
switch (outcome.Status)
|
||||
{
|
||||
case RuntimeSetPositionStatus.CommittedHostAcknowledgementPending:
|
||||
// R4 review fix (2026-08-03): unlike
|
||||
// RuntimeFirstEntryDriveController.TryCompleteContinuationPlacement
|
||||
// (whose ack here is benign because ITS residence is what
|
||||
// just drained, so the sink's residence gate can no longer
|
||||
// decline), this route's sink can legitimately decline this
|
||||
// exact receipt for real production reasons unrelated to
|
||||
// anything this route does (!IsLoaded(landblock), stale
|
||||
// transit authority — RuntimePlacementProjectionSubscription
|
||||
// deliberately leaves a declined Place at the FIFO head for
|
||||
// its OWN later retry). Acknowledging it here would consume
|
||||
// and destroy that retry with no second writer to cover the
|
||||
// render-facing projection (the generic App tail is skipped
|
||||
// for this route). So this route does NOT acknowledge the
|
||||
// projection itself — the production subscription already
|
||||
// did, synchronously, inside the SetPosition call above, if
|
||||
// it was going to; if it declined, its own retry contract
|
||||
// owns the receipt from here, exactly like every other
|
||||
// placement kind.
|
||||
ReconcileAndAcknowledge(record, route);
|
||||
SettlePending(record, token, route, positionEventOwed: false);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Committed;
|
||||
|
||||
case RuntimeSetPositionStatus.DeferredCell:
|
||||
// Parked with a published Withdraw; consume it if it is
|
||||
// already the head so the collision-generation wake can
|
||||
// resubmit (RuntimeFirstEntryDriveController
|
||||
// .TryCompleteContinuationPlacement:351-365's exact pattern).
|
||||
while (setPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot parked)
|
||||
&& parked.Token.Entity == token.Entity
|
||||
&& parked.Kind is RuntimePlacementProjectionKind.Withdraw)
|
||||
{
|
||||
if (!setPosition.AcknowledgeProjection(parked.Token))
|
||||
break;
|
||||
}
|
||||
if (!setPosition.WatchPlacementCompletion(token))
|
||||
{
|
||||
// Something displaced the operation between Submit and
|
||||
// here — no cross-pump tracking is possible; cancel
|
||||
// rather than leak a watch we can never resolve.
|
||||
CancelToken(setPosition, token);
|
||||
SettlePending(record, token, route, positionEventOwed: true);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
RetainPending(setPosition, new Pending
|
||||
{
|
||||
Record = record,
|
||||
Token = token,
|
||||
Route = route,
|
||||
AwaitingCommitWake = true,
|
||||
PositionEventOwed = true,
|
||||
});
|
||||
return RuntimeAcceptedPositionExecutionStatus.DeferredCell;
|
||||
|
||||
default:
|
||||
// Rejected/Cancelled — authority moved out from under this
|
||||
// operation, so the body never moved. Retail still
|
||||
// acknowledges the packet (SettlePending's positionEventOwed
|
||||
// path); it just does not re-apply it.
|
||||
CancelToken(setPosition, token);
|
||||
SettlePending(record, token, route, positionEventOwed: true);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R6 review fix (2026-08-03): the previous shape assigned
|
||||
/// <see cref="_pending"/> unconditionally, which could silently
|
||||
/// overwrite a still-tracked live pending (losing the only reference
|
||||
/// able to later consume its eventual acknowledged completion — a
|
||||
/// permanent <c>HasRetainedCompletion</c> orphan that would block every
|
||||
/// future Begin for this entity with Contention forever). Given
|
||||
/// <see cref="RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement"/>
|
||||
/// already refuses to Begin while the entity holds an active operation
|
||||
/// or an unconsumed acknowledged completion, <paramref name="next"/>'s
|
||||
/// token could only have successfully begun if any DIFFERENT existing
|
||||
/// <see cref="_pending"/> is already dead — so this is a defensive
|
||||
/// invariant check, not a routine code path.
|
||||
///
|
||||
/// Round 2 (2026-08-03): this is the ONLY "the operation is still
|
||||
/// outstanding" writer of <see cref="_pending"/>; every terminal outcome
|
||||
/// goes through <see cref="SettlePending"/> instead.
|
||||
/// </summary>
|
||||
private void RetainPending(
|
||||
RuntimeSetPositionState setPosition,
|
||||
Pending next)
|
||||
{
|
||||
if (_pending is { } existing
|
||||
&& existing.Token != next.Token
|
||||
&& setPosition.IsPlacementCurrent(existing.Token))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"RuntimeAcceptedPositionDriveController tracks at most one "
|
||||
+ "pending accepted-position operation (the local player); "
|
||||
+ "a still-live pending operation must never be silently "
|
||||
+ "overwritten by a new one.");
|
||||
}
|
||||
_pending = next;
|
||||
}
|
||||
|
||||
private static void CancelToken(
|
||||
RuntimeSetPositionState setPosition,
|
||||
in RuntimeEntityPlacementToken token)
|
||||
{
|
||||
RuntimePlacementCancellationReceipt cancellation =
|
||||
setPosition.ForgetExactPlacement(token);
|
||||
if (cancellation.IsValid)
|
||||
setPosition.PublishCancellation(cancellation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §4a/§4b: the controller-local reconciliation
|
||||
/// <c>CommitCanonical</c> does not perform, followed by the outbound ack
|
||||
/// — an OUTPUT of the committed route, never a step performed alongside
|
||||
/// it (retail <c>cmdinterp->SendPositionEvent()</c> @0x00454091 runs
|
||||
/// after <c>SmartBox::BlipPlayer</c> @0x00454074 returns).
|
||||
/// </summary>
|
||||
private void ReconcileAndAcknowledge(
|
||||
RuntimeEntityRecord record,
|
||||
in RuntimeAuthoritativePositionRoute route)
|
||||
{
|
||||
if (record.ServerGuid != _localPlayerServerGuid())
|
||||
return;
|
||||
if (_localController() is not { } controller)
|
||||
return;
|
||||
controller.CommitCanonicalForcePositionFrame();
|
||||
SendPositionEvent(controller, route);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>cmdinterp->SendPositionEvent()</c> @0x00454091 — the sole
|
||||
/// outbound-ack site for this route, shared by the committed path
|
||||
/// (<see cref="ReconcileAndAcknowledge"/>, which reconciles the moved
|
||||
/// frame first) and the terminal-without-commit path
|
||||
/// (<see cref="SettlePending"/>, which has no moved frame to reconcile and
|
||||
/// so sends the body's unchanged pose).
|
||||
///
|
||||
/// The <c>CanSendPositionEvent</c> admission inside
|
||||
/// <see cref="LocalPlayerOutboundController.SendImmediatePosition"/> is
|
||||
/// retail's own (<c>CommandInterpreter::SendPositionEvent</c> @0x006B4770
|
||||
/// tests the transient-state contact bits), so a legitimately airborne
|
||||
/// body still suppresses the send on BOTH paths — that suppression is
|
||||
/// retail behaviour, not a divergence.
|
||||
/// </summary>
|
||||
private void SendPositionEvent(
|
||||
PlayerMovementController controller,
|
||||
in RuntimeAuthoritativePositionRoute route)
|
||||
{
|
||||
if (!route.SendPositionImmediately)
|
||||
return;
|
||||
_localPlayerOutbound.SendImmediatePosition(_session(), controller);
|
||||
}
|
||||
|
||||
private RuntimeAuthoritativePositionRoute ClassifyForcePosition(
|
||||
RuntimeEntityRecord record,
|
||||
RuntimeEntityKey key,
|
||||
in WorldSession.EntityPositionUpdate update,
|
||||
PositionTimestampDisposition disposition,
|
||||
in AcceptedPhysicsTimestamps timestamps,
|
||||
ushort previousTeleportSequence)
|
||||
{
|
||||
var authority = new RuntimeAuthoritativePositionAuthority(
|
||||
_generation(),
|
||||
key,
|
||||
record.PositionAuthorityVersion,
|
||||
update.PositionSequence,
|
||||
previousTeleportSequence,
|
||||
timestamps.Teleport,
|
||||
disposition);
|
||||
|
||||
// Round 3 A3 (mirrored from RuntimeInitialCreateContinuationExecutor
|
||||
// .ApplyPositionAction): contact is SOLELY the retained wire
|
||||
// packet's own IsGrounded bit — never a live body query.
|
||||
bool hasContact = update.IsGrounded;
|
||||
bool hasAnimations = (record.Snapshot.MotionTableId
|
||||
?? record.Snapshot.Physics?.MotionTableId) is { } motionTableId
|
||||
&& motionTableId != 0u;
|
||||
|
||||
bool usePositionFromServer = _usePositionFromServer();
|
||||
float playerDistance = 0f;
|
||||
if (_localController() is { } controllerForDistance
|
||||
&& (record.Snapshot.Physics?.Position
|
||||
?? record.Snapshot.Position) is { } acceptedForDistance)
|
||||
{
|
||||
var target = new Vector3(
|
||||
acceptedForDistance.PositionX,
|
||||
acceptedForDistance.PositionY,
|
||||
acceptedForDistance.PositionZ);
|
||||
playerDistance = Vector3.Distance(
|
||||
target, controllerForDistance.Position);
|
||||
}
|
||||
|
||||
var request = new RuntimeAcceptedPositionRouteRequest(
|
||||
authority,
|
||||
RuntimePositionEntityKind.LocalPlayer,
|
||||
RuntimeAcceptedPositionSource.PositionEvent,
|
||||
update.Position,
|
||||
update.PlacementId,
|
||||
update.Velocity,
|
||||
record.FullCellId,
|
||||
hasContact,
|
||||
playerDistance,
|
||||
usePositionFromServer,
|
||||
hasAnimations,
|
||||
new RuntimePositionPlacementFacts(
|
||||
record.FinalPhysicsState,
|
||||
record.Snapshot.SetupTableId is not null));
|
||||
|
||||
return RuntimeAuthoritativePositionRouteClassifier
|
||||
.ClassifyAcceptedPosition(request);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,24 @@ public interface IRuntimeDirectWorldProjection
|
|||
bool isLocalPlayer,
|
||||
PositionTimestampDisposition disposition);
|
||||
|
||||
/// <summary>
|
||||
/// R2 review fix (2026-08-03): a ForcePosition on the local player is
|
||||
/// dispatched directly to <see cref="RuntimeAcceptedPositionDriveController"/>
|
||||
/// and never reaches <see cref="ProjectPosition"/> at all, so THIS is
|
||||
/// where a host that keeps a narrow collision/streaming window (the
|
||||
/// deleted <c>HeadlessSessionWorldProjection.BlipLocalPlayer</c>'s own
|
||||
/// <c>_collision.CenterOn</c> call) re-centers on the destination BEFORE
|
||||
/// the drive controller submits — establishing that the destination's
|
||||
/// collision generation is one this host's window can ever publish is a
|
||||
/// precondition for a <c>DeferredCell</c> park to be a real park rather
|
||||
/// than a dead end (see <c>RuntimeAcceptedPositionDriveController.Advance</c>'s
|
||||
/// R1 doc comment). A host with no narrow window (the graphical host,
|
||||
/// whose landblock streaming already follows the accepted position via
|
||||
/// <c>LiveEntityInboundAuthorityGate.ObserveAcceptedLocalPosition</c>) is
|
||||
/// a no-op here.
|
||||
/// </summary>
|
||||
void CenterOnAcceptedForcePosition(RuntimeEntityRecord record);
|
||||
|
||||
void BeginTeleport();
|
||||
|
||||
RuntimeDestinationReadiness PrepareDestination(
|
||||
|
|
@ -38,20 +56,28 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
private readonly WorldSession _session;
|
||||
private readonly Action<string> _log;
|
||||
private readonly IRuntimeDirectWorldProjection? _worldProjection;
|
||||
private readonly LocalPlayerOutboundController _localPlayerOutbound =
|
||||
new((_, _, _, _, _, _) => { });
|
||||
/// <summary>
|
||||
/// C4 route 2 (2026-08-03): the headless accepted-Position drive
|
||||
/// controller. Owns its own outbound-ack collaborator internally; the
|
||||
/// ForcePosition + manual <c>LocalPlayerOutboundController.SendImmediatePosition</c>
|
||||
/// pair this class used to drive directly is retired (the deleted
|
||||
/// <c>HeadlessSessionWorldProjection.BlipLocalPlayer</c>).
|
||||
/// </summary>
|
||||
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||
private bool _initialLoginCompleteSent;
|
||||
|
||||
public RuntimeLiveEntitySessionController(
|
||||
GameRuntime runtime,
|
||||
WorldSession session,
|
||||
Action<string>? log = null,
|
||||
IRuntimeDirectWorldProjection? worldProjection = null)
|
||||
IRuntimeDirectWorldProjection? worldProjection = null,
|
||||
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_log = log ?? (_ => { });
|
||||
_worldProjection = worldProjection;
|
||||
_acceptedPositionDrive = acceptedPositionDrive;
|
||||
}
|
||||
|
||||
public LiveEntitySessionSink CreateSink() => new(
|
||||
|
|
@ -218,16 +244,55 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
update.Guid,
|
||||
out RuntimeEntityRecord record))
|
||||
{
|
||||
_worldProjection?.ProjectPosition(
|
||||
record,
|
||||
isLocalPlayer: true,
|
||||
disposition);
|
||||
}
|
||||
if (disposition is PositionTimestampDisposition.ForcePosition)
|
||||
{
|
||||
_localPlayerOutbound.SendImmediatePosition(
|
||||
_session,
|
||||
_runtime.MovementOwner.Controller);
|
||||
if (disposition is PositionTimestampDisposition.ForcePosition)
|
||||
{
|
||||
// R2 review fix (2026-08-03): re-center BEFORE submitting —
|
||||
// see IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition's
|
||||
// doc comment. This is what the deleted BlipLocalPlayer's own
|
||||
// _collision.CenterOn call used to guarantee.
|
||||
_worldProjection?.CenterOnAcceptedForcePosition(record);
|
||||
|
||||
// C4 route 2 (2026-08-03): the Runtime-owned accepted-
|
||||
// Position execution seam replaces the deleted
|
||||
// HeadlessSessionWorldProjection.BlipLocalPlayer + manual
|
||||
// SendImmediatePosition pair. Canonical commit, controller
|
||||
// reconciliation, and the outbound ack (an OUTPUT of the
|
||||
// committed route, not a step alongside it) all run inside
|
||||
// the call below.
|
||||
RuntimeAcceptedPositionExecutionStatus forceStatus =
|
||||
_acceptedPositionDrive?.TryExecuteAcceptedLocalPosition(
|
||||
record,
|
||||
update,
|
||||
disposition,
|
||||
timestamps,
|
||||
timestamps.PreviousTeleport)
|
||||
?? RuntimeAcceptedPositionExecutionStatus.NotApplicable;
|
||||
if (forceStatus is RuntimeAcceptedPositionExecutionStatus
|
||||
.NotApplicable)
|
||||
{
|
||||
// R3 review fix (2026-08-03): NotApplicable (e.g. an
|
||||
// initial-Create residence still owns this record —
|
||||
// route 1's job, or the login-window controller-null
|
||||
// branch before route 1 has even published a
|
||||
// controller) is NOT the terminal case the previous
|
||||
// comment here claimed. ProjectPosition's own
|
||||
// controller-null branch is the pre-existing legacy
|
||||
// fallback this disposition always had — it must still
|
||||
// run, exactly as every other disposition's fallback
|
||||
// does below.
|
||||
_worldProjection?.ProjectPosition(
|
||||
record,
|
||||
isLocalPlayer: true,
|
||||
disposition);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_worldProjection?.ProjectPosition(
|
||||
record,
|
||||
isLocalPlayer: true,
|
||||
disposition);
|
||||
}
|
||||
}
|
||||
TryCompletePortal();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue