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:
parent
78f1eb1896
commit
529e0e9d88
68 changed files with 5977 additions and 831 deletions
|
|
@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
86
src/AcDream.App/Net/LiveMovementStatsApplier.cs
Normal file
86
src/AcDream.App/Net/LiveMovementStatsApplier.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 && 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) =>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
354
src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs
Normal file
354
src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
59
tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs
Normal file
59
tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
53
tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs
Normal file
53
tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs
Normal 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
204
tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs
Normal file
204
tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 _));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 _));
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue