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