diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 27fe49e2..bacb93f6 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -99,8 +99,8 @@ accepted-divergence entries (#96, #49, #50).
| AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (statics that never receive a UM/UP and so never get a `RemoteMotion`) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02; R5-V5 gave every `RemoteMotion`/player ONE literal `MovementManager` facade, so the residue is only the no-RemoteMotion class) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Motion for entities without a `RemoteMotion` (never UM/UP-touched) completes via the manager queue alone; nothing consumes their MotionDone until every sequencer-owning entity gets a host/`RemoteMotion` (doors DO have one since the R4-V5 door fix — first UM creates it) | An entity behavior depending on pending_motions bookkeeping in that no-RemoteMotion class (none known) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire when every sequencer-owning entity constructs a `RemoteMotion`/host (post-M1.5 entity-class unification; R5-V5 closed the facade half) |
| AD-37 | Camera rotation state is a forward VECTOR (nlerp + normalize; roll always 0, up = world Z); retail's sought carries a full Frame and slerps quaternions (`Frame::interpolate_rotation` shortest-path slerp with 2e-4 nlerp fallback). The dead-band compares forward-vector distance against the same 2e-4 epsilon retail applies per quaternion component | `src/AcDream.App/Rendering/RetailChaseCamera.cs` (`_dampedForward`, `ApplyConvergenceSnap`) | The chase camera never rolls (heading frames are Z-up by construction), so a forward vector spans the reachable rotation space; identified (not introduced) during the #180 UpdateCamera tail reading | If a future camera mode needs roll (death cam, cutscene) the vector state can't represent it; large-angle per-frame turns nlerp (chord) vs slerp (arc) — imperceptible at 0.45-stiffness step sizes | `Frame::interpolate_rotation` 0x00535390, `Frame::close_rotation` 0x00455d70; pseudocode doc 2026-07-06-camera-sought-position |
| 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.App/Input/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 ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.App/Input/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-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 ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; 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-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` |
@@ -135,12 +135,12 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE |
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
| AP-23 | Invented per-type pickup-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating plus the speculative local TurnToObject/MoveToObject install through the player's MoveToManager. **R5-V3 narrowed it:** the install threads the target's real Setup radius/height (`GetSetupCylinder`, same as wire mt-6) and the player's real radius; only the radius buckets remain invented. **Use retired from this seam 2026-07-25** and now sends immediately. | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | The retained pickup presentation reserves a destination slot before the authoritative transfer; its close branch still needs an arrival boundary | A target whose real UseRadius differs from the bucket misjudges the pickup gate — pickup waits forever or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
-| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
+| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 |
-| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1110` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
+| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1110` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`ShellDrawLiftZ`); `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`DrawPortalDepthWrite`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
@@ -180,7 +180,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
---
| AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification |
-| AP-77 | **NARROWED 2026-07-19 — animation-less/headless movement fallback only.** When `MotionInterpreter.DefaultSink` or the local PartArray callback is absent, acdream writes grounded command-derived body velocity and applies the DAT-pinned Humanoid `TurnRight` rate (1.5 radians/second) directly to the body Frame. Production animated players/remotes bind `MotionTableDispatchSink` plus CSequence and instead consume the complete DAT-authored root Frame; that path preserves airborne orientation while suppressing only origin exactly like retail | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`); `src/AcDream.App/Input/PlayerMovementController.cs` (no-PartArray object-quantum fallback) | Keeps isolated/headless physics tests and a deliberately animation-less entity controllable without fabricating a PartArray | A future production entity missing its animation binding uses Humanoid-only yaw/velocity, can foot-slide, and can rotate through an airborne quantum differently from a real creature's DAT Frame | `CMotionInterp::apply_interpreted_movement` 0x00528600; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire when animation-less production objects have an explicit motion owner |
+| AP-77 | **NARROWED 2026-07-19 — animation-less/headless movement fallback only.** When `MotionInterpreter.DefaultSink` or the local PartArray callback is absent, acdream writes grounded command-derived body velocity and applies the DAT-pinned Humanoid `TurnRight` rate (1.5 radians/second) directly to the body Frame. Production animated players/remotes bind `MotionTableDispatchSink` plus CSequence and instead consume the complete DAT-authored root Frame; that path preserves airborne orientation while suppressing only origin exactly like retail | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (no-PartArray object-quantum fallback) | Keeps isolated/headless physics tests and a deliberately animation-less entity controllable without fabricating a PartArray | A future production entity missing its animation binding uses Humanoid-only yaw/velocity, can foot-slide, and can rotate through an airborne quantum differently from a real creature's DAT Frame | `CMotionInterp::apply_interpreted_movement` 0x00528600; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire when animation-less production objects have an explicit motion owner |
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
| AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (VectorUpdate jump handler); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (landing blocks) | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. |
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
@@ -239,8 +239,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-18 | `LandCell.BuildingCellId` (CSortCell building bridge) declared but never populated — always null in Stage 1 | `src/AcDream.Core/World/Cells/LandCell.cs:19` | Cell graph shipped in stages; population is explicitly membership Stage 2 (the outdoor→indoor entry path the physics digest flags as unvalidated) | Cell-graph paths that should discover a building's EnvCells from the outdoor cell silently find nothing — the doorway-entry bug class | CSortCell (acclient.h:31880) |
| TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) |
| ~~TS-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16** — `CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons ∪ portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | — | — | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` |
-| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
-| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` — for BOTH the LOCAL player mover and, as of **#184 Slice 2b**, every remote-PLAYER dead-reckoning mover | `src/AcDream.App/Input/PlayerMovementController.cs:1177`; `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick` sweep, `IsPlayerGuid` branch) | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults. Slice 2b gave the remote-player mover `IsPlayer` (was bare `EdgeSlide`) so remote-vs-remote non-PK players WALK THROUGH exactly like the local player and like retail (they still collide with monsters + terrain + walls); without it Slice 2b would have de-overlapped players (MORE solid than retail) | On a PK/PKLite character the client lets players walk through where retail collides — now for the local player AND remote-vs-remote — the moment PvP statuses enter play (M2+) | PWD._bitfield acclient.h:6431-6463; pc:406898-406918; FindObjCollisions PvP block pc:276812 (mover IsPlayer via OBJECTINFO::init 0x0050cf30 `state\|=0x100`) |
+| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
+| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` — for BOTH the LOCAL player mover and, as of **#184 Slice 2b**, every remote-PLAYER dead-reckoning mover | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1177`; `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick` sweep, `IsPlayerGuid` branch) | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults. Slice 2b gave the remote-player mover `IsPlayer` (was bare `EdgeSlide`) so remote-vs-remote non-PK players WALK THROUGH exactly like the local player and like retail (they still collide with monsters + terrain + walls); without it Slice 2b would have de-overlapped players (MORE solid than retail) | On a PK/PKLite character the client lets players walk through where retail collides — now for the local player AND remote-vs-remote — the moment PvP statuses enter play (M2+) | PWD._bitfield acclient.h:6431-6463; pc:406898-406918; FindObjCollisions PvP block pc:276812 (mover IsPlayer via OBJECTINFO::init 0x0050cf30 `state\|=0x100`) |
| TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
@@ -249,14 +249,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
| TS-31 | **NARROWED 2026-07-13** — `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
-| TS-33 | **NARROWED 2026-07-15** — full AP tracker semantics are ported: MTS stamps time only; AP stamps complete cell-local Position + contact plane + time; `ShouldSendPositionEvent` compares cell/contact inside the interval and the complete Frame including orientation afterward. Residual: acdream's single update path snapshots the AP predicate, emits a same-update MTS first when input changed, then AP. Retail proves `UseTime` performs Should→AP, but MTS originates in separate input callbacks; their relative same-tick callback/wire order is not yet traced | `src/AcDream.App/Rendering/GameWindow.cs` (outbound MTS/AP blocks); `src/AcDream.App/Input/PlayerMovementController.cs` (ported tracker) | Preserve the pre-existing acdream wire order until a focused retail packet/breakpoint trace establishes input callback versus `UseTime`; do not infer it from `UseTime` alone | In the rare update where both packets are due, ACE may observe their position timestamps/action sequences in the opposite order from retail, shifting only that correction tick; stationary target-facing is live-gated because full-frame orientation now publishes | `CommandInterpreter::UseTime` 0x006B3BF0; `SendMovementEvent` 0x006B4680; `SendPositionEvent` 0x006B4770; `ShouldSendPositionEvent` 0x006B45E0; `Frame::is_equal` 0x00424C30 |
+| TS-33 | **NARROWED 2026-07-15** — full AP tracker semantics are ported: MTS stamps time only; AP stamps complete cell-local Position + contact plane + time; `ShouldSendPositionEvent` compares cell/contact inside the interval and the complete Frame including orientation afterward. Residual: acdream's single update path snapshots the AP predicate, emits a same-update MTS first when input changed, then AP. Retail proves `UseTime` performs Should→AP, but MTS originates in separate input callbacks; their relative same-tick callback/wire order is not yet traced | `src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs` (pre/post network slots); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (ported tracker) | Preserve the pre-existing acdream wire order until a focused retail packet/breakpoint trace establishes input callback versus `UseTime`; do not infer it from `UseTime` alone | In the rare update where both packets are due, ACE may observe their position timestamps/action sequences in the opposite order from retail, shifting only that correction tick; stationary target-facing is live-gated because full-frame orientation now publishes | `CommandInterpreter::UseTime` 0x006B3BF0; `SendMovementEvent` 0x006B4680; `SendPositionEvent` 0x006B4770; `ShouldSendPositionEvent` 0x006B45E0; `Frame::is_equal` 0x00424C30 |
| TS-40 | Retail's `physics_obj->cell` ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` (local player placement) and `RemoteMotion` construction (remotes exist only for world entities); consumed by `CMotionInterp`'s detached-object link-strip guards (`if (cell == 0) RemoveLinkAnimations`, raw @305627). Replaces the UNREGISTERED `CellPosition.ObjCellId == 0` proxy, which only the local player ever seeded (#145 `SnapToCell`), so every REMOTE body read "detached" and every dispatched transition link (door swings, remote walk↔run links) was stripped the same tick it was appended — the 2026-07-03 door-snap bug | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | acdream has no per-body CObjCell pointer; a boolean placement flag carries exactly the guard's retail meaning until cell-pointer plumbing exists | A body used without either placement path (a future entity class constructing bodies directly) reads detached and loses transition links until its creation site sets the flag | `CMotionInterp::DoInterpretedMotion` 0x00528360 tail @305627; `CPhysicsObj::RemoveLinkAnimations` |
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) |
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 |
| TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame |
-| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`GetSetupCylinder`); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
+| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`GetSetupCylinder`); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-91` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
diff --git a/docs/research/2026-07-26-slice-j5-4-local-movement-ownership.md b/docs/research/2026-07-26-slice-j5-4-local-movement-ownership.md
new file mode 100644
index 00000000..da53b2e4
--- /dev/null
+++ b/docs/research/2026-07-26-slice-j5-4-local-movement-ownership.md
@@ -0,0 +1,146 @@
+# Slice J5.4 — canonical local movement ownership
+
+**Date:** 2026-07-26
+**Parent:** `docs/plans/2026-07-26-modern-runtime-slice-j5.md`
+**Scope:** ownership relocation only; no movement, collision, animation, or
+wire behavior changes
+
+## Retail oracle
+
+The named September 2013 client remains the oracle:
+
+| Mechanism | Named symbol | Address / named pseudo-C |
+|---|---|---|
+| take local control | `CommandInterpreter::TakeControlFromServer` | `0x006B32D0`, lines 699046–699145 |
+| apply current raw movement | `CommandInterpreter::ApplyCurrentMovement` | `0x006B3430`, lines 699146–699530 |
+| stop decision | `CommandInterpreter::MaybeStopCompletely` | `0x006B3B90`, lines 699531–699563 |
+| post-inbound position slot | `CommandInterpreter::UseTime` | `0x006B3BF0`, lines 699564–699645 |
+| input command boundary | `CommandInterpreter::MovePlayer` | `0x006B3F40`, lines 699803–700232 |
+| AP predicate | `CommandInterpreter::ShouldSendPositionEvent` | `0x006B45E0`, lines 700233–700273 |
+| MTS serialization | `CommandInterpreter::SendMovementEvent` | `0x006B4680`, lines 700274–700315 |
+| AP serialization | `CommandInterpreter::SendPositionEvent` | `0x006B4770`, lines 700316 onward |
+| object/root-motion step | `CPhysicsObj::UpdatePositionInternal` | `0x00512C30`, lines 280817 onward |
+| object update envelope | `CPhysicsObj::update_object` | `0x00515D10`, lines 283950 onward |
+
+The detailed wire unpack/pack interpretation and prior ACE/Holtburger
+cross-checks are retained in:
+
+- `docs/research/2026-05-04-l3-port/02-um-handling.md`;
+- `docs/research/2026-05-04-l3-port/14-local-player-audit.md`;
+- `docs/research/2026-05-01-retail-motion-trace/findings.md`;
+- `docs/research/2026-07-26-slice-j5-owner-and-retail-order-inventory.md`.
+
+The reference source trees are not present in this worktree, so J5.4 reuses
+those already-recorded cross-checks rather than inventing a new interpretation.
+No behavioral algorithm is changed by this slice.
+
+## Consolidated retail pseudocode
+
+### Input / object phase
+
+```text
+MovePlayer(command, speed, hold, ...)
+ if server currently controls movement
+ TakeControlFromServer()
+
+ mutate the player's one RawMotionState / MotionInterpreter
+ apply the current movement to the same CPhysicsObj
+ if the movement boundary requires a report
+ SendMovementEvent()
+
+SendMovementEvent()
+ require player + SmartBox + raw motion state + autonomous movement
+ snapshot complete raw motion state
+ snapshot the player's canonical cell-local Position and contact
+ snapshot instance/server-control/teleport/force-position sequences
+ serialize and send MoveToState
+ stamp only last_sent_position_time after successful send
+```
+
+Jump uses the same pre-inbound input/object phase. The charged extent and
+body-local launch velocity are read from the canonical local controller and
+serialized exactly once on release.
+
+### Object simulation
+
+```text
+UpdatePositionInternal(dt, rootFrame)
+ apply the animation-authored root frame to the canonical physics body
+ integrate/transition through the existing retail physics pipeline
+ commit complete Position, velocity, contact, and cell membership
+
+update_object()
+ advance the retail object clock in fixed quanta
+ update movement and physics in retail order
+ leave one committed canonical body for every later borrower
+```
+
+App animation may supply the authored root frame and consume the committed
+pose. It does not own a second body, movement state, or object clock.
+
+### Post-inbound autonomous position phase
+
+```text
+UseTime()
+ after inbound messages may have corrected the canonical body:
+ if ShouldSendPositionEvent()
+ SendPositionEvent()
+
+ShouldSendPositionEvent()
+ compare current cell/contact against the last AP baseline
+ after the retail interval, compare the complete current Frame
+
+SendPositionEvent()
+ snapshot canonical cell-local Position, orientation, contact, timestamps
+ serialize and send AutonomousPosition
+ after successful send:
+ stamp last_sent_position_time
+ copy complete Position and contact plane into the AP baseline
+```
+
+This preserves the accepted acdream frame order:
+
+1. graphical or no-window input becomes a typed `MovementInput`;
+2. Runtime mutates the canonical controller/body;
+3. Runtime emits MTS/jump before inbound dispatch;
+4. inbound corrections mutate that same body;
+5. Runtime evaluates/emits AP after inbound dispatch;
+6. App projects the final committed body to animation, camera, and UI.
+
+## Ownership result
+
+- `PlayerMovementController`, its movement data types, and
+ `LocalPlayerOutboundController` now live in `AcDream.Runtime.Gameplay`.
+- `RuntimeLocalPlayerMovementState` is the sole controller, construction
+ motion seam, autorun latch, typed view, and terminal ownership ledger.
+- `DispatcherMovementInputSource` samples physical held state only and maps
+ configured edges onto the Runtime owner.
+- direct Runtime movement commands and graphical input mutate that same
+ autorun state.
+- `PlayerMovementConstructionOptions` replaces process environment reads and
+ is populated from `RuntimeMovementSkillState`.
+- the App diagnostic implementation crosses only
+ `IMovementTruthDiagnosticSink`.
+- shutdown retires graphical/session borrowers before disposing the Runtime
+ movement owner.
+
+## Preserved boundaries
+
+- Silk input, mouse filtering, camera, CSequence/PartArray presentation,
+ retained jump bar, sounds, and effects remain in App.
+- `PhysicsEngine` remains App-owned until J5.5.
+- remote movement and projectiles remain App-owned until J5.5/J5.6.
+- TS-33's already-recorded possible same-update MTS/AP ordering difference is
+ preserved; this ownership move neither widens nor hides it.
+
+## Automated evidence required before closeout
+
+- Runtime state/view identity, two-instance isolation, construction lease,
+ terminal convergence, typed movement skills, and zero-allocation snapshots;
+- graphical/direct autorun parity and one-production-owner source guards;
+- existing bit-exact RawMotionState, MTS, jump, AP, quaternion, contact, and
+ strict cadence tests;
+- existing run/walk/back/strafe/turn/mouse-look/jump/land, short-tap,
+ portal/reset, target-facing, and repeat-abort tests;
+- Release build, complete solution tests, exact-binary connected lifecycle,
+ and canonical movement route.
diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs
index 58ed4c30..f948c4ce 100644
--- a/src/AcDream.App/Composition/SessionPlayerComposition.cs
+++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs
@@ -883,8 +883,7 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerController,
worldReveal,
d.UpdateClock,
- live.SelectionInteractions,
- gameplayInput);
+ live.SelectionInteractions);
bindings.Adopt("current game runtime adapter", gameRuntime);
bindings.Adopt(
"retained-UI game runtime commands",
diff --git a/src/AcDream.App/GlobalUsings.cs b/src/AcDream.App/GlobalUsings.cs
new file mode 100644
index 00000000..d2d17bec
--- /dev/null
+++ b/src/AcDream.App/GlobalUsings.cs
@@ -0,0 +1,7 @@
+global using AcDream.Runtime.Gameplay;
+global using LocalPlayerControllerSlot =
+ AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState;
+global using ILocalPlayerControllerSource =
+ AcDream.Runtime.Gameplay.IRuntimeLocalPlayerControllerSource;
+global using ILocalPlayerMotionSource =
+ AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource;
diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs
index 2d536f02..2bc27f4f 100644
--- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs
+++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs
@@ -8,18 +8,25 @@ internal interface IMovementInputSource
}
///
-/// Owns held movement sampling and the retail autorun latch. The input
-/// dispatcher remains the only keyboard/mouse-button state source.
+/// Samples held graphical input and maps press edges onto the Runtime-owned
+/// retail autorun latch. The dispatcher remains the only physical
+/// keyboard/mouse-button state source.
///
internal sealed class DispatcherMovementInputSource : IMovementInputSource
{
+ private readonly RuntimeLocalPlayerMovementState _movement;
private readonly IInputCaptureSource? _capture;
private InputDispatcher? _dispatcher;
- public DispatcherMovementInputSource(IInputCaptureSource? capture = null) =>
+ public DispatcherMovementInputSource(
+ RuntimeLocalPlayerMovementState movement,
+ IInputCaptureSource? capture = null)
+ {
+ _movement = movement ?? throw new ArgumentNullException(nameof(movement));
_capture = capture;
+ }
- public bool AutoRunActive { get; private set; }
+ public bool AutoRunActive => _movement.AutoRunActive;
public bool IsAvailable => _dispatcher is not null;
public void Bind(InputDispatcher dispatcher)
@@ -70,10 +77,8 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
public bool HandlePressedAction(InputAction action)
{
if (action == InputAction.MovementRunLock)
- {
- AutoRunActive = !AutoRunActive;
- return true;
- }
+ return _movement.Execute(
+ AcDream.Runtime.RuntimeMovementCommand.ToggleRunLock);
if (AutoRunActive && action is (
InputAction.MovementBackup
@@ -81,11 +86,11 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
or InputAction.MovementStrafeLeft
or InputAction.MovementStrafeRight))
{
- AutoRunActive = false;
+ _movement.CancelAutoRun();
}
return false;
}
- public void ResetSession() => AutoRunActive = false;
+ public void ResetSession() => _movement.ResetInputIntent();
}
diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
index 59adbf2b..39613afa 100644
--- a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
+++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
@@ -16,89 +16,6 @@ internal sealed class LocalPlayerIdentityState : ILocalPlayerIdentitySource
public uint ServerGuid { get; set; }
}
-internal interface ILocalPlayerControllerSource
-{
- PlayerMovementController? Controller { get; }
-}
-
-internal interface ILocalPlayerMotionSource
-{
- MotionInterpreter? Motion { get; }
-}
-
-///
-/// The one mutable local movement-controller slot. Player-mode lifecycle owns
-/// assignment; update and presentation owners receive the read-only seam.
-///
-internal sealed class LocalPlayerControllerSlot
- : ILocalPlayerControllerSource,
- ILocalPlayerMotionSource
-{
- private PlayerMovementController? _preparingMotionOwner;
-
- public PlayerMovementController? Controller { get; set; }
-
- ///
- /// The motion owner visible to the local PartArray completion relay.
- /// During player-mode construction this is the fully animation-bound
- /// candidate controller; all other consumers continue to see only the
- /// committed .
- ///
- MotionInterpreter? ILocalPlayerMotionSource.Motion =>
- _preparingMotionOwner?.Motion ?? Controller?.Motion;
-
- ///
- /// Opens the narrow construction-time ownership seam required by retail's
- /// CPhysicsObj/PartArray lifetime. Initial placement synchronously queues
- /// and completes StopCompletely, so its MotionDone relay must reach the
- /// candidate interpreter before the complete controller is published.
- ///
- public IDisposable BeginMotionPreparation(
- PlayerMovementController controller,
- Action? drainPriorAnimationQueue = null)
- {
- ArgumentNullException.ThrowIfNull(controller);
- if (_preparingMotionOwner is not null)
- {
- throw new InvalidOperationException(
- "A local player motion owner is already being prepared.");
- }
-
- // The live PartArray exists before the local player controller. Drain
- // any animation completions produced under that prior ownership while
- // they still resolve to the prior (or null) interpreter. Publishing
- // the fresh interpreter first would let those old callbacks pop its
- // new pending_motions queue and leave an unmatched Ready sentinel.
- drainPriorAnimationQueue?.Invoke();
- _preparingMotionOwner = controller;
- return new MotionPreparation(this, controller);
- }
-
- private void EndMotionPreparation(PlayerMovementController controller)
- {
- if (!ReferenceEquals(_preparingMotionOwner, controller))
- {
- throw new InvalidOperationException(
- "The local player motion preparation owner changed unexpectedly.");
- }
-
- _preparingMotionOwner = null;
- }
-
- private sealed class MotionPreparation(
- LocalPlayerControllerSlot owner,
- PlayerMovementController controller) : IDisposable
- {
- private LocalPlayerControllerSlot? _owner = owner;
-
- public void Dispose()
- {
- LocalPlayerControllerSlot? current = Interlocked.Exchange(ref _owner, null);
- current?.EndMotionPreparation(controller);
- }
- }
-}
-
internal interface ILocalPlayerPhysicsHostSource
{
EntityPhysicsHost? Host { get; }
diff --git a/src/AcDream.App/Input/MovementTruthDiagnosticController.cs b/src/AcDream.App/Input/MovementTruthDiagnosticController.cs
index 1ed2a296..69aa8b75 100644
--- a/src/AcDream.App/Input/MovementTruthDiagnosticController.cs
+++ b/src/AcDream.App/Input/MovementTruthDiagnosticController.cs
@@ -3,23 +3,6 @@ using AcDream.Core.Net;
namespace AcDream.App.Input;
-internal interface IMovementTruthDiagnosticSink
-{
- void OnOutbound(
- string kind,
- uint sequence,
- MovementResult result,
- Vector3 wirePosition,
- uint wireCellId,
- byte contactByte);
-
- void OnServerEcho(
- WorldSession.EntityPositionUpdate update,
- Vector3 serverWorldPosition);
-
- void ResetSession();
-}
-
internal sealed class MovementTruthDiagnosticController
: IMovementTruthDiagnosticSink
{
@@ -129,33 +112,3 @@ internal sealed class MovementTruthDiagnosticController
? FormattableString.Invariant($"0x{command.Value:X8}")
: "-";
}
-
-internal sealed class DelegateMovementTruthDiagnosticSink
- : IMovementTruthDiagnosticSink
-{
- private readonly Action
- _outbound;
-
- public DelegateMovementTruthDiagnosticSink(
- Action outbound) =>
- _outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
-
- public void OnOutbound(
- string kind,
- uint sequence,
- MovementResult result,
- Vector3 wirePosition,
- uint wireCellId,
- byte contactByte) =>
- _outbound(kind, sequence, result, wirePosition, wireCellId, contactByte);
-
- public void OnServerEcho(
- WorldSession.EntityPositionUpdate update,
- Vector3 serverWorldPosition)
- {
- }
-
- public void ResetSession()
- {
- }
-}
diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs
index 654dc700..b21073d8 100644
--- a/src/AcDream.App/Input/PlayerModeController.cs
+++ b/src/AcDream.App/Input/PlayerModeController.cs
@@ -256,7 +256,8 @@ internal sealed class PlayerModeController :
{
var controller = new PlayerMovementController(
_physics,
- playerRecord.ObjectClock);
+ playerRecord.ObjectClock,
+ PlayerMovementConstructionOptions.From(_skills.Snapshot));
controller.ApplyPhysicsState(playerRecord.FinalPhysicsState);
// Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 126bf3f9..21cc63aa 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -162,7 +162,7 @@ public sealed class GameWindow :
private LiveEntityAnimationPresenter _animationPresenter = null!;
private readonly AcDream.App.Input.MovementTruthDiagnosticController
_movementTruthDiagnostics;
- private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
+ private readonly LocalPlayerOutboundController _localPlayerOutbound;
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map;
@@ -431,8 +431,8 @@ public sealed class GameWindow :
private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo;
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
// Phase B.2: player movement mode.
- private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new();
- private AcDream.App.Input.PlayerMovementController? _playerController
+ private readonly RuntimeLocalPlayerMovementState _playerControllerSlot = new();
+ private PlayerMovementController? _playerController
=> _playerControllerSlot.Controller;
private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new();
private AcDream.App.Rendering.ChaseCamera? _chaseCamera
@@ -586,6 +586,7 @@ public sealed class GameWindow :
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
_retainedInputCapture);
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
+ _playerControllerSlot,
_inputCapture);
_framebufferResize = new FramebufferResizeController(_viewportAspect);
_datDir = options.DatDir;
@@ -620,7 +621,7 @@ public sealed class GameWindow :
options.DumpMoveTruth,
_playerControllerSlot,
_localPlayerIdentity);
- _localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController(
+ _localPlayerOutbound = new LocalPlayerOutboundController(
_movementTruthDiagnostics);
}
@@ -1587,6 +1588,7 @@ public sealed class GameWindow :
_runtimeCharacter,
_runtimeCommunication,
_runtimeActions,
+ _playerControllerSlot,
_renderSceneShadow,
_livePresentationBindings,
_entityEffectAdvance,
diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs
index 1f83bfdb..3a9f53c6 100644
--- a/src/AcDream.App/Rendering/GameWindowLifetime.cs
+++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs
@@ -88,6 +88,7 @@ internal sealed record LiveShutdownRoots(
RuntimeCharacterState Character,
RuntimeCommunicationState Communication,
RuntimeActionState Actions,
+ RuntimeLocalPlayerMovementState Movement,
RenderSceneShadowRuntime? RenderSceneShadow,
LivePresentationRuntimeBindings? PresentationBindings,
DeferredEntityEffectAdvanceSource EffectAdvance,
@@ -392,6 +393,9 @@ internal static class GameWindowShutdownManifest
]),
new ResourceShutdownStage("runtime entity/object stream",
[
+ Hard(
+ "runtime local movement state",
+ live.Movement.Dispose),
Hard(
"runtime action state",
live.Actions.Dispose),
diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
index 8b71cfc3..68d506ec 100644
--- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
+++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
@@ -38,11 +38,10 @@ internal sealed class CurrentGameRuntimeAdapter
RuntimeCharacterState character,
RuntimeCommunicationState communication,
RuntimeActionState actions,
- ILocalPlayerControllerSource playerController,
+ RuntimeLocalPlayerMovementState movement,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock,
- SelectionInteractionController selection,
- GameplayInputFrameController gameplayInput)
+ SelectionInteractionController selection)
{
_view = new CurrentGameRuntimeViewAdapter(
session,
@@ -53,7 +52,7 @@ internal sealed class CurrentGameRuntimeAdapter
character,
communication,
actions,
- playerController,
+ movement,
worldReveal,
clock);
entityObjects.BindEventContext(
@@ -71,8 +70,8 @@ internal sealed class CurrentGameRuntimeAdapter
inventory,
character,
actions,
+ movement,
selection,
- gameplayInput,
_events);
}
diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs
index 910b58f0..5dd07a36 100644
--- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs
+++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs
@@ -34,8 +34,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
private readonly RuntimeInventoryState _inventory;
private readonly RuntimeCharacterState _character;
private readonly RuntimeActionState _actions;
+ private readonly RuntimeLocalPlayerMovementState _movement;
private readonly SelectionInteractionController _selection;
- private readonly GameplayInputFrameController _gameplayInput;
private readonly ICurrentGameRuntimeEventSink _events;
public CurrentGameRuntimeCommandAdapter(
@@ -46,8 +46,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeActionState actions,
+ RuntimeLocalPlayerMovementState movement,
SelectionInteractionController selection,
- GameplayInputFrameController gameplayInput,
ICurrentGameRuntimeEventSink events)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
@@ -58,9 +58,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
_character = character ?? throw new ArgumentNullException(nameof(character));
_actions = actions
?? throw new ArgumentNullException(nameof(actions));
+ _movement = movement
+ ?? throw new ArgumentNullException(nameof(movement));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
- _gameplayInput = gameplayInput
- ?? throw new ArgumentNullException(nameof(gameplayInput));
_events = events ?? throw new ArgumentNullException(nameof(events));
}
@@ -246,22 +246,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
- InputAction action = command switch
- {
- RuntimeMovementCommand.ToggleRunLock => InputAction.MovementRunLock,
- RuntimeMovementCommand.Stop => InputAction.MovementStop,
- _ => InputAction.None,
- };
- RuntimeCommandStatus status;
- if (action == InputAction.None)
- {
- status = RuntimeCommandStatus.Unsupported;
- }
- else
- {
- _gameplayInput.HandlePressedMovementAction(action);
- status = RuntimeCommandStatus.Accepted;
- }
+ RuntimeCommandStatus status = _movement.Execute(command)
+ ? RuntimeCommandStatus.Accepted
+ : RuntimeCommandStatus.Unsupported;
_events.EmitCommand(RuntimeCommandDomain.Movement, (int)command, status);
return Result(status);
diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs
index cae659a3..fc4e23db 100644
--- a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs
+++ b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs
@@ -29,7 +29,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
private readonly IRuntimeSocialView _socialView;
private readonly IRuntimeChatView _chatView;
private readonly IRuntimeActionView _actionView;
- private readonly MovementView _movementView;
+ private readonly IRuntimeMovementView _movementView;
private readonly PortalView _portalView;
private bool _active = true;
@@ -42,7 +42,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
RuntimeCharacterState character,
RuntimeCommunicationState communication,
RuntimeActionState actions,
- ILocalPlayerControllerSource playerController,
+ RuntimeLocalPlayerMovementState movement,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock)
{
@@ -65,10 +65,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_socialView = communication.SocialView;
_actionView = (
actions ?? throw new ArgumentNullException(nameof(actions))).View;
- _movementView = new MovementView(
- playerController
- ?? throw new ArgumentNullException(nameof(playerController)),
- _clock);
+ _movementView = (
+ movement ?? throw new ArgumentNullException(nameof(movement))).View;
_portalView = new PortalView(
worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)));
}
@@ -132,35 +130,6 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
internal void Deactivate() => _active = false;
- private sealed class MovementView(
- ILocalPlayerControllerSource owner,
- IGameRuntimeClock clock)
- : IRuntimeMovementView
- {
- public RuntimeMovementSnapshot Snapshot
- {
- get
- {
- PlayerMovementController? controller = owner.Controller;
- return controller is null
- ? new RuntimeMovementSnapshot(
- false,
- 0u,
- default,
- default,
- false,
- clock.SimulationTimeSeconds)
- : new RuntimeMovementSnapshot(
- true,
- controller.LocalEntityId,
- controller.CellPosition,
- controller.BodyVelocity,
- controller.IsAirborne,
- clock.SimulationTimeSeconds);
- }
- }
- }
-
private sealed class PortalView(WorldRevealCoordinator owner)
: IRuntimePortalView
{
diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs
index 177672b0..9ca78cea 100644
--- a/src/AcDream.Core/Physics/ResolveResult.cs
+++ b/src/AcDream.Core/Physics/ResolveResult.cs
@@ -9,7 +9,8 @@ namespace AcDream.Core.Physics;
///
///
/// L.3a (2026-04-30): added optional collision-normal fields so the
-/// caller (typically )
+/// caller (typically
+/// )
/// can apply retail's velocity-reflection bounce
/// (v_new = v - (1 + elasticity) * dot(v, n) * n) to the
/// PhysicsBody after the geometric resolve completes. ACE port mirror:
diff --git a/src/AcDream.Runtime/AcDream.Runtime.csproj b/src/AcDream.Runtime/AcDream.Runtime.csproj
index 7b33b2b2..4c033569 100644
--- a/src/AcDream.Runtime/AcDream.Runtime.csproj
+++ b/src/AcDream.Runtime/AcDream.Runtime.csproj
@@ -9,6 +9,9 @@
+
+
+
diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs
index 28496368..8b3cf7c0 100644
--- a/src/AcDream.Runtime/GameRuntimeViews.cs
+++ b/src/AcDream.Runtime/GameRuntimeViews.cs
@@ -73,7 +73,9 @@ public readonly record struct RuntimeMovementSnapshot(
Position Position,
System.Numerics.Vector3 Velocity,
bool IsAirborne,
- double SimulationTimeSeconds);
+ double SimulationTimeSeconds,
+ long Revision = 0,
+ bool AutoRunActive = false);
public interface IRuntimeMovementView
{
diff --git a/src/AcDream.App/Input/LocalPlayerOutboundController.cs b/src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs
similarity index 88%
rename from src/AcDream.App/Input/LocalPlayerOutboundController.cs
rename to src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs
index 7adc7903..0053e737 100644
--- a/src/AcDream.App/Input/LocalPlayerOutboundController.cs
+++ b/src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs
@@ -3,7 +3,24 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
-namespace AcDream.App.Input;
+namespace AcDream.Runtime.Gameplay;
+
+public interface IMovementTruthDiagnosticSink
+{
+ void OnOutbound(
+ string kind,
+ uint sequence,
+ MovementResult result,
+ Vector3 wirePosition,
+ uint wireCellId,
+ byte contactByte);
+
+ void OnServerEcho(
+ WorldSession.EntityPositionUpdate update,
+ Vector3 serverWorldPosition);
+
+ void ResetSession();
+}
///
/// Serializes input-originated output from the object-phase result and the
@@ -250,3 +267,33 @@ public sealed class LocalPlayerOutboundController
};
}
}
+
+internal sealed class DelegateMovementTruthDiagnosticSink
+ : IMovementTruthDiagnosticSink
+{
+ private readonly Action
+ _outbound;
+
+ public DelegateMovementTruthDiagnosticSink(
+ Action outbound) =>
+ _outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
+
+ public void OnOutbound(
+ string kind,
+ uint sequence,
+ MovementResult result,
+ Vector3 wirePosition,
+ uint wireCellId,
+ byte contactByte) =>
+ _outbound(kind, sequence, result, wirePosition, wireCellId, contactByte);
+
+ public void OnServerEcho(
+ WorldSession.EntityPositionUpdate update,
+ Vector3 serverWorldPosition)
+ {
+ }
+
+ public void ResetSession()
+ {
+ }
+}
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
similarity index 98%
rename from src/AcDream.App/Input/PlayerMovementController.cs
rename to src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
index 0450a319..0720ba4b 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
@@ -2,7 +2,7 @@ using System;
using System.Numerics;
using AcDream.Core.Physics;
-namespace AcDream.App.Input;
+namespace AcDream.Runtime.Gameplay;
///
/// Input state for a single frame of player movement.
@@ -18,6 +18,29 @@ public readonly record struct MovementInput(
float MouseDeltaX = 0f,
bool Jump = false);
+///
+/// Typed construction policy for the local movement owner. Server-authoritative
+/// values come from ; the fallback only
+/// preserves the pre-description test/login baseline and never reads process
+/// environment state.
+///
+public readonly record struct PlayerMovementConstructionOptions(
+ int RunSkill,
+ int JumpSkill)
+{
+ public const int FallbackRunSkill = 200;
+ public const int FallbackJumpSkill = 300;
+
+ public static PlayerMovementConstructionOptions Fallback =>
+ new(FallbackRunSkill, FallbackJumpSkill);
+
+ public static PlayerMovementConstructionOptions From(
+ RuntimeMovementSkillSnapshot skills) =>
+ new(
+ skills.RunSkill >= 0 ? skills.RunSkill : FallbackRunSkill,
+ skills.JumpSkill >= 0 ? skills.JumpSkill : FallbackJumpSkill);
+}
+
///
/// Read-only presentation snapshot of retail's pending jump build. The movement
/// controller remains the sole owner of charge timing; retained UI only projects
@@ -456,6 +479,14 @@ public sealed class PlayerMovementController
public PlayerMovementController(
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock = null)
+ : this(physics, objectClock, PlayerMovementConstructionOptions.Fallback)
+ {
+ }
+
+ public PlayerMovementController(
+ PhysicsEngine physics,
+ RetailObjectQuantumClock? objectClock,
+ PlayerMovementConstructionOptions options)
{
_physics = physics;
_objectClock = objectClock ?? new RetailObjectQuantumClock();
@@ -467,20 +498,20 @@ public sealed class PlayerMovementController
// Default skills — tuned toward mid-retail feel. Real characters'
// skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow
- // pushes them via SetCharacterSkills once the controller exists
- // (K-fix7; PD arrives at login before auto-entry). These env-var
- // defaults only cover tests / pre-PD frames:
- // ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
+ // Runtime supplies them through typed construction options and
+ // updates them via SetCharacterSkills when later authoritative
+ // values arrive.
// K-fix6 (2026-04-26): bumped default jump skill from 200 → 300.
// Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1):
// skill=200 → 3.01m max (felt too low — user complaint)
// skill=300 → 4.21m max (closer to a typical retail mid-tier
// character's "I can clear that fence" hop)
- // Until #7 ships and PlayerDescription gives us the server's real
- // skill, this default is the right "feels like retail" baseline.
- int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200;
- int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300;
- _weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
+ // Until PlayerDescription supplies both values, retain the typed
+ // construction baseline. RuntimeMovementSkillState updates this same
+ // PlayerWeenie after authoritative character data arrives.
+ _weenie = new PlayerWeenie(
+ runSkill: options.RunSkill,
+ jumpSkill: options.JumpSkill);
_motion = new MotionInterpreter(_body, _weenie);
// R5-V5: the MovementManager facade owns the interp from birth
// (retail CPhysicsObj::movement_manager); the moveto child binds
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs b/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs
index 1235eb42..bfa70952 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs
@@ -2,20 +2,22 @@ namespace AcDream.Runtime.Gameplay;
///
/// One allocation-free ledger over the gameplay-state lifetime group through
-/// J5.1. It contains no state of its own; graphical and no-window hosts capture
-/// the same four canonical owners.
+/// J5.4. It contains no state of its own; graphical and no-window hosts capture
+/// the same canonical owners.
///
public readonly record struct RuntimeGameplayOwnershipSnapshot(
RuntimeInventoryOwnershipSnapshot Inventory,
RuntimeCharacterOwnershipSnapshot Character,
RuntimeCommunicationOwnershipSnapshot Communication,
- RuntimeActionOwnershipSnapshot Actions)
+ RuntimeActionOwnershipSnapshot Actions,
+ RuntimeLocalMovementOwnershipSnapshot Movement)
{
public bool IsConverged =>
Inventory.IsConverged
&& Character.IsConverged
&& Communication.IsConverged
- && Actions.IsConverged;
+ && Actions.IsConverged
+ && Movement.IsConverged;
}
public static class RuntimeGameplayOwnership
@@ -24,16 +26,19 @@ public static class RuntimeGameplayOwnership
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeCommunicationState communication,
- RuntimeActionState actions)
+ RuntimeActionState actions,
+ RuntimeLocalPlayerMovementState movement)
{
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(communication);
ArgumentNullException.ThrowIfNull(actions);
+ ArgumentNullException.ThrowIfNull(movement);
return new RuntimeGameplayOwnershipSnapshot(
inventory.CaptureOwnership(),
character.CaptureOwnership(),
communication.CaptureOwnership(),
- actions.CaptureOwnership());
+ actions.CaptureOwnership(),
+ movement.CaptureOwnership());
}
}
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
new file mode 100644
index 00000000..e6f5463a
--- /dev/null
+++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
@@ -0,0 +1,200 @@
+using AcDream.Core.Physics;
+
+namespace AcDream.Runtime.Gameplay;
+
+public interface IRuntimeLocalPlayerControllerSource
+{
+ PlayerMovementController? Controller { get; }
+}
+
+public interface IRuntimeLocalPlayerMotionSource
+{
+ MotionInterpreter? Motion { get; }
+}
+
+///
+/// Canonical local movement lifetime and intent owner. Graphical input,
+/// presentation, diagnostics, and future no-window hosts borrow this exact
+/// state; they never mirror the controller or the autorun latch.
+///
+public sealed class RuntimeLocalPlayerMovementState
+ : IRuntimeLocalPlayerControllerSource,
+ IRuntimeLocalPlayerMotionSource,
+ IRuntimeMovementView,
+ IDisposable
+{
+ private PlayerMovementController? _controller;
+ private PlayerMovementController? _preparingMotionOwner;
+ private bool _autoRunActive;
+ private bool _disposed;
+ private long _revision;
+
+ public PlayerMovementController? Controller
+ {
+ get => _controller;
+ set
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (ReferenceEquals(_controller, value))
+ return;
+ _controller = value;
+ Interlocked.Increment(ref _revision);
+ }
+ }
+
+ public bool AutoRunActive => _autoRunActive;
+ public long Revision => Interlocked.Read(ref _revision);
+ public IRuntimeMovementView View => this;
+
+ MotionInterpreter? IRuntimeLocalPlayerMotionSource.Motion =>
+ _preparingMotionOwner?.Motion ?? _controller?.Motion;
+
+ public RuntimeMovementSnapshot Snapshot
+ {
+ get
+ {
+ PlayerMovementController? controller = _controller;
+ return controller is null
+ ? new RuntimeMovementSnapshot(
+ false,
+ 0u,
+ default,
+ default,
+ false,
+ 0d,
+ Revision,
+ _autoRunActive)
+ : new RuntimeMovementSnapshot(
+ true,
+ controller.LocalEntityId,
+ controller.CellPosition,
+ controller.BodyVelocity,
+ controller.IsAirborne,
+ controller.SimTimeSeconds,
+ Revision,
+ _autoRunActive);
+ }
+ }
+
+ ///
+ /// Opens the retail construction seam where a PartArray completion can
+ /// reach the candidate MotionInterpreter before the controller is
+ /// published to ordinary borrowers.
+ ///
+ public IDisposable BeginMotionPreparation(
+ PlayerMovementController controller,
+ Action? drainPriorAnimationQueue = null)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(controller);
+ if (_preparingMotionOwner is not null)
+ {
+ throw new InvalidOperationException(
+ "A local player motion owner is already being prepared.");
+ }
+
+ drainPriorAnimationQueue?.Invoke();
+ _preparingMotionOwner = controller;
+ return new MotionPreparation(this, controller);
+ }
+
+ public bool Execute(RuntimeMovementCommand command)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ switch (command)
+ {
+ case RuntimeMovementCommand.ToggleRunLock:
+ _autoRunActive = !_autoRunActive;
+ Interlocked.Increment(ref _revision);
+ return true;
+ case RuntimeMovementCommand.Stop:
+ CancelAutoRun();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ public bool CancelAutoRun()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (!_autoRunActive)
+ return false;
+ _autoRunActive = false;
+ Interlocked.Increment(ref _revision);
+ return true;
+ }
+
+ public void ResetInputIntent()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (!_autoRunActive)
+ return;
+ _autoRunActive = false;
+ Interlocked.Increment(ref _revision);
+ }
+
+ public RuntimeLocalMovementOwnershipSnapshot CaptureOwnership() =>
+ new(
+ _disposed,
+ _controller is not null,
+ _preparingMotionOwner is not null,
+ _autoRunActive,
+ Revision);
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _autoRunActive = false;
+ _controller = null;
+ _preparingMotionOwner = null;
+ Interlocked.Increment(ref _revision);
+ _disposed = true;
+ }
+
+ private void EndMotionPreparation(PlayerMovementController controller)
+ {
+ // Terminal disposal clears the unpublished construction seam. A
+ // caller may still unwind the already-issued lease afterward; that
+ // unwind is idempotent rather than resurrecting or faulting the
+ // retired runtime owner.
+ if (_disposed && _preparingMotionOwner is null)
+ return;
+
+ if (!ReferenceEquals(_preparingMotionOwner, controller))
+ {
+ throw new InvalidOperationException(
+ "The local player motion preparation owner changed unexpectedly.");
+ }
+ _preparingMotionOwner = null;
+ }
+
+ private sealed class MotionPreparation(
+ RuntimeLocalPlayerMovementState owner,
+ PlayerMovementController controller) : IDisposable
+ {
+ private RuntimeLocalPlayerMovementState? _owner = owner;
+
+ public void Dispose()
+ {
+ RuntimeLocalPlayerMovementState? current =
+ Interlocked.Exchange(ref _owner, null);
+ current?.EndMotionPreparation(controller);
+ }
+ }
+}
+
+public readonly record struct RuntimeLocalMovementOwnershipSnapshot(
+ bool IsDisposed,
+ bool HasController,
+ bool HasPreparingMotionOwner,
+ bool AutoRunActive,
+ long Revision)
+{
+ public bool IsConverged =>
+ IsDisposed
+ && !HasController
+ && !HasPreparingMotionOwner
+ && !AutoRunActive;
+}
diff --git a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs
index 399a94bd..b9fd011d 100644
--- a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs
@@ -96,7 +96,8 @@ public sealed class HostInputCameraCompositionTests
ViewportAspect = new ViewportAspectState();
Framebuffer = new FramebufferResizeController(ViewportAspect);
Capture = new CaptureSource();
- Movement = new DispatcherMovementInputSource(Capture);
+ MovementState = new RuntimeLocalPlayerMovementState();
+ Movement = new DispatcherMovementInputSource(MovementState, Capture);
CameraInput = new DispatcherCameraInputSource();
PlayerMode = new LocalPlayerModeState();
Chase = new ChaseCameraInputState();
@@ -111,6 +112,7 @@ public sealed class HostInputCameraCompositionTests
public ViewportAspectState ViewportAspect { get; }
public FramebufferResizeController Framebuffer { get; }
public CaptureSource Capture { get; }
+ public RuntimeLocalPlayerMovementState MovementState { get; }
public DispatcherMovementInputSource Movement { get; }
public DispatcherCameraInputSource CameraInput { get; }
public LocalPlayerModeState PlayerMode { get; }
@@ -141,7 +143,11 @@ public sealed class HostInputCameraCompositionTests
throw new InvalidOperationException($"fault at {point}");
});
- public void Dispose() => Publication.Dispose();
+ public void Dispose()
+ {
+ Publication.Dispose();
+ MovementState.Dispose();
+ }
}
private sealed class Publication :
diff --git a/tests/AcDream.App.Tests/GlobalUsings.cs b/tests/AcDream.App.Tests/GlobalUsings.cs
new file mode 100644
index 00000000..d2d17bec
--- /dev/null
+++ b/tests/AcDream.App.Tests/GlobalUsings.cs
@@ -0,0 +1,7 @@
+global using AcDream.Runtime.Gameplay;
+global using LocalPlayerControllerSlot =
+ AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState;
+global using ILocalPlayerControllerSource =
+ AcDream.Runtime.Gameplay.IRuntimeLocalPlayerControllerSource;
+global using ILocalPlayerMotionSource =
+ AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource;
diff --git a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs
index 6517273b..5e8ac21c 100644
--- a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs
@@ -76,7 +76,7 @@ public sealed class CameraPointerInputControllerTests
var mouseLook = new MouseLook(active: true);
var frame = new GameplayInputFrameController(
dispatcher: null,
- new DispatcherMovementInputSource(),
+ new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook,
new Combat());
@@ -113,7 +113,7 @@ public sealed class CameraPointerInputControllerTests
var mouseLook = new MouseLook(active: true);
var frame = new GameplayInputFrameController(
dispatcher: null,
- new DispatcherMovementInputSource(),
+ new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook,
new Combat());
fixture.Owner.BindGameplayFrame(frame);
diff --git a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs
index 0cdcf87a..ae5a06e9 100644
--- a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs
+++ b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs
@@ -9,7 +9,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact]
public void UnboundSourceCapturesNeutralInput()
{
- var source = new DispatcherMovementInputSource();
+ var source = CreateSource();
Assert.Equal(default, source.Capture());
}
@@ -18,7 +18,7 @@ public sealed class DispatcherMovementInputSourceTests
public void CapturesDispatcherHeldStateAndRetailWalkModifier()
{
var (dispatcher, _, _) = CreateDispatcher();
- var source = new DispatcherMovementInputSource();
+ var source = CreateSource();
source.Bind(dispatcher);
Assert.True(dispatcher.TrySetAutomationActionHeld(
@@ -38,7 +38,7 @@ public sealed class DispatcherMovementInputSourceTests
public void RetainedKeyboardCaptureSilencesHeldKeysButDoesNotCancelAutorun()
{
var (dispatcher, _, mouse) = CreateDispatcher();
- var source = new DispatcherMovementInputSource();
+ var source = CreateSource();
source.Bind(dispatcher);
dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
Assert.True(source.HandlePressedAction(InputAction.MovementRunLock));
@@ -59,7 +59,7 @@ public sealed class DispatcherMovementInputSourceTests
{
var capture = new FakeCapture();
var (dispatcher, _, _) = CreateDispatcher();
- var source = new DispatcherMovementInputSource(capture);
+ var source = CreateSource(capture);
source.Bind(dispatcher);
source.HandlePressedAction(InputAction.MovementRunLock);
@@ -78,7 +78,7 @@ public sealed class DispatcherMovementInputSourceTests
[InlineData(InputAction.MovementStrafeRight)]
public void RetailCancelActionsClearAutorun(InputAction action)
{
- var source = new DispatcherMovementInputSource();
+ var source = CreateSource();
source.HandlePressedAction(InputAction.MovementRunLock);
Assert.False(source.HandlePressedAction(action));
@@ -89,7 +89,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact]
public void ForwardDoesNotCancelAutorunAndResetDoes()
{
- var source = new DispatcherMovementInputSource();
+ var source = CreateSource();
source.HandlePressedAction(InputAction.MovementRunLock);
Assert.False(source.HandlePressedAction(InputAction.MovementForward));
@@ -102,7 +102,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact]
public void BindingIsIdempotentOnlyForTheSameDispatcher()
{
- var source = new DispatcherMovementInputSource();
+ var source = CreateSource();
var (first, _, _) = CreateDispatcher();
var (second, _, _) = CreateDispatcher();
@@ -115,7 +115,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact]
public void UnbindRequiresExactDispatcherAndRestoresNeutralCapture()
{
- var source = new DispatcherMovementInputSource();
+ var source = CreateSource();
var (first, _, _) = CreateDispatcher();
var (other, _, _) = CreateDispatcher();
source.Bind(first);
@@ -142,6 +142,10 @@ public sealed class DispatcherMovementInputSourceTests
return (dispatcher, keyboard, mouse);
}
+ private static DispatcherMovementInputSource CreateSource(
+ IInputCaptureSource? capture = null) =>
+ new(new RuntimeLocalPlayerMovementState(), capture);
+
private sealed class FakeKeyboard : IKeyboardSource
{
#pragma warning disable CS0067
diff --git a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs
index 5c591a18..0389db2b 100644
--- a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs
@@ -30,7 +30,7 @@ public sealed class GameplayInputFrameControllerTests
var combat = new FakeCombat(calls);
var controller = new GameplayInputFrameController(
dispatcher,
- new DispatcherMovementInputSource(),
+ new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook,
combat);
@@ -45,7 +45,7 @@ public sealed class GameplayInputFrameControllerTests
var calls = new List();
var controller = new GameplayInputFrameController(
dispatcher: null,
- new DispatcherMovementInputSource(),
+ new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook: null,
new FakeCombat(calls));
@@ -60,7 +60,7 @@ public sealed class GameplayInputFrameControllerTests
var calls = new List();
var controller = new GameplayInputFrameController(
dispatcher: null,
- new DispatcherMovementInputSource(),
+ new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook: null,
new FakeCombat(calls, consumes: true));
@@ -76,7 +76,8 @@ public sealed class GameplayInputFrameControllerTests
public void ResetSessionReleasesMouseLookAndAutorun()
{
var calls = new List();
- var movement = new DispatcherMovementInputSource();
+ var movement = new DispatcherMovementInputSource(
+ new RuntimeLocalPlayerMovementState());
movement.HandlePressedAction(InputAction.MovementRunLock);
var mouseLook = new FakeMouseLook(calls);
var controller = new GameplayInputFrameController(
@@ -98,7 +99,7 @@ public sealed class GameplayInputFrameControllerTests
var mouseLook = new FakeMouseLook(calls, active: true, consumes: true);
var controller = new GameplayInputFrameController(
dispatcher: null,
- new DispatcherMovementInputSource(),
+ new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook,
new FakeCombat(calls));
diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
index f6a32817..f5fe514b 100644
--- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
+++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs
@@ -782,7 +782,8 @@ public sealed class CurrentGameRuntimeAdapterTests
CombatMode = new RecordingCombatModeOperations();
_combatModeBinding =
CombatModeOperations.BindOwned(CombatMode);
- MovementInput = new DispatcherMovementInputSource();
+ MovementState = new RuntimeLocalPlayerMovementState();
+ MovementInput = new DispatcherMovementInputSource(MovementState);
GameplayInput = new GameplayInputFrameController(
dispatcher: null,
MovementInput,
@@ -832,11 +833,10 @@ public sealed class CurrentGameRuntimeAdapterTests
Character,
Communication,
Actions,
- new LocalPlayerControllerSlot(),
+ MovementState,
WorldReveal,
Clock,
- selectionController,
- GameplayInput);
+ selectionController);
}
public RuntimeOptions Options { get; }
@@ -853,6 +853,7 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeSpellCastOperationsSlot SpellCastOperations { get; }
public RuntimeActionState Actions { get; }
public SelectionState Selection => Actions.Selection;
+ public RuntimeLocalPlayerMovementState MovementState { get; }
public DispatcherMovementInputSource MovementInput { get; }
public GameplayInputFrameController GameplayInput { get; }
public RecordingCombatModeOperations CombatMode { get; }
@@ -874,6 +875,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Actions.Dispose();
InventoryState.Dispose();
Entities.Clear();
+ MovementState.Dispose();
}
}
diff --git a/tests/AcDream.App.Tests/Runtime/RuntimeMovementOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimeMovementOwnershipTests.cs
new file mode 100644
index 00000000..259c4a26
--- /dev/null
+++ b/tests/AcDream.App.Tests/Runtime/RuntimeMovementOwnershipTests.cs
@@ -0,0 +1,123 @@
+using System.Text.RegularExpressions;
+
+namespace AcDream.App.Tests.Runtime;
+
+public sealed class RuntimeMovementOwnershipTests
+{
+ [Fact]
+ public void ProductionConstructsOneCanonicalRuntimeMovementOwner()
+ {
+ string root = FindRepositoryRoot();
+ string appRoot = Path.Combine(root, "src", "AcDream.App");
+ string runtimeRoot = Path.Combine(root, "src", "AcDream.Runtime");
+ string app = string.Join(
+ "\n",
+ Directory.EnumerateFiles(appRoot, "*.cs", SearchOption.AllDirectories)
+ .Select(File.ReadAllText));
+ string runtime = string.Join(
+ "\n",
+ Directory.EnumerateFiles(runtimeRoot, "*.cs", SearchOption.AllDirectories)
+ .Select(File.ReadAllText));
+
+ Assert.False(File.Exists(Path.Combine(
+ appRoot,
+ "Input",
+ "PlayerMovementController.cs")));
+ Assert.False(File.Exists(Path.Combine(
+ appRoot,
+ "Input",
+ "LocalPlayerOutboundController.cs")));
+ Assert.DoesNotContain(
+ "class PlayerMovementController",
+ app,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "class LocalPlayerOutboundController",
+ app,
+ StringComparison.Ordinal);
+ Assert.Empty(Regex.Matches(app, @"\bbool\s+_autoRunActive\b"));
+ Assert.Single(Regex.Matches(
+ app,
+ @"RuntimeLocalPlayerMovementState\s+\w+\s*=\s*new\s*\(")
+ .Cast());
+ Assert.DoesNotContain(
+ "ACDREAM_RUN_SKILL",
+ runtime,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "ACDREAM_JUMP_SKILL",
+ runtime,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GraphicalInputRuntimeViewsAndShutdownBorrowTheExactOwner()
+ {
+ string root = FindRepositoryRoot();
+ string gameWindow = ReadAppSource(root, "Rendering", "GameWindow.cs");
+ string input = ReadAppSource(
+ root,
+ "Input",
+ "DispatcherMovementInputSource.cs");
+ string view = ReadAppSource(
+ root,
+ "Runtime",
+ "CurrentGameRuntimeViewAdapter.cs");
+ string commands = ReadAppSource(
+ root,
+ "Runtime",
+ "CurrentGameRuntimeCommandAdapter.cs");
+ string lifetime = ReadAppSource(
+ root,
+ "Rendering",
+ "GameWindowLifetime.cs");
+
+ Assert.Contains(
+ "RuntimeLocalPlayerMovementState _playerControllerSlot = new();",
+ gameWindow,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "new AcDream.App.Input.DispatcherMovementInputSource(",
+ gameWindow,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "_playerControllerSlot,",
+ gameWindow,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "RuntimeLocalPlayerMovementState movement,",
+ input,
+ StringComparison.Ordinal);
+ Assert.Contains("_movement.AutoRunActive", input, StringComparison.Ordinal);
+ Assert.Contains(
+ "movement ?? throw new ArgumentNullException(nameof(movement))).View",
+ view,
+ StringComparison.Ordinal);
+ Assert.Contains("_movement.Execute(command)", commands, StringComparison.Ordinal);
+ Assert.Contains(
+ "RuntimeLocalPlayerMovementState Movement",
+ lifetime,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "\"runtime local movement state\"",
+ lifetime,
+ StringComparison.Ordinal);
+ }
+
+ private static string ReadAppSource(string root, params string[] relative) =>
+ File.ReadAllText(Path.Combine(
+ [root, "src", "AcDream.App", .. relative]));
+
+ private static string FindRepositoryRoot()
+ {
+ var current = new DirectoryInfo(AppContext.BaseDirectory);
+ while (current is not null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "AcDream.slnx")))
+ return current.FullName;
+ current = current.Parent;
+ }
+
+ throw new DirectoryNotFoundException("AcDream.slnx was not found.");
+ }
+}
diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
index 43638ea8..9a3b2161 100644
--- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
+++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
@@ -625,11 +625,11 @@ public sealed class UpdateFrameOrchestratorTests
== typeof(AcDream.App.Interaction.SelectionInteractionController));
FieldInfo outboundDiagnostics = Assert.Single(
- typeof(AcDream.App.Input.LocalPlayerOutboundController).GetFields(
+ typeof(LocalPlayerOutboundController).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic),
field => field.Name == "_diagnostic");
Assert.Equal(
- typeof(AcDream.App.Input.IMovementTruthDiagnosticSink),
+ typeof(IMovementTruthDiagnosticSink),
outboundDiagnostics.FieldType);
Assert.DoesNotContain(
typeof(AcDream.App.Input.MovementTruthDiagnosticController).GetFields(
diff --git a/tests/AcDream.Core.Tests/GlobalUsings.cs b/tests/AcDream.Core.Tests/GlobalUsings.cs
new file mode 100644
index 00000000..bb478f73
--- /dev/null
+++ b/tests/AcDream.Core.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using AcDream.Runtime.Gameplay;
diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerImmediatePositionTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/LocalPlayerImmediatePositionTests.cs
similarity index 98%
rename from tests/AcDream.App.Tests/Input/LocalPlayerImmediatePositionTests.cs
rename to tests/AcDream.Runtime.Tests/Gameplay/LocalPlayerImmediatePositionTests.cs
index 8f7ee008..449cd604 100644
--- a/tests/AcDream.App.Tests/Input/LocalPlayerImmediatePositionTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/LocalPlayerImmediatePositionTests.cs
@@ -1,12 +1,12 @@
using System.Buffers.Binary;
using System.Net;
using System.Numerics;
-using AcDream.App.Input;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
+using AcDream.Runtime.Gameplay;
-namespace AcDream.App.Tests.Input;
+namespace AcDream.Runtime.Tests.Gameplay;
public sealed class LocalPlayerImmediatePositionTests
{
diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/LocalPlayerOutboundCombatStyleTests.cs
similarity index 95%
rename from tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs
rename to tests/AcDream.Runtime.Tests/Gameplay/LocalPlayerOutboundCombatStyleTests.cs
index 1103bb6b..53138e25 100644
--- a/tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/LocalPlayerOutboundCombatStyleTests.cs
@@ -1,10 +1,10 @@
using System.Buffers.Binary;
-using AcDream.App.Input;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
+using AcDream.Runtime.Gameplay;
-namespace AcDream.App.Tests.Input;
+namespace AcDream.Runtime.Tests.Gameplay;
public sealed class LocalPlayerOutboundCombatStyleTests
{
diff --git a/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMouseLookMovementTests.cs
similarity index 99%
rename from tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs
rename to tests/AcDream.Runtime.Tests/Gameplay/PlayerMouseLookMovementTests.cs
index c0946d21..da117431 100644
--- a/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMouseLookMovementTests.cs
@@ -1,10 +1,9 @@
using System.Numerics;
-using AcDream.App.Input;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
-using Xunit;
+using AcDream.Runtime.Gameplay;
-namespace AcDream.App.Tests.Input;
+namespace AcDream.Runtime.Tests.Gameplay;
public sealed class PlayerMouseLookMovementTests
{
diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
similarity index 99%
rename from tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
rename to tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
index 96d2d9aa..fdebf39b 100644
--- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
@@ -1,10 +1,9 @@
using System;
using System.Numerics;
-using AcDream.App.Input;
using AcDream.Core.Physics;
-using Xunit;
+using AcDream.Runtime.Gameplay;
-namespace AcDream.Core.Tests.Input;
+namespace AcDream.Runtime.Tests.Gameplay;
public class PlayerMovementControllerTests
{
diff --git a/tests/AcDream.App.Tests/Physics/PlayerOutboundPositionTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerOutboundPositionTests.cs
similarity index 99%
rename from tests/AcDream.App.Tests/Physics/PlayerOutboundPositionTests.cs
rename to tests/AcDream.Runtime.Tests/Gameplay/PlayerOutboundPositionTests.cs
index 6c10f77c..a4b0208f 100644
--- a/tests/AcDream.App.Tests/Physics/PlayerOutboundPositionTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerOutboundPositionTests.cs
@@ -1,9 +1,8 @@
using System.Numerics;
-using AcDream.App.Input;
using AcDream.Core.Physics;
-using Xunit;
+using AcDream.Runtime.Gameplay;
-namespace AcDream.App.Tests.Physics;
+namespace AcDream.Runtime.Tests.Gameplay;
public sealed class PlayerOutboundPositionTests
{
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs
index e88f03aa..ecbca098 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs
@@ -9,13 +9,15 @@ namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeGameplayOwnershipTests
{
[Fact]
- public void CombinedLedgerConvergesEveryJ4OwnerAndSubscription()
+ public void CombinedLedgerConvergesEveryGameplayOwnerAndSubscription()
{
using var entities = new RuntimeEntityObjectLifetime();
var inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState();
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
+ var movement = new RuntimeLocalPlayerMovementState();
+ movement.Execute(RuntimeMovementCommand.ToggleRunLock);
inventory.Shortcuts.Changed += static () => { };
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true);
@@ -73,7 +75,8 @@ public sealed class RuntimeGameplayOwnershipTests
inventory,
character,
communication,
- actions);
+ actions,
+ movement);
Assert.False(populated.IsConverged);
Assert.Equal(1, populated.Inventory.ShortcutCount);
@@ -86,7 +89,9 @@ public sealed class RuntimeGameplayOwnershipTests
AcDream.Core.Combat.CombatMode.Melee,
populated.Actions.CombatMode);
Assert.Equal(1, populated.Actions.TrackedTargetHealthCount);
+ Assert.True(populated.Movement.AutoRunActive);
+ movement.Dispose();
actions.Dispose();
communication.Dispose();
character.Dispose();
@@ -98,7 +103,8 @@ public sealed class RuntimeGameplayOwnershipTests
inventory,
character,
communication,
- actions);
+ actions,
+ movement);
Assert.True(retired.IsConverged);
Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount);
@@ -108,13 +114,14 @@ public sealed class RuntimeGameplayOwnershipTests
}
[Fact]
- public void BorrowerFailuresCannotStrandAnyJ4OwnerDuringTerminalDisposal()
+ public void BorrowerFailuresCannotStrandAnyGameplayOwnerDuringTerminalDisposal()
{
using var entities = new RuntimeEntityObjectLifetime();
var inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState();
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
+ var movement = new RuntimeLocalPlayerMovementState();
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
@@ -132,6 +139,7 @@ public sealed class RuntimeGameplayOwnershipTests
communication.Chat.OnSystemMessage("failure-isolated event", 0u);
Assert.Equal(1, communication.DispatchFailureCount);
+ movement.Dispose();
actions.Dispose();
Assert.Throws(inventory.Dispose);
Assert.Throws(character.Dispose);
@@ -142,7 +150,8 @@ public sealed class RuntimeGameplayOwnershipTests
inventory,
character,
communication,
- actions);
+ actions,
+ movement);
Assert.True(retired.IsConverged);
Assert.Equal(1, retired.Communication.DispatchFailureCount);
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs
new file mode 100644
index 00000000..5e9d0ce9
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs
@@ -0,0 +1,174 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.Runtime.Tests.Gameplay;
+
+public sealed class RuntimeLocalPlayerMovementStateTests
+{
+ [Fact]
+ public void ViewProjectsTheExactCanonicalControllerAndAutorunOwner()
+ {
+ var controller = new PlayerMovementController(new PhysicsEngine())
+ {
+ LocalEntityId = 0x50000001u,
+ };
+ controller.SetPosition(
+ new Vector3(11f, 12f, 13f),
+ 0xA9B40001u,
+ new Vector3(11f, 12f, 13f));
+ using var movement = new RuntimeLocalPlayerMovementState
+ {
+ Controller = controller,
+ };
+
+ Assert.Same(movement, movement.View);
+ Assert.True(movement.Execute(RuntimeMovementCommand.ToggleRunLock));
+
+ RuntimeMovementSnapshot snapshot = movement.View.Snapshot;
+ Assert.True(snapshot.HasController);
+ Assert.Equal(0x50000001u, snapshot.LocalEntityId);
+ Assert.Equal(controller.CellPosition, snapshot.Position);
+ Assert.Equal(controller.BodyVelocity, snapshot.Velocity);
+ Assert.Equal(controller.IsAirborne, snapshot.IsAirborne);
+ Assert.Equal(controller.SimTimeSeconds, snapshot.SimulationTimeSeconds);
+ Assert.True(snapshot.AutoRunActive);
+ Assert.Equal(movement.Revision, snapshot.Revision);
+ }
+
+ [Fact]
+ public void GraphicalAndDirectCommandsMutateOneAutorunLatch()
+ {
+ using var movement = new RuntimeLocalPlayerMovementState();
+
+ Assert.True(movement.Execute(RuntimeMovementCommand.ToggleRunLock));
+ Assert.True(movement.AutoRunActive);
+ Assert.True(movement.View.Snapshot.AutoRunActive);
+
+ Assert.True(movement.Execute(RuntimeMovementCommand.Stop));
+ Assert.False(movement.AutoRunActive);
+ Assert.False(movement.View.Snapshot.AutoRunActive);
+ }
+
+ [Fact]
+ public void ConcurrentRuntimeInstancesHaveIndependentMovementState()
+ {
+ using var first = new RuntimeLocalPlayerMovementState();
+ using var second = new RuntimeLocalPlayerMovementState();
+ var firstController = new PlayerMovementController(new PhysicsEngine())
+ {
+ LocalEntityId = 1u,
+ };
+ var secondController = new PlayerMovementController(new PhysicsEngine())
+ {
+ LocalEntityId = 2u,
+ };
+ first.Controller = firstController;
+ second.Controller = secondController;
+
+ first.Execute(RuntimeMovementCommand.ToggleRunLock);
+ firstController.SetPosition(Vector3.One, 0xA9B40001u, Vector3.One);
+ secondController.SetPosition(
+ new Vector3(2f),
+ 0xA9B50001u,
+ new Vector3(2f));
+
+ Assert.True(first.Snapshot.AutoRunActive);
+ Assert.False(second.Snapshot.AutoRunActive);
+ Assert.Equal(1u, first.Snapshot.LocalEntityId);
+ Assert.Equal(2u, second.Snapshot.LocalEntityId);
+ Assert.NotEqual(first.Snapshot.Position, second.Snapshot.Position);
+ }
+
+ [Fact]
+ public void MotionPreparationPublishesOnlyTheConstructionSeam()
+ {
+ using var movement = new RuntimeLocalPlayerMovementState();
+ IRuntimeLocalPlayerMotionSource source = movement;
+ var committed = new PlayerMovementController(new PhysicsEngine());
+ var candidate = new PlayerMovementController(new PhysicsEngine());
+ movement.Controller = committed;
+ bool drained = false;
+
+ using (movement.BeginMotionPreparation(
+ candidate,
+ () =>
+ {
+ drained = true;
+ Assert.Same(committed.Motion, source.Motion);
+ }))
+ {
+ Assert.True(drained);
+ Assert.Same(committed, movement.Controller);
+ Assert.Same(candidate.Motion, source.Motion);
+ }
+
+ Assert.Same(committed.Motion, source.Motion);
+ }
+
+ [Fact]
+ public void TerminalDisposalConvergesWhenPreparationLeaseUnwindsLater()
+ {
+ var movement = new RuntimeLocalPlayerMovementState();
+ var controller = new PlayerMovementController(new PhysicsEngine());
+ IDisposable preparation = movement.BeginMotionPreparation(controller);
+ movement.Controller = controller;
+ movement.Execute(RuntimeMovementCommand.ToggleRunLock);
+
+ movement.Dispose();
+ preparation.Dispose();
+
+ RuntimeLocalMovementOwnershipSnapshot ownership =
+ movement.CaptureOwnership();
+ Assert.True(ownership.IsConverged);
+ Assert.Throws(
+ () => movement.Execute(RuntimeMovementCommand.ToggleRunLock));
+ }
+
+ [Fact]
+ public void TypedSkillOptionsPreserveCompleteValuesAndFallbackPerField()
+ {
+ Assert.Equal(
+ new PlayerMovementConstructionOptions(321, 654),
+ PlayerMovementConstructionOptions.From(
+ new RuntimeMovementSkillSnapshot(321, 654, 1)));
+ Assert.Equal(
+ new PlayerMovementConstructionOptions(
+ PlayerMovementConstructionOptions.FallbackRunSkill,
+ 654),
+ PlayerMovementConstructionOptions.From(
+ new RuntimeMovementSkillSnapshot(-1, 654, 1)));
+ Assert.Equal(
+ new PlayerMovementConstructionOptions(
+ 321,
+ PlayerMovementConstructionOptions.FallbackJumpSkill),
+ PlayerMovementConstructionOptions.From(
+ new RuntimeMovementSkillSnapshot(321, -1, 1)));
+ }
+
+ [Fact]
+ public void WarmMovementSnapshotsAndOwnershipCaptureAllocateNothing()
+ {
+ using var movement = new RuntimeLocalPlayerMovementState();
+ movement.Controller = new PlayerMovementController(new PhysicsEngine())
+ {
+ LocalEntityId = 7u,
+ };
+
+ for (int i = 0; i < 1_000; i++)
+ {
+ _ = movement.Snapshot;
+ _ = movement.CaptureOwnership();
+ }
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ for (int i = 0; i < 100_000; i++)
+ {
+ _ = movement.Snapshot;
+ _ = movement.CaptureOwnership();
+ }
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Equal(0L, allocated);
+ }
+}