diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index e4080dee..9c818d22 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -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, diff --git a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs index 79e23e9c..9f062087 100644 --- a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs +++ b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs @@ -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) diff --git a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs index 51f3d156..3e325cdc 100644 --- a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs +++ b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs @@ -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 diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index a98e8c4b..2571d719 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -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, /// Round 5 R5-3: mirrors StreamDispatchFailureCount/HasLastStreamDispatchFailure for the executor's contained-replay failure surface. Diagnostic only - like its stream precedent, NOT gated by . long ReplayFailureCount = 0, - bool HasLastReplayFailure = false) + bool HasLastReplayFailure = false, + /// + /// F2: outstanding + /// token-to-receipt correlation entries - one per completed drain whose + /// ExecutorCompleted receipt a host has not yet acknowledged. Gated by + /// , mirroring + /// 's + /// existing "unacknowledged receipt is outstanding debt" shape for the + /// SAME underlying receipt stream - unlike ReplayFailureCount above, + /// this is NOT a diagnostic-only counter. + /// + 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); } + /// + /// C0-2: forwards to , + /// the same fan-out shape already uses for + /// generation binding. Separate from + /// because GameRuntime constructs RuntimeCharacterState/ + /// RuntimeLocalPlayerMovementState (the real source owners) AFTER + /// this lifetime, so the live-input bind necessarily happens at a later + /// point in GameRuntime's construction sequence than the + /// generation bind. + /// + public void BindLiveInputs( + Func usePositionFromServer, + Func localPlayerPosition) + { + EnsureNotDisposed(); + InitialCreateExecution.BindLiveInputs( + usePositionFromServer, localPlayerPosition); + } + /// /// 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; diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs index 9905b538..d545fe78 100644 --- a/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs @@ -274,7 +274,26 @@ internal sealed class RuntimeInitialCreateContinuationExecutor _applyAcceptedSpawn; private readonly Dictionary _progress = []; private readonly HashSet _executing = []; + /// + /// C0-1: correlates a published + /// + /// receipt back to the full execution receipt/trace, keyed by the SAME + /// public Entity/Sequence identity every other Kind uses (the receipt's + /// own Token.Entity/Token.Sequence). Overwritten (never + /// accumulated) per entity key - an entity cannot have two drains + /// completing concurrently ('s own + /// reentrancy guard), so only the most recent completion for a key is + /// ever meaningful; the exact-sequence check in + /// rejects a stale lookup against a + /// superseded completion under a reused key. + /// + private readonly Dictionary + _completionReceipts = []; private Func? _generation; + private Func? _usePositionFromServer; + private Func? _localPlayerPosition; + private bool _liveInputsBound; internal RuntimeInitialCreateContinuationExecutor( RuntimeEntityDirectory entities, @@ -308,6 +327,157 @@ internal sealed class RuntimeInitialCreateContinuationExecutor _generation = generation; } + /// + /// C0-2: binds Runtime's own live-input sources so no host ever computes + /// / + /// + /// itself. Optional/nullable exactly like 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 and + /// keep driving with an explicit caller-supplied + /// override (see + /// ). binds the real + /// owners - RuntimeCharacterState.UsePositionFromServer and the + /// live RuntimeLocalPlayerMovementState.Controller position - + /// once both exist (they are constructed AFTER + /// /this executor, so this bind + /// cannot happen at the executor's own constructor time the way + /// does; it happens alongside + /// BindEventContext in GameRuntime's construction + /// sequence). Throws if called twice, matching every other Bind* seam on + /// this class/its siblings (, + /// RuntimeEntityObjectEventStream.BindContext"/>, + /// RuntimePlacementProjectionChannel.BindGeneration). + /// F3: itself returns + /// Vector3?, not Vector3 - a BOUND source with no live + /// controller yet (the login-window drain, before + /// RuntimeLocalPlayerMovementState.Controller exists) must yield + /// null, not a fabricated Vector3.Zero. + /// 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. + /// + internal void BindLiveInputs( + Func usePositionFromServer, + Func 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; + } + + /// + /// C0-2: resolves the EFFECTIVE inputs for one + /// call. A bound source always wins; the caller-supplied + /// 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 + /// gets EXACTLY the caller-supplied values, + /// preserving every existing test's behavior unchanged. + /// uses + /// the SAME world-space basis as today's legacy remote path + /// (LiveEntityNetworkUpdateController.cs's + /// MaxPhysicsDistance/dist computation, cutover-routes.md + /// route 4: Vector3.Distance(worldPos, localPlayerPos) where + /// localPlayerPos is the live physics-CONTROLLER position, never a + /// record snapshot) - here, Vector3.Distance between THIS + /// entity's own currently-accepted position (the exact field + /// BeginAcceptedPlacementCore/CanonicalSetupTableId already + /// trust: Snapshot.Physics?.Position ?? Snapshot.Position) and the + /// bound local-player controller position. Computed ONCE per + /// call, matching the one-shot-per-call granularity + /// already had before this slice (retail + /// recomputes player_distance per wire packet; refining this + /// executor to per-continuation freshness is out of C0-2's scope). + /// + 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); + } + + /// + /// C0-1: reaches the full execution receipt/trace correlated with an + /// observed + /// receipt, purely via that receipt's own public + /// Token.Entity/Token.Sequence identity - the same identity + /// every other placement Kind is acknowledged by. Returns false for a + /// superseded/stale sequence under a reused entity key. + /// + 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; + } + + /// + /// F2: reaps exactly one completion-receipt correlation entry, bound as + /// '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 's + /// own currency check). + /// + internal void ForgetCompletionReceipt(RuntimeEntityKey key, ulong sequence) + { + if (_completionReceipts.TryGetValue( + key, + out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt) entry) + && entry.Sequence == sequence) + { + _completionReceipts.Remove(key); + } + } + + /// + /// F2: folded into / + /// IsConverged - an unacknowledged completion receipt is + /// outstanding host debt, mirroring + /// 's + /// existing "must be zero to converge" shape for the SAME underlying + /// receipt stream. + /// + internal int PendingCompletionReceiptCount => _completionReceipts.Count; + internal int ProgressCount => _progress.Count; /// @@ -389,6 +559,15 @@ internal sealed class RuntimeInitialCreateContinuationExecutor /// 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 diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 08898a69..44a5bf5f 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -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, diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index f9acaf5a..587d1f92 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -18,7 +18,9 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot( int PositionCount, int PropertyCount, bool OptionsAreDefaults, - bool MovementSkillsAreReset) + bool MovementSkillsAreReset, + /// C0-2: is back at retail's default (). + bool AutonomyIsDefault = true) { public bool IsConverged => IsDisposed @@ -33,7 +35,8 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot( && PositionCount == 0 && PropertyCount == 0 && OptionsAreDefaults - && MovementSkillsAreReset; + && MovementSkillsAreReset + && AutonomyIsDefault; } /// @@ -47,11 +50,27 @@ public sealed class RuntimeCharacterState : IDisposable public const uint RunSkillId = 24u; /// ACE Skill enum ordinal for Jump (K-fix7 / pseudocode doc ยง5). public const uint JumpSkillId = 22u; + /// + /// C0-2/F5(b): retail CommandInterpreter's own default, set at + /// construction (pseudo-C 699752, this->autonomy_level = 2;, a + /// direct field write, not a SetAutonomyLevel call) and by the + /// command-line-only override at admission + /// (command_line_autonomy_level, pseudo-C 1088429, itself + /// defaulting to 0x2). Exactly ONE retail caller of + /// CommandInterpreter::SetAutonomyLevel exists in the named + /// retail decomp - the startup construction path at pseudo-C 94102 + /// (cmdinterp->vtable->SetAutonomyLevel(cmdinterp, command_line_autonomy_level)) + /// - 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. + /// + public const uint FullAutonomyLevel = 2u; private bool _disposed; private long _characterRevision; private long _spellbookRevision; private bool _internalSubscriptionsAttached; + private uint _autonomyLevel = FullAutonomyLevel; /// /// Campaign P Slice P1 (2026-07-30): the pre-EnchantSkill base @@ -88,6 +107,39 @@ public sealed class RuntimeCharacterState : IDisposable public IRuntimeCharacterView View { get; } public bool IsDisposed => _disposed; + /// Retail CommandInterpreter::GetAutonomyLevel. + public uint AutonomyLevel => Volatile.Read(ref _autonomyLevel); + + /// + /// C0-2: retail CommandInterpreter::UsePositionFromServer + /// (pseudo-C 699506-699512: result = this->autonomy_level != 2;). + /// This is the source + /// + /// binds for RuntimeInitialCreateExecutionInputs.UsePositionFromServer + /// - the local-player-only interpolate gate consumed by + /// RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition. + /// + public bool UsePositionFromServer => AutonomyLevel != FullAutonomyLevel; + + /// + /// Retail CommandInterpreter::SetAutonomyLevel (pseudo-C + /// 699542-699552): rejects any value above 2, otherwise commits. + /// F5(a): retail's own setter ALSO sends SendAutonomyLevelEvent + /// (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. + /// + 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); } /// @@ -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 diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 4f6dc5bb..68efb27f 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -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, + /// + /// 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. + /// + 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? _executorCompletionAcknowledged; private bool _disposed; private readonly record struct LostDeadlineEntry( @@ -770,6 +783,87 @@ internal sealed class RuntimeSetPositionState : IDisposable } } + /// + /// 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. + /// + internal RuntimePlacementProjectionToken PublishExecutorCompletion( + RuntimeEntityRecord record, + Action? 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; + } + + /// + /// 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). + /// + internal void BindExecutorCompletionAcknowledgement( + Action 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; } + /// + /// 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 + /// - the SAME field + /// 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 + /// () instead of + /// reading anything; a record WITH an id but an unavailable/corrupt + /// asynchronous read yields + /// so the caller retries once the prepared-collision package lands, + /// mirroring 's own + /// doc-comment distinction between "not arrived yet" and "resolved + /// absent". Dormant: internal, no production caller - a residence + /// lease's own Placement/Route.OperationKind/ + /// Route.SetPositionFlags are exactly the token/kind/flags this + /// takes. + /// + 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 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( diff --git a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs index 7c6e252f..1d293203 100644 --- a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs @@ -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() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 131b4b6e..7e4c2886 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -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() { diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs index ab1b8a9b..fb81a88c 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs @@ -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(); + 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(); + 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(() => + 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 onPlacement) + : IRuntimePlacementObserver + { + public void OnPlacement(in RuntimePlacementDelta delta) => + onPlacement(delta); + } } diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs index 046333a4..57ed44e8 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs @@ -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(), + Array.Empty(), + 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(); + 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, diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index eee36fca..9179d1e9 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -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, diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index c3c34ab2..7b685664 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -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 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.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(); + 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(); + 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 } } + /// + /// C0-3 test double: a minimal + /// serving exactly one Setup id (matching CanonicalSetupTableId's + /// field), so + /// can prove ReadSetupCollision -> PrepareMover -> + /// SubmitPreparedPlacement actually chains, not merely that each + /// step works in isolation (the existing preparer tests' coverage). + /// + 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 + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by C0-3."); + + public PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + ReadCount++; + Assert.Equal(expectedSetupTableId, sourceFileId); + return status switch + { + PreparedAssetReadStatus.Loaded when setup is not null => + PreparedCollisionReadResult.Loaded( + setup), + PreparedAssetReadStatus.Corrupt => + PreparedCollisionReadResult.Corrupt, + _ => PreparedCollisionReadResult.Missing, + }; + } + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by C0-3."); + + public PreparedCollisionReadResult + 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 {