From aa3f4a60f87f2cbebc3d8ecd5e33d779b7fb13c1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Jul 2026 12:33:53 +0200 Subject: [PATCH] refactor(runtime): own local movement and outbound cadence Move the canonical local movement controller, body/motion managers, object clock, movement wire data, and MTS/jump/AP sender into AcDream.Runtime. Replace process skill defaults with typed Runtime character options, make graphical and direct commands borrow one autorun owner, retain the construction-time PartArray seam, and include movement in terminal ownership convergence. Preserve the accepted pre-inbound movement/jump and post-inbound autonomous-position order while moving the exact packet/cadence fixtures into Runtime tests. Add graphical/direct parity, two-instance isolation, teardown, allocation, architecture, and divergence-path coverage. Co-authored-by: Codex --- .../retail-divergence-register.md | 18 +- ...-26-slice-j5-4-local-movement-ownership.md | 146 +++++++++++++ .../Composition/SessionPlayerComposition.cs | 3 +- src/AcDream.App/GlobalUsings.cs | 7 + .../Input/DispatcherMovementInputSource.cs | 25 ++- .../Input/LocalPlayerRuntimeState.cs | 83 -------- .../MovementTruthDiagnosticController.cs | 47 ---- src/AcDream.App/Input/PlayerModeController.cs | 3 +- src/AcDream.App/Rendering/GameWindow.cs | 10 +- .../Rendering/GameWindowLifetime.cs | 4 + .../Runtime/CurrentGameRuntimeAdapter.cs | 9 +- .../CurrentGameRuntimeCommandAdapter.cs | 27 +-- .../Runtime/CurrentGameRuntimeViewAdapter.cs | 39 +--- src/AcDream.Core/Physics/ResolveResult.cs | 3 +- src/AcDream.Runtime/AcDream.Runtime.csproj | 3 + src/AcDream.Runtime/GameRuntimeViews.cs | 4 +- .../LocalPlayerOutboundController.cs | 49 ++++- .../Gameplay}/PlayerMovementController.cs | 51 ++++- .../Gameplay/RuntimeGameplayOwnership.cs | 17 +- .../RuntimeLocalPlayerMovementState.cs | 200 ++++++++++++++++++ .../HostInputCameraCompositionTests.cs | 10 +- tests/AcDream.App.Tests/GlobalUsings.cs | 7 + .../CameraPointerInputControllerTests.cs | 4 +- .../DispatcherMovementInputSourceTests.cs | 20 +- .../GameplayInputFrameControllerTests.cs | 11 +- .../Runtime/CurrentGameRuntimeAdapterTests.cs | 10 +- .../Runtime/RuntimeMovementOwnershipTests.cs | 123 +++++++++++ .../World/UpdateFrameOrchestratorTests.cs | 4 +- tests/AcDream.Core.Tests/GlobalUsings.cs | 1 + .../LocalPlayerImmediatePositionTests.cs | 4 +- .../LocalPlayerOutboundCombatStyleTests.cs | 4 +- .../Gameplay}/PlayerMouseLookMovementTests.cs | 5 +- .../PlayerMovementControllerTests.cs | 5 +- .../Gameplay}/PlayerOutboundPositionTests.cs | 5 +- .../Gameplay/RuntimeGameplayOwnershipTests.cs | 19 +- .../RuntimeLocalPlayerMovementStateTests.cs | 174 +++++++++++++++ 36 files changed, 878 insertions(+), 276 deletions(-) create mode 100644 docs/research/2026-07-26-slice-j5-4-local-movement-ownership.md create mode 100644 src/AcDream.App/GlobalUsings.cs rename src/{AcDream.App/Input => AcDream.Runtime/Gameplay}/LocalPlayerOutboundController.cs (88%) rename src/{AcDream.App/Input => AcDream.Runtime/Gameplay}/PlayerMovementController.cs (98%) create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs create mode 100644 tests/AcDream.App.Tests/GlobalUsings.cs create mode 100644 tests/AcDream.App.Tests/Runtime/RuntimeMovementOwnershipTests.cs create mode 100644 tests/AcDream.Core.Tests/GlobalUsings.cs rename tests/{AcDream.App.Tests/Input => AcDream.Runtime.Tests/Gameplay}/LocalPlayerImmediatePositionTests.cs (98%) rename tests/{AcDream.App.Tests/Input => AcDream.Runtime.Tests/Gameplay}/LocalPlayerOutboundCombatStyleTests.cs (95%) rename tests/{AcDream.App.Tests/Input => AcDream.Runtime.Tests/Gameplay}/PlayerMouseLookMovementTests.cs (99%) rename tests/{AcDream.Core.Tests/Input => AcDream.Runtime.Tests/Gameplay}/PlayerMovementControllerTests.cs (99%) rename tests/{AcDream.App.Tests/Physics => AcDream.Runtime.Tests/Gameplay}/PlayerOutboundPositionTests.cs (99%) create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs 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); + } +}