feat(runtime): C3c - production placement cutover: both hosts on the residence conductors (routes 1+8)

Campaign P remaining-physics-divergence, placement cutover slice C3c
(docs/plans/2026-08-02-placement-cutover.md). Both production hosts now
register every initial Create through the residence + continuation-
executor + first-entry-conductor machinery (C0-C3b):

- Graphical (route 1): RegisterEntityWithInitialResidence at Create; the
  shared RuntimeFirstEntryDriveController pumps both conductors from the
  placement-receipt flow; MaterializeProjection and RebucketLiveEntity
  are presentation-only while a residence is ACTIVE (ExecutorCompleted is
  the presentation-binding receipt); post-residence entities take the
  full legacy path including the prepare_to_enter_world clock edges.
  PlayerModeController attaches presentation to the Runtime-published
  controller; its legacy resolve/step-heights/host-construction path is
  deleted; presentation-only rollback (retail has no entry-flow rollback).
- Headless (route 8): OnSpawned registers with residence when a drive
  exists; content-less sessions keep the pre-flip direct registration;
  SynchronizeLocalPlayer/CreateController/ApplySetupStepHeights deleted;
  prepared-collision read failure is a typed AwaitingCollisionSource
  retry; far remotes outside the service window complete celless.
- RuntimeLocalPlayerMovementState.Controller setter sealed internal; all
  controller mutation flows through the publication lifecycle.

Fix slices landed within this cutover, each dual-gated:
- F1: live movement-stat/server-physics application routed through the
  Runtime ownership seam (post-logout ingest crash on the retired
  controller eliminated; RuntimeMovementSkillProjection deleted).
- F2: login activation wedge - collision-admission prefix gate factored
  out of the seal (reentrant-commit RejectedAuthority), rearm generation
  identity corrected, PlayerModeAutoEntry requires the Runtime-published
  controller (world reveal can no longer seal unmaterialized).
- F3: landblock-prefix 0-sentinel replaced by explicit absent-id guards;
  map-corner landblocks (grid row/col 0) fully legal through admission,
  park/rearm/retire, quiescence, and outdoor shadow seeds.
- F5: local-player first-entry ground contact seeded by the shared
  SpawnPlacementSettler (moved App->Core) at FinalizeActivation - the
  retail first-gravity-frame touch (enter_world 0x00516170 carries no
  seed); the legacy unconditional force-seed is overwritten by a real
  floor-found contact; airborne spawns stay airborne; outbound contact
  bit verified end-to-end. Fixes the standing-cast 'You can't do that
  while in the air!' rejections.
- R1 (dual-review round): login constraint leash armed at the committed
  placement (HandleReceivedPosition 0x00453FD0 analog); register rows
  AD-61 (settle-timing compression now covering the local player) and
  AD-42 (repointed off the deleted resolve split) in this commit;
  residence-conversion owner API; wire-landblock guards; drive-pending
  ledger in IsConverged; route attach/detach latch; executor-drain drift
  model documented + source-pinned.

Gates: Runtime 1,003, App 4,039/3 skips, Headless 79, complete solution
10,816/0 failed/4 skips (Release, -m:1); connected lifecycle/reconnect
gate PASS (logs/connected-world-gate-20260802-175401; graceful exits,
world-visible, zero airborne rejections). The nine-stop soak remains red
for the pre-existing 6b28ff99 whole-world collision-clone throughput
regression (attributed with evidence; scheduled as its own slice before
C5). Dual Opus reviews (retail-conformance + adversarial): delta PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-02 18:10:33 +02:00
parent 78f1eb1896
commit 529e0e9d88
68 changed files with 5977 additions and 831 deletions

View file

@ -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 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 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-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-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-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-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-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-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-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-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-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 | | 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 |
--- ---

View file

@ -500,6 +500,79 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerIdentity, d.PlayerIdentity,
dormantLiveEntities, dormantLiveEntities,
d.Options.DumpLiveSpawns ? d.Log : null); 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( var hydration = new LiveEntityHydrationController(
live.LiveEntities, live.LiveEntities,
d.EntityObjects, d.EntityObjects,
@ -517,7 +590,8 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerIdentity, d.PlayerIdentity,
deletion, deletion,
dormantLiveEntities, dormantLiveEntities,
d.Options.DumpLiveSpawns ? d.Log : null); d.Options.DumpLiveSpawns ? d.Log : null,
firstEntryDrive);
bindings.Adopt( bindings.Adopt(
"landblock-loaded hydration", "landblock-loaded hydration",
live.LandblockLoaded.Bind(hydration)); live.LandblockLoaded.Bind(hydration));
@ -884,7 +958,8 @@ internal sealed class SessionPlayerCompositionPhase
d.RemoteMovementObservations, d.RemoteMovementObservations,
live.RenderSceneShadow, live.RenderSceneShadow,
live.PlacementProjection, live.PlacementProjection,
placementProjectionRetry), placementProjectionRetry,
firstEntryDrive),
liveSessionCommands, liveSessionCommands,
d.Log); d.Log);
LiveSessionHost sessionHost = sessionRuntimeFactory.Create( LiveSessionHost sessionHost = sessionRuntimeFactory.Create(

View file

@ -2,6 +2,7 @@ using System;
using AcDream.App.Net; using AcDream.App.Net;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Runtime.Physics;
namespace AcDream.App.Input; namespace AcDream.App.Input;
@ -83,7 +84,24 @@ internal sealed class LivePlayerModeAutoEntryContext
public bool IsPlayerEntityPresent => public bool IsPlayerEntityPresent =>
_liveEntities.ContainsWorldEntity(_identity.ServerGuid); _liveEntities.ContainsWorldEntity(_identity.ServerGuid);
public bool IsPlayerControllerReady => true; /// <summary>
/// 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 <c>PlayerModeController.TryEnter</c> requires. It was
/// the constant <c>true</c>, 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
/// <see cref="EnterPlayerMode"/> completes the world reveal
/// unconditionally), a single early attempt permanently sealed the reveal
/// with the player never in world.
/// </summary>
public bool IsPlayerControllerReady =>
_playerMode.Controller is { IsRuntimePublished: true }
&& _liveEntities.TryGetRecord(
_identity.ServerGuid,
out LiveEntityRecord record)
&& record.PhysicsHost is EntityPhysicsHost;
public bool IsWorldReady => public bool IsWorldReady =>
_liveEntities.TryGetSnapshot( _liveEntities.TryGetSnapshot(

View file

@ -164,7 +164,9 @@ internal sealed class PlayerModeController :
try { RetireApproachLifetime(); } try { RetireApproachLifetime(); }
catch (Exception error) { failures.Add(error); } catch (Exception error) { failures.Add(error); }
_mode.IsPlayerMode = false; _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; _hostSlot.Host = null;
_chase.Legacy = null; _chase.Legacy = null;
_chase.Retail = null; _chase.Retail = null;
@ -201,7 +203,9 @@ internal sealed class PlayerModeController :
try { RetireApproachLifetime(); } try { RetireApproachLifetime(); }
catch (Exception error) { failures.Add(error); } catch (Exception error) { failures.Add(error); }
_mode.ResetSession(); _mode.ResetSession();
_controllerSlot.Controller = null; // C3c: the Runtime generation reset retires the controller through
// RuntimeLocalPlayerMovementState.ResetSession; App detaches
// presentation only.
_hostSlot.Host = null; _hostSlot.Host = null;
_chase.Legacy = null; _chase.Legacy = null;
_chase.Retail = null; _chase.Retail = null;
@ -233,6 +237,19 @@ internal sealed class PlayerModeController :
return false; 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( BuildControllerAndCamera(
loggingTag, loggingTag,
playerGuid, playerGuid,
@ -247,6 +264,32 @@ internal sealed class PlayerModeController :
WorldEntity playerEntity, WorldEntity playerEntity,
LiveEntityRecord playerRecord) 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 = IPlayerApproachCompletionSink approachLifetime =
_approachCompletions.BeginControllerLifetime(); _approachCompletions.BeginControllerLifetime();
bool lifetimeCommitted = false; bool lifetimeCommitted = false;
@ -256,46 +299,11 @@ internal sealed class PlayerModeController :
LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture(); LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture();
try try
{ {
var controller = new PlayerMovementController( // Approach-completion presentation rides the Runtime-owned
_physics, // MoveToManager (created by the publication chain's own
playerRecord.ObjectClock, // MakeMoveToManager).
PlayerMovementConstructionOptions.From(_skills.Snapshot)); if (controller.MoveTo is { } moveTo)
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 = () =>
{ {
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 => moveTo.MoveToComplete = error =>
{ {
if (PhysicsDiagnostics.ProbeAutoWalkEnabled) if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
@ -307,75 +315,8 @@ internal sealed class PlayerModeController :
}; };
moveTo.MoveToCancelled = error => moveTo.MoveToCancelled = error =>
approachLifetime.PublishCancellation(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) if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation)
&& animation.Sequencer is { } sequencer) && animation.Sequencer is { } sequencer)
{ {
@ -392,51 +333,9 @@ internal sealed class PlayerModeController :
sequencer.Manager.CheckForCompletedMotions; sequencer.Manager.CheckForCompletedMotions;
controller.Motion.DefaultSink = controller.Motion.DefaultSink =
new MotionTableDispatchSink(sequencer); 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 legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect };
var retailCamera = new RetailChaseCamera var retailCamera = new RetailChaseCamera
{ {
@ -446,44 +345,15 @@ internal sealed class PlayerModeController :
cameraAttempted = true; cameraAttempted = true;
_camera.EnterChaseMode(legacyCamera, retailCamera); _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; shadowAttempted = true;
_shadow.SyncPose( _shadow.SyncPose(
playerEntity, playerEntity,
initial.Position, controller.Position,
playerEntity.Rotation, playerEntity.Rotation,
initial.CellId, controller.CellId,
force: true); force: true);
// Publish the incarnation-stable CPhysicsObj delegates only after all _hostSlot.Host = playerHost;
// 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;
_chase.Legacy = legacyCamera; _chase.Legacy = legacyCamera;
_chase.Retail = retailCamera; _chase.Retail = retailCamera;
_mode.IsPlayerMode = true; _mode.IsPlayerMode = true;
@ -505,8 +375,16 @@ internal sealed class PlayerModeController :
catch (Exception cleanupError) { failures.Add(cleanupError); } 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; _mode.IsPlayerMode = false;
_controllerSlot.Controller = null;
_hostSlot.Host = null; _hostSlot.Host = null;
_chase.Legacy = null; _chase.Legacy = null;
_chase.Retail = null; _chase.Retail = null;
@ -532,92 +410,4 @@ internal sealed class PlayerModeController :
_approachCompletions.RetireControllerLifetime(lifetime); _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<DatReaderWriter.DBObjs.Setup>(
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<FlatCollisionSphere>.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<FlatCollisionSphere>.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;
}
} }

View file

@ -15,6 +15,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
_createSubscription; _createSubscription;
private readonly Func<RuntimeGenerationToken> _generation; private readonly Func<RuntimeGenerationToken> _generation;
private readonly RuntimePlacementProjectionRetrySlot _retries; private readonly RuntimePlacementProjectionRetrySlot _retries;
private readonly RuntimeFirstEntryDriveController? _firstEntry;
private RuntimePlacementProjectionSubscription? _subscription; private RuntimePlacementProjectionSubscription? _subscription;
private IDisposable? _retryLease; private IDisposable? _retryLease;
private bool _attachStarted; private bool _attachStarted;
@ -25,7 +26,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
ILiveSessionEventRouting events, ILiveSessionEventRouting events,
GameRuntime runtime, GameRuntime runtime,
IRuntimePlacementProjectionSink placements, IRuntimePlacementProjectionSink placements,
RuntimePlacementProjectionRetrySlot retries) RuntimePlacementProjectionRetrySlot retries,
RuntimeFirstEntryDriveController? firstEntry = null)
: this( : this(
events, events,
() => new RuntimePlacementProjectionSubscription( () => new RuntimePlacementProjectionSubscription(
@ -33,7 +35,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
placements, placements,
retryPendingOnSubscribe: false), retryPendingOnSubscribe: false),
() => runtime.Generation, () => runtime.Generation,
retries) retries,
firstEntry)
{ {
ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(placements); ArgumentNullException.ThrowIfNull(placements);
@ -43,7 +46,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
ILiveSessionEventRouting events, ILiveSessionEventRouting events,
Func<RuntimePlacementProjectionSubscription> createSubscription, Func<RuntimePlacementProjectionSubscription> createSubscription,
Func<RuntimeGenerationToken> generation, Func<RuntimeGenerationToken> generation,
RuntimePlacementProjectionRetrySlot retries) RuntimePlacementProjectionRetrySlot retries,
RuntimeFirstEntryDriveController? firstEntry = null)
{ {
_events = events ?? throw new ArgumentNullException(nameof(events)); _events = events ?? throw new ArgumentNullException(nameof(events));
_createSubscription = createSubscription _createSubscription = createSubscription
@ -51,6 +55,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
_generation = generation _generation = generation
?? throw new ArgumentNullException(nameof(generation)); ?? throw new ArgumentNullException(nameof(generation));
_retries = retries ?? throw new ArgumentNullException(nameof(retries)); _retries = retries ?? throw new ArgumentNullException(nameof(retries));
_firstEntry = firstEntry;
} }
public void Attach() public void Attach()
@ -60,6 +65,10 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
return; return;
_attachStarted = true; _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(); _events.Attach();
RuntimePlacementProjectionSubscription? subscription = null; RuntimePlacementProjectionSubscription? subscription = null;
@ -67,9 +76,20 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
try try
{ {
subscription = _createSubscription(); subscription = _createSubscription();
RuntimePlacementProjectionSubscription boundSubscription =
subscription;
retryLease = _retries.BindOwned( retryLease = _retries.BindOwned(
_generation(), _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; _subscription = subscription;
_retryLease = retryLease; _retryLease = retryLease;
_ = subscription.RetryPending(); _ = subscription.RetryPending();
@ -91,6 +111,12 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
// can therefore never retry a retired generation or disposed route. // can therefore never retry a retired generation or disposed route.
Interlocked.Exchange(ref _retryLease, null)?.Dispose(); Interlocked.Exchange(ref _retryLease, null)?.Dispose();
Interlocked.Exchange(ref _subscription, 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) if (!_eventsDisposed)
{ {
_events.Dispose(); _events.Dispose();

View file

@ -0,0 +1,86 @@
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Net;
/// <summary>
/// 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 <c>OnSkillsUpdated</c>/<c>OnMovementStatsUpdated</c>
/// callbacks) routes through
/// <see cref="RuntimeLocalPlayerMovementState.ApplyCharacterMovementStats"/>;
/// 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.
/// </summary>
/// <remarks>
/// Stuck-cast fix (2026-07-30): retail fires
/// <c>CPhysicsObj::report_exhaustion</c> from exactly ONE site —
/// <c>CommandInterpreter::HandleExhaustion</c> (0x006b3c70), a
/// notification-handler vtable slot invoked on the stamina-exhaustion
/// EVENT — not on every vitals refresh. The P1 wiring called
/// <c>ReportExhaustion()</c> 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 <c>PlayerWeenie</c> 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.
/// </remarks>
internal sealed class LiveMovementStatsApplier(
RuntimeLocalPlayerMovementState movement,
RuntimeMovementSkillState skills,
Action<string> log)
{
private readonly RuntimeLocalPlayerMovementState _movement = movement
?? throw new ArgumentNullException(nameof(movement));
private readonly RuntimeMovementSkillState _skills = skills
?? throw new ArgumentNullException(nameof(skills));
private readonly Action<string> _log = log
?? throw new ArgumentNullException(nameof(log));
private readonly StaminaExhaustionEdgeTracker _staminaExhaustion = new();
/// <summary>
/// Forgets the retiring character/session exhaustion baseline; the next
/// generation's first sample must not synthesize an edge.
/// </summary>
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;
}
}

View file

@ -85,7 +85,8 @@ internal sealed record LiveSessionWorldRuntime(
RemoteMovementObservationTracker RemoteMovementObservations, RemoteMovementObservationTracker RemoteMovementObservations,
RenderSceneShadowRuntime? RenderSceneShadow, RenderSceneShadowRuntime? RenderSceneShadow,
RuntimePlacementPresentationSink PlacementProjection, RuntimePlacementPresentationSink PlacementProjection,
RuntimePlacementProjectionRetrySlot PlacementRetries); RuntimePlacementProjectionRetrySlot PlacementRetries,
RuntimeFirstEntryDriveController FirstEntryDrive);
/// <summary> /// <summary>
/// Builds the exact per-generation route/reset graph for the canonical live /// 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 LiveSessionWorldRuntime _world;
private readonly LiveSessionCommandSurface _commands; private readonly LiveSessionCommandSurface _commands;
private readonly Action<string> _log; private readonly Action<string> _log;
private readonly StaminaExhaustionEdgeTracker _staminaExhaustion = new(); private readonly LiveMovementStatsApplier _movementStats;
public LiveSessionRuntimeFactory( public LiveSessionRuntimeFactory(
LiveSessionPlayerRuntime player, LiveSessionPlayerRuntime player,
@ -119,6 +120,12 @@ internal sealed class LiveSessionRuntimeFactory
_world = world ?? throw new ArgumentNullException(nameof(world)); _world = world ?? throw new ArgumentNullException(nameof(world));
_commands = commands ?? throw new ArgumentNullException(nameof(commands)); _commands = commands ?? throw new ArgumentNullException(nameof(commands));
_log = log ?? throw new ArgumentNullException(nameof(log)); _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( public LiveSessionHost Create(
@ -192,7 +199,7 @@ internal sealed class LiveSessionRuntimeFactory
private void ResetPlayerPresentation() private void ResetPlayerPresentation()
{ {
_staminaExhaustion.Reset(); _movementStats.Reset();
_interaction.PlayerMode.ResetSession(); _interaction.PlayerMode.ResetSession();
_world.SpawnClaims.Reset(); _world.SpawnClaims.Reset();
} }
@ -254,7 +261,8 @@ internal sealed class LiveSessionRuntimeFactory
route, route,
_domain.Runtime, _domain.Runtime,
_world.PlacementProjection, _world.PlacementProjection,
_world.PlacementRetries); _world.PlacementRetries,
_world.FirstEntryDrive);
} }
private LiveInventorySessionBindings CreateInventoryBindings() => new( private LiveInventorySessionBindings CreateInventoryBindings() => new(
@ -284,59 +292,19 @@ internal sealed class LiveSessionRuntimeFactory
_domain.Actions.Combat, _domain.Actions.Combat,
_domain.Character, _domain.Character,
ResolveSkillFormulaBonus: skillCreditResolver.Resolve, ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) => ApplyMovementStats("skills"), OnSkillsUpdated: (runSkill, jumpSkill) =>
_movementStats.Apply("skills"),
OnConfirmationRequest: request => OnConfirmationRequest: request =>
_ui.RetailUi?.HandleConfirmationRequest(request), _ui.RetailUi?.HandleConfirmationRequest(request),
OnConfirmationDone: done => OnConfirmationDone: done =>
_ui.RetailUi?.HandleConfirmationDone(done), _ui.RetailUi?.HandleConfirmationDone(done),
ClientTime: ClientTimerNow, ClientTime: ClientTimerNow,
// Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes // Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes
// reactively re-apply to the live controller through the SAME // reactively re-apply through the SAME seam skills already used
// seam skills already used, then wire the previously-dead // (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime
// ReportExhaustion() R3-W4 seam so movement re-evaluates // movement owner's typed application entry — see
// immediately (pseudocode doc §8/§9). // LiveMovementStatsApplier.
OnMovementStatsUpdated: () => ApplyMovementStats("stats")); OnMovementStatsUpdated: () => _movementStats.Apply("stats"));
}
/// <summary>
/// Re-applies the current <see cref="RuntimeMovementSkillState"/>
/// snapshot (skills/burden/stamina) to the live player controller.
/// </summary>
/// <remarks>
/// Stuck-cast fix (2026-07-30): retail fires
/// <c>CPhysicsObj::report_exhaustion</c> from exactly ONE site —
/// <c>CommandInterpreter::HandleExhaustion</c> (0x006b3c70), a
/// notification-handler vtable slot invoked on the stamina-exhaustion
/// EVENT — not on every vitals refresh. The P1 wiring called
/// <c>ReportExhaustion()</c> 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 <see cref="PlayerWeenie"/> immediately
/// via <see cref="RuntimeMovementSkillProjection.ApplyTo"/> — the next
/// natural dispatch picks up the new rates, exactly as retail.
/// </remarks>
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}");
} }
private LiveSessionCommandBindings CreateCommandBindings( private LiveSessionCommandBindings CreateCommandBindings(

View file

@ -190,7 +190,7 @@ internal sealed class LiveEntityNetworkUpdateController
// retail's first frame would (position snapped onto the floor, // retail's first frame would (position snapped onto the floor,
// contact plane + CONTACT/ON_WALKABLE committed below). A sweep that // contact plane + CONTACT/ON_WALKABLE committed below). A sweep that
// finds no floor (true airborne spawn) leaves the body airborne. // finds no floor (true airborne spawn) leaves the body airborne.
if (!RemoteSpawnPlacementSettler.TrySettle( if (!AcDream.Core.Physics.SpawnPlacementSettler.TrySettle(
_physicsEngine, _physicsEngine,
remote.Body, remote.Body,
worldPos, worldPos,
@ -1003,7 +1003,20 @@ internal sealed class LiveEntityNetworkUpdateController
return; return;
} }
if (parsed.Guid == _playerServerGuid) 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; if (!_liveEntities.TryGetWorldEntity(parsed.Guid, out var entity)) return;

View file

@ -709,6 +709,17 @@ internal sealed class DatLiveEntityProjectionMaterializer
} }
bool createdProjection = false; 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( WorldEntity? entity = _runtime.MaterializeLiveEntity(
expectedCanonical, expectedCanonical,
spawn.Position!.Value.LandblockId, spawn.Position!.Value.LandblockId,
@ -734,7 +745,8 @@ internal sealed class DatLiveEntityProjectionMaterializer
}, },
LiveEntityProjectionKind.World, LiveEntityProjectionKind.World,
initializeProjection: record => record.EffectProfile = profile, initializeProjection: record => record.EffectProfile = profile,
out LiveEntityRecord? expectedRecord); out LiveEntityRecord? expectedRecord,
residence);
if (entity is null if (entity is null
|| expectedRecord is null || expectedRecord is null
|| !_runtime.IsCurrentCreateIntegration( || !_runtime.IsCurrentCreateIntegration(
@ -744,6 +756,27 @@ internal sealed class DatLiveEntityProjectionMaterializer
{ {
return false; 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) if (!createdProjection)
{ {

View file

@ -524,6 +524,22 @@ public sealed class EquippedChildRenderController : IDisposable
{ {
return false; 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( WorldEntity? entity = _liveEntities.MaterializeLiveEntity(
childCanonical, childCanonical,
parentCellId, parentCellId,

View file

@ -4,6 +4,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Session;
namespace AcDream.App.World; namespace AcDream.App.World;
@ -180,6 +181,14 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
private readonly LiveEntityDeletionController _deletion; private readonly LiveEntityDeletionController _deletion;
private readonly DormantLiveEntityStore _dormant; private readonly DormantLiveEntityStore _dormant;
private readonly Action<string>? _diagnostic; private readonly Action<string>? _diagnostic;
/// <summary>
/// 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.
/// </summary>
private readonly RuntimeFirstEntryDriveController? _firstEntry;
private readonly Dictionary<RuntimeEntityRecord, CanonicalProjectionOperation> private readonly Dictionary<RuntimeEntityRecord, CanonicalProjectionOperation>
_projectionOperations = _projectionOperations =
new(ReferenceEqualityComparer.Instance); new(ReferenceEqualityComparer.Instance);
@ -203,7 +212,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
ILocalPlayerIdentitySource identity, ILocalPlayerIdentitySource identity,
LiveEntityDeletionController deletion, LiveEntityDeletionController deletion,
DormantLiveEntityStore? dormant = null, DormantLiveEntityStore? dormant = null,
Action<string>? diagnostic = null) Action<string>? diagnostic = null,
RuntimeFirstEntryDriveController? firstEntry = null)
{ {
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_entityObjects = entityObjects _entityObjects = entityObjects
@ -219,6 +229,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
_deletion = deletion ?? throw new ArgumentNullException(nameof(deletion)); _deletion = deletion ?? throw new ArgumentNullException(nameof(deletion));
_dormant = dormant ?? new DormantLiveEntityStore(); _dormant = dormant ?? new DormantLiveEntityStore();
_diagnostic = diagnostic; _diagnostic = diagnostic;
_firstEntry = firstEntry;
} }
internal event Action<uint>? AppearanceApplied; internal event Action<uint>? AppearanceApplied;
@ -259,7 +270,9 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
lock (_datLock) lock (_datLock)
{ {
LiveEntityRegistrationResult registration = LiveEntityRegistrationResult registration =
_runtime.RegisterLiveEntity(spawn); _runtime.RegisterLiveEntity(
spawn,
isLocalPlayer: spawn.Guid == _identity.ServerGuid);
InboundCreateResult result = registration.Inbound; InboundCreateResult result = registration.Inbound;
if (result.Disposition is if (result.Disposition is
AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration) 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.", $"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.",
cleanupFailure); 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();
} }
} }

View file

@ -523,7 +523,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
/// </summary> /// </summary>
public event Action<LiveEntityRecord, bool>? ProjectionVisibilityChanged; public event Action<LiveEntityRecord, bool>? ProjectionVisibilityChanged;
public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming) public LiveEntityRegistrationResult RegisterLiveEntity(
WorldSession.EntitySpawn incoming,
bool isLocalPlayer = false)
{ {
if (_isClearing || _sessionClearPendingFinalization || _isRegisteringResources) if (_isClearing || _sessionClearPendingFinalization || _isRegisteringResources)
{ {
@ -533,9 +535,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
: "A live entity cannot register from inside atomic resource registration."); : "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 = RuntimeEntityRegistrationResult registration =
_entityObjects.RegisterEntity( _entityObjects.RegisterEntityWithInitialResidence(
incoming, incoming,
isLocalPlayer,
RetirePriorProjection); RetirePriorProjection);
RuntimeEntityRecord? canonical = registration.Canonical; RuntimeEntityRecord? canonical = registration.Canonical;
LiveEntityRecord? projection = canonical is null LiveEntityRecord? projection = canonical is null
@ -795,11 +804,32 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
return false; return false;
if (record.MaterializationResidence is if (record.MaterializationResidence is
LiveEntityMaterializationResidence.AwaitRuntimePlacement) LiveEntityMaterializationResidence.AwaitRuntimePlacement
&& HasActiveInitialCreateResidence(record.Canonical))
{ {
// The private Runtime Place path below performs a presentation- // C3c: while the initial-create residence is ACTIVE, Runtime's
// only bucket update. This legacy API also commits canonical // SetPosition owner is the sole canonical position/cell/
// Runtime residence and cannot touch a cut-over incarnation. // 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; return false;
} }
@ -930,6 +960,197 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return true; return true;
} }
/// <summary>
/// C3c: the graphical-bucket-only projection of a conductor-owned
/// initial placement — called ONLY from
/// <see cref="TryApplyInitialCreateCompletionPresentation"/> (the
/// completion receipt at the initial-create residence boundary), never
/// from the public <see cref="RebucketLiveEntity"/> (C3c-R1 review R2:
/// post-residence moves take the full legacy branch there).
/// Deliberately never calls <c>CommitRebucket</c>,
/// <c>SuspendObjectClock</c>, or <c>ResetObjectClockForEnterWorld</c> —
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// C3c: true while the exact incarnation behind <paramref name="key"/>
/// 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.
/// </summary>
internal bool HasActiveInitialCreateResidence(RuntimeEntityKey key) =>
_directory.TryGetByLocalId(
key.LocalEntityId,
out RuntimeEntityRecord canonical)
&& _directory.IsCurrent(canonical)
&& canonical.Key == key
&& _entityObjects.TryGetInitialCreateResidence(canonical, out _);
/// <summary>
/// C3c: true when the exact incarnation behind <paramref name="canonical"/>
/// 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.
/// </summary>
internal bool HasActiveInitialCreateResidence(
RuntimeEntityRecord canonical) =>
_entityObjects.TryGetInitialCreateResidence(canonical, out _);
/// <summary>
/// C3c-R1 review F1: the ONLY sanctioned mutation of the otherwise
/// sticky <see cref="LiveEntityRecord.MaterializationResidence"/> — 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
/// <see cref="LiveEntityMaterializationResidence.LegacyImmediate"/>.
/// 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.
/// </summary>
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;
}
/// <summary> /// <summary>
/// Applies one canonical Runtime placement receipt to the graphical /// Applies one canonical Runtime placement receipt to the graphical
/// sidecar only. Runtime has already committed identity, position, /// sidecar only. Runtime has already committed identity, position,

View file

@ -66,6 +66,29 @@ internal sealed class RuntimePlacementPresentationSink
public bool TryApply(in RuntimePlacementProjectionSnapshot projection) 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 if (projection.Kind is RuntimePlacementProjectionKind.Place
&& !_transit.IsCurrentPlacementAuthority( && !_transit.IsCurrentPlacementAuthority(
projection.Token.Portal, projection.Token.Portal,
@ -76,19 +99,15 @@ internal sealed class RuntimePlacementPresentationSink
if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection)) if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection))
return false; return false;
if (projection.Kind is RuntimePlacementProjectionKind.Discard if (projection.Kind is RuntimePlacementProjectionKind.Discard)
or RuntimePlacementProjectionKind.ExecutorCompleted)
{ {
// F1: ExecutorCompleted is acknowledge-and-ignore like Discard - // Discard cancels only an unacknowledged observation - no
// no world/presentation mutation by definition. Must NOT fall // world/presentation mutation. Must NOT fall through to the
// through to the record-lookup gate below (that gate legitimately // record-lookup gate below (that gate legitimately rejects for
// rejects for OTHER reasons, and this sink's caller // OTHER reasons, and this sink's caller
// (RuntimePlacementProjectionSubscription) treats a false return // (RuntimePlacementProjectionSubscription) treats a false return
// as "leave at the FIFO head" - a rejected ExecutorCompleted // as "leave at the FIFO head" - a rejected Discard would
// would permanently wedge the whole ordered stream). Provably // permanently wedge the whole ordered stream).
// inert today: PublishExecutorCompletion has zero production
// callers - see
// RuntimePlacementPresentationSinkTests.ExecutorCompleted_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone.
return true; return true;
} }
if (!_liveEntities.TryGetRecord( if (!_liveEntities.TryGetRecord(
@ -109,6 +128,37 @@ internal sealed class RuntimePlacementPresentationSink
}; };
} }
/// <summary>
/// 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.
/// </summary>
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) private bool TryPublishPlace(LiveEntityRecord record, WorldEntity entity)
{ {
if (!IsCurrent(record, entity)) if (!IsCurrent(record, entity))

View file

@ -643,12 +643,17 @@ public sealed class ShadowObjectRegistry
private static uint DeriveOutdoorSeed( private static uint DeriveOutdoorSeed(
Vector3 worldPos, float worldOffsetX, float worldOffsetY, uint landblockId) 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 localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY; float localY = worldPos.Y - worldOffsetY;
int cx = (int)System.Math.Clamp(localX / 24f, 0f, 7f); int cx = (int)System.Math.Clamp(localX / 24f, 0f, 7f);
int cy = (int)System.Math.Clamp(localY / 24f, 0f, 7f); int cy = (int)System.Math.Clamp(localY / 24f, 0f, 7f);
uint lbPrefix = landblockId & 0xFFFF0000u; uint lbPrefix = landblockId & 0xFFFF0000u;
if (lbPrefix == 0u) return 0u;
// The clamp only anchors the SEED id; AddAllOutsideCells re-seats the // The clamp only anchors the SEED id; AddAllOutsideCells re-seats the
// actual flood cells from the sphere centers via LandDefs.AdjustToOutside // actual flood cells from the sphere centers via LandDefs.AdjustToOutside
// (block-crossing), so an out-of-block position still floods correctly. // (block-crossing), so an out-of-block position still floods correctly.
@ -2120,7 +2125,16 @@ public sealed class ShadowObjectRegistry
return false; return false;
} }
internal bool HasLogicalOwner(uint entityId) => /// <summary>
/// True while <paramref name="entityId"/> 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
/// <see cref="TryPrepareSetPosition"/> validates against this exact
/// registry state.
/// </summary>
public bool HasLogicalOwner(uint entityId) =>
_entityReg.ContainsKey(entityId); _entityReg.ContainsKey(entityId);
/// <summary> /// <summary>

View file

@ -1,15 +1,27 @@
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.App.Physics; namespace AcDream.Core.Physics;
/// <summary> /// <summary>
/// Performs the compressed first-gravity-frame settle used to establish /// 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: <c>CPhysicsObj::enter_world</c> (0x00516170) runs
/// <c>SetPosition</c> (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.
/// </summary> /// </summary>
internal static class RemoteSpawnPlacementSettler public static class SpawnPlacementSettler
{ {
internal const float SettleDistance = 0.5f; public const float SettleDistance = 0.5f;
public static bool TrySettle( public static bool TrySettle(
PhysicsEngine physicsEngine, PhysicsEngine physicsEngine,

View file

@ -34,23 +34,39 @@ internal sealed class HeadlessRuntimePlacementProjectionSink
if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted) if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted)
{ {
// F1: acknowledge-and-ignore, same as Discard - ExecutorCompleted // F1: acknowledge-and-ignore, same as Discard - ExecutorCompleted
// is not a placement to project (no world/presentation mutation // is not a placement to project (a headless host has no
// by definition; the executor's own drain already committed // presentation to bind off the completed initial drain; the
// every Place/Withdraw this receipt follows). It must NOT fall // executor's own drain already committed every canonical fact).
// through to the record-lookup gate below: that gate can validly // It must NOT fall through to the record-lookup gate below: that
// reject an unrelated entity/session mismatch, and this sink's // gate can validly reject an unrelated entity/session mismatch,
// caller (RuntimePlacementProjectionSubscription) treats a false // and this sink's caller (RuntimePlacementProjectionSubscription)
// return as "leave at the FIFO head" - a rejected ExecutorCompleted // treats a false return as "leave at the FIFO head" - a rejected
// would permanently wedge the entire ordered placement stream // ExecutorCompleted would permanently wedge the entire ordered
// behind it. Currently provably inert: PublishExecutorCompletion // placement stream behind it.
// has zero production callers (Execute/RegisterEntityWithInitialResidence
// are both unreached in production) - see
// HeadlessSessionHostTests.ExecutorCompletedReceiptIsAcknowledgeOnlyRegardlessOfRecordValidity.
return true; return true;
} }
RuntimePlacementProjectionToken token = projection.Token; RuntimePlacementProjectionToken token = projection.Token;
RuntimeEntityDirectory directory = _runtime.EntityObjects.Entities; 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 if (!token.IsValid
|| token.SessionLifetimeVersion || token.SessionLifetimeVersion
!= directory.SessionLifetimeVersion != directory.SessionLifetimeVersion

View file

@ -15,6 +15,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
private readonly ILiveSessionEventRouting _events; private readonly ILiveSessionEventRouting _events;
private readonly GameRuntime _runtime; private readonly GameRuntime _runtime;
private readonly IRuntimePlacementProjectionSink _placements; private readonly IRuntimePlacementProjectionSink _placements;
private readonly RuntimeFirstEntryDriveController? _firstEntry;
private RuntimePlacementProjectionSubscription? _subscription; private RuntimePlacementProjectionSubscription? _subscription;
private bool _attachStarted; private bool _attachStarted;
private bool _eventsDisposed; private bool _eventsDisposed;
@ -23,12 +24,14 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
internal HeadlessSessionEventRoute( internal HeadlessSessionEventRoute(
ILiveSessionEventRouting events, ILiveSessionEventRouting events,
GameRuntime runtime, GameRuntime runtime,
IRuntimePlacementProjectionSink placements) IRuntimePlacementProjectionSink placements,
RuntimeFirstEntryDriveController? firstEntry = null)
{ {
_events = events ?? throw new ArgumentNullException(nameof(events)); _events = events ?? throw new ArgumentNullException(nameof(events));
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_placements = placements _placements = placements
?? throw new ArgumentNullException(nameof(placements)); ?? throw new ArgumentNullException(nameof(placements));
_firstEntry = firstEntry;
} }
public void Attach() public void Attach()
@ -41,6 +44,10 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
// succeeds and throws, LiveSessionHost's retryable rollback still // succeeds and throws, LiveSessionHost's retryable rollback still
// invokes Dispose on the underlying route. // invokes Dispose on the underlying route.
_attachStarted = true; _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(); _events.Attach();
_subscription = new RuntimePlacementProjectionSubscription( _subscription = new RuntimePlacementProjectionSubscription(
_runtime, _runtime,
@ -56,6 +63,12 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
// network route. A still-pending FIFO head remains Runtime-owned for // network route. A still-pending FIFO head remains Runtime-owned for
// the replacement route to drain. // the replacement route to drain.
Interlocked.Exchange(ref _subscription, null)?.Dispose(); 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) if (!_eventsDisposed)
{ {
_events.Dispose(); _events.Dispose();

View file

@ -120,6 +120,12 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease? private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
_contentLease; _contentLease;
/// <summary>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.</summary>
private RuntimeFirstEntryDriveController? _firstEntryDrive;
private HeadlessSessionWorldProjection? _worldProjection;
private int _disposeStage; private int _disposeStage;
private long _reconnectDeadline; private long _reconnectDeadline;
private bool _reconnectPending; private bool _reconnectPending;
@ -295,6 +301,10 @@ internal sealed class HeadlessSessionHost : IDisposable
_localPlayerFrame.AdvanceBeforeNetwork( _localPlayerFrame.AdvanceBeforeNetwork(
checked((float)deltaSeconds)); checked((float)deltaSeconds));
Runtime.Session.Tick(); 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(); _localPlayerFrame.RunPostNetworkCommandPhase();
Runtime.ActionOwner.CombatAttack.Tick(); Runtime.ActionOwner.CombatAttack.Tick();
_policy.Tick(Runtime, Commands); _policy.Tick(Runtime, Commands);
@ -525,10 +535,34 @@ internal sealed class HeadlessSessionHost : IDisposable
private ILiveSessionEventRouting CreateEventRoute( private ILiveSessionEventRouting CreateEventRoute(
AcDream.Core.Net.WorldSession session) AcDream.Core.Net.WorldSession session)
{ {
IRuntimeDirectWorldProjection? worldProjection = IRuntimeDirectWorldProjection? worldProjection = null;
_contentLease is { } content if (_contentLease is { } content)
? new HeadlessSessionWorldProjection(Runtime, content) {
: null; // 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( var entities = new RuntimeLiveEntitySessionController(
Runtime, Runtime,
session, session,
@ -583,7 +617,8 @@ internal sealed class HeadlessSessionHost : IDisposable
return new HeadlessSessionEventRoute( return new HeadlessSessionEventRoute(
route, route,
Runtime, Runtime,
new HeadlessRuntimePlacementProjectionSink(Runtime)); new HeadlessRuntimePlacementProjectionSink(Runtime),
_firstEntryDrive);
} }
private static LiveSessionCharacterSelector MapCharacterSelector( private static LiveSessionCharacterSelector MapCharacterSelector(

View file

@ -16,6 +16,15 @@ internal interface IHeadlessCollisionNeighborhood
void CenterOn(uint fullCellId); void CenterOn(uint fullCellId);
bool IsReady(uint fullCellId); bool IsReady(uint fullCellId);
/// <summary>
/// C3c-R1 review F7: true when <paramref name="fullCellId"/>'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.
/// </summary>
bool IsWithinServiceWindow(uint fullCellId);
} }
internal readonly record struct HeadlessCollisionGenerationAdvance( internal readonly record struct HeadlessCollisionGenerationAdvance(
@ -233,6 +242,20 @@ internal sealed class HeadlessCollisionNeighborhood
AdvanceWork(); 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) public bool IsReady(uint fullCellId)
{ {
uint center = CanonicalLandblock(fullCellId); uint center = CanonicalLandblock(fullCellId);
@ -480,36 +503,72 @@ internal sealed class HeadlessSessionWorldProjection
private readonly GameRuntime _runtime; private readonly GameRuntime _runtime;
private readonly IHeadlessCollisionNeighborhood _collision; private readonly IHeadlessCollisionNeighborhood _collision;
private readonly IPreparedCollisionSource? _preparedCollision; private readonly RuntimeFirstEntryDriveController? _firstEntry;
private uint _requestedLocalPlayerCell;
internal HeadlessSessionWorldProjection( internal HeadlessSessionWorldProjection(
GameRuntime runtime, GameRuntime runtime,
HeadlessProcessContentOwner.HeadlessProcessContentLease content) HeadlessProcessContentOwner.HeadlessProcessContentLease content,
RuntimeFirstEntryDriveController? firstEntry = null)
: this( : this(
runtime, runtime,
new HeadlessCollisionNeighborhood(runtime, content), new HeadlessCollisionNeighborhood(runtime, content),
content.PreparedCollision) firstEntry)
{ {
} }
internal HeadlessSessionWorldProjection( internal HeadlessSessionWorldProjection(
GameRuntime runtime, GameRuntime runtime,
IHeadlessCollisionNeighborhood collision, IHeadlessCollisionNeighborhood collision,
IPreparedCollisionSource? preparedCollision = null) RuntimeFirstEntryDriveController? firstEntry = null)
{ {
_runtime = runtime _runtime = runtime
?? throw new ArgumentNullException(nameof(runtime)); ?? throw new ArgumentNullException(nameof(runtime));
_collision = collision _collision = collision
?? throw new ArgumentNullException(nameof(collision)); ?? throw new ArgumentNullException(nameof(collision));
_preparedCollision = preparedCollision; _firstEntry = firstEntry;
} }
public void ProjectSpawn( public void ProjectSpawn(
RuntimeEntityRecord record, RuntimeEntityRecord record,
bool isLocalPlayer) bool isLocalPlayer)
{ {
if (isLocalPlayer) // C3c route-8 flip: the first-entry conductors own mover
SynchronizeLocalPlayer(record); // 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( public void ProjectPosition(
@ -522,7 +581,17 @@ internal sealed class HeadlessSessionWorldProjection
if (_runtime.MovementOwner.Controller is null) 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; return;
} }
@ -530,6 +599,19 @@ internal sealed class HeadlessSessionWorldProjection
BlipLocalPlayer(record); BlipLocalPlayer(record);
} }
/// <summary>
/// C3c: the host tick's first-entry pump — advances the collision
/// neighborhood toward the requested local-player cell (its publication
/// work progresses on <c>IsReady</c> polls) and drives every pending
/// conductor sequence.
/// </summary>
internal void PumpFirstEntry()
{
if (_requestedLocalPlayerCell != 0u)
_ = _collision.IsReady(_requestedLocalPlayerCell);
_firstEntry?.DriveAll();
}
public void BeginTeleport() public void BeginTeleport()
{ {
if (_runtime.MovementOwner.Controller is { } controller) if (_runtime.MovementOwner.Controller is { } controller)
@ -545,7 +627,7 @@ internal sealed class HeadlessSessionWorldProjection
destination.EntityGuid, destination.EntityGuid,
out RuntimeEntityRecord record)) out RuntimeEntityRecord record))
{ {
SynchronizeLocalPlayer(record); ResynchronizeLocalPlayerForPortalArrival(record);
} }
if (_runtime.MovementOwner.Controller is { } controller) if (_runtime.MovementOwner.Controller is { } controller)
controller.State = PlayerState.InWorld; controller.State = PlayerState.InWorld;
@ -563,19 +645,27 @@ internal sealed class HeadlessSessionWorldProjection
IsCollisionReady: ready); IsCollisionReady: ready);
} }
private void SynchronizeLocalPlayer(RuntimeEntityRecord record) /// <summary>
/// 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.
/// </summary>
private void ResynchronizeLocalPlayerForPortalArrival(
RuntimeEntityRecord record)
{ {
if (record.ServerGuid if (record.ServerGuid
!= _runtime.PlayerIdentity.ServerGuid != _runtime.PlayerIdentity.ServerGuid
|| record.Snapshot.Position is not { } position) || record.Snapshot.Position is not { } position
|| _runtime.MovementOwner.Controller is not { } controller)
{ {
return; return;
} }
_collision.CenterOn(position.LandblockId); _collision.CenterOn(position.LandblockId);
PlayerMovementController controller =
_runtime.MovementOwner.Controller
?? CreateController(record);
Vector3 wirePosition = new( Vector3 wirePosition = new(
position.PositionX, position.PositionX,
position.PositionY, position.PositionY,
@ -636,57 +726,4 @@ internal sealed class HeadlessSessionWorldProjection
wirePosition); 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<FlatSetupCollision> 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;
}
} }

View file

@ -70,7 +70,16 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
/// keys - the remote/projectile Create-time body-construction conductor. /// keys - the remote/projectile Create-time body-construction conductor.
/// Dormant like its C3a sibling; converges to zero the same way. /// Dormant like its C3a sibling; converges to zero the same way.
/// </summary> /// </summary>
int RemoteFirstEntryActiveCount = 0) int RemoteFirstEntryActiveCount = 0,
/// <summary>
/// C3c-R1 review F5: outstanding host first-entry drive entries
/// (<c>RuntimeFirstEntryDriveController</c> pending keys, summed over
/// every drive registered against this lifetime via
/// <see cref="RuntimeEntityObjectLifetime.RegisterFirstEntryDriveOwnership"/>).
/// Previously outside every ledger; gated by <see cref="IsConverged"/>
/// like the conductor counts it pumps.
/// </summary>
int FirstEntryDrivePendingCount = 0)
{ {
public bool IsConverged => public bool IsConverged =>
IsDisposed IsDisposed
@ -94,6 +103,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
&& PendingCompletionReceiptCount == 0 && PendingCompletionReceiptCount == 0
&& LocalPlayerFirstEntryActiveCount == 0 && LocalPlayerFirstEntryActiveCount == 0
&& RemoteFirstEntryActiveCount == 0 && RemoteFirstEntryActiveCount == 0
&& FirstEntryDrivePendingCount == 0
&& StreamSubscriberCount == 0 && StreamSubscriberCount == 0
&& PlacementStreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0
&& PendingDispatchCount == 0 && PendingDispatchCount == 0
@ -137,6 +147,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
{ {
private bool _sessionClearInProgress; private bool _sessionClearInProgress;
private bool _disposed; private bool _disposed;
/// <summary>C3c: see <see cref="BindInitialResidenceBeginNotification"/>.</summary>
private Action<RuntimeEntityRecord>? _initialResidenceBegan;
/// <summary>C3c-R1 review F5: see <see cref="RegisterFirstEntryDriveOwnership"/>.</summary>
private readonly List<Func<int>> _firstEntryDriveOwnership = [];
public RuntimeEntityObjectLifetime( public RuntimeEntityObjectLifetime(
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
@ -437,7 +451,31 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
InitialCreateExecution.LastReplayFailure is not null, InitialCreateExecution.LastReplayFailure is not null,
InitialCreateExecution.PendingCompletionReceiptCount, InitialCreateExecution.PendingCompletionReceiptCount,
LocalPlayerFirstEntry.CaptureOwnership().ActiveCount, 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;
}
/// <summary>
/// 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
/// <see cref="BindInitialResidenceBeginNotification"/> there); multiple
/// registrations sum, mirroring the multicast notification shape.
/// </summary>
public void RegisterFirstEntryDriveOwnership(Func<int> pendingCount)
{
ArgumentNullException.ThrowIfNull(pendingCount);
EnsureNotDisposed();
_firstEntryDriveOwnership.Add(pendingCount);
} }
public void BindEventContext( public void BindEventContext(
@ -451,6 +489,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
InitialCreateExecution.BindGeneration(generation); InitialCreateExecution.BindGeneration(generation);
} }
/// <summary>
/// C3c: registers one host callback fired for every FRESH initial-create
/// residence begin (never for a same-generation FIFO append). Multicast,
/// mirroring <see cref="RuntimeInitialCreateResidenceState.BindRetirementNotification"/>.
/// 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.
/// </summary>
public void BindInitialResidenceBeginNotification(
Action<RuntimeEntityRecord> began)
{
ArgumentNullException.ThrowIfNull(began);
EnsureNotDisposed();
_initialResidenceBegan += began;
}
/// <summary> /// <summary>
/// C0-2: forwards to <see cref="RuntimeInitialCreateContinuationExecutor.BindLiveInputs"/>, /// C0-2: forwards to <see cref="RuntimeInitialCreateContinuationExecutor.BindLiveInputs"/>,
/// the same fan-out shape <see cref="BindEventContext"/> already uses for /// the same fan-out shape <see cref="BindEventContext"/> already uses for
@ -2270,6 +2324,20 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
return InitialCreateResidences.TryGetCurrent(canonical, out lease); return InitialCreateResidences.TryGetCurrent(canonical, out lease);
} }
/// <summary>
/// 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
/// <see cref="RuntimeInitialCreateResidenceState.TryConvertToCellessRoute"/>.
/// </summary>
public bool TryConvertInitialResidenceToCellessRoute(
RuntimeEntityRecord canonical)
{
EnsureNotDisposed();
return InitialCreateResidences.TryConvertToCellessRoute(canonical);
}
internal RuntimeInitialCreateResidenceCompletionStatus internal RuntimeInitialCreateResidenceCompletionStatus
CompleteInitialCreateResidence( CompleteInitialCreateResidence(
RuntimeEntityRecord canonical, RuntimeEntityRecord canonical,
@ -2339,7 +2407,19 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
canonical, canonical,
accepted, accepted,
isLocalPlayer); 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( private Exception FailInitialResidenceRegistration(

View file

@ -1007,6 +1007,52 @@ internal sealed class RuntimeInitialCreateResidenceState
return _completed.Remove(token.Entity); return _completed.Remove(token.Entity);
} }
/// <summary>
/// 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 <c>DiscardProgress</c> + both conductors' <c>Forget</c>);
/// the entry itself is deliberately NOT retired. Refused once any
/// placement has committed (<c>FullCellId != 0</c>) — the entity is not
/// a far remote then.
/// </summary>
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( internal bool Forget(
RuntimeEntityRecord record, RuntimeEntityRecord record,
out RuntimeInitialCreateResidenceLease lease, out RuntimeInitialCreateResidenceLease lease,

View file

@ -113,11 +113,13 @@ internal readonly record struct RuntimeRemoteFirstEntryOwnershipSnapshot(
/// <c>DormantLocalActivation</c> set, so the ordinary submission tail is the /// <c>DormantLocalActivation</c> set, so the ordinary submission tail is the
/// correct — and only — commit route. /// correct — and only — commit route.
/// ///
/// Dormant by design: <see cref="RuntimeEntityObjectLifetime"/> fully /// PRODUCTION-DRIVEN since the C3c flip: <see cref="RuntimeEntityObjectLifetime"/>
/// constructs and wires this class (construction, retirement fan-out, bulk /// fully constructs and wires this class (construction, retirement fan-out,
/// session-clear cleanup, ownership fold) exactly like the C3a conductor, /// bulk session-clear cleanup, ownership fold) exactly like the C3a
/// but nothing calls <see cref="Advance"/> in production — C3c wires the /// conductor, and the host first-entry drive
/// hosts. /// (<c>RuntimeFirstEntryDriveController</c>) calls <see cref="Advance"/>
/// for every remote/projectile initial-create residence on both the
/// graphical and headless hosts.
/// </summary> /// </summary>
internal sealed class RuntimeRemoteFirstEntryState internal sealed class RuntimeRemoteFirstEntryState
{ {

View file

@ -263,6 +263,13 @@ public sealed class GameRuntime
context.EntityObjects.Physics, context.EntityObjects.Physics,
context.Movement, context.Movement,
context.PlayerIdentity)); 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( context.EntityObjects.BindEventContext(
() => generationReset.ActiveRetiringGeneration () => generationReset.ActiveRetiringGeneration

View file

@ -368,6 +368,38 @@ public sealed class PlayerMovementController
_body.calc_acceleration(); _body.calc_acceleration();
} }
/// <summary>
/// C3c-F1 (2026-08-02): the lifecycle-deciding inbound-SetState entry
/// for the local player. Live states apply the exact
/// <see cref="ApplyPhysicsState"/> body; the dormant window drops the
/// push because the activation transaction owns the dormant body's
/// physics state exclusively (<see cref="RefreshDormantRuntimePhysicsState"/>
/// 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.
/// </summary>
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; public bool IsAirborne => !_body.OnWalkable;
/// <summary> /// <summary>
@ -1292,6 +1324,114 @@ public sealed class PlayerMovementController
lastPkAttackTimestamp); lastPkAttackTimestamp);
} }
/// <summary>
/// C3c-F1 (2026-08-02): the lifecycle-deciding half of the Runtime
/// movement-stats application seam
/// (<see cref="RuntimeLocalPlayerMovementState.ApplyCharacterMovementStats"/>).
/// The publication owner — not any App caller — decides whether a
/// server stat recompute may land:
/// <list type="bullet">
/// <item><see cref="PlayerMovementControllerPublicationLifecycle.StandalonePublished"/>,
/// <see cref="PlayerMovementControllerPublicationLifecycle.CandidatePreparing"/>, and
/// <see cref="PlayerMovementControllerPublicationLifecycle.RuntimePublished"/>
/// apply immediately — byte-identical to the deleted
/// <c>RuntimeMovementSkillProjection.ApplyTo</c> direct path.</item>
/// <item><see cref="PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant"/>
/// ALSO applies immediately: the dormant window (publication committed,
/// activation deferred on cell streaming —
/// <c>RuntimeLocalPlayerFirstEntryState.AdvanceCore</c>'s
/// AwaitingActivation loop) spans inbound pumps, and this exact instance
/// is the controller that <c>ActivateRuntimePublication</c> later makes
/// live, so the write must land here (same discipline as
/// <see cref="RefreshDormantRuntimePhysicsState"/> /
/// <see cref="RefreshDormantRuntimeVector"/>: accepted server facts
/// arriving mid-dormancy land on the dormant owner). These writes touch
/// only <see cref="PlayerWeenie"/> fields and the mover-flag latch —
/// no body/world/currency state the activation envelope validates.</item>
/// <item><see cref="PlayerMovementControllerPublicationLifecycle.CandidateSealed"/>,
/// <see cref="PlayerMovementControllerPublicationLifecycle.RuntimeRetired"/>, and
/// <see cref="PlayerMovementControllerPublicationLifecycle.Discarded"/>
/// 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
/// <see cref="RuntimeLocalPlayerMovementState"/> (Prepare requires the
/// movement owner empty and Commit installs it already-dormant in the
/// same synchronous Advance step).</item>
/// </list>
/// </summary>
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;
}
}
/// <summary>
/// The exact application body of the deleted
/// <c>RuntimeMovementSkillProjection.ApplyTo</c> (same fields, same
/// order, same conversions) — moved behind the lifecycle switch so the
/// dormant window can share it without routing through the
/// <see cref="EnsureConfigurationMutable"/>-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 <c>EntityCollisionFlagsExt.ToMoverState</c> and
/// <c>PlayerWeenie.JumpStaminaCost</c>.
/// </summary>
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);
}
/// <summary>
/// C3c-F1: the stamina-exhaustion EVENT dispatch
/// (retail <c>CommandInterpreter::HandleExhaustion</c> @ 0x006b3c70 →
/// <c>CPhysicsObj::report_exhaustion</c>), routed through the owner so
/// App never touches the gated <see cref="Motion"/> 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
/// <see cref="PlayerWeenie"/> stamina gate), and a terminal owner is a
/// displaced callback.
/// </summary>
internal bool ReportExhaustionAtMovementBoundary()
{
if (_publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.StandalonePublished
or PlayerMovementControllerPublicationLifecycle.CandidatePreparing
or PlayerMovementControllerPublicationLifecycle.RuntimePublished)
{
_motion.ReportExhaustion();
return true;
}
return false;
}
/// <summary> /// <summary>
/// R3-W2 (r3-port-plan.md §4): the player's <see cref="MotionInterpreter"/> /// R3-W2 (r3-port-plan.md §4): the player's <see cref="MotionInterpreter"/>
/// — GameWindow binds the player sequencer's MotionDone seam to it so the /// — GameWindow binds the player sequencer's MotionDone seam to it so the
@ -1651,15 +1791,39 @@ public sealed class PlayerMovementController
RearmConstraintLeashAtCurrentPosition(); RearmConstraintLeashAtCurrentPosition();
} }
/// <summary>
/// C3c-R1: arms the login-entry constraint leash from the Runtime
/// publication chain. The flip deleted the only login-path caller of
/// <see cref="RearmConstraintLeashAtCurrentPosition"/> (the App-side
/// <see cref="CommitPreparedPosition"/> call in the old
/// player-mode-entry commit); the dormant activation's final commit
/// (<c>RuntimeSetPositionState.TryApplyDormantLocalActivationFinalCommit</c>)
/// is the accepted-position event that replaces it — retail arms at
/// every accepted-position event (<c>SmartBox::HandleReceivedPosition</c>
/// 0x00453FD0). The final commit has already activated this controller
/// (<c>ActivateRuntimePublication</c>), 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 <see cref="PositionManager"/> was created by its
/// own publication candidate.
/// </summary>
internal void ArmConstraintLeashAtCommittedPlacement()
{
EnsurePublishedForRuntimeOperation();
RearmConstraintLeashAtCurrentPosition();
}
/// <summary> /// <summary>
/// #167 (Campaign P P5): retail <c>SmartBox::HandleReceivedPosition</c> /// #167 (Campaign P P5): retail <c>SmartBox::HandleReceivedPosition</c>
/// (0x00453fd0) "Player, teleport-newer" branch re-arms the leash /// (0x00453fd0) "Player, teleport-newer" branch re-arms the leash
/// immediately after <c>TeleportPlayer</c>'s teardown, anchored to the /// immediately after <c>TeleportPlayer</c>'s teardown, anchored to the
/// RECEIVED position (here, the body's just-snapped current position). /// 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 (<see cref="CommitPreparedPosition"/>), /// player-mode-entry commit path (<see cref="CommitPreparedPosition"/>),
/// which never ran UnConstrain because nothing could have armed the /// which never ran UnConstrain because nothing could have armed the
/// leash before the controller had a <see cref="PositionManager"/>. /// leash before the controller had a <see cref="PositionManager"/>,
/// and the C3c first-entry placement commit
/// (<see cref="ArmConstraintLeashAtCommittedPlacement"/>).
/// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2. /// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2.
/// </summary> /// </summary>
private void RearmConstraintLeashAtCurrentPosition() private void RearmConstraintLeashAtCurrentPosition()

View file

@ -131,12 +131,14 @@ internal readonly record struct RuntimeLocalPlayerFirstEntryOwnershipSnapshot(
/// <see cref="RuntimeSetPositionState.TryPrepareAuthoredMover"/> half instead /// <see cref="RuntimeSetPositionState.TryPrepareAuthoredMover"/> half instead
/// and never the fused method. /// and never the fused method.
/// ///
/// Dormant by design: <see cref="RuntimeEntityObjectLifetime"/> fully /// PRODUCTION-DRIVEN since the C3c flip: <see cref="RuntimeEntityObjectLifetime"/>
/// constructs and wires this class (construction, publication binding, /// fully constructs and wires this class (construction, publication binding,
/// retirement fan-out, bulk session-clear cleanup, ownership fold) exactly /// retirement fan-out, bulk session-clear cleanup, ownership fold) exactly
/// like every other owner it builds, but nothing calls /// like every other owner it builds, and the host first-entry drive
/// <see cref="Advance"/> in production — a later slice wires a host to drive /// (<c>RuntimeFirstEntryDriveController</c>, pumped by the graphical
/// it. /// hydration/frame-retry cadence and the headless spawn/position/tick
/// cadence) calls <see cref="Advance"/> for every local-player
/// initial-create residence.
/// </summary> /// </summary>
internal sealed class RuntimeLocalPlayerFirstEntryState internal sealed class RuntimeLocalPlayerFirstEntryState
{ {

View file

@ -14,6 +14,70 @@ public interface IRuntimeLocalPlayerMotionSource
MotionInterpreter? Motion { get; } MotionInterpreter? Motion { get; }
} }
/// <summary>
/// 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.
/// </summary>
public enum RuntimeMovementStatsApplication
{
/// <summary>Applied to the live (published/standalone) controller —
/// byte-identical to the pre-F1 direct application path.</summary>
AppliedLive,
/// <summary>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.</summary>
AppliedDormant,
/// <summary>No controller is installed (pre-first-entry, mid-candidate
/// construction, or after session teardown cleared the owner).</summary>
DroppedNoController,
/// <summary>The skill snapshot has no authoritative run/jump values yet
/// (PlayerDescription not processed) — same silent skip as the pre-F1
/// path.</summary>
DroppedIncompleteSnapshot,
/// <summary>The installed controller is terminal (sealed, retired, or
/// discarded): a displaced post-teardown write, reported instead of
/// faulting the session.</summary>
DroppedDisplacedController,
}
/// <summary>
/// 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
/// <see cref="RuntimeMovementStatsApplication"/>, 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 <c>RefreshDormantRuntimePhysicsState</c> 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.
/// </summary>
public enum RuntimeServerPhysicsStateApplication
{
/// <summary>Applied to the live (published/standalone) controller —
/// byte-identical to the direct <c>ApplyPhysicsState</c> path.</summary>
AppliedLive,
/// <summary>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.</summary>
DroppedDormantActivationOwned,
/// <summary>The installed controller is terminal — a displaced
/// post-teardown push.</summary>
DroppedDisplacedController,
}
/// <summary> /// <summary>
/// Canonical local movement lifetime and intent owner. Graphical input, /// Canonical local movement lifetime and intent owner. Graphical input,
/// presentation, diagnostics, and future no-window hosts borrow this exact /// presentation, diagnostics, and future no-window hosts borrow this exact
@ -37,7 +101,13 @@ public sealed class RuntimeLocalPlayerMovementState
public PlayerMovementController? Controller public PlayerMovementController? Controller
{ {
get => _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); ObjectDisposedException.ThrowIf(_disposed, this);
if (ReferenceEquals(_controller, value)) if (ReferenceEquals(_controller, value))
@ -189,6 +259,44 @@ public sealed class RuntimeLocalPlayerMovementState
return true; return true;
} }
/// <summary>
/// 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
/// <c>RuntimeMovementSkillProjection.ApplyTo</c>) 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
/// <c>PlayerMovementController.EnsureConfigurationMutable</c>.
/// Deliberately tolerant of a disposed owner: a recompute displaced
/// past teardown observes <see cref="RuntimeMovementStatsApplication.DroppedNoController"/>
/// instead of faulting the session.
/// </summary>
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);
}
/// <summary>
/// C3c-F1: routes the stamina-exhaustion EVENT (retail
/// <c>CommandInterpreter::HandleExhaustion</c>) 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 <see cref="ApplyCharacterMovementStats"/>.
/// </summary>
public bool ReportExhaustion() =>
_controller?.ReportExhaustionAtMovementBoundary() == true;
/// <summary> /// <summary>
/// Direct-host projection of the same combat readiness query used by the /// Direct-host projection of the same combat readiness query used by the
/// graphical attack adapter. A host without a constructed local movement /// graphical attack adapter. A host without a constructed local movement

View file

@ -278,13 +278,43 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
getObjectA: id => _physics.TryGetPhysicsHost(id, out var host) getObjectA: id => _physics.TryGetPhysicsHost(id, out var host)
? host ? host
: null, : 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: () => interruptCurrentMovement: () =>
movement.CancelMoveTo(WeenieError.ActionCancelled)); {
if (PhysicsDiagnostics.ProbeAutoWalkEnabled
&& movement.IsMovingTo())
{
Console.WriteLine("[autowalk-end] reason=interrupt");
}
movement.CancelMoveTo(WeenieError.ActionCancelled);
});
movement.MakeMoveToManager(); movement.MakeMoveToManager();
motion.UnstickFromObject = physicsHost.PositionManager.UnStick; motion.UnstickFromObject = physicsHost.PositionManager.UnStick;
motion.InterruptCurrentMovement = () => motion.InterruptCurrentMovement = () =>
{
if (PhysicsDiagnostics.ProbeAutoWalkEnabled
&& movement.IsMovingTo())
{
Console.WriteLine("[autowalk-end] reason=interrupt");
}
movement.CancelMoveTo(WeenieError.ActionCancelled); movement.CancelMoveTo(WeenieError.ActionCancelled);
};
controller.PositionManager = physicsHost.PositionManager; controller.PositionManager = physicsHost.PositionManager;
// This checkpoint publishes ownership only. The subsequent canonical // This checkpoint publishes ownership only. The subsequent canonical
// SetPosition transaction is the sole authority which may enter the // SetPosition transaction is the sole authority which may enter the
@ -687,11 +717,106 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
_physics.SetPosition.DispatchDormantLocalActivationShadow(committed); _physics.SetPosition.DispatchDormantLocalActivationShadow(committed);
if (!IsCommittedActivationSuffixCurrent(activation, committed)) if (!IsCommittedActivationSuffixCurrent(activation, committed))
return committed.Status; return committed.Status;
ArmFirstEntryConstraintLeash(activation);
SettleFirstEntryGroundContact(activation);
_physics.SetPosition.DispatchDormantLocalActivationPlacement(committed); _physics.SetPosition.DispatchDormantLocalActivationPlacement(committed);
projection = committed.Projection.Token; projection = committed.Projection.Token;
return committed.Status; return committed.Status;
} }
/// <summary>
/// C3c-R1: the login-entry constraint-leash arm the flip deleted with
/// the App-side <c>CommitPreparedPosition</c> caller. Ordering, with
/// file:line justification:
/// <list type="bullet">
/// <item>NOT at <c>Prepare</c> — <c>PreparePositionForCommit</c> (:219)
/// runs with <c>publishSharedState: false</c> and the controller's
/// <c>PositionManager</c> binds only later at :318, so the leash cannot
/// exist there (nor should it: the position is not accepted yet).</item>
/// <item>NOT at publication <c>Commit</c> — the activation's placement
/// evaluation (retail find-placement ring search) may still move or
/// reject the position.</item>
/// <item>HERE, after <c>TryApplyDormantLocalActivationFinalCommit</c>
/// (RuntimeSetPositionState.cs:2494-2516 commits the final cell,
/// activates the controller, and publishes the shared current cell) and
/// inside the same <c>IsCommittedActivationSuffixCurrent</c> gate the
/// settle uses — a stale suffix skips the arm exactly like the settle
/// (never armed on stale authority).</item>
/// <item>BEFORE <see cref="SettleFirstEntryGroundContact"/> — retail
/// arms anchored to the RECEIVED position
/// (<c>SmartBox::HandleReceivedPosition</c> 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.</item>
/// <item>Exactly once — <c>_activation</c> is nulled at :716 before
/// this suffix, so a resumed <c>AwaitingFinalShadowPreparation</c>
/// retry can never re-enter it after a successful final commit.</item>
/// </list>
/// </summary>
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++;
}
}
/// <summary>
/// C3c-F5: retail seeds the LOCAL player's ground contact from the first
/// gravity frame after <c>enter_world</c>, never from the placement
/// itself — <c>SmartBox::HandleCreateObject</c> (0x00454C80) runs
/// <c>init_player</c> (0x00455010) then <c>CPhysicsObj::enter_world</c>
/// (0x00455095 → 0x00516170), whose <c>SetPosition</c> 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 <see cref="SpawnPlacementSettler"/>):
/// 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 (<c>PlayerMovementController.CanSendPositionEvent</c>
/// reads <c>InContact &amp;&amp; OnWalkable</c> off the same body) and
/// the outbound wire contact bit (<c>LocalPlayerOutboundController</c>
/// serializes that predicate) — the flag ACE's "You can't do that while
/// in the air!" gate reads.
/// </summary>
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( private bool IsActivationPrephaseEnvelopeCurrent(
Activation activation, Activation activation,
in RuntimeDormantSetPositionCommitReceipt receipt) => in RuntimeDormantSetPositionCommitReceipt receipt) =>

View file

@ -1,41 +0,0 @@
using AcDream.Core.Physics;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -272,6 +272,39 @@ internal static class RuntimeAuthoritativePositionRouteClassifier
reporting); reporting);
} }
/// <summary>
/// C3c-R1 review F7: converts an already-classified SetPosition-performing
/// initial-Create route into the EXACT celless (AwaitFreshPosition) shape
/// the Parented/PickedUp branch of <see cref="ClassifyCreate"/> 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.
/// </summary>
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( internal static RuntimeAuthoritativePositionRoute ClassifyAcceptedPosition(
in RuntimeAcceptedPositionRouteRequest request) in RuntimeAcceptedPositionRouteRequest request)
{ {

View file

@ -1987,9 +1987,13 @@ public sealed class RuntimePhysicsState : IDisposable
{ {
EnsureNotDisposed(); EnsureNotDisposed();
EnsureCollisionMutationThread(); EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId); // C3c-F3: the old `canonical == 0u` check was dead (CanonicalLandblock
if (canonical == 0u) // 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)); throw new ArgumentOutOfRangeException(nameof(landblockId));
uint canonical = CanonicalLandblock(landblockId);
return SetPosition.BeginCollisionPrefixQuiescence( return SetPosition.BeginCollisionPrefixQuiescence(
canonical, canonical,
collisionGeneration, collisionGeneration,
@ -2034,6 +2038,13 @@ public sealed class RuntimePhysicsState : IDisposable
{ {
EnsureNotDisposed(); EnsureNotDisposed();
EnsureCollisionMutationThread(); 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); uint canonical = CanonicalLandblock(landblockId);
if (_collisionPrefixMutations.ContainsKey(canonical)) if (_collisionPrefixMutations.ContainsKey(canonical))
{ {
@ -2622,9 +2633,13 @@ public sealed class RuntimePhysicsState : IDisposable
{ {
EnsureNotDisposed(); EnsureNotDisposed();
EnsureCollisionMutationThread(); EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId); // C3c-F3: absent-id guard on the raw input — the old
if (canonical == 0u) // `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)); throw new ArgumentOutOfRangeException(nameof(landblockId));
uint canonical = CanonicalLandblock(landblockId);
if (kind is RuntimeCollisionPrefixMutationKind.Activation) if (kind is RuntimeCollisionPrefixMutationKind.Activation)
throw new ArgumentOutOfRangeException(nameof(kind)); throw new ArgumentOutOfRangeException(nameof(kind));
@ -2944,6 +2959,27 @@ public sealed class RuntimePhysicsState : IDisposable
: 1UL; : 1UL;
} }
/// <summary>
/// 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.
/// <see cref="TrySealCollisionEvaluationAuthority"/> 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.
/// </summary>
internal bool IsCollisionEvaluationPrefixAdmissible(uint exactCellId)
{
uint landblockId = CanonicalLandblock(exactCellId);
return landblockId != 0u
&& !_collisionAdmissions.ContainsKey(landblockId)
&& !SetPosition.IsCollisionPrefixQuiescing(landblockId);
}
/// <summary> /// <summary>
/// Exact collision-prefix generation authority used by private /// Exact collision-prefix generation authority used by private
/// SetPosition evaluations. Beginning a replacement generation advances /// SetPosition evaluations. Beginning a replacement generation advances
@ -3021,8 +3057,7 @@ public sealed class RuntimePhysicsState : IDisposable
} }
foreach (uint prefix in prefixes) foreach (uint prefix in prefixes)
{ {
if (_collisionAdmissions.ContainsKey(prefix) if (!IsCollisionEvaluationPrefixAdmissible(prefix))
|| SetPosition.IsCollisionPrefixQuiescing(prefix))
return false; return false;
} }

View file

@ -109,8 +109,14 @@ internal readonly record struct RuntimeCollisionPrefixQuiescenceToken(
ulong CollisionGeneration, ulong CollisionGeneration,
ulong OperationId) ulong OperationId)
{ {
internal bool IsValid => LandblockPrefix != 0u // C3c-F3: presence is discriminated by OperationId (allocated from a
&& (LandblockPrefix & 0xFFFFu) == 0u // 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 && CollisionGeneration != 0UL
&& OperationId != 0UL; && OperationId != 0UL;
} }
@ -778,9 +784,14 @@ internal sealed class RuntimeSetPositionState : IDisposable
EnsureNotDisposed(); EnsureNotDisposed();
if (collisionGeneration == 0UL) if (collisionGeneration == 0UL)
throw new ArgumentOutOfRangeException(nameof(collisionGeneration)); throw new ArgumentOutOfRangeException(nameof(collisionGeneration));
uint prefix = landblockId & 0xFFFF0000u; // C3c-F3: reject only the genuinely-absent landblock id (0). Prefix
if (prefix == 0u) // 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)); throw new ArgumentOutOfRangeException(nameof(landblockId));
uint prefix = landblockId & 0xFFFF0000u;
if (_collisionPrefixQuiescence.TryGetValue( if (_collisionPrefixQuiescence.TryGetValue(
prefix, prefix,
@ -1848,6 +1859,45 @@ internal sealed class RuntimeSetPositionState : IDisposable
&& operation.WakeableLostCell; && operation.WakeableLostCell;
} }
/// <summary>
/// C3c-F2: the identity check below is against
/// <see cref="RuntimePhysicsState.CollisionGenerationAuthority"/> — the
/// generation the collision world currently HOLDS — not against
/// <c>ExpectedCollisionGeneration</c>, which means two different things
/// at the two ends of this wait. At park time (this class's own
/// <c>TryPrepareDormantLocalActivationCommit</c>) 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 <c>CollisionGenerationReady</c> is
/// <c>CommitCollisionGeneration(lb, G, ready)</c>, 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.
///
/// <para>
/// The trailing
/// <see cref="RuntimePhysicsState.IsCollisionEvaluationPrefixAdmissible"/>
/// 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
/// <c>TrySealCollisionEvaluationAuthority</c> 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
/// <c>TryGetBlockingQuiescence</c> (:4069-4095).
/// </para>
/// </summary>
private bool TryRearmDeferredDormantLocalActivation( private bool TryRearmDeferredDormantLocalActivation(
RuntimeEntityRecord record, RuntimeEntityRecord record,
PhysicsBody body, PhysicsBody body,
@ -1868,8 +1918,10 @@ internal sealed class RuntimeSetPositionState : IDisposable
|| !operation.CollisionGenerationReady || !operation.CollisionGenerationReady
|| operation.ProjectionSequence != 0UL || operation.ProjectionSequence != 0UL
|| operation.CollisionGeneration != _physics || operation.CollisionGeneration != _physics
.ExpectedCollisionGeneration(operation.ExactCellId) .CollisionGenerationAuthority(operation.ExactCellId)
|| !_physics.Engine.IsSpawnCellReady(operation.ExactCellId)) || !_physics.Engine.IsSpawnCellReady(operation.ExactCellId)
|| !_physics.IsCollisionEvaluationPrefixAdmissible(
operation.ExactCellId))
{ {
return false; return false;
} }
@ -3366,14 +3418,19 @@ internal sealed class RuntimeSetPositionState : IDisposable
command); command);
CollisionPrefixQuiescence? quiescence = CollisionPrefixQuiescence? quiescence =
_collisionPrefixQuiescence.GetValueOrDefault(prefix); _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( RuntimeSetPositionOutcome parked = ParkDeferred(
operation, operation,
result, result,
publishImmediately: false, publishImmediately: false,
collisionGenerationOverride: collisionGenerationOverride:
quiescence?.Token.CollisionGeneration ?? 0UL, quiescence?.Token.CollisionGeneration,
collisionPrefixOverride: collisionPrefixOverride:
quiescence?.Token.LandblockPrefix ?? 0u); quiescence?.Token.LandblockPrefix);
if (_pendingProjection.TryGetValue( if (_pendingProjection.TryGetValue(
parked.Projection.Sequence, parked.Projection.Sequence,
out RuntimePlacementProjectionSnapshot staged)) out RuntimePlacementProjectionSnapshot staged))
@ -3929,12 +3986,22 @@ internal sealed class RuntimeSetPositionState : IDisposable
_operationPool.Clear(); _operationPool.Clear();
} }
/// <summary>
/// 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 <see cref="Operation.CollisionQuiescenceHeld"/> derived false and
/// the parked operation skipped the QuiescenceHeld stage entirely.
/// </summary>
private RuntimeSetPositionOutcome ParkDeferred( private RuntimeSetPositionOutcome ParkDeferred(
Operation operation, Operation operation,
in PhysicsSetPositionResult result, in PhysicsSetPositionResult result,
bool publishImmediately = true, bool publishImmediately = true,
ulong collisionGenerationOverride = 0UL, ulong? collisionGenerationOverride = null,
uint collisionPrefixOverride = 0u) uint? collisionPrefixOverride = null)
{ {
PhysicsBody body = operation.Body!; PhysicsBody body = operation.Body!;
body.Orientation = result.Orientation; body.Orientation = result.Orientation;
@ -3969,13 +4036,11 @@ internal sealed class RuntimeSetPositionState : IDisposable
operation.WakeableLostCell = true; operation.WakeableLostCell = true;
operation.EnteringWorldFromCelllessResidence = true; operation.EnteringWorldFromCelllessResidence = true;
ArmLostFamilyDeadlines(operation); ArmLostFamilyDeadlines(operation);
operation.CollisionGeneration = collisionGenerationOverride != 0UL operation.CollisionGeneration = collisionGenerationOverride
? collisionGenerationOverride ?? _physics.ExpectedCollisionGeneration(result.CellId);
: _physics.ExpectedCollisionGeneration(result.CellId); operation.CollisionPrefix = collisionPrefixOverride
operation.CollisionPrefix = collisionPrefixOverride != 0u ?? result.CellId & 0xFFFF0000u;
? collisionPrefixOverride operation.CollisionQuiescenceHeld = collisionPrefixOverride.HasValue;
: result.CellId & 0xFFFF0000u;
operation.CollisionQuiescenceHeld = collisionPrefixOverride != 0u;
operation.Command = operation.Command with operation.Command = operation.Command with
{ {
Physics = operation.Command.Physics with Physics = operation.Command.Physics with

View file

@ -0,0 +1,354 @@
using AcDream.Content;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Session;
/// <summary>
/// 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 <see cref="DriveAll"/> 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
/// <see cref="RuntimeEntityObjectLifetime.BindInitialResidenceBeginNotification"/>)
/// 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
/// <see cref="RuntimeSetPositionState.TryPrepareAndSubmitAuthoredPlacement"/>
/// — 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.
/// </summary>
internal sealed class RuntimeFirstEntryDriveController
{
/// <summary>
/// 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.
/// </summary>
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<PlayerMovementConstructionOptions> _localOptions;
private readonly Func<RuntimeEntityRecord,
RuntimeLocalPlayerPhysicsActivationPreparation> _localActivation;
private readonly Dictionary<RuntimeEntityKey, Pending> _pending = [];
private readonly List<RuntimeEntityKey> _driveScratch = [];
private bool _driving;
/// <summary>C3c-R1 review F6: see <see cref="AttachRoute"/>.</summary>
private object? _routeOwner;
internal RuntimeFirstEntryDriveController(
RuntimeEntityObjectLifetime entityObjects,
IGameRuntimeClock clock,
IPreparedCollisionSource collisionSource,
Func<PlayerMovementConstructionOptions> localOptions,
Func<RuntimeEntityRecord,
RuntimeLocalPlayerPhysicsActivationPreparation> 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;
/// <summary>
/// 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.
/// </summary>
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,
};
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Route-scoped teardown: clears every tracked entry, but ONLY when
/// <paramref name="route"/> 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).
/// </summary>
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.
}
}
/// <summary>
/// Completes (or makes bounded progress on) the executor's pending
/// continuation placement for <paramref name="key"/>. Returns true when
/// enough progress happened that re-calling Advance can observe it.
/// </summary>
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;
}
}
}

View file

@ -72,8 +72,28 @@ public sealed class RuntimeLiveEntitySessionController
private void OnSpawned(WorldSession.EntitySpawn spawn) private void OnSpawned(WorldSession.EntitySpawn spawn)
{ {
RuntimeEntityRegistrationResult registration = // C3c route-8 flip: every direct-host Create enters the SAME initial
Entities.RegisterEntity(spawn); // 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) if (registration.Canonical is not { } canonical)
return; return;

View file

@ -0,0 +1,59 @@
namespace AcDream.App.Tests.Input;
/// <summary>
/// C3c-F2 (2026-08-02): source pin for the production player-mode auto-entry
/// precondition. <c>PlayerModeAutoEntry</c> 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 <c>PlayerModeController.TryEnter</c> 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.
/// </summary>
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.");
}
}

View file

@ -12,24 +12,200 @@ namespace AcDream.App.Tests;
/// </summary> /// </summary>
internal static class LiveEntityRuntimeFixture internal static class LiveEntityRuntimeFixture
{ {
/// <summary>
/// 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.
/// </summary>
private static RuntimeEntityObjectLifetime WithGeneration(
RuntimeEntityObjectLifetime lifetime)
{
lifetime.BindEventContext(
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
static () => 1UL);
return lifetime;
}
public static LiveEntityRuntime Create( public static LiveEntityRuntime Create(
GpuWorldState spatial, GpuWorldState spatial,
ILiveEntityResourceLifecycle resources, ILiveEntityResourceLifecycle resources,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{ {
var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId); var lifetime = WithGeneration(
new RuntimeEntityObjectLifetime(firstLocalEntityId));
return new LiveEntityRuntime(spatial, resources, lifetime); return new LiveEntityRuntime(spatial, resources, lifetime);
} }
/// <summary>
/// C3c: a runtime whose initial-create residences can actually be driven
/// to completion — collision generation committed for
/// <paramref name="landblockId"/>, the production
/// <see cref="AcDream.Runtime.Session.RuntimeFirstEntryDriveController"/>
/// 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 <see cref="DrivenLiveEntityRuntime.Pump"/> to complete the
/// conductors exactly like the composed host's Create-transaction pump.
/// </summary>
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<AcDream.Core.Physics.CellSurface>(),
Array.Empty<AcDream.Core.Physics.PortalPlane>(),
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( public static LiveEntityRuntime Create(
GpuWorldState spatial, GpuWorldState spatial,
ILiveEntityResourceLifecycle resources, ILiveEntityResourceLifecycle resources,
PhysicsEngine physicsEngine, PhysicsEngine physicsEngine,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{ {
var lifetime = new RuntimeEntityObjectLifetime( var lifetime = WithGeneration(new RuntimeEntityObjectLifetime(
physicsEngine, physicsEngine,
firstLocalEntityId); firstLocalEntityId));
return new LiveEntityRuntime(spatial, resources, lifetime); return new LiveEntityRuntime(spatial, resources, lifetime);
} }
@ -39,7 +215,8 @@ internal static class LiveEntityRuntimeFixture
Action<LiveEntityRecord> tearDownRuntimeComponents, Action<LiveEntityRecord> tearDownRuntimeComponents,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{ {
var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId); var lifetime = WithGeneration(
new RuntimeEntityObjectLifetime(firstLocalEntityId));
return new LiveEntityRuntime( return new LiveEntityRuntime(
spatial, spatial,
resources, resources,
@ -53,7 +230,8 @@ internal static class LiveEntityRuntimeFixture
ILiveEntityRuntimeComponentLifecycle runtimeComponentLifecycle, ILiveEntityRuntimeComponentLifecycle runtimeComponentLifecycle,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{ {
var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId); var lifetime = WithGeneration(
new RuntimeEntityObjectLifetime(firstLocalEntityId));
return new LiveEntityRuntime( return new LiveEntityRuntime(
spatial, spatial,
resources, resources,
@ -68,9 +246,9 @@ internal static class LiveEntityRuntimeFixture
PhysicsEngine physicsEngine, PhysicsEngine physicsEngine,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{ {
var lifetime = new RuntimeEntityObjectLifetime( var lifetime = WithGeneration(new RuntimeEntityObjectLifetime(
physicsEngine, physicsEngine,
firstLocalEntityId); firstLocalEntityId));
return new LiveEntityRuntime( return new LiveEntityRuntime(
spatial, spatial,
resources, resources,

View file

@ -0,0 +1,53 @@
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.App.Tests;
/// <summary>
/// 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.
/// </summary>
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)),
};
}

View file

@ -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;
/// <summary>
/// C3c-F1 (2026-08-02): the movement-stats application seam driven through
/// the REAL inbound chain that crashed the connected lifecycle gate —
/// <c>ClientObjectTable.Ingest</c> → <c>ObjectAdded/ObjectUpdated</c> →
/// <c>LiveSessionEventRouter.RecomputePlayerQualities</c> →
/// <c>OnMovementStatsUpdated</c> → <see cref="LiveMovementStatsApplier"/> →
/// <see cref="RuntimeLocalPlayerMovementState.ApplyCharacterMovementStats"/>.
/// </summary>
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<string> 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();
}
/// <summary>
/// Ingests the player's own object row — the exact post-logout
/// inbound-Create edge (<c>ApplyAcceptedSpawn</c> →
/// <c>ClientObjectTable.Ingest</c>) that fired the crashing
/// recompute in logs/connected-world-gate-20260802-122749.
/// </summary>
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<InvalidOperationException>(
() => 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));
}
}

View file

@ -257,7 +257,12 @@ public sealed class LiveSessionResetPlanTests
spatial, spatial,
new FailingOnceResources(), new FailingOnceResources(),
runtime.EntityObjects); 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( live.MaterializeLiveEntity(
player, player,
0x01010001u, 0x01010001u,

View file

@ -0,0 +1,53 @@
using System.Text.RegularExpressions;
namespace AcDream.App.Tests.Physics;
/// <summary>
/// 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).
/// </summary>
public sealed class C3cF1ProductionWiringTests
{
[Fact]
public void LocalPlayerInboundSetState_RoutesThroughTheTypedOwnerEntry()
{
string source = ReadSource(
"Physics",
"LiveEntityNetworkUpdateController.cs");
Assert.Single(
Regex.Matches(source, @"ApplyServerPhysicsState\(")
.Cast<Match>());
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.");
}
}

View file

@ -7,20 +7,40 @@ public sealed class Issue270ProductionWiringTests
[Fact] [Fact]
public void MovementStats_UseOneEdgeTrackerAndResetItWithTheSession() 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( Assert.Contains(
"_staminaExhaustion.Observe(snapshot.CurrentStamina)", "_staminaExhaustion.Observe(snapshot.CurrentStamina)",
source, applier,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Single( Assert.Single(
Regex.Matches( Regex.Matches(
source, applier,
@"controller!\.Motion\.ReportExhaustion\(\);") @"_movement\.ReportExhaustion\(\);")
.Cast<Match>()); .Cast<Match>());
Assert.Contains( Assert.Contains(
"_staminaExhaustion.Reset();", "_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); StringComparison.Ordinal);
} }
@ -38,8 +58,11 @@ public sealed class Issue270ProductionWiringTests
Assert.Equal( Assert.Equal(
3, 3,
Regex.Matches(source, @"SeedRemoteSpawnPlacement\(").Count); 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( Assert.Contains(
"RemoteSpawnPlacementSettler.TrySettle(", "SpawnPlacementSettler.TrySettle(",
source, source,
StringComparison.Ordinal); StringComparison.Ordinal);
} }

View file

@ -221,7 +221,12 @@ public sealed class LiveEntityInboundAuthorityGateTests
out AcceptedPositionNetworkUpdate accepted)); out AcceptedPositionNetworkUpdate accepted));
Assert.Same(registration.Canonical, accepted.Canonical); 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.Equal(1, publishCount);
Assert.False(runtime.TryGetRecord(Guid, out _)); Assert.False(runtime.TryGetRecord(Guid, out _));
} }

View file

@ -1112,7 +1112,10 @@ public sealed class RemotePhysicsUpdaterTests
0f, 0f,
0f, 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( var physics = new PhysicsSpawnData(
RawState: (uint)state, RawState: (uint)state,
Position: serverPosition, Position: serverPosition,

View file

@ -466,6 +466,7 @@ public sealed class EquippedChildProjectionWithdrawalTests
ChildInstanceSequence: 1, ChildInstanceSequence: 1,
ChildPositionSequence: 1); ChildPositionSequence: 1);
fixture.CompleteFirstEntry();
Assert.True(fixture.Live.TryApplyCreateParent(update, out _)); Assert.True(fixture.Live.TryApplyCreateParent(update, out _));
fixture.Controller.OnCreateParentAccepted(update); fixture.Controller.OnCreateParentAccepted(update);
@ -509,13 +510,20 @@ public sealed class EquippedChildProjectionWithdrawalTests
LiveEntityRecord parent = fixture.Spawn(0x70000270u, generation: 1); LiveEntityRecord parent = fixture.Spawn(0x70000270u, generation: 1);
WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData( WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
0x70000271u, 0x70000271u,
generation: 1) with generation: 1);
childSpawn = childSpawn with
{ {
Position = null, Position = null,
ParentGuid = parent.ServerGuid, ParentGuid = parent.ServerGuid,
ParentLocation = 0, ParentLocation = 0,
PlacementId = null, PlacementId = null,
PositionSequence = 0, PositionSequence = 0,
Physics = childSpawn.Physics!.Value with
{
Position = null,
Parent = new PhysicsAttachment(parent.ServerGuid, 0u),
AnimationFrame = null,
},
}; };
fixture.Live.RegisterLiveEntity(childSpawn); fixture.Live.RegisterLiveEntity(childSpawn);
fixture.Controller.OnSpawn(childSpawn); fixture.Controller.OnSpawn(childSpawn);
@ -558,13 +566,20 @@ public sealed class EquippedChildProjectionWithdrawalTests
LiveEntityRecord parent = fixture.Spawn(0x70000272u, generation: 1); LiveEntityRecord parent = fixture.Spawn(0x70000272u, generation: 1);
WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData( WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
0x70000273u, 0x70000273u,
generation: 1) with generation: 1);
childSpawn = childSpawn with
{ {
Position = null, Position = null,
ParentGuid = parent.ServerGuid, ParentGuid = parent.ServerGuid,
ParentLocation = 0, ParentLocation = 0,
PlacementId = 0, PlacementId = 0,
PositionSequence = 0, PositionSequence = 0,
Physics = childSpawn.Physics!.Value with
{
Position = null,
Parent = new PhysicsAttachment(parent.ServerGuid, 0u),
AnimationFrame = 0,
},
}; };
fixture.Live.RegisterLiveEntity(childSpawn); fixture.Live.RegisterLiveEntity(childSpawn);
fixture.Controller.OnSpawn(childSpawn); fixture.Controller.OnSpawn(childSpawn);
@ -577,13 +592,20 @@ public sealed class EquippedChildProjectionWithdrawalTests
Assert.Null(entity.PaletteOverride); Assert.Null(entity.PaletteOverride);
WorldSession.EntitySpawn grandchildSpawn = ControllerFixture.SpawnData( WorldSession.EntitySpawn grandchildSpawn = ControllerFixture.SpawnData(
0x70000274u, 0x70000274u,
generation: 1) with generation: 1);
grandchildSpawn = grandchildSpawn with
{ {
Position = null, Position = null,
ParentGuid = child.ServerGuid, ParentGuid = child.ServerGuid,
ParentLocation = 0, ParentLocation = 0,
PlacementId = 0, PlacementId = 0,
PositionSequence = 0, PositionSequence = 0,
Physics = grandchildSpawn.Physics!.Value with
{
Position = null,
Parent = new PhysicsAttachment(child.ServerGuid, 0u),
AnimationFrame = 0,
},
}; };
fixture.Live.RegisterLiveEntity(grandchildSpawn); fixture.Live.RegisterLiveEntity(grandchildSpawn);
fixture.Controller.OnSpawn(grandchildSpawn); fixture.Controller.OnSpawn(grandchildSpawn);
@ -734,6 +756,8 @@ public sealed class EquippedChildProjectionWithdrawalTests
Position = null, Position = null,
PositionSequence = 1, PositionSequence = 1,
PlacementId = 0, PlacementId = 0,
ParentGuid = parent.ServerGuid,
ParentLocation = 0,
Physics = physics, Physics = physics,
}; };
@ -1192,6 +1216,50 @@ public sealed class EquippedChildProjectionWithdrawalTests
private const uint Cell = 0x01010001u; private const uint Cell = 0x01010001u;
private readonly DeferredLiveEntityRuntimeComponentLifecycle _lifecycle = new(); private readonly DeferredLiveEntityRuntimeComponentLifecycle _lifecycle = new();
private readonly Setup _setup; 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( internal ControllerFixture(
Func<LiveEntityRecord, ulong, ulong, ExactProjectionWithdrawalOutcome> Func<LiveEntityRecord, ulong, ulong, ExactProjectionWithdrawalOutcome>
@ -1201,10 +1269,24 @@ public sealed class EquippedChildProjectionWithdrawalTests
(Cell & 0xFFFF0000u) | 0xFFFFu, (Cell & 0xFFFF0000u) | 0xFFFFu,
new LandBlock(), new LandBlock(),
Array.Empty<WorldEntity>())); Array.Empty<WorldEntity>()));
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, Spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
_lifecycle); _lifecycle,
EntityObjects);
_setup = new Setup _setup = new Setup
{ {
HoldingLocations = HoldingLocations =
@ -1231,6 +1313,21 @@ public sealed class EquippedChildProjectionWithdrawalTests
} }
internal GpuWorldState Spatial { get; } = new(); internal GpuWorldState Spatial { get; } = new();
internal RuntimeEntityObjectLifetime EntityObjects { get; }
/// <summary>
/// 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.
/// </summary>
internal void CompleteFirstEntry()
{
_firstEntry.DriveAll();
}
internal ClientObjectTable Objects { get; } = new(); internal ClientObjectTable Objects { get; } = new();
internal EntityEffectPoseRegistry Poses { get; } = new(); internal EntityEffectPoseRegistry Poses { get; } = new();
internal LiveEntityRuntime Live { get; } internal LiveEntityRuntime Live { get; }
@ -1254,7 +1351,13 @@ public sealed class EquippedChildProjectionWithdrawalTests
{ {
WorldSession.EntitySpawn spawn = SpawnData(guid, generation); WorldSession.EntitySpawn spawn = SpawnData(guid, generation);
if (!hasPosition) if (!hasPosition)
spawn = spawn with { Position = null }; {
spawn = spawn with
{
Position = null,
Physics = spawn.Physics!.Value with { Position = null },
};
}
if (!hasSetup) if (!hasSetup)
spawn = spawn with { SetupTableId = null }; spawn = spawn with { SetupTableId = null };
return Assert.IsType<RuntimeEntityRecord>( return Assert.IsType<RuntimeEntityRecord>(
@ -1288,21 +1391,59 @@ public sealed class EquippedChildProjectionWithdrawalTests
return record; return record;
} }
internal static WorldSession.EntitySpawn SpawnData(uint guid, ushort generation) => new( internal static WorldSession.EntitySpawn SpawnData(uint guid, ushort generation)
guid, {
new CreateObject.ServerPosition( // C3c: residence admission freezes the raw create and requires
Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f), // the flattened parser projections to agree with the nested
0x02000001u, // PhysicsDesc block (HasConsistentCreateIdentityAndParent).
Array.Empty<CreateObject.AnimPartChange>(), var position = new CreateObject.ServerPosition(
Array.Empty<CreateObject.TextureChange>(), Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f);
Array.Empty<CreateObject.SubPaletteSwap>(), var physics = new PhysicsSpawnData(
BasePaletteId: null, RawState: 0u,
ObjScale: null, Position: position,
Name: "attached fixture", Movement: null,
ItemType: null, AnimationFrame: null,
MotionState: null, SetupTableId: 0x02000001u,
MotionTableId: null, MotionTableId: null,
InstanceSequence: generation); 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<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: null,
Name: "attached fixture",
ItemType: null,
MotionState: null,
MotionTableId: null,
InstanceSequence: generation,
Physics: physics);
}
internal void InstallAttached( internal void InstallAttached(
LiveEntityRecord parent, LiveEntityRecord parent,

View file

@ -33,7 +33,7 @@ public sealed class LiveAppearanceAnimationTests
ItemType: null, ItemType: null,
MotionState: null, MotionState: null,
MotionTableId: null, MotionTableId: null,
InstanceSequence: 1); InstanceSequence: 1).WithConsistentPhysics();
LiveEntityRecord record = runtime.RegisterAndMaterializeProjection( LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(
spawn, spawn,
id => Entity(id, 0x01000001u, guid)); id => Entity(id, 0x01000001u, guid));
@ -175,7 +175,7 @@ public sealed class LiveAppearanceAnimationTests
Assert.Equal(1, registry.RetainedRegistrationCount); 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, guid,
new CreateObject.ServerPosition(cell, 1f, 1f, 1f, 1f, 0f, 0f, 0f), new CreateObject.ServerPosition(cell, 1f, 1f, 1f, 1f, 0f, 0f, 0f),
0x02000010u, 0x02000010u,
@ -188,7 +188,7 @@ public sealed class LiveAppearanceAnimationTests
ItemType: null, ItemType: null,
MotionState: null, MotionState: null,
MotionTableId: null, MotionTableId: null,
InstanceSequence: instance); InstanceSequence: instance).WithConsistentPhysics();
private static WorldEntity Entity( private static WorldEntity Entity(
uint id, uint id,

View file

@ -74,7 +74,22 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests
publishAppearance: _ => publishAppearance: _ =>
{ {
operations.Add("appearance"); 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; return true;
}, },
publishCurrentSnapshot: _ => operations.Add("current"), publishCurrentSnapshot: _ => operations.Add("current"),
@ -315,7 +330,7 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests
return record; return record;
} }
private static WorldSession.EntitySpawn Spawn() => new( private static WorldSession.EntitySpawn Spawn() => new WorldSession.EntitySpawn(
Guid, Guid,
new CreateObject.ServerPosition( new CreateObject.ServerPosition(
Cell, Cell,
@ -336,7 +351,7 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests
ItemType: null, ItemType: null,
MotionState: null, MotionState: null,
MotionTableId: null, MotionTableId: null,
InstanceSequence: 1); InstanceSequence: 1).WithConsistentPhysics();
private static LiveEntityAnimationState AnimationState( private static LiveEntityAnimationState AnimationState(
WorldEntity entity, WorldEntity entity,

View file

@ -232,7 +232,7 @@ public sealed class LiveEntityLightControllerTests
InstanceSequence: 1, InstanceSequence: 1,
MovementSequence: 1, MovementSequence: 1,
ServerControlSequence: 1, ServerControlSequence: 1,
PositionSequence: 1); PositionSequence: 1).WithConsistentPhysics();
} }
} }

