feat(runtime): bridge executor completion to the placement stream
Cutover slice C0 (docs/plans/2026-08-02-placement-cutover.md): the seam work that lets C3 flip hosts onto a complete receipt stream instead of growing one mid-cutover. The executor's Released exit now publishes an acknowledge-only ExecutorCompleted receipt through the one placement projection stream — registered before observer dispatch, correlated to the full execution receipt, reaped exactly once on acknowledgement/ discard/session-clear, and counted in the convergence ledger. All three production placement sinks acknowledge-and-ignore the new kind via early returns proven behavior-preserving for every existing kind; without them the first such receipt at cutover would permanently wedge the exact-head FIFO behind sinks that return false. Provably inert today: the publisher has no production caller. Execute's live inputs now derive from Runtime's own owners bound at GameRuntime construction: UsePositionFromServer is retail's exact autonomy_level != 2 (CommandInterpreter::UsePositionFromServer 0x006B3B40, startup-only knob), and PlayerDistance uses the live movement controller's position with a null-safe fallback to the caller struct — never a fabricated origin. TryPrepareAndSubmitAuthoredPlacement chains the prepared-collision Setup read through PrepareMover to submission with zero validation-semantics changes. TryCommitParent and CommitWithdrawal gain the sibling cancellation flow (residence + ordinary placement family); TryCommitParent deliberately omits LeaveWorld — retail's set_parent performs its single gated leave_world (0x00515A90) and a second would have no counterpart. Not fully dormant: the two cancellation fixes change Runtime paths production already calls (today as no-op-adjacent hardening, since nothing upstream begins a residence yet); everything else is reachable only by tests. Reviewed: retail-conformance PASS + architecture/ adversarial PASS after one fix round (sink wedge, completion-receipt lifecycle, null-controller distance). Runtime 921/921; complete Release solution 10,716 passed / 4 intentional skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
27e05b99e4
commit
67f63e85e5
14 changed files with 1639 additions and 11 deletions
|
|
@ -946,6 +946,19 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
return true;
|
||||
}
|
||||
|
||||
if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted)
|
||||
{
|
||||
// F1: acknowledge-and-ignore, same as Discard. Must NOT fall
|
||||
// through to the record-lookup/spatial-load gates below - those
|
||||
// legitimately reject for reasons unrelated to this receipt (no
|
||||
// sidecar yet, destination backend not loaded), and a false
|
||||
// return here wedges the whole ordered placement stream at the
|
||||
// FIFO head (RuntimePlacementProjectionSubscription's contract).
|
||||
// Provably inert today: PublishExecutorCompletion has zero
|
||||
// production callers.
|
||||
return true;
|
||||
}
|
||||
|
||||
RuntimePlacementProjectionToken token = projection.Token;
|
||||
if (!TryGetRuntimePlacementProjectionRecord(
|
||||
token,
|
||||
|
|
|
|||
|
|
@ -76,8 +76,21 @@ internal sealed class RuntimePlacementPresentationSink
|
|||
|
||||
if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection))
|
||||
return false;
|
||||
if (projection.Kind is RuntimePlacementProjectionKind.Discard)
|
||||
if (projection.Kind is RuntimePlacementProjectionKind.Discard
|
||||
or RuntimePlacementProjectionKind.ExecutorCompleted)
|
||||
{
|
||||
// F1: ExecutorCompleted is acknowledge-and-ignore like Discard -
|
||||
// no world/presentation mutation by definition. Must NOT fall
|
||||
// through to the record-lookup gate below (that gate legitimately
|
||||
// rejects for OTHER reasons, and this sink's caller
|
||||
// (RuntimePlacementProjectionSubscription) treats a false return
|
||||
// as "leave at the FIFO head" - a rejected ExecutorCompleted
|
||||
// would permanently wedge the whole ordered stream). Provably
|
||||
// inert today: PublishExecutorCompletion has zero production
|
||||
// callers - see
|
||||
// RuntimePlacementPresentationSinkTests.ExecutorCompleted_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone.
|
||||
return true;
|
||||
}
|
||||
if (!_liveEntities.TryGetRecord(
|
||||
projection.Token.Entity,
|
||||
out LiveEntityRecord record)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,24 @@ internal sealed class HeadlessRuntimePlacementProjectionSink
|
|||
return true;
|
||||
}
|
||||
|
||||
if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted)
|
||||
{
|
||||
// F1: acknowledge-and-ignore, same as Discard - ExecutorCompleted
|
||||
// is not a placement to project (no world/presentation mutation
|
||||
// by definition; the executor's own drain already committed
|
||||
// every Place/Withdraw this receipt follows). It must NOT fall
|
||||
// through to the record-lookup gate below: that gate can validly
|
||||
// reject an unrelated entity/session mismatch, and this sink's
|
||||
// caller (RuntimePlacementProjectionSubscription) treats a false
|
||||
// return as "leave at the FIFO head" - a rejected ExecutorCompleted
|
||||
// would permanently wedge the entire ordered placement stream
|
||||
// behind it. Currently provably inert: PublishExecutorCompletion
|
||||
// has zero production callers (Execute/RegisterEntityWithInitialResidence
|
||||
// are both unreached in production) - see
|
||||
// HeadlessSessionHostTests.ExecutorCompletedReceiptIsAcknowledgeOnlyRegardlessOfRecordValidity.
|
||||
return true;
|
||||
}
|
||||
|
||||
RuntimePlacementProjectionToken token = projection.Token;
|
||||
RuntimeEntityDirectory directory = _runtime.EntityObjects.Entities;
|
||||
if (!token.IsValid
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
|
@ -44,7 +45,18 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
|
|||
int DeferredAcceptedRelationCount = 0,
|
||||
/// <summary>Round 5 R5-3: mirrors StreamDispatchFailureCount/HasLastStreamDispatchFailure for the executor's contained-replay failure surface. Diagnostic only - like its stream precedent, NOT gated by <see cref="IsConverged"/>.</summary>
|
||||
long ReplayFailureCount = 0,
|
||||
bool HasLastReplayFailure = false)
|
||||
bool HasLastReplayFailure = false,
|
||||
/// <summary>
|
||||
/// F2: outstanding <see cref="RuntimeInitialCreateContinuationExecutor"/>
|
||||
/// token-to-receipt correlation entries - one per completed drain whose
|
||||
/// ExecutorCompleted receipt a host has not yet acknowledged. Gated by
|
||||
/// <see cref="IsConverged"/>, mirroring
|
||||
/// <see cref="RuntimeSetPositionOwnershipSnapshot.PendingProjectionAcknowledgementCount"/>'s
|
||||
/// existing "unacknowledged receipt is outstanding debt" shape for the
|
||||
/// SAME underlying receipt stream - unlike ReplayFailureCount above,
|
||||
/// this is NOT a diagnostic-only counter.
|
||||
/// </summary>
|
||||
int PendingCompletionReceiptCount = 0)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
|
|
@ -65,6 +77,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
|
|||
&& PendingMoveCount == 0
|
||||
&& InitialCreateResidenceLeaseCount == 0
|
||||
&& InitialCreateExecutorProgressCount == 0
|
||||
&& PendingCompletionReceiptCount == 0
|
||||
&& StreamSubscriberCount == 0
|
||||
&& PlacementStreamSubscriberCount == 0
|
||||
&& PendingDispatchCount == 0
|
||||
|
|
@ -152,6 +165,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
// residence state referencing the executor type directly.
|
||||
InitialCreateResidences.BindRetirementNotification(
|
||||
key => InitialCreateExecution.DiscardProgress(key));
|
||||
// F2: reaps the executor's completion-receipt correlation entry
|
||||
// exactly when a host acknowledges the ExecutorCompleted receipt it
|
||||
// correlates - mirrors the residence-retirement binding immediately
|
||||
// above.
|
||||
Physics.SetPosition.BindExecutorCompletionAcknowledgement(
|
||||
(key, sequence) =>
|
||||
InitialCreateExecution.ForgetCompletionReceipt(key, sequence));
|
||||
Placements = new RuntimePlacementProjectionChannel(
|
||||
Events,
|
||||
Physics.SetPosition);
|
||||
|
|
@ -197,6 +217,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
// residence state referencing the executor type directly.
|
||||
InitialCreateResidences.BindRetirementNotification(
|
||||
key => InitialCreateExecution.DiscardProgress(key));
|
||||
// F2: reaps the executor's completion-receipt correlation entry
|
||||
// exactly when a host acknowledges the ExecutorCompleted receipt it
|
||||
// correlates - mirrors the residence-retirement binding immediately
|
||||
// above.
|
||||
Physics.SetPosition.BindExecutorCompletionAcknowledgement(
|
||||
(key, sequence) =>
|
||||
InitialCreateExecution.ForgetCompletionReceipt(key, sequence));
|
||||
Placements = new RuntimePlacementProjectionChannel(
|
||||
Events,
|
||||
Physics.SetPosition);
|
||||
|
|
@ -242,6 +269,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
// residence state referencing the executor type directly.
|
||||
InitialCreateResidences.BindRetirementNotification(
|
||||
key => InitialCreateExecution.DiscardProgress(key));
|
||||
// F2: reaps the executor's completion-receipt correlation entry
|
||||
// exactly when a host acknowledges the ExecutorCompleted receipt it
|
||||
// correlates - mirrors the residence-retirement binding immediately
|
||||
// above.
|
||||
Physics.SetPosition.BindExecutorCompletionAcknowledgement(
|
||||
(key, sequence) =>
|
||||
InitialCreateExecution.ForgetCompletionReceipt(key, sequence));
|
||||
Placements = new RuntimePlacementProjectionChannel(
|
||||
Events,
|
||||
Physics.SetPosition);
|
||||
|
|
@ -292,7 +326,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
_disposed,
|
||||
parents.DeferredAcceptedRelationCount,
|
||||
InitialCreateExecution.ReplayFailureCount,
|
||||
InitialCreateExecution.LastReplayFailure is not null);
|
||||
InitialCreateExecution.LastReplayFailure is not null,
|
||||
InitialCreateExecution.PendingCompletionReceiptCount);
|
||||
}
|
||||
|
||||
public void BindEventContext(
|
||||
|
|
@ -306,6 +341,25 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
InitialCreateExecution.BindGeneration(generation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C0-2: forwards to <see cref="RuntimeInitialCreateContinuationExecutor.BindLiveInputs"/>,
|
||||
/// the same fan-out shape <see cref="BindEventContext"/> already uses for
|
||||
/// generation binding. Separate from <see cref="BindEventContext"/>
|
||||
/// because <c>GameRuntime</c> constructs <c>RuntimeCharacterState</c>/
|
||||
/// <c>RuntimeLocalPlayerMovementState</c> (the real source owners) AFTER
|
||||
/// this lifetime, so the live-input bind necessarily happens at a later
|
||||
/// point in <c>GameRuntime</c>'s construction sequence than the
|
||||
/// generation bind.
|
||||
/// </summary>
|
||||
public void BindLiveInputs(
|
||||
Func<bool> usePositionFromServer,
|
||||
Func<Vector3?> localPlayerPosition)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
InitialCreateExecution.BindLiveInputs(
|
||||
usePositionFromServer, localPlayerPosition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the presentation-free half of retail's CreateObject lifetime
|
||||
/// transaction. An attached graphical host may synchronously retire the
|
||||
|
|
@ -963,6 +1017,28 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
}
|
||||
|
||||
Entities.RefreshSnapshot(canonical, accepted);
|
||||
// C0-4(a): this method had NO cancellation choke-point at all,
|
||||
// leaving a residence lease or ordinary SetPosition operation
|
||||
// dangling once ordinary placement traffic goes live - fixed with
|
||||
// the SAME exactly-once ForgetInitialCreateResidence ->
|
||||
// Physics.SetPosition.Forget -> PreferCancellation sequence every
|
||||
// other commit in this family (CommitPositionChannelUpdate,
|
||||
// TryApplyPickup, CommitAcceptedParentCellless, TryAcceptDelete)
|
||||
// uses for THIS part of the job.
|
||||
// F4 (deliberate, NOT an oversight): unlike CommitPositionChannelUpdate,
|
||||
// this method does NOT also call Physics.CollisionReports.LeaveWorld.
|
||||
// Retail set_parent (0x00515A90, lines 283832-283833) performs its
|
||||
// single leave_world call gated behind the SAME add_child branch this
|
||||
// method's staged/deferred-replay commit represents (App's
|
||||
// EquippedChildRenderController realize sequence, the executor's own
|
||||
// ParentRelationReplay) - a second LeaveWorld here would double-leave-
|
||||
// world with no retail counterpart.
|
||||
RuntimePlacementCancellationReceipt initialCancellation =
|
||||
ForgetInitialCreateResidence(canonical);
|
||||
RuntimePlacementCancellationReceipt ordinaryCancellation =
|
||||
Physics.SetPosition.Forget(canonical);
|
||||
RuntimePlacementCancellationReceipt cancellation =
|
||||
PreferCancellation(initialCancellation, ordinaryCancellation);
|
||||
Entities.AdvanceParentCommit(canonical);
|
||||
ulong parentCommitVersion = canonical.ParentCommitVersion;
|
||||
return AcknowledgeProjectionAndPublish(
|
||||
|
|
@ -970,7 +1046,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
() => acknowledgeProjection?.Invoke(canonical),
|
||||
RuntimeEntityChange.Updated,
|
||||
() => canonical.ParentCommitVersion
|
||||
== parentCommitVersion);
|
||||
== parentCommitVersion,
|
||||
cancellation);
|
||||
}
|
||||
|
||||
public bool CommitAcceptedParentCellless(
|
||||
|
|
@ -1407,9 +1484,21 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
if (!Entities.IsCurrent(canonical))
|
||||
return false;
|
||||
|
||||
RuntimePlacementCancellationReceipt cancellation =
|
||||
// C0-4(b): this cancelled the initial-create residence but never the
|
||||
// ORDINARY placement family (Physics.SetPosition.Forget), unlike
|
||||
// TryApplyPickup/CommitAcceptedParentCellless/TryAcceptDelete, which
|
||||
// all cancel both. WithdrawLiveEntityProjectionToCellless routes
|
||||
// through here, so a live ordinary SetPosition/lost-cell watch could
|
||||
// dangle across a withdrawal-to-cellless once ordinary placement
|
||||
// traffic goes live. Fixed symmetrically with the same
|
||||
// Forget/PreferCancellation pair every sibling withdrawal uses.
|
||||
RuntimePlacementCancellationReceipt initialCancellation =
|
||||
ForgetInitialCreateResidence(canonical);
|
||||
Physics.CollisionReports.LeaveWorld(canonical);
|
||||
RuntimePlacementCancellationReceipt ordinaryCancellation =
|
||||
Physics.SetPosition.Forget(canonical);
|
||||
RuntimePlacementCancellationReceipt cancellation =
|
||||
PreferCancellation(initialCancellation, ordinaryCancellation);
|
||||
Entities.SuspendObjectClock(canonical);
|
||||
Entities.SetFullCell(canonical, 0u, 0u);
|
||||
ulong spatialVersion = canonical.SpatialAuthorityVersion;
|
||||
|
|
|
|||
|
|
@ -274,7 +274,26 @@ internal sealed class RuntimeInitialCreateContinuationExecutor
|
|||
_applyAcceptedSpawn;
|
||||
private readonly Dictionary<RuntimeEntityKey, Progress> _progress = [];
|
||||
private readonly HashSet<RuntimeEntityKey> _executing = [];
|
||||
/// <summary>
|
||||
/// C0-1: correlates a published
|
||||
/// <see cref="RuntimePlacementProjectionKind.ExecutorCompleted"/>
|
||||
/// receipt back to the full execution receipt/trace, keyed by the SAME
|
||||
/// public Entity/Sequence identity every other Kind uses (the receipt's
|
||||
/// own <c>Token.Entity</c>/<c>Token.Sequence</c>). Overwritten (never
|
||||
/// accumulated) per entity key - an entity cannot have two drains
|
||||
/// completing concurrently (<see cref="Execute"/>'s own <see cref="_executing"/>
|
||||
/// reentrancy guard), so only the most recent completion for a key is
|
||||
/// ever meaningful; the exact-sequence check in
|
||||
/// <see cref="TryGetCompletionReceipt"/> rejects a stale lookup against a
|
||||
/// superseded completion under a reused key.
|
||||
/// </summary>
|
||||
private readonly Dictionary<RuntimeEntityKey,
|
||||
(ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt)>
|
||||
_completionReceipts = [];
|
||||
private Func<RuntimeGenerationToken>? _generation;
|
||||
private Func<bool>? _usePositionFromServer;
|
||||
private Func<Vector3?>? _localPlayerPosition;
|
||||
private bool _liveInputsBound;
|
||||
|
||||
internal RuntimeInitialCreateContinuationExecutor(
|
||||
RuntimeEntityDirectory entities,
|
||||
|
|
@ -308,6 +327,157 @@ internal sealed class RuntimeInitialCreateContinuationExecutor
|
|||
_generation = generation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C0-2: binds Runtime's own live-input sources so no host ever computes
|
||||
/// <see cref="RuntimeInitialCreateExecutionInputs.UsePositionFromServer"/>/
|
||||
/// <see cref="RuntimeInitialCreateExecutionInputs.PlayerDistance"/>
|
||||
/// itself. Optional/nullable exactly like <see cref="_generation"/> is
|
||||
/// NOT (that one throws when unbound) - here an unbound source is a
|
||||
/// legitimate, permanent state for bare-lifetime tests, which keep
|
||||
/// constructing the executor without a <see cref="GameRuntime"/> and
|
||||
/// keep driving <see cref="Execute"/> with an explicit caller-supplied
|
||||
/// <see cref="RuntimeInitialCreateExecutionInputs"/> override (see
|
||||
/// <see cref="ResolveInputs"/>). <see cref="GameRuntime"/> binds the real
|
||||
/// owners - <c>RuntimeCharacterState.UsePositionFromServer</c> and the
|
||||
/// live <c>RuntimeLocalPlayerMovementState.Controller</c> position -
|
||||
/// once both exist (they are constructed AFTER
|
||||
/// <see cref="RuntimeEntityObjectLifetime"/>/this executor, so this bind
|
||||
/// cannot happen at the executor's own constructor time the way
|
||||
/// <see cref="BindGeneration"/> does; it happens alongside
|
||||
/// <c>BindEventContext</c> in <c>GameRuntime</c>'s construction
|
||||
/// sequence). Throws if called twice, matching every other Bind* seam on
|
||||
/// this class/its siblings (<see cref="BindGeneration"/>,
|
||||
/// <c>RuntimeEntityObjectEventStream.BindContext"/>,
|
||||
/// <c>RuntimePlacementProjectionChannel.BindGeneration</c>).
|
||||
/// F3: <paramref name="localPlayerPosition"/> itself returns
|
||||
/// <c>Vector3?</c>, not <c>Vector3</c> - a BOUND source with no live
|
||||
/// controller yet (the login-window drain, before
|
||||
/// <c>RuntimeLocalPlayerMovementState.Controller</c> exists) must yield
|
||||
/// null, not a fabricated <c>Vector3.Zero</c>. <see cref="ResolveInputs"/>
|
||||
/// falls back to the caller-supplied struct's PlayerDistance whenever
|
||||
/// this source is unbound OR returns null - the SAME fallback rule
|
||||
/// either way, never a synthetic origin-point distance that could
|
||||
/// misclassify a remote entity as implausibly far (>96 m) during that
|
||||
/// window.
|
||||
/// </summary>
|
||||
internal void BindLiveInputs(
|
||||
Func<bool> usePositionFromServer,
|
||||
Func<Vector3?> localPlayerPosition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(usePositionFromServer);
|
||||
ArgumentNullException.ThrowIfNull(localPlayerPosition);
|
||||
if (_liveInputsBound)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The initial-create continuation executor's live-input sources are already bound.");
|
||||
}
|
||||
_usePositionFromServer = usePositionFromServer;
|
||||
_localPlayerPosition = localPlayerPosition;
|
||||
_liveInputsBound = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C0-2: resolves the EFFECTIVE inputs for one <see cref="Execute"/>
|
||||
/// call. A bound source always wins; the caller-supplied
|
||||
/// <paramref name="inputs"/> struct is the test-override shape (its own
|
||||
/// doc comment still describes production usage now that this method
|
||||
/// exists) and is used verbatim only for whichever field has no bound
|
||||
/// source - a bare-lifetime test that never calls
|
||||
/// <see cref="BindLiveInputs"/> gets EXACTLY the caller-supplied values,
|
||||
/// preserving every existing test's behavior unchanged.
|
||||
/// <see cref="RuntimeInitialCreateExecutionInputs.PlayerDistance"/> uses
|
||||
/// the SAME world-space basis as today's legacy remote path
|
||||
/// (<c>LiveEntityNetworkUpdateController.cs</c>'s
|
||||
/// <c>MaxPhysicsDistance</c>/<c>dist</c> computation, cutover-routes.md
|
||||
/// route 4: <c>Vector3.Distance(worldPos, localPlayerPos)</c> where
|
||||
/// <c>localPlayerPos</c> is the live physics-CONTROLLER position, never a
|
||||
/// record snapshot) - here, <c>Vector3.Distance</c> between THIS
|
||||
/// entity's own currently-accepted position (the exact field
|
||||
/// <c>BeginAcceptedPlacementCore</c>/<c>CanonicalSetupTableId</c> already
|
||||
/// trust: <c>Snapshot.Physics?.Position ?? Snapshot.Position</c>) and the
|
||||
/// bound local-player controller position. Computed ONCE per
|
||||
/// <see cref="Execute"/> call, matching the one-shot-per-call granularity
|
||||
/// <paramref name="inputs"/> already had before this slice (retail
|
||||
/// recomputes <c>player_distance</c> per wire packet; refining this
|
||||
/// executor to per-continuation freshness is out of C0-2's scope).
|
||||
/// </summary>
|
||||
private RuntimeInitialCreateExecutionInputs ResolveInputs(
|
||||
RuntimeEntityRecord canonical,
|
||||
in RuntimeInitialCreateExecutionInputs inputs)
|
||||
{
|
||||
bool usePositionFromServer = _usePositionFromServer is { } source
|
||||
? source()
|
||||
: inputs.UsePositionFromServer;
|
||||
float playerDistance = inputs.PlayerDistance;
|
||||
// F3: an unbound source AND a bound-but-null live position (no
|
||||
// controller yet) both fall back to the caller-supplied struct
|
||||
// identically - never fabricate Vector3.Zero as a stand-in.
|
||||
if (_localPlayerPosition?.Invoke() is { } localPlayerPosition
|
||||
&& (canonical.Snapshot.Physics?.Position
|
||||
?? canonical.Snapshot.Position) is { } accepted)
|
||||
{
|
||||
var target = new Vector3(
|
||||
accepted.PositionX, accepted.PositionY, accepted.PositionZ);
|
||||
playerDistance = Vector3.Distance(target, localPlayerPosition);
|
||||
}
|
||||
return new RuntimeInitialCreateExecutionInputs(
|
||||
usePositionFromServer, playerDistance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C0-1: reaches the full execution receipt/trace correlated with an
|
||||
/// observed <see cref="RuntimePlacementProjectionKind.ExecutorCompleted"/>
|
||||
/// receipt, purely via that receipt's own public
|
||||
/// <c>Token.Entity</c>/<c>Token.Sequence</c> identity - the same identity
|
||||
/// every other placement Kind is acknowledged by. Returns false for a
|
||||
/// superseded/stale sequence under a reused entity key.
|
||||
/// </summary>
|
||||
internal bool TryGetCompletionReceipt(
|
||||
in RuntimePlacementProjectionToken token,
|
||||
out RuntimeInitialCreateExecutionReceipt receipt)
|
||||
{
|
||||
if (_completionReceipts.TryGetValue(
|
||||
token.Entity,
|
||||
out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt) entry)
|
||||
&& entry.Sequence == token.Sequence)
|
||||
{
|
||||
receipt = entry.Receipt;
|
||||
return true;
|
||||
}
|
||||
receipt = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F2: reaps exactly one completion-receipt correlation entry, bound as
|
||||
/// <see cref="RuntimeSetPositionState.BindExecutorCompletionAcknowledgement"/>'s
|
||||
/// notification callback - fired the moment a host acknowledges the
|
||||
/// Kind ExecutorCompleted receipt this entry correlates, never before.
|
||||
/// The exact-sequence check rejects removing a NEWER completion's entry
|
||||
/// under a reused key (mirrors <see cref="TryGetCompletionReceipt"/>'s
|
||||
/// own currency check).
|
||||
/// </summary>
|
||||
internal void ForgetCompletionReceipt(RuntimeEntityKey key, ulong sequence)
|
||||
{
|
||||
if (_completionReceipts.TryGetValue(
|
||||
key,
|
||||
out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt) entry)
|
||||
&& entry.Sequence == sequence)
|
||||
{
|
||||
_completionReceipts.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F2: folded into <see cref="RuntimeEntityObjectOwnershipSnapshot"/>/
|
||||
/// <c>IsConverged</c> - an unacknowledged completion receipt is
|
||||
/// outstanding host debt, mirroring
|
||||
/// <see cref="RuntimeSetPositionOwnershipSnapshot.PendingProjectionAcknowledgementCount"/>'s
|
||||
/// existing "must be zero to converge" shape for the SAME underlying
|
||||
/// receipt stream.
|
||||
/// </summary>
|
||||
internal int PendingCompletionReceiptCount => _completionReceipts.Count;
|
||||
|
||||
internal int ProgressCount => _progress.Count;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -389,6 +559,15 @@ internal sealed class RuntimeInitialCreateContinuationExecutor
|
|||
/// </summary>
|
||||
internal void DiscardProgress(RuntimeEntityKey key)
|
||||
{
|
||||
// F2: reap this key's completion-receipt correlation entry
|
||||
// unconditionally - DiscardProgress owns cleanup of every piece of
|
||||
// state IT introduces, and this cache is exactly that (see
|
||||
// _completionReceipts's own doc comment). Independent of whether
|
||||
// _progress still tracks this key: a completed drain has ALREADY
|
||||
// removed its own Progress entry before this correlation entry was
|
||||
// ever added (see ExecuteCore's Released case), so this is the
|
||||
// ONLY choke point that reaps it outside of a normal acknowledge.
|
||||
_completionReceipts.Remove(key);
|
||||
if (!_progress.Remove(key, out Progress? progress))
|
||||
return;
|
||||
if (progress.PendingContinuationPlacement.IsValid)
|
||||
|
|
@ -421,6 +600,9 @@ internal sealed class RuntimeInitialCreateContinuationExecutor
|
|||
_physics.SetPosition.PublishCancellation(cancellation);
|
||||
}
|
||||
_progress.Clear();
|
||||
// F2: bulk-reap every completion-receipt correlation entry - a full
|
||||
// session clear must not carry any of this cache across a reset.
|
||||
_completionReceipts.Clear();
|
||||
}
|
||||
|
||||
internal RuntimeInitialCreateExecutionStatus Execute(
|
||||
|
|
@ -458,6 +640,11 @@ internal sealed class RuntimeInitialCreateContinuationExecutor
|
|||
out RuntimeInitialCreateExecutionReceipt receipt)
|
||||
{
|
||||
receipt = default;
|
||||
// C0-2: resolve ONCE per Execute call - a bound Runtime source always
|
||||
// wins over the caller-supplied test-override struct (see
|
||||
// ResolveInputs's own doc comment for the exact fallback rule).
|
||||
RuntimeInitialCreateExecutionInputs effectiveInputs =
|
||||
ResolveInputs(canonical, inputs);
|
||||
|
||||
// An existing Progress for a DIFFERENT (older or ABA-reused) lease
|
||||
// id is discarded here, and THIS exact call fails closed - an old
|
||||
|
|
@ -561,7 +748,7 @@ internal sealed class RuntimeInitialCreateContinuationExecutor
|
|||
return Abandon(canonical, key);
|
||||
|
||||
RuntimeInitialCreateExecutionStatus applyStatus =
|
||||
ApplyContinuation(canonical, token, key, continuation, inputs, progress);
|
||||
ApplyContinuation(canonical, token, key, continuation, effectiveInputs, progress);
|
||||
// Round 3 B2: every apply method below now rebaselines
|
||||
// itself immediately after its own canonical mutation and
|
||||
// BEFORE its own publish (mutate -> rebaseline -> publish),
|
||||
|
|
@ -592,14 +779,36 @@ internal sealed class RuntimeInitialCreateContinuationExecutor
|
|||
switch (release)
|
||||
{
|
||||
case RuntimeInitialCreateResidenceExecutorReleaseStatus.Released:
|
||||
receipt = new RuntimeInitialCreateExecutionReceipt(
|
||||
{
|
||||
var completedReceipt = new RuntimeInitialCreateExecutionReceipt(
|
||||
key,
|
||||
residenceReceipt.FullCellId,
|
||||
residenceReceipt.TeleportHookPhase,
|
||||
progress.Trace.ToImmutable(),
|
||||
progress.ReplayedDeferredChildCount);
|
||||
receipt = completedReceipt;
|
||||
_progress.Remove(key);
|
||||
// C0-1: bridge the executor's own completion onto the
|
||||
// SAME ordered placement receipt stream every
|
||||
// Place/Withdraw/Discard uses (canonical is still
|
||||
// current here - nothing between the last continuation
|
||||
// apply and ConsumeExecuted's Released outcome mutates
|
||||
// it). Correlate the full trace via the fresh token's
|
||||
// Entity/Sequence identity - see TryGetCompletionReceipt.
|
||||
// F2: registration happens INSIDE PublishExecutorCompletion's
|
||||
// beforePublish callback (before the synchronous observer
|
||||
// dispatch), not after this call returns - a subscriber
|
||||
// reading the correlation back from inside its own
|
||||
// OnPlacement callback must already find it. receipt is
|
||||
// copied to a local (completedReceipt) because an `out`
|
||||
// parameter cannot be captured by a lambda.
|
||||
_physics.SetPosition.PublishExecutorCompletion(
|
||||
canonical,
|
||||
beforePublish: token =>
|
||||
_completionReceipts[key] =
|
||||
(token.Sequence, completedReceipt));
|
||||
return RuntimeInitialCreateExecutionStatus.Completed;
|
||||
}
|
||||
case RuntimeInitialCreateResidenceExecutorReleaseStatus.Revised:
|
||||
// A new continuation arrived mid-drain (Enqueue bumps the
|
||||
// completed entry's Adoption.Revision in place). Re-fetch
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
|
@ -267,6 +268,22 @@ public sealed class GameRuntime
|
|||
() => generationReset.ActiveRetiringGeneration
|
||||
?? context.Session.Generation,
|
||||
() => clock.FrameNumber);
|
||||
// C0-2: bind the executor's live-input sources to the real
|
||||
// Runtime owners now that both exist (RuntimeCharacterState at
|
||||
// CharacterCreated, RuntimeLocalPlayerMovementState at
|
||||
// MovementCreated - both after EntityObjectsCreated, so this
|
||||
// cannot move earlier). UsePositionFromServer mirrors retail
|
||||
// CommandInterpreter::UsePositionFromServer exactly; PlayerDistance
|
||||
// is derived per Execute call from the live physics-controller
|
||||
// position, matching the legacy remote path's own distance basis
|
||||
// (LiveEntityNetworkUpdateController's MaxPhysicsDistance/dist).
|
||||
// F3: the position source is nullable - a null Controller (the
|
||||
// login-window drain, before the local player's own controller
|
||||
// exists yet) must yield null, never a fabricated Vector3.Zero
|
||||
// that would misclassify every remote entity as implausibly far.
|
||||
context.EntityObjects.BindLiveInputs(
|
||||
() => context.Character.UsePositionFromServer,
|
||||
() => context.Movement.Controller?.Position);
|
||||
context.Events = new GameRuntimeEventHub(
|
||||
context.EntityObjects,
|
||||
context.Communication,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot(
|
|||
int PositionCount,
|
||||
int PropertyCount,
|
||||
bool OptionsAreDefaults,
|
||||
bool MovementSkillsAreReset)
|
||||
bool MovementSkillsAreReset,
|
||||
/// <summary>C0-2: <see cref="RuntimeCharacterState.AutonomyLevel"/> is back at retail's default (<see cref="RuntimeCharacterState.FullAutonomyLevel"/>).</summary>
|
||||
bool AutonomyIsDefault = true)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
|
|
@ -33,7 +35,8 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot(
|
|||
&& PositionCount == 0
|
||||
&& PropertyCount == 0
|
||||
&& OptionsAreDefaults
|
||||
&& MovementSkillsAreReset;
|
||||
&& MovementSkillsAreReset
|
||||
&& AutonomyIsDefault;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -47,11 +50,27 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
public const uint RunSkillId = 24u;
|
||||
/// <summary>ACE Skill enum ordinal for Jump (K-fix7 / pseudocode doc §5).</summary>
|
||||
public const uint JumpSkillId = 22u;
|
||||
/// <summary>
|
||||
/// C0-2/F5(b): retail <c>CommandInterpreter</c>'s own default, set at
|
||||
/// construction (pseudo-C 699752, <c>this->autonomy_level = 2;</c>, a
|
||||
/// direct field write, not a <c>SetAutonomyLevel</c> call) and by the
|
||||
/// command-line-only override at admission
|
||||
/// (<c>command_line_autonomy_level</c>, pseudo-C 1088429, itself
|
||||
/// defaulting to <c>0x2</c>). Exactly ONE retail caller of
|
||||
/// <c>CommandInterpreter::SetAutonomyLevel</c> exists in the named
|
||||
/// retail decomp - the startup construction path at pseudo-C 94102
|
||||
/// (<c>cmdinterp->vtable->SetAutonomyLevel(cmdinterp, command_line_autonomy_level)</c>)
|
||||
/// - it is a startup/debug knob, not a per-play-session gameplay
|
||||
/// toggle, so acdream's own default matches retail's value exactly and
|
||||
/// nothing in ordinary play ever changes it.
|
||||
/// </summary>
|
||||
public const uint FullAutonomyLevel = 2u;
|
||||
|
||||
private bool _disposed;
|
||||
private long _characterRevision;
|
||||
private long _spellbookRevision;
|
||||
private bool _internalSubscriptionsAttached;
|
||||
private uint _autonomyLevel = FullAutonomyLevel;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30): the pre-<c>EnchantSkill</c> base
|
||||
|
|
@ -88,6 +107,39 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
public IRuntimeCharacterView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
/// <summary>Retail <c>CommandInterpreter::GetAutonomyLevel</c>.</summary>
|
||||
public uint AutonomyLevel => Volatile.Read(ref _autonomyLevel);
|
||||
|
||||
/// <summary>
|
||||
/// C0-2: retail <c>CommandInterpreter::UsePositionFromServer</c>
|
||||
/// (pseudo-C 699506-699512: <c>result = this->autonomy_level != 2;</c>).
|
||||
/// This is the source
|
||||
/// <see cref="AcDream.Runtime.Entities.RuntimeInitialCreateContinuationExecutor.BindLiveInputs"/>
|
||||
/// binds for <c>RuntimeInitialCreateExecutionInputs.UsePositionFromServer</c>
|
||||
/// - the local-player-only interpolate gate consumed by
|
||||
/// <c>RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition</c>.
|
||||
/// </summary>
|
||||
public bool UsePositionFromServer => AutonomyLevel != FullAutonomyLevel;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CommandInterpreter::SetAutonomyLevel</c> (pseudo-C
|
||||
/// 699542-699552): rejects any value above 2, otherwise commits.
|
||||
/// F5(a): retail's own setter ALSO sends <c>SendAutonomyLevelEvent</c>
|
||||
/// (pseudo-C 699550) after committing - this Runtime-only port has no
|
||||
/// outbound wire concept to carry that event today (autonomy level has
|
||||
/// no host caller yet). Any FUTURE host exposure of this setter (e.g. a
|
||||
/// debug/admin command) MUST also send the equivalent outbound event -
|
||||
/// do not port only the field write.
|
||||
/// </summary>
|
||||
public bool TrySetAutonomyLevel(uint level)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (level > FullAutonomyLevel)
|
||||
return false;
|
||||
Volatile.Write(ref _autonomyLevel, level);
|
||||
return true;
|
||||
}
|
||||
|
||||
public RuntimeCharacterOwnershipSnapshot CaptureOwnership()
|
||||
{
|
||||
int favoriteCount = 0;
|
||||
|
|
@ -144,7 +196,8 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
&& MovementSkills.LastPkAttackTimestamp is null
|
||||
&& _runSkillBase == -1
|
||||
&& _jumpSkillBase == -1
|
||||
&& _movementSkillAugmentations == default);
|
||||
&& _movementSkillAugmentations == default,
|
||||
AutonomyLevel == FullAutonomyLevel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -355,6 +408,7 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
_runSkillBase = -1;
|
||||
_jumpSkillBase = -1;
|
||||
_movementSkillAugmentations = default;
|
||||
Volatile.Write(ref _autonomyLevel, FullAutonomyLevel);
|
||||
Try(MovementSkills.ResetSession, ref failures);
|
||||
if (failures is not null)
|
||||
{
|
||||
|
|
@ -380,6 +434,7 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
_runSkillBase = -1;
|
||||
_jumpSkillBase = -1;
|
||||
_movementSkillAugmentations = default;
|
||||
Volatile.Write(ref _autonomyLevel, FullAutonomyLevel);
|
||||
Try(MovementSkills.ResetSession, ref failures);
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -59,6 +60,17 @@ public enum RuntimePlacementProjectionKind
|
|||
Withdraw,
|
||||
Place,
|
||||
Discard,
|
||||
/// <summary>
|
||||
/// C0-1: the initial-create continuation executor's own FIFO drain has
|
||||
/// finished for this entity (not itself a SetPosition operation - the
|
||||
/// residence lease is already released by the time this publishes).
|
||||
/// Published on the SAME ordered stream every Place/Withdraw/Discard
|
||||
/// receipt uses so a host learns "this entity's placement committed and
|
||||
/// its FIFO drained" through the one already-built observer seam,
|
||||
/// instead of a second stream/queue. Acknowledge-only, like Discard -
|
||||
/// see AcknowledgeProjection's dedicated branch.
|
||||
/// </summary>
|
||||
ExecutorCompleted,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimePortalPlacementAuthority(
|
||||
|
|
@ -432,6 +444,7 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
private ulong _nextCollisionPrefixQuiescenceOperationId;
|
||||
private ulong _nextLostDeadlineSequence;
|
||||
private RuntimeEntityObjectEventStream? _events;
|
||||
private Action<RuntimeEntityKey, ulong>? _executorCompletionAcknowledged;
|
||||
private bool _disposed;
|
||||
|
||||
private readonly record struct LostDeadlineEntry(
|
||||
|
|
@ -770,6 +783,87 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C0-1: publishes the initial-create continuation executor's own
|
||||
/// completion (its FIFO drain has finished and its residence lease is
|
||||
/// already released) on the SAME ordered receipt stream every
|
||||
/// Place/Withdraw/Discard uses - the pinned contract forbids a second
|
||||
/// stream/queue. This is NOT an Operation-backed receipt (the executor's
|
||||
/// own placement operation, if any, already committed and was
|
||||
/// acknowledged earlier in the drain - see
|
||||
/// RuntimeInitialCreateContinuationExecutor.ExecuteCore's call site) so
|
||||
/// the token is built the same way PublishProjection assembles one, but
|
||||
/// from the CANONICAL record's current authority/version/cell facts
|
||||
/// (self-consistent: nothing else can move them synchronously between
|
||||
/// the executor's release and this publish) instead of a now-gone
|
||||
/// Operation. AcknowledgeProjection's dedicated ExecutorCompleted branch
|
||||
/// treats it exactly like Discard - acknowledge-only, no operation to
|
||||
/// resume or commit against.
|
||||
/// </summary>
|
||||
internal RuntimePlacementProjectionToken PublishExecutorCompletion(
|
||||
RuntimeEntityRecord record,
|
||||
Action<RuntimePlacementProjectionToken>? beforePublish = null,
|
||||
RuntimePortalPlacementAuthority portal = default)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
if (record.Key is not { } key)
|
||||
return default;
|
||||
|
||||
PhysicsBody? body = record.PhysicsBody;
|
||||
ulong sequence = checked(++_nextProjectionSequence);
|
||||
var token = new RuntimePlacementProjectionToken(
|
||||
sequence,
|
||||
Revision: 1UL,
|
||||
key,
|
||||
record.PositionAuthorityVersion,
|
||||
record.SpatialAuthorityVersion,
|
||||
record.PlacementCommitVersion,
|
||||
_entities.SessionLifetimeVersion,
|
||||
record.FullCellId,
|
||||
_physics.ExpectedCollisionGeneration(record.FullCellId),
|
||||
portal);
|
||||
var snapshot = new RuntimePlacementProjectionSnapshot(
|
||||
token,
|
||||
RuntimePlacementProjectionKind.ExecutorCompleted,
|
||||
body?.Position ?? Vector3.Zero,
|
||||
body?.Orientation ?? Quaternion.Identity,
|
||||
body?.CellPosition.Frame.Origin ?? Vector3.Zero,
|
||||
body?.InContact ?? false,
|
||||
body?.OnWalkable ?? false);
|
||||
_pendingProjection.Add(sequence, snapshot);
|
||||
// F2: register-before-publish - beforePublish runs while the token is
|
||||
// already in _pendingProjection but before PublishPlacement's
|
||||
// synchronous observer dispatch, so a subscriber reading back the
|
||||
// executor's correlation entry from inside its OWN OnPlacement
|
||||
// callback always finds it.
|
||||
beforePublish?.Invoke(token);
|
||||
PublishPlacement(snapshot);
|
||||
return token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F2: binds the ONE notification fired when a Kind ExecutorCompleted
|
||||
/// receipt is acknowledged (mirrors
|
||||
/// RuntimeInitialCreateResidenceState.BindRetirementNotification's
|
||||
/// existing one-bound-delegate shape). The executor uses this to reap
|
||||
/// its own token-to-receipt correlation entry exactly when the receipt
|
||||
/// it correlates is consumed - never before (a host might still be
|
||||
/// mid-retry) and never left dangling after (unbounded per-completed-
|
||||
/// entity retention).
|
||||
/// </summary>
|
||||
internal void BindExecutorCompletionAcknowledgement(
|
||||
Action<RuntimeEntityKey, ulong> acknowledged)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acknowledged);
|
||||
if (_executorCompletionAcknowledged is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The executor-completion acknowledgement notification is already bound.");
|
||||
}
|
||||
_executorCompletionAcknowledged = acknowledged;
|
||||
}
|
||||
|
||||
internal void ResetSession()
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
|
|
@ -1132,6 +1226,95 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
return RuntimeSetPositionMoverPreparationStatus.Prepared;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C0-3: chains the exact-Setup mover pipeline end-to-end for an
|
||||
/// authored placement (initial-Create or any other authored-mover
|
||||
/// operation) whose route performs SetPosition - PrepareMover /
|
||||
/// RuntimeSetPositionMoverPreparer.TryBuild /
|
||||
/// IPreparedCollisionSource.ReadSetupCollision already exist piecewise
|
||||
/// (inventory gap a); this is the missing wiring, not a behavior change.
|
||||
/// Reads the CANONICAL Setup table id from the record via
|
||||
/// <see cref="CanonicalSetupTableId"/> - the SAME field
|
||||
/// <see cref="CapturePreparationAuthority"/> already trusts - rather than
|
||||
/// a caller-supplied id, so this can never be pointed at the wrong
|
||||
/// Setup. A record with no authored Setup at all (id 0) takes retail's
|
||||
/// genuine "no Setup" dummy-sphere path
|
||||
/// (<see cref="RuntimeSetPositionMoverSetup.ResolvedAbsent"/>) instead of
|
||||
/// reading anything; a record WITH an id but an unavailable/corrupt
|
||||
/// asynchronous read yields <see cref="RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable"/>
|
||||
/// so the caller retries once the prepared-collision package lands,
|
||||
/// mirroring <see cref="RuntimeSetPositionMoverSetup"/>'s own
|
||||
/// doc-comment distinction between "not arrived yet" and "resolved
|
||||
/// absent". Dormant: internal, no production caller - a residence
|
||||
/// lease's own <c>Placement</c>/<c>Route.OperationKind</c>/
|
||||
/// <c>Route.SetPositionFlags</c> are exactly the token/kind/flags this
|
||||
/// takes.
|
||||
/// </summary>
|
||||
internal RuntimeSetPositionMoverPreparationStatus
|
||||
TryPrepareAndSubmitAuthoredPlacement(
|
||||
RuntimeEntityRecord record,
|
||||
in RuntimeEntityPlacementToken token,
|
||||
RuntimeSetPositionOperationKind operationKind,
|
||||
PhysicsSetPositionFlags flags,
|
||||
IPreparedCollisionSource collisionSource,
|
||||
double gameTime,
|
||||
out RuntimeSetPositionOutcome outcome,
|
||||
PhysicsPlacementClass placementClass = PhysicsPlacementClass.Ordinary,
|
||||
RuntimePortalPlacementAuthority portal = default,
|
||||
Vector3 line = default,
|
||||
float scatterRadiusX = 0f,
|
||||
float scatterRadiusY = 0f,
|
||||
uint scatterAttempts = 0u,
|
||||
float shadowWorldOffsetX = 0f,
|
||||
float shadowWorldOffsetY = 0f)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(collisionSource);
|
||||
outcome = default;
|
||||
|
||||
uint setupTableId = CanonicalSetupTableId(record);
|
||||
RuntimeSetPositionMoverSetup setup;
|
||||
if (setupTableId == 0u)
|
||||
{
|
||||
setup = RuntimeSetPositionMoverSetup.ResolvedAbsent;
|
||||
}
|
||||
else
|
||||
{
|
||||
PreparedCollisionReadResult<FlatSetupCollision> read =
|
||||
collisionSource.ReadSetupCollision(setupTableId);
|
||||
if (read.Status != PreparedAssetReadStatus.Loaded
|
||||
|| read.Data is null)
|
||||
{
|
||||
return RuntimeSetPositionMoverPreparationStatus
|
||||
.RetrySetupUnavailable;
|
||||
}
|
||||
setup = RuntimeSetPositionMoverSetup.Resolved(
|
||||
setupTableId, read.Data);
|
||||
}
|
||||
|
||||
var preparation = new RuntimeSetPositionMoverPreparation(
|
||||
setup,
|
||||
operationKind,
|
||||
gameTime,
|
||||
placementClass,
|
||||
flags,
|
||||
line,
|
||||
scatterRadiusX,
|
||||
scatterRadiusY,
|
||||
scatterAttempts,
|
||||
shadowWorldOffsetX,
|
||||
shadowWorldOffsetY,
|
||||
portal);
|
||||
RuntimeSetPositionMoverPreparationStatus status = PrepareMover(
|
||||
token, preparation, out RuntimeSetPositionCommand command);
|
||||
if (status != RuntimeSetPositionMoverPreparationStatus.Prepared)
|
||||
return status;
|
||||
|
||||
outcome = SubmitPreparedPlacement(token, command);
|
||||
return RuntimeSetPositionMoverPreparationStatus.Prepared;
|
||||
}
|
||||
|
||||
internal bool IsExactPreparedPlacementCurrent(
|
||||
RuntimeEntityRecord record,
|
||||
in RuntimeEntityPlacementToken token,
|
||||
|
|
@ -2313,10 +2496,24 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
{
|
||||
return false;
|
||||
}
|
||||
if (pending.Kind is RuntimePlacementProjectionKind.Discard)
|
||||
if (pending.Kind is RuntimePlacementProjectionKind.Discard
|
||||
or RuntimePlacementProjectionKind.ExecutorCompleted)
|
||||
{
|
||||
// C0-1: an ExecutorCompleted receipt is never Operation-backed
|
||||
// (see PublishExecutorCompletion) - there is nothing to resume or
|
||||
// commit against, exactly like Discard.
|
||||
_pendingProjection.Remove(token.Sequence);
|
||||
RetireQuiescenceProjectionSequence(token.Sequence);
|
||||
if (pending.Kind is RuntimePlacementProjectionKind.ExecutorCompleted)
|
||||
{
|
||||
// F2: notify the executor so it can reap its own
|
||||
// token-to-receipt correlation entry now - not before (a
|
||||
// host might still be mid-retry against this exact
|
||||
// unacknowledged receipt) and not left dangling after.
|
||||
_executorCompletionAcknowledged?.Invoke(
|
||||
token.Entity,
|
||||
token.Sequence);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!_operations.TryGetValue(
|
||||
|
|
|
|||
|
|
@ -155,6 +155,44 @@ public sealed class RuntimePlacementPresentationSinkTests
|
|||
Assert.Equal(priorVisible, record.IsSpatiallyVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutorCompleted_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone()
|
||||
{
|
||||
// F1: mirrors Discard_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone
|
||||
// exactly - proves ExecutorCompleted is acknowledged unconditionally
|
||||
// (never wedges the FIFO on a record-lookup failure) and never
|
||||
// mutates presentation state, even under a completely bogus token.
|
||||
Fixture fixture = Fixture.Create();
|
||||
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
|
||||
WorldEntity entity = record.WorldEntity!;
|
||||
RuntimePlacementProjectionSnapshot completion = Placement(
|
||||
fixture,
|
||||
record,
|
||||
RuntimePlacementProjectionKind.ExecutorCompleted,
|
||||
new Vector3(900f),
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1f)) with
|
||||
{
|
||||
Token = Placement(fixture, record,
|
||||
RuntimePlacementProjectionKind.Place,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity).Token with
|
||||
{
|
||||
SessionLifetimeVersion = ulong.MaxValue,
|
||||
PositionAuthorityVersion = ulong.MaxValue,
|
||||
ExactCellId = 0xDEAD0001u,
|
||||
},
|
||||
};
|
||||
Vector3 priorPosition = entity.Position;
|
||||
Quaternion priorRotation = entity.Rotation;
|
||||
bool priorVisible = record.IsSpatiallyVisible;
|
||||
|
||||
Assert.True(fixture.Sink.TryApply(in completion));
|
||||
|
||||
Assert.Equal(priorPosition, entity.Position);
|
||||
Assert.Equal(priorRotation, entity.Rotation);
|
||||
Assert.Equal(priorVisible, record.IsSpatiallyVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Place_RejectsStaleCanonicalVersionsWithoutChangingSidecar()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -479,6 +479,56 @@ public sealed class HeadlessSessionHostTests
|
|||
Assert.True(projection.TryApply(in discard));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutorCompletedReceiptIsAcknowledgeOnlyRegardlessOfRecordValidity()
|
||||
{
|
||||
// F1: mirrors PlacementReceiptUsesExactIncarnationAndDiscardIsAckOnly's
|
||||
// stale-token half - proves ExecutorCompleted is acknowledged
|
||||
// unconditionally (never gated by the record-lookup/portal-shape
|
||||
// checks Place/Withdraw depend on), so a genuinely stale/mismatched
|
||||
// token can never wedge the FIFO behind it.
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntity(Spawn(0x50000006u))
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
record.CreateIntegrationVersion,
|
||||
record.Snapshot,
|
||||
replaceGeneration: false));
|
||||
var sink = new HeadlessRuntimePlacementProjectionSink(runtime);
|
||||
RuntimePlacementProjectionSnapshot completion = Placement(
|
||||
runtime,
|
||||
record,
|
||||
RuntimePlacementProjectionKind.ExecutorCompleted,
|
||||
Vector3.One,
|
||||
Quaternion.Identity);
|
||||
RuntimePlacementProjectionSnapshot stale = completion with
|
||||
{
|
||||
Token = completion.Token with
|
||||
{
|
||||
Entity = completion.Token.Entity with
|
||||
{
|
||||
Incarnation = unchecked((ushort)(
|
||||
completion.Token.Entity.Incarnation + 1)),
|
||||
},
|
||||
SessionLifetimeVersion = ulong.MaxValue,
|
||||
},
|
||||
};
|
||||
|
||||
Assert.True(sink.TryApply(in completion));
|
||||
Assert.True(sink.TryApply(in stale));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionEventRouteOwnsOneObserverAndUnsubscribesBeforeNetworkDetach()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4223,6 +4223,480 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests
|
|||
Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// F. C0-1: executor-completion bridge / C0-2: live-input binding
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ExecutorCompletion_PublishesOnTheSamePlacementStreamCorrelatedWithTheFullReceipt()
|
||||
{
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime, 400UL);
|
||||
const uint guid = 0x70024000u;
|
||||
RuntimeEntityRecord canonical = lifetime
|
||||
.RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(lifetime.TryGetInitialCreateResidence(
|
||||
canonical,
|
||||
out RuntimeInitialCreateResidenceLease lease));
|
||||
AttachDormantBody(lifetime, canonical);
|
||||
CompleteInitialPlacement(lifetime, lease);
|
||||
|
||||
var observed = new List<RuntimePlacementProjectionSnapshot>();
|
||||
using IDisposable subscription = lifetime.Events.SubscribePlacement(
|
||||
new PlacementObserver(delta => observed.Add(delta.Placement)));
|
||||
|
||||
RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion(
|
||||
lifetime, canonical, lease.Token, NoContact);
|
||||
|
||||
RuntimePlacementProjectionSnapshot completion = Assert.Single(observed);
|
||||
Assert.Equal(
|
||||
RuntimePlacementProjectionKind.ExecutorCompleted,
|
||||
completion.Kind);
|
||||
Assert.Equal(canonical.Key, completion.Token.Entity);
|
||||
Assert.True(lifetime.InitialCreateExecution.TryGetCompletionReceipt(
|
||||
completion.Token,
|
||||
out RuntimeInitialCreateExecutionReceipt correlated));
|
||||
Assert.Equal(receipt, correlated);
|
||||
|
||||
// Acknowledge-only, exact-head, same as every other Kind.
|
||||
Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
completion.Token));
|
||||
Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutorCompletion_ObservedOnlyAfterAnyContinuationPlacementInFifoOrder()
|
||||
{
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime, 401UL);
|
||||
const uint guid = 0x70024001u;
|
||||
RuntimeEntityRecord canonical = lifetime
|
||||
.RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(lifetime.TryGetInitialCreateResidence(
|
||||
canonical,
|
||||
out RuntimeInitialCreateResidenceLease lease));
|
||||
AttachDormantBody(lifetime, canonical);
|
||||
CompleteInitialPlacement(lifetime, lease);
|
||||
|
||||
// A teleport-advanced Position continuation performs its OWN
|
||||
// authored SetPosition - the continuation placement C0-1's contract
|
||||
// says already flows through the channel unchanged.
|
||||
WorldSession.EntityPositionUpdate update = PositionUpdate(
|
||||
guid, positionSequence: 2, teleportSequence: 1,
|
||||
forcePositionSequence: 0, positionX: 40f);
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
update, isLocalPlayer: true, null, null, true, null,
|
||||
out PositionTimestampDisposition disposition, out _, out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
|
||||
var observed = new List<RuntimePlacementProjectionSnapshot>();
|
||||
using IDisposable subscription = lifetime.Events.SubscribePlacement(
|
||||
new PlacementObserver(delta => observed.Add(delta.Placement)));
|
||||
|
||||
RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion(
|
||||
lifetime, canonical, lease.Token, NoContact);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
RuntimePlacementProjectionKind.Place,
|
||||
RuntimePlacementProjectionKind.ExecutorCompleted,
|
||||
],
|
||||
observed.Select(static s => s.Kind));
|
||||
Assert.Equal(canonical.Key, observed[0].Token.Entity);
|
||||
Assert.True(lifetime.InitialCreateExecution.TryGetCompletionReceipt(
|
||||
observed[1].Token,
|
||||
out RuntimeInitialCreateExecutionReceipt correlated));
|
||||
Assert.Equal(receipt, correlated);
|
||||
// The continuation Place receipt was already acknowledged by
|
||||
// RunToCompletion's own CompletePendingContinuationPlacement helper
|
||||
// before Execute ever reached Completed - only the fresh
|
||||
// ExecutorCompleted receipt is still outstanding.
|
||||
Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
observed[1].Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutorCompletion_ReceiptIsReadableFromWithinTheSameSynchronousOnPlacementDispatch()
|
||||
{
|
||||
// F2: registration must happen BEFORE PublishPlacement's synchronous
|
||||
// observer dispatch - a subscriber reading the correlation back from
|
||||
// inside its OWN OnPlacement callback must already find it, not only
|
||||
// after RunToCompletion returns.
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime, 410UL);
|
||||
const uint guid = 0x70024010u;
|
||||
RuntimeEntityRecord canonical = lifetime
|
||||
.RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(lifetime.TryGetInitialCreateResidence(
|
||||
canonical,
|
||||
out RuntimeInitialCreateResidenceLease lease));
|
||||
AttachDormantBody(lifetime, canonical);
|
||||
CompleteInitialPlacement(lifetime, lease);
|
||||
|
||||
RuntimeInitialCreateExecutionReceipt? observedFromInsideDispatch = null;
|
||||
using IDisposable subscription = lifetime.Events.SubscribePlacement(
|
||||
new PlacementObserver(delta =>
|
||||
{
|
||||
if (delta.Placement.Kind
|
||||
is not RuntimePlacementProjectionKind.ExecutorCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Assert.True(lifetime.InitialCreateExecution.TryGetCompletionReceipt(
|
||||
delta.Placement.Token,
|
||||
out RuntimeInitialCreateExecutionReceipt receipt));
|
||||
observedFromInsideDispatch = receipt;
|
||||
}));
|
||||
|
||||
RuntimeInitialCreateExecutionReceipt receiptReturned = RunToCompletion(
|
||||
lifetime, canonical, lease.Token, NoContact);
|
||||
|
||||
Assert.NotNull(observedFromInsideDispatch);
|
||||
Assert.Equal(receiptReturned, observedFromInsideDispatch!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutorCompletion_ConvergenceLedgerCountsAnUnacknowledgedReceiptAsOutstandingDebtUntilAcknowledged()
|
||||
{
|
||||
// F2: PendingCompletionReceiptCount mirrors
|
||||
// RuntimeSetPositionOwnershipSnapshot.PendingProjectionAcknowledgementCount's
|
||||
// existing "unacknowledged receipt is outstanding debt" shape for
|
||||
// the SAME underlying receipt stream - non-zero while unacknowledged,
|
||||
// reaped to zero exactly on acknowledge (never before, never left
|
||||
// dangling after).
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime, 411UL);
|
||||
const uint guid = 0x70024011u;
|
||||
RuntimeEntityRecord canonical = lifetime
|
||||
.RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(lifetime.TryGetInitialCreateResidence(
|
||||
canonical,
|
||||
out RuntimeInitialCreateResidenceLease lease));
|
||||
AttachDormantBody(lifetime, canonical);
|
||||
CompleteInitialPlacement(lifetime, lease);
|
||||
Assert.Equal(
|
||||
0,
|
||||
lifetime.CaptureOwnership().PendingCompletionReceiptCount);
|
||||
|
||||
RunToCompletion(lifetime, canonical, lease.Token, NoContact);
|
||||
|
||||
Assert.Equal(
|
||||
1,
|
||||
lifetime.CaptureOwnership().PendingCompletionReceiptCount);
|
||||
Assert.True(lifetime.Physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot completion));
|
||||
Assert.Equal(
|
||||
RuntimePlacementProjectionKind.ExecutorCompleted,
|
||||
completion.Kind);
|
||||
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
completion.Token));
|
||||
|
||||
Assert.Equal(
|
||||
0,
|
||||
lifetime.CaptureOwnership().PendingCompletionReceiptCount);
|
||||
Assert.False(lifetime.InitialCreateExecution.TryGetCompletionReceipt(
|
||||
completion.Token,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutorCompletion_CorrelationEntryIsReapedByDiscardProgress()
|
||||
{
|
||||
// F2: DiscardProgress (reached via ForgetInitialCreateResidence in
|
||||
// production) must reap this correlation cache too - it is exactly
|
||||
// the kind of executor-introduced state that method already owns
|
||||
// cleaning up. The drain already removed _progress[key] before
|
||||
// publishing the completion (see ExecuteCore's Released case), so
|
||||
// this proves DiscardProgress reaps _completionReceipts
|
||||
// UNCONDITIONALLY, not only when _progress still tracks the key.
|
||||
// Left deliberately UNACKNOWLEDGED in _pendingProjection (a single-
|
||||
// entity lifetime, so there is no exact-head contention to worry
|
||||
// about) - proving DiscardProgress reaps the correlation cache
|
||||
// independently of the normal acknowledge path.
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime, 413UL);
|
||||
const uint guid = 0x70024013u;
|
||||
RuntimeEntityRecord canonical = lifetime
|
||||
.RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(lifetime.TryGetInitialCreateResidence(
|
||||
canonical,
|
||||
out RuntimeInitialCreateResidenceLease lease));
|
||||
AttachDormantBody(lifetime, canonical);
|
||||
CompleteInitialPlacement(lifetime, lease);
|
||||
RunToCompletion(lifetime, canonical, lease.Token, NoContact);
|
||||
Assert.Equal(
|
||||
1,
|
||||
lifetime.CaptureOwnership().PendingCompletionReceiptCount);
|
||||
|
||||
lifetime.InitialCreateExecution.DiscardProgress(canonical.Key!.Value);
|
||||
|
||||
Assert.Equal(
|
||||
0,
|
||||
lifetime.CaptureOwnership().PendingCompletionReceiptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutorCompletion_CorrelationEntryIsReapedByDiscardAll()
|
||||
{
|
||||
// F2: a full session clear (DiscardAll, reached via
|
||||
// RuntimeInitialCreateResidenceState.Clear's call site) must never
|
||||
// carry this correlation cache across a reset.
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime, 414UL);
|
||||
const uint guid = 0x70024014u;
|
||||
RuntimeEntityRecord canonical = lifetime
|
||||
.RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(lifetime.TryGetInitialCreateResidence(
|
||||
canonical,
|
||||
out RuntimeInitialCreateResidenceLease lease));
|
||||
AttachDormantBody(lifetime, canonical);
|
||||
CompleteInitialPlacement(lifetime, lease);
|
||||
RunToCompletion(lifetime, canonical, lease.Token, NoContact);
|
||||
Assert.Equal(
|
||||
1,
|
||||
lifetime.CaptureOwnership().PendingCompletionReceiptCount);
|
||||
|
||||
lifetime.InitialCreateExecution.DiscardAll();
|
||||
|
||||
Assert.Equal(
|
||||
0,
|
||||
lifetime.CaptureOwnership().PendingCompletionReceiptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindLiveInputs_DrivesClassificationFromTheBoundSourcesInsteadOfTheCallerStruct()
|
||||
{
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime, 402UL);
|
||||
const uint guid = 0x70024002u;
|
||||
RuntimeEntityRecord canonical = lifetime
|
||||
.RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(lifetime.TryGetInitialCreateResidence(
|
||||
canonical,
|
||||
out RuntimeInitialCreateResidenceLease lease));
|
||||
AttachDormantBody(lifetime, canonical);
|
||||
CompleteInitialPlacement(lifetime, lease);
|
||||
|
||||
WorldSession.EntityPositionUpdate update = PositionUpdate(
|
||||
guid, positionSequence: 2, teleportSequence: 0,
|
||||
forcePositionSequence: 0, positionX: 15f, isGrounded: true);
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
update, isLocalPlayer: true, null, null, false, null,
|
||||
out PositionTimestampDisposition disposition, out _, out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
|
||||
bool usePositionFromServer = true;
|
||||
// Position 15 units from a local player parked far away (100 units)
|
||||
// is irrelevant here (PlayerDistance only matters for the
|
||||
// Remote/Projectile near/far branch, not LocalPlayer's own
|
||||
// interpolate gate) - it exists purely to prove the DISTANCE source
|
||||
// is read at all.
|
||||
lifetime.InitialCreateExecution.BindLiveInputs(
|
||||
() => usePositionFromServer,
|
||||
() => new Vector3(115f, 20f, 7f));
|
||||
|
||||
// The caller-supplied struct says UsePositionFromServer:false - if
|
||||
// the bound source is actually driving classification, retail's
|
||||
// "UsePositionFromServer && wire-contact" local-ordinary gate must
|
||||
// still interpolate.
|
||||
RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion(
|
||||
lifetime, canonical, lease.Token, NoContact);
|
||||
RuntimeInitialCreateExecutedAction positionAction = Assert.Single(
|
||||
receipt.Trace,
|
||||
static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position);
|
||||
Assert.Equal(
|
||||
RuntimeAuthoritativePositionDisposition.Interpolate,
|
||||
positionAction.PositionDisposition);
|
||||
|
||||
// C0-1: Completing the first entity's drain also published its own
|
||||
// ExecutorCompleted receipt on the SAME exact-head stream - it must
|
||||
// be acknowledged before a SECOND entity's own placement receipt can
|
||||
// ever become the head.
|
||||
Assert.True(lifetime.Physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot firstCompletion));
|
||||
Assert.Equal(
|
||||
RuntimePlacementProjectionKind.ExecutorCompleted,
|
||||
firstCompletion.Kind);
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
firstCompletion.Token));
|
||||
|
||||
// Flip the bound source off and confirm the SAME struct now takes
|
||||
// the non-interpolating branch - proves it is read live, not cached
|
||||
// at bind time.
|
||||
usePositionFromServer = false;
|
||||
const uint secondGuid = 0x70024003u;
|
||||
RuntimeEntityRecord second = lifetime
|
||||
.RegisterEntityWithInitialResidence(Spawn(secondGuid, 1), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(lifetime.TryGetInitialCreateResidence(
|
||||
second,
|
||||
out RuntimeInitialCreateResidenceLease secondLease));
|
||||
AttachDormantBody(lifetime, second);
|
||||
CompleteInitialPlacement(lifetime, secondLease);
|
||||
WorldSession.EntityPositionUpdate secondUpdate = PositionUpdate(
|
||||
secondGuid, positionSequence: 2, teleportSequence: 0,
|
||||
forcePositionSequence: 0, positionX: 16f, isGrounded: true);
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
secondUpdate, isLocalPlayer: true, null, null, false, null,
|
||||
out PositionTimestampDisposition secondDisposition, out _, out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, secondDisposition);
|
||||
RuntimeInitialCreateExecutionReceipt secondReceipt = RunToCompletion(
|
||||
lifetime, second, secondLease.Token, NoContact);
|
||||
RuntimeInitialCreateExecutedAction secondPositionAction = Assert.Single(
|
||||
secondReceipt.Trace,
|
||||
static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position);
|
||||
Assert.Equal(
|
||||
RuntimeAuthoritativePositionDisposition.NoPositionOperation,
|
||||
secondPositionAction.PositionDisposition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindLiveInputs_ThrowsOnASecondBindAndUnboundExecutorsUseTheCallerStructUnchanged()
|
||||
{
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
lifetime.InitialCreateExecution.BindLiveInputs(
|
||||
static () => true, static () => Vector3.Zero);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
lifetime.InitialCreateExecution.BindLiveInputs(
|
||||
static () => false, static () => Vector3.Zero));
|
||||
|
||||
// A SEPARATE, never-bound lifetime still honors the caller-supplied
|
||||
// struct verbatim - the existing bare-lifetime test contract is
|
||||
// unchanged by this slice.
|
||||
using RuntimeEntityObjectLifetime unbound = EngineLifetime();
|
||||
Bind(unbound, 403UL);
|
||||
const uint guid = 0x70024004u;
|
||||
RuntimeEntityRecord canonical = unbound
|
||||
.RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(unbound.TryGetInitialCreateResidence(
|
||||
canonical,
|
||||
out RuntimeInitialCreateResidenceLease lease));
|
||||
AttachDormantBody(unbound, canonical);
|
||||
CompleteInitialPlacement(unbound, lease);
|
||||
WorldSession.EntityPositionUpdate update = PositionUpdate(
|
||||
guid, positionSequence: 2, teleportSequence: 0,
|
||||
forcePositionSequence: 0, positionX: 15f, isGrounded: true);
|
||||
Assert.True(unbound.TryApplyPosition(
|
||||
update, isLocalPlayer: true, null, null, false, null,
|
||||
out PositionTimestampDisposition disposition, out _, out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
|
||||
RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion(
|
||||
unbound, canonical, lease.Token, NoContact);
|
||||
RuntimeInitialCreateExecutedAction positionAction = Assert.Single(
|
||||
receipt.Trace,
|
||||
static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position);
|
||||
// NoContact (UsePositionFromServer:false) -> not interpolated.
|
||||
Assert.Equal(
|
||||
RuntimeAuthoritativePositionDisposition.NoPositionOperation,
|
||||
positionAction.PositionDisposition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindLiveInputs_PlayerDistanceIsReadFromANonNullBoundSourceAndFallsBackToTheCallerStructWhenNull()
|
||||
{
|
||||
// F3: proves BOTH directions of the nullable local-player-position
|
||||
// source. Entity 1: the bound source returns a REAL near position -
|
||||
// the caller struct claims a FAR distance (200f), so if the bound
|
||||
// source is genuinely read (not ignored), the entity's own near
|
||||
// distance must win and classify Interpolate. Entity 2: the SAME
|
||||
// bound source now returns null (e.g. the login-window drain before
|
||||
// RuntimeLocalPlayerMovementState.Controller exists) - it must fall
|
||||
// back to the caller struct's FAR distance exactly like an unbound
|
||||
// source would, never fabricate Vector3.Zero (which would compute a
|
||||
// small, misleadingly-near distance to the entity's own position and
|
||||
// wrongly classify Interpolate instead of the far SetPositionSimple
|
||||
// hard-snap).
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime, 421UL);
|
||||
Vector3? boundPosition = new Vector3(30f, 20f, 7f);
|
||||
lifetime.InitialCreateExecution.BindLiveInputs(
|
||||
static () => false,
|
||||
() => boundPosition);
|
||||
|
||||
const uint nearGuid = 0x70024021u;
|
||||
RuntimeEntityRecord near = lifetime
|
||||
.RegisterEntityWithInitialResidence(Spawn(nearGuid, 1), isLocalPlayer: false)
|
||||
.Canonical!;
|
||||
Assert.True(lifetime.TryGetInitialCreateResidence(
|
||||
near, out RuntimeInitialCreateResidenceLease nearLease));
|
||||
AttachDormantBody(lifetime, near);
|
||||
CompleteInitialPlacement(lifetime, nearLease);
|
||||
WorldSession.EntityPositionUpdate nearUpdate = PositionUpdate(
|
||||
nearGuid, positionSequence: 2, teleportSequence: 0,
|
||||
forcePositionSequence: 0, positionX: 25f, isGrounded: true);
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
nearUpdate, isLocalPlayer: false, null, null, false, null,
|
||||
out PositionTimestampDisposition nearDisposition, out _, out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, nearDisposition);
|
||||
var farStruct = new RuntimeInitialCreateExecutionInputs(
|
||||
UsePositionFromServer: false, PlayerDistance: 200f);
|
||||
|
||||
RuntimeInitialCreateExecutionReceipt nearReceipt = RunToCompletion(
|
||||
lifetime, near, nearLease.Token, farStruct);
|
||||
|
||||
RuntimeInitialCreateExecutedAction nearAction = Assert.Single(
|
||||
nearReceipt.Trace,
|
||||
static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position);
|
||||
Assert.Equal(
|
||||
RuntimeAuthoritativePositionDisposition.Interpolate,
|
||||
nearAction.PositionDisposition);
|
||||
|
||||
// C0-1: the completed near entity published its own ExecutorCompleted
|
||||
// receipt on the SAME exact-head stream - acknowledge it before the
|
||||
// far entity's own Place receipt can ever become the head.
|
||||
Assert.True(lifetime.Physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot nearCompletion));
|
||||
Assert.Equal(
|
||||
RuntimePlacementProjectionKind.ExecutorCompleted,
|
||||
nearCompletion.Kind);
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
nearCompletion.Token));
|
||||
|
||||
boundPosition = null;
|
||||
const uint farGuid = 0x70024022u;
|
||||
RuntimeEntityRecord far = lifetime
|
||||
.RegisterEntityWithInitialResidence(Spawn(farGuid, 1), isLocalPlayer: false)
|
||||
.Canonical!;
|
||||
Assert.True(lifetime.TryGetInitialCreateResidence(
|
||||
far, out RuntimeInitialCreateResidenceLease farLease));
|
||||
AttachDormantBody(lifetime, far);
|
||||
CompleteInitialPlacement(lifetime, farLease);
|
||||
WorldSession.EntityPositionUpdate farUpdate = PositionUpdate(
|
||||
farGuid, positionSequence: 2, teleportSequence: 0,
|
||||
forcePositionSequence: 0, positionX: 25f, isGrounded: true);
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
farUpdate, isLocalPlayer: false, null, null, false, null,
|
||||
out PositionTimestampDisposition farDisposition, out _, out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, farDisposition);
|
||||
|
||||
RuntimeInitialCreateExecutionStatus farStatus = lifetime
|
||||
.InitialCreateExecution.Execute(
|
||||
far, farLease.Token, farStruct, out _);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement,
|
||||
farStatus);
|
||||
RuntimeEntityKey farKey = far.Key!.Value;
|
||||
Assert.True(lifetime.InitialCreateExecution.TryGetPendingContinuationRoute(
|
||||
farKey, out RuntimeAuthoritativePositionRoute farRoute));
|
||||
Assert.Equal(
|
||||
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
||||
farRoute.Disposition);
|
||||
Assert.True(farRoute.StopInterpolating);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Harness
|
||||
// ---------------------------------------------------------------
|
||||
|
|
@ -4461,4 +4935,12 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests
|
|||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PlacementObserver(
|
||||
Action<RuntimePlacementDelta> onPlacement)
|
||||
: IRuntimePlacementObserver
|
||||
{
|
||||
public void OnPlacement(in RuntimePlacementDelta delta) =>
|
||||
onPlacement(delta);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2379,6 +2379,82 @@ public sealed class RuntimeInitialCreateResidenceStateTests
|
|||
Assert.Single(retained.Continuations).Kind);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// C0-4: TryCommitParent/CommitWithdrawal cancellation asymmetries
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryCommitParent_CancelsActiveInitialResidenceAndItsPendingPlacement()
|
||||
{
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
engine.AddLandblock(
|
||||
Landblock,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(engine);
|
||||
Bind(lifetime, 200UL);
|
||||
const uint guid = 0x70004001u;
|
||||
RuntimeEntityRecord canonical = lifetime
|
||||
.RegisterEntityWithInitialResidence(
|
||||
Spawn(guid, 1, setupId: null),
|
||||
isLocalPlayer: false)
|
||||
.Canonical!;
|
||||
Assert.True(lifetime.TryGetInitialCreateResidence(
|
||||
canonical,
|
||||
out RuntimeInitialCreateResidenceLease lease));
|
||||
// Unparented -> the classifier's route performs SetPosition, so
|
||||
// Own() has already begun the lease's own placement operation.
|
||||
// Submit it (a live Place receipt now sits unacknowledged) so
|
||||
// cancellation has an ACTUAL outstanding receipt to discard, not
|
||||
// merely an unpublished AwaitingPreparation operation.
|
||||
Assert.True(lease.Placement.IsValid);
|
||||
AttachDormantBody(lifetime, canonical);
|
||||
RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition
|
||||
.SubmitPreparedPlacement(
|
||||
lease.Placement,
|
||||
Prepare(lifetime, lease, RuntimeSetPositionMoverSetup.ResolvedAbsent));
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
|
||||
outcome.Status);
|
||||
Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
|
||||
var discards = new List<RuntimePlacementProjectionSnapshot>();
|
||||
using IDisposable subscription = lifetime.Events.SubscribePlacement(
|
||||
new PlacementObserver(delta =>
|
||||
{
|
||||
if (delta.Placement.Kind is RuntimePlacementProjectionKind.Discard)
|
||||
discards.Add(delta.Placement);
|
||||
}));
|
||||
|
||||
var relation = new ParentAttachmentRelation(
|
||||
ParentGuid: 0x70004100u,
|
||||
ChildGuid: guid,
|
||||
ParentLocation: 1u,
|
||||
PlacementId: 1u,
|
||||
ParentInstanceSequence: 1,
|
||||
ChildPositionSequence: 1);
|
||||
Assert.True(lifetime.TryCommitParent(relation, null, out _));
|
||||
|
||||
Assert.Equal(0, lifetime.CaptureOwnership()
|
||||
.InitialCreateResidenceLeaseCount);
|
||||
Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership()
|
||||
.ActiveOperationCount);
|
||||
// The old Place receipt is REPLACED by a Discard at the same
|
||||
// sequence, not removed outright - still awaiting host ack.
|
||||
Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
Assert.False(lifetime.Physics.SetPosition.IsPlacementCurrent(
|
||||
lease.Placement));
|
||||
RuntimePlacementProjectionSnapshot discard = Assert.Single(discards);
|
||||
Assert.Equal(canonical.Key, discard.Token.Entity);
|
||||
Assert.Equal(outcome.Projection.Sequence, discard.Token.Sequence);
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
discard.Token));
|
||||
Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
}
|
||||
|
||||
private static RuntimeSetPositionCommand Prepare(
|
||||
RuntimeEntityObjectLifetime lifetime,
|
||||
in RuntimeInitialCreateResidenceLease lease,
|
||||
|
|
|
|||
|
|
@ -340,6 +340,51 @@ public sealed class RuntimeCharacterStateTests
|
|||
Assert.False(state.CaptureOwnership().MovementSkillsAreReset);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// C0-2: retail CommandInterpreter::autonomy_level/UsePositionFromServer
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AutonomyLevel_DefaultsToFullAndMirrorsRetailUsePositionFromServer()
|
||||
{
|
||||
using var state = new RuntimeCharacterState();
|
||||
|
||||
Assert.Equal(RuntimeCharacterState.FullAutonomyLevel, state.AutonomyLevel);
|
||||
Assert.False(state.UsePositionFromServer);
|
||||
Assert.True(state.CaptureOwnership().AutonomyIsDefault);
|
||||
|
||||
Assert.True(state.TrySetAutonomyLevel(0u));
|
||||
Assert.Equal(0u, state.AutonomyLevel);
|
||||
Assert.True(state.UsePositionFromServer);
|
||||
Assert.False(state.CaptureOwnership().AutonomyIsDefault);
|
||||
|
||||
Assert.True(state.TrySetAutonomyLevel(1u));
|
||||
Assert.True(state.UsePositionFromServer);
|
||||
|
||||
// Retail's SetAutonomyLevel rejects anything above 2; the level and
|
||||
// the derived UsePositionFromServer gate stay exactly as they were.
|
||||
Assert.False(state.TrySetAutonomyLevel(3u));
|
||||
Assert.Equal(1u, state.AutonomyLevel);
|
||||
|
||||
Assert.True(state.TrySetAutonomyLevel(RuntimeCharacterState.FullAutonomyLevel));
|
||||
Assert.False(state.UsePositionFromServer);
|
||||
Assert.True(state.CaptureOwnership().AutonomyIsDefault);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSession_RestoresAutonomyLevelToFull()
|
||||
{
|
||||
using var state = new RuntimeCharacterState();
|
||||
Assert.True(state.TrySetAutonomyLevel(0u));
|
||||
Assert.True(state.UsePositionFromServer);
|
||||
|
||||
state.ResetSession();
|
||||
|
||||
Assert.Equal(RuntimeCharacterState.FullAutonomyLevel, state.AutonomyLevel);
|
||||
Assert.False(state.UsePositionFromServer);
|
||||
Assert.True(state.CaptureOwnership().AutonomyIsDefault);
|
||||
}
|
||||
|
||||
private static ActiveEnchantmentRecord MakeVitae(uint spellId, float val) =>
|
||||
new(
|
||||
spellId, LayerId: 0u, Duration: -1f, CasterGuid: 0u,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using AcDream.Content;
|
||||
using AcDream.Content.Pak;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -2247,6 +2249,263 @@ public sealed class RuntimeSetPositionStateTests
|
|||
placed.Token));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// C0-3: exact-Setup mover chain end-to-end
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryPrepareAndSubmitAuthoredPlacement_ChainsSetupReadThroughPrepareMoverToSubmit()
|
||||
{
|
||||
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(engine);
|
||||
RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001101u, 1);
|
||||
AttachBody(lifetime, record, SourceCell);
|
||||
RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition
|
||||
.BeginAuthoredPlacement(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative);
|
||||
Assert.True(token.IsValid);
|
||||
|
||||
// Spawn's own default SetupTableId (0x02000001u) - the exact
|
||||
// CanonicalSetupTableId CapturePreparationAuthority already trusts;
|
||||
// the chain must read THIS id, not a caller-supplied one.
|
||||
ImmutableArray<FlatCollisionSphere> spheres =
|
||||
[
|
||||
new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 0.4f),
|
||||
new FlatCollisionSphere(new Vector3(0f, 0f, 1.2f), 0.4f),
|
||||
];
|
||||
var source = new FakeCollisionSource(
|
||||
0x02000001u,
|
||||
new FlatSetupCollision(
|
||||
ImmutableArray<FlatCollisionCylinder>.Empty,
|
||||
spheres,
|
||||
height: 0f,
|
||||
radius: 0f,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.35f));
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionMoverPreparationStatus.Prepared,
|
||||
lifetime.Physics.SetPosition.TryPrepareAndSubmitAuthoredPlacement(
|
||||
record,
|
||||
token,
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative,
|
||||
PhysicsSetPositionFlags.Placement
|
||||
| PhysicsSetPositionFlags.Slide,
|
||||
source,
|
||||
gameTime: 10d,
|
||||
out RuntimeSetPositionOutcome outcome));
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
|
||||
outcome.Status);
|
||||
Assert.Equal(1, source.ReadCount);
|
||||
Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount(
|
||||
record,
|
||||
out int sphereCount));
|
||||
Assert.Equal(2, sphereCount);
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
outcome.Projection));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryPrepareAndSubmitAuthoredPlacement_YieldsRetryOnAMissingSetupReadWithoutMutatingStage()
|
||||
{
|
||||
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(engine);
|
||||
RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001102u, 1);
|
||||
AttachBody(lifetime, record, SourceCell);
|
||||
RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition
|
||||
.BeginAuthoredPlacement(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative);
|
||||
Assert.True(token.IsValid);
|
||||
|
||||
var source = new FakeCollisionSource(
|
||||
0x02000001u,
|
||||
setup: null,
|
||||
status: PreparedAssetReadStatus.Missing);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable,
|
||||
lifetime.Physics.SetPosition.TryPrepareAndSubmitAuthoredPlacement(
|
||||
record,
|
||||
token,
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative,
|
||||
PhysicsSetPositionFlags.Placement
|
||||
| PhysicsSetPositionFlags.Slide,
|
||||
source,
|
||||
gameTime: 10d,
|
||||
out RuntimeSetPositionOutcome outcome));
|
||||
|
||||
Assert.Equal(default, outcome);
|
||||
Assert.Equal(1, source.ReadCount);
|
||||
// Never manufactured a fallback while the read is in flight - the
|
||||
// token can still be prepared once the asset lands.
|
||||
Assert.True(lifetime.Physics.SetPosition.IsPlacementCurrent(token));
|
||||
Assert.False(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount(
|
||||
record,
|
||||
out _));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// C0-1: executor completion on the SAME ordered placement stream
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PublishExecutorCompletion_PublishesAcknowledgeOnlyReceiptAndConverges()
|
||||
{
|
||||
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(engine);
|
||||
RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001201u, 1);
|
||||
PhysicsBody body = AttachBody(lifetime, record, SourceCell);
|
||||
var observer = new PlacementObserver();
|
||||
using IDisposable subscription =
|
||||
lifetime.Events.SubscribePlacement(observer);
|
||||
|
||||
RuntimePlacementProjectionToken token =
|
||||
lifetime.Physics.SetPosition.PublishExecutorCompletion(record);
|
||||
|
||||
Assert.True(token.IsValid);
|
||||
Assert.Equal(record.Key, token.Entity);
|
||||
RuntimePlacementProjectionSnapshot published =
|
||||
Assert.Single(observer.Deltas).Placement;
|
||||
Assert.Equal(RuntimePlacementProjectionKind.ExecutorCompleted,
|
||||
published.Kind);
|
||||
Assert.Equal(token, published.Token);
|
||||
Assert.Equal(body.Position, published.WorldPosition);
|
||||
Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
|
||||
// Acknowledge-only, exactly like Discard - no operation to resume.
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(token));
|
||||
Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
// A stale re-acknowledge of the already-consumed receipt fails.
|
||||
Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishExecutorCompletion_RespectsExactHeadOrderingAcrossEntities()
|
||||
{
|
||||
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(engine);
|
||||
RuntimeEntityRecord first = CreateRecord(lifetime, 0x70001202u, 1);
|
||||
AttachBody(lifetime, first, SourceCell);
|
||||
RuntimeEntityRecord second = CreateRecord(lifetime, 0x70001203u, 1);
|
||||
AttachBody(lifetime, second, SourceCell);
|
||||
|
||||
RuntimePlacementProjectionToken firstToken =
|
||||
lifetime.Physics.SetPosition.PublishExecutorCompletion(first);
|
||||
RuntimePlacementProjectionToken secondToken =
|
||||
lifetime.Physics.SetPosition.PublishExecutorCompletion(second);
|
||||
|
||||
Assert.Equal(2, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
// The exact head (first) must acknowledge before the second, matching
|
||||
// the ordered-stream contract every other Kind already honors.
|
||||
Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
secondToken));
|
||||
Assert.Equal(2, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
firstToken));
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
secondToken));
|
||||
Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// C0-4: TryCommitParent/CommitWithdrawal cancellation asymmetries
|
||||
// (the ordinary Physics.SetPosition.Forget half, isolated from any
|
||||
// initial-create residence)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TryCommitParent_CancelsASeparateActiveOrdinaryPendingPlacement()
|
||||
{
|
||||
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(engine);
|
||||
const uint guid = 0x70001301u;
|
||||
RuntimeEntityRecord record = CreateRecord(lifetime, guid, 1);
|
||||
AttachBody(lifetime, record, SourceCell);
|
||||
RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
Command(Request(SourceCell, new Vector3(12f, 18f, 7f))));
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
|
||||
outcome.Status);
|
||||
Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
|
||||
var discards = new List<RuntimePlacementProjectionSnapshot>();
|
||||
using IDisposable subscription = lifetime.Events.SubscribePlacement(
|
||||
new PlacementObserver(delta =>
|
||||
{
|
||||
if (delta.Placement.Kind is RuntimePlacementProjectionKind.Discard)
|
||||
discards.Add(delta.Placement);
|
||||
}));
|
||||
|
||||
var relation = new ParentAttachmentRelation(
|
||||
ParentGuid: 0x70001400u,
|
||||
ChildGuid: guid,
|
||||
ParentLocation: 1u,
|
||||
PlacementId: 1u,
|
||||
ParentInstanceSequence: 1,
|
||||
ChildPositionSequence: 1);
|
||||
Assert.True(lifetime.TryCommitParent(relation, null, out _));
|
||||
|
||||
Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership()
|
||||
.ActiveOperationCount);
|
||||
// The old Place receipt is REPLACED by a Discard at the same
|
||||
// sequence, not removed outright - it still awaits host
|
||||
// acknowledgement, exactly like every other cancelled-in-flight
|
||||
// receipt.
|
||||
Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
RuntimePlacementProjectionSnapshot discard = Assert.Single(discards);
|
||||
Assert.Equal(record.Key, discard.Token.Entity);
|
||||
Assert.Equal(outcome.Projection.Sequence, discard.Token.Sequence);
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
discard.Token));
|
||||
Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommitWithdrawal_CancelsAnActiveOrdinaryPendingPlacementSymmetricallyWithPickup()
|
||||
{
|
||||
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
|
||||
using var lifetime = new RuntimeEntityObjectLifetime(engine);
|
||||
const uint guid = 0x70001302u;
|
||||
RuntimeEntityRecord record = CreateRecord(lifetime, guid, 1);
|
||||
AttachBody(lifetime, record, SourceCell);
|
||||
RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
Command(Request(SourceCell, new Vector3(12f, 18f, 7f))));
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
|
||||
outcome.Status);
|
||||
Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
|
||||
var discards = new List<RuntimePlacementProjectionSnapshot>();
|
||||
using IDisposable subscription = lifetime.Events.SubscribePlacement(
|
||||
new PlacementObserver(delta =>
|
||||
{
|
||||
if (delta.Placement.Kind is RuntimePlacementProjectionKind.Discard)
|
||||
discards.Add(delta.Placement);
|
||||
}));
|
||||
|
||||
Assert.True(lifetime.CommitWithdrawal(record));
|
||||
|
||||
Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership()
|
||||
.ActiveOperationCount);
|
||||
Assert.Equal(1, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
RuntimePlacementProjectionSnapshot discard = Assert.Single(discards);
|
||||
Assert.Equal(record.Key, discard.Token.Entity);
|
||||
Assert.Equal(outcome.Projection.Sequence, discard.Token.Sequence);
|
||||
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
discard.Token));
|
||||
Assert.Equal(0, lifetime.Physics.SetPosition.PendingProjectionCount);
|
||||
}
|
||||
|
||||
private static void VerifyPositionChannelCancellation(
|
||||
CancellationChannel channel)
|
||||
{
|
||||
|
|
@ -2618,6 +2877,73 @@ public sealed class RuntimeSetPositionStateTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C0-3 test double: a minimal <see cref="IPreparedCollisionSource"/>
|
||||
/// serving exactly one Setup id (matching <c>CanonicalSetupTableId</c>'s
|
||||
/// field), so <see cref="TryPrepareAndSubmitAuthoredPlacement_ChainsSetupReadThroughPrepareMoverToSubmit"/>
|
||||
/// can prove <c>ReadSetupCollision</c> -> <c>PrepareMover</c> ->
|
||||
/// <c>SubmitPreparedPlacement</c> actually chains, not merely that each
|
||||
/// step works in isolation (the existing preparer tests' coverage).
|
||||
/// </summary>
|
||||
private sealed class FakeCollisionSource(
|
||||
uint expectedSetupTableId,
|
||||
FlatSetupCollision? setup,
|
||||
PreparedAssetReadStatus status = PreparedAssetReadStatus.Loaded)
|
||||
: IPreparedCollisionSource
|
||||
{
|
||||
internal int ReadCount { get; private set; }
|
||||
|
||||
public PreparedAssetPresence ProbeCollision(
|
||||
PakAssetType type, uint sourceFileId) =>
|
||||
PreparedAssetPresence.Available;
|
||||
|
||||
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
|
||||
ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException(
|
||||
"Only ReadSetupCollision is exercised by C0-3.");
|
||||
|
||||
public PreparedCollisionReadResult<FlatSetupCollision>
|
||||
ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ReadCount++;
|
||||
Assert.Equal(expectedSetupTableId, sourceFileId);
|
||||
return status switch
|
||||
{
|
||||
PreparedAssetReadStatus.Loaded when setup is not null =>
|
||||
PreparedCollisionReadResult<FlatSetupCollision>.Loaded(
|
||||
setup),
|
||||
PreparedAssetReadStatus.Corrupt =>
|
||||
PreparedCollisionReadResult<FlatSetupCollision>.Corrupt,
|
||||
_ => PreparedCollisionReadResult<FlatSetupCollision>.Missing,
|
||||
};
|
||||
}
|
||||
|
||||
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
|
||||
ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException(
|
||||
"Only ReadSetupCollision is exercised by C0-3.");
|
||||
|
||||
public PreparedCollisionReadResult<FlatEnvCellTopology>
|
||||
ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException(
|
||||
"Only ReadSetupCollision is exercised by C0-3.");
|
||||
|
||||
public PreparedCollisionSourceStats CollisionStats =>
|
||||
new(ReadCount, ReadCount, ReadCount, 0, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CollisionReportObserver
|
||||
: IRuntimeCollisionReportObserver
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue