From 0934a1211105cdbf7a28fb739eeacf203ca06312 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 10:20:48 +0200 Subject: [PATCH] feat(runtime): construct remote bodies at Create per retail set_description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cutover slice C3b: residence-route remote/creature/projectile Creates now get their canonical PhysicsBody at Create time — retail's order, closing the C3 flip's Finding C (production builds bodies at first motion; retail builds them in ACCObjectMaint::CreateObject). RuntimeRemoteBodyDescription walks set_description 0x00514F40 exactly: the motion-table gate (zero id PASSES — verified at 0051871f/005127ca), the frame-vs-movement branch keyed on retail's movement_buffer != 0 (an empty-buffer movement payload takes the PLACEMENT branch and writes no autonomy — the wire-shape defect the retail review caught), set_state, the byte-certain friction gate (inclusive [0,1]; NaN deliberately skipped per the gates doc's sanctioned deviation), the set_elasticity clamp with retail's unordered-to-zero NaN routing (ACE diverges to 0.1 on that edge), the translucency gate (!= 0.0f, original always recorded), velocity via setter, omega raw, and ctor-defaults for absent wire fields (0.95f/0.05f/0 — the fresh-desc-per- message flow verified at both UnPack call sites). InWorld stays false until submission, the enter_world analog. RuntimeRemoteFirstEntryState sequences mover-prep -> body construction -> placement -> acknowledgement -> Execute with every C3a hardening inherited: exactly-once stages, the shared acknowledge-stage discriminator (extracted to RuntimeFirstEntryAcknowledgement, one body for both conductors), typed Contention against in-flight remote-motion binds, never-clobber body binding through the canonical writer (foreign body fails closed — provably safe coexistence with today's build-at-first-motion path in both directions), automatic convergence through the retirement fan-out, and the construction receipt riding the terminal Advance. Dormant: no production caller; C3c wires both hosts. Reviewed: retail-conformance PASS (the construction order, both gate boundary/NaN semantics, the motion-table and autonomy verdicts all re-derived from the pseudo-C) + architecture/adversarial PASS after one fix round. Runtime 982/982; complete Release solution 10,777 passed / 4 intentional skips. Co-Authored-By: Claude Fable 5 --- .../Entities/RuntimeEntityObjectLifetime.cs | 53 +- .../RuntimeFirstEntryAcknowledgement.cs | 66 ++ .../Entities/RuntimeRemoteBodyDescription.cs | 321 ++++++ .../Entities/RuntimeRemoteFirstEntryState.cs | 649 ++++++++++++ .../RuntimeLocalPlayerFirstEntryState.cs | 55 +- .../RuntimeRemoteFirstEntryStateTests.cs | 980 ++++++++++++++++++ 6 files changed, 2083 insertions(+), 41 deletions(-) create mode 100644 src/AcDream.Runtime/Entities/RuntimeFirstEntryAcknowledgement.cs create mode 100644 src/AcDream.Runtime/Entities/RuntimeRemoteBodyDescription.cs create mode 100644 src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs create mode 100644 tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index f9c96d00..f9c3f5c9 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -64,7 +64,13 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( /// Advance), but fully constructed/wired like every other owner /// here, so its own ownership must converge to zero the same way. /// - int LocalPlayerFirstEntryActiveCount = 0) + int LocalPlayerFirstEntryActiveCount = 0, + /// + /// C3b: outstanding tracked + /// keys - the remote/projectile Create-time body-construction conductor. + /// Dormant like its C3a sibling; converges to zero the same way. + /// + int RemoteFirstEntryActiveCount = 0) { public bool IsConverged => IsDisposed @@ -87,6 +93,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && InitialCreateExecutorProgressCount == 0 && PendingCompletionReceiptCount == 0 && LocalPlayerFirstEntryActiveCount == 0 + && RemoteFirstEntryActiveCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -176,6 +183,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences, InitialCreateExecution, Physics); + // C3b: the remote/projectile analog of the conductor above — retail + // body construction at Create time in place of the publication + // chain. Dormant like its sibling (no production caller of Advance); + // constructed and wired identically so its ownership converges the + // same way. + RemoteFirstEntry = new RuntimeRemoteFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -190,6 +206,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // updated doc comment for why this is now multicast. InitialCreateResidences.BindRetirementNotification( key => LocalPlayerFirstEntry.Forget(key)); + // C3b: the remote conductor joins the SAME multicast retirement + // fan-out, third in registration order. + InitialCreateResidences.BindRetirementNotification( + key => RemoteFirstEntry.Forget(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 @@ -245,6 +265,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences, InitialCreateExecution, Physics); + // C3b: the remote/projectile analog of the conductor above — retail + // body construction at Create time in place of the publication + // chain. Dormant like its sibling (no production caller of Advance); + // constructed and wired identically so its ownership converges the + // same way. + RemoteFirstEntry = new RuntimeRemoteFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -259,6 +288,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // updated doc comment for why this is now multicast. InitialCreateResidences.BindRetirementNotification( key => LocalPlayerFirstEntry.Forget(key)); + // C3b: the remote conductor joins the SAME multicast retirement + // fan-out, third in registration order. + InitialCreateResidences.BindRetirementNotification( + key => RemoteFirstEntry.Forget(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 @@ -314,6 +347,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences, InitialCreateExecution, Physics); + // C3b: the remote/projectile analog of the conductor above — retail + // body construction at Create time in place of the publication + // chain. Dormant like its sibling (no production caller of Advance); + // constructed and wired identically so its ownership converges the + // same way. + RemoteFirstEntry = new RuntimeRemoteFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -328,6 +370,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // updated doc comment for why this is now multicast. InitialCreateResidences.BindRetirementNotification( key => LocalPlayerFirstEntry.Forget(key)); + // C3b: the remote conductor joins the SAME multicast retirement + // fan-out, third in registration order. + InitialCreateResidences.BindRetirementNotification( + key => RemoteFirstEntry.Forget(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 @@ -353,6 +399,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable internal RuntimeInitialCreateContinuationExecutor InitialCreateExecution { get; } internal RuntimeLocalPlayerFirstEntryState LocalPlayerFirstEntry { get; } + internal RuntimeRemoteFirstEntryState RemoteFirstEntry { get; } public RuntimeEntityObjectOwnershipSnapshot CaptureOwnership() { @@ -389,7 +436,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateExecution.ReplayFailureCount, InitialCreateExecution.LastReplayFailure is not null, InitialCreateExecution.PendingCompletionReceiptCount, - LocalPlayerFirstEntry.CaptureOwnership().ActiveCount); + LocalPlayerFirstEntry.CaptureOwnership().ActiveCount, + RemoteFirstEntry.CaptureOwnership().ActiveCount); } public void BindEventContext( @@ -1734,6 +1782,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences.Clear(); InitialCreateExecution.DiscardAll(); LocalPlayerFirstEntry.DiscardAll(); + RemoteFirstEntry.DiscardAll(); Physics.CollisionReports.LeaveWorldBatch(active); Physics.ResetSessionPhysics(); Entities.BeginSessionClear(); diff --git a/src/AcDream.Runtime/Entities/RuntimeFirstEntryAcknowledgement.cs b/src/AcDream.Runtime/Entities/RuntimeFirstEntryAcknowledgement.cs new file mode 100644 index 00000000..10b93734 --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeFirstEntryAcknowledgement.cs @@ -0,0 +1,66 @@ +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Entities; + +/// +/// The ONE post-failed-acknowledge re-validation both first-entry conductors +/// (AcDream.Runtime.Gameplay.RuntimeLocalPlayerFirstEntryState and +/// ) share. Extracted (C3b review +/// M2) so the C3a abandonment fix — a delete rewriting the pending Place +/// slot to Discard with a bumped revision would otherwise make +/// AcknowledgeProjection fail forever while the conductor retried +/// endlessly — cannot regress independently in either copy. Follows the +/// class family's pure-static-helper convention +/// (, +/// , +/// ). +/// +internal static class RuntimeFirstEntryAcknowledgement +{ + /// + /// Re-validates authority after a failed acknowledge. Two independent + /// checks, either of which failing means authority moved and the + /// conductor must abandon rather than keep retrying forever: + /// (1) the residence lease the whole sequence began under must still be + /// exactly current (a delete or reset retires it); (2) if the FIFO head + /// belongs to THIS entity at all, it must still be the exact Place + /// projection the conductor is holding — a head that belongs to us but + /// is no longer that exact token (rewritten to Discard, or to a later + /// revision) means our specific placement was superseded even if the + /// residence lookup transiently still resolves. A head belonging to a + /// DIFFERENT entity is the genuine "not yet our turn" case and must + /// stay retryable. This reads the Runtime-internal + /// directly + /// rather than through the public, generation-gated + /// — the conductors are + /// part of Runtime, not external hosts crossing that boundary, exactly + /// like their existing direct + /// calls. + /// + internal static bool IsStillPending( + RuntimeInitialCreateResidenceState residences, + RuntimeSetPositionState setPosition, + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + in RuntimePlacementProjectionToken expected) + { + if (!residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + return false; + } + + if (setPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head) + && head.Token.Entity == expected.Entity + && (head.Kind is not RuntimePlacementProjectionKind.Place + || head.Token != expected)) + { + return false; + } + + return true; + } +} diff --git a/src/AcDream.Runtime/Entities/RuntimeRemoteBodyDescription.cs b/src/AcDream.Runtime/Entities/RuntimeRemoteBodyDescription.cs new file mode 100644 index 00000000..b2335135 --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeRemoteBodyDescription.cs @@ -0,0 +1,321 @@ +using System.Numerics; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Entities; + +/// +/// Typed record of one retail-ordered body construction. Fields with no +/// slot (translucency) or no unconditional retail +/// write (the three float gates) are recorded here so the construction's +/// gated outcomes stay provable both ways without a parallel body field. +/// The receipt is Runtime-internal evidence, never a second source of truth: +/// the body's own fields remain authoritative for everything they carry. +/// +internal readonly record struct RuntimeRemoteBodyConstructionReceipt( + /// The motion-table id retail's gate evaluated (0 = none on the wire). + uint MotionTableId, + /// + /// Retail CPhysicsObj::SetMotionTableID (0x00512780, + /// pseudo-C:280528) fails ONLY when part_array == 0 (005127da) or + /// when MotionTableManager::Create fails for a NONZERO id + /// (CPartArray::SetMotionTableID 0x005186E0, pseudo-C:286732, + /// 0051872f). A ZERO id skips manager creation (0051871f falls through + /// to return 1 at 00518743) and CPhysicsObj then skips + /// MakeMovementManager for INVALID_DID (005127ca) — the gate + /// PASSES. In Runtime the part-array precondition is the already-run + /// mover-preparation stage (the Setup shape resolved before this + /// construction is reachable) and motion-table DAT installation stays + /// presentation-side, so the gate passes for zero and nonzero ids alike; + /// this field records that it was evaluated in retail's position. + /// + bool MotionTableGatePassed, + /// + /// True when the frozen PhysicsDesc carried a Movement payload — + /// retail's mutually-exclusive branch (set_description step 4, + /// 0x00514F40): movement present means NO placement frame is staged. + /// + bool MovementBranch, + /// Retail last_move_was_autonomous written on the movement branch. + bool LastMoveWasAutonomous, + /// True when the no-movement branch staged the dormant cell frame. + bool PlacementFrameStaged, + /// The friction value the body carries after the gated write. + float Friction, + /// + /// Friction gate outcome — byte-certain gates 1+2 of + /// docs/research/2026-08-02-set-description-float-gates.md: applied only + /// when 0.0f <= friction <= 1.0f. + /// + bool FrictionApplied, + /// The elasticity the body carries after retail set_elasticity's clamp. + float Elasticity, + /// + /// Retail's UNCONDITIONAL translucencyOriginal write + /// (0x00515097, gates doc conditional 3 — written before the gate, + /// always). has no translucency slot + /// (translucency is presentation-side in acdream — the AP-89 + /// TranslucencyFadeManager family), so the construction receipt is where + /// the unconditional write lands Runtime-side. + /// + float TranslucencyOriginal, + /// + /// Translucency gate outcome — byte-certain gate 3: live translucency + /// (+ PartArray propagation, presentation-side) only when + /// translucency != 0.0f. + /// + bool TranslucencyApplied, + /// True when a present, finite wire velocity was applied via set_velocity. + bool VelocityApplied, + /// True when a present, finite wire omega was written (raw field, no setter). + bool OmegaApplied); + +/// +/// Pure retail-ordered construction of one remote/projectile canonical +/// from the frozen wire , +/// per CPhysicsObj::set_description (0x00514F40, pseudo-C:283155-283276) +/// as called from ACCObjectMaint::CreateObject (0x00558870, +/// pseudo-C:356155-356245, step 6). The caller +/// () sequences this AFTER +/// mover preparation (retail: CPhysicsObj::makeObject shapes the +/// Setup/part-array before set_description runs) and binds the result through +/// the canonical +/// writer — never a parallel binding idiom. +/// +/// Field routing, per the retail order: +/// +/// Motion-table gate — evaluated (see +/// ); +/// motion-table DAT installation stays presentation-side. +/// Sound table (step 2) / physics-script table (step 3) — snapshot/ +/// presentation-side today; not body fields. +/// Placement-frame-vs-Movement (step 4, mutually exclusive) — movement +/// payload present writes +/// (retail last_move_was_autonomous = get_autonomous_movement) and +/// stages NO frame (movement unpack itself is presentation-side today); +/// no payload stages the dormant cell frame from the PREPARED MOVER +/// COMMAND's exact position (retail +/// SetPlacementFrameInternal; enter_world remains the later +/// submission — stays false). +/// set_state (step 5) — from +/// , the retail +/// state-transition view of the wire state (mirrors the existing canonical +/// writers' convention: RuntimePhysicsState.InitializeNewPhysicsBody, +/// SetRemoteMotion). +/// Scale (step 6) — NOT a field; it rides +/// the prepared mover command +/// ( reads +/// Snapshot.Physics.Scale ?? ObjScale ?? 1f, matching the +/// PhysicsDesc constructor default 1f at 0x0051D4D0). +/// Friction (step 7) — gated per the byte-certain gates doc. +/// Elasticity (step 8) — via the set_elasticity clamp port below. +/// Translucency (step 9) — original always recorded; live apply gated; +/// both receipt-side (no body slot; presentation owns render alpha). +/// set_velocity (step 10) / omega raw write (step 11) — retail passes +/// the desc values unconditionally, but the desc DEFAULTS are the zero +/// vector (PhysicsDesc ctor 0x0051D4D0 / Destroy 0x0051D5D0), so gating on +/// wire presence — the existing InitializeNewPhysicsBody +/// convention, mirrored here — produces the identical end state for an +/// absent field (the fresh body's velocity/omega are already zero). +/// default_script / default_script_intensity (step 12) — snapshot/ +/// presentation-side; not body fields. +/// All nine PhysicsTimeStamp slots (step 13, LAST) — already applied +/// at admission and frozen on the residence lease +/// (); nothing body-side. +/// +/// +internal static class RuntimeRemoteBodyDescription +{ + /// + /// PhysicsDesc constructor default friction: 0x0051D4D0 (pseudo-C:292056) + /// writes bytes "33s?" = 0x3F733333 = 0.95f. Same value as + /// . + /// + private const float DefaultDescFriction = 0.95f; + + /// PhysicsDesc constructor default elasticity (0x0051D4D0): 0.05f. + private const float DefaultDescElasticity = 0.05f; + + /// + /// Retail CPhysicsObj::set_elasticity (0x0050FD40, + /// pseudo-C:277817) upper clamp constant 0.100000001f (float 0.1). + /// Cross-check: ACE PhysicsGlobals.MaxElasticity = 0.1f + /// (ACE PhysicsObj.cs:3586-3599 reproduces the identical clamp). + /// + private const float MaxElasticity = 0.1f; + + internal static PhysicsBody Construct( + RuntimeEntityRecord record, + PhysicsSpawnData? description, + in RuntimeSetPositionCommand preparedCommand, + out RuntimeRemoteBodyConstructionReceipt receipt) + { + ArgumentNullException.ThrowIfNull(record); + var body = new PhysicsBody(); + + // 1. Motion-table gate — set_description step 1 (0x00514F5C, + // pseudo-C:283159): the ONLY group that gates the rest of the + // function. See the receipt field's doc comment for the recovered + // zero-id semantics; in Runtime the gate passes (part-array analog + // already satisfied by the sequenced mover preparation; DAT + // motion-table installation is presentation-side). + uint motionTableId = description?.MotionTableId ?? 0u; + const bool motionTableGatePassed = true; + + // 2./3. Sound table + physics-script table (steps 2-3) — snapshot/ + // presentation-side; deliberately untouched here. + + // 4. Placement-frame-vs-movement — mutually exclusive + // (set_description step 4, 0x00514F40; load-bearing ordering fact 2 + // of the retail notes). C3b retail review R1: the retail + // discriminator is the movement BUFFER pointer (`movement_buffer != + // 0`), NOT the wire flag — PhysicsDesc::UnPack (0x0051DDD0) assigns + // movement_buffer only inside its `if (buff_length != 0)` block + // (0051DE1F-0051DE2A) after Destroy nulled it (0051D61A), so a + // movement-flag-set-with-EMPTY-buffer desc reaches set_description + // with movement_buffer == 0 and takes the PLACEMENT branch. The + // autonomy write lives in the ELSE (movement) branch only — retail's + // empty-buffer flavor writes neither the frame-suppression NOR + // last_move_was_autonomous (UnPack likewise reads + // autonomous_movement only inside the same nonzero-length block, so + // an empty-buffer desc never even carries a wire autonomy value). + // Our parser materializes a non-null PhysicsMovementData wrapper + // with empty RawData for exactly that case, so the wrapper's + // presence alone must never decide the branch. + bool movementBranch = description?.Movement is { } movementData + && !movementData.RawData.IsEmpty; + bool lastMoveWasAutonomous = false; + bool placementFrameStaged = false; + if (movementBranch) + { + // Retail: this->last_move_was_autonomous = + // PhysicsDesc::get_autonomous_movement(esi) — written whenever a + // movement payload is present, before the createMode-gated + // unpack_movement (which is presentation-side today). + lastMoveWasAutonomous = + description!.Value.Movement!.Value.IsAutonomous ?? false; + body.LastMoveWasAutonomous = lastMoveWasAutonomous; + } + else + { + // Retail: CPhysicsObj::SetPlacementFrameInternal — direct + // placement from the description's position frame. The staged + // frame uses the PREPARED MOVER COMMAND's exact values (the same + // accepted wire position the submission will resolve), so there + // is no second position source. enter_world remains the later + // submission suffix — InWorld stays false + // (PhysicsBody.StageDormantCellFrame's documented contract). + body.Orientation = preparedCommand.Physics.Orientation; + body.StageDormantCellFrame( + preparedCommand.Physics.CellId, + preparedCommand.Physics.Position, + preparedCommand.Physics.CellLocalPosition); + placementFrameStaged = true; + } + + // 5. set_state — unconditional, AFTER the position/movement branch + // (set_description step 5). FinalPhysicsState is the retail + // state-transition view of the wire state; assigning it mirrors + // every existing canonical writer. + body.State = record.FinalPhysicsState; + + // 6. Scale — command-side (see the class doc comment); no body slot. + + // 7. Friction — byte-certain gates 1+2 + // (docs/research/2026-08-02-set-description-float-gates.md): + // outer FCOM vs 0.0 (VA 0x0051505A) admits friction >= 0.0f; inner + // FCOM vs 1.0 (VA 0x0051506A) admits friction <= 1.0f; only then is + // this->friction assigned (0x0051506C). An absent wire friction + // takes the PhysicsDesc constructor default 0.95f, which passes the + // gate — identical to the fresh body's own default, applied + // explicitly to keep retail's write order observable. + // NaN (C3b retail review R2b): retail's x87 unordered case falls + // into the APPLY bucket on both compares — the gates doc's + // pre-declared accepted compiler-codegen quirk ("not something the + // retail struct's float fields would ever hit in practice"). This + // modern port's `>= && <=` deliberately SKIPS NaN (both comparisons + // fail), the doc's sanctioned deviation; keep it — do not "fix" it + // toward the quirk. + float friction = description?.Friction ?? DefaultDescFriction; + bool frictionApplied = friction >= 0.0f && friction <= 1.0f; + if (frictionApplied) + body.Friction = friction; + + // 8. Elasticity — unconditional via the setter (set_description step + // 8); the CLAMP lives inside CPhysicsObj::set_elasticity 0x0050FD40: + // < 0 -> 0; <= 0.1 -> value; > 0.1 -> 0.1 (ACE MaxElasticity = 0.1f + // agrees; the <= vs < boundary difference at exactly 0.1 is + // valueless — both assign 0.1). + // NaN (C3b retail review R2a): retail's FIRST x87 compare sends the + // unordered case into the zeroing arm (0x0050FD51) — NaN -> 0f. + // `!(e >= 0f)` reproduces that exactly (NaN fails every ordered + // comparison, so it lands in the first arm like retail; a plain + // `e < 0f` would instead fall through and clamp NaN to 0.1f, which + // is ACE's — divergent — behavior, not the binary's). + float elasticity = description?.Elasticity ?? DefaultDescElasticity; + body.Elasticity = !(elasticity >= 0f) + ? 0f + : elasticity <= MaxElasticity + ? elasticity + : MaxElasticity; + + // 9. Translucency — translucencyOriginal is ALWAYS written + // (0x00515097, before the gate); the LIVE apply + PartArray + // propagation happen only when translucency != 0.0f (byte-certain + // gate 3, JNP fires ONLY for exact equality with 0.0f). No body + // slot — recorded on the receipt; render alpha stays + // presentation-side. NaN: `!=` sends NaN into the apply bucket, + // which here MATCHES retail's unordered case exactly (gates doc + // conditional 3's case table) — no deviation to manage. + // R2c note: IsFinite guards remain velocity/omega-only (the + // existing InitializeNewPhysicsBody convention); the float trio's + // NaN routes are each pinned above instead — no broader validation. + float translucency = description?.Translucency ?? 0f; + bool translucencyApplied = translucency != 0.0f; + + // 10. set_velocity — via the setter (set_description step 10). + // Presence-gated per the InitializeNewPhysicsBody convention; the + // absent-field end state is identical (desc default zero vector). + bool velocityApplied = false; + if (description?.Velocity is { } velocity && IsFinite(velocity)) + { + body.set_velocity(velocity); + velocityApplied = true; + } + + // 11. Omega — DIRECT field write, not the setter (load-bearing + // ordering fact 4 of the retail notes: velocity and omega are not + // symmetric). + bool omegaApplied = false; + if (description?.AngularVelocity is { } omega && IsFinite(omega)) + { + body.Omega = omega; + omegaApplied = true; + } + + // 12. default_script / default_script_intensity — snapshot-side. + // 13. All nine timestamps — LAST in retail; already applied at + // admission and frozen on the residence lease; nothing body-side. + + receipt = new RuntimeRemoteBodyConstructionReceipt( + motionTableId, + motionTableGatePassed, + movementBranch, + lastMoveWasAutonomous, + placementFrameStaged, + body.Friction, + frictionApplied, + body.Elasticity, + translucency, + translucencyApplied, + velocityApplied, + omegaApplied); + return body; + } + + private static bool IsFinite(Vector3 value) => + float.IsFinite(value.X) + && float.IsFinite(value.Y) + && float.IsFinite(value.Z); +} diff --git a/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs new file mode 100644 index 00000000..9a8ae39e --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs @@ -0,0 +1,649 @@ +using AcDream.Content; +using AcDream.Core.Physics; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Entities; + +/// +/// Typed yields for . +/// Mirrors the C3a conductor's vocabulary +/// (RuntimeLocalPlayerFirstEntryStatus) rather than inventing a +/// parallel one; the two publication-only statuses have no remote analog. +/// +internal enum RuntimeRemoteFirstEntryStatus : byte +{ + /// + /// The underlying + /// call reported Completed: residence consumed, initial tail and + /// FIFO drained, ExecutorCompleted receipt dispatched. Terminal. + /// + Completed, + + /// + /// The authored-mover Setup read + /// () is not + /// yet available. Retry with the same arguments once the prepared-asset + /// package lands; no Runtime state changed. + /// + AwaitingCollisionSource, + + /// + /// The submitted placement deferred (DeferredCell — destination + /// collision generation not ready, or a collision-prefix quiescence held + /// it) and its parked operation has not produced an acknowledgeable + /// Place projection yet. The wake is internal to + /// (collision-generation commit + /// drives RetryDeferred); retry after it. + /// + AwaitingPlacement, + + /// + /// Our projection exists but could not be acknowledged this call — + /// either another entity's receipt sits ahead of ours in the one ordered + /// FIFO, or + /// still observed PendingPlacement. Retry the same stage. + /// + AwaitingReceiptAcknowledgement, + + /// + /// Passthrough of the executor's own AwaitingContinuationPlacement + /// — a later FIFO continuation needs its own authored placement before + /// the drain can finish; entirely the executor's concern from here on. + /// + AwaitingContinuationPlacement, + + /// + /// A reentrant for the SAME entity arrived while an + /// outer call for it was still on the stack, or another owner's + /// body/remote-motion binding callback is mid-flight on this record. + /// Retry once the outer call has returned. + /// + Contention, + + /// + /// The residence token matches nothing this conductor can own — including + /// a LOCAL-PLAYER lease (), + /// which belongs to the C3a conductor, never this one. + /// + RejectedToken, + + /// + /// An authority-shaped failure (stale epoch/session/identity, deleted or + /// replaced record, a foreign physics body bound out-of-band, a rejected + /// or cancelled submission). Abandoned; progress removed. The caller must + /// begin a fresh sequence (a new residence lease), never retry this call. + /// + RejectedAuthority, +} + +internal readonly record struct RuntimeRemoteFirstEntryOwnershipSnapshot( + int ActiveCount) +{ + internal bool IsConverged => ActiveCount == 0; +} + +/// +/// The dormant, resumable Runtime transaction that dissolves C3's Finding C: +/// ordinary remote-creature and projectile Creates classify to +/// SetPosition, but no production path constructs their canonical +/// at Create time (bodies arrive with first motion +/// today), so 's +/// Record.PhysicsBody requirement rejects the residence route's +/// initial placement. This class is the remote analog of the C3a conductor +/// (RuntimeLocalPlayerFirstEntryState) WITHOUT the publication chain — +/// remotes have no PlayerMovementController — and with retail body +/// construction in its place: +/// +/// mover preparation (retail CPhysicsObj::makeObject shaping the +/// Setup, which precedes set_description in +/// ACCObjectMaint::CreateObject 0x00558870 step 2-vs-6) -> +/// body construction per the exact set_description order +/// (0x00514F40; ) bound through the +/// canonical writer +/// -> ordinary authored submission +/// ( — retail +/// enter_world, which SmartBox::HandleCreateObject 0x00454C80 +/// runs for a top-level object with a nonzero wire cell AFTER CreateObject +/// returns) -> Withdraw/Place receipt acknowledgement -> +/// (FIFO +/// drain). +/// +/// Unlike the local-player path this class never touches the dormant +/// activation family: no operation it drives ever has +/// DormantLocalActivation set, so the ordinary submission tail is the +/// correct — and only — commit route. +/// +/// Dormant by design: fully +/// constructs and wires this class (construction, retirement fan-out, bulk +/// session-clear cleanup, ownership fold) exactly like the C3a conductor, +/// but nothing calls in production — C3c wires the +/// hosts. +/// +internal sealed class RuntimeRemoteFirstEntryState +{ + private enum Stage : byte + { + /// No progress yet, or the mover has not been prepared. + AwaitingMoverPreparation, + + /// Mover command in hand; the body has not been constructed. + MoverPrepared, + + /// + /// The canonical body is constructed and bound; the placement has + /// not been submitted. + /// + BodyConstructed, + + /// + /// Submission deferred (DeferredCell): the parked operation's + /// Withdraw/Place receipts are drained from the projection FIFO as + /// they surface; the wake itself is internal to + /// . + /// + PlacementSubmitted, + + /// + /// The Place projection token is known but not yet acknowledged. + /// + PlacementCommitted, + + /// + /// The Place projection has been acknowledged. Only + /// + /// remains; the acknowledgement step is never re-entered. + /// + Acknowledged, + } + + private sealed class Progress + { + internal required ulong LeaseId { get; init; } + internal Stage Stage { get; set; } = Stage.AwaitingMoverPreparation; + internal RuntimeSetPositionCommand PreparedCommand { get; set; } + internal PhysicsBody? ConstructedBody { get; set; } + internal RuntimeRemoteBodyConstructionReceipt Construction { get; set; } + internal RuntimePlacementProjectionToken Projection { get; set; } + } + + private readonly RuntimeInitialCreateResidenceState _residences; + private readonly RuntimeInitialCreateContinuationExecutor _executor; + private readonly RuntimePhysicsState _physics; + private readonly Dictionary _progress = []; + private readonly HashSet _executing = []; + + internal RuntimeRemoteFirstEntryState( + RuntimeInitialCreateResidenceState residences, + RuntimeInitialCreateContinuationExecutor executor, + RuntimePhysicsState physics) + { + _residences = residences + ?? throw new ArgumentNullException(nameof(residences)); + _executor = executor + ?? throw new ArgumentNullException(nameof(executor)); + _physics = physics + ?? throw new ArgumentNullException(nameof(physics)); + } + + /// + /// Exposes the body-construction receipt for a still-tracked entry — + /// the MID-FLIGHT half of the consumption rule documented on + /// (C3b review M1): while the sequence is in + /// flight this query serves diagnostics/tests; the terminal + /// Completed yield delivers the same receipt through Advance's + /// own out-param in the call that reaps this entry. Returns false once + /// the sequence completed or was abandoned. + /// + internal bool TryGetConstruction( + RuntimeEntityKey key, + out RuntimeRemoteBodyConstructionReceipt construction) + { + if (_progress.TryGetValue(key, out Progress? progress) + && progress.ConstructedBody is not null) + { + construction = progress.Construction; + return true; + } + construction = default; + return false; + } + + /// + /// One resumable step. Callers pass the SAME arguments on every retry; + /// this method re-reads currency from the owning states on every entry + /// rather than trusting anything cached beyond its own stage cursor and + /// the exact command/token structs the owning methods themselves require. + /// + /// Construction-receipt consumption rule (C3b review M1), + /// following the C3a/F2 precedent of receipts riding the terminal + /// Advance out-params: is populated + /// ONLY on the + /// yield — the same call that delivers the executor + /// — because the terminal Advance is a C3c + /// host's one natural consumption point and the progress entry (the + /// receipt's only retained storage) is reaped in that same call. + /// Mid-flight the receipt stays inspectable via + /// ; after Completed nothing is + /// retained. A lease whose route performs no SetPosition (Parented/ + /// PickedUp) constructs no body, so its terminal receipt is default. + /// + internal RuntimeRemoteFirstEntryStatus Advance( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + IPreparedCollisionSource collisionSource, + double gameTime, + in RuntimeInitialCreateExecutionInputs inputs, + out RuntimeInitialCreateExecutionReceipt receipt, + out RuntimeRemoteBodyConstructionReceipt construction) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(collisionSource); + receipt = default; + construction = default; + if (!residenceToken.IsValid || record.Key is not { } key) + return RuntimeRemoteFirstEntryStatus.RejectedToken; + + // Mirrors the executor's and the C3a conductor's _executing guard: a + // synchronous reentrant call for the SAME entity fails closed rather + // than interleaving two drains of one stage machine. + if (!_executing.Add(key)) + return RuntimeRemoteFirstEntryStatus.Contention; + try + { + return AdvanceCore( + record, + residenceToken, + collisionSource, + gameTime, + inputs, + key, + out receipt, + out construction); + } + finally + { + _executing.Remove(key); + } + } + + private RuntimeRemoteFirstEntryStatus AdvanceCore( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + IPreparedCollisionSource collisionSource, + double gameTime, + in RuntimeInitialCreateExecutionInputs inputs, + RuntimeEntityKey key, + out RuntimeInitialCreateExecutionReceipt receipt, + out RuntimeRemoteBodyConstructionReceipt construction) + { + receipt = default; + construction = default; + + _progress.TryGetValue(key, out Progress? progress); + // ABA/GUID-reuse guard, exactly like the C3a conductor and the + // executor's own Progress reconciliation. + if (progress is not null && progress.LeaseId != residenceToken.LeaseId) + { + Discard(key); + progress = null; + } + + if (progress is null || progress.Stage is Stage.AwaitingMoverPreparation) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + if (progress is null) + return RuntimeRemoteFirstEntryStatus.RejectedToken; + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + + // This conductor owns REMOTE and PROJECTILE residence leases + // only. A local-player lease (InitialLogin — or the structurally + // impossible-at-Create LocalAuthoritative) belongs to the C3a + // conductor and its publication chain; refusing it here is + // "nothing tracked in this domain", not an abandonment. + if (lease.Route.OperationKind + is not (RuntimeSetPositionOperationKind.RemoteAuthoritative + or RuntimeSetPositionOperationKind.ProjectileAuthoritative)) + { + return RuntimeRemoteFirstEntryStatus.RejectedToken; + } + + if (!lease.Route.PerformsSetPosition) + { + // Parented/PickedUp residence: no SetPosition operation + // exists, so there is nothing to place and — matching + // today's production behavior for those routes — no body is + // constructed at Create (retail constructs one, but a + // parented child's placement is driven by later parent/ + // pickup events; body-at-Create for those routes stays with + // the first-motion path until a later slice widens this). + // Skip straight to Execute, mirroring the C3a conductor. + progress ??= new Progress { LeaseId = residenceToken.LeaseId }; + progress.Stage = Stage.Acknowledged; + _progress[key] = progress; + return RunExecute( + record, + residenceToken, + inputs, + key, + progress, + out receipt, + out construction); + } + + RuntimeSetPositionMoverPreparationStatus moverStatus = _physics + .SetPosition.TryPrepareAuthoredMover( + record, + lease.Placement, + lease.Route.OperationKind, + lease.Route.SetPositionFlags, + collisionSource, + gameTime, + out RuntimeSetPositionCommand command); + if (moverStatus + == RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable) + { + return RuntimeRemoteFirstEntryStatus.AwaitingCollisionSource; + } + if (moverStatus != RuntimeSetPositionMoverPreparationStatus.Prepared) + { + if (progress is not null) + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + + progress ??= new Progress { LeaseId = residenceToken.LeaseId }; + progress.PreparedCommand = command; + progress.Stage = Stage.MoverPrepared; + _progress[key] = progress; + } + + if (progress.Stage is Stage.MoverPrepared) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + + if (record.PhysicsBody is { } existing) + { + if (ReferenceEquals(progress.ConstructedBody, existing)) + { + // Idempotent retry: our own construction already bound. + progress.Stage = Stage.BodyConstructed; + } + else + { + // A body this conductor did not construct appeared while + // the residence lease was still active — an out-of-band + // owner raced Create-time construction. Never clobber an + // existing canonical body (the writer map's invariant); + // fail closed and let the lease's own retirement path + // converge. + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + } + else if (record.PhysicsBodyAcquisitionInProgress + || record.RemoteMotionBindingInProgress) + { + // Another owner's binding callback is mid-flight on this + // exact record (only reachable when this Advance itself runs + // inside that callback). Typed contention instead of letting + // GetOrCreatePhysicsBody throw its structural guard. + return RuntimeRemoteFirstEntryStatus.Contention; + } + else + { + // Retail order: CreateObject acquires the physics object + // from the Setup (makeObject — our mover preparation, stage + // 1) and then applies the PhysicsDesc via set_description + // (RuntimeRemoteBodyDescription.Construct). Binding runs + // through the canonical GetOrCreatePhysicsBody writer: its + // post-factory InitializeNewPhysicsBody re-applies + // state/velocity/omega from the live snapshot — identical by + // value to the frozen-description writes the factory already + // made (nothing between admission and this call mutates the + // snapshot's physics payload; continuations are queued, not + // applied) — and its SynchronizeBodyActiveState aligns the + // Active transient bit with the record's object clock. + RuntimeRemoteBodyConstructionReceipt built = default; + PhysicsBody constructed = _physics.GetOrCreatePhysicsBody( + record, + r => RuntimeRemoteBodyDescription.Construct( + r, + lease.InitialCreate.Physics, + progress.PreparedCommand, + out built)); + progress.ConstructedBody = constructed; + progress.Construction = built; + progress.Stage = Stage.BodyConstructed; + } + } + + if (progress.Stage is Stage.BodyConstructed) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + + RuntimeSetPositionOutcome outcome = _physics.SetPosition + .SubmitPreparedPlacement(lease.Placement, progress.PreparedCommand); + switch (outcome.Status) + { + case RuntimeSetPositionStatus.CommittedHostAcknowledgementPending: + progress.Projection = outcome.Projection; + progress.Stage = Stage.PlacementCommitted; + break; + case RuntimeSetPositionStatus.DeferredCell: + // ParkDeferred published a Withdraw receipt and parked + // the operation; the projection FIFO drives everything + // from here (drained in the PlacementSubmitted stage + // below, this same call). + progress.Stage = Stage.PlacementSubmitted; + break; + default: + // Rejected (the SetPosition transaction failed — retail's + // enter_world failure leaves the object celless; the + // resident-cell-cleanup family owns that destiny, not a + // silent retry here) or Cancelled (a reentrant observer + // displaced the operation). Fail closed. + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + } + + if (progress.Stage is Stage.PlacementSubmitted) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + + // Drain OUR OWN receipts from the FIFO head as they surface: + // Withdraw (the deferred park) must be acknowledged before the + // internal collision-generation wake can resubmit; the wake's + // commit then publishes the Place this stage is waiting for. + while (true) + { + if (!_physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)) + { + // Nothing pending anywhere — the operation is parked + // awaiting its cell/collision-generation wake. The + // residence currency check above already proved the + // placement operation itself is still tracked. + return RuntimeRemoteFirstEntryStatus.AwaitingPlacement; + } + if (head.Token.Entity != key) + { + // Another entity's receipt sits ahead of ours in the one + // ordered FIFO. + return RuntimeRemoteFirstEntryStatus + .AwaitingReceiptAcknowledgement; + } + if (head.Kind is RuntimePlacementProjectionKind.Withdraw) + { + if (!_physics.SetPosition.AcknowledgeProjection(head.Token)) + { + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + // The withdrawal acknowledgement may have re-armed (or — + // when the generation was already ready — synchronously + // resubmitted) the parked operation; peek again. + continue; + } + if (head.Kind is RuntimePlacementProjectionKind.Place) + { + progress.Projection = head.Token; + progress.Stage = Stage.PlacementCommitted; + break; + } + // Discard (a delete/cancel rewrote our slot) or any other + // kind bearing our key: authority moved. Leave the receipt + // for the ordinary host drain — mirroring the C3a + // conductor's abandonment, which never consumes a Discard + // it did not publish — and fail closed. + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + } + + if (progress.Stage is Stage.PlacementCommitted) + { + if (!_physics.SetPosition.AcknowledgeProjection(progress.Projection)) + { + // Same re-validation the C3a conductor performs on a failed + // acknowledge: only a genuinely-not-our-turn FIFO head stays + // retryable; a retired lease or a rewritten/superseded slot + // means authority moved. + if (!IsAcknowledgementStillPending( + record, residenceToken, progress.Projection)) + { + Discard(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + return RuntimeRemoteFirstEntryStatus + .AwaitingReceiptAcknowledgement; + } + progress.Stage = Stage.Acknowledged; + } + + return RunExecute( + record, + residenceToken, + inputs, + key, + progress, + out receipt, + out construction); + } + + /// + /// Re-validates authority after a failed acknowledge — the exact C3a + /// mechanism, shared verbatim with the local-player conductor via + /// (C3b + /// review M2: one body, so the abandonment fix cannot regress + /// independently in either conductor). Full rationale on the shared + /// helper. + /// + private bool IsAcknowledgementStillPending( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + in RuntimePlacementProjectionToken expected) => + RuntimeFirstEntryAcknowledgement.IsStillPending( + _residences, + _physics.SetPosition, + record, + residenceToken, + expected); + + private RuntimeRemoteFirstEntryStatus RunExecute( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + in RuntimeInitialCreateExecutionInputs inputs, + RuntimeEntityKey key, + Progress progress, + out RuntimeInitialCreateExecutionReceipt receipt, + out RuntimeRemoteBodyConstructionReceipt construction) + { + construction = default; + RuntimeInitialCreateExecutionStatus executeStatus = _executor.Execute( + record, residenceToken, inputs, out receipt); + switch (executeStatus) + { + case RuntimeInitialCreateExecutionStatus.Completed: + // C3b review M1: the terminal Advance is the one natural + // consumption point — deliver the construction receipt in + // the same call that reaps its only retained storage (this + // progress entry). Default (no body constructed) for a + // route that performs no SetPosition. + construction = progress.Construction; + _progress.Remove(key); + return RuntimeRemoteFirstEntryStatus.Completed; + case RuntimeInitialCreateExecutionStatus.PendingPlacement: + return RuntimeRemoteFirstEntryStatus + .AwaitingReceiptAcknowledgement; + case RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement: + return RuntimeRemoteFirstEntryStatus + .AwaitingContinuationPlacement; + case RuntimeInitialCreateExecutionStatus.RejectedToken: + _progress.Remove(key); + return RuntimeRemoteFirstEntryStatus.RejectedToken; + default: + _progress.Remove(key); + return RuntimeRemoteFirstEntryStatus.RejectedAuthority; + } + } + + /// + /// Drops this class's own progress entry for . + /// Unlike the C3a conductor there is no publication candidate/activation + /// to discard — the constructed body, once bound through the canonical + /// writer, belongs to the record and is torn down by ordinary entity + /// teardown (retail has no entry-flow rollback; the C3a carried finding + /// applies identically here). The residence and executor own their own + /// convergence independently. + /// + private void Discard(RuntimeEntityKey key) => _progress.Remove(key); + + /// + /// Cleanup for one key. binds + /// this into 's multicast + /// retirement notification (alongside the executor's + /// DiscardProgress and the C3a conductor's Forget), so any + /// residence retirement path — delete, reset, generation replacement, a + /// host discovering staleness — reaps this class's progress + /// automatically, using the exact key the residence tracked internally. + /// + internal void Forget(RuntimeEntityKey key) => Discard(key); + + /// + /// Bulk cleanup wired into the same session-clear sequence + /// () as the + /// executor's and the C3a conductor's own DiscardAll calls. + /// + internal void DiscardAll() => _progress.Clear(); + + internal RuntimeRemoteFirstEntryOwnershipSnapshot CaptureOwnership() => + new(_progress.Count); +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs index c5629e5d..f366c733 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs @@ -533,49 +533,26 @@ internal sealed class RuntimeLocalPlayerFirstEntryState } /// - /// Re-validates authority after a failed acknowledge. Two independent - /// checks, either of which failing means authority moved and this class - /// must abandon rather than keep retrying forever: (1) the residence - /// lease this whole sequence began under must still be exactly current - /// (mirrors the stage0/stage1 checks — a delete or reset retires it); - /// (2) if the FIFO head belongs to THIS entity at all, it must still be - /// the exact Place projection this class is holding — a head that - /// belongs to us but is no longer that exact token (rewritten to - /// Discard, or to a later revision) means our specific placement was - /// superseded even if the residence lookup transiently still resolves. - /// A head belonging to a DIFFERENT entity is the genuine "not yet our - /// turn" case and must stay retryable. This reads the Runtime-internal - /// directly - /// rather than through the public, generation-gated - /// — this class is part - /// of Runtime, not an external host crossing that boundary, exactly like - /// its existing direct - /// call above. + /// Re-validates authority after a failed acknowledge. C3b review M2: + /// the mechanism (residence-lease currency + exact-head-token match; a + /// DIFFERENT entity's head stays retryable) is shared verbatim with the + /// remote conductor via + /// — one + /// body, so the abandonment fix that stops a delete-rewritten Discard + /// head from producing an infinite AwaitingReceiptAcknowledgement retry + /// cannot regress independently in either conductor. Full rationale on + /// the shared helper. /// private bool IsAcknowledgementStillPending( RuntimeEntityRecord record, in RuntimeInitialCreateResidenceToken residenceToken, - in RuntimePlacementProjectionToken expected) - { - if (!_residences.TryGetCurrent( - record, - out RuntimeInitialCreateResidenceLease lease) - || lease.Token != residenceToken) - { - return false; - } - - if (_physics.SetPosition.TryPeekProjection( - out RuntimePlacementProjectionSnapshot head) - && head.Token.Entity == expected.Entity - && (head.Kind is not RuntimePlacementProjectionKind.Place - || head.Token != expected)) - { - return false; - } - - return true; - } + in RuntimePlacementProjectionToken expected) => + RuntimeFirstEntryAcknowledgement.IsStillPending( + _residences, + _physics.SetPosition, + record, + residenceToken, + expected); private RuntimeLocalPlayerFirstEntryStatus RunExecute( RuntimeEntityRecord record, diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs new file mode 100644 index 00000000..7f54988d --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs @@ -0,0 +1,980 @@ +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; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using AcDream.Runtime; + +namespace AcDream.Runtime.Tests.Entities; + +public sealed class RuntimeRemoteFirstEntryStateTests +{ + private const uint Landblock = 0xA9B60000u; + private const uint Cell = Landblock | 0x0001u; + private const uint SetupId = 0x02000001u; + private static readonly RuntimeInitialCreateExecutionInputs NoContact = + new(UsePositionFromServer: false, PlayerDistance: 0f); + + // --------------------------------------------------------------- + // Happy path + // --------------------------------------------------------------- + + [Fact] + public void FullSequenceRemoteEntryConstructsGatedBodyPlacesAndCompletes() + { + using var fixture = new Fixture(residentWorld: true); + + RuntimeRemoteFirstEntryStatus status = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt, + out RuntimeRemoteBodyConstructionReceipt construction); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, status); + Assert.Equal(Cell, receipt.FullCellId); + // Remote Creates carry NO teleport hook (classifier: AfterEnterWorld + // is local-player-only). + Assert.Equal(RuntimeTeleportHookPhase.None, receipt.TeleportHookPhase); + // M1: the terminal Advance delivers the construction receipt in the + // same call that reaps its retained storage — the C3c host's one + // natural consumption point. + Assert.True(construction.MotionTableGatePassed); + Assert.Equal(0x09000001u, construction.MotionTableId); + Assert.False(construction.MovementBranch); + Assert.True(construction.PlacementFrameStaged); + Assert.True(construction.FrictionApplied); + Assert.Equal(0.5f, construction.Friction); + Assert.False(construction.TranslucencyApplied); + Assert.Equal(0f, construction.TranslucencyOriginal); + Assert.True(construction.VelocityApplied); + Assert.True(construction.OmegaApplied); + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + Assert.Equal(Cell, fixture.Record.FullCellId); + // set_description field application from the frozen wire desc: + // friction 0.5 passed gates 1+2; elasticity 0.05 inside the + // set_elasticity clamp; state is the record's retail-transitioned + // view; omega was the raw step-11 write. + Assert.Equal(0.5f, body.Friction); + Assert.Equal(0.05f, body.Elasticity); + Assert.Equal(fixture.Record.FinalPhysicsState, body.State); + Assert.Equal(new Vector3(0f, 0f, 0.25f), body.Omega); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.False(fixture.Conductor.TryGetConstruction( + fixture.Key, out _)); + Assert.False(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out _)); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .InitialCreateExecutorProgressCount); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .RemoteFirstEntryActiveCount); + + // Retrying with the now-stale residence token is a distinct, safe + // no-op — nothing left to resume. + Assert.Equal( + RuntimeRemoteFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + } + + [Fact] + public void ProjectileFlavorFullSequenceCompletesWithMissileState() + { + using var fixture = new Fixture( + residentWorld: true, + rawState: (uint)(PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.Inelastic + | PhysicsStateFlags.PathClipped + | PhysicsStateFlags.AlignPath)); + + // The Missile flag classified the Create to ProjectileAuthoritative + // (RuntimeInitialCreateResidenceState.Begin) — the conductor accepts + // that flavor through the identical stages. + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal( + RuntimeSetPositionOperationKind.ProjectileAuthoritative, + lease.Route.OperationKind); + + RuntimeRemoteFirstEntryStatus status = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, status); + Assert.Equal(Cell, receipt.FullCellId); + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + Assert.True(body.State.HasFlag(PhysicsStateFlags.Missile)); + Assert.Equal(fixture.Record.FinalPhysicsState, body.State); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // The three float gates, proven both ways on the pure construction + // (docs/research/2026-08-02-set-description-float-gates.md is law) + // --------------------------------------------------------------- + + [Theory] + [InlineData(-1f, false, 0.95f)] + [InlineData(0f, true, 0f)] + [InlineData(0.5f, true, 0.5f)] + [InlineData(1f, true, 1f)] + [InlineData(1.5f, false, 0.95f)] + // R2b: NaN is SKIPPED (body keeps its default) — the gates doc's + // sanctioned deviation from retail's unordered-goes-to-apply codegen + // quirk; see the friction comment in RuntimeRemoteBodyDescription. + [InlineData(float.NaN, false, 0.95f)] + public void FrictionGateAppliesOnlyInsideClosedUnitInterval( + float wireFriction, + bool expectedApplied, + float expectedBodyFriction) + { + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(friction: wireFriction); + + Assert.Equal(expectedApplied, receipt.FrictionApplied); + Assert.Equal(expectedBodyFriction, body.Friction); + Assert.Equal(body.Friction, receipt.Friction); + } + + [Fact] + public void FrictionAbsentOnWireTakesTheDescConstructorDefault() + { + // PhysicsDesc ctor 0x0051D4D0 seeds friction 0.95f; it passes the + // gate, so an absent wire friction still records an APPLIED write. + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(friction: null); + + Assert.True(receipt.FrictionApplied); + Assert.Equal(0.95f, body.Friction); + } + + [Theory] + [InlineData(0f, false)] + [InlineData(0.5f, true)] + [InlineData(-0.25f, true)] + // R2: NaN lands in the apply bucket — matching retail's unordered case + // exactly (gates doc conditional 3: JNP fires ONLY on exact equality + // with 0.0f). + [InlineData(float.NaN, true)] + public void TranslucencyGateAppliesOnlyWhenNonZeroAndOriginalIsAlwaysRecorded( + float wireTranslucency, + bool expectedApplied) + { + (_, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(translucency: wireTranslucency); + + // translucencyOriginal is written UNCONDITIONALLY (0x00515097, + // before the gate); the live apply fires for every value except + // exact 0.0f (gate 3's JNP fires only on equality). + Assert.Equal(wireTranslucency, receipt.TranslucencyOriginal); + Assert.Equal(expectedApplied, receipt.TranslucencyApplied); + } + + [Fact] + public void TranslucencyAbsentOnWireIsZeroOriginalAndNoLiveApply() + { + (_, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(translucency: null); + + Assert.Equal(0f, receipt.TranslucencyOriginal); + Assert.False(receipt.TranslucencyApplied); + } + + [Theory] + [InlineData(-0.5f, 0f)] + [InlineData(0f, 0f)] + [InlineData(0.05f, 0.05f)] + [InlineData(0.1f, 0.1f)] + [InlineData(0.2f, 0.1f)] + // R2a: retail's FIRST x87 compare sends unordered to the zeroing arm + // (0x0050FD51) — NaN -> 0f, NOT ACE's divergent NaN -> 0.1f. + [InlineData(float.NaN, 0f)] + public void ElasticityClampMatchesRetailSetter( + float wireElasticity, + float expectedBodyElasticity) + { + // CPhysicsObj::set_elasticity 0x0050FD40: < 0 -> 0; <= 0.1 -> value; + // > 0.1 -> 0.1 (ACE PhysicsGlobals.MaxElasticity = 0.1f agrees). + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(elasticity: wireElasticity); + + Assert.Equal(expectedBodyElasticity, body.Elasticity); + Assert.Equal(body.Elasticity, receipt.Elasticity); + } + + // --------------------------------------------------------------- + // Placement-frame-vs-movement branch (set_description step 4) + // --------------------------------------------------------------- + + [Fact] + public void MovementPayloadSuppressesPlacementFrameAndWritesAutonomy() + { + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(movement: new PhysicsMovementData( + RawData: new byte[] { 0x01 }, + MotionState: null, + IsAutonomous: true)); + + Assert.True(receipt.MovementBranch); + Assert.True(receipt.LastMoveWasAutonomous); + Assert.True(body.LastMoveWasAutonomous); + Assert.False(receipt.PlacementFrameStaged); + // No frame staged: the body's cell identity stays default-detached. + Assert.Equal(0u, body.CellPosition.ObjCellId); + Assert.False(body.InWorld); + } + + [Fact] + public void MovementFlagWithEmptyBufferTakesThePlacementBranch() + { + // R1: retail's branch discriminator is `movement_buffer != 0` — + // PhysicsDesc::UnPack (0x0051DDD0) assigns the buffer only inside + // `if (buff_length != 0)` (0051DE1F-0051DE2A), so a movement flag + // with a ZERO-LENGTH buffer reaches set_description with a null + // buffer and takes the PLACEMENT branch; the autonomy write (in the + // movement else-branch only) never runs. Our parser materializes + // exactly this wrapper shape (CreateObject.cs: non-null + // PhysicsMovementData with empty RawData). IsAutonomous is set true + // here deliberately — the BRANCH, never the wrapper's value, must + // decide. + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(movement: new PhysicsMovementData( + RawData: ReadOnlyMemory.Empty, + MotionState: null, + IsAutonomous: true)); + + Assert.False(receipt.MovementBranch); + Assert.True(receipt.PlacementFrameStaged); + Assert.Equal(Cell, body.CellPosition.ObjCellId); + Assert.False(body.InWorld); + Assert.False(receipt.LastMoveWasAutonomous); + Assert.False(body.LastMoveWasAutonomous); + } + + [Fact] + public void NoMovementPayloadStagesTheDormantPlacementFrame() + { + (PhysicsBody body, RuntimeRemoteBodyConstructionReceipt receipt) = + ConstructDirect(movement: null); + + Assert.False(receipt.MovementBranch); + Assert.True(receipt.PlacementFrameStaged); + Assert.Equal(Cell, body.CellPosition.ObjCellId); + // SetPlacementFrameInternal is NOT enter_world — the submission owns + // world residence. + Assert.False(body.InWorld); + Assert.False(body.LastMoveWasAutonomous); + } + + // --------------------------------------------------------------- + // Yield flavors + retry idempotency per stage + // --------------------------------------------------------------- + + [Fact] + public void AwaitingCollisionSourceRetriesThenResumesOnceSetupLands() + { + using var fixture = new Fixture(residentWorld: true, setupTableId: SetupId); + fixture.CollisionSource.Status = PreparedAssetReadStatus.Missing; + + RuntimeRemoteFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingCollisionSource, first); + // No progress entry, no body, nothing mutated while the Setup is + // outstanding. + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Null(fixture.Record.PhysicsBody); + + RuntimeRemoteFirstEntryStatus second = fixture.Advance(out _); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingCollisionSource, second); + Assert.True(fixture.CollisionSource.ReadCount >= 2); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Null(fixture.Record.PhysicsBody); + + fixture.CollisionSource.Status = PreparedAssetReadStatus.Loaded; + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(Cell, receipt.FullCellId); + Assert.NotNull(fixture.Record.PhysicsBody); + } + + [Fact] + public void DeferredPlacementRetainsOneBodyIdentityAcrossRetriesThenWakesAndCompletes() + { + // Non-resident world: the ordinary submission defers (the + // destination landblock's collision world was never added), parking + // the operation behind a Withdraw the conductor drains itself. + using var fixture = new Fixture(residentWorld: false); + + RuntimeRemoteFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, first); + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.False(body.InWorld); + Assert.Equal(0u, fixture.Record.FullCellId); + // The construction is still inspectable mid-flight, and the + // set_description writes are already on the body. + Assert.True(fixture.Conductor.TryGetConstruction( + fixture.Key, out RuntimeRemoteBodyConstructionReceipt construction)); + Assert.True(construction.FrictionApplied); + Assert.Equal(0.5f, body.Friction); + Assert.True(construction.VelocityApplied); + Assert.Equal(new Vector3(1f, 2f, 0.5f), body.Velocity); + Assert.True(construction.OmegaApplied); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + + // Retry idempotency: the SAME body identity survives every retry — + // no duplicate construction, no re-submission churn. + for (int i = 0; i < 3; i++) + { + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, + fixture.Advance(out _)); + Assert.Same(body, fixture.Record.PhysicsBody); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + const ulong generation = 1UL; + fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( + Landblock, generation); + fixture.Lifetime.Physics.Engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( + Landblock, generation, ready: true); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Same(body, fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(Cell, fixture.Record.FullCellId); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + [Fact] + public void ReentrantAdvanceDuringCollisionCallbackFailsClosedWithContentionAndOuterCallStillCompletes() + { + using var fixture = new Fixture(residentWorld: true); + bool reentered = false; + RuntimeRemoteFirstEntryStatus? innerStatus = null; + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (_, phase, _, observed) => + { + if (!reentered && phase is TransitionCellCollisionPhase.Environment) + { + reentered = true; + innerStatus = fixture.Advance(out _); + } + return observed; + }; + + RuntimeRemoteFirstEntryStatus outerStatus = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.True(reentered); + Assert.Equal(RuntimeRemoteFirstEntryStatus.Contention, innerStatus); + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, outerStatus); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + [Fact] + public void AwaitingReceiptAcknowledgementWhileAnotherEntityHoldsTheFifoHeadThenResumes() + { + using var fixture = new Fixture(residentWorld: true); + (RuntimeEntityRecord other, RuntimePlacementProjectionToken otherToken) = + BeginPendingOrdinaryPlacement(fixture, 0x70099001u); + + // Our submission commits, but its Place sits BEHIND the other + // entity's unacknowledged receipt in the one ordered FIFO. + RuntimeRemoteFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal( + RuntimeRemoteFirstEntryStatus.AwaitingReceiptAcknowledgement, + first); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + + RuntimeRemoteFirstEntryStatus second = fixture.Advance(out _); + Assert.Equal( + RuntimeRemoteFirstEntryStatus.AwaitingReceiptAcknowledgement, + second); + + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(otherToken)); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(Cell, receipt.FullCellId); + _ = other; + } + + [Fact] + public void DeleteWhileAwaitingReceiptAcknowledgementAbandonsInsteadOfRetryingForeverAndConverges() + { + // M2a — the exact C3a-shaped scenario: the placement has ALREADY + // committed (body in world) but acknowledgement is blocked behind + // another entity's unacknowledged Place. The delete-path mechanism + // (Physics.SetPosition.Forget with releasePreparedMover — the exact + // inner call TryAcceptDelete makes) rewrites our still-pending Place + // slot to Discard with a bumped revision, so the cached + // progress.Projection can never match the FIFO head again. Without + // the shared IsAcknowledgementStillPending re-check, Advance would + // report AwaitingReceiptAcknowledgement forever and ownership would + // never converge. + using var fixture = new Fixture(residentWorld: true); + (RuntimeEntityRecord other, RuntimePlacementProjectionToken otherToken) = + BeginPendingOrdinaryPlacement(fixture, 0x70099002u); + + Assert.Equal( + RuntimeRemoteFirstEntryStatus.AwaitingReceiptAcknowledgement, + fixture.Advance(out _)); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out _)); + + RuntimePlacementCancellationReceipt cancellation = fixture.Lifetime + .Physics.SetPosition.Forget( + fixture.Record, + releasePreparedMover: true); + fixture.Lifetime.Physics.SetPosition.PublishCancellation(cancellation); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.RejectedAuthority, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + + // The unrelated entity's own placement is untouched and still + // acknowledgeable — the abandonment never reached past our own + // entity's projection. + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(otherToken)); + _ = other; + } + + [Fact] + public void AwaitingContinuationPlacementPropagatesExecutorYieldThenResumes() + { + using var fixture = new Fixture(residentWorld: true); + // A fresh Position arrives while the initial residence is still + // pending — enqueued as a continuation, classified only at drain. + WorldSession.EntityPositionUpdate update = new( + fixture.Record.ServerGuid, + new CreateObject.ServerPosition(Cell, 40f, 20f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: 2, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 1, + ForcePositionSequence: 0); + Assert.True(fixture.Lifetime.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + RuntimeRemoteFirstEntryStatus status = fixture.Advance(out _); + Assert.Equal( + RuntimeRemoteFirstEntryStatus.AwaitingContinuationPlacement, + status); + // The conductor's own sequence reached its terminal Acknowledged + // stage; the retained progress entry lets a retry skip straight to + // re-calling Execute. + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + + RuntimeEntityKey key = fixture.Key; + Assert.True(fixture.Lifetime.InitialCreateExecution + .TryGetPendingContinuationPlacement( + key, out RuntimeEntityPlacementToken placement)); + Assert.True(fixture.Lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute( + key, out RuntimeAuthoritativePositionRoute route)); + CompleteOrdinaryPlacement(fixture, placement, route); + + RuntimeRemoteFirstEntryStatus resumed = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, resumed); + Assert.Contains(receipt.Trace, + a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // Domain refusal + never-clobber + // --------------------------------------------------------------- + + [Fact] + public void LocalPlayerLeaseIsRefusedWithoutTrackingAnything() + { + using var fixture = new Fixture(residentWorld: true, isLocalPlayer: true); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Null(fixture.Record.PhysicsBody); + // The lease itself is untouched — the C3a conductor still owns it. + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal(fixture.Lease.Token, lease.Token); + } + + [Fact] + public void ForeignBodyBoundOutOfBandAbandonsWithoutClobbering() + { + using var fixture = new Fixture(residentWorld: true); + var foreign = new PhysicsBody + { + Position = new Vector3(1f, 2f, 3f), + State = fixture.Record.FinalPhysicsState, + }; + fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, foreign); + + Assert.Equal(RuntimeRemoteFirstEntryStatus.RejectedAuthority, + fixture.Advance(out _)); + // The foreign body was never replaced or mutated toward ours. + Assert.Same(foreign, fixture.Record.PhysicsBody); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // Delete / reset mid-flight convergence + // --------------------------------------------------------------- + + [Fact] + public void DeleteWhileAwaitingPlacementConvergesAutomaticallyThroughTheRetirementFanOut() + { + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, + fixture.Advance(out _)); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + + DeleteEntity(fixture); + Assert.Null(fixture.Record.Key); + + // TryAcceptDelete's ForgetInitialCreateResidence fired the multicast + // retirement notification synchronously — this conductor's Forget is + // bound into that same fan-out, so convergence is complete before + // delete returns. + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + + // Retrying Advance afterward is a safe, distinct no-op. + Assert.Equal(RuntimeRemoteFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + } + + [Fact] + public void SessionClearMidFlightConvergesOwnership() + { + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, + fixture.Advance(out _)); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + + _ = fixture.Lifetime.BeginSessionClear(); + + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .RemoteFirstEntryActiveCount); + } + + [Fact] + public void DeleteAndSameGuidReincarnationStartsAFreshSequence() + { + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, + fixture.Advance(out _)); + RuntimeEntityKey staleKey = fixture.Key; + uint guid = fixture.Record.ServerGuid; + + DeleteEntity(fixture); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + + RuntimeEntityRecord reincarnated = fixture.Lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, incarnation: 2), + isLocalPlayer: false) + .Canonical!; + Assert.NotEqual(staleKey, reincarnated.Key!.Value); + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + reincarnated, + out RuntimeInitialCreateResidenceLease freshLease)); + fixture.Record = reincarnated; + fixture.Lease = freshLease; + + Assert.Equal(RuntimeRemoteFirstEntryStatus.AwaitingPlacement, + fixture.Advance(out _)); + const ulong generation = 1UL; + fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( + Landblock, generation); + fixture.Lifetime.Physics.Engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( + Landblock, generation, ready: true); + + RuntimeRemoteFirstEntryStatus status = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + Assert.Equal(RuntimeRemoteFirstEntryStatus.Completed, status); + Assert.Equal(Cell, receipt.FullCellId); + Assert.NotNull(reincarnated.PhysicsBody); + Assert.True(reincarnated.PhysicsBody!.InWorld); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private static (PhysicsBody Body, RuntimeRemoteBodyConstructionReceipt Receipt) + ConstructDirect( + float? friction = 0.5f, + float? elasticity = 0.05f, + float? translucency = null, + PhysicsMovementData? movement = null) + { + var record = new RuntimeEntityRecordFactory(); + RuntimeEntityRecord canonical = record.Lifetime + .RegisterEntity( + Spawn( + 0x70090077u, + incarnation: 1, + friction: friction, + elasticity: elasticity, + translucency: translucency, + movement: movement)) + .Canonical!; + var command = new RuntimeSetPositionCommand( + new PhysicsSetPositionRequest( + new Vector3(1f, 2f, 3f), + Quaternion.Identity, + Cell, + new Vector3(1f, 2f, 3f), + ImmutableArray.Empty, + Scale: 1f, + StepUpHeight: 0f, + StepDownHeight: 0f, + canonical.FinalPhysicsState, + ObjectInfoState.None, + canonical.Key?.LocalEntityId ?? 0u, + PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags.Placement | PhysicsSetPositionFlags.Slide), + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 1d, + ExpectedVelocityAuthorityVersion: 0UL); + + PhysicsBody body = RuntimeRemoteBodyDescription.Construct( + canonical, + canonical.Snapshot.Physics, + command, + out RuntimeRemoteBodyConstructionReceipt receipt); + record.Dispose(); + return (body, receipt); + } + + private sealed class RuntimeEntityRecordFactory : IDisposable + { + internal RuntimeEntityObjectLifetime Lifetime { get; } = new(); + + internal RuntimeEntityRecordFactory() + { + var generation = new RuntimeGenerationToken(1UL); + Lifetime.BindEventContext(() => generation, static () => 1UL); + } + + public void Dispose() => Lifetime.Dispose(); + } + + private static (RuntimeEntityRecord Record, RuntimePlacementProjectionToken Token) + BeginPendingOrdinaryPlacement(Fixture fixture, uint guid) + { + RuntimeEntityRecord record = fixture.Lifetime.RegisterEntity( + Spawn(guid, incarnation: 1)).Canonical!; + var body = new PhysicsBody + { + Position = new Vector3(50f, 50f, 3f), + Orientation = Quaternion.Identity, + State = record.FinalPhysicsState, + }; + body.SnapToCell(Cell, body.Position, body.Position); + fixture.Lifetime.Entities.SetPhysicsBody(record, body); + RuntimeEntityPlacementToken placement = fixture.Lifetime.Physics + .SetPosition.BeginAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(placement.IsValid); + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.ResolvedAbsent, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags.Placement | PhysicsSetPositionFlags.Slide); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + fixture.Lifetime.Physics.SetPosition.PrepareMover( + placement, preparation, out RuntimeSetPositionCommand command)); + RuntimeSetPositionOutcome outcome = fixture.Lifetime.Physics.SetPosition + .SubmitPreparedPlacement(placement, command); + Assert.Equal(RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + return (record, outcome.Projection); + } + + private static void CompleteOrdinaryPlacement( + Fixture fixture, + in RuntimeEntityPlacementToken placement, + in RuntimeAuthoritativePositionRoute route) + { + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.ResolvedAbsent, + route.OperationKind, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + route.SetPositionFlags); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + fixture.Lifetime.Physics.SetPosition.PrepareMover( + placement, preparation, out RuntimeSetPositionCommand command)); + RuntimeSetPositionOutcome outcome = fixture.Lifetime.Physics.SetPosition + .SubmitPreparedPlacement(placement, command); + Assert.Equal(RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(outcome.Projection)); + } + + private static void DeleteEntity(Fixture fixture) + { + Assert.True(fixture.Lifetime.TryAcceptDelete( + new DeleteObject.Parsed( + fixture.Record.ServerGuid, fixture.Record.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + fixture.Lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(fixture.Lifetime.RetireCanonicalOnly(fixture.Record)); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation, + bool includePosition = true, + uint setupTableId = 0u, + uint rawState = (uint)(PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions), + float? friction = 0.5f, + float? elasticity = 0.05f, + float? translucency = null, + PhysicsMovementData? movement = null) + { + CreateObject.ServerPosition? position = includePosition + ? new CreateObject.ServerPosition(Cell, 1f, 2f, 3f, 1f, 0f, 0f, 0f) + : null; + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: incarnation); + var physics = new PhysicsSpawnData( + RawState: rawState, + Position: position, + Movement: movement, + AnimationFrame: null, + SetupTableId: setupTableId == 0u ? null : setupTableId, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: 1f, + Friction: friction, + Elasticity: elasticity, + Translucency: translucency, + Velocity: new Vector3(1f, 2f, 0.5f), + Acceleration: null, + AngularVelocity: new Vector3(0f, 0f, 0.25f), + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + Guid: guid, + Position: position, + SetupTableId: setupTableId == 0u ? null : setupTableId, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "remote-entry-fixture", + ItemType: null, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: physics.RawState, + ObjectDescriptionFlags: 0x8u, + Friction: friction, + Elasticity: elasticity, + InstanceSequence: incarnation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class FakeCollisionSource( + uint expectedSetupTableId, + FlatSetupCollision setup) : IPreparedCollisionSource + { + internal int ReadCount { get; private set; } + internal PreparedAssetReadStatus Status { get; set; } = + PreparedAssetReadStatus.Loaded; + + public PreparedAssetPresence ProbeCollision( + PakAssetType type, uint sourceFileId) => + PreparedAssetPresence.Available; + + public PreparedCollisionReadResult ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + ReadCount++; + Assert.Equal(expectedSetupTableId, sourceFileId); + return Status switch + { + PreparedAssetReadStatus.Loaded => + PreparedCollisionReadResult.Loaded(setup), + PreparedAssetReadStatus.Corrupt => + PreparedCollisionReadResult.Corrupt, + _ => PreparedCollisionReadResult.Missing, + }; + } + + public PreparedCollisionReadResult + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() + { + } + } + + private sealed class Fixture : IDisposable + { + internal Fixture( + bool residentWorld, + uint setupTableId = 0u, + uint rawState = (uint)(PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions), + bool isLocalPlayer = false) + { + if (residentWorld) + { + 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); + Lifetime = new RuntimeEntityObjectLifetime(engine); + } + else + { + Lifetime = new RuntimeEntityObjectLifetime(); + } + var generation = new RuntimeGenerationToken(1UL); + Lifetime.BindEventContext(() => generation, static () => 1UL); + + // The SAME conductor instance RuntimeEntityObjectLifetime itself + // constructs and wires into the residence's multicast retirement + // fan-out and BeginSessionClear — never a standalone copy — so + // these tests exercise the real production wiring. + Conductor = Lifetime.RemoteFirstEntry; + + Record = Lifetime.RegisterEntityWithInitialResidence( + Spawn( + 0x70090002u, + incarnation: 1, + setupTableId: setupTableId, + rawState: rawState), + isLocalPlayer).Canonical!; + Assert.True(Lifetime.TryGetInitialCreateResidence( + Record, out RuntimeInitialCreateResidenceLease lease)); + Lease = lease; + + CollisionSource = new FakeCollisionSource( + setupTableId, + new FlatSetupCollision( + ImmutableArray.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + } + + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal RuntimeRemoteFirstEntryState Conductor { get; } + internal RuntimeEntityRecord Record { get; set; } + internal RuntimeInitialCreateResidenceLease Lease { get; set; } + internal FakeCollisionSource CollisionSource { get; } + + internal RuntimeEntityKey Key => Record.Key!.Value; + + internal RuntimeRemoteFirstEntryStatus Advance( + out RuntimeInitialCreateExecutionReceipt receipt) => + Advance(out receipt, out _); + + internal RuntimeRemoteFirstEntryStatus Advance( + out RuntimeInitialCreateExecutionReceipt receipt, + out RuntimeRemoteBodyConstructionReceipt construction) => + Conductor.Advance( + Record, + Lease.Token, + CollisionSource, + gameTime: 10d, + NoContact, + out receipt, + out construction); + + public void Dispose() => Lifetime.Dispose(); + } +}