View file

@ -278,6 +278,15 @@ public sealed class CurrentGameRuntimeAdapterTests
[Fact] [Fact]
public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace() 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 = WorldSession.EntitySpawn spawn =
Spawn(Harness.TargetGuid, instance: 7, cell: 0x12340001u); Spawn(Harness.TargetGuid, instance: 7, cell: 0x12340001u);
var direct = new RuntimeEntityObjectLifetime(); var direct = new RuntimeEntityObjectLifetime();
@ -286,148 +295,28 @@ public sealed class CurrentGameRuntimeAdapterTests
using IDisposable directSubscription = using IDisposable directSubscription =
direct.Events.Subscribe(directTrace); direct.Events.Subscribe(directTrace);
RuntimeEntityRegistrationResult directRegistration = InvalidOperationException directRefusal =
direct.RegisterEntity(spawn); Assert.Throws<InvalidOperationException>(() =>
RuntimeEntityRecord directCanonical = direct.RegisterEntityWithInitialResidence(
Assert.IsType<RuntimeEntityRecord>(directRegistration.Canonical); spawn,
direct.Objects.AddOrUpdate(Object(spawn)); isLocalPlayer: false));
direct.Objects.MoveItem(
spawn.Guid,
Harness.PlayerGuid,
newSlot: 3);
var objDesc = new ObjDescEvent.Parsed(
spawn.Guid,
new CreateObject.ModelData(
0x04000001u,
Array.Empty<CreateObject.SubPaletteSwap>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.AnimPartChange>()),
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));
using var graphical = new Harness(); using var graphical = new Harness();
var graphicalTrace = new EntityObjectTrace(); var graphicalTrace = new EntityObjectTrace();
using IDisposable graphicalSubscription = using IDisposable graphicalSubscription =
graphical.EntityObjects.Events.Subscribe(graphicalTrace); 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<InvalidOperationException>(() =>
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.Equal(directTrace.Entries, graphicalTrace.Entries);
Assert.Empty(directTrace.Entries);
Assert.Equal(0, direct.Entities.Count); Assert.Equal(0, direct.Entities.Count);
Assert.Equal(0, direct.Objects.ObjectCount); Assert.Equal(0, direct.Objects.ObjectCount);
Assert.Equal(0, graphical.EntityObjects.Entities.Count); Assert.Equal(0, graphical.EntityObjects.Entities.Count);
@ -438,6 +327,11 @@ public sealed class CurrentGameRuntimeAdapterTests
public void GraphicalObserverFailure_DoesNotStarveLaterObserverOrOwner() public void GraphicalObserverFailure_DoesNotStarveLaterObserverOrOwner()
{ {
using var harness = new Harness(); 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 throwing = new ThrowingEntityObserver();
var recording = new RuntimeTraceRecorder(); var recording = new RuntimeTraceRecorder();
IDisposable first = harness.Runtime.Subscribe(throwing); IDisposable first = harness.Runtime.Subscribe(throwing);

View file

@ -599,7 +599,7 @@ public sealed class LocalPlayerTeleportControllerTests
new Vector3(x, y, z), new Vector3(x, y, z),
Quaternion.Identity)); 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, Guid: guid,
Position: new CreateObject.ServerPosition( Position: new CreateObject.ServerPosition(
cell, cell,
@ -619,7 +619,7 @@ public sealed class LocalPlayerTeleportControllerTests
Name: "player", Name: "player",
ItemType: null, ItemType: null,
MotionState: null, MotionState: null,
MotionTableId: null); MotionTableId: null).WithConsistentPhysics();
private sealed class NullResources : ILiveEntityResourceLifecycle private sealed class NullResources : ILiveEntityResourceLifecycle
{ {

View file

@ -544,7 +544,7 @@ public sealed class StreamingFrameControllerTests
InstanceSequence: 1, InstanceSequence: 1,
MovementSequence: 1, MovementSequence: 1,
ServerControlSequence: 1, ServerControlSequence: 1,
PositionSequence: 1); PositionSequence: 1).WithConsistentPhysics();
} }
private static IReadOnlyList<LandblockStreamResult> Drain( private static IReadOnlyList<LandblockStreamResult> Drain(

View file

@ -0,0 +1,74 @@
using System.Text.RegularExpressions;
namespace AcDream.App.Tests.World;
/// <summary>
/// C3c-R1 F3 (coordinator resolution, 2026-08-02): the create-authority
/// drift probes in the expectation-item 6/8 tests
/// (LiveEntityHydrationControllerTests + LiveEntityCreateSupersessionRecoveryTests)
/// hand-call <c>record.Canonical.AdvanceCreateAuthority()</c> 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
/// <c>!beginInitialResidence</c>) and <c>ConsumeExecuted</c> 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.
/// </summary>
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<Match>());
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.");
}
}

