diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 35d2cf94..f73acf81 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 46 active rows (AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 47 active rows (AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-42 refreshed same round — its cited App-side login resolve split was deleted by the C3c flip, the split survives only on the unflipped remote-teleport/headless portal-resync paths; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -138,13 +138,14 @@ readiness/requeue adaptation. See | AD-39 | The `frames_stationary_fall` ladder + fsf≥3 UP-contact-plane manufacture runs AFTER acdream's fused LKCP-restore/contact-marking block, deriving retail's `_redo` as `cleanAdvance \|\| OnWalkable`; retail (ACE Transition.cs:1029-1061) interleaves the fsf block BETWEEN the LKCP-restore (sets `_redo`) and the contact-marking (reads the manufactured plane) (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` fsf tail) | acdream deliberately fused ACE's separate LKCP-restore + contact-mark blocks (the L.2.3c/L.2.4/A6.P3 contact-retention divergences); running the ladder after them and re-marking grounding inside the manufacture branch is semantically equal (a grounded wall-slide is not a stuck-fall in either arrangement) without disturbing those hard-won fixes | If a future contact-retention change alters when OnWalkable is set relative to the ladder, `_redo` could misclassify a frame (grounded-jam mistaken for stuck-fall → spurious velocity zero, or vice-versa) — the fsf conformance tests pin the current arrangement | `CTransition::validate_transition` 0x0050aa70 pc:272625-656; ACE Transition.cs:1029-1061 | | AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) | | AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses the WHOLE SetPositionInternal-shaped commit (contact/walkable flags, HitGround/LeaveGround, `handle_all_collisions`, `cached_velocity`) on a no-move frame — narrowed 2026-07-30 (#265 bounce rework) from "only handle_all_collisions"; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) | -| AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 | +| AD-42 | **Refreshed 2026-08-02 (C3c review round 1).** The two-call enter-world placement split (legacy `Resolve` = retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` = the verbatim object-aware `find_placement_pos` ring search) survives ONLY on the unflipped portal-arrival paths: the remote-teleport controller and the headless portal-arrival resync. The LOCAL login first-entry no longer uses it — the C3c flip routes it through the single canonical Runtime SetPosition transaction (the faithful placement family), retiring the row's original `GameWindow.EnterPlayerModeNow` citation. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Physics/RemoteTeleportController.cs` (`ResolvePlacement`); `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` (`ResynchronizeLocalPlayerForPortalArrival`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. The surviving split paths are C4's portal-route flip scope | A teleport arrival that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 | | AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 | | AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | | AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` | | AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 | | AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx | | AD-59 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The `SameIncarnationCreate` envelope buffers one publish per committed stage and flushes them ALL, in stage order, only after the LAST stage commits (constant-true per-field predicate, `IsCurrent`-checked at flush - the per-field closure variant was invalidated by WeenieDescription's six-field `AdvanceCreateAuthority`). A subscriber sees N back-to-back events with no interleaved observation point, each carrying the FINAL merged post-envelope record state, not per-stage state. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyEnvelope` buffered-publish tail; `Publish`/`PublishNow`) | Retail's own tail is one synchronous critical section, and retail emits ONE notice per Create (`ECM_Physics::SendNotice_CreateObject`, fired whenever a weenie exists, independent of the physics-registration outcome) - never N per-internal-step notices. The buffered flush is closer to retail's one-signal model than per-step publication would be, though not a literal 1:1 match. | A subscriber diffing consecutive `Updated` events from the SAME envelope to isolate one stage's delta gets every stage's cumulative state on each event - silently wrong incremental-diff logic, not a crash. | `SmartBox::HandleCreateObject` 0x00454C80 same-incarnation tail (one synchronous critical section); `ACCObjectMaint::CreateObject` 0x00558870 step 11 (`ECM_Physics::SendNotice_CreateObject`) | +| AD-61 | **Filed 2026-08-02 (C3c review round 1).** The #270 settle-timing compression now covers the LOCAL player: `RuntimeLocalPlayerPhysicsPublicationState.SettleFirstEntryGroundContact` runs the shared `SpawnPlacementSettler` exactly once after the dormant activation's final commit (suffix-current authority only), compressing retail's first post-`enter_world` gravity frame — which grants CONTACT/ON_WALKABLE from a real touch — into the placement transaction. The legacy App-era force-seed (`Contact\|OnWalkable\|Active` in `PlayerMovementController.SetPositionCore`) still RUNS during publication-candidate preparation and is then OVERWRITTEN by the faithful activation commit + settle (it was never deleted). Caveat (review minor M2): the settler commits `settle.Position` but discards `settle.CellId` — a settle whose few-cm sweep crosses a cell boundary keeps the placement cell until the next resolve corrects it (inherited #270 semantics; ISSUES entry filed) | `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs` (`SettleFirstEntryGroundContact`); `src/AcDream.Core/Physics/SpawnPlacementSettler.cs` (`TrySettle`); overwritten seed `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`SetPositionCore`) | Timing compression only: contact comes exclusively from the sweep's real touch (no caller-bool seeding, no forced transients), an airborne spawn stays genuinely airborne, and the overwritten force-seed leaves no observable residue past the activation commit — the committed state is exactly what retail's first gravity frame produces | A settle crossing a cell boundary reports the stale placement cell for the frames before the next resolve; a future reader trusting `SetPositionCore`'s "treat as grounded" seed comment could reintroduce the Contact-without-plane state the landing family calls unrepresentable | `CPhysicsObj::enter_world` 0x00516170; `SmartBox::HandleCreateObject` 0x00454C80 | | AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** Executor Position-continuation merges never directly commit residency: `ApplyPositionAction` refreshes `canonical.Snapshot.Position` with the retained wire pose but withholds the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`); only a Runtime `SetPosition` commit (the continuation's own classified placement) or a later simulation full-cell commit may change residency. The LEGACY immediate-apply path's `RefreshSnapshot(canonical, snapshot, refreshPosition: acceptedPosition)` (`RuntimeEntityObjectLifetime.cs:1338`) still derives `FullCellId` from bare wire acceptance - that coarser rule is part of the AP-1 divergence this campaign is removing, not something this row blesses. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment) | Matches retail exactly: `HandleReceivedPosition` never writes a resident cell - `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. | If a future change passes `refreshPosition: true` here, a wire Position would make a cellless canonical body resident without any placement/collision commit - the classic AP-1-shaped bug this campaign exists to close. | `SmartBox::HandleReceivedPosition` 0x00453FD0; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment | --- diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 2702f7b9..84d04dca 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -500,6 +500,79 @@ internal sealed class SessionPlayerCompositionPhase d.PlayerIdentity, dormantLiveEntities, d.Options.DumpLiveSpawns ? d.Log : null); + // C3c: the graphical first-entry drive controller — walks every + // initial-Create residence through its Runtime conductor with the + // production prepared-collision source, the live movement-skill + // options, and the truthful local-player activation preparation + // (authored-cylinder radius/height with the legacy fallbacks, and + // the shadow disposition read from the exact shadow registry the + // activation commit validates against). + IPreparedCollisionSource firstEntryCollision = + content.PreparedAssets as IPreparedCollisionSource + ?? throw new NotSupportedException( + "Production prepared assets must expose the matching " + + "prepared-collision catalog."); + // C3c-R1 review F9: the provider runs on EVERY drive pump while the + // entity's conductor is still yielding, and the Setup cylinder per + // incarnation is immutable (SourceGfxObjOrSetupId and the spawn + // record's ObjScale are fixed at Create) — cache the resolved + // world-entity lookup + GetSetupCylinder per pending incarnation + // (keyed by the incarnation-unique local id; an unresolved/default + // result is NOT cached so late hydration still upgrades it). The + // shadow disposition deliberately stays live: shadow registration + // can land between pumps and the activation commit validates the + // disposition against the exact registry. + uint firstEntryCylinderLocalId = 0u; + float firstEntryCylinderRadius = 0f; + float firstEntryCylinderHeight = 0f; + var firstEntryDrive = new RuntimeFirstEntryDriveController( + d.EntityObjects, + d.Runtime.Clock, + firstEntryCollision, + () => PlayerMovementConstructionOptions.From( + d.Runtime.CharacterOwner.MovementSkills.Snapshot), + record => + { + float radius = 0.48f; + float height = 1.835f; + uint localId = record.Key?.LocalEntityId ?? 0u; + if (localId != 0u && localId == firstEntryCylinderLocalId) + { + radius = firstEntryCylinderRadius; + height = firstEntryCylinderHeight; + } + else if (live.LiveEntities.TryGetWorldEntity( + record.ServerGuid, + out WorldEntity? playerEntity) + && playerEntity is not null) + { + (float setupRadius, float setupHeight) = + d.MotionBindings.GetSetupCylinder( + record.ServerGuid, + playerEntity); + if (setupRadius >= 0.05f) + { + radius = setupRadius; + height = setupHeight; + if (localId != 0u) + { + firstEntryCylinderLocalId = localId; + firstEntryCylinderRadius = radius; + firstEntryCylinderHeight = height; + } + } + } + bool hasAuthoredShadow = record.Key is { } key + && d.PhysicsEngine.ShadowObjects.HasLogicalOwner( + key.LocalEntityId); + return new RuntimeLocalPlayerPhysicsActivationPreparation( + radius, + height, + hasAuthoredShadow + ? RuntimeLocalPlayerShadowDisposition + .RegisteredAuthoredPayload + : RuntimeLocalPlayerShadowDisposition.ProvenShapeless); + }); var hydration = new LiveEntityHydrationController( live.LiveEntities, d.EntityObjects, @@ -517,7 +590,8 @@ internal sealed class SessionPlayerCompositionPhase d.PlayerIdentity, deletion, dormantLiveEntities, - d.Options.DumpLiveSpawns ? d.Log : null); + d.Options.DumpLiveSpawns ? d.Log : null, + firstEntryDrive); bindings.Adopt( "landblock-loaded hydration", live.LandblockLoaded.Bind(hydration)); @@ -884,7 +958,8 @@ internal sealed class SessionPlayerCompositionPhase d.RemoteMovementObservations, live.RenderSceneShadow, live.PlacementProjection, - placementProjectionRetry), + placementProjectionRetry, + firstEntryDrive), liveSessionCommands, d.Log); LiveSessionHost sessionHost = sessionRuntimeFactory.Create( diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs index cc158601..f75e80f0 100644 --- a/src/AcDream.App/Input/PlayerModeAutoEntry.cs +++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs @@ -2,6 +2,7 @@ using System; using AcDream.App.Net; using AcDream.App.Streaming; using AcDream.App.World; +using AcDream.Runtime.Physics; namespace AcDream.App.Input; @@ -83,7 +84,24 @@ internal sealed class LivePlayerModeAutoEntryContext public bool IsPlayerEntityPresent => _liveEntities.ContainsWorldEntity(_identity.ServerGuid); - public bool IsPlayerControllerReady => true; + /// + /// C3c-F2: post-flip the movement controller is Runtime-owned, so this + /// precondition has to report the Runtime first-entry conductor's commit + /// — exactly what PlayerModeController.TryEnter requires. It was + /// the constant true, which was harmless only while entry itself + /// CONSTRUCTED the controller and therefore could not fail on it. After + /// the flip a not-yet-committed conductor made entry return false, and + /// because this guard is a one-shot that disarms before invoking (and + /// completes the world reveal + /// unconditionally), a single early attempt permanently sealed the reveal + /// with the player never in world. + /// + public bool IsPlayerControllerReady => + _playerMode.Controller is { IsRuntimePublished: true } + && _liveEntities.TryGetRecord( + _identity.ServerGuid, + out LiveEntityRecord record) + && record.PhysicsHost is EntityPhysicsHost; public bool IsWorldReady => _liveEntities.TryGetSnapshot( diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index 732684d7..0080c8ba 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -164,7 +164,9 @@ internal sealed class PlayerModeController : try { RetireApproachLifetime(); } catch (Exception error) { failures.Add(error); } _mode.IsPlayerMode = false; - _controllerSlot.Controller = null; + // C3c: the movement controller is Runtime-owned — player-mode exit + // detaches presentation only; the publication lifecycle (generation + // reset/teardown) owns the controller's retirement. _hostSlot.Host = null; _chase.Legacy = null; _chase.Retail = null; @@ -201,7 +203,9 @@ internal sealed class PlayerModeController : try { RetireApproachLifetime(); } catch (Exception error) { failures.Add(error); } _mode.ResetSession(); - _controllerSlot.Controller = null; + // C3c: the Runtime generation reset retires the controller through + // RuntimeLocalPlayerMovementState.ResetSession; App detaches + // presentation only. _hostSlot.Host = null; _chase.Legacy = null; _chase.Retail = null; @@ -233,6 +237,19 @@ internal sealed class PlayerModeController : return false; } + // C3c: player mode attaches presentation to the Runtime-published + // controller/host; until the first-entry conductor commits them, + // entry simply retries on a later frame. + if (_controllerSlot.Controller is not { } publishedController + || !publishedController.IsRuntimePublished + || playerRecord.PhysicsHost is not EntityPhysicsHost) + { + Console.WriteLine( + $"live: {loggingTag} — Runtime first-entry controller for " + + $"0x{playerGuid:X8} not committed yet"); + return false; + } + BuildControllerAndCamera( loggingTag, playerGuid, @@ -247,6 +264,32 @@ internal sealed class PlayerModeController : WorldEntity playerEntity, LiveEntityRecord playerRecord) { + // C3c route-1 flip: the movement controller, physics body, host, and + // committed placement are Runtime-owned — constructed and activated + // by the first-entry conductor's publication chain before player + // mode can enter. This method attaches only App presentation + // (approach lifetime, animation bindings, camera, shadow, host + // slot). A failure here rolls back camera/shadow ONLY and never + // touches Runtime. C3c-R1 review F8: auto-entry does NOT retry a + // throw from this attach — PlayerModeAutoEntry.TryEnter disarms its + // one-shot BEFORE invoking EnterPlayerMode, so an exception here + // burns the shot; recovery is the manual Tab entry (or a session + // reset re-arming the trigger). + if (_controllerSlot.Controller is not { } controller + || !controller.IsRuntimePublished) + { + throw new InvalidOperationException( + $"Player mode ({loggingTag}) requires the Runtime-published " + + "local movement controller; the first-entry conductor has " + + "not committed it yet."); + } + if (playerRecord.PhysicsHost is not EntityPhysicsHost playerHost) + { + throw new InvalidOperationException( + $"Player mode ({loggingTag}) requires the Runtime-committed " + + "local physics host."); + } + IPlayerApproachCompletionSink approachLifetime = _approachCompletions.BeginControllerLifetime(); bool lifetimeCommitted = false; @@ -256,46 +299,11 @@ internal sealed class PlayerModeController : LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture(); try { - var controller = new PlayerMovementController( - _physics, - playerRecord.ObjectClock, - PlayerMovementConstructionOptions.From(_skills.Snapshot)); - controller.ApplyPhysicsState(playerRecord.FinalPhysicsState); - - // Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one - // MoveToManager facade over the local CPhysicsObj seams. - PlayerMovementController capturedController = controller; - EntityPhysicsHost playerHost = null!; - controller.Movement.MoveToFactory = () => + // Approach-completion presentation rides the Runtime-owned + // MoveToManager (created by the publication chain's own + // MakeMoveToManager). + if (controller.MoveTo is { } moveTo) { - var moveTo = new MoveToManager( - capturedController.Motion, - stopCompletely: () => - capturedController.StopCompletelyAtPhysicsObjectBoundary(), - getPosition: () => new Position( - capturedController.CellId, - capturedController.Position, - capturedController.BodyOrientation), - getHeading: () => MoveToMath.HeadingFromYaw(capturedController.Yaw), - setHeading: (heading, _) => capturedController.Yaw = - MoveToMath.YawFromHeading(heading), - getOwnRadius: () => _motionBindings.GetSetupCylinder( - playerGuid, - playerEntity).Radius, - getOwnHeight: () => _motionBindings.GetSetupCylinder( - playerGuid, - playerEntity).Height, - contact: () => capturedController.BodyInContact, - isInterpolating: () => false, - getVelocity: () => capturedController.BodyVelocity, - getSelfId: () => playerGuid, - setTarget: (context, target, radius, quantum) => - playerHost.SetTarget(context, target, radius, quantum), - clearTarget: playerHost.ClearTarget, - getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(), - setTargetQuantum: playerHost.TargetManager.SetTargetQuantum, - curTime: () => capturedController.SimTimeSeconds); - moveTo.MoveToComplete = error => { if (PhysicsDiagnostics.ProbeAutoWalkEnabled) @@ -307,75 +315,8 @@ internal sealed class PlayerModeController : }; moveTo.MoveToCancelled = error => approachLifetime.PublishCancellation(error); - moveTo.StickTo = (target, radius, height) => - playerHost.PositionManager.StickTo(target, radius, height); - moveTo.Unstick = () => playerHost.PositionManager.UnStick(); - return moveTo; - }; - - MovementManager exactMovement = controller.Movement; - var configuredHost = new EntityPhysicsHost( - playerGuid, - getPosition: () => new Position( - playerRecord.FullCellId, - playerRecord.WorldEntity?.Position ?? capturedController.Position, - capturedController.BodyOrientation), - getVelocity: () => capturedController.BodyVelocity, - getRadius: () => _motionBindings.GetSetupCylinder( - playerGuid, - playerEntity).Radius, - inContact: () => capturedController.BodyInContact, - minterpMaxSpeed: () => capturedController.Motion.GetAdjustedMaxSpeed(), - curTime: () => capturedController.SimTimeSeconds, - physicsTimerTime: () => capturedController.SimTimeSeconds, - getObjectA: _motionBindings.ResolvePhysicsHost, - handleUpdateTarget: info => - { - if (PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - Console.WriteLine( - $"[autowalk-target] object=0x{info.ObjectId:X8} " - + $"status={info.Status} context={info.ContextId} " - + $"target=({info.TargetPosition.Frame.Origin.X:F2}," - + $"{info.TargetPosition.Frame.Origin.Y:F2}," - + $"{info.TargetPosition.Frame.Origin.Z:F2})"); - } - exactMovement.HandleUpdateTarget(info); - }, - interruptCurrentMovement: () => exactMovement.CancelMoveTo( - WeenieError.ActionCancelled)); - playerHost = EntityPhysicsHostComposition.SelectStableHostWithoutRebind( - _liveEntities, - playerRecord, - configuredHost); - - exactMovement.MakeMoveToManager(); - controller.Motion.UnstickFromObject = () => - playerHost.PositionManager.UnStick(); - controller.PositionManager = playerHost.PositionManager; - controller.Motion.InterruptCurrentMovement = () => - { - if (PhysicsDiagnostics.ProbeAutoWalkEnabled - && exactMovement.IsMovingTo()) - { - Console.WriteLine("[autowalk-end] reason=interrupt"); - } - exactMovement.CancelMoveTo(WeenieError.ActionCancelled); - }; - - if (RuntimeMovementSkillProjection.ApplyTo( - _skills, - controller)) - { - Console.WriteLine( - $"live: {loggingTag} — applied server skills " - + $"run={_skills.RunSkill} jump={_skills.JumpSkill}"); } - ApplyStepHeights(controller, playerEntity, playerGuid); - uint initialCellId = ResolveInitialCell(playerGuid, playerEntity); - - Action? drainPriorAnimationQueue = null; if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation) && animation.Sequencer is { } sequencer) { @@ -392,51 +333,9 @@ internal sealed class PlayerModeController : sequencer.Manager.CheckForCompletedMotions; controller.Motion.DefaultSink = new MotionTableDispatchSink(sequencer); - drainPriorAnimationQueue = sequencer.Manager.HandleEnterWorld; + sequencer.Manager.HandleEnterWorld(); } - // Retail CPhysicsObj owns CMotionInterp and CPartArray throughout - // construction. Our split owners preserve that lifetime with a - // narrow preparation lease: SetPosition's synchronous type-5 - // completion reaches this candidate MotionInterpreter, while the - // public controller slot remains unpublished until every other - // player-mode edge has prepared successfully. - using IDisposable motionPreparation = - _controllerSlot.BeginMotionPreparation( - controller, - drainPriorAnimationQueue); - - ResolveResult initial = _physics.Resolve( - playerEntity.Position, - initialCellId, - Vector3.Zero, - 100f); - var (placementRadius, placementHeight) = - _motionBindings.GetSetupCylinder(playerGuid, playerEntity); - if (placementRadius < 0.05f) - { - placementRadius = 0.48f; - placementHeight = 1.835f; - } - - ResolveResult placement = _physics.ResolvePlacement( - initial.Position, - initial.CellId, - placementRadius, - placementHeight, - controller.StepUpHeight, - controller.StepDownHeight, - ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, - playerEntity.Id); - if (placement.Ok) - initial = placement; - - controller.PreparePositionForCommit( - initial.Position, - initial.CellId, - CellLocalForSeed(initial.Position, initial.CellId)); - controller.SetBodyOrientation(playerEntity.Rotation); - var legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect }; var retailCamera = new RetailChaseCamera { @@ -446,44 +345,15 @@ internal sealed class PlayerModeController : cameraAttempted = true; _camera.EnterChaseMode(legacyCamera, retailCamera); - EntityPhysicsHost stableAfterCamera = - EntityPhysicsHostComposition.SelectStableHostWithoutRebind( - _liveEntities, - playerRecord, - configuredHost); - if (!ReferenceEquals(stableAfterCamera, playerHost)) - { - throw new InvalidOperationException( - "The local physics host changed during chase-camera activation."); - } - shadowAttempted = true; _shadow.SyncPose( playerEntity, - initial.Position, + controller.Position, playerEntity.Rotation, - initial.CellId, + controller.CellId, force: true); - // Publish the incarnation-stable CPhysicsObj delegates only after all - // DAT, placement, shadow, and camera preparation has succeeded. A - // late preparation failure therefore cannot expose an abandoned - // controller through LiveEntityRecord.PhysicsHost. - EntityPhysicsHost publishedHost = EntityPhysicsHostComposition.InstallOrRebind( - _liveEntities, - playerRecord, - configuredHost); - if (!ReferenceEquals(publishedHost, playerHost)) - { - throw new InvalidOperationException( - "The local physics host changed between preparation and commit."); - } - - playerEntity.SetPosition(initial.Position); - playerEntity.ParentCellId = initial.CellId; - controller.CommitPreparedPosition(); - _hostSlot.Host = publishedHost; - _controllerSlot.Controller = controller; + _hostSlot.Host = playerHost; _chase.Legacy = legacyCamera; _chase.Retail = retailCamera; _mode.IsPlayerMode = true; @@ -505,8 +375,16 @@ internal sealed class PlayerModeController : catch (Exception cleanupError) { failures.Add(cleanupError); } } + // C3c: presentation-only rollback. The Runtime-published + // controller/body/host stay live — retail has no entry-flow + // rollback. C3c-R1 review F8: this rethrow is NOT retried by + // auto-entry — the one-shot trigger disarms before invoking + // (PlayerModeAutoEntry.TryEnter), and the throw also propagates + // out of the auto-entry context before its world-reveal + // Complete() call. After a failed attach the player re-enters + // via the manual Tab path (or a session reset re-arms the + // trigger). _mode.IsPlayerMode = false; - _controllerSlot.Controller = null; _hostSlot.Host = null; _chase.Legacy = null; _chase.Retail = null; @@ -532,92 +410,4 @@ internal sealed class PlayerModeController : _approachCompletions.RetireControllerLifetime(lifetime); } - private void ApplyStepHeights( - PlayerMovementController controller, - WorldEntity playerEntity, - uint playerGuid) - { - if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) - { - DatReaderWriter.DBObjs.Setup? setup; - lock (_datLock) - setup = _dats.Get( - playerEntity.SourceGfxObjOrSetupId); - if (setup is not null) - _collisionAssets.CacheSetup( - playerEntity.SourceGfxObjOrSetupId, - setup); - // TS-46 (2026-07-30): CPartArray::GetStepUpHeight/GetStepDownHeight - // (0x005180d0/0x005180f0) return setup->step_up_height * this->scale - // — apply the same ObjScale multiply the remote/ordinary paths now - // use (LiveEntityMotionRuntimeController.GetSetupMoverShape), for - // parity on a non-1.0-scale player (a rare but real case — e.g. a - // disguise/size-changing effect). Human ObjScale is 1.0 in the - // overwhelming common case, so this is a no-op there. - float scale = - _liveEntities.Snapshots.TryGetValue(playerGuid, out var sp) - && sp.ObjScale is { } objScale && objScale > 0f - ? objScale - : (playerEntity.Scale > 0f ? playerEntity.Scale : 1f); - controller.StepUpHeight = setup is { StepUpHeight: > 0f } - ? setup.StepUpHeight * scale - : 0.4f; - controller.StepDownHeight = setup is { StepDownHeight: > 0f } - ? setup.StepDownHeight * scale - : 0.4f; - // TS-46 (2026-07-30): the Setup's own ≤2-sphere list, verbatim — - // retail CPhysicsObj::transition (0x00512dc0) seeds the sweep - // from CPartArray::GetSphere, not a (radius, height) capsule - // reconstruction. Empty (no Setup, or a Setup with no sphere - // rows) leaves SphereList at its default empty value, which - // ResolveWithTransition treats as "use the legacy scalar - // reconstruction." - controller.SphereList = setup?.Spheres is { Count: > 0 } spheres - ? spheres - .Select(s => new FlatCollisionSphere(s.Origin, s.Radius)) - .ToImmutableArray() - : ImmutableArray.Empty; - Console.WriteLine( - $"physics: player step heights — StepUp={controller.StepUpHeight:F3} m " - + $"(Setup.StepUpHeight={(setup?.StepUpHeight ?? 0f):F3}), " - + $"StepDown={controller.StepDownHeight:F3} m " - + $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3}), " - + $"Spheres={controller.SphereList.Length}"); - return; - } - - controller.StepUpHeight = 0.4f; - controller.StepDownHeight = 0.4f; - controller.SphereList = ImmutableArray.Empty; - Console.WriteLine( - "physics: player step heights — defaulting to 0.4 m (no setup dat)"); - } - - private uint ResolveInitialCell(uint playerGuid, WorldEntity playerEntity) - { - if (_liveEntities.Snapshots.TryGetValue(playerGuid, out var spawn) - && spawn.Position is { LandblockId: not 0u } position) - { - return position.LandblockId; - } - - int landblockX = _origin.CenterX - + (int)MathF.Floor(playerEntity.Position.X / 192f); - int landblockY = _origin.CenterY - + (int)MathF.Floor(playerEntity.Position.Y / 192f); - return ((uint)landblockX << 24) - | ((uint)landblockY << 16) - | 0x0001u; - } - - private Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId) - { - int landblockX = (int)((cellId >> 24) & 0xFFu); - int landblockY = (int)((cellId >> 16) & 0xFFu); - var origin = new Vector3( - (landblockX - _origin.CenterX) * 192f, - (landblockY - _origin.CenterY) * 192f, - 0f); - return worldPosition - origin; - } } diff --git a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs index e2411320..518a2ae8 100644 --- a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs +++ b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs @@ -15,6 +15,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting _createSubscription; private readonly Func _generation; private readonly RuntimePlacementProjectionRetrySlot _retries; + private readonly RuntimeFirstEntryDriveController? _firstEntry; private RuntimePlacementProjectionSubscription? _subscription; private IDisposable? _retryLease; private bool _attachStarted; @@ -25,7 +26,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting ILiveSessionEventRouting events, GameRuntime runtime, IRuntimePlacementProjectionSink placements, - RuntimePlacementProjectionRetrySlot retries) + RuntimePlacementProjectionRetrySlot retries, + RuntimeFirstEntryDriveController? firstEntry = null) : this( events, () => new RuntimePlacementProjectionSubscription( @@ -33,7 +35,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting placements, retryPendingOnSubscribe: false), () => runtime.Generation, - retries) + retries, + firstEntry) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(placements); @@ -43,7 +46,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting ILiveSessionEventRouting events, Func createSubscription, Func generation, - RuntimePlacementProjectionRetrySlot retries) + RuntimePlacementProjectionRetrySlot retries, + RuntimeFirstEntryDriveController? firstEntry = null) { _events = events ?? throw new ArgumentNullException(nameof(events)); _createSubscription = createSubscription @@ -51,6 +55,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting _generation = generation ?? throw new ArgumentNullException(nameof(generation)); _retries = retries ?? throw new ArgumentNullException(nameof(retries)); + _firstEntry = firstEntry; } public void Attach() @@ -60,6 +65,10 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting return; _attachStarted = true; + // C3c-R1 review F6: assert (not assume) that the prior route + // detached — session reset precedes a new route — before this route + // takes ownership of the shared drive controller's tracked entries. + _firstEntry?.AttachRoute(this); _events.Attach(); RuntimePlacementProjectionSubscription? subscription = null; @@ -67,9 +76,20 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting try { subscription = _createSubscription(); + RuntimePlacementProjectionSubscription boundSubscription = + subscription; retryLease = _retries.BindOwned( _generation(), - subscription.RetryPending); + // C3c: drive pending first-entry sequences before + // republishing the pending FIFO head — a conductor's own + // Advance is what consumes conductor-owned receipts, and the + // subsequent RetryPending lets the presentation sink apply + // whatever new head the drive surfaced. + () => + { + _firstEntry?.DriveAll(); + return boundSubscription.RetryPending(); + }); _subscription = subscription; _retryLease = retryLease; _ = subscription.RetryPending(); @@ -91,6 +111,12 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting // can therefore never retry a retired generation or disposed route. Interlocked.Exchange(ref _retryLease, null)?.Dispose(); Interlocked.Exchange(ref _subscription, null)?.Dispose(); + // C3c: the drive controller's tracked entries die with this exact + // session route; Runtime's own retirement/session-clear fan-out owns + // conductor/residence convergence independently. C3c-R1 review F6: + // route-scoped — a route that never attached cannot clear a live + // route's entries. + _firstEntry?.DetachRoute(this); if (!_eventsDisposed) { _events.Dispose(); diff --git a/src/AcDream.App/Net/LiveMovementStatsApplier.cs b/src/AcDream.App/Net/LiveMovementStatsApplier.cs new file mode 100644 index 00000000..f1afc1ec --- /dev/null +++ b/src/AcDream.App/Net/LiveMovementStatsApplier.cs @@ -0,0 +1,86 @@ +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.Net; + +/// +/// C3c-F1 (2026-08-02): the App half of the movement-stats application +/// seam. Every server stat recompute (skills, burden, stamina, PK status — +/// the character-bindings OnSkillsUpdated/OnMovementStatsUpdated +/// callbacks) routes through +/// ; +/// App holds no controller reference and performs no direct configuration +/// mutation. A recompute displaced past session teardown (the post-logout +/// inbound-Create ingest chain that crashed the connected lifecycle gate) +/// observes a typed dropped outcome and is logged under the existing +/// player diagnostics instead of faulting the session. +/// +/// +/// Stuck-cast fix (2026-07-30): retail fires +/// CPhysicsObj::report_exhaustion from exactly ONE site — +/// CommandInterpreter::HandleExhaustion (0x006b3c70), a +/// notification-handler vtable slot invoked on the stamina-exhaustion +/// EVENT — not on every vitals refresh. The P1 wiring called +/// ReportExhaustion() on EVERY movement-stats application +/// (every stamina regen/drain tick), and each call re-dispatches the +/// current movement state through the animation sink — truncating any +/// in-flight action animation (cast gestures wedged mid-play; the +/// diagnostic session showed 490 spurious stance re-queues). The +/// re-apply fires only when the exhausted state (stamina == 0) +/// actually TRANSITIONS, matching retail's event semantics. Skill/ +/// burden changes still reach PlayerWeenie immediately through +/// the owner seam — the next natural dispatch picks up the new rates, +/// exactly as retail. The edge is observed for dormant applications too +/// (the event fired; a player not yet in world has no movement to +/// re-dispatch, and activation starts movement from the already-current +/// stamina gate), but dispatched only on a live controller. +/// +internal sealed class LiveMovementStatsApplier( + RuntimeLocalPlayerMovementState movement, + RuntimeMovementSkillState skills, + Action log) +{ + private readonly RuntimeLocalPlayerMovementState _movement = movement + ?? throw new ArgumentNullException(nameof(movement)); + private readonly RuntimeMovementSkillState _skills = skills + ?? throw new ArgumentNullException(nameof(skills)); + private readonly Action _log = log + ?? throw new ArgumentNullException(nameof(log)); + private readonly StaminaExhaustionEdgeTracker _staminaExhaustion = new(); + + /// + /// Forgets the retiring character/session exhaustion baseline; the next + /// generation's first sample must not synthesize an edge. + /// + public void Reset() => _staminaExhaustion.Reset(); + + public RuntimeMovementStatsApplication Apply(string reason) + { + RuntimeMovementStatsApplication outcome = + _movement.ApplyCharacterMovementStats(_skills); + switch (outcome) + { + case RuntimeMovementStatsApplication.DroppedNoController: + case RuntimeMovementStatsApplication.DroppedIncompleteSnapshot: + // Byte-identical to the pre-F1 ApplyTo=false silent skip. + return outcome; + case RuntimeMovementStatsApplication.DroppedDisplacedController: + _log( + $"player: dropped displaced movement {reason} — the " + + "Runtime movement controller is terminal"); + return outcome; + } + + RuntimeMovementSkillSnapshot snapshot = _skills.Snapshot; + if (_staminaExhaustion.Observe(snapshot.CurrentStamina) + && outcome is RuntimeMovementStatsApplication.AppliedLive) + { + _movement.ReportExhaustion(); + } + + _log( + $"player: applied server movement {reason} " + + $"run={snapshot.RunSkill} jump={snapshot.JumpSkill} " + + $"burden={snapshot.Burden:F2} stamina={snapshot.CurrentStamina}"); + return outcome; + } +} diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index a300012e..b9103078 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -85,7 +85,8 @@ internal sealed record LiveSessionWorldRuntime( RemoteMovementObservationTracker RemoteMovementObservations, RenderSceneShadowRuntime? RenderSceneShadow, RuntimePlacementPresentationSink PlacementProjection, - RuntimePlacementProjectionRetrySlot PlacementRetries); + RuntimePlacementProjectionRetrySlot PlacementRetries, + RuntimeFirstEntryDriveController FirstEntryDrive); /// /// Builds the exact per-generation route/reset graph for the canonical live @@ -100,7 +101,7 @@ internal sealed class LiveSessionRuntimeFactory private readonly LiveSessionWorldRuntime _world; private readonly LiveSessionCommandSurface _commands; private readonly Action _log; - private readonly StaminaExhaustionEdgeTracker _staminaExhaustion = new(); + private readonly LiveMovementStatsApplier _movementStats; public LiveSessionRuntimeFactory( LiveSessionPlayerRuntime player, @@ -119,6 +120,12 @@ internal sealed class LiveSessionRuntimeFactory _world = world ?? throw new ArgumentNullException(nameof(world)); _commands = commands ?? throw new ArgumentNullException(nameof(commands)); _log = log ?? throw new ArgumentNullException(nameof(log)); + // C3c-F1: stat recomputes route through the Runtime movement owner's + // typed application seam; App keeps zero direct controller mutations. + _movementStats = new LiveMovementStatsApplier( + _player.Controller, + _domain.Character.MovementSkills, + _log); } public LiveSessionHost Create( @@ -192,7 +199,7 @@ internal sealed class LiveSessionRuntimeFactory private void ResetPlayerPresentation() { - _staminaExhaustion.Reset(); + _movementStats.Reset(); _interaction.PlayerMode.ResetSession(); _world.SpawnClaims.Reset(); } @@ -254,7 +261,8 @@ internal sealed class LiveSessionRuntimeFactory route, _domain.Runtime, _world.PlacementProjection, - _world.PlacementRetries); + _world.PlacementRetries, + _world.FirstEntryDrive); } private LiveInventorySessionBindings CreateInventoryBindings() => new( @@ -284,59 +292,19 @@ internal sealed class LiveSessionRuntimeFactory _domain.Actions.Combat, _domain.Character, ResolveSkillFormulaBonus: skillCreditResolver.Resolve, - OnSkillsUpdated: (runSkill, jumpSkill) => ApplyMovementStats("skills"), + OnSkillsUpdated: (runSkill, jumpSkill) => + _movementStats.Apply("skills"), OnConfirmationRequest: request => _ui.RetailUi?.HandleConfirmationRequest(request), OnConfirmationDone: done => _ui.RetailUi?.HandleConfirmationDone(done), ClientTime: ClientTimerNow, // Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes - // reactively re-apply to the live controller through the SAME - // seam skills already used, then wire the previously-dead - // ReportExhaustion() R3-W4 seam so movement re-evaluates - // immediately (pseudocode doc §8/§9). - OnMovementStatsUpdated: () => ApplyMovementStats("stats")); - } - - /// - /// Re-applies the current - /// snapshot (skills/burden/stamina) to the live player controller. - /// - /// - /// Stuck-cast fix (2026-07-30): retail fires - /// CPhysicsObj::report_exhaustion from exactly ONE site — - /// CommandInterpreter::HandleExhaustion (0x006b3c70), a - /// notification-handler vtable slot invoked on the stamina-exhaustion - /// EVENT — not on every vitals refresh. The P1 wiring called - /// ReportExhaustion() on EVERY movement-stats application - /// (every stamina regen/drain tick), and each call re-dispatches the - /// current movement state through the animation sink — truncating any - /// in-flight action animation (cast gestures wedged mid-play; the - /// diagnostic session showed 490 spurious stance re-queues). The - /// re-apply now fires only when the exhausted state (stamina == 0) - /// actually TRANSITIONS, matching retail's event semantics. Skill/ - /// burden changes still reach immediately - /// via — the next - /// natural dispatch picks up the new rates, exactly as retail. - /// - private void ApplyMovementStats(string reason) - { - PlayerMovementController? controller = _player.Controller.Controller; - if (!RuntimeMovementSkillProjection.ApplyTo( - _domain.Character.MovementSkills, - controller)) - { - return; - } - - RuntimeMovementSkillSnapshot snapshot = _domain.Character.MovementSkills.Snapshot; - if (_staminaExhaustion.Observe(snapshot.CurrentStamina)) - controller!.Motion.ReportExhaustion(); - - _log( - $"player: applied server movement {reason} " - + $"run={snapshot.RunSkill} jump={snapshot.JumpSkill} " - + $"burden={snapshot.Burden:F2} stamina={snapshot.CurrentStamina}"); + // reactively re-apply through the SAME seam skills already used + // (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime + // movement owner's typed application entry — see + // LiveMovementStatsApplier. + OnMovementStatsUpdated: () => _movementStats.Apply("stats")); } private LiveSessionCommandBindings CreateCommandBindings( diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 870e9d47..a2b4cafd 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -190,7 +190,7 @@ internal sealed class LiveEntityNetworkUpdateController // retail's first frame would (position snapped onto the floor, // contact plane + CONTACT/ON_WALKABLE committed below). A sweep that // finds no floor (true airborne spawn) leaves the body airborne. - if (!RemoteSpawnPlacementSettler.TrySettle( + if (!AcDream.Core.Physics.SpawnPlacementSettler.TrySettle( _physicsEngine, remote.Body, worldPos, @@ -1003,7 +1003,20 @@ internal sealed class LiveEntityNetworkUpdateController return; } if (parsed.Guid == _playerServerGuid) - _playerController?.ApplyPhysicsState(record.FinalPhysicsState); + { + // C3c-F1 (2026-08-02): route through the owner's + // lifecycle-deciding typed entry. The publication lifecycle — + // not this inbound handler — decides whether the push lands: + // a dormant first-entry controller drops it (the activation + // transaction re-reads the same canonical FinalPhysicsState + // itself; the accepted SetState is queued behind the initial + // residence so this value is unchanged), and a terminal + // controller treats it as a displaced push instead of faulting + // the session (the second connected-gate crash chain, + // logs/connected-world-gate-20260802-125907). + _ = _playerController?.ApplyServerPhysicsState( + record.FinalPhysicsState); + } if (!_liveEntities.TryGetWorldEntity(parsed.Guid, out var entity)) return; diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs index 3624f934..440c2900 100644 --- a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -709,6 +709,17 @@ internal sealed class DatLiveEntityProjectionMaterializer } bool createdProjection = false; + // C3c route-1 flip: a fresh world Create materializes presentation- + // only — the sidecar exists but stays non-spatial until the + // residence-driven Runtime placement's completion receipt (or, for a + // record whose residence already completed before its sidecar could + // exist, the self-projection below). An already-materialized record + // keeps its sticky residence so unflipped same-incarnation + // transitions (an equipped child dropping to world) stay on their + // legacy path by construction. + LiveEntityMaterializationResidence residence = + retainedRecord?.MaterializationResidence + ?? LiveEntityMaterializationResidence.AwaitRuntimePlacement; WorldEntity? entity = _runtime.MaterializeLiveEntity( expectedCanonical, spawn.Position!.Value.LandblockId, @@ -734,7 +745,8 @@ internal sealed class DatLiveEntityProjectionMaterializer }, LiveEntityProjectionKind.World, initializeProjection: record => record.EffectProfile = profile, - out LiveEntityRecord? expectedRecord); + out LiveEntityRecord? expectedRecord, + residence); if (entity is null || expectedRecord is null || !_runtime.IsCurrentCreateIntegration( @@ -744,6 +756,27 @@ internal sealed class DatLiveEntityProjectionMaterializer { return false; } + if (residence is LiveEntityMaterializationResidence.AwaitRuntimePlacement + && expectedCanonical.FullCellId != 0u + && !_runtime.HasActiveInitialCreateResidence(expectedCanonical)) + { + // The residence-driven placement already committed before this + // sidecar existed (a deferred-parent child replayed during its + // parent's drain, or a recovery re-materialization) — its + // completion receipt is gone, so presentation self-projects from + // the committed canonical state through the presentation-only + // bucket path. + if (!_runtime.RebucketLiveEntity( + spawn.Guid, + expectedCanonical.FullCellId) + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + } if (!createdProjection) { diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index e408afa8..1be511df 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -524,6 +524,22 @@ public sealed class EquippedChildRenderController : IDisposable { return false; } + // C3c: a world-created (residence-managed) child converting to an + // attached projection exits the residence-managed presentation path + // at this same-incarnation kind transition. Attached children have + // no Runtime placement, and the flip's sticky-residence rule expects + // equipped children to carry LegacyImmediate so a later drop back to + // world stays on the legacy path by construction + // (DatLiveEntityProjectionMaterializer.MaterializeProjection's + // retained-residence comment). C3c-R1 review F1: the conversion is + // the owner's explicit API, which asserts no initial-create + // residence is still active, rather than a direct field write here. + if (retainedChild is not null + && _liveEntities.IsCurrentRecord(retainedChild)) + { + _liveEntities.ConvertMaterializationResidenceToLegacyImmediate( + retainedChild); + } WorldEntity? entity = _liveEntities.MaterializeLiveEntity( childCanonical, parentCellId, diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index 2963bbb4..47cbf35d 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -4,6 +4,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.World; using AcDream.Runtime.Entities; +using AcDream.Runtime.Session; namespace AcDream.App.World; @@ -180,6 +181,14 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded private readonly LiveEntityDeletionController _deletion; private readonly DormantLiveEntityStore _dormant; private readonly Action? _diagnostic; + /// + /// C3c: the graphical first-entry drive pump — pumped at the end of each + /// Create transaction so a fresh residence drives its conductor + /// synchronously (retail HandleCreateObject runs enter_world inline). + /// Optional so presentation-free hydration tests keep constructing this + /// controller without one. + /// + private readonly RuntimeFirstEntryDriveController? _firstEntry; private readonly Dictionary _projectionOperations = new(ReferenceEqualityComparer.Instance); @@ -203,7 +212,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded ILocalPlayerIdentitySource identity, LiveEntityDeletionController deletion, DormantLiveEntityStore? dormant = null, - Action? diagnostic = null) + Action? diagnostic = null, + RuntimeFirstEntryDriveController? firstEntry = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _entityObjects = entityObjects @@ -219,6 +229,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded _deletion = deletion ?? throw new ArgumentNullException(nameof(deletion)); _dormant = dormant ?? new DormantLiveEntityStore(); _diagnostic = diagnostic; + _firstEntry = firstEntry; } internal event Action? AppearanceApplied; @@ -259,7 +270,9 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded lock (_datLock) { LiveEntityRegistrationResult registration = - _runtime.RegisterLiveEntity(spawn); + _runtime.RegisterLiveEntity( + spawn, + isLocalPlayer: spawn.Guid == _identity.ServerGuid); InboundCreateResult result = registration.Inbound; if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration) @@ -380,6 +393,16 @@ AppearanceSynchronization: $"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.", cleanupFailure); } + + // C3c: pump the first-entry drive after the complete Create + // hydration transaction — the sidecar exists, so this entity's + // conductor can run mover-prep -> placement -> drain and its + // completion receipt can bind presentation synchronously, + // matching retail HandleCreateObject's inline enter_world. Any + // still-yielding sequence (missing prepared Setup, deferred + // destination cell, FIFO ahead of us) is retried by the + // per-frame placement retry phase. + _firstEntry?.DriveAll(); } } diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 9c818d22..c708c8d2 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -523,7 +523,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource /// public event Action? ProjectionVisibilityChanged; - public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming) + public LiveEntityRegistrationResult RegisterLiveEntity( + WorldSession.EntitySpawn incoming, + bool isLocalPlayer = false) { if (_isClearing || _sessionClearPendingFinalization || _isRegisteringResources) { @@ -533,9 +535,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource : "A live entity cannot register from inside atomic resource registration."); } + // C3c route-1 flip: every graphical initial Create enters the + // canonical initial-residence lease. The accepted wire frame stays on + // the canonical record with FullCell 0 until the authored Runtime + // SetPosition operation commits; the host's first-entry drive + // controller walks the conductors from the residence-begin + // notification. RuntimeEntityRegistrationResult registration = - _entityObjects.RegisterEntity( + _entityObjects.RegisterEntityWithInitialResidence( incoming, + isLocalPlayer, RetirePriorProjection); RuntimeEntityRecord? canonical = registration.Canonical; LiveEntityRecord? projection = canonical is null @@ -795,11 +804,32 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource || record.WorldEntity is not { } entity) return false; if (record.MaterializationResidence is - LiveEntityMaterializationResidence.AwaitRuntimePlacement) + LiveEntityMaterializationResidence.AwaitRuntimePlacement + && HasActiveInitialCreateResidence(record.Canonical)) { - // The private Runtime Place path below performs a presentation- - // only bucket update. This legacy API also commits canonical - // Runtime residence and cannot touch a cut-over incarnation. + // C3c: while the initial-create residence is ACTIVE, Runtime's + // SetPosition owner is the sole canonical position/cell/ + // object-clock authority and even the graphical bucket stays + // suppressed: the conductor's completion receipt (which reaches + // presentation through + // TryApplyInitialCreateCompletionPresentation, not this API) is + // the entity's first world-visible moment. Without this gate a + // re-entrant caller (e.g. a resource-registration observer) + // could install a bucket for a suppressed record before its + // placement ever committed. A STALE residence is lazily retired + // by this same query, after which legacy moves flow. + // + // C3c-R1 review R2: the gate is the EXACT-token residence + // activity view, never the sticky MaterializationResidence enum + // alone. Post-residence (the lease completed and was consumed) + // this method falls through to the FULL legacy branch below: + // the unflipped update routes (network position/state, remote + // and local teleports, streaming reprojection, hydration + // recovery) are the position authority again, and retail's + // prepare_to_enter_world (0x00511FA0) clock rebase must run on + // every root-workset membership edge — the earlier + // presentation-only shortcut skipped CommitRebucket and that + // clock edge for the entity's whole post-residence lifetime. return false; } @@ -930,6 +960,197 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource return true; } + /// + /// C3c: the graphical-bucket-only projection of a conductor-owned + /// initial placement — called ONLY from + /// (the + /// completion receipt at the initial-create residence boundary), never + /// from the public (C3c-R1 review R2: + /// post-residence moves take the full legacy branch there). + /// Deliberately never calls CommitRebucket, + /// SuspendObjectClock, or ResetObjectClockForEnterWorld — + /// Runtime's SetPosition commit already owns all of those for the + /// residence-driven placement this receipt projects. May place into a + /// pending (not-yet-loaded) bucket exactly like the legacy Create path + /// did; the pending drain publishes visibility when the landblock loads. + /// + private bool RebucketLiveEntityPresentationOnly( + uint serverGuid, + LiveEntityRecord record, + WorldEntity entity, + uint spatialCellOrLandblockId) + { + RuntimeEntityKey key = RequireProjectionKey(record); + bool wasProjected = record.IsSpatiallyProjected; + bool wasVisible = record.IsSpatiallyVisible; + ulong projectionOperation = ++record.ProjectionMutationVersion; + record.IsSpatiallyProjected = true; + Exception? spatialNotificationFailure = null; + uint priorRebucketingGuid = _rebucketingGuid; + _rebucketingGuid = serverGuid; + BeginPresentationOnlySpatialMutation(key); + try + { + try + { + _spatial.RebucketLiveEntity( + key, + entity, + spatialCellOrLandblockId); + } + catch (AggregateException error) + { + spatialNotificationFailure = error; + } + } + finally + { + EndPresentationOnlySpatialMutation(key); + _rebucketingGuid = priorRebucketingGuid; + } + if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } + bool visible = _spatial.IsLiveEntityProjectionResident(key); + record.IsSpatiallyVisible = visible; + RefreshSpatialPresentationIndexes(record); + RefreshPresentation(record); + RefreshSpatialRuntimeIndexes(record); + Exception? runtimeNotificationFailure = null; + if (!wasProjected || wasVisible != visible) + { + try + { + PublishProjectionVisibilityChanged(record, visible); + } + catch (Exception error) + { + runtimeNotificationFailure = error; + } + } + if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure); + return false; + } + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure); + return true; + } + + /// + /// C3c: applies one initial-Create ExecutorCompleted receipt's + /// presentation — the graphical binding point for a residence-driven + /// initial placement. Runtime committed position, cell, body, clocks, + /// and worksets during the conductor's drain; this installs the + /// committed frame on the sidecar and moves its graphical bucket + /// (pending buckets allowed — the legacy Create path's own semantics). + /// Superseded facts (a later legacy-path move already advanced the + /// record past the receipt) are treated as already-projected: the + /// receipt is stale for presentation and must not snap the entity back. + /// + internal bool TryApplyInitialCreateCompletionPresentation( + in RuntimePlacementProjectionSnapshot projection) + { + RuntimePlacementProjectionToken token = projection.Token; + if (!token.IsValid + || token.SessionLifetimeVersion != _directory.SessionLifetimeVersion + || !_projections.TryGet(token.Entity, out LiveEntityRecord? record) + || !_directory.IsCurrent(record.Canonical) + || record.Canonical.Key != token.Entity + || record.WorldEntity is not { } entity) + { + // No sidecar (a deferred-child replay materializes later and + // self-projects from canonical state) or a displaced identity — + // acknowledge-only. + return true; + } + if (record.FullCellId != token.ExactCellId + || record.Canonical.PlacementCommitVersion + != token.PlacementCommitVersion) + { + // A newer move superseded this receipt's facts after the drain. + return true; + } + + entity.SetPosition(projection.WorldPosition); + entity.Rotation = projection.Orientation; + entity.ParentCellId = token.ExactCellId; + entity.EffectCellId = token.ExactCellId; + return RebucketLiveEntityPresentationOnly( + record.ServerGuid, + record, + entity, + token.ExactCellId); + } + + /// + /// C3c: true while the exact incarnation behind + /// holds an initial-create residence lease — the discriminator the + /// placement sink uses to leave conductor-owned Place/Withdraw receipts + /// at the FIFO head for the drive controller to consume. + /// + internal bool HasActiveInitialCreateResidence(RuntimeEntityKey key) => + _directory.TryGetByLocalId( + key.LocalEntityId, + out RuntimeEntityRecord canonical) + && _directory.IsCurrent(canonical) + && canonical.Key == key + && _entityObjects.TryGetInitialCreateResidence(canonical, out _); + + /// + /// C3c: true when the exact incarnation behind + /// holds an initial-create residence lease. Used by materialization to + /// decide whether presentation must await the conductor's completion + /// receipt or may self-project from already-committed canonical state. + /// + internal bool HasActiveInitialCreateResidence( + RuntimeEntityRecord canonical) => + _entityObjects.TryGetInitialCreateResidence(canonical, out _); + + /// + /// C3c-R1 review F1: the ONLY sanctioned mutation of the otherwise + /// sticky — a + /// world-created (residence-managed) entity converting to an attached + /// projection at a same-incarnation kind transition (the equipped-child + /// world→attached path). Attached children have no Runtime placement, + /// so the sticky-residence rule expects them to carry + /// . + /// Owned here so the invariant is asserted at the owner: converting + /// while the initial-create residence is still ACTIVE would let an + /// attached materialization race the conductor's pending placement. + /// + internal void ConvertMaterializationResidenceToLegacyImmediate( + LiveEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + if (record.MaterializationResidence is not + LiveEntityMaterializationResidence.AwaitRuntimePlacement) + { + return; + } + if (HasActiveInitialCreateResidence(record.Canonical)) + { + throw new InvalidOperationException( + $"Live entity 0x{record.ServerGuid:X8}/" + + $"{record.Canonical.Incarnation} cannot convert to " + + "LegacyImmediate residence while its initial-create " + + "residence lease is still active."); + } + record.MaterializationResidence = + LiveEntityMaterializationResidence.LegacyImmediate; + } + /// /// Applies one canonical Runtime placement receipt to the graphical /// sidecar only. Runtime has already committed identity, position, diff --git a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs index 9f062087..b26e43fd 100644 --- a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs +++ b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs @@ -66,6 +66,29 @@ internal sealed class RuntimePlacementPresentationSink public bool TryApply(in RuntimePlacementProjectionSnapshot projection) { + if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted) + { + // C3c: the initial-Create completion receipt is the graphical + // binding point for a residence-driven placement (the F1 + // acknowledge-and-ignore behavior applied only while + // PublishExecutorCompletion had zero production callers). + return TryApplyInitialCreateCompletion(in projection); + } + + if (projection.Kind is RuntimePlacementProjectionKind.Place + or RuntimePlacementProjectionKind.Withdraw + && _liveEntities.HasActiveInitialCreateResidence( + projection.Token.Entity)) + { + // C3c: a Place/Withdraw for an entity still holding its + // initial-create residence belongs to the first-entry conductor + // machinery, which acknowledges its own receipts at the exact + // FIFO head. Leave it there — the drive controller's pump + // consumes it; applying or acknowledging here would starve the + // conductor's own acknowledgement stage forever. + return false; + } + if (projection.Kind is RuntimePlacementProjectionKind.Place && !_transit.IsCurrentPlacementAuthority( projection.Token.Portal, @@ -76,19 +99,15 @@ internal sealed class RuntimePlacementPresentationSink if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection)) return false; - if (projection.Kind is RuntimePlacementProjectionKind.Discard - or RuntimePlacementProjectionKind.ExecutorCompleted) + if (projection.Kind is RuntimePlacementProjectionKind.Discard) { - // 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 + // Discard cancels only an unacknowledged observation - no + // world/presentation mutation. 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. + // as "leave at the FIFO head" - a rejected Discard would + // permanently wedge the whole ordered stream). return true; } if (!_liveEntities.TryGetRecord( @@ -109,6 +128,37 @@ internal sealed class RuntimePlacementPresentationSink }; } + /// + /// C3c: binds one completed initial-Create drain's presentation. A + /// celless completion (a route that performed no SetPosition — a + /// deferred-parent child staying invisible until its parent replay, or a + /// positionless create) and a missing/superseded sidecar are + /// acknowledge-only; the sidecar's own materialization self-projects + /// from canonical state in those cases. Pending (not-yet-loaded) + /// destination buckets are allowed — the legacy Create path's own + /// semantics — so this receipt can never wedge the ordered stream behind + /// an unloaded graphical backend. + /// + private bool TryApplyInitialCreateCompletion( + in RuntimePlacementProjectionSnapshot projection) + { + if (projection.Token.ExactCellId == 0u) + return true; + if (!_liveEntities.TryApplyInitialCreateCompletionPresentation( + in projection)) + { + return false; + } + if (!_liveEntities.TryGetRecord( + projection.Token.Entity, + out LiveEntityRecord record) + || record.WorldEntity is not { } entity) + { + return true; + } + return TryPublishPlace(record, entity); + } + private bool TryPublishPlace(LiveEntityRecord record, WorldEntity entity) { if (!IsCurrent(record, entity)) diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index df86dbd4..e367b2c9 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -643,12 +643,17 @@ public sealed class ShadowObjectRegistry private static uint DeriveOutdoorSeed( Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId) { + // C3c-F3: only a genuinely-absent landblock id (0) has no seed — + // prefix 0x00000000 is landblock (0,0), the map corner, whose + // outdoor cells 0x00000001..0x40 are as real as any other block's. + // The old prefix-0 sentinel silently dropped every landblock-baked + // static in the corner block. + if (landblockId == 0u) return 0u; float localX = worldPos.X - worldOffsetX; float localY = worldPos.Y - worldOffsetY; int cx = (int)System.Math.Clamp(localX / 24f, 0f, 7f); int cy = (int)System.Math.Clamp(localY / 24f, 0f, 7f); uint lbPrefix = landblockId & 0xFFFF0000u; - if (lbPrefix == 0u) return 0u; // The clamp only anchors the SEED id; AddAllOutsideCells re-seats the // actual flood cells from the sphere centers via LandDefs.AdjustToOutside // (block-crossing), so an out-of-block position still floods correctly. @@ -2120,7 +2125,16 @@ public sealed class ShadowObjectRegistry return false; } - internal bool HasLogicalOwner(uint entityId) => + /// + /// True while owns a logical shadow + /// registration (suspended or live). Public since C3c: the graphical + /// host reports the truthful + /// local-player shadow disposition (authored payload vs proven + /// shapeless) into the Runtime first-entry activation, which + /// validates against this exact + /// registry state. + /// + public bool HasLogicalOwner(uint entityId) => _entityReg.ContainsKey(entityId); /// diff --git a/src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs b/src/AcDream.Core/Physics/SpawnPlacementSettler.cs similarity index 61% rename from src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs rename to src/AcDream.Core/Physics/SpawnPlacementSettler.cs index 6ea506d0..86263c87 100644 --- a/src/AcDream.App/Physics/RemoteSpawnPlacementSettler.cs +++ b/src/AcDream.Core/Physics/SpawnPlacementSettler.cs @@ -1,15 +1,27 @@ using System.Numerics; -using AcDream.Core.Physics; -namespace AcDream.App.Physics; +namespace AcDream.Core.Physics; /// /// Performs the compressed first-gravity-frame settle used to establish -/// retail Contact/OnWalkable state for a newly materialized remote body. +/// retail Contact/OnWalkable state for a newly placed body. +/// +/// Retail gains spawn contact from the FIRST GRAVITY FRAME, not the +/// placement itself: CPhysicsObj::enter_world (0x00516170) runs +/// SetPosition (find_placement validates the spot but records no +/// touch) and every retail CPhysicsObj then simulates, falls the few +/// centimetres onto the floor, and the transition's touch grants the +/// contact plane + CONTACT/ON_WALKABLE. Bodies that do not run that first +/// ordinary frame at placement (stationary remotes — #270 — and the local +/// player's Runtime first-entry activation — C3c-F5) compress the settle +/// here: a short downward sweep from the placed position whose touch +/// handler produces exactly the state retail's first frame would. A sweep +/// that finds no floor (true airborne spawn) leaves the body airborne, +/// exactly like retail's fall. /// -internal static class RemoteSpawnPlacementSettler +public static class SpawnPlacementSettler { - internal const float SettleDistance = 0.5f; + public const float SettleDistance = 0.5f; public static bool TrySettle( PhysicsEngine physicsEngine, diff --git a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs index 3e325cdc..e52dd6ab 100644 --- a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs +++ b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs @@ -34,23 +34,39 @@ internal sealed class HeadlessRuntimePlacementProjectionSink 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. + // is not a placement to project (a headless host has no + // presentation to bind off the completed initial drain; the + // executor's own drain already committed every canonical fact). + // 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. return true; } RuntimePlacementProjectionToken token = projection.Token; RuntimeEntityDirectory directory = _runtime.EntityObjects.Entities; + if (projection.Kind is RuntimePlacementProjectionKind.Place + or RuntimePlacementProjectionKind.Withdraw + && token.IsValid + && directory.TryGetByLocalId( + token.Entity.LocalEntityId, + out RuntimeEntityRecord residenceCandidate) + && directory.IsCurrent(residenceCandidate) + && residenceCandidate.Key == token.Entity + && _runtime.EntityObjects.TryGetInitialCreateResidence( + residenceCandidate, + out _)) + { + // C3c: a Place/Withdraw for an entity still holding its + // initial-create residence belongs to the first-entry conductor + // machinery, which acknowledges its own receipts at the exact + // FIFO head. Leave it there for the drive pump; validating or + // acknowledging it here would starve the conductor forever. + return false; + } if (!token.IsValid || token.SessionLifetimeVersion != directory.SessionLifetimeVersion diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs index a07d56a9..afb0c400 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs @@ -15,6 +15,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting private readonly ILiveSessionEventRouting _events; private readonly GameRuntime _runtime; private readonly IRuntimePlacementProjectionSink _placements; + private readonly RuntimeFirstEntryDriveController? _firstEntry; private RuntimePlacementProjectionSubscription? _subscription; private bool _attachStarted; private bool _eventsDisposed; @@ -23,12 +24,14 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting internal HeadlessSessionEventRoute( ILiveSessionEventRouting events, GameRuntime runtime, - IRuntimePlacementProjectionSink placements) + IRuntimePlacementProjectionSink placements, + RuntimeFirstEntryDriveController? firstEntry = null) { _events = events ?? throw new ArgumentNullException(nameof(events)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _placements = placements ?? throw new ArgumentNullException(nameof(placements)); + _firstEntry = firstEntry; } public void Attach() @@ -41,6 +44,10 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting // succeeds and throws, LiveSessionHost's retryable rollback still // invokes Dispose on the underlying route. _attachStarted = true; + // C3c-R1 review F6: assert (not assume) that the prior route + // detached — session reset precedes a new route — before this route + // takes ownership of the shared drive controller's tracked entries. + _firstEntry?.AttachRoute(this); _events.Attach(); _subscription = new RuntimePlacementProjectionSubscription( _runtime, @@ -56,6 +63,12 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting // network route. A still-pending FIFO head remains Runtime-owned for // the replacement route to drain. Interlocked.Exchange(ref _subscription, null)?.Dispose(); + // C3c: the drive controller's tracked entries die with this exact + // route; Runtime's retirement/session-clear fan-out owns + // conductor/residence convergence independently. C3c-R1 review F6: + // route-scoped — a route that never attached cannot clear a live + // route's entries. + _firstEntry?.DetachRoute(this); if (!_eventsDisposed) { _events.Dispose(); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 5c55b088..8d5ca3b0 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -120,6 +120,12 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease? _contentLease; + /// C3c: one per-host first-entry drive controller (lazy — its + /// residence-begin subscription binds once against the persistent + /// Runtime lifetime) plus the active world projection it pumps + /// through. + private RuntimeFirstEntryDriveController? _firstEntryDrive; + private HeadlessSessionWorldProjection? _worldProjection; private int _disposeStage; private long _reconnectDeadline; private bool _reconnectPending; @@ -295,6 +301,10 @@ internal sealed class HeadlessSessionHost : IDisposable _localPlayerFrame.AdvanceBeforeNetwork( checked((float)deltaSeconds)); Runtime.Session.Tick(); + // C3c: pump pending first-entry sequences after the network drain — + // collision-generation progress and freshly accepted Creates both + // surface here, mirroring the graphical per-frame retry phase. + _worldProjection?.PumpFirstEntry(); _localPlayerFrame.RunPostNetworkCommandPhase(); Runtime.ActionOwner.CombatAttack.Tick(); _policy.Tick(Runtime, Commands); @@ -525,10 +535,34 @@ internal sealed class HeadlessSessionHost : IDisposable private ILiveSessionEventRouting CreateEventRoute( AcDream.Core.Net.WorldSession session) { - IRuntimeDirectWorldProjection? worldProjection = - _contentLease is { } content - ? new HeadlessSessionWorldProjection(Runtime, content) - : null; + IRuntimeDirectWorldProjection? worldProjection = null; + if (_contentLease is { } content) + { + // C3c: one drive controller per host — the residence-begin + // notification binds once against the persistent Runtime + // lifetime; reconnects reuse it (its tracked entries are cleared + // with each retiring route). + _firstEntryDrive ??= new RuntimeFirstEntryDriveController( + Runtime.EntityObjects, + Runtime.Clock, + content.PreparedCollision, + () => PlayerMovementConstructionOptions.From( + Runtime.CharacterOwner.MovementSkills.Snapshot), + // A headless host registers no shadow payloads — the local + // player is provably shapeless in the shadow registry, with + // the same default approach cylinder the deleted + // hand-resolve used. + static _ => new RuntimeLocalPlayerPhysicsActivationPreparation( + Radius: 0.48f, + Height: 1.835f, + RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + var projection = new HeadlessSessionWorldProjection( + Runtime, + content, + _firstEntryDrive); + _worldProjection = projection; + worldProjection = projection; + } var entities = new RuntimeLiveEntitySessionController( Runtime, session, @@ -583,7 +617,8 @@ internal sealed class HeadlessSessionHost : IDisposable return new HeadlessSessionEventRoute( route, Runtime, - new HeadlessRuntimePlacementProjectionSink(Runtime)); + new HeadlessRuntimePlacementProjectionSink(Runtime), + _firstEntryDrive); } private static LiveSessionCharacterSelector MapCharacterSelector( diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index 407d7a62..4ee0a614 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -16,6 +16,15 @@ internal interface IHeadlessCollisionNeighborhood void CenterOn(uint fullCellId); bool IsReady(uint fullCellId); + + /// + /// C3c-R1 review F7: true when 's landblock + /// is one this neighborhood can ever collision-publish — inside the 3x3 + /// window around the requested center (or no center has been requested + /// yet). A remote Create outside the window must not open a deferred + /// placement: its collision-generation wake could never fire. + /// + bool IsWithinServiceWindow(uint fullCellId); } internal readonly record struct HeadlessCollisionGenerationAdvance( @@ -233,6 +242,20 @@ internal sealed class HeadlessCollisionNeighborhood AdvanceWork(); } + public bool IsWithinServiceWindow(uint fullCellId) + { + if (_requestedCenterLandblock == 0u) + return true; + uint target = CanonicalLandblock(fullCellId); + int dx = Math.Abs( + (int)((target >> 24) & 0xFFu) + - (int)((_requestedCenterLandblock >> 24) & 0xFFu)); + int dy = Math.Abs( + (int)((target >> 16) & 0xFFu) + - (int)((_requestedCenterLandblock >> 16) & 0xFFu)); + return dx <= 1 && dy <= 1; + } + public bool IsReady(uint fullCellId) { uint center = CanonicalLandblock(fullCellId); @@ -480,36 +503,72 @@ internal sealed class HeadlessSessionWorldProjection private readonly GameRuntime _runtime; private readonly IHeadlessCollisionNeighborhood _collision; - private readonly IPreparedCollisionSource? _preparedCollision; + private readonly RuntimeFirstEntryDriveController? _firstEntry; + private uint _requestedLocalPlayerCell; internal HeadlessSessionWorldProjection( GameRuntime runtime, - HeadlessProcessContentOwner.HeadlessProcessContentLease content) + HeadlessProcessContentOwner.HeadlessProcessContentLease content, + RuntimeFirstEntryDriveController? firstEntry = null) : this( runtime, new HeadlessCollisionNeighborhood(runtime, content), - content.PreparedCollision) + firstEntry) { } internal HeadlessSessionWorldProjection( GameRuntime runtime, IHeadlessCollisionNeighborhood collision, - IPreparedCollisionSource? preparedCollision = null) + RuntimeFirstEntryDriveController? firstEntry = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _collision = collision ?? throw new ArgumentNullException(nameof(collision)); - _preparedCollision = preparedCollision; + _firstEntry = firstEntry; } public void ProjectSpawn( RuntimeEntityRecord record, bool isLocalPlayer) { - if (isLocalPlayer) - SynchronizeLocalPlayer(record); + // C3c route-8 flip: the first-entry conductors own mover + // preparation, body/controller construction, and placement for every + // Create. The host's spawn projection centers the collision + // neighborhood on the local player's wire cell (the activation + // defers until its collision generation commits) and pumps the + // drive; remote leases ride the same pump. + if (isLocalPlayer + && record.ServerGuid == _runtime.PlayerIdentity.ServerGuid + // C3c-R1 review F4: LandblockId is the RAW wire value; 0 is the + // absent-id sentinel and the F3 admission guards + // (RuntimePhysicsState.BeginCollisionAdmission) now throw on it, + // which would make one absent-position Create session-fatal. + // Skip the centering; the conductor pumps regardless. + && record.Snapshot.Position is { LandblockId: not 0u } position) + { + _requestedLocalPlayerCell = position.LandblockId; + _collision.CenterOn(position.LandblockId); + } + else if (!isLocalPlayer + && record.Snapshot.Position is + { LandblockId: not 0u } remotePosition + && !_collision.IsWithinServiceWindow(remotePosition.LandblockId)) + { + // C3c-R1 review F7: a remote/projectile Create outside the + // neighborhood's service window would submit a placement whose + // DeferredCell park can never wake (the far landblock is never + // collision-published here), pinning its residence and this + // pump's entry forever. Convert to the celless completion route + // BEFORE the pump: the conductor completes with FullCell 0 and + // the accepted wire frame stays on the canonical snapshot — the + // exact pre-flip accepted-frame behavior for far remotes. A + // later fresh Position event owns any subsequent placement. + _ = _runtime.EntityObjects + .TryConvertInitialResidenceToCellessRoute(record); + } + _firstEntry?.DriveAll(); } public void ProjectPosition( @@ -522,7 +581,17 @@ internal sealed class HeadlessSessionWorldProjection if (_runtime.MovementOwner.Controller is null) { - SynchronizeLocalPlayer(record); + // C3c: the initial-resolve hand-copy is gone — a Position + // arriving before the conductor's publication commit only pumps + // the drive (the conductor re-reads the accepted snapshot + // itself). C3c-R1 review F4: guard the raw wire LandblockId — + // 0 is the absent-id sentinel the F3 admission guards throw on. + if (record.Snapshot.Position is { LandblockId: not 0u } position) + { + _requestedLocalPlayerCell = position.LandblockId; + _collision.CenterOn(position.LandblockId); + } + _firstEntry?.DriveAll(); return; } @@ -530,6 +599,19 @@ internal sealed class HeadlessSessionWorldProjection BlipLocalPlayer(record); } + /// + /// C3c: the host tick's first-entry pump — advances the collision + /// neighborhood toward the requested local-player cell (its publication + /// work progresses on IsReady polls) and drives every pending + /// conductor sequence. + /// + internal void PumpFirstEntry() + { + if (_requestedLocalPlayerCell != 0u) + _ = _collision.IsReady(_requestedLocalPlayerCell); + _firstEntry?.DriveAll(); + } + public void BeginTeleport() { if (_runtime.MovementOwner.Controller is { } controller) @@ -545,7 +627,7 @@ internal sealed class HeadlessSessionWorldProjection destination.EntityGuid, out RuntimeEntityRecord record)) { - SynchronizeLocalPlayer(record); + ResynchronizeLocalPlayerForPortalArrival(record); } if (_runtime.MovementOwner.Controller is { } controller) controller.State = PlayerState.InWorld; @@ -563,19 +645,27 @@ internal sealed class HeadlessSessionWorldProjection IsCollisionReady: ready); } - private void SynchronizeLocalPlayer(RuntimeEntityRecord record) + /// + /// TODO-C4 (route 3): portal-arrival re-synchronization only. The + /// route-1/8 initial-entry hand-copy (controller construction + first + /// resolve/placement) was deleted at C3c — the first-entry conductor's + /// publication chain owns it — but the portal route is unflipped, so its + /// arrival re-resolve keeps today's exact behavior against the + /// already-published controller until C4 routes it through + /// RuntimePortalPlacementAuthority. + /// + private void ResynchronizeLocalPlayerForPortalArrival( + RuntimeEntityRecord record) { if (record.ServerGuid != _runtime.PlayerIdentity.ServerGuid - || record.Snapshot.Position is not { } position) + || record.Snapshot.Position is not { } position + || _runtime.MovementOwner.Controller is not { } controller) { return; } _collision.CenterOn(position.LandblockId); - PlayerMovementController controller = - _runtime.MovementOwner.Controller - ?? CreateController(record); Vector3 wirePosition = new( position.PositionX, position.PositionY, @@ -636,57 +726,4 @@ internal sealed class HeadlessSessionWorldProjection wirePosition); } - private PlayerMovementController CreateController( - RuntimeEntityRecord record) - { - var controller = new PlayerMovementController( - _runtime.EntityObjects.Physics.Engine, - record.ObjectClock, - PlayerMovementConstructionOptions.From( - _runtime.CharacterOwner.MovementSkills.Snapshot)); - controller.ApplyPhysicsState(record.FinalPhysicsState); - controller.LocalEntityId = record.LocalEntityId ?? 0u; - ApplySetupStepHeights(record, controller); - RuntimeMovementSkillProjection.ApplyTo( - _runtime.CharacterOwner.MovementSkills, - controller); - _runtime.MovementOwner.Controller = controller; - return controller; - } - - private void ApplySetupStepHeights( - RuntimeEntityRecord record, - PlayerMovementController controller) - { - if (record.Snapshot.SetupTableId is not { } setupId - || (setupId & 0xFF000000u) != 0x02000000u - || _preparedCollision is null) - { - return; - } - - PreparedCollisionReadResult read = - _preparedCollision.ReadSetupCollision(setupId); - if (read.Status != PreparedAssetReadStatus.Loaded - || read.Data is not { } setup) - { - throw new InvalidDataException( - $"Player Setup collision 0x{setupId:X8} is {read.Status}."); - } - _runtime.EntityObjects.Physics.DataCache.CacheSetup( - setupId, - setup); - controller.StepUpHeight = setup.StepUpHeight > 0f - ? setup.StepUpHeight - : 0.4f; - controller.StepDownHeight = setup.StepDownHeight > 0f - ? setup.StepDownHeight - : 0.4f; - // TS-46 (2026-07-30): the prepared package already carries the - // Setup's verbatim sphere list — no raw-DAT read needed here (unlike - // the graphical PlayerModeController.ApplyStepHeights, which reads - // DatReaderWriter.DBObjs.Setup directly). Empty falls back to - // ResolveWithTransition's legacy scalar reconstruction. - controller.SphereList = setup.Spheres; - } } diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index f9c3f5c9..f8a47c86 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -70,7 +70,16 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( /// keys - the remote/projectile Create-time body-construction conductor. /// Dormant like its C3a sibling; converges to zero the same way. /// - int RemoteFirstEntryActiveCount = 0) + int RemoteFirstEntryActiveCount = 0, + /// + /// C3c-R1 review F5: outstanding host first-entry drive entries + /// (RuntimeFirstEntryDriveController pending keys, summed over + /// every drive registered against this lifetime via + /// ). + /// Previously outside every ledger; gated by + /// like the conductor counts it pumps. + /// + int FirstEntryDrivePendingCount = 0) { public bool IsConverged => IsDisposed @@ -94,6 +103,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && PendingCompletionReceiptCount == 0 && LocalPlayerFirstEntryActiveCount == 0 && RemoteFirstEntryActiveCount == 0 + && FirstEntryDrivePendingCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -137,6 +147,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable { private bool _sessionClearInProgress; private bool _disposed; + /// C3c: see . + private Action? _initialResidenceBegan; + /// C3c-R1 review F5: see . + private readonly List> _firstEntryDriveOwnership = []; public RuntimeEntityObjectLifetime( uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, @@ -437,7 +451,31 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateExecution.LastReplayFailure is not null, InitialCreateExecution.PendingCompletionReceiptCount, LocalPlayerFirstEntry.CaptureOwnership().ActiveCount, - RemoteFirstEntry.CaptureOwnership().ActiveCount); + RemoteFirstEntry.CaptureOwnership().ActiveCount, + CaptureFirstEntryDrivePendingCount()); + } + + private int CaptureFirstEntryDrivePendingCount() + { + int total = 0; + for (int i = 0; i < _firstEntryDriveOwnership.Count; i++) + total = checked(total + _firstEntryDriveOwnership[i]()); + return total; + } + + /// + /// C3c-R1 review F5: registers one host first-entry drive controller's + /// pending-count provider into this lifetime's ownership snapshot, so + /// tracked-but-undriven entries can never sit outside every ledger. The + /// drive controller registers itself at construction (it already binds + /// there); multiple + /// registrations sum, mirroring the multicast notification shape. + /// + public void RegisterFirstEntryDriveOwnership(Func pendingCount) + { + ArgumentNullException.ThrowIfNull(pendingCount); + EnsureNotDisposed(); + _firstEntryDriveOwnership.Add(pendingCount); } public void BindEventContext( @@ -451,6 +489,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateExecution.BindGeneration(generation); } + /// + /// C3c: registers one host callback fired for every FRESH initial-create + /// residence begin (never for a same-generation FIFO append). Multicast, + /// mirroring . + /// The callback runs synchronously inside the registration transaction — + /// subscribers must only record the entity for a later drive pump, never + /// call a conductor's Advance re-entrantly from it. + /// + public void BindInitialResidenceBeginNotification( + Action began) + { + ArgumentNullException.ThrowIfNull(began); + EnsureNotDisposed(); + _initialResidenceBegan += began; + } + /// /// C0-2: forwards to , /// the same fan-out shape already uses for @@ -2270,6 +2324,20 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return InitialCreateResidences.TryGetCurrent(canonical, out lease); } + /// + /// C3c-R1 review F7: host seam for a bounded-collision-neighborhood + /// host to convert a remote/projectile Create's active residence to the + /// celless completion route when its destination landblock will never + /// be collision-published (a headless far remote). See + /// . + /// + public bool TryConvertInitialResidenceToCellessRoute( + RuntimeEntityRecord canonical) + { + EnsureNotDisposed(); + return InitialCreateResidences.TryConvertToCellessRoute(canonical); + } + internal RuntimeInitialCreateResidenceCompletionStatus CompleteInitialCreateResidence( RuntimeEntityRecord canonical, @@ -2339,7 +2407,19 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable canonical, accepted, isLocalPlayer); - return lease.IsValid; + if (!lease.IsValid) + return false; + // C3c: host drive notification. Fires for EVERY fresh residence + // begin through this single choke point — wire-dispatch Creates AND + // the executor's deferred-child replays (which register through this + // class's own bound delegate, never through a host runtime). The + // subscriber must only RECORD the key for a later drive pump — this + // fires mid-registration, before Registered publishes, and a + // synchronous Advance here would interleave with the enclosing + // transaction (and, for a replayed child, with the parent's own + // in-flight Execute). + _initialResidenceBegan?.Invoke(canonical); + return true; } private Exception FailInitialResidenceRegistration( diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs index 74e3fbb1..14230cda 100644 --- a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs @@ -1007,6 +1007,52 @@ internal sealed class RuntimeInitialCreateResidenceState return _completed.Remove(token.Entity); } + /// + /// C3c-R1 review F7: converts an ACTIVE, not-yet-placed + /// SetPosition-performing residence to the celless + /// (AwaitFreshPosition) route shape, forgetting its authored placement + /// operation. The residence entry itself stays active — the conductor's + /// next pump takes the existing celless skip-to-Execute path and + /// completes with FullCell 0, exactly like a Parented/PickedUp lease. + /// The retirement fan-out is fired to reset conductor/executor progress + /// for the key (its subscribers are pure progress reapers: + /// executor DiscardProgress + both conductors' Forget); + /// the entry itself is deliberately NOT retired. Refused once any + /// placement has committed (FullCellId != 0) — the entity is not + /// a far remote then. + /// + internal bool TryConvertToCellessRoute(RuntimeEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + if (record.Key is not { } key + || !_entries.TryGetValue(key, out Entry? entry) + || !ReferenceEquals(entry.Record, record)) + { + return false; + } + if (!IsCurrent(entry)) + { + Retire(entry); + return false; + } + RuntimeInitialCreateResidenceLease lease = entry.Lease; + if (!lease.Route.PerformsSetPosition) + return true; + if (record.FullCellId != 0u) + return false; + RuntimePlacementCancellationReceipt cancellation = + _setPosition.ForgetExactPlacement(lease.Placement); + entry.Lease = lease with + { + Route = RuntimeAuthoritativePositionRouteClassifier + .ToCellessCreateRoute(lease.Route), + Placement = default, + }; + _setPosition.PublishCancellation(cancellation); + NotifyRetirement(key); + return true; + } + internal bool Forget( RuntimeEntityRecord record, out RuntimeInitialCreateResidenceLease lease, diff --git a/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs index 9a8ae39e..9563881d 100644 --- a/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs +++ b/src/AcDream.Runtime/Entities/RuntimeRemoteFirstEntryState.cs @@ -113,11 +113,13 @@ internal readonly record struct RuntimeRemoteFirstEntryOwnershipSnapshot( /// 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. +/// PRODUCTION-DRIVEN since the C3c flip: +/// fully constructs and wires this class (construction, retirement fan-out, +/// bulk session-clear cleanup, ownership fold) exactly like the C3a +/// conductor, and the host first-entry drive +/// (RuntimeFirstEntryDriveController) calls +/// for every remote/projectile initial-create residence on both the +/// graphical and headless hosts. /// internal sealed class RuntimeRemoteFirstEntryState { diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 44a5bf5f..1fba5d08 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -263,6 +263,13 @@ public sealed class GameRuntime context.EntityObjects.Physics, context.Movement, context.PlayerIdentity)); + // C3c: the C3a conductor's "first act" — bind the publication + // owner the conductor was constructed without (it is built by + // RuntimeEntityObjectLifetime BEFORE + // RuntimeLocalPlayerPhysicsPublicationState exists; see the F2 + // late-bind note on RuntimeLocalPlayerFirstEntryState's ctor). + context.EntityObjects.LocalPlayerFirstEntry.BindPublication( + context.Movement.PhysicsPublication); context.EntityObjects.BindEventContext( () => generationReset.ActiveRetiringGeneration diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index cc2e8fd5..dd1abd03 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -368,6 +368,38 @@ public sealed class PlayerMovementController _body.calc_acceleration(); } + /// + /// C3c-F1 (2026-08-02): the lifecycle-deciding inbound-SetState entry + /// for the local player. Live states apply the exact + /// body; the dormant window drops the + /// push because the activation transaction owns the dormant body's + /// physics state exclusively ( + /// re-reads the canonical record's FinalPhysicsState at both activation + /// phases, and while the accepted SetState is queued behind the initial + /// residence the App-side push carries that same unchanged record value + /// — the drop is value-preserving by construction); terminal states are + /// displaced pushes (J3.6 displaced-callback-rejection), never a fault. + /// + internal RuntimeServerPhysicsStateApplication ApplyServerPhysicsState( + PhysicsStateFlags state) + { + switch (_publicationLifecycle) + { + case PlayerMovementControllerPublicationLifecycle.StandalonePublished: + case PlayerMovementControllerPublicationLifecycle.CandidatePreparing: + case PlayerMovementControllerPublicationLifecycle.RuntimePublished: + _body.State = state; + _body.calc_acceleration(); + return RuntimeServerPhysicsStateApplication.AppliedLive; + case PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant: + return RuntimeServerPhysicsStateApplication + .DroppedDormantActivationOwned; + default: + return RuntimeServerPhysicsStateApplication + .DroppedDisplacedController; + } + } + public bool IsAirborne => !_body.OnWalkable; /// @@ -1292,6 +1324,114 @@ public sealed class PlayerMovementController lastPkAttackTimestamp); } + /// + /// C3c-F1 (2026-08-02): the lifecycle-deciding half of the Runtime + /// movement-stats application seam + /// (). + /// The publication owner — not any App caller — decides whether a + /// server stat recompute may land: + /// + /// , + /// , and + /// + /// apply immediately — byte-identical to the deleted + /// RuntimeMovementSkillProjection.ApplyTo direct path. + /// + /// ALSO applies immediately: the dormant window (publication committed, + /// activation deferred on cell streaming — + /// RuntimeLocalPlayerFirstEntryState.AdvanceCore's + /// AwaitingActivation loop) spans inbound pumps, and this exact instance + /// is the controller that ActivateRuntimePublication later makes + /// live, so the write must land here (same discipline as + /// / + /// : accepted server facts + /// arriving mid-dormancy land on the dormant owner). These writes touch + /// only fields and the mover-flag latch — + /// no body/world/currency state the activation envelope validates. + /// , + /// , and + /// + /// report the typed displaced-write outcome (J3.6 + /// displaced-callback-rejection): a stat write against a terminal + /// controller is meaningless by design — the next login re-derives from + /// PlayerDescription. A sealed candidate is additionally unreachable + /// through the seam in production: it is never installed into + /// (Prepare requires the + /// movement owner empty and Commit installs it already-dormant in the + /// same synchronous Advance step). + /// + /// + internal RuntimeMovementStatsApplication ApplyCharacterMovementStats( + in RuntimeMovementSkillSnapshot snapshot) + { + switch (_publicationLifecycle) + { + case PlayerMovementControllerPublicationLifecycle.StandalonePublished: + case PlayerMovementControllerPublicationLifecycle.CandidatePreparing: + case PlayerMovementControllerPublicationLifecycle.RuntimePublished: + ApplyCharacterMovementStatsCore(snapshot); + return RuntimeMovementStatsApplication.AppliedLive; + case PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant: + ApplyCharacterMovementStatsCore(snapshot); + return RuntimeMovementStatsApplication.AppliedDormant; + default: + return RuntimeMovementStatsApplication.DroppedDisplacedController; + } + } + + /// + /// The exact application body of the deleted + /// RuntimeMovementSkillProjection.ApplyTo (same fields, same + /// order, same conversions) — moved behind the lifecycle switch so the + /// dormant window can share it without routing through the + /// -gated public setters. + /// Campaign P Slice P1 (2026-07-30): burden/stamina ride the SAME seam + /// run/jump skill already used — see the pseudocode doc §9. TS-23 + /// (Campaign P Slice P3, 2026-07-30): the player's own + /// PK/PKLite/Impenetrable collision-exemption bits and the + /// PlayerKillerStatus/LastPkAttackTimestamp pair the jump-cost PK-timer + /// bump reads — see EntityCollisionFlagsExt.ToMoverState and + /// PlayerWeenie.JumpStaminaCost. + /// + private void ApplyCharacterMovementStatsCore( + in RuntimeMovementSkillSnapshot snapshot) + { + _weenie.SetSkills(snapshot.RunSkill, snapshot.JumpSkill); + _weenie.SetBurden(snapshot.Burden); + _weenie.SetStamina( + snapshot.CurrentStamina < 0 ? null : (uint)snapshot.CurrentStamina); + _ownPvpFlags = EntityCollisionFlagsExt + .FromPwdBitfield(snapshot.OwnPwdBitfield) + .ToMoverState(); + _weenie.SetPlayerKillerStatus( + snapshot.PlayerKillerStatus < 0 ? null : snapshot.PlayerKillerStatus, + snapshot.LastPkAttackTimestamp); + } + + /// + /// C3c-F1: the stamina-exhaustion EVENT dispatch + /// (retail CommandInterpreter::HandleExhaustion @ 0x006b3c70 → + /// CPhysicsObj::report_exhaustion), routed through the owner so + /// App never touches the gated surface. Fires only + /// on a live controller: a dormant owner has no in-flight movement to + /// re-dispatch (retail's handler is a no-op for a player not in world; + /// activation dispatches movement fresh from the already-current + /// stamina gate), and a terminal owner is a + /// displaced callback. + /// + internal bool ReportExhaustionAtMovementBoundary() + { + if (_publicationLifecycle + is PlayerMovementControllerPublicationLifecycle.StandalonePublished + or PlayerMovementControllerPublicationLifecycle.CandidatePreparing + or PlayerMovementControllerPublicationLifecycle.RuntimePublished) + { + _motion.ReportExhaustion(); + return true; + } + return false; + } + /// /// R3-W2 (r3-port-plan.md §4): the player's /// — GameWindow binds the player sequencer's MotionDone seam to it so the @@ -1651,15 +1791,39 @@ public sealed class PlayerMovementController RearmConstraintLeashAtCurrentPosition(); } + /// + /// C3c-R1: arms the login-entry constraint leash from the Runtime + /// publication chain. The flip deleted the only login-path caller of + /// (the App-side + /// call in the old + /// player-mode-entry commit); the dormant activation's final commit + /// (RuntimeSetPositionState.TryApplyDormantLocalActivationFinalCommit) + /// is the accepted-position event that replaces it — retail arms at + /// every accepted-position event (SmartBox::HandleReceivedPosition + /// 0x00453FD0). The final commit has already activated this controller + /// (ActivateRuntimePublication), so the published guard doubles + /// as a stale-caller check. Like the pre-flip commit path, no + /// UnConstrain teardown is needed: nothing can have armed the leash on + /// a controller whose was created by its + /// own publication candidate. + /// + internal void ArmConstraintLeashAtCommittedPlacement() + { + EnsurePublishedForRuntimeOperation(); + RearmConstraintLeashAtCurrentPosition(); + } + /// /// #167 (Campaign P P5): retail SmartBox::HandleReceivedPosition /// (0x00453fd0) "Player, teleport-newer" branch re-arms the leash /// immediately after TeleportPlayer's teardown, anchored to the /// RECEIVED position (here, the body's just-snapped current position). - /// Shared by the teleport path (after UnConstrain) and the deferred + /// Shared by the teleport path (after UnConstrain), the deferred /// player-mode-entry commit path (), /// which never ran UnConstrain because nothing could have armed the - /// leash before the controller had a . + /// leash before the controller had a , + /// and the C3c first-entry placement commit + /// (). /// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2. /// private void RearmConstraintLeashAtCurrentPosition() diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs index f366c733..2328da2d 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs @@ -131,12 +131,14 @@ internal readonly record struct RuntimeLocalPlayerFirstEntryOwnershipSnapshot( /// half instead /// and never the fused method. /// -/// Dormant by design: fully -/// constructs and wires this class (construction, publication binding, +/// PRODUCTION-DRIVEN since the C3c flip: +/// fully constructs and wires this class (construction, publication binding, /// retirement fan-out, bulk session-clear cleanup, ownership fold) exactly -/// like every other owner it builds, but nothing calls -/// in production — a later slice wires a host to drive -/// it. +/// like every other owner it builds, and the host first-entry drive +/// (RuntimeFirstEntryDriveController, pumped by the graphical +/// hydration/frame-retry cadence and the headless spawn/position/tick +/// cadence) calls for every local-player +/// initial-create residence. /// internal sealed class RuntimeLocalPlayerFirstEntryState { diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs index 942e7249..89f00213 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs @@ -14,6 +14,70 @@ public interface IRuntimeLocalPlayerMotionSource MotionInterpreter? Motion { get; } } +/// +/// C3c-F1 (2026-08-02): typed outcome of routing a server movement-stat +/// recompute through the Runtime movement owner. The dropped outcomes are +/// the J3.6 displaced-callback-rejection pattern — never an exception and +/// never a silent void: the caller logs them under its existing +/// diagnostics. A skill write against a dead session is meaningless by +/// design; the next login re-derives everything from PlayerDescription. +/// +public enum RuntimeMovementStatsApplication +{ + /// Applied to the live (published/standalone) controller — + /// byte-identical to the pre-F1 direct application path. + AppliedLive, + + /// Applied to the Runtime-owned dormant controller during the + /// committed-but-not-yet-activated first-entry window. The same + /// instance goes live at activation, so the values are already current + /// when movement starts. + AppliedDormant, + + /// No controller is installed (pre-first-entry, mid-candidate + /// construction, or after session teardown cleared the owner). + DroppedNoController, + + /// The skill snapshot has no authoritative run/jump values yet + /// (PlayerDescription not processed) — same silent skip as the pre-F1 + /// path. + DroppedIncompleteSnapshot, + + /// The installed controller is terminal (sealed, retired, or + /// discarded): a displaced post-teardown write, reported instead of + /// faulting the session. + DroppedDisplacedController, +} + +/// +/// C3c-F1 (2026-08-02): typed outcome of routing an inbound server +/// PhysicsState push through the local movement controller's publication +/// lifecycle. Same displaced-callback-rejection family as +/// , with one deliberate +/// difference: the dormant window DROPS the push rather than applying it, +/// because the activation transaction owns the dormant body's physics +/// state exclusively (it re-reads the canonical record's FinalPhysicsState +/// through RefreshDormantRuntimePhysicsState at both activation +/// phases), and the App-side push carries that exact same unchanged record +/// value while the accepted SetState itself is queued behind the initial +/// residence — dropping it is value-preserving by construction. +/// +public enum RuntimeServerPhysicsStateApplication +{ + /// Applied to the live (published/standalone) controller — + /// byte-identical to the direct ApplyPhysicsState path. + AppliedLive, + + /// The controller is Runtime-owned dormant: the activation + /// pipeline is the sole authority for the dormant body's physics state + /// and re-reads the canonical value itself. + DroppedDormantActivationOwned, + + /// The installed controller is terminal — a displaced + /// post-teardown push. + DroppedDisplacedController, +} + /// /// Canonical local movement lifetime and intent owner. Graphical input, /// presentation, diagnostics, and future no-window hosts borrow this exact @@ -37,7 +101,13 @@ public sealed class RuntimeLocalPlayerMovementState public PlayerMovementController? Controller { get => _controller; - set + // C3c seal: the public write escape hatch is closed. Production + // controller installation flows only through the publication + // lifecycle (CommitRuntimeOwnedController via + // RuntimeLocalPlayerPhysicsPublicationState.Commit) and teardown + // through ResetSession/Dispose/DiscardActivation. The setter stays + // reachable for tests via InternalsVisibleTo only. + internal set { ObjectDisposedException.ThrowIf(_disposed, this); if (ReferenceEquals(_controller, value)) @@ -189,6 +259,44 @@ public sealed class RuntimeLocalPlayerMovementState return true; } + /// + /// C3c-F1 (2026-08-02): the ONLY route by which server-authoritative + /// movement stats (run/jump skill, burden, stamina, PK status — the + /// exact field set of the deleted + /// RuntimeMovementSkillProjection.ApplyTo) reach the local + /// movement controller. App holds no controller reference for stat + /// application and performs no direct configuration mutation; the + /// owner's publication lifecycle decides whether the write lands + /// (live/dormant) or is reported as a typed displaced drop (terminal) — + /// the fix for the connected-gate post-logout ingest crash at + /// PlayerMovementController.EnsureConfigurationMutable. + /// Deliberately tolerant of a disposed owner: a recompute displaced + /// past teardown observes + /// instead of faulting the session. + /// + public RuntimeMovementStatsApplication ApplyCharacterMovementStats( + RuntimeMovementSkillState skills) + { + ArgumentNullException.ThrowIfNull(skills); + if (_controller is not { } controller) + return RuntimeMovementStatsApplication.DroppedNoController; + RuntimeMovementSkillSnapshot snapshot = skills.Snapshot; + if (!snapshot.IsComplete) + return RuntimeMovementStatsApplication.DroppedIncompleteSnapshot; + return controller.ApplyCharacterMovementStats(snapshot); + } + + /// + /// C3c-F1: routes the stamina-exhaustion EVENT (retail + /// CommandInterpreter::HandleExhaustion) through the owner so the + /// App edge-tracker never touches the gated controller motion surface. + /// Returns false when no live controller can dispatch it (absent, + /// dormant, terminal, or disposed owner) — displaced-callback-tolerant + /// for the same reason as . + /// + public bool ReportExhaustion() => + _controller?.ReportExhaustionAtMovementBoundary() == true; + /// /// Direct-host projection of the same combat readiness query used by the /// graphical attack adapter. A host without a constructed local movement diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs index 8a631030..4080bc11 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs @@ -278,13 +278,43 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable getObjectA: id => _physics.TryGetPhysicsHost(id, out var host) ? host : null, - handleUpdateTarget: movement.HandleUpdateTarget, + // C3c: the [autowalk-target]/[autowalk-end] probes moved here + // with controller construction (previously App-side in + // PlayerModeController.BuildControllerAndCamera); they stay on + // the PhysicsDiagnostics owner exactly as before. + handleUpdateTarget: info => + { + if (PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + Console.WriteLine( + $"[autowalk-target] object=0x{info.ObjectId:X8} " + + $"status={info.Status} context={info.ContextId} " + + $"target=({info.TargetPosition.Frame.Origin.X:F2}," + + $"{info.TargetPosition.Frame.Origin.Y:F2}," + + $"{info.TargetPosition.Frame.Origin.Z:F2})"); + } + movement.HandleUpdateTarget(info); + }, interruptCurrentMovement: () => - movement.CancelMoveTo(WeenieError.ActionCancelled)); + { + if (PhysicsDiagnostics.ProbeAutoWalkEnabled + && movement.IsMovingTo()) + { + Console.WriteLine("[autowalk-end] reason=interrupt"); + } + movement.CancelMoveTo(WeenieError.ActionCancelled); + }); movement.MakeMoveToManager(); motion.UnstickFromObject = physicsHost.PositionManager.UnStick; motion.InterruptCurrentMovement = () => + { + if (PhysicsDiagnostics.ProbeAutoWalkEnabled + && movement.IsMovingTo()) + { + Console.WriteLine("[autowalk-end] reason=interrupt"); + } movement.CancelMoveTo(WeenieError.ActionCancelled); + }; controller.PositionManager = physicsHost.PositionManager; // This checkpoint publishes ownership only. The subsequent canonical // SetPosition transaction is the sole authority which may enter the @@ -687,11 +717,106 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable _physics.SetPosition.DispatchDormantLocalActivationShadow(committed); if (!IsCommittedActivationSuffixCurrent(activation, committed)) return committed.Status; + ArmFirstEntryConstraintLeash(activation); + SettleFirstEntryGroundContact(activation); _physics.SetPosition.DispatchDormantLocalActivationPlacement(committed); projection = committed.Projection.Token; return committed.Status; } + /// + /// C3c-R1: the login-entry constraint-leash arm the flip deleted with + /// the App-side CommitPreparedPosition caller. Ordering, with + /// file:line justification: + /// + /// NOT at PreparePreparePositionForCommit (:219) + /// runs with publishSharedState: false and the controller's + /// PositionManager binds only later at :318, so the leash cannot + /// exist there (nor should it: the position is not accepted yet). + /// NOT at publication Commit — the activation's placement + /// evaluation (retail find-placement ring search) may still move or + /// reject the position. + /// HERE, after TryApplyDormantLocalActivationFinalCommit + /// (RuntimeSetPositionState.cs:2494-2516 commits the final cell, + /// activates the controller, and publishes the shared current cell) and + /// inside the same IsCommittedActivationSuffixCurrent gate the + /// settle uses — a stale suffix skips the arm exactly like the settle + /// (never armed on stale authority). + /// BEFORE — retail + /// arms anchored to the RECEIVED position + /// (SmartBox::HandleReceivedPosition 0x00453FD0) and only then + /// simulates the first gravity frame, which the settle compresses; the + /// anchor is therefore the committed placement, not the post-settle + /// pose. + /// Exactly once — _activation is nulled at :716 before + /// this suffix, so a resumed AwaitingFinalShadowPreparation + /// retry can never re-enter it after a successful final commit. + /// + /// + private void ArmFirstEntryConstraintLeash(Activation activation) + { + // Same containment as the settle below: the placement commit has + // already succeeded; a leash-arm failure must not unwind the suffix. + try + { + activation.Controller.ArmConstraintLeashAtCommittedPlacement(); + } + catch + { + _activationDispatchFailureCount++; + } + } + + /// + /// C3c-F5: retail seeds the LOCAL player's ground contact from the first + /// gravity frame after enter_world, never from the placement + /// itself — SmartBox::HandleCreateObject (0x00454C80) runs + /// init_player (0x00455010) then CPhysicsObj::enter_world + /// (0x00455095 → 0x00516170), whose SetPosition validates the + /// spot but records no touch and whose tail only sets ACTIVE (0x80). + /// Every retail CPhysicsObj then simulates, falls the few centimetres + /// onto the floor, and the transition's touch grants the contact plane + /// + CONTACT/ON_WALKABLE. The dormant activation's just-finished commit + /// is the faithful SetPosition port, so a fresh login body would start + /// airborne here; this compresses the settle exactly like the #270 + /// remote-spawn seed (the shared ): + /// a short downward sweep whose real touch produces the state retail's + /// first frame would. No floor within reach (a genuine airborne spawn) + /// leaves the body airborne — the ordinary per-tick gravity fall owns + /// it from there. The body transients this commits ARE the controller's + /// grounded state (PlayerMovementController.CanSendPositionEvent + /// reads InContact && OnWalkable off the same body) and + /// the outbound wire contact bit (LocalPlayerOutboundController + /// serializes that predicate) — the flag ACE's "You can't do that while + /// in the air!" gate reads. + /// + private void SettleFirstEntryGroundContact(Activation activation) + { + // Same post-commit callback-dispatch containment as the ground-edge + // dispatch in CommitActivation: the placement commit has already + // succeeded; a HitGround-side failure must not unwind the suffix. + try + { + _ = SpawnPlacementSettler.TrySettle( + _physics.Engine, + activation.Body, + activation.Body.Position, + activation.Body.CellPosition.ObjCellId, + activation.ActivationPreparation.Radius, + activation.ActivationPreparation.Height, + ObjectInfoState.IsPlayer + | ObjectInfoState.EdgeSlide + | activation.Controller.OwnPvpFlags, + activation.Controller.LocalEntityId, + activation.Movement.HitGround, + activation.Motion.LeaveGround); + } + catch + { + _activationDispatchFailureCount++; + } + } + private bool IsActivationPrephaseEnvelopeCurrent( Activation activation, in RuntimeDormantSetPositionCommitReceipt receipt) => diff --git a/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs b/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs deleted file mode 100644 index 65d77146..00000000 --- a/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs +++ /dev/null @@ -1,41 +0,0 @@ -using AcDream.Core.Physics; - -namespace AcDream.Runtime.Gameplay; - -/// -/// Applies the exact server-owned run/jump snapshot to either host's one local -/// movement controller. This lives beside the canonical skill owner so -/// graphical and no-window construction cannot drift. -/// -public static class RuntimeMovementSkillProjection -{ - public static bool ApplyTo( - RuntimeMovementSkillState skills, - PlayerMovementController? controller) - { - ArgumentNullException.ThrowIfNull(skills); - RuntimeMovementSkillSnapshot snapshot = skills.Snapshot; - if (controller is null || !snapshot.IsComplete) - return false; - - controller.SetCharacterSkills( - snapshot.RunSkill, - snapshot.JumpSkill); - // Campaign P Slice P1 (2026-07-30): burden/stamina ride the SAME - // seam run/jump skill already used — see the pseudocode doc §9. - controller.SetCharacterBurden(snapshot.Burden); - controller.SetCharacterStamina(snapshot.CurrentStamina); - // TS-23 (Campaign P Slice P3, 2026-07-30): the player's own - // PK/PKLite/Impenetrable collision-exemption bits and the - // PlayerKillerStatus/LastPkAttackTimestamp pair the jump-cost - // PK-timer bump reads — see EntityCollisionFlagsExt.ToMoverState - // and PlayerWeenie.JumpStaminaCost. - controller.OwnPvpFlags = - EntityCollisionFlagsExt.FromPwdBitfield(snapshot.OwnPwdBitfield) - .ToMoverState(); - controller.SetCharacterPkStatus( - snapshot.PlayerKillerStatus, - snapshot.LastPkAttackTimestamp); - return true; - } -} diff --git a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs index 3d90357b..60e8ae03 100644 --- a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs +++ b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs @@ -272,6 +272,39 @@ internal static class RuntimeAuthoritativePositionRouteClassifier reporting); } + /// + /// C3c-R1 review F7: converts an already-classified SetPosition-performing + /// initial-Create route into the EXACT celless (AwaitFreshPosition) shape + /// the Parented/PickedUp branch of produces, + /// preserving the route's authority, operation kind, and collision-batch + /// eligibility. A host with a bounded collision neighborhood (headless) + /// applies this to a remote/projectile Create whose destination landblock + /// that neighborhood will never publish — the parked placement's + /// collision-generation wake could otherwise never fire. The residence + /// then completes celless (FullCell stays 0, the accepted wire frame + /// stays on the canonical snapshot), mirroring the pre-flip direct-host + /// accepted-frame behavior for far remotes; a later fresh Position event + /// owns any subsequent placement. + /// + internal static RuntimeAuthoritativePositionRoute ToCellessCreateRoute( + in RuntimeAuthoritativePositionRoute route) => + new( + route.Authority, + RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, + route.OperationKind, + PhysicsSetPositionFlags.None, + 0u, + UnparentBeforeRouting: false, + ApplyPlacementFrameBeforeRouting: false, + LeaveWorld: false, + TeleportHookPhase: RuntimeTeleportHookPhase.None, + StopInterpolating: false, + ConstrainPhase: RuntimePositionConstrainPhase.None, + PreserveHeading: false, + ZeroVelocity: false, + SendPositionImmediately: false, + route.CollisionBatchEligible); + internal static RuntimeAuthoritativePositionRoute ClassifyAcceptedPosition( in RuntimeAcceptedPositionRouteRequest request) { diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 52f12009..c6fbc561 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -1987,9 +1987,13 @@ public sealed class RuntimePhysicsState : IDisposable { EnsureNotDisposed(); EnsureCollisionMutationThread(); - uint canonical = CanonicalLandblock(landblockId); - if (canonical == 0u) + // C3c-F3: the old `canonical == 0u` check was dead (CanonicalLandblock + // ORs in 0xFFFF, so it never returns 0) — the real absent-id guard is + // on the raw input. Landblock (0,0) canonicalizes to 0x0000FFFF and + // is fully legal here. + if (landblockId == 0u) throw new ArgumentOutOfRangeException(nameof(landblockId)); + uint canonical = CanonicalLandblock(landblockId); return SetPosition.BeginCollisionPrefixQuiescence( canonical, collisionGeneration, @@ -2034,6 +2038,13 @@ public sealed class RuntimePhysicsState : IDisposable { EnsureNotDisposed(); EnsureCollisionMutationThread(); + // C3c-F3: an absent landblock id (0) canonicalizes to 0x0000FFFF — + // the REAL map-corner landblock — so it must be rejected at the + // admission entrance. The prefix-0 sentinel used to (accidentally, + // and only at commit time) catch this caller bug; with prefix + // 0x00000000 now legal, the explicit guard is the only protection. + if (landblockId == 0u) + throw new ArgumentOutOfRangeException(nameof(landblockId)); uint canonical = CanonicalLandblock(landblockId); if (_collisionPrefixMutations.ContainsKey(canonical)) { @@ -2622,9 +2633,13 @@ public sealed class RuntimePhysicsState : IDisposable { EnsureNotDisposed(); EnsureCollisionMutationThread(); - uint canonical = CanonicalLandblock(landblockId); - if (canonical == 0u) + // C3c-F3: absent-id guard on the raw input — the old + // `canonical == 0u` test was dead (CanonicalLandblock never returns + // 0), and the corner landblock (canonical 0x0000FFFF) retires like + // any other. + if (landblockId == 0u) throw new ArgumentOutOfRangeException(nameof(landblockId)); + uint canonical = CanonicalLandblock(landblockId); if (kind is RuntimeCollisionPrefixMutationKind.Activation) throw new ArgumentOutOfRangeException(nameof(kind)); @@ -2944,6 +2959,27 @@ public sealed class RuntimePhysicsState : IDisposable : 1UL; } + /// + /// True when a collision evaluation may read this cell's landblock right + /// now — no admission is in flight for it and its prefix is not quiescing. + /// enforces exactly this + /// per queried prefix, so any owner that is about to DEPEND on a + /// successful seal must consult the same predicate first. C3c-F2: the + /// dormant local-player activation rearm did not, so a collision-generation + /// commit that reentered the first-entry pump before its own admission + /// retired rearmed the parked lease out of AwaitingCell, immediately failed + /// this seal, and — no longer being AwaitingCell — was reported as + /// RejectedAuthority (terminal) instead of "still waiting". That dropped + /// the login conductor for the whole session. + /// + internal bool IsCollisionEvaluationPrefixAdmissible(uint exactCellId) + { + uint landblockId = CanonicalLandblock(exactCellId); + return landblockId != 0u + && !_collisionAdmissions.ContainsKey(landblockId) + && !SetPosition.IsCollisionPrefixQuiescing(landblockId); + } + /// /// Exact collision-prefix generation authority used by private /// SetPosition evaluations. Beginning a replacement generation advances @@ -3021,8 +3057,7 @@ public sealed class RuntimePhysicsState : IDisposable } foreach (uint prefix in prefixes) { - if (_collisionAdmissions.ContainsKey(prefix) - || SetPosition.IsCollisionPrefixQuiescing(prefix)) + if (!IsCollisionEvaluationPrefixAdmissible(prefix)) return false; } diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 263c8f5b..39378bda 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -109,8 +109,14 @@ internal readonly record struct RuntimeCollisionPrefixQuiescenceToken( ulong CollisionGeneration, ulong OperationId) { - internal bool IsValid => LandblockPrefix != 0u - && (LandblockPrefix & 0xFFFFu) == 0u + // C3c-F3: presence is discriminated by OperationId (allocated from a + // monotonic counter starting at 1, so a default token always carries 0) + // and CollisionGeneration (generations also start at 1) — NOT by + // LandblockPrefix != 0. Prefix 0x00000000 is the legitimate prefix of + // landblock (0,0) (id 0x0000FFFF, Dereth's map corner); the old + // prefix-based term made every real corner-landblock token read as + // invalid, wedging TryGetCurrentQuiescence and every release path. + internal bool IsValid => (LandblockPrefix & 0xFFFFu) == 0u && CollisionGeneration != 0UL && OperationId != 0UL; } @@ -778,9 +784,14 @@ internal sealed class RuntimeSetPositionState : IDisposable EnsureNotDisposed(); if (collisionGeneration == 0UL) throw new ArgumentOutOfRangeException(nameof(collisionGeneration)); - uint prefix = landblockId & 0xFFFF0000u; - if (prefix == 0u) + // C3c-F3: reject only the genuinely-absent landblock id (0). Prefix + // 0x00000000 is landblock (0,0) — the map corner — so a prefix == 0 + // test can no longer stand in for "no landblock"; that sentinel + // collision crashed every collision publication whose streaming + // window reached the corner (connected-gate 20260802-135444). + if (landblockId == 0u) throw new ArgumentOutOfRangeException(nameof(landblockId)); + uint prefix = landblockId & 0xFFFF0000u; if (_collisionPrefixQuiescence.TryGetValue( prefix, @@ -1848,6 +1859,45 @@ internal sealed class RuntimeSetPositionState : IDisposable && operation.WakeableLostCell; } + /// + /// C3c-F2: the identity check below is against + /// — the + /// generation the collision world currently HOLDS — not against + /// ExpectedCollisionGeneration, which means two different things + /// at the two ends of this wait. At park time (this class's own + /// TryPrepareDormantLocalActivationCommit) an admission for the + /// destination landblock is in flight, so Expected == that admission's + /// generation G and the lease correctly parks against G. The wake that + /// sets CollisionGenerationReady is + /// CommitCollisionGeneration(lb, G, ready), and the very next + /// statement in RuntimePhysicsState retires the admission + /// (AdvanceCommittedActivation) while leaving the committed generation at + /// G — from that instant Expected returns G+1, a generation that does not + /// exist and may never be begun. Comparing the parked G against Expected + /// therefore refused every login rearm forever (the connected-gate + /// DeferredCell wedge: controller never published, world never visible). + /// The committed-authority comparison keeps every staleness guarantee: a + /// superseding BeginCollisionAdmission or a CancelCollisionGeneration + /// moves the authority off G and this lease still refuses to rearm. + /// + /// + /// The trailing + /// + /// term is the second half of the same C3c-F2 defect and is what the live + /// probe caught: the collision-generation commit reenters the host's + /// first-entry pump BEFORE its own admission is retired + /// (RuntimePhysicsState.cs:2503 commits the generation, :2552-2558 retires + /// the admission). Rearming inside that window moves the lease out of + /// AwaitingCell and the very next evaluation fails + /// TrySealCollisionEvaluationAuthority on the still-registered + /// admission — at which point EvaluateActivation can no longer report + /// DeferredCell (the operation is no longer AwaitingCell) and returns + /// RejectedAuthority, which is TERMINAL for the conductor. Refusing the + /// rearm until the prefix is evaluable keeps the lease parked and + /// retryable, exactly as the remote wake path already does with + /// TryGetBlockingQuiescence (:4069-4095). + /// + /// private bool TryRearmDeferredDormantLocalActivation( RuntimeEntityRecord record, PhysicsBody body, @@ -1868,8 +1918,10 @@ internal sealed class RuntimeSetPositionState : IDisposable || !operation.CollisionGenerationReady || operation.ProjectionSequence != 0UL || operation.CollisionGeneration != _physics - .ExpectedCollisionGeneration(operation.ExactCellId) - || !_physics.Engine.IsSpawnCellReady(operation.ExactCellId)) + .CollisionGenerationAuthority(operation.ExactCellId) + || !_physics.Engine.IsSpawnCellReady(operation.ExactCellId) + || !_physics.IsCollisionEvaluationPrefixAdmissible( + operation.ExactCellId)) { return false; } @@ -3366,14 +3418,19 @@ internal sealed class RuntimeSetPositionState : IDisposable command); CollisionPrefixQuiescence? quiescence = _collisionPrefixQuiescence.GetValueOrDefault(prefix); + // C3c-F3: pass the overrides through as genuinely optional — + // `quiescence?.` yields null (absent) with no quiescence and the + // token's exact values (present, prefix 0x00000000 included) + // with one. The old `?? 0u` collapse made a corner-landblock + // quiescence indistinguishable from "no quiescence". RuntimeSetPositionOutcome parked = ParkDeferred( operation, result, publishImmediately: false, collisionGenerationOverride: - quiescence?.Token.CollisionGeneration ?? 0UL, + quiescence?.Token.CollisionGeneration, collisionPrefixOverride: - quiescence?.Token.LandblockPrefix ?? 0u); + quiescence?.Token.LandblockPrefix); if (_pendingProjection.TryGetValue( parked.Projection.Sequence, out RuntimePlacementProjectionSnapshot staged)) @@ -3929,12 +3986,22 @@ internal sealed class RuntimeSetPositionState : IDisposable _operationPool.Clear(); } + /// + /// C3c-F3: the quiescence-override pair is nullable — null means "no + /// quiescence holds this park", a present value means "parked under that + /// quiescence's exact prefix/generation". Nullable uint is the chosen + /// has-prefix representation for the whole chain because the previous + /// 0-sentinel collided with landblock (0,0)'s legitimate prefix + /// 0x00000000: a corner-landblock quiescence override read as "absent", + /// so derived false and + /// the parked operation skipped the QuiescenceHeld stage entirely. + /// private RuntimeSetPositionOutcome ParkDeferred( Operation operation, in PhysicsSetPositionResult result, bool publishImmediately = true, - ulong collisionGenerationOverride = 0UL, - uint collisionPrefixOverride = 0u) + ulong? collisionGenerationOverride = null, + uint? collisionPrefixOverride = null) { PhysicsBody body = operation.Body!; body.Orientation = result.Orientation; @@ -3969,13 +4036,11 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.WakeableLostCell = true; operation.EnteringWorldFromCelllessResidence = true; ArmLostFamilyDeadlines(operation); - operation.CollisionGeneration = collisionGenerationOverride != 0UL - ? collisionGenerationOverride - : _physics.ExpectedCollisionGeneration(result.CellId); - operation.CollisionPrefix = collisionPrefixOverride != 0u - ? collisionPrefixOverride - : result.CellId & 0xFFFF0000u; - operation.CollisionQuiescenceHeld = collisionPrefixOverride != 0u; + operation.CollisionGeneration = collisionGenerationOverride + ?? _physics.ExpectedCollisionGeneration(result.CellId); + operation.CollisionPrefix = collisionPrefixOverride + ?? result.CellId & 0xFFFF0000u; + operation.CollisionQuiescenceHeld = collisionPrefixOverride.HasValue; operation.Command = operation.Command with { Physics = operation.Command.Physics with diff --git a/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs new file mode 100644 index 00000000..43acaf53 --- /dev/null +++ b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs @@ -0,0 +1,354 @@ +using AcDream.Content; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Session; + +/// +/// C3c: the host-driven pump that walks every initial-Create residence +/// through its first-entry conductor. One instance per host session route; +/// graphical and no-window hosts construct it with their own prepared +/// collision source and local-player activation-preparation provider and +/// call from their own cadence (post-Create +/// hydration and the per-frame placement retry phase for the graphical +/// host; spawn/position projection and the session tick for headless). +/// +/// The controller owns NO placement state — it records which entities hold +/// a fresh residence lease (via +/// ) +/// and repeatedly calls the conductors, which re-validate all currency +/// themselves. Terminal yields (Completed/RejectedToken/RejectedAuthority) +/// drop the entry; every Awaiting*/Contention yield keeps it for the next +/// pump. +/// +/// Continuation placements (the executor's AwaitingContinuationPlacement +/// yield) are completed here through the C0 fused +/// +/// — legal for a continuation operation, which never has +/// DormantLocalActivation set — followed by head acknowledgement. The +/// production sink may consume the resulting Place first (the residence is +/// already consumed by then, so the sink's residence gate does not fire); +/// a failed acknowledgement after that is benign — the executor's +/// ResumePendingPlacement keys off the retained acknowledged completion, +/// not off who acknowledged. +/// +internal sealed class RuntimeFirstEntryDriveController +{ + /// + /// Bounded chase of synchronous progress inside one entity's drive — + /// enough for mover-prep + placement + acknowledgement + a handful of + /// continuation placements in a single pump without risking an unbounded + /// loop against a livelocked yield. + /// + private const int MaxSynchronousStepsPerEntity = 16; + + private sealed class Pending + { + internal required RuntimeEntityRecord Record { get; init; } + internal required RuntimeInitialCreateResidenceToken Token { get; init; } + internal required bool IsLocalPlayer { get; init; } + } + + private readonly RuntimeEntityObjectLifetime _entityObjects; + private readonly IGameRuntimeClock _clock; + private readonly IPreparedCollisionSource _collisionSource; + private readonly Func _localOptions; + private readonly Func _localActivation; + private readonly Dictionary _pending = []; + private readonly List _driveScratch = []; + private bool _driving; + /// C3c-R1 review F6: see . + private object? _routeOwner; + + internal RuntimeFirstEntryDriveController( + RuntimeEntityObjectLifetime entityObjects, + IGameRuntimeClock clock, + IPreparedCollisionSource collisionSource, + Func localOptions, + Func localActivation) + { + _entityObjects = entityObjects + ?? throw new ArgumentNullException(nameof(entityObjects)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _collisionSource = collisionSource + ?? throw new ArgumentNullException(nameof(collisionSource)); + _localOptions = localOptions + ?? throw new ArgumentNullException(nameof(localOptions)); + _localActivation = localActivation + ?? throw new ArgumentNullException(nameof(localActivation)); + _entityObjects.BindInitialResidenceBeginNotification( + NoteResidenceBegan); + // C3c-R1 review F5: tracked-but-undriven entries fold into the + // entity-object ownership snapshot instead of sitting outside every + // ledger. + _entityObjects.RegisterFirstEntryDriveOwnership(() => _pending.Count); + } + + internal int PendingCount => _pending.Count; + + /// + /// Records a fresh residence for a later pump. Runs synchronously inside + /// the registration transaction (including the executor's deferred-child + /// replays, which re-enter registration mid-Execute), so it must never + /// call Advance here — only capture the exact key/token/dispatch facts. + /// + private void NoteResidenceBegan(RuntimeEntityRecord record) + { + if (record.Key is not { } key + || !_entityObjects.TryGetInitialCreateResidence( + record, + out RuntimeInitialCreateResidenceLease lease)) + { + return; + } + + _pending[key] = new Pending + { + Record = record, + Token = lease.Token, + // Dispatch is decided ONCE from the lease's classified route — + // TryGetCurrent fails mid-drain (the residence moves to its + // completed table at Complete), so the lease cannot be + // re-fetched on a later pump. + IsLocalPlayer = lease.Route.OperationKind + is RuntimeSetPositionOperationKind.InitialLogin, + }; + } + + /// + /// Drives every tracked first-entry sequence one bounded step. Safe to + /// call from any host cadence point; re-entrant calls (a conductor's own + /// synchronous callbacks reaching a host pump) fail closed into the next + /// outer pump instead of interleaving. + /// + internal void DriveAll() + { + if (_driving || _pending.Count == 0) + return; + _driving = true; + try + { + _driveScratch.Clear(); + foreach (RuntimeEntityKey key in _pending.Keys) + _driveScratch.Add(key); + foreach (RuntimeEntityKey key in _driveScratch) + { + if (_pending.TryGetValue(key, out Pending? pending)) + DriveOne(key, pending); + } + } + finally + { + _driving = false; + } + } + + /// + /// C3c-R1 review F6: the explicit one-route-at-a-time latch. A drive + /// controller outlives its session routes (hosts reuse it across + /// reconnects), and route teardown clears the tracked entries — so the + /// "session reset precedes a new route" ordering the hosts rely on is + /// asserted here instead of silently assumed: a second route attaching + /// before the prior route detached would otherwise let the OLD route's + /// dispose wipe the NEW route's tracked entries. + /// + internal void AttachRoute(object route) + { + ArgumentNullException.ThrowIfNull(route); + if (_routeOwner is not null && !ReferenceEquals(_routeOwner, route)) + { + throw new InvalidOperationException( + "A first-entry drive controller serves one session route at " + + "a time; the prior route must be disposed (session reset " + + "precedes a new route) before a replacement attaches."); + } + _routeOwner = route; + } + + /// + /// Route-scoped teardown: clears every tracked entry, but ONLY when + /// is the attached owner — a route that never + /// attached (construction rollback) or was displaced must not clear the + /// live route's entries. The conductors and residence own their own + /// convergence independently (retirement fan-out + session clear). + /// + internal void DetachRoute(object route) + { + ArgumentNullException.ThrowIfNull(route); + if (!ReferenceEquals(_routeOwner, route)) + return; + _routeOwner = null; + _pending.Clear(); + } + + private void DriveOne(RuntimeEntityKey key, Pending pending) + { + for (int step = 0; step < MaxSynchronousStepsPerEntity; step++) + { + if (pending.Record.Key != key) + { + // Post-teardown key release; the retirement fan-out already + // reaped the conductors' own progress. + _pending.Remove(key); + return; + } + + bool terminal; + bool awaitingContinuationPlacement; + if (pending.IsLocalPlayer) + { + RuntimeLocalPlayerFirstEntryStatus status = + _entityObjects.LocalPlayerFirstEntry.Advance( + pending.Record, + pending.Token, + _localOptions(), + _localActivation(pending.Record), + _collisionSource, + _clock.SimulationTimeSeconds, + inputs: default, + out _); + terminal = status + is RuntimeLocalPlayerFirstEntryStatus.Completed + or RuntimeLocalPlayerFirstEntryStatus.RejectedToken + or RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + awaitingContinuationPlacement = status + is RuntimeLocalPlayerFirstEntryStatus + .AwaitingContinuationPlacement; + } + else + { + RuntimeRemoteFirstEntryStatus status = + _entityObjects.RemoteFirstEntry.Advance( + pending.Record, + pending.Token, + _collisionSource, + _clock.SimulationTimeSeconds, + inputs: default, + out _, + out _); + terminal = status + is RuntimeRemoteFirstEntryStatus.Completed + or RuntimeRemoteFirstEntryStatus.RejectedToken + or RuntimeRemoteFirstEntryStatus.RejectedAuthority; + awaitingContinuationPlacement = status + is RuntimeRemoteFirstEntryStatus + .AwaitingContinuationPlacement; + } + + if (terminal) + { + _pending.Remove(key); + return; + } + if (!awaitingContinuationPlacement) + { + // AwaitingCollisionSource / AwaitingActivation / + // AwaitingPlacement / AwaitingReceiptAcknowledgement / + // Contention — nothing more this pump can do synchronously. + return; + } + if (!TryCompleteContinuationPlacement(key, pending.Record)) + return; + // A continuation placement progressed — re-Advance so the + // executor can consume the acknowledged completion and keep + // draining. + } + } + + /// + /// Completes (or makes bounded progress on) the executor's pending + /// continuation placement for . Returns true when + /// enough progress happened that re-calling Advance can observe it. + /// + private bool TryCompleteContinuationPlacement( + RuntimeEntityKey key, + RuntimeEntityRecord record) + { + RuntimeSetPositionState setPosition = + _entityObjects.Physics.SetPosition; + + // A receipt of OURS already at the FIFO head (a Place from a prior + // submit attempt, or the Withdraw of a deferred park) is consumed + // first — acknowledgement is what re-arms a parked operation and what + // ResumePendingPlacement's retained-completion check requires. + bool acknowledgedSomething = false; + while (setPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head) + && head.Token.Entity == key + && head.Kind is RuntimePlacementProjectionKind.Place + or RuntimePlacementProjectionKind.Withdraw) + { + if (!setPosition.AcknowledgeProjection(head.Token)) + break; + acknowledgedSomething = true; + } + + if (!_entityObjects.InitialCreateExecution + .TryGetPendingContinuationPlacement( + key, + out RuntimeEntityPlacementToken placement)) + { + // Flavor 2 (transient operation-slot contention): no token was + // ever begun; the only correct action is a later Execute retry. + return acknowledgedSomething; + } + if (!_entityObjects.InitialCreateExecution + .TryGetPendingContinuationRoute( + key, + out RuntimeAuthoritativePositionRoute route)) + { + return acknowledgedSomething; + } + + RuntimeSetPositionMoverPreparationStatus status = + setPosition.TryPrepareAndSubmitAuthoredPlacement( + record, + placement, + route.OperationKind, + route.SetPositionFlags, + _collisionSource, + _clock.SimulationTimeSeconds, + out RuntimeSetPositionOutcome outcome); + if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) + { + // RetrySetupUnavailable retries on a later pump; a rejected + // preparation for an already-submitted-and-awaiting operation is + // driven purely by the head acknowledgements above. + return acknowledgedSomething; + } + + switch (outcome.Status) + { + case RuntimeSetPositionStatus.CommittedHostAcknowledgementPending: + // The synchronous publish may already have let the production + // sink apply-and-acknowledge this exact receipt (the + // residence is consumed by drain time, so the sink's + // residence gate no longer declines it). A false return here + // is therefore benign; the retained acknowledged completion + // is what the executor consumes either way. + _ = setPosition.AcknowledgeProjection(outcome.Projection); + return true; + case RuntimeSetPositionStatus.DeferredCell: + // Parked with a published Withdraw; consume it if it is + // already the head so the collision-generation wake can + // resubmit. + while (setPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot parked) + && parked.Token.Entity == key + && parked.Kind is RuntimePlacementProjectionKind.Withdraw) + { + if (!setPosition.AcknowledgeProjection(parked.Token)) + break; + acknowledgedSomething = true; + } + return acknowledgedSomething; + default: + // Rejected/Cancelled — authority moved; the next Advance + // observes it and abandons through the conductor's own path. + return true; + } + } +} diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index 4fada9f0..55140db2 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -72,8 +72,28 @@ public sealed class RuntimeLiveEntitySessionController private void OnSpawned(WorldSession.EntitySpawn spawn) { - RuntimeEntityRegistrationResult registration = - Entities.RegisterEntity(spawn); + // C3c route-8 flip: every direct-host Create enters the SAME initial + // residence lease graphical route 1 uses; the conductor drive (via + // IRuntimeDirectWorldProjection.ProjectSpawn and the host's pump) + // owns mover preparation, body/controller construction, placement, + // and the FIFO drain from here. + // + // C3c-R1 review R3: a CONTENT-LESS host (a validated-legal headless + // configuration — HeadlessConfigurationLoader.ValidateContent + // accepts a null process.content) constructs no world projection + // and therefore no first-entry drive; opening a residence with no + // drive to pump it would park every Create (and every position/ + // state packet queued behind its pending residence) forever. That + // configuration keeps the exact pre-flip legacy registration: + // presentation-free RegisterEntity plus the direct accepted-frame + // commit below. C4/C5 revisit: unify once the direct-host conductor + // drive no longer requires prepared content. + RuntimeEntityRegistrationResult registration = _worldProjection is null + ? Entities.RegisterEntity(spawn) + : Entities.RegisterEntityWithInitialResidence( + spawn, + isLocalPlayer: spawn.Guid + == _runtime.PlayerIdentity.ServerGuid); if (registration.Canonical is not { } canonical) return; diff --git a/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs b/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs new file mode 100644 index 00000000..056140cc --- /dev/null +++ b/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs @@ -0,0 +1,59 @@ +namespace AcDream.App.Tests.Input; + +/// +/// C3c-F2 (2026-08-02): source pin for the production player-mode auto-entry +/// precondition. PlayerModeAutoEntry is a ONE-SHOT that disarms before +/// invoking its callback, and the production callback completes the world +/// reveal, so an attempt made before the Runtime first-entry conductor has +/// committed permanently seals the reveal with the player never in world — +/// the second link of the connected-gate login wedge +/// (logs/connected-world-gate-20260802-130455: one "not committed yet" line, +/// then event=complete with materialized=False and 5,577 +/// readiness-after-terminal rejections). The production context therefore has +/// to report the same commit PlayerModeController.TryEnter requires. +/// The guard's own one-shot/latch behavior is covered behaviorally by +/// AcDream.Core.Tests.Input.AutoEnterPlayerModeTests; only the production +/// context's dependency graph (a ~15-dependency PlayerModeController) has no +/// focused harness, hence the source pin. +/// +public sealed class C3cF2AutoEntryWiringTests +{ + [Fact] + public void ProductionAutoEntryRequiresTheRuntimePublishedController() + { + string source = ReadSource("Input", "PlayerModeAutoEntry.cs"); + + Assert.DoesNotContain( + "public bool IsPlayerControllerReady => true;", + source, + StringComparison.Ordinal); + Assert.Contains( + "IsRuntimePublished: true", + source, + StringComparison.Ordinal); + Assert.Contains( + "record.PhysicsHost is EntityPhysicsHost", + source, + StringComparison.Ordinal); + } + + private static string ReadSource(params string[] relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return File.ReadAllText(Path.Combine( + directory.FullName, + "src", + "AcDream.App", + Path.Combine(relativePath))); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs b/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs index b1292cf3..635459f7 100644 --- a/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs +++ b/tests/AcDream.App.Tests/LiveEntityRuntimeFixture.cs @@ -12,24 +12,200 @@ namespace AcDream.App.Tests; /// internal static class LiveEntityRuntimeFixture { + /// + /// C3c: initial-Create registration now begins the canonical residence + /// lease, whose admission requires a live session generation. Focused + /// App fixtures bind a fixed non-zero token exactly like the Runtime + /// conductor fixtures do. + /// + private static RuntimeEntityObjectLifetime WithGeneration( + RuntimeEntityObjectLifetime lifetime) + { + lifetime.BindEventContext( + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + static () => 1UL); + return lifetime; + } + public static LiveEntityRuntime Create( GpuWorldState spatial, ILiveEntityResourceLifecycle resources, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) { - var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId); + var lifetime = WithGeneration( + new RuntimeEntityObjectLifetime(firstLocalEntityId)); return new LiveEntityRuntime(spatial, resources, lifetime); } + /// + /// C3c: a runtime whose initial-create residences can actually be driven + /// to completion — collision generation committed for + /// , the production + /// + /// constructed against the lifetime, and an acknowledge-only placement + /// subscription mirroring the host rules (Discard/ExecutorCompleted + /// acknowledged; Place/Withdraw left at the head for the conductors). + /// Tests exercising post-residence legacy update paths register, then + /// call to complete the + /// conductors exactly like the composed host's Create-transaction pump. + /// + internal sealed class DrivenLiveEntityRuntime + { + internal required LiveEntityRuntime Runtime { get; init; } + internal required RuntimeEntityObjectLifetime Lifetime { get; init; } + internal required AcDream.Runtime.Session.RuntimeFirstEntryDriveController + FirstEntry { get; init; } + internal required AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription Subscription { get; init; } + + internal void Pump() => FirstEntry.DriveAll(); + } + + public static DrivenLiveEntityRuntime CreateDriven( + GpuWorldState spatial, + ILiveEntityResourceLifecycle resources, + uint landblockId = 0x01010000u) + { + var lifetime = WithGeneration(new RuntimeEntityObjectLifetime()); + lifetime.Physics.SetPosition.BeginCollisionGeneration( + landblockId & 0xFFFF0000u, 1UL); + lifetime.Physics.Engine.AddLandblock( + landblockId & 0xFFFF0000u, + new AcDream.Core.Physics.TerrainSurface( + new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + landblockId & 0xFFFF0000u, 1UL, ready: true); + var runtime = new LiveEntityRuntime(spatial, resources, lifetime); + var movement = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerMovementState(); + var identity = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerIdentityState(); + var publication = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsPublicationState( + lifetime.Entities, + lifetime.Physics, + movement, + identity); + movement.AttachPhysicsPublication(publication); + lifetime.LocalPlayerFirstEntry.BindPublication(publication); + var firstEntry = new AcDream.Runtime.Session + .RuntimeFirstEntryDriveController( + lifetime, + new AcDream.Runtime.GameRuntimeClock(), + new SphereCollisionSource(), + static () => AcDream.Runtime.Gameplay + .PlayerMovementConstructionOptions.Fallback, + static _ => new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, + 1.835f, + AcDream.Runtime.Gameplay + .RuntimeLocalPlayerShadowDisposition + .ProvenShapeless)); + var subscription = new AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription( + lifetime.Placements, + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + new AckOnlyPlacementSink(runtime)); + return new DrivenLiveEntityRuntime + { + Runtime = runtime, + Lifetime = lifetime, + FirstEntry = firstEntry, + Subscription = subscription, + }; + } + + private sealed class AckOnlyPlacementSink(LiveEntityRuntime runtime) + : AcDream.Runtime.Physics.IRuntimePlacementProjectionSink + { + public bool TryApply( + in AcDream.Runtime.Physics.RuntimePlacementProjectionSnapshot + projection) + { + if (projection.Kind is AcDream.Runtime.Physics + .RuntimePlacementProjectionKind.Discard) + { + return true; + } + if (projection.Kind is AcDream.Runtime.Physics + .RuntimePlacementProjectionKind.ExecutorCompleted) + { + return projection.Token.ExactCellId == 0u + || runtime.TryApplyInitialCreateCompletionPresentation( + in projection); + } + return !runtime.HasActiveInitialCreateResidence( + projection.Token.Entity) + && runtime.TryApplyRuntimePlacementProjection(in projection); + } + } + + private sealed class SphereCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision>.Loaded( + new AcDream.Core.Physics.FlatSetupCollision( + System.Collections.Immutable.ImmutableArray< + AcDream.Core.Physics.FlatCollisionCylinder>.Empty, + [new AcDream.Core.Physics.FlatCollisionSphere( + System.Numerics.Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatGfxObjCollisionAsset> + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatCellStructureCollisionAsset> + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } + public static LiveEntityRuntime Create( GpuWorldState spatial, ILiveEntityResourceLifecycle resources, PhysicsEngine physicsEngine, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) { - var lifetime = new RuntimeEntityObjectLifetime( + var lifetime = WithGeneration(new RuntimeEntityObjectLifetime( physicsEngine, - firstLocalEntityId); + firstLocalEntityId)); return new LiveEntityRuntime(spatial, resources, lifetime); } @@ -39,7 +215,8 @@ internal static class LiveEntityRuntimeFixture Action tearDownRuntimeComponents, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) { - var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId); + var lifetime = WithGeneration( + new RuntimeEntityObjectLifetime(firstLocalEntityId)); return new LiveEntityRuntime( spatial, resources, @@ -53,7 +230,8 @@ internal static class LiveEntityRuntimeFixture ILiveEntityRuntimeComponentLifecycle runtimeComponentLifecycle, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) { - var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId); + var lifetime = WithGeneration( + new RuntimeEntityObjectLifetime(firstLocalEntityId)); return new LiveEntityRuntime( spatial, resources, @@ -68,9 +246,9 @@ internal static class LiveEntityRuntimeFixture PhysicsEngine physicsEngine, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) { - var lifetime = new RuntimeEntityObjectLifetime( + var lifetime = WithGeneration(new RuntimeEntityObjectLifetime( physicsEngine, - firstLocalEntityId); + firstLocalEntityId)); return new LiveEntityRuntime( spatial, resources, diff --git a/tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs b/tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs new file mode 100644 index 00000000..336b5ae6 --- /dev/null +++ b/tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs @@ -0,0 +1,53 @@ +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Tests; + +/// +/// C3c: initial-Create registration now freezes the raw create for the +/// canonical residence lease and requires the flattened parser projections +/// to agree with the nested PhysicsDesc block +/// (RuntimeEntityObjectLifetime.HasConsistentCreateIdentityAndParent). +/// Legacy hand-built fixture spawns predate that gate; this helper derives +/// the minimal consistent nested block from the flattened fields. +/// +internal static class LiveEntitySpawnFixture +{ + internal static WorldSession.EntitySpawn WithConsistentPhysics( + this WorldSession.EntitySpawn spawn) => spawn with + { + Physics = new PhysicsSpawnData( + RawState: spawn.PhysicsState ?? 0u, + Position: spawn.Position, + Movement: null, + AnimationFrame: spawn.PlacementId, + SetupTableId: spawn.SetupTableId, + MotionTableId: spawn.MotionTableId, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: spawn.ParentGuid is { } parentGuid + && spawn.ParentLocation is { } parentLocation + ? new PhysicsAttachment(parentGuid, parentLocation) + : null, + Children: null, + Scale: spawn.ObjScale, + Friction: spawn.Friction, + Elasticity: spawn.Elasticity, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: new PhysicsTimestamps( + Position: spawn.PositionSequence, + Movement: spawn.MovementSequence, + State: 0, + Vector: 0, + Teleport: 0, + ServerControlledMove: spawn.ServerControlSequence, + ForcePosition: 0, + ObjDesc: 0, + Instance: spawn.InstanceSequence)), + }; +} diff --git a/tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs b/tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs new file mode 100644 index 00000000..72851261 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs @@ -0,0 +1,204 @@ +using System.Net; +using AcDream.App.Net; +using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Physics; +using AcDream.Core.Social; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.Net; + +/// +/// C3c-F1 (2026-08-02): the movement-stats application seam driven through +/// the REAL inbound chain that crashed the connected lifecycle gate — +/// ClientObjectTable.IngestObjectAdded/ObjectUpdated → +/// LiveSessionEventRouter.RecomputePlayerQualities → +/// OnMovementStatsUpdated → +/// . +/// +public sealed class LiveMovementStatsApplierTests +{ + private const uint PlayerGuid = 0x5000000Au; + + private sealed class Harness : IDisposable + { + public WorldSession Session { get; } + public LiveSessionEventRouter Router { get; } + public ClientObjectTable Objects { get; } = new(); + public RuntimeCharacterState Character { get; } = new(); + public RuntimeLocalPlayerMovementState Movement { get; } = new(); + public LiveMovementStatsApplier Applier { get; } + public List Log { get; } = []; + + public Harness() + { + Session = new WorldSession(new IPEndPoint(IPAddress.Loopback, 9)); + // The REAL applier the factory binds (LiveSessionRuntimeFactory + // constructs the same class over the same owner pair) with the + // REAL character-bindings recompute callback shape. + Applier = new LiveMovementStatsApplier( + Movement, + Character.MovementSkills, + Log.Add); + Router = new LiveSessionEventRouter( + Session, + new LiveEntitySessionSink( + _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, + _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }), + new LiveEnvironmentSessionSink(_ => { }, _ => { }), + new LiveInventorySessionBindings( + Objects, + PlayerGuid: () => PlayerGuid, + OnShortcuts: null, + OnUseDone: null, + ItemMana: new ItemManaState(), + ExternalContainers: new ExternalContainerState()), + new LiveCharacterSessionBindings( + new CombatState(), + Character, + ResolveSkillFormulaBonus: null, + OnSkillsUpdated: (_, _) => Applier.Apply("skills"), + OnConfirmationRequest: null, + OnConfirmationDone: null, + ClientTime: () => 0d, + OnMovementStatsUpdated: () => Applier.Apply("stats")), + new LiveSocialSessionBindings( + new ChatLog(), + new TurbineChatState(), + new FriendsState(), + new SquelchState())); + Router.Attach(); + } + + /// + /// Ingests the player's own object row — the exact post-logout + /// inbound-Create edge (ApplyAcceptedSpawn → + /// ClientObjectTable.Ingest) that fired the crashing + /// recompute in logs/connected-world-gate-20260802-122749. + /// + public void IngestPlayerRow(uint? pwdBitfield = null) => + Objects.Ingest(new WeenieData( + Guid: PlayerGuid, Name: "+Acdream", Type: ItemType.Creature, + WeenieClassId: 1u, IconId: 0, IconOverlayId: 0, + IconUnderlayId: 0, Effects: 0, + Value: null, StackSize: null, StackSizeMax: null, Burden: null, + ContainerId: null, WielderId: null, ValidLocations: null, + CurrentWieldedLocation: null, Priority: null, + ItemsCapacity: null, ContainersCapacity: null, + Structure: null, MaxStructure: null, Workmanship: null, + PublicWeenieBitfield: pwdBitfield)); + + public void Dispose() + { + Router.Dispose(); + Session.Dispose(); + Movement.Dispose(); + Character.Dispose(); + } + } + + private static PlayerMovementController NewDormantRuntimeController() + { + PlayerMovementController controller = + PlayerMovementController.CreatePublicationCandidate( + new PhysicsEngine(), + PlayerMovementConstructionOptions.Fallback); + controller.SealPublicationCandidate(); + controller.CommitRuntimeOwnership(new RetailObjectQuantumClock()); + return controller; + } + + [Fact] + public void PostTeardownIngestRecomputeReportsTypedDropInsteadOfCrashing() + { + using var harness = new Harness(); + // The session's authoritative skills were complete before teardown. + harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180); + + // Post-teardown transient truth: a retired controller still + // reachable through the displaced recompute callback — the state the + // old direct path crashed on ("A sealed, retired, or discarded + // Runtime movement controller cannot be mutated"). + PlayerMovementController controller = NewDormantRuntimeController(); + harness.Movement.Controller = controller; + controller.ActivateRuntimePublication(); + controller.RetireRuntimePublication(); + Assert.Throws( + () => controller.SetCharacterSkills(1, 1)); + + // The exact crash edge: a post-logout inbound Create's object-table + // ingest fires the quality recompute through the real router. + harness.IngestPlayerRow(); + + Assert.Contains( + harness.Log, + line => line.StartsWith( + "player: dropped displaced movement stats", + StringComparison.Ordinal)); + Assert.DoesNotContain( + harness.Log, + line => line.StartsWith( + "player: applied server movement", + StringComparison.Ordinal)); + } + + [Fact] + public void DormantWindowIngestRecomputeAppliesToTheControllerThatGoesLive() + { + using var harness = new Harness(); + harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180); + harness.Character.MovementSkills.UpdateStamina(37); + + // The committed-but-unactivated first-entry window (activation + // deferred on cell streaming) with inbound ingests still pumping. + PlayerMovementController controller = NewDormantRuntimeController(); + harness.Movement.Controller = controller; + Assert.True(controller.IsRuntimeOwnedDormant); + + // BF_PLAYER (0x8) + BF_PLAYER_KILLER (0x20) on the player's own row + // exercises the full stat set through the recompute. + harness.IngestPlayerRow(pwdBitfield: 0x28u); + + Assert.Contains( + harness.Log, + line => line.StartsWith( + "player: applied server movement stats", + StringComparison.Ordinal)); + + // The same instance goes live at activation with the values already + // current. + controller.ActivateRuntimePublication(); + IWeenieObject weenie = controller.Motion.WeenieObj!; + Assert.True(weenie.InqRunRate(out float runRate)); + Assert.True(runRate > 0f); + Assert.Equal( + ObjectInfoState.IsPK, + controller.OwnPvpFlags & ObjectInfoState.IsPK); + } + + [Fact] + public void AbsentControllerAndIncompleteSnapshotStaySilent() + { + using var harness = new Harness(); + + // Incomplete snapshot (no PlayerDescription yet) with no controller: + // byte-identical to the pre-F1 silent skip — no log line at all. + harness.IngestPlayerRow(); + Assert.DoesNotContain( + harness.Log, + line => line.StartsWith("player:", StringComparison.Ordinal)); + + // Complete snapshot but still no controller (pre-first-entry): + // still the silent skip. + harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180); + Assert.Equal( + RuntimeMovementStatsApplication.DroppedNoController, + harness.Applier.Apply("stats")); + Assert.DoesNotContain( + harness.Log, + line => line.StartsWith("player:", StringComparison.Ordinal)); + } +} diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs index 33f4c3a1..51541dcd 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -257,7 +257,12 @@ public sealed class LiveSessionResetPlanTests spatial, new FailingOnceResources(), runtime.EntityObjects); - live.RegisterLiveEntity(Spawn(player, 1, 1, 0x01010001u)); + // C3c: this GameRuntime has no session, so its generation is zero and + // residence-based registration (RegisterLiveEntity) correctly refuses + // an initial world Create. The subject here is reset/teardown retry, + // not the create flow — seed the entity through the legacy direct + // Runtime registration, which needs no residence lease. + runtime.EntityObjects.RegisterEntity(Spawn(player, 1, 1, 0x01010001u)); live.MaterializeLiveEntity( player, 0x01010001u, diff --git a/tests/AcDream.App.Tests/Physics/C3cF1ProductionWiringTests.cs b/tests/AcDream.App.Tests/Physics/C3cF1ProductionWiringTests.cs new file mode 100644 index 00000000..967c15e4 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/C3cF1ProductionWiringTests.cs @@ -0,0 +1,53 @@ +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests.Physics; + +/// +/// C3c-F1 (2026-08-02): source pins for the App-side halves of the +/// movement-owner application seams whose production graphs have no +/// focused harness (the inbound network-update controller's dependency +/// set is composition-only). The Runtime lifecycle matrices carry the +/// behavioral coverage; these pins keep the App wiring routed through the +/// owner's typed entries instead of the throwing direct mutations that +/// crashed the connected lifecycle gate twice +/// (logs/connected-world-gate-20260802-122749 — SetCharacterSkills; +/// logs/connected-world-gate-20260802-125907 — ApplyPhysicsState). +/// +public sealed class C3cF1ProductionWiringTests +{ + [Fact] + public void LocalPlayerInboundSetState_RoutesThroughTheTypedOwnerEntry() + { + string source = ReadSource( + "Physics", + "LiveEntityNetworkUpdateController.cs"); + + Assert.Single( + Regex.Matches(source, @"ApplyServerPhysicsState\(") + .Cast()); + Assert.DoesNotContain( + ".ApplyPhysicsState(", + source, + StringComparison.Ordinal); + } + + private static string ReadSource(params string[] relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return File.ReadAllText(Path.Combine( + directory.FullName, + "src", + "AcDream.App", + Path.Combine(relativePath))); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs b/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs index dbd1b5d4..b83fa9d1 100644 --- a/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs +++ b/tests/AcDream.App.Tests/Physics/Issue270ProductionWiringTests.cs @@ -7,20 +7,40 @@ public sealed class Issue270ProductionWiringTests [Fact] public void MovementStats_UseOneEdgeTrackerAndResetItWithTheSession() { - string source = ReadSource("Net", "LiveSessionRuntimeFactory.cs"); + // C3c-F1 (2026-08-02): the #270 invariant is unchanged — exactly one + // stamina-exhaustion edge tracker, exhaustion dispatched once on the + // edge, tracker reset with the session — but the wiring moved from + // the factory's deleted ApplyMovementStats body into + // LiveMovementStatsApplier, which routes through the Runtime + // movement owner's typed seam instead of touching the controller. + string applier = ReadSource("Net", "LiveMovementStatsApplier.cs"); + string factory = ReadSource("Net", "LiveSessionRuntimeFactory.cs"); Assert.Contains( "_staminaExhaustion.Observe(snapshot.CurrentStamina)", - source, + applier, StringComparison.Ordinal); Assert.Single( Regex.Matches( - source, - @"controller!\.Motion\.ReportExhaustion\(\);") + applier, + @"_movement\.ReportExhaustion\(\);") .Cast()); Assert.Contains( "_staminaExhaustion.Reset();", - source, + applier, + StringComparison.Ordinal); + Assert.Contains( + "_movementStats.Reset();", + factory, + StringComparison.Ordinal); + // The factory keeps zero direct controller mutations: both stat + // callbacks route through the applier's seam. + Assert.Equal( + 2, + Regex.Matches(factory, @"_movementStats\.Apply\(").Count); + Assert.DoesNotContain( + "Motion.ReportExhaustion", + factory, StringComparison.Ordinal); } @@ -38,8 +58,11 @@ public sealed class Issue270ProductionWiringTests Assert.Equal( 3, Regex.Matches(source, @"SeedRemoteSpawnPlacement\(").Count); + // C3c-F5: the settle helper moved to Core (SpawnPlacementSettler) so + // the local player's Runtime first-entry activation shares the same + // tested compressed-first-gravity-frame sweep. Assert.Contains( - "RemoteSpawnPlacementSettler.TrySettle(", + "SpawnPlacementSettler.TrySettle(", source, StringComparison.Ordinal); } diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs index d2662229..accb5b6c 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs @@ -221,7 +221,12 @@ public sealed class LiveEntityInboundAuthorityGateTests out AcceptedPositionNetworkUpdate accepted)); Assert.Same(registration.Canonical, accepted.Canonical); - Assert.Equal((ulong)2, accepted.PositionAuthorityVersion); + // C3c: the accepted Position of an entity whose initial-create + // residence is still pending is admitted into the residence FIFO — + // its merge (and the position-authority advance) commits at the + // executor drain, so the gate's captured authority is the + // admission-time version, not a post-apply bump. + Assert.Equal((ulong)1, accepted.PositionAuthorityVersion); Assert.Equal(1, publishCount); Assert.False(runtime.TryGetRecord(Guid, out _)); } diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index 3ff794ea..aea08387 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -1112,7 +1112,10 @@ public sealed class RemotePhysicsUpdaterTests 0f, 0f, 0f); - var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + // C3c: residence admission requires the nested block's Instance + // timestamp to agree with the flattened InstanceSequence. + var timestamps = new PhysicsTimestamps( + 1, 1, 1, 1, 0, 1, 0, 1, instanceSequence); var physics = new PhysicsSpawnData( RawState: (uint)state, Position: serverPosition, diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs index 61ff1ca2..779aa605 100644 --- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -466,6 +466,7 @@ public sealed class EquippedChildProjectionWithdrawalTests ChildInstanceSequence: 1, ChildPositionSequence: 1); + fixture.CompleteFirstEntry(); Assert.True(fixture.Live.TryApplyCreateParent(update, out _)); fixture.Controller.OnCreateParentAccepted(update); @@ -509,13 +510,20 @@ public sealed class EquippedChildProjectionWithdrawalTests LiveEntityRecord parent = fixture.Spawn(0x70000270u, generation: 1); WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData( 0x70000271u, - generation: 1) with + generation: 1); + childSpawn = childSpawn with { Position = null, ParentGuid = parent.ServerGuid, ParentLocation = 0, PlacementId = null, PositionSequence = 0, + Physics = childSpawn.Physics!.Value with + { + Position = null, + Parent = new PhysicsAttachment(parent.ServerGuid, 0u), + AnimationFrame = null, + }, }; fixture.Live.RegisterLiveEntity(childSpawn); fixture.Controller.OnSpawn(childSpawn); @@ -558,13 +566,20 @@ public sealed class EquippedChildProjectionWithdrawalTests LiveEntityRecord parent = fixture.Spawn(0x70000272u, generation: 1); WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData( 0x70000273u, - generation: 1) with + generation: 1); + childSpawn = childSpawn with { Position = null, ParentGuid = parent.ServerGuid, ParentLocation = 0, PlacementId = 0, PositionSequence = 0, + Physics = childSpawn.Physics!.Value with + { + Position = null, + Parent = new PhysicsAttachment(parent.ServerGuid, 0u), + AnimationFrame = 0, + }, }; fixture.Live.RegisterLiveEntity(childSpawn); fixture.Controller.OnSpawn(childSpawn); @@ -577,13 +592,20 @@ public sealed class EquippedChildProjectionWithdrawalTests Assert.Null(entity.PaletteOverride); WorldSession.EntitySpawn grandchildSpawn = ControllerFixture.SpawnData( 0x70000274u, - generation: 1) with + generation: 1); + grandchildSpawn = grandchildSpawn with { Position = null, ParentGuid = child.ServerGuid, ParentLocation = 0, PlacementId = 0, PositionSequence = 0, + Physics = grandchildSpawn.Physics!.Value with + { + Position = null, + Parent = new PhysicsAttachment(child.ServerGuid, 0u), + AnimationFrame = 0, + }, }; fixture.Live.RegisterLiveEntity(grandchildSpawn); fixture.Controller.OnSpawn(grandchildSpawn); @@ -734,6 +756,8 @@ public sealed class EquippedChildProjectionWithdrawalTests Position = null, PositionSequence = 1, PlacementId = 0, + ParentGuid = parent.ServerGuid, + ParentLocation = 0, Physics = physics, }; @@ -1192,6 +1216,50 @@ public sealed class EquippedChildProjectionWithdrawalTests private const uint Cell = 0x01010001u; private readonly DeferredLiveEntityRuntimeComponentLifecycle _lifecycle = new(); private readonly Setup _setup; + private readonly AcDream.Runtime.Session.RuntimeFirstEntryDriveController _firstEntry; + + private sealed class NullCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision>.Missing; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatGfxObjCollisionAsset> + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatCellStructureCollisionAsset> + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } internal ControllerFixture( Func @@ -1201,10 +1269,24 @@ public sealed class EquippedChildProjectionWithdrawalTests (Cell & 0xFFFF0000u) | 0xFFFFu, new LandBlock(), Array.Empty())); - Live = LiveEntityRuntimeFixture.Create( + EntityObjects = new RuntimeEntityObjectLifetime(); + EntityObjects.BindEventContext( + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + static () => 1UL); + _firstEntry = new AcDream.Runtime.Session.RuntimeFirstEntryDriveController( + EntityObjects, + new AcDream.Runtime.GameRuntimeClock(), + new NullCollisionSource(), + () => AcDream.Runtime.Gameplay.PlayerMovementConstructionOptions.Fallback, + static _ => new AcDream.Runtime.Gameplay.RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, + 1.835f, + AcDream.Runtime.Gameplay.RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + Live = new LiveEntityRuntime( Spatial, new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), - _lifecycle); + _lifecycle, + EntityObjects); _setup = new Setup { HoldingLocations = @@ -1231,6 +1313,21 @@ public sealed class EquippedChildProjectionWithdrawalTests } internal GpuWorldState Spatial { get; } = new(); + internal RuntimeEntityObjectLifetime EntityObjects { get; } + + /// + /// C3c: completes fresh residences through the first-entry + /// conductors (a celless route performs no SetPosition, so its drain + /// completes synchronously), releasing the lease so legacy direct + /// CreateParent/Parent application stays valid post-flip. The + /// controller is constructed lazily but binds no notification of its + /// own tracking value until first use, so it sweeps active leases + /// directly instead. + /// + internal void CompleteFirstEntry() + { + _firstEntry.DriveAll(); + } internal ClientObjectTable Objects { get; } = new(); internal EntityEffectPoseRegistry Poses { get; } = new(); internal LiveEntityRuntime Live { get; } @@ -1254,7 +1351,13 @@ public sealed class EquippedChildProjectionWithdrawalTests { WorldSession.EntitySpawn spawn = SpawnData(guid, generation); if (!hasPosition) - spawn = spawn with { Position = null }; + { + spawn = spawn with + { + Position = null, + Physics = spawn.Physics!.Value with { Position = null }, + }; + } if (!hasSetup) spawn = spawn with { SetupTableId = null }; return Assert.IsType( @@ -1288,21 +1391,59 @@ public sealed class EquippedChildProjectionWithdrawalTests return record; } - internal static WorldSession.EntitySpawn SpawnData(uint guid, ushort generation) => new( - guid, - new CreateObject.ServerPosition( - Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f), - 0x02000001u, - Array.Empty(), - Array.Empty(), - Array.Empty(), - BasePaletteId: null, - ObjScale: null, - Name: "attached fixture", - ItemType: null, - MotionState: null, - MotionTableId: null, - InstanceSequence: generation); + internal static WorldSession.EntitySpawn SpawnData(uint guid, ushort generation) + { + // C3c: residence admission freezes the raw create and requires + // the flattened parser projections to agree with the nested + // PhysicsDesc block (HasConsistentCreateIdentityAndParent). + var position = new CreateObject.ServerPosition( + Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f); + var physics = new PhysicsSpawnData( + RawState: 0u, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: new PhysicsTimestamps( + Position: 0, + Movement: 0, + State: 0, + Vector: 0, + Teleport: 0, + ServerControlledMove: 0, + ForcePosition: 0, + ObjDesc: 0, + Instance: generation)); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "attached fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: generation, + Physics: physics); + } internal void InstallAttached( LiveEntityRecord parent, diff --git a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs index 29039d38..8eef106d 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs @@ -33,7 +33,7 @@ public sealed class LiveAppearanceAnimationTests ItemType: null, MotionState: null, MotionTableId: null, - InstanceSequence: 1); + InstanceSequence: 1).WithConsistentPhysics(); LiveEntityRecord record = runtime.RegisterAndMaterializeProjection( spawn, id => Entity(id, 0x01000001u, guid)); @@ -175,7 +175,7 @@ public sealed class LiveAppearanceAnimationTests Assert.Equal(1, registry.RetainedRegistrationCount); } - private static WorldSession.EntitySpawn Spawn(uint guid, uint cell, ushort instance) => new( + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell, ushort instance) => new WorldSession.EntitySpawn( guid, new CreateObject.ServerPosition(cell, 1f, 1f, 1f, 1f, 0f, 0f, 0f), 0x02000010u, @@ -188,7 +188,7 @@ public sealed class LiveAppearanceAnimationTests ItemType: null, MotionState: null, MotionTableId: null, - InstanceSequence: instance); + InstanceSequence: instance).WithConsistentPhysics(); private static WorldEntity Entity( uint id, diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs index 0a9f6753..39ef45da 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs @@ -74,7 +74,22 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests publishAppearance: _ => { operations.Add("appearance"); - runtime.RegisterLiveEntity(Spawn() with { Name = "fresher" }); + // C3c: a fresher same-generation CreateObject no longer + // advances create authority at registration — while a + // residence is active it is FIFO-staged and its authority + // advance lands at the executor drain's WeenieDescription + // stage (AdvanceCreateAuthority). Model that exact advance + // directly so the between-stage revalidation guard stays + // covered. + // C3c-R1 F3: honest MODEL of the executor drain's advance + // (ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests); a + // nested production OnCreate can no longer reach it — + // post-residence registration is description-only + // (RuntimeEntityObjectLifetime :660-665, + // !beginInitialResidence gate) and ConsumeExecuted already + // removed the completed residence entry. + record.Canonical.AdvanceCreateAuthority(); return true; }, publishCurrentSnapshot: _ => operations.Add("current"), @@ -315,7 +330,7 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests return record; } - private static WorldSession.EntitySpawn Spawn() => new( + private static WorldSession.EntitySpawn Spawn() => new WorldSession.EntitySpawn( Guid, new CreateObject.ServerPosition( Cell, @@ -336,7 +351,7 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests ItemType: null, MotionState: null, MotionTableId: null, - InstanceSequence: 1); + InstanceSequence: 1).WithConsistentPhysics(); private static LiveEntityAnimationState AnimationState( WorldEntity entity, diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs index 3cf160e3..298bc97d 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs @@ -232,7 +232,7 @@ public sealed class LiveEntityLightControllerTests InstanceSequence: 1, MovementSequence: 1, ServerControlSequence: 1, - PositionSequence: 1); + PositionSequence: 1).WithConsistentPhysics(); } } diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 3697c088..3acc470a 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -278,6 +278,15 @@ public sealed class CurrentGameRuntimeAdapterTests [Fact] public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace() { + // C3c (clause 2 — undriven-residence semantics): an initial world + // Create now begins the canonical initial-create residence, whose + // admission requires a live session generation. Both hosts here + // deliberately carry the zero generation of a session-less runtime, + // so BOTH refuse the Create transactionally with the identical + // structural error and publish no entity-object trace at all. The + // full driven-flow direct-vs-graphical parity (residence -> + // conductor -> receipts) is covered by the C3c first-entry + // integration tests. WorldSession.EntitySpawn spawn = Spawn(Harness.TargetGuid, instance: 7, cell: 0x12340001u); var direct = new RuntimeEntityObjectLifetime(); @@ -286,148 +295,28 @@ public sealed class CurrentGameRuntimeAdapterTests using IDisposable directSubscription = direct.Events.Subscribe(directTrace); - RuntimeEntityRegistrationResult directRegistration = - direct.RegisterEntity(spawn); - RuntimeEntityRecord directCanonical = - Assert.IsType(directRegistration.Canonical); - direct.Objects.AddOrUpdate(Object(spawn)); - direct.Objects.MoveItem( - spawn.Guid, - Harness.PlayerGuid, - newSlot: 3); - var objDesc = new ObjDescEvent.Parsed( - spawn.Guid, - new CreateObject.ModelData( - 0x04000001u, - Array.Empty(), - Array.Empty(), - Array.Empty()), - spawn.InstanceSequence, - ObjDescSequence: 2); - Assert.True(direct.TryApplyObjDesc( - objDesc, - acknowledgeProjection: null, - out _)); - var motion = new WorldSession.EntityMotionUpdate( - spawn.Guid, - new CreateObject.ServerMotionState(0x3D, 0x11), - spawn.InstanceSequence, - MovementSequence: 2, - ServerControlSequence: 2, - IsAutonomous: false); - Assert.True(direct.TryApplyMotion( - motion, - retainPayload: true, - acknowledgeProjection: null, - out _, - out _)); - var vector = new VectorUpdate.Parsed( - spawn.Guid, - new Vector3(1f, 2f, 3f), - new Vector3(0f, 0f, 0.25f), - spawn.InstanceSequence, - VectorSequence: 2); - Assert.True(direct.TryApplyVector( - vector, - acknowledgeProjection: null, - out _)); - var state = new SetState.Parsed( - spawn.Guid, - (uint)(PhysicsStateFlags.ReportCollisions - | PhysicsStateFlags.Hidden), - spawn.InstanceSequence, - StateSequence: 2); - Assert.True(direct.TryApplyState( - state, - acknowledgeProjection: null, - out _, - out _)); - Assert.True(direct.CommitChildNoDraw( - directCanonical, - noDraw: true)); - Assert.True(direct.CommitChildNoDraw( - directCanonical, - noDraw: false)); - WorldSession.EntityPositionUpdate position = - Position(spawn.Guid, spawn.InstanceSequence); - Assert.True(direct.TryApplyPosition( - position, - isLocalPlayer: false, - forcePositionRotation: null, - currentLocalVelocity: null, - projectionRequiresTeleportHook: false, - acknowledgeProjection: null, - out _, - out _, - out _)); - Assert.True(direct.CommitRebucket( - directCanonical, - 0x12360001u, - 0x1236FFFFu)); - Assert.True(direct.TryApplyPickup( - new PickupEvent.Parsed( - spawn.Guid, - spawn.InstanceSequence, - PositionSequence: 3), - acknowledgeProjection: null, - out _)); - Assert.True(direct.TryAcceptDelete( - new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence), - isLocalPlayer: false, - removeRetainedObject: true, - out RuntimeEntityDeleteAcceptance directDelete)); - direct.CompleteAcceptedDelete(directDelete); - Assert.Null(direct.RetireCanonicalOnly(directCanonical)); + InvalidOperationException directRefusal = + Assert.Throws(() => + direct.RegisterEntityWithInitialResidence( + spawn, + isLocalPlayer: false)); using var graphical = new Harness(); var graphicalTrace = new EntityObjectTrace(); using IDisposable graphicalSubscription = graphical.EntityObjects.Events.Subscribe(graphicalTrace); - _ = graphical.Entities.RegisterAndMaterializeProjection(spawn); - graphical.Objects.AddOrUpdate(Object(spawn)); - graphical.Objects.MoveItem( - spawn.Guid, - Harness.PlayerGuid, - newSlot: 3); - Assert.True(graphical.Entities.TryApplyObjDesc( - objDesc, - out _)); - Assert.True(graphical.Entities.TryApplyMotion( - motion, - retainPayload: true, - out _, - out _)); - Assert.True(graphical.Entities.TryApplyVector(vector, out _)); - Assert.True(graphical.Entities.TryApplyState(state, out _, out _)); - Assert.True(graphical.Entities.SetAttachedChildNoDraw( - spawn.Guid, - noDraw: true)); - Assert.True(graphical.Entities.SetAttachedChildNoDraw( - spawn.Guid, - noDraw: false)); - Assert.True(graphical.Entities.TryApplyPosition( - position, - isLocalPlayer: false, - forcePositionRotation: null, - currentLocalVelocity: null, - out _, - out _, - out _)); - Assert.True(graphical.Entities.RebucketLiveEntity( - spawn.Guid, - 0x12360001u)); - Assert.True(graphical.Entities.TryApplyPickup( - new PickupEvent.Parsed( - spawn.Guid, - spawn.InstanceSequence, - PositionSequence: 3), - out _)); - Assert.True(graphical.Entities.UnregisterLiveEntity( - new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence), - isLocalPlayer: false, - removeRetainedObject: true)); + InvalidOperationException graphicalRefusal = + Assert.Throws(() => + graphical.Entities.RegisterAndMaterializeProjection(spawn)); + + Assert.Contains( + "cannot acquire a structurally valid initial residence lease", + directRefusal.Message, + StringComparison.Ordinal); + Assert.Equal(directRefusal.Message, graphicalRefusal.Message); Assert.Equal(directTrace.Entries, graphicalTrace.Entries); + Assert.Empty(directTrace.Entries); Assert.Equal(0, direct.Entities.Count); Assert.Equal(0, direct.Objects.ObjectCount); Assert.Equal(0, graphical.EntityObjects.Entities.Count); @@ -438,6 +327,11 @@ public sealed class CurrentGameRuntimeAdapterTests public void GraphicalObserverFailure_DoesNotStarveLaterObserverOrOwner() { using var harness = new Harness(); + // C3c: registration begins the canonical initial-create residence, + // whose admission requires a live session generation. + Assert.Equal( + RuntimeSessionStartStatus.Connected, + harness.Runtime.Session.Start(harness.Runtime.Generation).Status); var throwing = new ThrowingEntityObserver(); var recording = new RuntimeTraceRecorder(); IDisposable first = harness.Runtime.Subscribe(throwing); diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index 1b6c9fcc..92b78533 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -599,7 +599,7 @@ public sealed class LocalPlayerTeleportControllerTests new Vector3(x, y, z), Quaternion.Identity)); - private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new( + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new WorldSession.EntitySpawn( Guid: guid, Position: new CreateObject.ServerPosition( cell, @@ -619,7 +619,7 @@ public sealed class LocalPlayerTeleportControllerTests Name: "player", ItemType: null, MotionState: null, - MotionTableId: null); + MotionTableId: null).WithConsistentPhysics(); private sealed class NullResources : ILiveEntityResourceLifecycle { diff --git a/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs index 3b4e9cfd..ae029adb 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs @@ -544,7 +544,7 @@ public sealed class StreamingFrameControllerTests InstanceSequence: 1, MovementSequence: 1, ServerControlSequence: 1, - PositionSequence: 1); + PositionSequence: 1).WithConsistentPhysics(); } private static IReadOnlyList Drain( diff --git a/tests/AcDream.App.Tests/World/C3cR1F3DriftModelSourcePinTests.cs b/tests/AcDream.App.Tests/World/C3cR1F3DriftModelSourcePinTests.cs new file mode 100644 index 00000000..80de6162 --- /dev/null +++ b/tests/AcDream.App.Tests/World/C3cR1F3DriftModelSourcePinTests.cs @@ -0,0 +1,74 @@ +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests.World; + +/// +/// C3c-R1 F3 (coordinator resolution, 2026-08-02): the create-authority +/// drift probes in the expectation-item 6/8 tests +/// (LiveEntityHydrationControllerTests + LiveEntityCreateSupersessionRecoveryTests) +/// hand-call record.Canonical.AdvanceCreateAuthority() as an HONEST +/// MODEL of the executor drain's advance — the SOLE remaining production +/// site that advances create authority for an existing incarnation. A +/// nested production OnCreate can no longer produce that drift: +/// post-residence ExistingGeneration registration is description-only +/// (RuntimeEntityObjectLifetime gates the advance on +/// !beginInitialResidence) and ConsumeExecuted removes the +/// completed residence entry at Released, closing the FIFO-adoption path +/// (empirically confirmed: the restored nested-OnCreate probe produced no +/// drift and no CreateSupersessionRecovery). This pin flags the model as +/// STALE if the production site ever moves or loses the advance — the +/// item 6/8 probes must be re-derived from wherever it goes. +/// +public sealed class C3cR1F3DriftModelSourcePinTests +{ + [Fact] + public void HandCalledDriftProbe_StillModelsTheExecutorDrainAdvance() + { + string executor = ReadRuntimeSource( + "Entities", + "RuntimeInitialCreateContinuationExecutor.cs"); + + // Exactly one production advance, and it lives inside the + // WeenieDescription drain stage the probes model. + Assert.Single( + Regex.Matches(executor, @"_entities\.AdvanceCreateAuthority\(") + .Cast()); + Assert.Matches( + new Regex( + @"private bool ApplyWeenieDescriptionAction[\s\S]{0,6000}?" + + @"_entities\.AdvanceCreateAuthority\(canonical\);"), + executor); + + // The registration-time advance stays gated OFF the residence + // route — the reason a nested production OnCreate cannot reach the + // modeled drift. + string lifetime = ReadRuntimeSource( + "Entities", + "RuntimeEntityObjectLifetime.cs"); + Assert.Matches( + new Regex( + @"if \(!beginInitialResidence\)\s*" + + @"Entities\.AdvanceCreateAuthority\(retained\);"), + lifetime); + } + + private static string ReadRuntimeSource(params string[] relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return File.ReadAllText(Path.Combine( + directory.FullName, + "src", + "AcDream.Runtime", + Path.Combine(relativePath))); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs index 55a3e554..7626956f 100644 --- a/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs +++ b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs @@ -144,7 +144,7 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests } private static WorldSession.EntitySpawn CreateSpawn(uint guid) => - new( + new WorldSession.EntitySpawn( Guid: guid, Position: null, SetupTableId: null, @@ -157,5 +157,5 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests ItemType: null, MotionState: null, MotionTableId: null, - InstanceSequence: 1); + InstanceSequence: 1).WithConsistentPhysics(); } diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index fdd55068..7b51ef7d 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -370,12 +370,14 @@ public sealed class LiveEntityHydrationControllerTests fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); LiveEntityRecord record = fixture.Record; WorldEntity entity = record.WorldEntity!; - var body = new PhysicsBody(); - Assert.Same( - body, - fixture.Runtime.GetOrCreatePhysicsBody( - record.ServerGuid, - _ => body)); + // C3c/C3b: the first-entry conductor constructs the canonical + // physics body at Create (retail ACCObjectMaint::CreateObject / + // set_description). Capture that existing body — the identity the + // pickup/re-enter cycle must preserve — instead of seeding one. + PhysicsBody body = fixture.Runtime.GetOrCreatePhysicsBody( + record.ServerGuid, + static _ => throw new InvalidOperationException( + "The conductor-built canonical body should already exist.")); fixture.Relationships.OnUnparentAction = _ => fixture.Runtime.WithdrawLiveEntityProjection(record) ? ChildUnparentDisposition.Completed @@ -962,6 +964,14 @@ public sealed class LiveEntityHydrationControllerTests // Local-player records ordinarily do not rebucket from streaming // callbacks; an incomplete initial transaction must still recover. + // C3c: the failed Create transaction unwound before OnCreateCore's + // own drive pump ran, leaving the residence pending with FullCellId + // 0 (streaming callbacks key candidates off the committed cell). In + // production the per-frame first-entry pump completes the conductor + // independently of the failed hydration transaction; model that + // pump here, then let the streaming callback recover the partial + // projection exactly as before. + fixture.FirstEntry.DriveAll(); fixture.Controller.OnLandblockLoaded(Cell); Assert.Same(partial, record.WorldEntity); @@ -1042,7 +1052,13 @@ public sealed class LiveEntityHydrationControllerTests Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); Assert.True(fixture.Record.InitialHydrationCompleted); Assert.Single(fixture.Materializer.Calls); - Assert.Equal((ushort)2, fixture.Materializer.PositionSequences[0]); + // C3c: the nested fresher same-generation Create is admitted into + // the outer create's ACTIVE residence FIFO (AD-59) and its facts + // commit at the executor drain, in array order. The single + // materialization therefore runs from the admission-frozen seq-1 + // create; the seq-2 facts (including the name asserted above) land + // through the drain and bind via the completion receipt. + Assert.Equal((ushort)1, fixture.Materializer.PositionSequences[0]); } [Fact] @@ -1114,10 +1130,16 @@ public sealed class LiveEntityHydrationControllerTests Assert.Equal((ushort)1, fixture.Record.Generation); Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); Assert.True(fixture.Record.InitialHydrationCompleted); - Assert.Equal((ushort)3, fixture.Materializer.PositionSequences[^1]); + // C3c: the nested fresher same-generation Create is admitted into + // the replacement generation's ACTIVE residence FIFO (AD-59) and its + // facts commit at the executor drain, in array order. The + // materialization therefore runs from the admission-frozen seq-2 + // replacement create; the seq-3 facts (including the name asserted + // above) land through the drain and never re-materialize. + Assert.Equal((ushort)2, fixture.Materializer.PositionSequences[^1]); Assert.DoesNotContain( - (ushort)2, - fixture.Materializer.PositionSequences.Skip(1)); + (ushort)3, + fixture.Materializer.PositionSequences); } [Fact] @@ -1210,11 +1232,19 @@ public sealed class LiveEntityHydrationControllerTests Assert.True(record.InitialHydrationCompleted); Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); - Assert.Equal([1, 1, 2], fixture.Materializer.PositionSequences); + // C3c: a post-residence same-generation Create is description-only + // at registration — its position churn flows through the + // freshness-gated events tail (which is what makes RecoverProjection + // return false above), and create authority advances only inside the + // residence transaction. No drift means no nested + // CreateSupersessionRecovery re-materialization: the recovery's own + // SpatialRecovery attempt stays the last call at the original + // installed version. + Assert.Equal([1, 1], fixture.Materializer.PositionSequences); Assert.Equal( - LiveProjectionPurpose.CreateSupersessionRecovery, + LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[^1].Purpose); - Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion); + Assert.Equal(1UL, fixture.Materializer.InstalledCreateIntegrationVersion); } [Theory] @@ -1236,10 +1266,23 @@ public sealed class LiveEntityHydrationControllerTests if (refreshed || spawn.PositionSequence != 1) return; refreshed = true; - fixture.Controller.OnCreate(Spawn( - Generation: 1, - PositionSequence: 2, - Name: "recovery v2")); + // C3c: a fresher same-generation CreateObject no longer advances + // create authority at registration (its advance lands at the + // residence drain's WeenieDescription stage). Model that exact + // advance directly so the drift-retry machinery under test still + // fires. + // C3c-R1 F3 (coordinator resolution): this hand-call is an + // honest MODEL of the executor drain's advance + // (RuntimeInitialCreateContinuationExecutor + // .ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests). A nested + // production OnCreate can no longer reach it here: + // post-residence ExistingGeneration registration is + // description-only (RuntimeEntityObjectLifetime gates the + // advance on !beginInitialResidence, :660-665) and + // ConsumeExecuted already removed the completed residence + // entry, closing the FIFO-adoption path. + record.Canonical.AdvanceCreateAuthority(); }; fixture.Materializer.ThrowAfterMaterializePurposeOnce = LiveProjectionPurpose.CreateSupersessionRecovery; @@ -1270,8 +1313,12 @@ public sealed class LiveEntityHydrationControllerTests Assert.False(record.CreateProjectionSynchronizationPending); Assert.Same(retained, record.WorldEntity); + // C3c: the retry retransmit (a post-residence same-generation + // Create) no longer advances create authority itself, so both retry + // paths install the drift probe's version (2), not a + // retransmit-advanced 3. Assert.Equal( - retryFromLandblock ? 2UL : 3UL, + 2UL, fixture.Materializer.InstalledCreateIntegrationVersion); Assert.Equal( LiveProjectionPurpose.CreateSupersessionRecovery, @@ -1357,10 +1404,23 @@ public sealed class LiveEntityHydrationControllerTests if (refreshed || spawn.PositionSequence != 1) return; refreshed = true; - fixture.Controller.OnCreate(Spawn( - Generation: 1, - PositionSequence: 2, - Name: "ready v2")); + // C3c: a fresher same-generation CreateObject no longer advances + // create authority at registration (its advance lands at the + // residence drain's WeenieDescription stage). Model that exact + // advance directly so the drift-retry machinery under test still + // fires. + // C3c-R1 F3 (coordinator resolution): this hand-call is an + // honest MODEL of the executor drain's advance + // (RuntimeInitialCreateContinuationExecutor + // .ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests). A nested + // production OnCreate can no longer reach it here: + // post-residence ExistingGeneration registration is + // description-only (RuntimeEntityObjectLifetime gates the + // advance on !beginInitialResidence, :660-665) and + // ConsumeExecuted already removed the completed residence + // entry, closing the FIFO-adoption path. + record.Canonical.AdvanceCreateAuthority(); }; fixture.Ready.FailPublishCount = 1; @@ -1486,6 +1546,12 @@ public sealed class LiveEntityHydrationControllerTests { const uint parentGuid = 0x70000002u; using var fixture = new Fixture(originKnown: true); + // C3c: a Create whose parent is not addressable is now queued under + // the parent's GUID (retail QueueBlobForObject) instead of applying + // immediately. Register the parent so the nested parented Create + // routes exactly as before. + fixture.Runtime.RegisterLiveEntity( + Spawn(Generation: 1, PositionSequence: 1) with { Guid = parentGuid }); fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); LiveEntityRecord record = fixture.Record; WorldEntity retained = record.WorldEntity!; @@ -1522,6 +1588,17 @@ public sealed class LiveEntityHydrationControllerTests record, positionVersion)); Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(record)); + // C3c: mirror the production relationship owner + // (EquippedChildRenderController.TryAttach), which converts a + // residence-managed child's sticky residence to LegacyImmediate + // at the world -> attached kind transition. + if (record.MaterializationResidence is + AcDream.App.World.LiveEntityMaterializationResidence + .AwaitRuntimePlacement) + { + record.MaterializationResidence = AcDream.App.World + .LiveEntityMaterializationResidence.LegacyImmediate; + } WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity( Guid, Cell, @@ -1540,6 +1617,12 @@ public sealed class LiveEntityHydrationControllerTests fixture.Controller.OnCreate(CelllessSpawn( PositionSequence: 2, parentGuid)); + // C3c: a post-residence same-generation Create no longer + // advances create authority at registration (the advance lands + // at a residence drain's WeenieDescription stage). Model that + // advance directly so the supersession-recovery machinery under + // test still fires and completes at the attached-ready boundary. + record.Canonical.AdvanceCreateAuthority(); }; Assert.False(fixture.Controller.RecoverProjection( @@ -1575,6 +1658,25 @@ public sealed class LiveEntityHydrationControllerTests using var fixture = new Fixture( originKnown: true, playerGuid: retryFromLandblock ? Guid : 0u); + // C3c: a Create whose parent is not addressable is now queued under + // the parent's GUID (retail QueueBlobForObject) instead of applying + // immediately. Register the parent so the initial parented Create + // routes exactly as before — placed in a DIFFERENT landblock so this + // scaffolding identity is not itself a candidate for the recovered + // landblock's projection sweep (the child's RegisterCount assertions + // count only the child's resources). + WorldSession.EntitySpawn parentSpawn = + Spawn(Generation: 1, PositionSequence: 1) with { Guid = parentGuid }; + var parentPosition = new CreateObject.ServerPosition( + 0x01020001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + fixture.Runtime.RegisterLiveEntity(parentSpawn with + { + Position = parentPosition, + Physics = parentSpawn.Physics!.Value with + { + Position = parentPosition, + }, + }); fixture.Ready.FailPublishCount = 2; fixture.Network.ApplyAction = events => { @@ -1659,8 +1761,20 @@ public sealed class LiveEntityHydrationControllerTests if (!replaced && replacementStage == stage) { replaced = true; - fixture.Runtime.RegisterLiveEntity( - Spawn(Generation: 1, PositionSequence: 2)); + // C3c: a fresher same-generation CreateObject no longer + // advances create authority at registration — its advance + // lands at the residence drain's WeenieDescription stage. + // Model that exact advance directly so the between-stage + // revalidation guard stays covered. + // C3c-R1 F3: honest MODEL of the executor drain's advance + // (ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests); a + // nested production OnCreate can no longer reach it — + // post-residence registration is description-only + // (RuntimeEntityObjectLifetime :660-665, + // !beginInitialResidence gate) and ConsumeExecuted already + // removed the completed residence entry. + expected.Canonical.AdvanceCreateAuthority(); } return true; } @@ -1768,8 +1882,18 @@ public sealed class LiveEntityHydrationControllerTests var projection = new RecordingLiveProjectionSink( _ => { - fixture.Runtime.RegisterLiveEntity( - Spawn(Generation: 1, PositionSequence: 2)); + // C3c: a fresher same-generation CreateObject's authority + // advance now lands at the residence drain's + // WeenieDescription stage; model that advance directly. + // C3c-R1 F3: honest MODEL of the executor drain's advance + // (ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests); a + // nested production OnCreate can no longer reach it — + // post-residence registration is description-only + // (RuntimeEntityObjectLifetime :660-665, + // !beginInitialResidence gate) and ConsumeExecuted already + // removed the completed residence entry. + fixture.Record.Canonical.AdvanceCreateAuthority(); return true; }); var publisher = new LiveEntityReadyPublisher( @@ -1792,10 +1916,19 @@ public sealed class LiveEntityHydrationControllerTests WorldEntity entity = record.WorldEntity!; ulong capturedCreateIntegrationVersion = record.CreateIntegrationVersion; - // Models ProjectionPoseReady synchronously accepting a fresher - // same-generation CreateObject before EntityReady is emitted. - fixture.Runtime.RegisterLiveEntity( - Spawn(Generation: 1, PositionSequence: 2)); + // Models ProjectionPoseReady synchronously observing a fresher + // same-generation CreateObject's authority advance before + // EntityReady is emitted. C3c: that advance now lands at the + // residence drain's WeenieDescription stage + // (AdvanceCreateAuthority), not at registration; model it directly. + // C3c-R1 F3: honest MODEL of the executor drain's advance + // (ApplyWeenieDescriptionAction — the sole production site, + // source-pinned by C3cR1F3DriftModelSourcePinTests); a nested + // production OnCreate can no longer reach it — post-residence + // registration is description-only (RuntimeEntityObjectLifetime + // :660-665, !beginInitialResidence gate) and ConsumeExecuted + // already removed the completed residence entry. + record.Canonical.AdvanceCreateAuthority(); bool published = false; Assert.False(EquippedChildRenderController.PublishEntityReadyExact( @@ -1937,6 +2070,55 @@ public sealed class LiveEntityHydrationControllerTests uint playerGuid = 0u) { Resources = resources ?? new RecordingResources(); + // C3c: initial-Create registration begins the canonical + // residence, whose admission requires a live generation; the + // fixture also commits the wire landblock's collision generation + // and wires the production first-entry drive pump so each Create + // transaction completes its conductor synchronously, exactly + // like the composed graphical host. + EntityObjects.BindEventContext( + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + static () => 1UL); + EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + Cell & 0xFFFF0000u, 1UL); + EntityObjects.Physics.Engine.AddLandblock( + Cell & 0xFFFF0000u, + new AcDream.Core.Physics.TerrainSurface( + new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + Cell & 0xFFFF0000u, 1UL, ready: true); + Movement = new AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState(); + IdentityState = new AcDream.Runtime.Gameplay.RuntimeLocalPlayerIdentityState(); + var publication = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsPublicationState( + EntityObjects.Entities, + EntityObjects.Physics, + Movement, + IdentityState); + Movement.AttachPhysicsPublication(publication); + EntityObjects.LocalPlayerFirstEntry.BindPublication(publication); + IdentityState.ServerGuid = playerGuid; + FirstEntry = new AcDream.Runtime.Session.RuntimeFirstEntryDriveController( + EntityObjects, + new AcDream.Runtime.GameRuntimeClock(), + new HydrationNullCollisionSource(), + () => AcDream.Runtime.Gameplay.PlayerMovementConstructionOptions.Fallback, + static _ => new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, + 1.835f, + AcDream.Runtime.Gameplay + .RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + // C3c: the real per-session placement subscription — without it + // the first entity's unacknowledged ExecutorCompleted receipt + // wedges the one ordered FIFO and every later Create's conductor + // yields AwaitingReceiptAcknowledgement forever. Ack rules mirror + // production: Discard/ExecutorCompleted acknowledge-only; + // Place/Withdraw stay at the head for the conductor machinery. var spatial = new GpuWorldState(); spatial.AddLandblock(new LoadedLandblock( 0x0101FFFFu, @@ -1947,6 +2129,11 @@ public sealed class LiveEntityHydrationControllerTests Resources, Teardown, EntityObjects); + _placements = new AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription( + EntityObjects.Placements, + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + new FixturePlacementSink(Runtime)); Materializer = new RecordingMaterializer(Runtime, Operations); Relationships = new RecordingRelationships(Operations); Ready = new RecordingReadyPublisher(Operations); @@ -1988,7 +2175,90 @@ public sealed class LiveEntityHydrationControllerTests Timestamps, identity, deletion, - Dormant); + Dormant, + firstEntry: FirstEntry); + } + + public AcDream.Runtime.Session.RuntimeFirstEntryDriveController FirstEntry { get; } + private readonly AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription _placements; + + private sealed class FixturePlacementSink(LiveEntityRuntime runtime) + : AcDream.Runtime.Physics.IRuntimePlacementProjectionSink + { + public bool TryApply( + in AcDream.Runtime.Physics.RuntimePlacementProjectionSnapshot projection) + { + if (projection.Kind is AcDream.Runtime.Physics + .RuntimePlacementProjectionKind.Discard) + { + return true; + } + if (projection.Kind is AcDream.Runtime.Physics + .RuntimePlacementProjectionKind.ExecutorCompleted) + { + return projection.Token.ExactCellId == 0u + || runtime.TryApplyInitialCreateCompletionPresentation( + in projection); + } + return !runtime.HasActiveInitialCreateResidence( + projection.Token.Entity) + && runtime.TryApplyRuntimePlacementProjection(in projection); + } + } + public AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState Movement { get; } + public AcDream.Runtime.Gameplay.RuntimeLocalPlayerIdentityState IdentityState { get; } + + private sealed class HydrationNullCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision>.Loaded( + new AcDream.Core.Physics.FlatSetupCollision( + System.Collections.Immutable.ImmutableArray< + AcDream.Core.Physics.FlatCollisionCylinder>.Empty, + [new AcDream.Core.Physics.FlatCollisionSphere( + System.Numerics.Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatGfxObjCollisionAsset> + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatCellStructureCollisionAsset> + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } } public LiveEntityRecord Record @@ -2165,7 +2435,34 @@ public sealed class LiveEntityHydrationControllerTests }, LiveEntityProjectionKind.World, initializeProjection: null, - out LiveEntityRecord? expectedRecord); + out LiveEntityRecord? expectedRecord, + // C3c: mirror the production materializer + // (DatLiveEntityProjectionMaterializer.MaterializeProjection), + // which materializes route-1 world creates residence-managed. + // The legacy-immediate default would commit the wire cell + // out-of-band and retire the fresh residence lease before the + // fixture's drive pump ever ran (diagnosed RejectedToken). + AcDream.App.World.LiveEntityMaterializationResidence + .AwaitRuntimePlacement); + // C3c: mirror the production materializer's self-projection + // branch — when the residence-driven placement already committed + // (or a legacy post-residence path committed the cell) before + // this sidecar could exist, its completion receipt is gone, so + // presentation self-projects from the committed canonical state + // through the presentation-only bucket path. + if (entity is not null + && expectedRecord is not null + && runtime.IsCurrentCreateIntegration( + expectedCanonical, + expectedCreateIntegrationVersion) + && expectedCanonical.FullCellId != 0u + && !runtime.HasActiveInitialCreateResidence(expectedCanonical) + && !runtime.RebucketLiveEntity( + canonicalSpawn.Guid, + expectedCanonical.FullCellId)) + { + return false; + } if (ThrowAfterMaterializePurposeOnce == purpose) { ThrowAfterMaterializePurposeOnce = null; diff --git a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs index 3bdfdd62..98167806 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs @@ -423,6 +423,11 @@ public sealed class LiveEntityLifecycleStressTests canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true); EntityObjects = new RuntimeEntityObjectLifetime(Engine); + // C3c: initial-Create registration begins the canonical residence, + // whose admission requires a live session generation. + EntityObjects.BindEventContext( + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + static () => 1UL); Runtime = new LiveEntityRuntime( Spatial, new DelegateLiveEntityResourceLifecycle( diff --git a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs index 53a849d1..cafbf0e6 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs @@ -639,6 +639,9 @@ public sealed class LiveEntityPhysicsHostOwnershipTests teardown); private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) => + // C3c: residence admission requires the flattened identity fields to + // agree with a nested PhysicsDesc block; a bare logical fixture + // carries the minimal consistent one (instance timestamp only). new( Guid: guid, Position: null, @@ -652,7 +655,37 @@ public sealed class LiveEntityPhysicsHostOwnershipTests ItemType: null, MotionState: null, MotionTableId: null, - InstanceSequence: instance); + InstanceSequence: instance, + Physics: new PhysicsSpawnData( + RawState: 0u, + Position: null, + Movement: null, + AnimationFrame: null, + SetupTableId: null, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: new PhysicsTimestamps( + Position: 0, + Movement: 0, + State: 0, + Vector: 0, + Teleport: 0, + ServerControlledMove: 0, + ForcePosition: 0, + ObjDesc: 0, + Instance: instance))); private static AcDream.Core.World.WorldEntity Entity( uint localId, diff --git a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs index 366babc1..fc52de54 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs @@ -733,7 +733,10 @@ public sealed class LiveEntityPresentationControllerTests { var position = new CreateObject.ServerPosition( 0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f); - var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + // C3c: residence admission requires the nested block's Instance + // timestamp to agree with the flattened InstanceSequence. + var timestamps = new PhysicsTimestamps( + 1, 1, 1, 1, 0, 1, 0, 1, instanceSequence); var physics = new PhysicsSpawnData( RawState: (uint)state, Position: position, diff --git a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs index ad291825..8661ef29 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs @@ -247,6 +247,9 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests MotionState: null, MotionTableId: null, InstanceSequence: instance); + // C3c: residence admission requires the flattened parser + // projections to agree with a nested PhysicsDesc block. + spawn = spawn.WithConsistentPhysics(); LiveEntityRecord record = Live.RegisterAndMaterializeProjection( spawn, id => new WorldEntity diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index d4704ef4..7baa11ef 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -826,13 +826,22 @@ public sealed class LiveEntityRuntimeTests { const uint parentGuid = 0x70000020u; const uint childGuid = 0x70000021u; - var runtime = LiveEntityRuntimeFixture.Create(new GpuWorldState(), new RecordingResources()); + // C3c: initial residences defer wire applies into their FIFO until a + // host pump drives the conductors; use the driven fixture and pump + // after each Create so the parent-event tail commits as before. + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven = + LiveEntityRuntimeFixture.CreateDriven( + new GpuWorldState(), + new RecordingResources()); + LiveEntityRuntime runtime = driven.Runtime; runtime.RegisterLiveEntity(Spawn(parentGuid, 9, 1, 0x01010001u)); + driven.Pump(); runtime.ParentAttachments.Enqueue(new ParentEvent.Parsed( parentGuid, childGuid, 1, 2, 9, 5)); ResolveParent(runtime, childGuid); runtime.RegisterLiveEntity(Spawn(childGuid, 3, 4, 0x01010001u)); + driven.Pump(); ResolveParent(runtime, childGuid); Assert.True(runtime.ParentAttachments.TryGetProjection( @@ -1261,6 +1270,143 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(0.0, clock.PendingSeconds, 8); } + /// + /// C3c-R1 review R2: the presentation-only rebucket shortcut is scoped + /// to the ACTIVE initial-create residence (where the public API is + /// suppressed outright — the conductor's completion receipt is the only + /// presentation channel). A RETIRED-residence entity's sticky + /// MaterializationResidence must NOT keep it on the shortcut: the + /// unflipped legacy update routes (network position/state, teleports, + /// streaming reprojection, hydration recovery — all callers of this one + /// public RebucketLiveEntity chokepoint) are the position authority + /// again, so post-residence moves take the FULL legacy branch: + /// CommitRebucket writes the canonical cell and retail's + /// prepare_to_enter_world (0x00511FA0) clock rebase runs on every + /// root-workset membership edge. + /// + [Fact] + public void PostResidenceRebucket_TakesTheFullLegacyPathIncludingTheClockEdge() + { + const uint guid = 0x7000004Au; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven = + LiveEntityRuntimeFixture.CreateDriven( + spatial, + new RecordingResources()); + LiveEntityRuntime runtime = driven.Runtime; + RuntimeEntityRecord canonical = + Assert.IsType(runtime.RegisterLiveEntity( + Spawn(guid, 1, 1, 0x01010001u)).Canonical); + runtime.MaterializeLiveEntity( + canonical, + 0x01010001u, + id => Entity(id, guid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out _, + LiveEntityMaterializationResidence.AwaitRuntimePlacement); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + + // ACTIVE residence: the public API stays suppressed (the completion + // receipt is the entity's first world-visible moment) and no legacy + // cell commit can race the conductor's pending placement. + Assert.True(runtime.HasActiveInitialCreateResidence(canonical)); + Assert.False(runtime.RebucketLiveEntity(guid, 0x01020001u)); + Assert.Equal(0u, canonical.FullCellId); + // C3c-R1 review F5: the tracked-but-undriven entry is visible in + // the entity-object ownership ledger while it awaits its pump. + Assert.Equal( + 1, + driven.Lifetime.CaptureOwnership().FirstEntryDrivePendingCount); + + driven.Pump(); + Assert.False(runtime.HasActiveInitialCreateResidence(canonical)); + Assert.Equal( + LiveEntityMaterializationResidence.AwaitRuntimePlacement, + record.MaterializationResidence); + Assert.Equal(0x01010001u, canonical.FullCellId); + + // C3c-R1 review F5: the drive's tracked entries fold into the + // entity-object ownership ledger — one pending entry while the + // residence awaited its pump, zero after. + Assert.Equal( + 0, + driven.Lifetime.CaptureOwnership().FirstEntryDrivePendingCount); + + // RETIRED residence, loaded-to-loaded: full legacy branch commits + // the canonical cell (the presentation-only shortcut never did) and + // preserves the running clock. + RetailObjectQuantumClock clock = record.ObjectClock; + Assert.Equal(0, clock.Advance(0.02).Count); + Assert.True(runtime.RebucketLiveEntity(guid, 0x01020001u)); + Assert.Equal(0x01020001u, canonical.FullCellId); + Assert.Same(clock, record.ObjectClock); + Assert.True(clock.IsActive); + Assert.Equal(0.02, clock.PendingSeconds, 8); + + // Membership edge into a pending bucket suspends the clock; the + // pending drain's reentry rebases it for enter-world — the retail + // prepare_to_enter_world edge the shortcut skipped. + Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u)); + Assert.Equal(0x02020001u, canonical.FullCellId); + Assert.False(clock.IsActive); + spatial.AddLandblock(EmptyLandblock(0x0202FFFFu)); + Assert.True(clock.IsActive); + Assert.Equal(0.0, clock.PendingSeconds, 8); + } + + /// + /// C3c-R1 review F1: converting the sticky residence-managed + /// presentation kind to LegacyImmediate (the equipped-child + /// world→attached transition) is the owner's explicit API — it refuses + /// while the initial-create residence lease is still active, because an + /// attached materialization would otherwise race the conductor's + /// pending placement. + /// + [Fact] + public void ResidenceConversionToLegacyImmediate_RefusesWhileTheResidenceIsActive() + { + const uint guid = 0x7000004Bu; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven = + LiveEntityRuntimeFixture.CreateDriven( + spatial, + new RecordingResources()); + LiveEntityRuntime runtime = driven.Runtime; + RuntimeEntityRecord canonical = + Assert.IsType(runtime.RegisterLiveEntity( + Spawn(guid, 1, 1, 0x01010001u)).Canonical); + runtime.MaterializeLiveEntity( + canonical, + 0x01010001u, + id => Entity(id, guid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out _, + LiveEntityMaterializationResidence.AwaitRuntimePlacement); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + + Assert.Throws(() => + runtime.ConvertMaterializationResidenceToLegacyImmediate(record)); + Assert.Equal( + LiveEntityMaterializationResidence.AwaitRuntimePlacement, + record.MaterializationResidence); + + driven.Pump(); + runtime.ConvertMaterializationResidenceToLegacyImmediate(record); + Assert.Equal( + LiveEntityMaterializationResidence.LegacyImmediate, + record.MaterializationResidence); + // Idempotent once converted (and a no-op for legacy records). + runtime.ConvertMaterializationResidenceToLegacyImmediate(record); + Assert.Equal( + LiveEntityMaterializationResidence.LegacyImmediate, + record.MaterializationResidence); + } + [Fact] public void InitiallyVisibleStaticObject_RebasesWithoutBecomingActive() { @@ -1428,10 +1574,27 @@ public sealed class LiveEntityRuntimeTests { const uint stateBeforeBindGuid = 0x70000037u; const uint bindBeforeStateGuid = 0x70000038u; - var runtime = LiveEntityRuntimeFixture.Create(new GpuWorldState(), new RecordingResources()); - runtime.RegisterLiveEntity(Spawn(stateBeforeBindGuid, 1, 1, 0x01010001u)); - runtime.RegisterLiveEntity(Spawn(bindBeforeStateGuid, 1, 1, 0x01010001u)); + // C3c: initial residences defer wire applies into their FIFO until a + // host pump drives the conductors. + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven = + LiveEntityRuntimeFixture.CreateDriven( + new GpuWorldState(), + new RecordingResources()); + LiveEntityRuntime runtime = driven.Runtime; + RuntimeEntityRecord stateBeforeBindCanonical = + Assert.IsType(runtime.RegisterLiveEntity( + Spawn(stateBeforeBindGuid, 1, 1, 0x01010001u)).Canonical); + RuntimeEntityRecord bindBeforeStateCanonical = + Assert.IsType(runtime.RegisterLiveEntity( + Spawn(bindBeforeStateGuid, 1, 1, 0x01010001u)).Canonical); + // C3c/C3b: the first-entry conductor constructs the canonical body + // at Create (never-clobber: a fixture can no longer seed a + // replacement RemoteMotionRuntime over it). "Arrival order" is now + // SetState-before-the-drain (FIFO'd into the pending residence, + // applied against the conductor-built body at Execute) versus + // SetState-after-completion (legacy immediate apply). Both must + // leave the canonical body's state synchronized. PhysicsStateFlags firstState = PhysicsStateFlags.Hidden | PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions; @@ -1439,18 +1602,30 @@ public sealed class LiveEntityRuntimeTests new SetState.Parsed(stateBeforeBindGuid, (uint)firstState, 1, 2), out _)); runtime.MaterializeLiveEntity( - stateBeforeBindGuid, + stateBeforeBindCanonical, 0x01010001u, - id => Entity(id, stateBeforeBindGuid)); - var lateBody = new RemoteMotionRuntime(); - runtime.SetRemoteMotionRuntime(stateBeforeBindGuid, lateBody); - + id => Entity(id, stateBeforeBindGuid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out _, + LiveEntityMaterializationResidence.AwaitRuntimePlacement); runtime.MaterializeLiveEntity( - bindBeforeStateGuid, + bindBeforeStateCanonical, 0x01010001u, - id => Entity(id, bindBeforeStateGuid)); - var earlyBody = new RemoteMotionRuntime(); - runtime.SetRemoteMotionRuntime(bindBeforeStateGuid, earlyBody); + id => Entity(id, bindBeforeStateGuid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out _, + LiveEntityMaterializationResidence.AwaitRuntimePlacement); + driven.Pump(); + PhysicsBody lateBody = runtime.GetOrCreatePhysicsBody( + stateBeforeBindGuid, + static _ => throw new InvalidOperationException( + "The conductor-built canonical body should already exist.")); + PhysicsBody earlyBody = runtime.GetOrCreatePhysicsBody( + bindBeforeStateGuid, + static _ => throw new InvalidOperationException( + "The conductor-built canonical body should already exist.")); PhysicsStateFlags secondState = PhysicsStateFlags.Static | PhysicsStateFlags.Ethereal | PhysicsStateFlags.NoDraw; @@ -1460,8 +1635,8 @@ public sealed class LiveEntityRuntimeTests Assert.Equal((firstState & ~PhysicsStateFlags.ReportCollisions) | PhysicsStateFlags.IgnoreCollisions, - lateBody.Body.State); - Assert.Equal(secondState, earlyBody.Body.State); + lateBody.State); + Assert.Equal(secondState, earlyBody.State); } [Fact] @@ -1573,9 +1748,17 @@ public sealed class LiveEntityRuntimeTests public void PositionAfterPickup_RequiresTeleportHookEvenWithEqualTeleportStamp() { const uint guid = 0x70000043u; - var runtime = LiveEntityRuntimeFixture.Create(new GpuWorldState(), new RecordingResources()); + // C3c: initial residences defer wire applies into their FIFO until a + // host pump drives the conductors; use the driven fixture and pump + // after the Create so the pickup/position tail flows as before. + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven = + LiveEntityRuntimeFixture.CreateDriven( + new GpuWorldState(), + new RecordingResources()); + LiveEntityRuntime runtime = driven.Runtime; WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u); runtime.RegisterLiveEntity(spawn); + driven.Pump(); Assert.True(runtime.TryApplyPickup( new PickupEvent.Parsed(guid, 1, 2), out _)); diff --git a/tests/AcDream.App.Tests/World/RuntimeFirstEntryHostIntegrationTests.cs b/tests/AcDream.App.Tests/World/RuntimeFirstEntryHostIntegrationTests.cs new file mode 100644 index 00000000..829a1c25 --- /dev/null +++ b/tests/AcDream.App.Tests/World/RuntimeFirstEntryHostIntegrationTests.cs @@ -0,0 +1,743 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Plugins; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using AcDream.Runtime.Entities; +using AcDream.Runtime.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.World; + +/// +/// C3c contract integration tests: the flipped graphical host wiring driven +/// end-to-end — registration through , +/// the REAL behind the REAL +/// , +/// and the production +/// pump. +/// No conductor is ever hand-called. +/// +public sealed class RuntimeFirstEntryHostIntegrationTests +{ + private const uint Cell = 0x01010001u; + private const uint Guid = 0x70000301u; + + [Fact] + public void InitialCreate_ResidenceConductorReceipt_BindsWorldVisibilityExactlyOnce() + { + using var fixture = new HostFixture(playerGuid: 0u); + int residencesBegan = 0; + fixture.EntityObjects.BindInitialResidenceBeginNotification( + _ => residencesBegan++); + bool visibleAtMaterialize = true; + fixture.Materializer.AfterMaterialize = record => + { + // Clause 1: the sidecar exists but presentation stays suppressed + // until the conductor's completion receipt binds it. + visibleAtMaterialize = record.IsSpatiallyProjected + || record.IsSpatiallyVisible; + }; + + fixture.Controller.OnCreate(Spawn(Guid, Cell)); + + Assert.Equal(1, residencesBegan); + Assert.False(visibleAtMaterialize); + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + // The residence was consumed by the conductor inside the Create + // transaction's own pump. + Assert.False(fixture.Runtime.HasActiveInitialCreateResidence( + record.Canonical)); + Assert.Equal(Cell, record.Canonical.FullCellId); + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.NotNull(record.PhysicsBody); + Assert.Equal((record, true), Assert.Single(fixture.VisibilityEdges)); + AcDream.Plugin.Abstractions.WorldEntitySnapshot snapshot = + Assert.Single(fixture.WorldState.Entities); + Assert.Equal(record.WorldEntity!.Position, snapshot.Position); + Assert.Equal(0, fixture.EntityObjects.Placements.PendingCount); + Assert.Equal(0, fixture.FirstEntry.PendingCount); + } + + [Fact] + public void DeferredParentCreate_StaysInvisibleUntilParentReplay() + { + const uint parentGuid = 0x70000302u; + const uint childGuid = 0x70000303u; + using var fixture = new HostFixture(playerGuid: 0u); + int residencesBegan = 0; + fixture.EntityObjects.BindInitialResidenceBeginNotification( + _ => residencesBegan++); + + fixture.Controller.OnCreate(ParentedSpawn(childGuid, parentGuid)); + + // Retail queues the raw blob under the parent's GUID; nothing about + // the child may escape — no canonical, no sidecar, no presentation. + Assert.Equal(0, residencesBegan); + Assert.False(fixture.Runtime.TryGetCanonical(childGuid, out _)); + Assert.False(fixture.Runtime.TryGetRecord(childGuid, out _)); + Assert.True(fixture.Runtime.ParentAttachments.ContainsDeferredCreate( + childGuid, + instanceSequence: 1)); + Assert.Empty(fixture.WorldState.Entities); + Assert.Empty(fixture.VisibilityEdges); + + fixture.Controller.OnCreate(Spawn(parentGuid, Cell)); + // The replayed child's residence was recorded mid-drain; the next + // frame's pump (the per-frame retry phase) drives its conductor. + fixture.FirstEntry.DriveAll(); + + Assert.Equal(2, residencesBegan); + Assert.False(fixture.Runtime.ParentAttachments.ContainsDeferredCreate( + childGuid, + instanceSequence: 1)); + Assert.True(fixture.Runtime.TryGetCanonical( + childGuid, + out RuntimeEntityRecord child)); + Assert.False(fixture.Runtime.HasActiveInitialCreateResidence(child)); + Assert.Equal(0, fixture.FirstEntry.PendingCount); + // The parented child is celless and presentation-suppressed until its + // own attach/position flow — only the parent is world-visible. + Assert.Equal(0u, child.FullCellId); + Assert.False(fixture.Runtime.TryGetRecord(childGuid, out _)); + Assert.True(fixture.Runtime.TryGetRecord( + parentGuid, + out LiveEntityRecord parent)); + Assert.True(parent.IsSpatiallyVisible); + Assert.Single(fixture.WorldState.Entities); + } + + [Fact] + public void LocalLogin_PresentationAttachFailure_RetriesWithoutRuntimeRollback() + { + using var fixture = new HostFixture(playerGuid: Guid); + // The camera/shadow-analog App attach failure: the first + // world-visibility binding throws AFTER Runtime committed the + // controller/body/placement. + fixture.VisibilityFailuresRemaining = 1; + + fixture.Controller.OnCreate(Spawn(Guid, Cell)); + + // Runtime is NOT rolled back by the App-side presentation failure: + // the published movement controller, canonical body, and committed + // cell all survive; only the completion receipt stays pending for + // the per-frame retry. + AcDream.Runtime.Gameplay.PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + Assert.True(controller.IsRuntimePublished); + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + Assert.Equal(Cell, record.Canonical.FullCellId); + Assert.NotNull(record.PhysicsBody); + Assert.False(fixture.Runtime.HasActiveInitialCreateResidence( + record.Canonical)); + Assert.Equal(1, fixture.EntityObjects.Placements.PendingCount); + Assert.Equal((record, true), Assert.Single(fixture.VisibilityEdges)); + + Assert.True(fixture.Subscription.RetryPending()); + + Assert.Same(controller, fixture.Movement.Controller); + Assert.True(controller.IsRuntimePublished); + Assert.Equal(0, fixture.EntityObjects.Placements.PendingCount); + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.Equal(2, fixture.VisibilityEdges.Count); + } + + /// + /// C3c-F5: through the REAL flipped host wiring (hydration -> + /// residence -> conductor -> publication -> dormant activation), a + /// login onto flat ground must complete with retail's + /// first-gravity-frame contact (SmartBox::HandleCreateObject 0x00454C80 + /// -> init_player 0x00455010 -> CPhysicsObj::enter_world 0x00516170 + + /// the first simulated frame's touch, compressed via the shared #270 + /// settle) — and the outbound motion snapshot must report grounded, + /// the exact bit LocalPlayerOutboundController serializes and ACE's + /// "You can't do that while in the air!" gate reads. Spawn feet at 5 + /// over a flat floor at 4.7 (0.3 m inside the settle reach), with the + /// production human bottom-sphere origin so the authored placement + /// stands clear of the floor. + /// + [Fact] + public void LocalLogin_FlatGround_ReportsGroundedOutboundContactBit() + { + using var fixture = new HostFixture( + playerGuid: Guid, + terrainHeight: 4.7f, + moverSphereOriginZ: 0.475f); + + fixture.Controller.OnCreate(Spawn(Guid, Cell)); + + AcDream.Runtime.Gameplay.PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + Assert.True(controller.IsRuntimePublished); + Assert.True(fixture.Runtime.TryGetRecord( + Guid, + out LiveEntityRecord record)); + PhysicsBody body = Assert.IsType(record.PhysicsBody); + Assert.True(body.InWorld); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + Assert.True(body.ContactPlaneValid); + Assert.InRange(body.Position.Z, 4.65f, 4.76f); + Assert.True(controller.CanSendPositionEvent); + Assert.True(controller.CaptureMovementResult( + mouseLookEvent: false).IsOnGround); + Assert.Equal(0, fixture.FirstEntry.PendingCount); + } + + /// + /// C3c-F5 counterpart through the same real wiring: a login spawn with + /// no floor within the settle's reach stays genuinely airborne — no + /// forced grounding anywhere in the first-entry sequence. + /// + [Fact] + public void LocalLogin_AirborneSpawn_StaysGenuinelyAirborne() + { + using var fixture = new HostFixture( + playerGuid: Guid, + moverSphereOriginZ: 0.475f); + + fixture.Controller.OnCreate(Spawn(Guid, Cell)); + + AcDream.Runtime.Gameplay.PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + Assert.True(controller.IsRuntimePublished); + Assert.True(fixture.Runtime.TryGetRecord( + Guid, + out LiveEntityRecord record)); + PhysicsBody body = Assert.IsType(record.PhysicsBody); + Assert.True(body.InWorld); + Assert.False(body.InContact); + Assert.False(body.OnWalkable); + Assert.False(controller.CanSendPositionEvent); + Assert.False(controller.CaptureMovementResult( + mouseLookEvent: false).IsOnGround); + } + + [Fact] + public void GraphicalAndDirectHosts_CommitIdenticalFirstEntryRuntimeFacts() + { + // Graphical host: full flipped wiring. + using var graphical = new HostFixture(playerGuid: 0u); + graphical.Controller.OnCreate(Spawn(Guid, Cell)); + Assert.True(graphical.Runtime.TryGetCanonical( + Guid, + out RuntimeEntityRecord graphicalRecord)); + + // Direct (no-window) host: the same canonical machinery with no + // presentation at all — registration, drive pump, ack-only + // subscription (the headless host shape). + LiveEntityRuntimeFixture.DrivenLiveEntityRuntime direct = + LiveEntityRuntimeFixture.CreateDriven( + new GpuWorldState(), + new NoopResources()); + RuntimeEntityRecord directRecord = Assert.IsType( + direct.Lifetime.RegisterEntityWithInitialResidence( + Spawn(Guid, Cell), + isLocalPlayer: false).Canonical); + Assert.True(direct.Lifetime.ApplyAcceptedSpawn( + directRecord, + directRecord.CreateIntegrationVersion, + directRecord.Snapshot, + replaceGeneration: false)); + direct.Pump(); + + Assert.Equal( + FirstEntryFacts.Capture(graphicalRecord), + FirstEntryFacts.Capture(directRecord)); + Assert.False(graphical.Runtime.HasActiveInitialCreateResidence( + graphicalRecord)); + Assert.Equal(0, direct.FirstEntry.PendingCount); + Assert.Equal(0, graphical.FirstEntry.PendingCount); + } + + private readonly record struct FirstEntryFacts( + uint ServerGuid, + ushort Incarnation, + uint? LocalEntityId, + uint FullCellId, + uint CanonicalLandblockId, + ulong PositionAuthorityVersion, + ulong PlacementCommitVersion, + ulong CreateIntegrationVersion, + ushort SnapshotPositionSequence, + bool HasBody, + Vector3 BodyPosition, + Quaternion BodyOrientation, + PhysicsStateFlags BodyState, + bool BodyInWorld) + { + internal static FirstEntryFacts Capture(RuntimeEntityRecord record) => + new( + record.ServerGuid, + record.Incarnation, + record.LocalEntityId, + record.FullCellId, + record.CanonicalLandblockId, + record.PositionAuthorityVersion, + record.PlacementCommitVersion, + record.CreateIntegrationVersion, + record.Snapshot.PositionSequence, + record.PhysicsBody is not null, + record.PhysicsBody?.Position ?? default, + record.PhysicsBody?.Orientation ?? default, + record.PhysicsBody?.State ?? default, + record.PhysicsBody?.InWorld ?? false); + } + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) + { + var position = new CreateObject.ServerPosition( + cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + [], + [], + [], + null, + null, + "first entry", + (uint)ItemType.Creature, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private static WorldSession.EntitySpawn ParentedSpawn( + uint guid, + uint parentGuid) + { + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: null, + Movement: null, + AnimationFrame: 1u, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: new PhysicsAttachment(parentGuid, LocationId: 1u), + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + Position: null, + SetupTableId: 0x02000001u, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: "deferred child", + ItemType: (uint)ItemType.Creature, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + ParentGuid: parentGuid, + ParentLocation: 1u, + PlacementId: 1u, + Physics: physics); + } + + private sealed class HostFixture : IDisposable + { + internal readonly RuntimeEntityObjectLifetime EntityObjects = new(); + internal readonly LiveEntityRuntime Runtime; + internal readonly LiveEntityHydrationController Controller; + internal readonly HostMaterializer Materializer; + internal readonly AcDream.Runtime.Session.RuntimeFirstEntryDriveController + FirstEntry; + internal readonly AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription Subscription; + internal readonly AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState + Movement; + internal readonly WorldGameState WorldState = new(); + internal readonly List<(LiveEntityRecord Record, bool Visible)> + VisibilityEdges = []; + internal int VisibilityFailuresRemaining; + + internal HostFixture( + uint playerGuid, + float terrainHeight = 0f, + float moverSphereOriginZ = 0f) + { + EntityObjects.BindEventContext( + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + static () => 1UL); + EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + Cell & 0xFFFF0000u, 1UL); + EntityObjects.Physics.Engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface( + new byte[81], + Enumerable.Repeat(terrainHeight, 256).ToArray()), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + Cell & 0xFFFF0000u, 1UL, ready: true); + Movement = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerMovementState(); + var runtimeIdentity = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerIdentityState(); + var publication = new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsPublicationState( + EntityObjects.Entities, + EntityObjects.Physics, + Movement, + runtimeIdentity); + Movement.AttachPhysicsPublication(publication); + EntityObjects.LocalPlayerFirstEntry.BindPublication(publication); + runtimeIdentity.ServerGuid = playerGuid; + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + (Cell & 0xFFFF0000u) | 0xFFFFu, + new LandBlock(), + Array.Empty())); + Runtime = new LiveEntityRuntime( + spatial, + new NoopResources(), + EntityObjects); + FirstEntry = new AcDream.Runtime.Session + .RuntimeFirstEntryDriveController( + EntityObjects, + new AcDream.Runtime.GameRuntimeClock(), + new SphereCollisionSource(moverSphereOriginZ), + static () => AcDream.Runtime.Gameplay + .PlayerMovementConstructionOptions.Fallback, + static _ => new AcDream.Runtime.Gameplay + .RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, + 1.835f, + AcDream.Runtime.Gameplay + .RuntimeLocalPlayerShadowDisposition + .ProvenShapeless)); + var sink = new RuntimePlacementPresentationSink( + Runtime, + new RuntimeWorldTransitState(), + WorldState, + new WorldEvents(), + new EntityEffectPoseRegistry(), + new LocalPlayerShadowState(), + () => playerGuid, + _ => { }, + [ + (record, visible) => + { + VisibilityEdges.Add((record, visible)); + if (VisibilityFailuresRemaining > 0) + { + VisibilityFailuresRemaining--; + throw new InvalidOperationException( + "fixture presentation attach failure"); + } + }, + ]); + Subscription = new AcDream.Runtime.Physics + .RuntimePlacementProjectionSubscription( + EntityObjects.Placements, + static () => new AcDream.Runtime.RuntimeGenerationToken(1UL), + sink); + Materializer = new HostMaterializer(Runtime); + var identity = new LocalPlayerIdentityState + { + ServerGuid = playerGuid, + }; + var dormant = new DormantLiveEntityStore(); + var teardown = new NoopTeardown(); + var deletion = new LiveEntityDeletionController( + Runtime, + EntityObjects, + teardown, + identity, + dormant); + Controller = new LiveEntityHydrationController( + Runtime, + EntityObjects, + new object(), + Materializer, + new NoopRelationships(), + new AcceptingReady(), + new KnownOrigin(), + new NoopNetworkSink(), + new NoopTimestamps(), + identity, + deletion, + dormant, + firstEntry: FirstEntry); + } + + public void Dispose() + { + try + { + Runtime.Clear(); + } + catch + { + // Failure-path tests assert their own exceptions. + } + } + } + + /// + /// Mirrors the production materializer + /// (DatLiveEntityProjectionMaterializer.MaterializeProjection): route-1 + /// world creates materialize residence-managed and self-project only when + /// the committed cell already exists with no active residence. + /// + private sealed class HostMaterializer(LiveEntityRuntime runtime) + : ILiveEntityProjectionMaterializer + { + internal Action? AfterMaterialize { get; set; } + + public bool TryMaterialize( + RuntimeEntityRecord expectedCanonical, + WorldSession.EntitySpawn canonicalSpawn, + LiveProjectionPurpose purpose, + ulong expectedCreateIntegrationVersion, + AcDream.App.Rendering.LiveEntityAppearanceUpdateState? appearanceUpdate = null) + { + if (canonicalSpawn.Position is not { } position + || canonicalSpawn.SetupTableId is null) + { + return false; + } + + WorldEntity? entity = runtime.MaterializeLiveEntity( + expectedCanonical, + position.LandblockId, + id => new WorldEntity + { + Id = id, + ServerGuid = canonicalSpawn.Guid, + SourceGfxObjOrSetupId = canonicalSpawn.SetupTableId.Value, + Position = new Vector3( + position.PositionX, + position.PositionY, + position.PositionZ), + Rotation = Quaternion.Identity, + MeshRefs = [], + ParentCellId = position.LandblockId, + }, + LiveEntityProjectionKind.World, + initializeProjection: null, + out LiveEntityRecord? record, + LiveEntityMaterializationResidence.AwaitRuntimePlacement); + if (entity is null || record is null) + return false; + if (runtime.IsCurrentCreateIntegration( + expectedCanonical, + expectedCreateIntegrationVersion) + && expectedCanonical.FullCellId != 0u + && !runtime.HasActiveInitialCreateResidence(expectedCanonical) + && !runtime.RebucketLiveEntity( + canonicalSpawn.Guid, + expectedCanonical.FullCellId)) + { + return false; + } + AfterMaterialize?.Invoke(record); + return runtime.IsCurrentRecord(record); + } + + public void ResetSessionState() + { + } + } + + private sealed class NoopResources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) + { + } + + public void Unregister(WorldEntity entity) + { + } + } + + private sealed class NoopTeardown : ILiveEntityTeardownCoordinator + { + public void TearDown(LiveEntityRecord record) + { + } + + public void ForgetUnknownOwner(uint serverGuid) + { + } + } + + private sealed class NoopRelationships : ILiveEntityRelationshipProjection + { + public void OnSpawn(WorldSession.EntitySpawn spawn) + { + } + + public void OnParent(ParentEvent.Parsed update) + { + } + + public void OnCreateParentAccepted(CreateParentUpdate update) + { + } + + public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid) => + ChildUnparentDisposition.Completed; + + public bool TryApplyAttachedAppearance( + LiveEntityRecord record, + ulong objDescAuthorityVersion) => false; + } + + private sealed class AcceptingReady : ILiveEntityReadyPublisher + { + public bool Publish(LiveEntityReadyCandidate candidate) => true; + } + + private sealed class KnownOrigin : ILiveEntityWorldOriginCoordinator + { + public bool IsKnown => true; + + public LiveEntityOriginInitialization TryInitialize( + WorldSession.EntitySpawn spawn) => new(true, []); + } + + private sealed class NoopNetworkSink : ILiveEntityNetworkUpdateSink + { + public void ApplySameGeneration(SameGenerationCreateObjectEvents events) + { + } + } + + private sealed class NoopTimestamps : IAcceptedLocalPhysicsTimestampPublisher + { + public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps) + { + } + } + + private sealed class SphereCollisionSource(float sphereOriginZ = 0f) + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult< + FlatSetupCollision> ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult + .Loaded(new FlatSetupCollision( + System.Collections.Immutable.ImmutableArray< + FlatCollisionCylinder>.Empty, + [new FlatCollisionSphere( + new Vector3(0f, 0f, sphereOriginZ), + 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + + public AcDream.Content.PreparedCollisionReadResult< + FlatGfxObjCollisionAsset> ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + FlatCellStructureCollisionAsset> ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + FlatEnvCellTopology> ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } +} diff --git a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs index 1d293203..7398e43d 100644 --- a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs @@ -757,6 +757,16 @@ public sealed class RuntimePlacementPresentationSinkTests internal LiveEntityRecord Materialize(WorldSession.EntitySpawn spawn) { LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(spawn); + // C3c fixture normalization: RegisterAndMaterializeProjection's + // legacy-immediate rebucket commits the wire cell out-of-band of + // the fresh initial-create residence, leaving that residence + // stale. Converge it deterministically HERE (the query performs + // the lazy retirement, releasing the residence's never-driven + // initial SetPosition operation) so ownership snapshots captured + // by tests reflect the settled post-registration state instead of + // shifting inside the sink's own first residence-gate query. + Assert.False(Runtime.HasActiveInitialCreateResidence( + record.Canonical)); Assert.True(record.ResourcesRegistered); WorldEntity entity = record.WorldEntity!; var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 4adcb828..28527d3e 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -740,16 +740,22 @@ public sealed class UpdateFrameOrchestratorTests "AcDream.App", "Input", "PlayerModeController.cs")); + // C3c (clause 4 — sealed-setter lifecycle routing): the movement + // controller, physics body, host, and committed placement are + // Runtime-owned, published by the first-entry conductor's + // publication transaction. Player-mode entry attaches presentation + // only: it gates on the Runtime-published controller, then wires + // camera -> shadow -> host slot -> mode flag. The old pinned + // markers (PreparePositionForCommit / InstallOrRebind / + // CommitPreparedPosition / `_controllerSlot.Controller =`) were + // exactly the App-side controller construction+commit this flip + // deleted. AssertAppearsInOrder( playerModeSource, - "controller.PreparePositionForCommit(", + "controller.IsRuntimePublished", "_camera.EnterChaseMode(legacyCamera, retailCamera);", - "EntityPhysicsHostComposition.SelectStableHostWithoutRebind(", "_shadow.SyncPose(", - "EntityPhysicsHostComposition.InstallOrRebind(", - "playerEntity.SetPosition(initial.Position);", - "controller.CommitPreparedPosition();", - "_controllerSlot.Controller = controller;", + "_hostSlot.Host = playerHost;", "_mode.IsPlayerMode = true;"); Assert.Contains("_shadow.Restore(playerEntity, priorShadow);", playerModeSource, StringComparison.Ordinal); diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index 91194f05..1b7939a5 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -56,6 +56,29 @@ public class ShadowObjectRegistryTests Assert.Equal(1, reg.TotalRegistered); } + [Fact] + public void Register_CornerLandblock_DerivesRealOutdoorSeed() + { + // C3c-F3: landblock (0,0) — id 0x0000FFFF — has prefix 0x00000000. + // DeriveOutdoorSeed's prefix-0 "absent" sentinel used to reject it, + // silently dropping every landblock-baked static in the map-corner + // block. Local (12,12) = cell (0,0) = cellId 0x00000000 | 1. + var reg = new ShadowObjectRegistry(); + reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, 0x0000FFFFu); + Assert.Equal(1, reg.TotalRegistered); + Assert.Contains(reg.GetObjectsInCell(0x00000001u), e => e.EntityId == 1u); + } + + [Fact] + public void Register_AbsentLandblockId_StillKeepsWhenEmpty() + { + // The genuine "no landblock" input (id 0) must keep the + // keep-when-empty behavior (retail pc:283540) the sentinel provided. + var reg = new ShadowObjectRegistry(); + reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, 0u); + Assert.Equal(0, reg.TotalRegistered); + } + // ----------------------------------------------------------------------- // GetObjectsInCell // ----------------------------------------------------------------------- diff --git a/tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs b/tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs similarity index 86% rename from tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs rename to tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs index cbef8c97..a3e60a1c 100644 --- a/tests/AcDream.App.Tests/Physics/RemoteSpawnPlacementSettlerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs @@ -1,10 +1,15 @@ using System.Numerics; -using AcDream.App.Physics; using AcDream.Core.Physics; -namespace AcDream.App.Tests.Physics; +namespace AcDream.Core.Tests.Physics; -public sealed class RemoteSpawnPlacementSettlerTests +/// +/// C3c-F5: moved from tests/AcDream.App.Tests/Physics/ +/// RemoteSpawnPlacementSettlerTests.cs when the #270 settler moved to Core +/// (App -> ) so the local player's +/// Runtime first-entry activation can share it. Test bodies unchanged. +/// +public sealed class SpawnPlacementSettlerTests { private const uint Landblock = 0xA9B40000u; private const uint Cell = Landblock | 0x0001u; @@ -19,7 +24,7 @@ public sealed class RemoteSpawnPlacementSettlerTests int hitGround = 0; int leaveGround = 0; - bool settled = RemoteSpawnPlacementSettler.TrySettle( + bool settled = SpawnPlacementSettler.TrySettle( engine, body, body.Position, @@ -71,7 +76,7 @@ public sealed class RemoteSpawnPlacementSettlerTests } private static bool TrySettle(PhysicsEngine engine, PhysicsBody body) => - RemoteSpawnPlacementSettler.TrySettle( + SpawnPlacementSettler.TrySettle( engine, body, body.Position, diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 7e4c2886..ff9ab063 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -238,11 +238,25 @@ public sealed class HeadlessSessionHostTests new HeadlessDiagnosticWriter(TextWriter.Null), operations); GameRuntime runtime = host.Runtime; + // C3c: conductor-driven flow — a live generation admits the initial + // residence, the flat landblock's collision generation commits, and + // the world projection's pump drives the local first-entry conductor + // to completion (the deleted SynchronizeLocalPlayer hand-copy's + // replacement). + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); const uint player = 0x50000002u; runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); RuntimeEntityRecord record = runtime.EntityObjects - .RegisterEntity(Spawn(player)) + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) .Canonical!; Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( record, @@ -252,7 +266,8 @@ public sealed class HeadlessSessionHostTests var collision = new FixtureCollisionNeighborhood(); var projection = new HeadlessSessionWorldProjection( runtime, - collision); + collision, + firstEntry); projection.ProjectSpawn(record, isLocalPlayer: true); PlayerMovementController controller = @@ -310,11 +325,25 @@ public sealed class HeadlessSessionHostTests new HeadlessDiagnosticWriter(TextWriter.Null), operations); GameRuntime runtime = host.Runtime; + // C3c: conductor-driven flow — a live generation admits the initial + // residence, the flat landblock's collision generation commits, and + // the world projection's pump drives the local first-entry conductor + // to completion (the deleted SynchronizeLocalPlayer hand-copy's + // replacement). + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); const uint player = 0x50000003u; runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); RuntimeEntityRecord record = runtime.EntityObjects - .RegisterEntity(Spawn(player)) + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) .Canonical!; Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( record, @@ -324,7 +353,8 @@ public sealed class HeadlessSessionHostTests var collision = new FixtureCollisionNeighborhood(); var projection = new HeadlessSessionWorldProjection( runtime, - collision); + collision, + firstEntry); projection.ProjectSpawn(record, isLocalPlayer: true); PlayerMovementController controller = Assert.IsType( @@ -376,6 +406,104 @@ public sealed class HeadlessSessionHostTests Assert.Equal(2, collision.CenterCount); } + /// + /// C3c-R1 review F7: a remote Create whose landblock lies outside the + /// bounded collision neighborhood's service window can never see its + /// deferred placement's collision-generation wake — left alone it would + /// pin its residence (and the drive's pending entry) for the whole + /// session. The host converts it to the celless completion route before + /// the pump: the residence completes with FullCell 0, the accepted wire + /// frame stays on the canonical snapshot (the exact pre-flip + /// accepted-frame behavior for far remotes), and every ledger converges. + /// + [Fact] + public void FarRemoteCreateCompletesCelllessWithoutPinningItsResidence() + { + 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; + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + const uint player = 0x5000000Bu; + runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); + AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); + var collision = new FixtureCollisionNeighborhood(); + var projection = new HeadlessSessionWorldProjection( + runtime, + collision, + firstEntry); + RuntimeEntityRecord playerRecord = runtime.EntityObjects + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + playerRecord, + playerRecord.CreateIntegrationVersion, + playerRecord.Snapshot, + replaceGeneration: false)); + projection.ProjectSpawn(playerRecord, isLocalPlayer: true); + + const uint farRemote = 0x70000010u; + const uint farCell = 0x00010001u; + Assert.False(collision.IsWithinServiceWindow(farCell)); + RuntimeEntityRecord remote = runtime.EntityObjects + .RegisterEntityWithInitialResidence( + Spawn(farRemote, farCell), + isLocalPlayer: false) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + remote, + remote.CreateIntegrationVersion, + remote.Snapshot, + replaceGeneration: false)); + + projection.ProjectSpawn(remote, isLocalPlayer: false); + + // Celless completion: no pinned residence, FullCell stays 0, the + // accepted wire frame survives on the canonical snapshot. + Assert.False(runtime.EntityObjects.TryGetInitialCreateResidence( + remote, + out _)); + Assert.Equal(0u, remote.FullCellId); + Assert.Equal( + farCell, + remote.Snapshot.Position!.Value.LandblockId); + RuntimeEntityObjectOwnershipSnapshot ownership = + runtime.EntityObjects.CaptureOwnership(); + Assert.Equal(0, ownership.InitialCreateResidenceLeaseCount); + Assert.Equal(0, ownership.FirstEntryDrivePendingCount); + Assert.Equal(0, firstEntry.PendingCount); + + // Draining the placement FIFO the way the host subscription would + // converges the completion-receipt ledger too. + while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)) + { + if (!runtime.EntityObjects.Physics.SetPosition + .AcknowledgeProjection(head.Token)) + { + break; + } + } + Assert.Equal( + 0, + runtime.EntityObjects.CaptureOwnership() + .PendingCompletionReceiptCount); + } + [Fact] public void PlacementReceiptValidationDoesNotRegainMovementOrPhysicsAuthority() { @@ -389,11 +517,25 @@ public sealed class HeadlessSessionHostTests new HeadlessDiagnosticWriter(TextWriter.Null), operations); GameRuntime runtime = host.Runtime; + // C3c: conductor-driven flow — a live generation admits the initial + // residence, the flat landblock's collision generation commits, and + // the world projection's pump drives the local first-entry conductor + // to completion (the deleted SynchronizeLocalPlayer hand-copy's + // replacement). + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); const uint player = 0x50000004u; runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); RuntimeEntityRecord record = runtime.EntityObjects - .RegisterEntity(Spawn(player)) + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) .Canonical!; Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( record, @@ -402,7 +544,8 @@ public sealed class HeadlessSessionHostTests replaceGeneration: false)); var directProjection = new HeadlessSessionWorldProjection( runtime, - new FixtureCollisionNeighborhood()); + new FixtureCollisionNeighborhood(), + firstEntry); directProjection.ProjectSpawn(record, isLocalPlayer: true); PlayerMovementController controller = Assert.IsType< PlayerMovementController>(runtime.MovementOwner.Controller); @@ -1010,6 +1153,197 @@ public sealed class HeadlessSessionHostTests private sealed class FixtureCollisionPublicationException : Exception; + [Fact] + public void MissingPreparedCollisionYieldsTypedRetryAndCompletesWhenAvailable() + { + // C3c contract: the prepared-collision read failure is the + // conductor's typed AwaitingCollisionSource retry — no + // InvalidDataException (or any exception) escapes the host wiring, + // the entity stays a tracked, re-drivable first entry, and the same + // sequence completes once the source can serve the Setup. + 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; + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + const uint player = 0x50000021u; + runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); + AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + var source = new FlakySetupCollisionSource(); + var firstEntry = new AcDream.Runtime.Session + .RuntimeFirstEntryDriveController( + runtime.EntityObjects, + runtime.Clock, + source, + () => PlayerMovementConstructionOptions.From( + runtime.CharacterOwner.MovementSkills.Snapshot), + static _ => new RuntimeLocalPlayerPhysicsActivationPreparation( + Radius: 0.48f, + Height: 1.835f, + RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + RuntimeEntityRecord record = runtime.EntityObjects + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + record, + record.CreateIntegrationVersion, + record.Snapshot, + replaceGeneration: false)); + var collision = new FixtureCollisionNeighborhood(); + var projection = new HeadlessSessionWorldProjection( + runtime, + collision, + firstEntry); + + projection.ProjectSpawn(record, isLocalPlayer: true); + + Assert.True(source.SetupReadAttempts >= 1); + Assert.Null(runtime.MovementOwner.Controller); + Assert.Equal(1, firstEntry.PendingCount); + Assert.Equal(0u, record.FullCellId); + + source.Available = true; + // The session tick's retry pump. + firstEntry.DriveAll(); + + Assert.IsType( + runtime.MovementOwner.Controller); + Assert.Equal(0, firstEntry.PendingCount); + Assert.Equal(0xA9B40000u, record.FullCellId & 0xFFFF0000u); + Assert.NotEqual(0u, record.FullCellId); + } + + private sealed class FlakySetupCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + internal bool Available { get; set; } + internal int SetupReadAttempts { get; private set; } + + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + SetupReadAttempts++; + if (!Available) + { + return AcDream.Content.PreparedCollisionReadResult< + FlatSetupCollision>.Missing; + } + return AcDream.Content.PreparedCollisionReadResult< + FlatSetupCollision>.Loaded(new FlatSetupCollision( + System.Collections.Immutable.ImmutableArray< + FlatCollisionCylinder>.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + } + + public AcDream.Content.PreparedCollisionReadResult< + FlatGfxObjCollisionAsset> ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + FlatCellStructureCollisionAsset> ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } + + private static AcDream.Runtime.Session.RuntimeFirstEntryDriveController + CreateFirstEntryDrive(GameRuntime runtime) => new( + runtime.EntityObjects, + runtime.Clock, + new LoadedSetupCollisionSource(), + () => PlayerMovementConstructionOptions.From( + runtime.CharacterOwner.MovementSkills.Snapshot), + static _ => new RuntimeLocalPlayerPhysicsActivationPreparation( + Radius: 0.48f, + Height: 1.835f, + RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + + private sealed class LoadedSetupCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult + .Loaded(new FlatSetupCollision( + System.Collections.Immutable.ImmutableArray< + FlatCollisionCylinder>.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + + public AcDream.Content.PreparedCollisionReadResult< + FlatGfxObjCollisionAsset> ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + FlatCellStructureCollisionAsset> ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } + private static void AddFlatLandblock(PhysicsEngine engine) { var heights = new byte[81]; @@ -1026,10 +1360,12 @@ public sealed class HeadlessSessionHostTests worldOffsetY: 0f); } - private static WorldSession.EntitySpawn Spawn(uint guid) + private static WorldSession.EntitySpawn Spawn( + uint guid, + uint cellId = 0xA9B40001u) { var position = new CreateObject.ServerPosition( - 0xA9B40001u, + cellId, 96f, 97f, 50f, @@ -1217,6 +1553,22 @@ public sealed class HeadlessSessionHostTests public bool IsReady(uint fullCellId) => fullCellId == LastCell; + + // C3c-R1 review F7: the fixture window mirrors production's 3x3 + // membership around the last requested center; no center yet means + // "within" (never convert before the first CenterOn). + public bool IsWithinServiceWindow(uint fullCellId) + { + if (LastCell == 0u) + return true; + int dx = Math.Abs( + (int)((fullCellId >> 24) & 0xFFu) + - (int)((LastCell >> 24) & 0xFFu)); + int dy = Math.Abs( + (int)((fullCellId >> 16) & 0xFFu) + - (int)((LastCell >> 16) & 0xFFu)); + return dx <= 1 && dy <= 1; + } } private sealed class FixtureEventRoute( diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs index 07ae7bd7..d3d410ad 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs @@ -104,18 +104,13 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, second); Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); - const ulong generation = 1UL; - fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( - Cell & 0xFFFF0000u, generation); - fixture.Lifetime.Physics.Engine.AddLandblock( - Cell & 0xFFFF0000u, - new TerrainSurface(new byte[81], new float[256]), - Array.Empty(), - Array.Empty(), - worldOffsetX: 0f, - worldOffsetY: 0f); - fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( - Cell & 0xFFFF0000u, generation, ready: true); + // C3c-F2: the wake goes through the SAME owner production uses (the + // collision admission ledger). Driving the SetPosition seam directly + // leaves that ledger empty — a state production can never be in, and + // the reason this very test did not catch the login activation wedge. + CommitProductionCollisionGeneration( + fixture.Lifetime.Physics, + Cell & 0xFFFF0000u); Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); @@ -603,18 +598,9 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests // AwaitingActivation retry test does. Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, fixture.Advance(out _)); - const ulong generation = 1UL; - fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( - Cell & 0xFFFF0000u, generation); - fixture.Lifetime.Physics.Engine.AddLandblock( - Cell & 0xFFFF0000u, - new TerrainSurface(new byte[81], new float[256]), - Array.Empty(), - Array.Empty(), - worldOffsetX: 0f, - worldOffsetY: 0f); - fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( - Cell & 0xFFFF0000u, generation, ready: true); + CommitProductionCollisionGeneration( + fixture.Lifetime.Physics, + Cell & 0xFFFF0000u); RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance( out RuntimeInitialCreateExecutionReceipt receipt); @@ -631,6 +617,63 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests // Helpers // --------------------------------------------------------------- + /// + /// C3c-F2: publishes one landblock collision generation through the exact + /// production owner chain — BeginCollisionAdmission -> prepare -> stage -> + /// CommitCollisionGeneration — so a parked local-player activation wakes + /// the way it does live, with RuntimePhysicsState's admission ledger + /// populated and then retired. + /// + private static void CommitProductionCollisionGeneration( + RuntimePhysicsState physics, + uint landblockId) + { + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(landblockId); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + new RuntimeLandblockCollisionAssets( + landblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + 0f, + 0f, + 0u)); + for (int poll = 0; poll < 10_000; poll++) + { + while (physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection)) + { + Assert.True(physics.SetPosition.AcknowledgeProjection( + projection.Token)); + } + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal(admission, prepared); + } + while (!seal.Completed && !seal.Restarted); + if (!seal.Completed) + continue; + if (physics.CommitCollisionGeneration(admission, prepared).Completed) + return; + } + + throw new InvalidOperationException( + "Collision generation did not complete its Runtime mutation transaction."); + } + private static (RuntimeEntityRecord Record, RuntimePlacementProjectionToken Token) BeginPendingOrdinaryPlacement(Fixture fixture, uint guid) { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs index 1c6194e9..e4517127 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs @@ -244,4 +244,261 @@ public sealed class RuntimeLocalPlayerMovementStateTests Assert.Equal(0L, allocated); } + + // ── C3c-F1 (2026-08-02): movement-stats application seam ────────────── + // + // The typed seam is the ONLY route by which server stat recomputes + // reach the controller; the publication lifecycle decides whether the + // write lands (live/dormant) or is a typed displaced drop (terminal). + + private static RuntimeMovementSkillState CompleteSkillState() + { + var skills = new RuntimeMovementSkillState(); + skills.Update(runSkill: 240, jumpSkill: 180); + skills.UpdateBurden(0.5f); + skills.UpdateStamina(37); + // BF_PLAYER (0x8) + BF_PLAYER_KILLER (0x20) → ObjectInfoState.IsPK. + skills.UpdateOwnPwdBitfield(0x28u); + skills.UpdatePlayerKillerStatus(1, 123.5f); + return skills; + } + + private static PlayerMovementController NewDormantRuntimeController() + { + PlayerMovementController controller = + PlayerMovementController.CreatePublicationCandidate( + new PhysicsEngine(), + PlayerMovementConstructionOptions.Fallback); + controller.SealPublicationCandidate(); + controller.CommitRuntimeOwnership(new RetailObjectQuantumClock()); + return controller; + } + + private static (float RunRate, float JumpVz, bool CanJump, int JumpCost, + ObjectInfoState PvpFlags) CaptureStatObservables( + PlayerMovementController controller) + { + IWeenieObject weenie = controller.Motion.WeenieObj!; + Assert.True(weenie.InqRunRate(out float runRate)); + Assert.True(weenie.InqJumpVelocity(1.0f, out float jumpVz)); + bool canJump = weenie.CanJump(1.0f); + Assert.True(((PlayerWeenie)weenie).JumpStaminaCost(1.0f, out int cost)); + return (runRate, jumpVz, canJump, cost, controller.OwnPvpFlags); + } + + [Fact] + public void ApplyCharacterMovementStats_LiveApplicationIsByteIdenticalToTheRetiredDirectPath() + { + RuntimeMovementSkillState skills = CompleteSkillState(); + RuntimeMovementSkillSnapshot snapshot = skills.Snapshot; + + // Old direct path (the deleted RuntimeMovementSkillProjection.ApplyTo + // body), applied through the public gated setters. + var direct = new PlayerMovementController(new PhysicsEngine()); + direct.SetCharacterSkills(snapshot.RunSkill, snapshot.JumpSkill); + direct.SetCharacterBurden(snapshot.Burden); + direct.SetCharacterStamina(snapshot.CurrentStamina); + direct.OwnPvpFlags = EntityCollisionFlagsExt + .FromPwdBitfield(snapshot.OwnPwdBitfield) + .ToMoverState(); + direct.SetCharacterPkStatus( + snapshot.PlayerKillerStatus, + snapshot.LastPkAttackTimestamp); + + var routed = new PlayerMovementController(new PhysicsEngine()); + using var movement = new RuntimeLocalPlayerMovementState + { + Controller = routed, + }; + + Assert.Equal( + RuntimeMovementStatsApplication.AppliedLive, + movement.ApplyCharacterMovementStats(skills)); + Assert.Equal( + CaptureStatObservables(direct), + CaptureStatObservables(routed)); + Assert.Equal(ObjectInfoState.IsPK, routed.OwnPvpFlags); + } + + [Fact] + public void ApplyCharacterMovementStats_DormantWindowWriteLandsOnTheControllerThatGoesLive() + { + RuntimeMovementSkillState skills = CompleteSkillState(); + PlayerMovementController controller = NewDormantRuntimeController(); + using var movement = new RuntimeLocalPlayerMovementState + { + Controller = controller, + }; + + // The committed-but-unactivated first-entry window: the dormant + // controller is installed in the movement owner while activation + // waits on cell streaming, and inbound recomputes keep pumping. + Assert.True(controller.IsRuntimeOwnedDormant); + Assert.Throws( + () => controller.SetCharacterSkills(1, 1)); + Assert.Equal( + RuntimeMovementStatsApplication.AppliedDormant, + movement.ApplyCharacterMovementStats(skills)); + + // Activation flips the SAME instance live — the applied values are + // already current when movement starts. + controller.ActivateRuntimePublication(); + Assert.True(controller.IsRuntimePublished); + (float runRate, _, _, _, ObjectInfoState pvp) = + CaptureStatObservables(controller); + Assert.Equal( + PlayerWeenie.GetRunRate(0.5f, 240), + runRate, + precision: 5); + Assert.Equal(ObjectInfoState.IsPK, pvp); + } + + [Fact] + public void ApplyCharacterMovementStats_TerminalControllerReportsTypedDisplacedDrop() + { + RuntimeMovementSkillState skills = CompleteSkillState(); + PlayerMovementController controller = NewDormantRuntimeController(); + using var movement = new RuntimeLocalPlayerMovementState + { + Controller = controller, + }; + controller.ActivateRuntimePublication(); + var before = CaptureStatObservables(controller); + // The Motion surface is gated once terminal; capture the weenie + // reference while the controller is still published. + IWeenieObject weenie = controller.Motion.WeenieObj!; + + // Post-teardown transient truth: the controller retired while still + // reachable through a displaced recompute callback. The public + // setter still throws (the seal working); the seam reports the + // typed drop instead and mutates nothing. + controller.RetireRuntimePublication(); + Assert.Throws( + () => controller.SetCharacterSkills(1, 1)); + Assert.Equal( + RuntimeMovementStatsApplication.DroppedDisplacedController, + movement.ApplyCharacterMovementStats(skills)); + + Assert.True(weenie.InqRunRate(out float runRate)); + Assert.Equal(before.RunRate, runRate); + Assert.Equal(before.PvpFlags, controller.OwnPvpFlags); + } + + [Fact] + public void ApplyCharacterMovementStats_SealedCandidateIsATypedDisplacedDrop() + { + // Defensive lifecycle-matrix row: a sealed candidate is frozen for + // the Prepare→Commit validation handshake and never installed into + // the movement owner in production (Prepare requires the owner + // empty; Commit installs it already-dormant within the same + // synchronous conductor step), so only a direct controller-level + // call can observe this row. + PlayerMovementController controller = + PlayerMovementController.CreatePublicationCandidate( + new PhysicsEngine(), + PlayerMovementConstructionOptions.Fallback); + controller.SealPublicationCandidate(); + + Assert.Equal( + RuntimeMovementStatsApplication.DroppedDisplacedController, + controller.ApplyCharacterMovementStats( + CompleteSkillState().Snapshot)); + } + + [Fact] + public void ApplyCharacterMovementStats_AbsentControllerAndIncompleteSnapshotDropSilently() + { + var skills = new RuntimeMovementSkillState(); + using var movement = new RuntimeLocalPlayerMovementState(); + + Assert.Equal( + RuntimeMovementStatsApplication.DroppedNoController, + movement.ApplyCharacterMovementStats(skills)); + + movement.Controller = new PlayerMovementController(new PhysicsEngine()); + Assert.Equal( + RuntimeMovementStatsApplication.DroppedIncompleteSnapshot, + movement.ApplyCharacterMovementStats(skills)); + } + + [Fact] + public void ApplyCharacterMovementStats_DisposedOwnerToleratesTheDisplacedCallback() + { + var movement = new RuntimeLocalPlayerMovementState + { + Controller = new PlayerMovementController(new PhysicsEngine()), + }; + movement.Dispose(); + + // The displaced-callback-rejection pattern: a recompute landing + // after terminal disposal observes a typed drop, never a fault. + Assert.Equal( + RuntimeMovementStatsApplication.DroppedNoController, + movement.ApplyCharacterMovementStats(CompleteSkillState())); + Assert.False(movement.ReportExhaustion()); + } + + [Fact] + public void ReportExhaustion_DispatchesOnlyThroughALiveController() + { + using var movement = new RuntimeLocalPlayerMovementState(); + Assert.False(movement.ReportExhaustion()); + + PlayerMovementController controller = NewDormantRuntimeController(); + movement.Controller = controller; + Assert.False(movement.ReportExhaustion()); + + controller.ActivateRuntimePublication(); + Assert.True(movement.ReportExhaustion()); + + controller.RetireRuntimePublication(); + Assert.False(movement.ReportExhaustion()); + } + + [Fact] + public void ApplyServerPhysicsState_DormantDropsForActivationAndLiveAppliesExactly() + { + // The second connected-gate crash chain + // (logs/connected-world-gate-20260802-125907): an inbound local- + // player SetState pushed ApplyPhysicsState at the dormant + // first-entry controller. The typed entry decides by lifecycle. + PlayerMovementController controller = + PlayerMovementController.CreatePublicationCandidate( + new PhysicsEngine(), + PlayerMovementConstructionOptions.Fallback); + PhysicsBody body = controller.PhysicsBody; + PhysicsStateFlags initial = body.State; + PhysicsStateFlags pushed = + PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Ethereal; + Assert.NotEqual(initial, pushed); + + controller.SealPublicationCandidate(); + controller.CommitRuntimeOwnership(new RetailObjectQuantumClock()); + + // Dormant: the activation transaction owns the dormant body's + // physics state; the push is dropped and the body is untouched. + Assert.Equal( + RuntimeServerPhysicsStateApplication.DroppedDormantActivationOwned, + controller.ApplyServerPhysicsState(pushed)); + Assert.Equal(initial, body.State); + + // Published: byte-identical to the direct ApplyPhysicsState body. + controller.ActivateRuntimePublication(); + Assert.Equal( + RuntimeServerPhysicsStateApplication.AppliedLive, + controller.ApplyServerPhysicsState(pushed)); + Assert.Equal(pushed, body.State); + + // Terminal: a displaced push, typed instead of a fault, mutating + // nothing. + controller.RetireRuntimePublication(); + Assert.Throws( + () => controller.ApplyPhysicsState(initial)); + Assert.Equal( + RuntimeServerPhysicsStateApplication.DroppedDisplacedController, + controller.ApplyServerPhysicsState(initial)); + Assert.Equal(pushed, body.State); + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs index 0c432e83..3c8468d2 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs @@ -198,6 +198,175 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Assert.True(body.InWorld); } + /// + /// C3c-F5: retail seeds the local player's ground contact from the first + /// gravity frame after enter_world (SmartBox::HandleCreateObject + /// 0x00454C80 → init_player 0x00455010 → CPhysicsObj::enter_world + /// 0x00516170; SetPosition records no touch), which the activation + /// compresses through the shared #270 settle sweep. Spawn (1,2,3) with a + /// flat floor at 2.7 — feet 0.3 m above it, inside the 0.5 m settle + /// reach — and the production human bottom-sphere origin (0.475) so the + /// authored placement itself stands clear of the floor. + /// + [Fact] + public void CommitActivationOnFlatGroundSeedsRetailFirstGravityFrameContact() + { + using var fixture = new Fixture( + residentWorld: true, + terrainHeight: 2.7f, + moverSphereOriginZ: 0.475f); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out _)); + + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + Assert.True(body.ContactPlaneValid); + Assert.True(body.ContactPlane.Normal.Z > 0.9f); + // The settle snapped the feet onto the floor, exactly like retail's + // first gravity frame. + Assert.InRange(body.Position.Z, 2.65f, 2.76f); + PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + // The exact outbound predicate LocalPlayerOutboundController + // serializes as the wire contact bit (InContact && OnWalkable). + Assert.True(controller.CanSendPositionEvent); + Assert.True(controller.CaptureMovementResult( + mouseLookEvent: false).IsOnGround); + } + + /// + /// C3c-F5 counterpart: a spawn with no floor within the settle's reach + /// (flat terrain at 0, feet at 3) must stay genuinely airborne — the + /// seed only ever commits contact the sweep actually found, exactly + /// like retail's fall after enter_world. + /// + [Fact] + public void CommitActivationOverVoidLeavesFirstEntryGenuinelyAirborne() + { + using var fixture = new Fixture( + residentWorld: true, + moverSphereOriginZ: 0.475f); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out _)); + + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + Assert.False(body.InContact); + Assert.False(body.OnWalkable); + Assert.False(body.ContactPlaneValid); + Assert.Equal(3f, body.Position.Z); + PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + Assert.False(controller.CanSendPositionEvent); + Assert.False(controller.CaptureMovementResult( + mouseLookEvent: false).IsOnGround); + } + + /// + /// C3c-R1 review R1: the login constraint leash. Retail arms the leash + /// at every accepted-position event (SmartBox::HandleReceivedPosition + /// 0x00453FD0); the flip deleted the App-side login arm + /// (CommitPreparedPosition), so the dormant activation's final commit + /// must arm it — anchored at the committed placement (already + /// floor-snapped by the faithful placement transaction), before the + /// compressed first-gravity-frame settle runs, exactly like retail arms + /// at the received position and only then simulates the first frame. + /// + [Fact] + public void CommitActivationArmsTheLoginConstraintLeashAtTheCommittedPlacement() + { + using var fixture = new Fixture( + residentWorld: true, + terrainHeight: 2.7f, + moverSphereOriginZ: 0.475f); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out _)); + + PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + ConstraintManager? constraint = + controller.PositionManager?.Constraint; + Assert.NotNull(constraint); + Assert.True(constraint!.IsConstrained); + // The anchor cell is the placement's committed containing cell. + Assert.Equal( + controller.PhysicsBody.CellPosition.ObjCellId, + constraint.ConstraintPos.ObjCellId); + // Anchored at the committed (floor-snapped) placement pose. + Assert.InRange( + constraint.ConstraintPos.Frame.Origin.Z, 2.65f, 2.76f); + Assert.InRange( + controller.PhysicsBody.Position.Z, 2.65f, 2.76f); + Assert.Equal( + ConstraintDistance.GetStartConstraintDistance( + constraint.ConstraintPos.ObjCellId), + constraint.ConstraintDistanceStart); + Assert.Equal( + ConstraintDistance.GetMaxConstraintDistance( + constraint.ConstraintPos.ObjCellId), + constraint.ConstraintDistanceMax); + } + + /// + /// C3c-R1 review R1: the arm is exactly-once — the successful final + /// commit nulls the activation envelope, so a stale CommitActivation + /// retry is RejectedAuthority and can never re-arm the leash. + /// + [Fact] + public void CommitActivationNeverRearmsTheLeashOnAStaleRetry() + { + using var fixture = new Fixture( + residentWorld: true, + terrainHeight: 2.7f, + moverSphereOriginZ: 0.475f); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var evaluation)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(evaluation, out _)); + PlayerMovementController controller = + Assert.IsType( + fixture.Movement.Controller); + Assert.True( + controller.PositionManager!.Constraint!.IsConstrained); + + controller.PositionManager.UnConstrain(); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority, + fixture.Owner.CommitActivation(evaluation, out _)); + + Assert.False( + controller.PositionManager.Constraint.IsConstrained); + } + [Fact] public void DeferredActivationEvaluationLeavesExactOwnedGraphDormantAndRetryable() { @@ -356,10 +525,117 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Assert.Equal(1, fixture.Owner.CaptureOwnership() .PendingActivationCount); - const ulong generation = 1UL; - fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( - Cell & 0xFFFF0000u, - generation); + // C3c-F2: the wake is driven through the SAME owner production uses + // (RuntimePhysicsState's collision admission), not through the raw + // SetPosition seam. Driving SetPosition directly leaves the admission + // ledger empty, which is a state production can never be in and which + // hid the login rearm wedge this test is supposed to cover. + CommitProductionCollisionGeneration(fixture, Cell & 0xFFFF0000u); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var ready)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(ready, out var projection)); + Assert.True(projection.IsValid); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + } + + [Fact] + public void DeferredCommitRearmsAfterProductionAdmissionCommitsItsGeneration() + { + // C3c-F2 regression (the connected-gate login wedge). The sibling + // test above wakes the parked lease by calling BeginCollisionGeneration + // / CommitCollisionGeneration DIRECTLY on the SetPosition state, which + // leaves RuntimePhysicsState's admission ledger empty. PRODUCTION + // always wakes through BeginCollisionAdmission -> stage -> + // CommitCollisionGeneration, and that path retires the admission the + // instant the generation commits — after which + // ExpectedCollisionGeneration names the NEXT, never-begun generation. + // The rearm must key off the generation the collision world now HOLDS. + using var fixture = new Fixture(); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var deferred)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell, + fixture.Owner.CommitActivation(deferred, out var noProjection)); + Assert.False(noProjection.IsValid); + Assert.False(fixture.Record.PhysicsBody!.InWorld); + Assert.False(fixture.Movement.Controller!.IsRuntimePublished); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + + RuntimeCollisionAdmission admission = fixture.Lifetime.Physics + .BeginCollisionAdmission(Cell & 0xFFFF0000u); + // While the destination admission is in flight the lease must simply + // keep waiting — never a rejection, never an early rearm. + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var waiting)); + Assert.False(waiting.IsValid); + using PreparedLandblockCollisionGeneration prepared = fixture + .Lifetime.Physics.PrepareCollisionGeneration(admission); + fixture.Lifetime.Physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(Cell & 0xFFFF0000u)); + Assert.True(CommitPrepared( + fixture.Lifetime.Physics, + admission, + prepared).Committed); + + // The exact divergence this test pins: the committed authority is the + // generation the lease parked on; "expected" has already moved past it. + Assert.Equal( + admission.Generation, + fixture.Lifetime.Physics.CollisionGenerationAuthority(Cell)); + Assert.NotEqual( + admission.Generation, + fixture.Lifetime.Physics.ExpectedCollisionGeneration(Cell)); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, + fixture.Owner.EvaluateActivation(token, out var ready)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, + fixture.Owner.CommitActivation(ready, out var projection)); + Assert.True(projection.IsValid); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.NotNull(fixture.Record.PhysicsHost); + Assert.True(fixture.Movement.Controller!.IsRuntimePublished); + Assert.Equal(0, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + } + + [Fact] + public void DeferredCommitStaysParkedWhileTheCommittingAdmissionIsStillRegistered() + { + // C3c-F2, the link the live probe caught: the collision-generation + // commit marks the parked lease ready and only THEN retires its + // admission (RuntimePhysicsState.cs:2503 vs :2552-2558). Production + // reenters the first-entry pump inside that window. Rearming there + // moves the lease out of AwaitingCell and its very next evaluation + // fails the seal on the still-registered admission — after which + // EvaluateActivation can no longer answer DeferredCell and reports + // RejectedAuthority, which is TERMINAL for the conductor. The lease + // must stay parked and retryable instead. + using var fixture = new Fixture(); + Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed, + fixture.Owner.Commit( + fixture.Prepare(), + out RuntimeLocalPlayerPhysicsActivationToken token)); + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var deferred)); + Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell, + fixture.Owner.CommitActivation(deferred, out _)); + + RuntimeCollisionAdmission admission = fixture.Lifetime.Physics + .BeginCollisionAdmission(Cell & 0xFFFF0000u); + // Reconstruct the exact production instant: the landblock is in the + // engine and the parked lease has been marked ready by the SAME call + // RuntimePhysicsState.cs:2503 makes, while the admission that is + // committing has not yet been retired (:2552-2558). fixture.Lifetime.Physics.Engine.AddLandblock( Cell & 0xFFFF0000u, new TerrainSurface(new byte[81], new float[256]), @@ -369,8 +645,39 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests worldOffsetY: 0f); fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( Cell & 0xFFFF0000u, - generation, + admission.Generation, ready: true); + Assert.Equal( + admission.Generation, + fixture.Lifetime.Physics.CollisionGenerationAuthority(Cell)); + Assert.Equal( + admission.Generation, + fixture.Lifetime.Physics.ExpectedCollisionGeneration(Cell)); + Assert.False(fixture.Lifetime.Physics + .IsCollisionEvaluationPrefixAdmissible(Cell)); + + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, + fixture.Owner.EvaluateActivation(token, out var stillWaiting)); + Assert.False(stillWaiting.IsValid); + Assert.Equal(1, fixture.Owner.CaptureOwnership() + .PendingActivationCount); + Assert.False(fixture.Record.PhysicsBody!.InWorld); + Assert.False(fixture.Movement.Controller!.IsRuntimePublished); + + using (PreparedLandblockCollisionGeneration prepared = fixture + .Lifetime.Physics.PrepareCollisionGeneration(admission)) + { + fixture.Lifetime.Physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(Cell & 0xFFFF0000u)); + Assert.True(CommitPrepared( + fixture.Lifetime.Physics, + admission, + prepared).Committed); + } + Assert.True(fixture.Lifetime.Physics + .IsCollisionEvaluationPrefixAdmissible(Cell)); Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, fixture.Owner.EvaluateActivation(token, out var ready)); @@ -378,6 +685,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests fixture.Owner.CommitActivation(ready, out var projection)); Assert.True(projection.IsValid); Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.True(fixture.Movement.Controller!.IsRuntimePublished); Assert.Equal(0, fixture.Owner.CaptureOwnership() .PendingActivationCount); } @@ -562,15 +870,9 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Assert.Equal(0, fixture.Lifetime.Physics.Engine.ShadowObjects .PendingSetPositionDispatchCount); - const ulong generation = 1UL; - fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( - Cell & 0xFFFF0000u, generation); - fixture.Lifetime.Physics.Engine.AddLandblock( - Cell & 0xFFFF0000u, - new TerrainSurface(new byte[81], new float[256]), - Array.Empty(), Array.Empty(), 0f, 0f); - fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( - Cell & 0xFFFF0000u, generation, ready: true); + // C3c-F2: production wake path (collision admission), see + // CommitProductionCollisionGeneration. + CommitProductionCollisionGeneration(fixture, Cell & 0xFFFF0000u); Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, fixture.Owner.EvaluateActivation(token, out var ready)); Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, @@ -2192,6 +2494,11 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests private sealed class Fixture : IDisposable { private bool _lifetimeDisposed; + // C3c-F5: default 0 keeps every pre-existing test's mover shape + // byte-identical; the first-entry settle tests pass the production + // human Setup's bottom-sphere origin (0.475) so an authored + // placement can stand clear of a floor the settle then reaches. + private readonly float _moverSphereOriginZ; internal Fixture( bool preparePlacement = true, @@ -2199,11 +2506,13 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests RuntimeLocalPlayerShadowDisposition shadowDisposition = RuntimeLocalPlayerShadowDisposition.ProvenShapeless, float terrainHeight = 0f, + float moverSphereOriginZ = 0f, Vector3? initialVelocity = null, Vector3? initialOmega = null, float? initialFriction = null, float? initialElasticity = null) { + _moverSphereOriginZ = moverSphereOriginZ; if (residentWorld) { var engine = new PhysicsEngine @@ -2269,7 +2578,9 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Assert.True(Placement.IsValid); var setup = new FlatSetupCollision( ImmutableArray.Empty, - [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + [new FlatCollisionSphere( + new Vector3(0f, 0f, _moverSphereOriginZ), + 0.48f)], height: 0f, radius: 0f, stepUpHeight: 0.4f, @@ -2537,6 +2848,31 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests "Collision generation did not complete its Runtime mutation transaction."); } + /// + /// C3c-F2: publishes one landblock collision generation through the exact + /// production owner chain — BeginCollisionAdmission -> prepare -> stage -> + /// CommitCollisionGeneration. Waking a parked activation any other way + /// (calling the SetPosition seam directly) leaves RuntimePhysicsState's + /// admission ledger empty, a state production can never reach. + /// + private static void CommitProductionCollisionGeneration( + Fixture fixture, + uint landblockId) + { + RuntimeCollisionAdmission admission = fixture.Lifetime.Physics + .BeginCollisionAdmission(landblockId); + using PreparedLandblockCollisionGeneration prepared = fixture + .Lifetime.Physics.PrepareCollisionGeneration(admission); + fixture.Lifetime.Physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId)); + Assert.True(CommitPrepared( + fixture.Lifetime.Physics, + admission, + prepared).Committed); + } + private static RuntimeLandblockCollisionAssets CollisionAssets( uint landblockId) => new( landblockId, diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.CornerLandblock.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.CornerLandblock.cs new file mode 100644 index 00000000..d2465a54 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionPrefixQuiescenceTests.CornerLandblock.cs @@ -0,0 +1,356 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +/// +/// C3c-F3: landblock (0,0) — id 0x0000FFFF, Dereth's south-west map corner — +/// has the legitimate collision prefix 0x00000000. The prefix-0 "absent" +/// sentinel used to make every collision publication, quiescence, park, wake, +/// and retirement against that landblock throw +/// from +/// RuntimeSetPositionState.BeginCollisionPrefixQuiescence (the +/// connected-gate crash at teleport destination (9,4), whose far streaming +/// radius reaches the corner: logs/connected-world-gate-20260802-135444). +/// These tests drive the exact production owner chain against the corner id +/// and, for quiescence semantics, assert step-for-step parity with a +/// nonzero-prefix landblock. +/// +public sealed partial class RuntimeCollisionPrefixQuiescenceTests +{ + private const uint CornerLandblock = 0x0000FFFFu; + private const uint CornerPrefix = 0x00000000u; + private const uint CornerCell = 0x00000001u; + private const uint CornerCell2 = 0x00000002u; + private const uint CornerIndoorCell = 0x00000100u; + private const uint NeighborLandblock = 0x0001FFFFu; + + [Fact] + public void CornerLandblockCollisionGenerationCommitsThroughTheProductionAdmissionChain() + { + // Empty engine — production streaming publishes the corner landblock + // from nothing, exactly like LandblockPhysicsPublisher.AdvanceCompleteOne. + using var fixture = new Fixture( + bindGeneration: true, + engine: new PhysicsEngine { DataCache = new PhysicsDataCache() }); + RuntimePhysicsState physics = fixture.Lifetime.Physics; + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(CornerLandblock); + Assert.Equal(CornerLandblock, admission.LandblockId); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, CornerLandblock); + // Pre-fix this first commit threw ArgumentOutOfRangeException + // ("landblockId") from BeginCollisionPrefixQuiescence's prefix == 0 + // sentinel guard. + RuntimeCollisionGenerationCommit commit = + CommitToCompletion(physics, admission, prepared); + Assert.True(commit.Completed); + Assert.True(physics.Engine.IsLandblockTerrainResident(CornerLandblock)); + + // The neighbouring landblock (0,1) — prefix 0x00010000 — publishes + // identically through the same chain. + RuntimeCollisionAdmission neighborAdmission = + physics.BeginCollisionAdmission(NeighborLandblock); + using PreparedLandblockCollisionGeneration neighborPrepared = + PrepareSealedMutation(physics, neighborAdmission, NeighborLandblock); + RuntimeCollisionGenerationCommit neighborCommit = + CommitToCompletion(physics, neighborAdmission, neighborPrepared); + Assert.True(neighborCommit.Completed); + Assert.True( + physics.Engine.IsLandblockTerrainResident(NeighborLandblock)); + + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(0, ownership.CollisionPrefixMutationCount); + Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount); + Assert.Equal(0, ownership.PendingCollisionPrefixProjectionCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + + [Fact] + public void CornerResidentParksAndRestoresAcrossAnActivationReplacement() + { + // Mirror of ActivationWaitsForExactWithdrawAndPlaceReceipts against + // the corner landblock: publish/park (ParkDeferred's quiescence + // override carries prefix 0x00000000), wake, and restore. + using var fixture = new Fixture( + bindGeneration: true, + engine: CornerEngine()); + RuntimeEntityRecord record = fixture.Add( + 0x700031F1u, + 1, + CornerCell, + new Vector3(11f, 12f, 0f)); + RuntimeSetPositionOutcome seeded = fixture.Place( + record, + CornerCell, + new Vector3(11.5f, 12f, 0f)); + // SetPosition against a corner cell commits (host acknowledgement of + // the Place projection is the ordinary pending suffix, identical to + // any nonzero-prefix landblock). + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + seeded.Status); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(seeded.Projection)); + + RuntimePhysicsState physics = fixture.Lifetime.Physics; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(CornerLandblock); + using PreparedLandblockCollisionGeneration prepared = + PrepareSealedMutation(physics, admission, CornerLandblock); + + RuntimeCollisionGenerationCommit first = + physics.CommitCollisionGeneration(admission, prepared); + Assert.False(first.EngineCommitted); + Assert.False(first.Completed); + Assert.False(physics.IsSpatialRoot(record)); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawn)); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawn.Kind); + + Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawn.Token)); + _ = SealMutation(physics, admission, prepared); + RuntimeCollisionGenerationCommit transferred = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(transferred.EngineCommitted); + Assert.False(transferred.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored)); + Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind); + + Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token)); + RuntimeCollisionGenerationCommit completed = + physics.CommitCollisionGeneration(admission, prepared); + Assert.True(completed.Completed); + Assert.True(physics.IsSpatialRoot(record)); + Assert.Equal(CornerCell, record.FullCellId); + Assert.Equal(0, physics.CaptureOwnership().CollisionPrefixMutationCount); + Assert.Equal( + 0, + physics.CaptureOwnership().CollisionPrefixQuiescenceCount); + } + + [Fact] + public void CornerPrefixQuiescenceHoldsAndReleasesExactlyLikeANonzeroPrefix() + { + // Contract test 2: a parked deferral against the prefix-0 landblock + // holds and releases quiescence exactly like a nonzero-prefix + // landblock. The identical script runs against both and every step's + // observable outcome must match. + using var corner = new Fixture( + bindGeneration: true, + engine: CornerEngine()); + using var control = new Fixture(bindGeneration: true); + + // The corner run addresses landblock (0,0) by its canonical id + // 0x0000FFFF — the raw input 0x00000000 stays reserved for "absent" + // (see AbsentLandblockIdStillCannotBeginQuiescence). + List cornerLog = RunHeldPlacementQuiescenceCycle( + corner, + CornerLandblock, + sourceCell: CornerCell, + targetCell: CornerCell2, + guid: 0x700031F2u); + List controlLog = RunHeldPlacementQuiescenceCycle( + control, + PrefixP, + sourceCell: CellP, + targetCell: PrefixP | 0x0002u, + guid: 0x700031F3u); + + Assert.Equal(controlLog, cornerLog); + } + + [Fact] + public void CornerLandblockDemotesAndWithdrawsThroughRetirementMutations() + { + using (var demoteFixture = new Fixture( + bindGeneration: true, + engine: CornerEngine())) + { + RuntimeEntityRecord outdoor = demoteFixture.Add( + 0x700031F4u, + 1, + CornerCell, + new Vector3(12f, 41f, 0f)); + RuntimeEntityRecord indoor = demoteFixture.Add( + 0x700031F5u, + 1, + CornerIndoorCell, + new Vector3(13f, 41f, 0f)); + + RuntimeCollisionMutationResult first = demoteFixture.Lifetime + .Physics.DemoteCollisionToTerrain(CornerLandblock); + Assert.False(first.Completed); + Assert.True(demoteFixture.Lifetime.Physics.IsSpatialRoot(outdoor)); + Assert.False(demoteFixture.Lifetime.Physics.IsSpatialRoot(indoor)); + Assert.True(demoteFixture.Lifetime.Physics.SetPosition + .TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.Equal(indoor.Key, withdrawal.Token.Entity); + Assert.True(demoteFixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(withdrawal.Token)); + + RuntimeCollisionMutationResult completed = demoteFixture.Lifetime + .Physics.DemoteCollisionToTerrain(CornerLandblock); + Assert.True(completed.Completed); + Assert.True(completed.Ready); + } + + using var withdrawFixture = new Fixture( + bindGeneration: true, + engine: CornerEngine()); + RuntimeEntityRecord record = withdrawFixture.Add( + 0x700031F6u, + 1, + CornerCell, + new Vector3(14f, 42f, 0f)); + RuntimeSetPositionOutcome seeded = withdrawFixture.Place( + record, + CornerCell, + new Vector3(14.5f, 42f, 0f)); + Assert.True(withdrawFixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(seeded.Projection)); + RuntimePhysicsState physics = withdrawFixture.Lifetime.Physics; + + RuntimeCollisionMutationResult pending = + physics.WithdrawCollision(CornerLandblock); + Assert.False(pending.Completed); + Assert.True(physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot removed)); + Assert.True(physics.SetPosition.AcknowledgeProjection(removed.Token)); + RuntimeCollisionMutationResult withdrawn = + physics.WithdrawCollision(CornerLandblock); + Assert.True(withdrawn.Completed); + Assert.False(withdrawn.Ready); + Assert.False(physics.IsSpatialRoot(record)); + } + + [Fact] + public void AbsentLandblockIdStillCannotBeginQuiescence() + { + // The prefix-0 sentinel accidentally rejected the corner landblock; + // the genuine "no landblock at all" input (id 0) must keep throwing. + using var fixture = new Fixture( + bindGeneration: true, + engine: CornerEngine()); + Assert.Throws( + () => fixture.Begin(0u, 2UL, includeOutdoorCells: true)); + Assert.Throws( + () => fixture.Lifetime.Physics.BeginCollisionAdmission(0u)); + + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin( + CornerLandblock, + 2UL, + includeOutdoorCells: true); + Assert.True(token.IsValid); + Assert.Equal(CornerPrefix, token.LandblockPrefix); + Assert.True(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence( + token)); + } + + /// + /// One held-placement quiescence cycle (the + /// SourceToOutsidePlacementIsHeldThenRestoredBeforeBarrierOpens shape, + /// single-prefix variant), with every observable step outcome recorded so + /// two runs can be compared for exact parity. + /// + private static List RunHeldPlacementQuiescenceCycle( + Fixture fixture, + uint landblockId, + uint sourceCell, + uint targetCell, + uint guid) + { + var log = new List(); + RuntimeEntityRecord record = fixture.Add( + guid, + 1, + sourceCell, + new Vector3(10f, 22f, 0f)); + RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin( + landblockId, + 2UL, + includeOutdoorCells: true); + log.Add($"tokenValid={token.IsValid}"); + + RuntimeSetPositionOutcome held = fixture.Place( + record, + targetCell, + new Vector3(14f, 22f, 0f), + currentCell: sourceCell); + log.Add($"place={held.Status}"); + log.Add($"placeGeneration={held.Projection.CollisionGeneration}"); + log.Add($"placeCellLow={held.ExactCellId & 0xFFFFu:X4}"); + log.Add( + $"root={fixture.Lifetime.Physics.IsSpatialRoot(record)}"); + log.Add($"ackWithdraw={fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(held.Projection)}"); + log.Add($"acquire1={fixture.TryAcquire(token, out _)}"); + log.Add($"acquire2={fixture.TryAcquire(token, out _)}"); + + log.Add($"cancelRestorePending={fixture.Lifetime.Physics + .CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)}"); + bool peeked = fixture.Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot restored); + log.Add($"restorePeeked={peeked}"); + log.Add($"restoreKind={restored.Kind}"); + log.Add($"restoreCellLow={restored.Token.ExactCellId & 0xFFFFu:X4}"); + log.Add($"ackRestore={fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(restored.Token)}"); + log.Add($"cancelCompleted={fixture.Lifetime.Physics + .CancelCollisionPrefixQuiescence( + token, + successorGeneration: 1UL, + successorReady: true)}"); + log.Add($"finalCellLow={record.FullCellId & 0xFFFFu:X4}"); + log.Add( + $"finalRoot={fixture.Lifetime.Physics.IsSpatialRoot(record)}"); + RuntimePhysicsOwnershipSnapshot ownership = + fixture.Lifetime.Physics.CaptureOwnership(); + log.Add($"quiescences={ownership.CollisionPrefixQuiescenceCount}"); + log.Add( + $"pendingProjections={ownership.PendingCollisionPrefixProjectionCount}"); + return log; + } + + private static RuntimeCollisionGenerationCommit CommitToCompletion( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + for (int poll = 0; poll < 10_000; poll++) + { + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + if (commit.Completed) + return commit; + while (physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection)) + { + Assert.True(physics.SetPosition.AcknowledgeProjection( + projection.Token)); + } + if (!commit.EngineCommitted) + _ = SealMutation(physics, admission, prepared); + } + throw new InvalidOperationException( + "Collision generation did not complete its mutation transaction."); + } + + private static PhysicsEngine CornerEngine() + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + AddFlatLandblock(engine, CornerPrefix); + return engine; + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 524bdb92..647f1ee8 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -17,18 +17,33 @@ public sealed class RuntimeLiveEntitySessionControllerTests [Fact] public void DirectSinkOwnsCanonicalCreateUpdateDeleteWithoutProjection() { - using GameRuntime runtime = CreateRuntime(); + // C3c: the direct sink's Create now enters the canonical initial + // residence; this test drives the remote first-entry conductor to + // completion (the direct-host pump's job) before the follow-up + // Position flows the ordinary post-residence path. + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + CommitLandblockCollision(runtime, 0x01010000u); + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); using var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), new FixtureTransport()); + // C3c-R1 review R3: the residence route requires a drive-backed + // world projection — a content-less (projection-less) controller + // keeps the pre-flip legacy registration instead (see + // ContentLessDirectSink_KeepsPreFlipLegacyRegistration). var controller = new RuntimeLiveEntitySessionController( runtime, - session); + session, + worldProjection: new FixtureWorldProjection()); LiveEntitySessionSink sink = controller.CreateSink(); WorldSession.EntitySpawn spawn = Spawn(0x70000001u, incarnation: 1); sink.Spawned(spawn); + drive.DriveAll(); + Assert.Equal(0, drive.PendingCount); + DrainPlacementFifo(runtime); sink.PositionUpdated(new WorldSession.EntityPositionUpdate( spawn.Guid, spawn.Position!.Value with @@ -62,10 +77,79 @@ public sealed class RuntimeLiveEntitySessionControllerTests runtime.EntityObjects.Entities.PendingTeardownCount); } + /// + /// C3c-R1 review R3: a CONTENT-LESS headless host (validated-legal + /// configuration: HeadlessConfigurationLoader accepts a null + /// process.content) builds no world projection and no first-entry + /// drive. Its Creates must keep the exact pre-flip legacy registration + /// — the accepted frame commits directly (FullCellId derives from the + /// wire cell at registration), no residence lease ever opens, and the + /// entity/residence/drive ledgers stay at zero — because a residence + /// with no drive to pump it would park every Create (and every packet + /// FIFO'd behind its pending residence) forever. + /// + [Fact] + public void ContentLessDirectSink_KeepsPreFlipLegacyRegistration() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session); + LiveEntitySessionSink sink = controller.CreateSink(); + WorldSession.EntitySpawn spawn = + Spawn(0x70000003u, incarnation: 1); + + sink.Spawned(spawn); + + Assert.True( + runtime.EntityObjects.Entities.TryGetActive( + spawn.Guid, + out RuntimeEntityRecord canonical)); + Assert.Equal( + spawn.Position!.Value.LandblockId, + canonical.FullCellId); + RuntimeEntityObjectOwnershipSnapshot ownership = + runtime.EntityObjects.CaptureOwnership(); + Assert.Equal(0, ownership.InitialCreateResidenceLeaseCount); + Assert.Equal(0, ownership.FirstEntryDrivePendingCount); + Assert.Equal(1, runtime.Entities.Count); + Assert.Equal(1, runtime.Inventory.ObjectCount); + + // Position packets flow the ordinary immediate path — nothing is + // FIFO'd behind a pending residence. + sink.PositionUpdated(new WorldSession.EntityPositionUpdate( + spawn.Guid, + spawn.Position!.Value with + { + PositionX = 20f, + }, + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0)); + Assert.Equal( + 20f, + canonical.Snapshot.Position!.Value.PositionX); + + sink.Deleted(new DeleteObject.Parsed(spawn.Guid, 1)); + Assert.Equal(0, runtime.Entities.Count); + Assert.Equal( + 0, + runtime.EntityObjects.Entities.PendingTeardownCount); + } + [Fact] public void DirectSinkCompletesExactPortalAndSendsLoginComplete() { - using GameRuntime runtime = CreateRuntime(); + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; const uint playerGuid = 0x50000001u; runtime.PlayerIdentity.ServerGuid = playerGuid; using var session = new WorldSession( @@ -106,7 +190,8 @@ public sealed class RuntimeLiveEntitySessionControllerTests [Fact] public void DirectSinkProjectsAcceptedLocalWorldStateThroughOneHostSeam() { - using GameRuntime runtime = CreateRuntime(); + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; const uint playerGuid = 0x50000002u; runtime.PlayerIdentity.ServerGuid = playerGuid; using var session = new WorldSession( @@ -154,16 +239,268 @@ public sealed class RuntimeLiveEntitySessionControllerTests Assert.True(runtime.Portal.Snapshot.Completed); } - private static GameRuntime CreateRuntime() + /// + /// C3c-R1 review F6: the drive controller outlives its session routes, + /// so "session reset precedes a new route" is an asserted latch, not a + /// silent assumption — a second route cannot attach before the prior + /// route detached, and a route that never owned the drive cannot clear + /// the live route's tracked entries. + /// + [Fact] + public void FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); + var routeA = new object(); + var routeB = new object(); + + drive.AttachRoute(routeA); + // Re-attaching the same owner is a no-op; a SECOND route asserts. + drive.AttachRoute(routeA); + Assert.Throws( + () => drive.AttachRoute(routeB)); + + _ = runtime.EntityObjects.RegisterEntityWithInitialResidence( + Spawn(0x70000004u, incarnation: 1), + isLocalPlayer: false); + Assert.Equal(1, drive.PendingCount); + + // A non-owner detach (never-attached / displaced route rollback) + // must not clear the live route's entries. + drive.DetachRoute(routeB); + Assert.Equal(1, drive.PendingCount); + + drive.DetachRoute(routeA); + Assert.Equal(0, drive.PendingCount); + // After the owner detached, a replacement route may attach. + drive.AttachRoute(routeB); + drive.DetachRoute(routeB); + } + + /// + /// C3c: initial-residence admission requires a live session generation + /// (RuntimeInitialCreateResidenceState.CanAcceptCreate), so these direct + /// sink tests start one through the same fixture-session shape + /// DirectGameRuntimeCommandAdapterTests uses. + /// + private sealed class StartedRuntime : IDisposable + { + internal required GameRuntime Runtime { get; init; } + internal required LiveSessionHost Live { get; init; } + + public void Dispose() + { + _ = Live.Stop(Runtime.Generation); + Runtime.Dispose(); + } + } + + private static StartedRuntime StartRuntime() { var operations = new FixtureGameplayOperations(); + var sessionOperations = new FixtureSessionOperations(); var runtime = new GameRuntime(new GameRuntimeDependencies( operations, operations, operations, - operations)); + operations, + SessionOperations: sessionOperations)); operations.Bind(runtime); - return runtime; + var resetHost = new FixtureResetHost(); + var options = new LiveSessionConnectOptions( + true, + "127.0.0.1", + 9000, + "account", + "password"); + var live = new LiveSessionHost( + runtime.Session, + new LiveSessionHostBindings( + new LiveSessionRoutingFactories( + _ => new FixtureEventRoute(), + _ => new FixtureCommandRoute()), + generation => runtime.ResetGeneration(generation, resetHost), + new LiveSessionSelectionBindings( + id => runtime.PlayerIdentity.ServerGuid = id, + _ => { }, + runtime.CommunicationOwner.Chat.SetLocalPlayerGuid, + _ => { }, + _ => { }, + runtime.ActionOwner.Combat.Clear), + new LiveSessionEnteredWorldBindings( + _ => { }, + () => { }, + () => { }, + _ => { }, + () => { }), + (_, _, _) => { }, + () => { }), + options); + LiveSessionStartResult startResult = live.Start(options); + Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); + Assert.NotEqual(0UL, runtime.Generation.Value); + return new StartedRuntime { Runtime = runtime, Live = live }; + } + + private static void CommitLandblockCollision( + GameRuntime runtime, + uint landblockId) + { + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + landblockId, 1UL); + runtime.EntityObjects.Physics.Engine.AddLandblock( + landblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + landblockId, 1UL, ready: true); + } + + private static RuntimeFirstEntryDriveController CreateDrive( + GameRuntime runtime) => + new( + runtime.EntityObjects, + runtime.Clock, + new UnusedCollisionSource(), + () => PlayerMovementConstructionOptions.Fallback, + static _ => new RuntimeLocalPlayerPhysicsActivationPreparation( + Radius: 0.48f, + Height: 1.835f, + RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + + /// + /// Drains/acknowledges every still-pending placement receipt (the + /// ExecutorCompleted correlation is reaped by its acknowledgement) the + /// way a host subscription would. + /// + private static void DrainPlacementFifo(GameRuntime runtime) + { + while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection( + out AcDream.Runtime.Physics.RuntimePlacementProjectionSnapshot head)) + { + if (!runtime.EntityObjects.Physics.SetPosition + .AcknowledgeProjection(head.Token)) + { + break; + } + } + } + + private sealed class UnusedCollisionSource + : AcDream.Content.IPreparedCollisionSource + { + public AcDream.Content.PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => + AcDream.Content.PreparedAssetPresence.Available; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatSetupCollision>.Missing; + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatGfxObjCollisionAsset> ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatCellStructureCollisionAsset> + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionReadResult< + AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AcDream.Content.PreparedCollisionSourceStats CollisionStats => + default; + + public void Dispose() + { + } + } + + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) => + new(endpoint, new FixtureTransport()); + + public void Connect(WorldSession session, string user, string password) + { + } + + public CharacterList.Parsed GetCharacters(WorldSession session) => + new( + 0u, + [new CharacterList.Character(0x50000001u, "Direct", 0u)], + [], + 11, + "account", + true, + true); + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) => + session.Dispose(); + } + + private sealed class FixtureEventRoute : ILiveSessionEventRouting + { + public void Attach() + { + } + + public void Dispose() + { + } + } + + private sealed class FixtureCommandRoute : ILiveSessionCommandRouting + { + public void Activate() + { + } + + public void Dispose() + { + } + } + + private sealed class FixtureResetHost : IRuntimeGenerationResetHost + { + public void RetireEntityProjection(RuntimeEntityRecord entity) + { + } + + public void DrainEntityProjectionBoundary() + { + } + + public void CompleteEntityProjectionRetirement() + { + } } private static WorldSession.EntitySpawn Spawn( @@ -194,7 +531,7 @@ public sealed class RuntimeLiveEntitySessionControllerTests Position: position, Movement: null, AnimationFrame: null, - SetupTableId: 0x02000001u, + SetupTableId: null, MotionTableId: null, SoundTableId: null, PhysicsScriptTableId: null, @@ -213,7 +550,7 @@ public sealed class RuntimeLiveEntitySessionControllerTests return new WorldSession.EntitySpawn( guid, position, - 0x02000001u, + null, [], [], [],