View file

@ -144,7 +144,7 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests
} }
private static WorldSession.EntitySpawn CreateSpawn(uint guid) => private static WorldSession.EntitySpawn CreateSpawn(uint guid) =>
new( new WorldSession.EntitySpawn(
Guid: guid, Guid: guid,
Position: null, Position: null,
SetupTableId: null, SetupTableId: null,
@ -157,5 +157,5 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests
ItemType: null, ItemType: null,
MotionState: null, MotionState: null,
MotionTableId: null, MotionTableId: null,
InstanceSequence: 1); InstanceSequence: 1).WithConsistentPhysics();
} }

View file

@ -370,12 +370,14 @@ public sealed class LiveEntityHydrationControllerTests
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord record = fixture.Record; LiveEntityRecord record = fixture.Record;
WorldEntity entity = record.WorldEntity!; WorldEntity entity = record.WorldEntity!;
var body = new PhysicsBody(); // C3c/C3b: the first-entry conductor constructs the canonical
Assert.Same( // physics body at Create (retail ACCObjectMaint::CreateObject /
body, // set_description). Capture that existing body — the identity the
fixture.Runtime.GetOrCreatePhysicsBody( // pickup/re-enter cycle must preserve — instead of seeding one.
record.ServerGuid, PhysicsBody body = fixture.Runtime.GetOrCreatePhysicsBody(
_ => body)); record.ServerGuid,
static _ => throw new InvalidOperationException(
"The conductor-built canonical body should already exist."));
fixture.Relationships.OnUnparentAction = _ => fixture.Relationships.OnUnparentAction = _ =>
fixture.Runtime.WithdrawLiveEntityProjection(record) fixture.Runtime.WithdrawLiveEntityProjection(record)
? ChildUnparentDisposition.Completed ? ChildUnparentDisposition.Completed
@ -962,6 +964,14 @@ public sealed class LiveEntityHydrationControllerTests
// Local-player records ordinarily do not rebucket from streaming // Local-player records ordinarily do not rebucket from streaming
// callbacks; an incomplete initial transaction must still recover. // 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); fixture.Controller.OnLandblockLoaded(Cell);
Assert.Same(partial, record.WorldEntity); Assert.Same(partial, record.WorldEntity);
@ -1042,7 +1052,13 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name);
Assert.True(fixture.Record.InitialHydrationCompleted); Assert.True(fixture.Record.InitialHydrationCompleted);
Assert.Single(fixture.Materializer.Calls); 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] [Fact]
@ -1114,10 +1130,16 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Equal((ushort)1, fixture.Record.Generation); Assert.Equal((ushort)1, fixture.Record.Generation);
Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name);
Assert.True(fixture.Record.InitialHydrationCompleted); 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( Assert.DoesNotContain(
(ushort)2, (ushort)3,
fixture.Materializer.PositionSequences.Skip(1)); fixture.Materializer.PositionSequences);
} }
[Fact] [Fact]
@ -1210,11 +1232,19 @@ public sealed class LiveEntityHydrationControllerTests
Assert.True(record.InitialHydrationCompleted); Assert.True(record.InitialHydrationCompleted);
Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); 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( Assert.Equal(
LiveProjectionPurpose.CreateSupersessionRecovery, LiveProjectionPurpose.SpatialRecovery,
fixture.Materializer.Calls[^1].Purpose); fixture.Materializer.Calls[^1].Purpose);
Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion); Assert.Equal(1UL, fixture.Materializer.InstalledCreateIntegrationVersion);
} }
[Theory] [Theory]
@ -1236,10 +1266,23 @@ public sealed class LiveEntityHydrationControllerTests
if (refreshed || spawn.PositionSequence != 1) if (refreshed || spawn.PositionSequence != 1)
return; return;
refreshed = true; refreshed = true;
fixture.Controller.OnCreate(Spawn( // C3c: a fresher same-generation CreateObject no longer advances
Generation: 1, // create authority at registration (its advance lands at the
PositionSequence: 2, // residence drain's WeenieDescription stage). Model that exact
Name: "recovery v2")); // 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 = fixture.Materializer.ThrowAfterMaterializePurposeOnce =
LiveProjectionPurpose.CreateSupersessionRecovery; LiveProjectionPurpose.CreateSupersessionRecovery;
@ -1270,8 +1313,12 @@ public sealed class LiveEntityHydrationControllerTests
Assert.False(record.CreateProjectionSynchronizationPending); Assert.False(record.CreateProjectionSynchronizationPending);
Assert.Same(retained, record.WorldEntity); 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( Assert.Equal(
retryFromLandblock ? 2UL : 3UL, 2UL,
fixture.Materializer.InstalledCreateIntegrationVersion); fixture.Materializer.InstalledCreateIntegrationVersion);
Assert.Equal( Assert.Equal(
LiveProjectionPurpose.CreateSupersessionRecovery, LiveProjectionPurpose.CreateSupersessionRecovery,
@ -1357,10 +1404,23 @@ public sealed class LiveEntityHydrationControllerTests
if (refreshed || spawn.PositionSequence != 1) if (refreshed || spawn.PositionSequence != 1)
return; return;
refreshed = true; refreshed = true;
fixture.Controller.OnCreate(Spawn( // C3c: a fresher same-generation CreateObject no longer advances
Generation: 1, // create authority at registration (its advance lands at the
PositionSequence: 2, // residence drain's WeenieDescription stage). Model that exact
Name: "ready v2")); // 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; fixture.Ready.FailPublishCount = 1;
@ -1486,6 +1546,12 @@ public sealed class LiveEntityHydrationControllerTests
{ {
const uint parentGuid = 0x70000002u; const uint parentGuid = 0x70000002u;
using var fixture = new Fixture(originKnown: true); 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)); fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord record = fixture.Record; LiveEntityRecord record = fixture.Record;
WorldEntity retained = record.WorldEntity!; WorldEntity retained = record.WorldEntity!;
@ -1522,6 +1588,17 @@ public sealed class LiveEntityHydrationControllerTests
record, record,
positionVersion)); positionVersion));
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(record)); 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( WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity(
Guid, Guid,
Cell, Cell,
@ -1540,6 +1617,12 @@ public sealed class LiveEntityHydrationControllerTests
fixture.Controller.OnCreate(CelllessSpawn( fixture.Controller.OnCreate(CelllessSpawn(
PositionSequence: 2, PositionSequence: 2,
parentGuid)); 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( Assert.False(fixture.Controller.RecoverProjection(
@ -1575,6 +1658,25 @@ public sealed class LiveEntityHydrationControllerTests
using var fixture = new Fixture( using var fixture = new Fixture(
originKnown: true, originKnown: true,
playerGuid: retryFromLandblock ? Guid : 0u); 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.Ready.FailPublishCount = 2;
fixture.Network.ApplyAction = events => fixture.Network.ApplyAction = events =>
{ {
@ -1659,8 +1761,20 @@ public sealed class LiveEntityHydrationControllerTests
if (!replaced && replacementStage == stage) if (!replaced && replacementStage == stage)
{ {
replaced = true; replaced = true;
fixture.Runtime.RegisterLiveEntity( // C3c: a fresher same-generation CreateObject no longer
Spawn(Generation: 1, PositionSequence: 2)); // 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; return true;
} }
@ -1768,8 +1882,18 @@ public sealed class LiveEntityHydrationControllerTests
var projection = new RecordingLiveProjectionSink( var projection = new RecordingLiveProjectionSink(
_ => _ =>
{ {
fixture.Runtime.RegisterLiveEntity( // C3c: a fresher same-generation CreateObject's authority
Spawn(Generation: 1, PositionSequence: 2)); // 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; return true;
}); });
var publisher = new LiveEntityReadyPublisher( var publisher = new LiveEntityReadyPublisher(
@ -1792,10 +1916,19 @@ public sealed class LiveEntityHydrationControllerTests
WorldEntity entity = record.WorldEntity!; WorldEntity entity = record.WorldEntity!;
ulong capturedCreateIntegrationVersion = record.CreateIntegrationVersion; ulong capturedCreateIntegrationVersion = record.CreateIntegrationVersion;
// Models ProjectionPoseReady synchronously accepting a fresher // Models ProjectionPoseReady synchronously observing a fresher
// same-generation CreateObject before EntityReady is emitted. // same-generation CreateObject's authority advance before
fixture.Runtime.RegisterLiveEntity( // EntityReady is emitted. C3c: that advance now lands at the
Spawn(Generation: 1, PositionSequence: 2)); // 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; bool published = false;
Assert.False(EquippedChildRenderController.PublishEntityReadyExact( Assert.False(EquippedChildRenderController.PublishEntityReadyExact(
@ -1937,6 +2070,55 @@ public sealed class LiveEntityHydrationControllerTests
uint playerGuid = 0u) uint playerGuid = 0u)
{ {
Resources = resources ?? new RecordingResources(); 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<AcDream.Core.Physics.CellSurface>(),
Array.Empty<AcDream.Core.Physics.PortalPlane>(),
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(); var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock( spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu, 0x0101FFFFu,
@ -1947,6 +2129,11 @@ public sealed class LiveEntityHydrationControllerTests
Resources, Resources,
Teardown, Teardown,
EntityObjects); EntityObjects);
_placements = new AcDream.Runtime.Physics
.RuntimePlacementProjectionSubscription(
EntityObjects.Placements,
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
new FixturePlacementSink(Runtime));
Materializer = new RecordingMaterializer(Runtime, Operations); Materializer = new RecordingMaterializer(Runtime, Operations);
Relationships = new RecordingRelationships(Operations); Relationships = new RecordingRelationships(Operations);
Ready = new RecordingReadyPublisher(Operations); Ready = new RecordingReadyPublisher(Operations);
@ -1988,7 +2175,90 @@ public sealed class LiveEntityHydrationControllerTests
Timestamps, Timestamps,
identity, identity,
deletion, 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 public LiveEntityRecord Record
@ -2165,7 +2435,34 @@ public sealed class LiveEntityHydrationControllerTests
}, },
LiveEntityProjectionKind.World, LiveEntityProjectionKind.World,
initializeProjection: null, 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) if (ThrowAfterMaterializePurposeOnce == purpose)
{ {
ThrowAfterMaterializePurposeOnce = null; ThrowAfterMaterializePurposeOnce = null;

View file

@ -423,6 +423,11 @@ public sealed class LiveEntityLifecycleStressTests
canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true); canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true);
EntityObjects = new RuntimeEntityObjectLifetime(Engine); 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( Runtime = new LiveEntityRuntime(
Spatial, Spatial,
new DelegateLiveEntityResourceLifecycle( new DelegateLiveEntityResourceLifecycle(

View file

@ -639,6 +639,9 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
teardown); teardown);
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) => 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( new(
Guid: guid, Guid: guid,
Position: null, Position: null,
@ -652,7 +655,37 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
ItemType: null, ItemType: null,
MotionState: null, MotionState: null,
MotionTableId: 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( private static AcDream.Core.World.WorldEntity Entity(
uint localId, uint localId,

View file

@ -733,7 +733,10 @@ public sealed class LiveEntityPresentationControllerTests
{ {
var position = new CreateObject.ServerPosition( var position = new CreateObject.ServerPosition(
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f); 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( var physics = new PhysicsSpawnData(
RawState: (uint)state, RawState: (uint)state,
Position: position, Position: position,

View file

@ -247,6 +247,9 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests
MotionState: null, MotionState: null,
MotionTableId: null, MotionTableId: null,
InstanceSequence: instance); InstanceSequence: instance);
// C3c: residence admission requires the flattened parser
// projections to agree with a nested PhysicsDesc block.
spawn = spawn.WithConsistentPhysics();
LiveEntityRecord record = Live.RegisterAndMaterializeProjection( LiveEntityRecord record = Live.RegisterAndMaterializeProjection(
spawn, spawn,
id => new WorldEntity id => new WorldEntity

View file

@ -826,13 +826,22 @@ public sealed class LiveEntityRuntimeTests
{ {
const uint parentGuid = 0x70000020u; const uint parentGuid = 0x70000020u;
const uint childGuid = 0x70000021u; 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)); runtime.RegisterLiveEntity(Spawn(parentGuid, 9, 1, 0x01010001u));
driven.Pump();
runtime.ParentAttachments.Enqueue(new ParentEvent.Parsed( runtime.ParentAttachments.Enqueue(new ParentEvent.Parsed(
parentGuid, childGuid, 1, 2, 9, 5)); parentGuid, childGuid, 1, 2, 9, 5));
ResolveParent(runtime, childGuid); ResolveParent(runtime, childGuid);
runtime.RegisterLiveEntity(Spawn(childGuid, 3, 4, 0x01010001u)); runtime.RegisterLiveEntity(Spawn(childGuid, 3, 4, 0x01010001u));
driven.Pump();
ResolveParent(runtime, childGuid); ResolveParent(runtime, childGuid);
Assert.True(runtime.ParentAttachments.TryGetProjection( Assert.True(runtime.ParentAttachments.TryGetProjection(
@ -1261,6 +1270,143 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(0.0, clock.PendingSeconds, 8); Assert.Equal(0.0, clock.PendingSeconds, 8);
} }
/// <summary>
/// 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.
/// </summary>
[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<RuntimeEntityRecord>(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);
}
/// <summary>
/// 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.
/// </summary>
[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<RuntimeEntityRecord>(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<InvalidOperationException>(() =>
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] [Fact]
public void InitiallyVisibleStaticObject_RebasesWithoutBecomingActive() public void InitiallyVisibleStaticObject_RebasesWithoutBecomingActive()
{ {
@ -1428,10 +1574,27 @@ public sealed class LiveEntityRuntimeTests
{ {
const uint stateBeforeBindGuid = 0x70000037u; const uint stateBeforeBindGuid = 0x70000037u;
const uint bindBeforeStateGuid = 0x70000038u; const uint bindBeforeStateGuid = 0x70000038u;
var runtime = LiveEntityRuntimeFixture.Create(new GpuWorldState(), new RecordingResources()); // C3c: initial residences defer wire applies into their FIFO until a
runtime.RegisterLiveEntity(Spawn(stateBeforeBindGuid, 1, 1, 0x01010001u)); // host pump drives the conductors.
runtime.RegisterLiveEntity(Spawn(bindBeforeStateGuid, 1, 1, 0x01010001u)); LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven =
LiveEntityRuntimeFixture.CreateDriven(
new GpuWorldState(),
new RecordingResources());
LiveEntityRuntime runtime = driven.Runtime;
RuntimeEntityRecord stateBeforeBindCanonical =
Assert.IsType<RuntimeEntityRecord>(runtime.RegisterLiveEntity(
Spawn(stateBeforeBindGuid, 1, 1, 0x01010001u)).Canonical);
RuntimeEntityRecord bindBeforeStateCanonical =
Assert.IsType<RuntimeEntityRecord>(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 firstState = PhysicsStateFlags.Hidden
| PhysicsStateFlags.Gravity | PhysicsStateFlags.Gravity
| PhysicsStateFlags.ReportCollisions; | PhysicsStateFlags.ReportCollisions;
@ -1439,18 +1602,30 @@ public sealed class LiveEntityRuntimeTests
new SetState.Parsed(stateBeforeBindGuid, (uint)firstState, 1, 2), new SetState.Parsed(stateBeforeBindGuid, (uint)firstState, 1, 2),
out _)); out _));
runtime.MaterializeLiveEntity( runtime.MaterializeLiveEntity(
stateBeforeBindGuid, stateBeforeBindCanonical,
0x01010001u, 0x01010001u,
id => Entity(id, stateBeforeBindGuid)); id => Entity(id, stateBeforeBindGuid),
var lateBody = new RemoteMotionRuntime(); LiveEntityProjectionKind.World,
runtime.SetRemoteMotionRuntime(stateBeforeBindGuid, lateBody); initializeProjection: null,
out _,
LiveEntityMaterializationResidence.AwaitRuntimePlacement);
runtime.MaterializeLiveEntity( runtime.MaterializeLiveEntity(
bindBeforeStateGuid, bindBeforeStateCanonical,
0x01010001u, 0x01010001u,
id => Entity(id, bindBeforeStateGuid)); id => Entity(id, bindBeforeStateGuid),
var earlyBody = new RemoteMotionRuntime(); LiveEntityProjectionKind.World,
runtime.SetRemoteMotionRuntime(bindBeforeStateGuid, earlyBody); 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 secondState = PhysicsStateFlags.Static
| PhysicsStateFlags.Ethereal | PhysicsStateFlags.Ethereal
| PhysicsStateFlags.NoDraw; | PhysicsStateFlags.NoDraw;
@ -1460,8 +1635,8 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal((firstState & ~PhysicsStateFlags.ReportCollisions) Assert.Equal((firstState & ~PhysicsStateFlags.ReportCollisions)
| PhysicsStateFlags.IgnoreCollisions, | PhysicsStateFlags.IgnoreCollisions,
lateBody.Body.State); lateBody.State);
Assert.Equal(secondState, earlyBody.Body.State); Assert.Equal(secondState, earlyBody.State);
} }
[Fact] [Fact]
@ -1573,9 +1748,17 @@ public sealed class LiveEntityRuntimeTests
public void PositionAfterPickup_RequiresTeleportHookEvenWithEqualTeleportStamp() public void PositionAfterPickup_RequiresTeleportHookEvenWithEqualTeleportStamp()
{ {
const uint guid = 0x70000043u; 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); WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn); runtime.RegisterLiveEntity(spawn);
driven.Pump();
Assert.True(runtime.TryApplyPickup( Assert.True(runtime.TryApplyPickup(
new PickupEvent.Parsed(guid, 1, 2), new PickupEvent.Parsed(guid, 1, 2),
out _)); out _));

View file

@ -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;
/// <summary>
/// C3c contract integration tests: the flipped graphical host wiring driven
/// end-to-end — registration through <see cref="LiveEntityHydrationController"/>,
/// the REAL <see cref="RuntimePlacementPresentationSink"/> behind the REAL
/// <see cref="AcDream.Runtime.Physics.RuntimePlacementProjectionSubscription"/>,
/// and the production
/// <see cref="AcDream.Runtime.Session.RuntimeFirstEntryDriveController"/> pump.
/// No conductor is ever hand-called.
/// </summary>
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<AcDream.Runtime.Gameplay.PlayerMovementController>(
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);
}
/// <summary>
/// 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.
/// </summary>
[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<AcDream.Runtime.Gameplay.PlayerMovementController>(
fixture.Movement.Controller);
Assert.True(controller.IsRuntimePublished);
Assert.True(fixture.Runtime.TryGetRecord(
Guid,
out LiveEntityRecord record));
PhysicsBody body = Assert.IsType<PhysicsBody>(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);
}
/// <summary>
/// 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.
/// </summary>
[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<AcDream.Runtime.Gameplay.PlayerMovementController>(
fixture.Movement.Controller);
Assert.True(controller.IsRuntimePublished);
Assert.True(fixture.Runtime.TryGetRecord(
Guid,
out LiveEntityRecord record));
PhysicsBody body = Assert.IsType<PhysicsBody>(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<RuntimeEntityRecord>(
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<CellSurface>(),
Array.Empty<PortalPlane>(),
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<WorldEntity>()));
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.
}
}
}
/// <summary>
/// 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.
/// </summary>
private sealed class HostMaterializer(LiveEntityRuntime runtime)
: ILiveEntityProjectionMaterializer
{
internal Action<LiveEntityRecord>? 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<FlatSetupCollision>
.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()
{
}
}
}

View file

@ -757,6 +757,16 @@ public sealed class RuntimePlacementPresentationSinkTests
internal LiveEntityRecord Materialize(WorldSession.EntitySpawn spawn) internal LiveEntityRecord Materialize(WorldSession.EntitySpawn spawn)
{ {
LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(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); Assert.True(record.ResourcesRegistered);
WorldEntity entity = record.WorldEntity!; WorldEntity entity = record.WorldEntity!;
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(

View file

@ -740,16 +740,22 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App", "AcDream.App",
"Input", "Input",
"PlayerModeController.cs")); "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( AssertAppearsInOrder(
playerModeSource, playerModeSource,
"controller.PreparePositionForCommit(", "controller.IsRuntimePublished",
"_camera.EnterChaseMode(legacyCamera, retailCamera);", "_camera.EnterChaseMode(legacyCamera, retailCamera);",
"EntityPhysicsHostComposition.SelectStableHostWithoutRebind(",
"_shadow.SyncPose(", "_shadow.SyncPose(",
"EntityPhysicsHostComposition.InstallOrRebind(", "_hostSlot.Host = playerHost;",
"playerEntity.SetPosition(initial.Position);",
"controller.CommitPreparedPosition();",
"_controllerSlot.Controller = controller;",
"_mode.IsPlayerMode = true;"); "_mode.IsPlayerMode = true;");
Assert.Contains("_shadow.Restore(playerEntity, priorShadow);", playerModeSource, Assert.Contains("_shadow.Restore(playerEntity, priorShadow);", playerModeSource,
StringComparison.Ordinal); StringComparison.Ordinal);

View file

@ -56,6 +56,29 @@ public class ShadowObjectRegistryTests
Assert.Equal(1, reg.TotalRegistered); 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 // GetObjectsInCell
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View file

@ -1,10 +1,15 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Physics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
namespace AcDream.App.Tests.Physics; namespace AcDream.Core.Tests.Physics;
public sealed class RemoteSpawnPlacementSettlerTests /// <summary>
/// C3c-F5: moved from tests/AcDream.App.Tests/Physics/
/// RemoteSpawnPlacementSettlerTests.cs when the #270 settler moved to Core
/// (App -> <see cref="SpawnPlacementSettler"/>) so the local player's
/// Runtime first-entry activation can share it. Test bodies unchanged.
/// </summary>
public sealed class SpawnPlacementSettlerTests
{ {
private const uint Landblock = 0xA9B40000u; private const uint Landblock = 0xA9B40000u;
private const uint Cell = Landblock | 0x0001u; private const uint Cell = Landblock | 0x0001u;
@ -19,7 +24,7 @@ public sealed class RemoteSpawnPlacementSettlerTests
int hitGround = 0; int hitGround = 0;
int leaveGround = 0; int leaveGround = 0;
bool settled = RemoteSpawnPlacementSettler.TrySettle( bool settled = SpawnPlacementSettler.TrySettle(
engine, engine,
body, body,
body.Position, body.Position,
@ -71,7 +76,7 @@ public sealed class RemoteSpawnPlacementSettlerTests
} }
private static bool TrySettle(PhysicsEngine engine, PhysicsBody body) => private static bool TrySettle(PhysicsEngine engine, PhysicsBody body) =>
RemoteSpawnPlacementSettler.TrySettle( SpawnPlacementSettler.TrySettle(
engine, engine,
body, body,
body.Position, body.Position,

View file

@ -238,11 +238,25 @@ public sealed class HeadlessSessionHostTests
new HeadlessDiagnosticWriter(TextWriter.Null), new HeadlessDiagnosticWriter(TextWriter.Null),
operations); operations);
GameRuntime runtime = host.Runtime; 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; const uint player = 0x50000002u;
runtime.PlayerIdentity.ServerGuid = player; runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
0xA9B40000u, 1UL);
AddFlatLandblock(runtime.EntityObjects.Physics.Engine); 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 RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntity(Spawn(player)) .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
.Canonical!; .Canonical!;
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
record, record,
@ -252,7 +266,8 @@ public sealed class HeadlessSessionHostTests
var collision = new FixtureCollisionNeighborhood(); var collision = new FixtureCollisionNeighborhood();
var projection = new HeadlessSessionWorldProjection( var projection = new HeadlessSessionWorldProjection(
runtime, runtime,
collision); collision,
firstEntry);
projection.ProjectSpawn(record, isLocalPlayer: true); projection.ProjectSpawn(record, isLocalPlayer: true);
PlayerMovementController controller = PlayerMovementController controller =
@ -310,11 +325,25 @@ public sealed class HeadlessSessionHostTests
new HeadlessDiagnosticWriter(TextWriter.Null), new HeadlessDiagnosticWriter(TextWriter.Null),
operations); operations);
GameRuntime runtime = host.Runtime; 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; const uint player = 0x50000003u;
runtime.PlayerIdentity.ServerGuid = player; runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
0xA9B40000u, 1UL);
AddFlatLandblock(runtime.EntityObjects.Physics.Engine); 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 RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntity(Spawn(player)) .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
.Canonical!; .Canonical!;
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
record, record,
@ -324,7 +353,8 @@ public sealed class HeadlessSessionHostTests
var collision = new FixtureCollisionNeighborhood(); var collision = new FixtureCollisionNeighborhood();
var projection = new HeadlessSessionWorldProjection( var projection = new HeadlessSessionWorldProjection(
runtime, runtime,
collision); collision,
firstEntry);
projection.ProjectSpawn(record, isLocalPlayer: true); projection.ProjectSpawn(record, isLocalPlayer: true);
PlayerMovementController controller = PlayerMovementController controller =
Assert.IsType<PlayerMovementController>( Assert.IsType<PlayerMovementController>(
@ -376,6 +406,104 @@ public sealed class HeadlessSessionHostTests
Assert.Equal(2, collision.CenterCount); Assert.Equal(2, collision.CenterCount);
} }
/// <summary>
/// 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.
/// </summary>
[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] [Fact]
public void PlacementReceiptValidationDoesNotRegainMovementOrPhysicsAuthority() public void PlacementReceiptValidationDoesNotRegainMovementOrPhysicsAuthority()
{ {
@ -389,11 +517,25 @@ public sealed class HeadlessSessionHostTests
new HeadlessDiagnosticWriter(TextWriter.Null), new HeadlessDiagnosticWriter(TextWriter.Null),
operations); operations);
GameRuntime runtime = host.Runtime; 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; const uint player = 0x50000004u;
runtime.PlayerIdentity.ServerGuid = player; runtime.PlayerIdentity.ServerGuid = player;
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
0xA9B40000u, 1UL);
AddFlatLandblock(runtime.EntityObjects.Physics.Engine); 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 RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntity(Spawn(player)) .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
.Canonical!; .Canonical!;
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
record, record,
@ -402,7 +544,8 @@ public sealed class HeadlessSessionHostTests
replaceGeneration: false)); replaceGeneration: false));
var directProjection = new HeadlessSessionWorldProjection( var directProjection = new HeadlessSessionWorldProjection(
runtime, runtime,
new FixtureCollisionNeighborhood()); new FixtureCollisionNeighborhood(),
firstEntry);
directProjection.ProjectSpawn(record, isLocalPlayer: true); directProjection.ProjectSpawn(record, isLocalPlayer: true);
PlayerMovementController controller = Assert.IsType< PlayerMovementController controller = Assert.IsType<
PlayerMovementController>(runtime.MovementOwner.Controller); PlayerMovementController>(runtime.MovementOwner.Controller);
@ -1010,6 +1153,197 @@ public sealed class HeadlessSessionHostTests
private sealed class FixtureCollisionPublicationException : Exception; 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<PlayerMovementController>(
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<FlatSetupCollision>
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<FlatEnvCellTopology>
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<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
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<FlatEnvCellTopology>
ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public AcDream.Content.PreparedCollisionSourceStats CollisionStats =>
default;
public void Dispose()
{
}
}
private static void AddFlatLandblock(PhysicsEngine engine) private static void AddFlatLandblock(PhysicsEngine engine)
{ {
var heights = new byte[81]; var heights = new byte[81];
@ -1026,10 +1360,12 @@ public sealed class HeadlessSessionHostTests
worldOffsetY: 0f); worldOffsetY: 0f);
} }
private static WorldSession.EntitySpawn Spawn(uint guid) private static WorldSession.EntitySpawn Spawn(
uint guid,
uint cellId = 0xA9B40001u)
{ {
var position = new CreateObject.ServerPosition( var position = new CreateObject.ServerPosition(
0xA9B40001u, cellId,
96f, 96f,
97f, 97f,
50f, 50f,
@ -1217,6 +1553,22 @@ public sealed class HeadlessSessionHostTests
public bool IsReady(uint fullCellId) => public bool IsReady(uint fullCellId) =>
fullCellId == LastCell; 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( private sealed class FixtureEventRoute(

View file

@ -104,18 +104,13 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests
Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, second); Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, second);
Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount);
const ulong generation = 1UL; // C3c-F2: the wake goes through the SAME owner production uses (the
fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( // collision admission ledger). Driving the SetPosition seam directly
Cell & 0xFFFF0000u, generation); // leaves that ledger empty — a state production can never be in, and
fixture.Lifetime.Physics.Engine.AddLandblock( // the reason this very test did not catch the login activation wedge.
Cell & 0xFFFF0000u, CommitProductionCollisionGeneration(
new TerrainSurface(new byte[81], new float[256]), fixture.Lifetime.Physics,
Array.Empty<CellSurface>(), Cell & 0xFFFF0000u);
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration(
Cell & 0xFFFF0000u, generation, ready: true);
Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed,
fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt));
@ -603,18 +598,9 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests
// AwaitingActivation retry test does. // AwaitingActivation retry test does.
Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation,
fixture.Advance(out _)); fixture.Advance(out _));
const ulong generation = 1UL; CommitProductionCollisionGeneration(
fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( fixture.Lifetime.Physics,
Cell & 0xFFFF0000u, generation); Cell & 0xFFFF0000u);
fixture.Lifetime.Physics.Engine.AddLandblock(
Cell & 0xFFFF0000u,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration(
Cell & 0xFFFF0000u, generation, ready: true);
RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance( RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance(
out RuntimeInitialCreateExecutionReceipt receipt); out RuntimeInitialCreateExecutionReceipt receipt);
@ -631,6 +617,63 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests
// Helpers // Helpers
// --------------------------------------------------------------- // ---------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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<CellSurface>(),
Array.Empty<PortalPlane>(),
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) private static (RuntimeEntityRecord Record, RuntimePlacementProjectionToken Token)
BeginPendingOrdinaryPlacement(Fixture fixture, uint guid) BeginPendingOrdinaryPlacement(Fixture fixture, uint guid)
{ {

View file

@ -244,4 +244,261 @@ public sealed class RuntimeLocalPlayerMovementStateTests
Assert.Equal(0L, allocated); 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => controller.ApplyPhysicsState(initial));
Assert.Equal(
RuntimeServerPhysicsStateApplication.DroppedDisplacedController,
controller.ApplyServerPhysicsState(initial));
Assert.Equal(pushed, body.State);
}
} }

View file

@ -198,6 +198,175 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Assert.True(body.InWorld); Assert.True(body.InWorld);
} }
/// <summary>
/// C3c-F5: retail seeds the local player's ground contact from the first
/// gravity frame after <c>enter_world</c> (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.
/// </summary>
[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<PhysicsBody>(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<PlayerMovementController>(
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);
}
/// <summary>
/// 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.
/// </summary>
[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<PhysicsBody>(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<PlayerMovementController>(
fixture.Movement.Controller);
Assert.False(controller.CanSendPositionEvent);
Assert.False(controller.CaptureMovementResult(
mouseLookEvent: false).IsOnGround);
}
/// <summary>
/// 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.
/// </summary>
[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<PlayerMovementController>(
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);
}
/// <summary>
/// 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.
/// </summary>
[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<PlayerMovementController>(
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] [Fact]
public void DeferredActivationEvaluationLeavesExactOwnedGraphDormantAndRetryable() public void DeferredActivationEvaluationLeavesExactOwnedGraphDormantAndRetryable()
{ {
@ -356,10 +525,117 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Assert.Equal(1, fixture.Owner.CaptureOwnership() Assert.Equal(1, fixture.Owner.CaptureOwnership()
.PendingActivationCount); .PendingActivationCount);
const ulong generation = 1UL; // C3c-F2: the wake is driven through the SAME owner production uses
fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( // (RuntimePhysicsState's collision admission), not through the raw
Cell & 0xFFFF0000u, // SetPosition seam. Driving SetPosition directly leaves the admission
generation); // 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( fixture.Lifetime.Physics.Engine.AddLandblock(
Cell & 0xFFFF0000u, Cell & 0xFFFF0000u,
new TerrainSurface(new byte[81], new float[256]), new TerrainSurface(new byte[81], new float[256]),
@ -369,8 +645,39 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
worldOffsetY: 0f); worldOffsetY: 0f);
fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration(
Cell & 0xFFFF0000u, Cell & 0xFFFF0000u,
generation, admission.Generation,
ready: true); 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, Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
fixture.Owner.EvaluateActivation(token, out var ready)); fixture.Owner.EvaluateActivation(token, out var ready));
@ -378,6 +685,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
fixture.Owner.CommitActivation(ready, out var projection)); fixture.Owner.CommitActivation(ready, out var projection));
Assert.True(projection.IsValid); Assert.True(projection.IsValid);
Assert.True(fixture.Record.PhysicsBody!.InWorld); Assert.True(fixture.Record.PhysicsBody!.InWorld);
Assert.True(fixture.Movement.Controller!.IsRuntimePublished);
Assert.Equal(0, fixture.Owner.CaptureOwnership() Assert.Equal(0, fixture.Owner.CaptureOwnership()
.PendingActivationCount); .PendingActivationCount);
} }
@ -562,15 +870,9 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Assert.Equal(0, fixture.Lifetime.Physics.Engine.ShadowObjects Assert.Equal(0, fixture.Lifetime.Physics.Engine.ShadowObjects
.PendingSetPositionDispatchCount); .PendingSetPositionDispatchCount);
const ulong generation = 1UL; // C3c-F2: production wake path (collision admission), see
fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( // CommitProductionCollisionGeneration.
Cell & 0xFFFF0000u, generation); CommitProductionCollisionGeneration(fixture, Cell & 0xFFFF0000u);
fixture.Lifetime.Physics.Engine.AddLandblock(
Cell & 0xFFFF0000u,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration(
Cell & 0xFFFF0000u, generation, ready: true);
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated, Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
fixture.Owner.EvaluateActivation(token, out var ready)); fixture.Owner.EvaluateActivation(token, out var ready));
Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
@ -2192,6 +2494,11 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
private sealed class Fixture : IDisposable private sealed class Fixture : IDisposable
{ {
private bool _lifetimeDisposed; 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( internal Fixture(
bool preparePlacement = true, bool preparePlacement = true,
@ -2199,11 +2506,13 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
RuntimeLocalPlayerShadowDisposition shadowDisposition = RuntimeLocalPlayerShadowDisposition shadowDisposition =
RuntimeLocalPlayerShadowDisposition.ProvenShapeless, RuntimeLocalPlayerShadowDisposition.ProvenShapeless,
float terrainHeight = 0f, float terrainHeight = 0f,
float moverSphereOriginZ = 0f,
Vector3? initialVelocity = null, Vector3? initialVelocity = null,
Vector3? initialOmega = null, Vector3? initialOmega = null,
float? initialFriction = null, float? initialFriction = null,
float? initialElasticity = null) float? initialElasticity = null)
{ {
_moverSphereOriginZ = moverSphereOriginZ;
if (residentWorld) if (residentWorld)
{ {
var engine = new PhysicsEngine var engine = new PhysicsEngine
@ -2269,7 +2578,9 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Assert.True(Placement.IsValid); Assert.True(Placement.IsValid);
var setup = new FlatSetupCollision( var setup = new FlatSetupCollision(
ImmutableArray<FlatCollisionCylinder>.Empty, ImmutableArray<FlatCollisionCylinder>.Empty,
[new FlatCollisionSphere(Vector3.Zero, 0.48f)], [new FlatCollisionSphere(
new Vector3(0f, 0f, _moverSphereOriginZ),
0.48f)],
height: 0f, height: 0f,
radius: 0f, radius: 0f,
stepUpHeight: 0.4f, stepUpHeight: 0.4f,
@ -2537,6 +2848,31 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
"Collision generation did not complete its Runtime mutation transaction."); "Collision generation did not complete its Runtime mutation transaction.");
} }
/// <summary>
/// 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.
/// </summary>
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( private static RuntimeLandblockCollisionAssets CollisionAssets(
uint landblockId) => new( uint landblockId) => new(
landblockId, landblockId,

View file

@ -0,0 +1,356 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Tests.Physics;
/// <summary>
/// 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
/// <see cref="ArgumentOutOfRangeException"/> from
/// <c>RuntimeSetPositionState.BeginCollisionPrefixQuiescence</c> (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.
/// </summary>
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<string> cornerLog = RunHeldPlacementQuiescenceCycle(
corner,
CornerLandblock,
sourceCell: CornerCell,
targetCell: CornerCell2,
guid: 0x700031F2u);
List<string> 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<ArgumentOutOfRangeException>(
() => fixture.Begin(0u, 2UL, includeOutdoorCells: true));
Assert.Throws<ArgumentOutOfRangeException>(
() => 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));
}
/// <summary>
/// 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.
/// </summary>
private static List<string> RunHeldPlacementQuiescenceCycle(
Fixture fixture,
uint landblockId,
uint sourceCell,
uint targetCell,
uint guid)
{
var log = new List<string>();
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;
}
}

View file

@ -17,18 +17,33 @@ public sealed class RuntimeLiveEntitySessionControllerTests
[Fact] [Fact]
public void DirectSinkOwnsCanonicalCreateUpdateDeleteWithoutProjection() 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( using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000), new IPEndPoint(IPAddress.Loopback, 9000),
new FixtureTransport()); 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( var controller = new RuntimeLiveEntitySessionController(
runtime, runtime,
session); session,
worldProjection: new FixtureWorldProjection());
LiveEntitySessionSink sink = controller.CreateSink(); LiveEntitySessionSink sink = controller.CreateSink();
WorldSession.EntitySpawn spawn = WorldSession.EntitySpawn spawn =
Spawn(0x70000001u, incarnation: 1); Spawn(0x70000001u, incarnation: 1);
sink.Spawned(spawn); sink.Spawned(spawn);
drive.DriveAll();
Assert.Equal(0, drive.PendingCount);
DrainPlacementFifo(runtime);
sink.PositionUpdated(new WorldSession.EntityPositionUpdate( sink.PositionUpdated(new WorldSession.EntityPositionUpdate(
spawn.Guid, spawn.Guid,
spawn.Position!.Value with spawn.Position!.Value with
@ -62,10 +77,79 @@ public sealed class RuntimeLiveEntitySessionControllerTests
runtime.EntityObjects.Entities.PendingTeardownCount); runtime.EntityObjects.Entities.PendingTeardownCount);
} }
/// <summary>
/// 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.
/// </summary>
[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] [Fact]
public void DirectSinkCompletesExactPortalAndSendsLoginComplete() public void DirectSinkCompletesExactPortalAndSendsLoginComplete()
{ {
using GameRuntime runtime = CreateRuntime(); using StartedRuntime started = StartRuntime();
GameRuntime runtime = started.Runtime;
const uint playerGuid = 0x50000001u; const uint playerGuid = 0x50000001u;
runtime.PlayerIdentity.ServerGuid = playerGuid; runtime.PlayerIdentity.ServerGuid = playerGuid;
using var session = new WorldSession( using var session = new WorldSession(
@ -106,7 +190,8 @@ public sealed class RuntimeLiveEntitySessionControllerTests
[Fact] [Fact]
public void DirectSinkProjectsAcceptedLocalWorldStateThroughOneHostSeam() public void DirectSinkProjectsAcceptedLocalWorldStateThroughOneHostSeam()
{ {
using GameRuntime runtime = CreateRuntime(); using StartedRuntime started = StartRuntime();
GameRuntime runtime = started.Runtime;
const uint playerGuid = 0x50000002u; const uint playerGuid = 0x50000002u;
runtime.PlayerIdentity.ServerGuid = playerGuid; runtime.PlayerIdentity.ServerGuid = playerGuid;
using var session = new WorldSession( using var session = new WorldSession(
@ -154,16 +239,268 @@ public sealed class RuntimeLiveEntitySessionControllerTests
Assert.True(runtime.Portal.Snapshot.Completed); Assert.True(runtime.Portal.Snapshot.Completed);
} }
private static GameRuntime CreateRuntime() /// <summary>
/// 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.
/// </summary>
[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<InvalidOperationException>(
() => 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);
}
/// <summary>
/// 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.
/// </summary>
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 operations = new FixtureGameplayOperations();
var sessionOperations = new FixtureSessionOperations();
var runtime = new GameRuntime(new GameRuntimeDependencies( var runtime = new GameRuntime(new GameRuntimeDependencies(
operations, operations,
operations, operations,
operations, operations,
operations)); operations,
SessionOperations: sessionOperations));
operations.Bind(runtime); 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<CellSurface>(),
Array.Empty<PortalPlane>(),
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));
/// <summary>
/// Drains/acknowledges every still-pending placement receipt (the
/// ExecutorCompleted correlation is reaped by its acknowledgement) the
/// way a host subscription would.
/// </summary>
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( private static WorldSession.EntitySpawn Spawn(
@ -194,7 +531,7 @@ public sealed class RuntimeLiveEntitySessionControllerTests
Position: position, Position: position,
Movement: null, Movement: null,
AnimationFrame: null, AnimationFrame: null,
SetupTableId: 0x02000001u, SetupTableId: null,
MotionTableId: null, MotionTableId: null,
SoundTableId: null, SoundTableId: null,
PhysicsScriptTableId: null, PhysicsScriptTableId: null,
@ -213,7 +550,7 @@ public sealed class RuntimeLiveEntitySessionControllerTests
return new WorldSession.EntitySpawn( return new WorldSession.EntitySpawn(
guid, guid,
position, position,
0x02000001u, null,
[], [],
[], [],
[], [],