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 <noreply@openai.com>
This commit is contained in:
Erik 2026-07-26 12:33:53 +02:00
parent 3456dff038
commit aa3f4a60f8
36 changed files with 878 additions and 276 deletions

View file

@ -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-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-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-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-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.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-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-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-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 |
| AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | | AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` |
@ -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-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-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-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-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-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-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-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-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-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 | | 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-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-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-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) | | 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-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-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-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-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.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-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 1115) + 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-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 1115) + 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-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 | | 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-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-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-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-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-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-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-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-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-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-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-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` | | 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` |

View file

@ -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 699046699145 |
| apply current raw movement | `CommandInterpreter::ApplyCurrentMovement` | `0x006B3430`, lines 699146699530 |
| stop decision | `CommandInterpreter::MaybeStopCompletely` | `0x006B3B90`, lines 699531699563 |
| post-inbound position slot | `CommandInterpreter::UseTime` | `0x006B3BF0`, lines 699564699645 |
| input command boundary | `CommandInterpreter::MovePlayer` | `0x006B3F40`, lines 699803700232 |
| AP predicate | `CommandInterpreter::ShouldSendPositionEvent` | `0x006B45E0`, lines 700233700273 |
| MTS serialization | `CommandInterpreter::SendMovementEvent` | `0x006B4680`, lines 700274700315 |
| 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.

View file

@ -883,8 +883,7 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerController, d.PlayerController,
worldReveal, worldReveal,
d.UpdateClock, d.UpdateClock,
live.SelectionInteractions, live.SelectionInteractions);
gameplayInput);
bindings.Adopt("current game runtime adapter", gameRuntime); bindings.Adopt("current game runtime adapter", gameRuntime);
bindings.Adopt( bindings.Adopt(
"retained-UI game runtime commands", "retained-UI game runtime commands",

View file

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

View file

@ -8,18 +8,25 @@ internal interface IMovementInputSource
} }
/// <summary> /// <summary>
/// Owns held movement sampling and the retail autorun latch. The input /// Samples held graphical input and maps press edges onto the Runtime-owned
/// dispatcher remains the only keyboard/mouse-button state source. /// retail autorun latch. The dispatcher remains the only physical
/// keyboard/mouse-button state source.
/// </summary> /// </summary>
internal sealed class DispatcherMovementInputSource : IMovementInputSource internal sealed class DispatcherMovementInputSource : IMovementInputSource
{ {
private readonly RuntimeLocalPlayerMovementState _movement;
private readonly IInputCaptureSource? _capture; private readonly IInputCaptureSource? _capture;
private InputDispatcher? _dispatcher; private InputDispatcher? _dispatcher;
public DispatcherMovementInputSource(IInputCaptureSource? capture = null) => public DispatcherMovementInputSource(
RuntimeLocalPlayerMovementState movement,
IInputCaptureSource? capture = null)
{
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
_capture = capture; _capture = capture;
}
public bool AutoRunActive { get; private set; } public bool AutoRunActive => _movement.AutoRunActive;
public bool IsAvailable => _dispatcher is not null; public bool IsAvailable => _dispatcher is not null;
public void Bind(InputDispatcher dispatcher) public void Bind(InputDispatcher dispatcher)
@ -70,10 +77,8 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
public bool HandlePressedAction(InputAction action) public bool HandlePressedAction(InputAction action)
{ {
if (action == InputAction.MovementRunLock) if (action == InputAction.MovementRunLock)
{ return _movement.Execute(
AutoRunActive = !AutoRunActive; AcDream.Runtime.RuntimeMovementCommand.ToggleRunLock);
return true;
}
if (AutoRunActive && action is ( if (AutoRunActive && action is (
InputAction.MovementBackup InputAction.MovementBackup
@ -81,11 +86,11 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
or InputAction.MovementStrafeLeft or InputAction.MovementStrafeLeft
or InputAction.MovementStrafeRight)) or InputAction.MovementStrafeRight))
{ {
AutoRunActive = false; _movement.CancelAutoRun();
} }
return false; return false;
} }
public void ResetSession() => AutoRunActive = false; public void ResetSession() => _movement.ResetInputIntent();
} }

View file

@ -16,89 +16,6 @@ internal sealed class LocalPlayerIdentityState : ILocalPlayerIdentitySource
public uint ServerGuid { get; set; } public uint ServerGuid { get; set; }
} }
internal interface ILocalPlayerControllerSource
{
PlayerMovementController? Controller { get; }
}
internal interface ILocalPlayerMotionSource
{
MotionInterpreter? Motion { get; }
}
/// <summary>
/// The one mutable local movement-controller slot. Player-mode lifecycle owns
/// assignment; update and presentation owners receive the read-only seam.
/// </summary>
internal sealed class LocalPlayerControllerSlot
: ILocalPlayerControllerSource,
ILocalPlayerMotionSource
{
private PlayerMovementController? _preparingMotionOwner;
public PlayerMovementController? Controller { get; set; }
/// <summary>
/// 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 <see cref="Controller"/>.
/// </summary>
MotionInterpreter? ILocalPlayerMotionSource.Motion =>
_preparingMotionOwner?.Motion ?? Controller?.Motion;
/// <summary>
/// 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.
/// </summary>
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 internal interface ILocalPlayerPhysicsHostSource
{ {
EntityPhysicsHost? Host { get; } EntityPhysicsHost? Host { get; }

View file

@ -3,23 +3,6 @@ using AcDream.Core.Net;
namespace AcDream.App.Input; 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 internal sealed class MovementTruthDiagnosticController
: IMovementTruthDiagnosticSink : IMovementTruthDiagnosticSink
{ {
@ -129,33 +112,3 @@ internal sealed class MovementTruthDiagnosticController
? FormattableString.Invariant($"0x{command.Value:X8}") ? FormattableString.Invariant($"0x{command.Value:X8}")
: "-"; : "-";
} }
internal sealed class DelegateMovementTruthDiagnosticSink
: IMovementTruthDiagnosticSink
{
private readonly Action<string, uint, MovementResult, Vector3, uint, byte>
_outbound;
public DelegateMovementTruthDiagnosticSink(
Action<string, uint, MovementResult, Vector3, uint, byte> 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()
{
}
}

View file

@ -256,7 +256,8 @@ internal sealed class PlayerModeController :
{ {
var controller = new PlayerMovementController( var controller = new PlayerMovementController(
_physics, _physics,
playerRecord.ObjectClock); playerRecord.ObjectClock,
PlayerMovementConstructionOptions.From(_skills.Snapshot));
controller.ApplyPhysicsState(playerRecord.FinalPhysicsState); controller.ApplyPhysicsState(playerRecord.FinalPhysicsState);
// Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one // Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one

View file

@ -162,7 +162,7 @@ public sealed class GameWindow :
private LiveEntityAnimationPresenter _animationPresenter = null!; private LiveEntityAnimationPresenter _animationPresenter = null!;
private readonly AcDream.App.Input.MovementTruthDiagnosticController private readonly AcDream.App.Input.MovementTruthDiagnosticController
_movementTruthDiagnostics; _movementTruthDiagnostics;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; private readonly LocalPlayerOutboundController _localPlayerOutbound;
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new(); private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map; // 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.SceneLightingUboBinding? _sceneLightingUbo;
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer; private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
// Phase B.2: player movement mode. // Phase B.2: player movement mode.
private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new(); private readonly RuntimeLocalPlayerMovementState _playerControllerSlot = new();
private AcDream.App.Input.PlayerMovementController? _playerController private PlayerMovementController? _playerController
=> _playerControllerSlot.Controller; => _playerControllerSlot.Controller;
private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new(); private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new();
private AcDream.App.Rendering.ChaseCamera? _chaseCamera private AcDream.App.Rendering.ChaseCamera? _chaseCamera
@ -586,6 +586,7 @@ public sealed class GameWindow :
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools), new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
_retainedInputCapture); _retainedInputCapture);
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource( _movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
_playerControllerSlot,
_inputCapture); _inputCapture);
_framebufferResize = new FramebufferResizeController(_viewportAspect); _framebufferResize = new FramebufferResizeController(_viewportAspect);
_datDir = options.DatDir; _datDir = options.DatDir;
@ -620,7 +621,7 @@ public sealed class GameWindow :
options.DumpMoveTruth, options.DumpMoveTruth,
_playerControllerSlot, _playerControllerSlot,
_localPlayerIdentity); _localPlayerIdentity);
_localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController( _localPlayerOutbound = new LocalPlayerOutboundController(
_movementTruthDiagnostics); _movementTruthDiagnostics);
} }
@ -1587,6 +1588,7 @@ public sealed class GameWindow :
_runtimeCharacter, _runtimeCharacter,
_runtimeCommunication, _runtimeCommunication,
_runtimeActions, _runtimeActions,
_playerControllerSlot,
_renderSceneShadow, _renderSceneShadow,
_livePresentationBindings, _livePresentationBindings,
_entityEffectAdvance, _entityEffectAdvance,

View file

@ -88,6 +88,7 @@ internal sealed record LiveShutdownRoots(
RuntimeCharacterState Character, RuntimeCharacterState Character,
RuntimeCommunicationState Communication, RuntimeCommunicationState Communication,
RuntimeActionState Actions, RuntimeActionState Actions,
RuntimeLocalPlayerMovementState Movement,
RenderSceneShadowRuntime? RenderSceneShadow, RenderSceneShadowRuntime? RenderSceneShadow,
LivePresentationRuntimeBindings? PresentationBindings, LivePresentationRuntimeBindings? PresentationBindings,
DeferredEntityEffectAdvanceSource EffectAdvance, DeferredEntityEffectAdvanceSource EffectAdvance,
@ -392,6 +393,9 @@ internal static class GameWindowShutdownManifest
]), ]),
new ResourceShutdownStage("runtime entity/object stream", new ResourceShutdownStage("runtime entity/object stream",
[ [
Hard(
"runtime local movement state",
live.Movement.Dispose),
Hard( Hard(
"runtime action state", "runtime action state",
live.Actions.Dispose), live.Actions.Dispose),

View file

@ -38,11 +38,10 @@ internal sealed class CurrentGameRuntimeAdapter
RuntimeCharacterState character, RuntimeCharacterState character,
RuntimeCommunicationState communication, RuntimeCommunicationState communication,
RuntimeActionState actions, RuntimeActionState actions,
ILocalPlayerControllerSource playerController, RuntimeLocalPlayerMovementState movement,
WorldRevealCoordinator worldReveal, WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock, IGameRuntimeClock clock,
SelectionInteractionController selection, SelectionInteractionController selection)
GameplayInputFrameController gameplayInput)
{ {
_view = new CurrentGameRuntimeViewAdapter( _view = new CurrentGameRuntimeViewAdapter(
session, session,
@ -53,7 +52,7 @@ internal sealed class CurrentGameRuntimeAdapter
character, character,
communication, communication,
actions, actions,
playerController, movement,
worldReveal, worldReveal,
clock); clock);
entityObjects.BindEventContext( entityObjects.BindEventContext(
@ -71,8 +70,8 @@ internal sealed class CurrentGameRuntimeAdapter
inventory, inventory,
character, character,
actions, actions,
movement,
selection, selection,
gameplayInput,
_events); _events);
} }

View file

@ -34,8 +34,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
private readonly RuntimeInventoryState _inventory; private readonly RuntimeInventoryState _inventory;
private readonly RuntimeCharacterState _character; private readonly RuntimeCharacterState _character;
private readonly RuntimeActionState _actions; private readonly RuntimeActionState _actions;
private readonly RuntimeLocalPlayerMovementState _movement;
private readonly SelectionInteractionController _selection; private readonly SelectionInteractionController _selection;
private readonly GameplayInputFrameController _gameplayInput;
private readonly ICurrentGameRuntimeEventSink _events; private readonly ICurrentGameRuntimeEventSink _events;
public CurrentGameRuntimeCommandAdapter( public CurrentGameRuntimeCommandAdapter(
@ -46,8 +46,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeInventoryState inventory, RuntimeInventoryState inventory,
RuntimeCharacterState character, RuntimeCharacterState character,
RuntimeActionState actions, RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
SelectionInteractionController selection, SelectionInteractionController selection,
GameplayInputFrameController gameplayInput,
ICurrentGameRuntimeEventSink events) ICurrentGameRuntimeEventSink events)
{ {
_session = session ?? throw new ArgumentNullException(nameof(session)); _session = session ?? throw new ArgumentNullException(nameof(session));
@ -58,9 +58,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
_character = character ?? throw new ArgumentNullException(nameof(character)); _character = character ?? throw new ArgumentNullException(nameof(character));
_actions = actions _actions = actions
?? throw new ArgumentNullException(nameof(actions)); ?? throw new ArgumentNullException(nameof(actions));
_movement = movement
?? throw new ArgumentNullException(nameof(movement));
_selection = selection ?? throw new ArgumentNullException(nameof(selection)); _selection = selection ?? throw new ArgumentNullException(nameof(selection));
_gameplayInput = gameplayInput
?? throw new ArgumentNullException(nameof(gameplayInput));
_events = events ?? throw new ArgumentNullException(nameof(events)); _events = events ?? throw new ArgumentNullException(nameof(events));
} }
@ -246,22 +246,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
if (gate != RuntimeCommandStatus.Accepted) if (gate != RuntimeCommandStatus.Accepted)
return Result(gate); return Result(gate);
InputAction action = command switch RuntimeCommandStatus status = _movement.Execute(command)
{ ? RuntimeCommandStatus.Accepted
RuntimeMovementCommand.ToggleRunLock => InputAction.MovementRunLock, : RuntimeCommandStatus.Unsupported;
RuntimeMovementCommand.Stop => InputAction.MovementStop,
_ => InputAction.None,
};
RuntimeCommandStatus status;
if (action == InputAction.None)
{
status = RuntimeCommandStatus.Unsupported;
}
else
{
_gameplayInput.HandlePressedMovementAction(action);
status = RuntimeCommandStatus.Accepted;
}
_events.EmitCommand(RuntimeCommandDomain.Movement, (int)command, status); _events.EmitCommand(RuntimeCommandDomain.Movement, (int)command, status);
return Result(status); return Result(status);

View file

@ -29,7 +29,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
private readonly IRuntimeSocialView _socialView; private readonly IRuntimeSocialView _socialView;
private readonly IRuntimeChatView _chatView; private readonly IRuntimeChatView _chatView;
private readonly IRuntimeActionView _actionView; private readonly IRuntimeActionView _actionView;
private readonly MovementView _movementView; private readonly IRuntimeMovementView _movementView;
private readonly PortalView _portalView; private readonly PortalView _portalView;
private bool _active = true; private bool _active = true;
@ -42,7 +42,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
RuntimeCharacterState character, RuntimeCharacterState character,
RuntimeCommunicationState communication, RuntimeCommunicationState communication,
RuntimeActionState actions, RuntimeActionState actions,
ILocalPlayerControllerSource playerController, RuntimeLocalPlayerMovementState movement,
WorldRevealCoordinator worldReveal, WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock) IGameRuntimeClock clock)
{ {
@ -65,10 +65,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_socialView = communication.SocialView; _socialView = communication.SocialView;
_actionView = ( _actionView = (
actions ?? throw new ArgumentNullException(nameof(actions))).View; actions ?? throw new ArgumentNullException(nameof(actions))).View;
_movementView = new MovementView( _movementView = (
playerController movement ?? throw new ArgumentNullException(nameof(movement))).View;
?? throw new ArgumentNullException(nameof(playerController)),
_clock);
_portalView = new PortalView( _portalView = new PortalView(
worldReveal ?? throw new ArgumentNullException(nameof(worldReveal))); worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)));
} }
@ -132,35 +130,6 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
internal void Deactivate() => _active = false; 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) private sealed class PortalView(WorldRevealCoordinator owner)
: IRuntimePortalView : IRuntimePortalView
{ {

View file

@ -9,7 +9,8 @@ namespace AcDream.Core.Physics;
/// ///
/// <para> /// <para>
/// L.3a (2026-04-30): added optional collision-normal fields so the /// L.3a (2026-04-30): added optional collision-normal fields so the
/// caller (typically <see cref="AcDream.App.Input.PlayerMovementController"/>) /// caller (typically
/// <see cref="AcDream.Runtime.Gameplay.PlayerMovementController"/>)
/// can apply retail's velocity-reflection bounce /// can apply retail's velocity-reflection bounce
/// (<c>v_new = v - (1 + elasticity) * dot(v, n) * n</c>) to the /// (<c>v_new = v - (1 + elasticity) * dot(v, n) * n</c>) to the
/// PhysicsBody after the geometric resolve completes. ACE port mirror: /// PhysicsBody after the geometric resolve completes. ACE port mirror:

View file

@ -9,6 +9,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="AcDream.Runtime.Tests" /> <InternalsVisibleTo Include="AcDream.Runtime.Tests" />
<InternalsVisibleTo Include="AcDream.App" />
<InternalsVisibleTo Include="AcDream.App.Tests" />
<InternalsVisibleTo Include="AcDream.Core.Tests" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" /> <ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />

View file

@ -73,7 +73,9 @@ public readonly record struct RuntimeMovementSnapshot(
Position Position, Position Position,
System.Numerics.Vector3 Velocity, System.Numerics.Vector3 Velocity,
bool IsAirborne, bool IsAirborne,
double SimulationTimeSeconds); double SimulationTimeSeconds,
long Revision = 0,
bool AutoRunActive = false);
public interface IRuntimeMovementView public interface IRuntimeMovementView
{ {

View file

@ -3,7 +3,24 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; 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();
}
/// <summary> /// <summary>
/// Serializes input-originated output from the object-phase result and the /// 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<string, uint, MovementResult, Vector3, uint, byte>
_outbound;
public DelegateMovementTruthDiagnosticSink(
Action<string, uint, MovementResult, Vector3, uint, byte> 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()
{
}
}

View file

@ -2,7 +2,7 @@ using System;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
namespace AcDream.App.Input; namespace AcDream.Runtime.Gameplay;
/// <summary> /// <summary>
/// Input state for a single frame of player movement. /// Input state for a single frame of player movement.
@ -18,6 +18,29 @@ public readonly record struct MovementInput(
float MouseDeltaX = 0f, float MouseDeltaX = 0f,
bool Jump = false); bool Jump = false);
/// <summary>
/// Typed construction policy for the local movement owner. Server-authoritative
/// values come from <see cref="RuntimeMovementSkillState"/>; the fallback only
/// preserves the pre-description test/login baseline and never reads process
/// environment state.
/// </summary>
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);
}
/// <summary> /// <summary>
/// Read-only presentation snapshot of retail's pending jump build. The movement /// Read-only presentation snapshot of retail's pending jump build. The movement
/// controller remains the sole owner of charge timing; retained UI only projects /// controller remains the sole owner of charge timing; retained UI only projects
@ -456,6 +479,14 @@ public sealed class PlayerMovementController
public PlayerMovementController( public PlayerMovementController(
PhysicsEngine physics, PhysicsEngine physics,
RetailObjectQuantumClock? objectClock = null) RetailObjectQuantumClock? objectClock = null)
: this(physics, objectClock, PlayerMovementConstructionOptions.Fallback)
{
}
public PlayerMovementController(
PhysicsEngine physics,
RetailObjectQuantumClock? objectClock,
PlayerMovementConstructionOptions options)
{ {
_physics = physics; _physics = physics;
_objectClock = objectClock ?? new RetailObjectQuantumClock(); _objectClock = objectClock ?? new RetailObjectQuantumClock();
@ -467,20 +498,20 @@ public sealed class PlayerMovementController
// Default skills — tuned toward mid-retail feel. Real characters' // Default skills — tuned toward mid-retail feel. Real characters'
// skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow // skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow
// pushes them via SetCharacterSkills once the controller exists // Runtime supplies them through typed construction options and
// (K-fix7; PD arrives at login before auto-entry). These env-var // updates them via SetCharacterSkills when later authoritative
// defaults only cover tests / pre-PD frames: // values arrive.
// ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
// K-fix6 (2026-04-26): bumped default jump skill from 200 → 300. // K-fix6 (2026-04-26): bumped default jump skill from 200 → 300.
// Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1): // Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1):
// skill=200 → 3.01m max (felt too low — user complaint) // skill=200 → 3.01m max (felt too low — user complaint)
// skill=300 → 4.21m max (closer to a typical retail mid-tier // skill=300 → 4.21m max (closer to a typical retail mid-tier
// character's "I can clear that fence" hop) // character's "I can clear that fence" hop)
// Until #7 ships and PlayerDescription gives us the server's real // Until PlayerDescription supplies both values, retain the typed
// skill, this default is the right "feels like retail" baseline. // construction baseline. RuntimeMovementSkillState updates this same
int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200; // PlayerWeenie after authoritative character data arrives.
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300; _weenie = new PlayerWeenie(
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill); runSkill: options.RunSkill,
jumpSkill: options.JumpSkill);
_motion = new MotionInterpreter(_body, _weenie); _motion = new MotionInterpreter(_body, _weenie);
// R5-V5: the MovementManager facade owns the interp from birth // R5-V5: the MovementManager facade owns the interp from birth
// (retail CPhysicsObj::movement_manager); the moveto child binds // (retail CPhysicsObj::movement_manager); the moveto child binds

View file

@ -2,20 +2,22 @@ namespace AcDream.Runtime.Gameplay;
/// <summary> /// <summary>
/// One allocation-free ledger over the gameplay-state lifetime group through /// 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 /// J5.4. It contains no state of its own; graphical and no-window hosts capture
/// the same four canonical owners. /// the same canonical owners.
/// </summary> /// </summary>
public readonly record struct RuntimeGameplayOwnershipSnapshot( public readonly record struct RuntimeGameplayOwnershipSnapshot(
RuntimeInventoryOwnershipSnapshot Inventory, RuntimeInventoryOwnershipSnapshot Inventory,
RuntimeCharacterOwnershipSnapshot Character, RuntimeCharacterOwnershipSnapshot Character,
RuntimeCommunicationOwnershipSnapshot Communication, RuntimeCommunicationOwnershipSnapshot Communication,
RuntimeActionOwnershipSnapshot Actions) RuntimeActionOwnershipSnapshot Actions,
RuntimeLocalMovementOwnershipSnapshot Movement)
{ {
public bool IsConverged => public bool IsConverged =>
Inventory.IsConverged Inventory.IsConverged
&& Character.IsConverged && Character.IsConverged
&& Communication.IsConverged && Communication.IsConverged
&& Actions.IsConverged; && Actions.IsConverged
&& Movement.IsConverged;
} }
public static class RuntimeGameplayOwnership public static class RuntimeGameplayOwnership
@ -24,16 +26,19 @@ public static class RuntimeGameplayOwnership
RuntimeInventoryState inventory, RuntimeInventoryState inventory,
RuntimeCharacterState character, RuntimeCharacterState character,
RuntimeCommunicationState communication, RuntimeCommunicationState communication,
RuntimeActionState actions) RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement)
{ {
ArgumentNullException.ThrowIfNull(inventory); ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(character); ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(communication); ArgumentNullException.ThrowIfNull(communication);
ArgumentNullException.ThrowIfNull(actions); ArgumentNullException.ThrowIfNull(actions);
ArgumentNullException.ThrowIfNull(movement);
return new RuntimeGameplayOwnershipSnapshot( return new RuntimeGameplayOwnershipSnapshot(
inventory.CaptureOwnership(), inventory.CaptureOwnership(),
character.CaptureOwnership(), character.CaptureOwnership(),
communication.CaptureOwnership(), communication.CaptureOwnership(),
actions.CaptureOwnership()); actions.CaptureOwnership(),
movement.CaptureOwnership());
} }
} }

View file

@ -0,0 +1,200 @@
using AcDream.Core.Physics;
namespace AcDream.Runtime.Gameplay;
public interface IRuntimeLocalPlayerControllerSource
{
PlayerMovementController? Controller { get; }
}
public interface IRuntimeLocalPlayerMotionSource
{
MotionInterpreter? Motion { get; }
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Opens the retail construction seam where a PartArray completion can
/// reach the candidate MotionInterpreter before the controller is
/// published to ordinary borrowers.
/// </summary>
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;
}

View file

@ -96,7 +96,8 @@ public sealed class HostInputCameraCompositionTests
ViewportAspect = new ViewportAspectState(); ViewportAspect = new ViewportAspectState();
Framebuffer = new FramebufferResizeController(ViewportAspect); Framebuffer = new FramebufferResizeController(ViewportAspect);
Capture = new CaptureSource(); Capture = new CaptureSource();
Movement = new DispatcherMovementInputSource(Capture); MovementState = new RuntimeLocalPlayerMovementState();
Movement = new DispatcherMovementInputSource(MovementState, Capture);
CameraInput = new DispatcherCameraInputSource(); CameraInput = new DispatcherCameraInputSource();
PlayerMode = new LocalPlayerModeState(); PlayerMode = new LocalPlayerModeState();
Chase = new ChaseCameraInputState(); Chase = new ChaseCameraInputState();
@ -111,6 +112,7 @@ public sealed class HostInputCameraCompositionTests
public ViewportAspectState ViewportAspect { get; } public ViewportAspectState ViewportAspect { get; }
public FramebufferResizeController Framebuffer { get; } public FramebufferResizeController Framebuffer { get; }
public CaptureSource Capture { get; } public CaptureSource Capture { get; }
public RuntimeLocalPlayerMovementState MovementState { get; }
public DispatcherMovementInputSource Movement { get; } public DispatcherMovementInputSource Movement { get; }
public DispatcherCameraInputSource CameraInput { get; } public DispatcherCameraInputSource CameraInput { get; }
public LocalPlayerModeState PlayerMode { get; } public LocalPlayerModeState PlayerMode { get; }
@ -141,7 +143,11 @@ public sealed class HostInputCameraCompositionTests
throw new InvalidOperationException($"fault at {point}"); throw new InvalidOperationException($"fault at {point}");
}); });
public void Dispose() => Publication.Dispose(); public void Dispose()
{
Publication.Dispose();
MovementState.Dispose();
}
} }
private sealed class Publication : private sealed class Publication :

View file

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

View file

@ -76,7 +76,7 @@ public sealed class CameraPointerInputControllerTests
var mouseLook = new MouseLook(active: true); var mouseLook = new MouseLook(active: true);
var frame = new GameplayInputFrameController( var frame = new GameplayInputFrameController(
dispatcher: null, dispatcher: null,
new DispatcherMovementInputSource(), new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook, mouseLook,
new Combat()); new Combat());
@ -113,7 +113,7 @@ public sealed class CameraPointerInputControllerTests
var mouseLook = new MouseLook(active: true); var mouseLook = new MouseLook(active: true);
var frame = new GameplayInputFrameController( var frame = new GameplayInputFrameController(
dispatcher: null, dispatcher: null,
new DispatcherMovementInputSource(), new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook, mouseLook,
new Combat()); new Combat());
fixture.Owner.BindGameplayFrame(frame); fixture.Owner.BindGameplayFrame(frame);

View file

@ -9,7 +9,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact] [Fact]
public void UnboundSourceCapturesNeutralInput() public void UnboundSourceCapturesNeutralInput()
{ {
var source = new DispatcherMovementInputSource(); var source = CreateSource();
Assert.Equal(default, source.Capture()); Assert.Equal(default, source.Capture());
} }
@ -18,7 +18,7 @@ public sealed class DispatcherMovementInputSourceTests
public void CapturesDispatcherHeldStateAndRetailWalkModifier() public void CapturesDispatcherHeldStateAndRetailWalkModifier()
{ {
var (dispatcher, _, _) = CreateDispatcher(); var (dispatcher, _, _) = CreateDispatcher();
var source = new DispatcherMovementInputSource(); var source = CreateSource();
source.Bind(dispatcher); source.Bind(dispatcher);
Assert.True(dispatcher.TrySetAutomationActionHeld( Assert.True(dispatcher.TrySetAutomationActionHeld(
@ -38,7 +38,7 @@ public sealed class DispatcherMovementInputSourceTests
public void RetainedKeyboardCaptureSilencesHeldKeysButDoesNotCancelAutorun() public void RetainedKeyboardCaptureSilencesHeldKeysButDoesNotCancelAutorun()
{ {
var (dispatcher, _, mouse) = CreateDispatcher(); var (dispatcher, _, mouse) = CreateDispatcher();
var source = new DispatcherMovementInputSource(); var source = CreateSource();
source.Bind(dispatcher); source.Bind(dispatcher);
dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true); dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
Assert.True(source.HandlePressedAction(InputAction.MovementRunLock)); Assert.True(source.HandlePressedAction(InputAction.MovementRunLock));
@ -59,7 +59,7 @@ public sealed class DispatcherMovementInputSourceTests
{ {
var capture = new FakeCapture(); var capture = new FakeCapture();
var (dispatcher, _, _) = CreateDispatcher(); var (dispatcher, _, _) = CreateDispatcher();
var source = new DispatcherMovementInputSource(capture); var source = CreateSource(capture);
source.Bind(dispatcher); source.Bind(dispatcher);
source.HandlePressedAction(InputAction.MovementRunLock); source.HandlePressedAction(InputAction.MovementRunLock);
@ -78,7 +78,7 @@ public sealed class DispatcherMovementInputSourceTests
[InlineData(InputAction.MovementStrafeRight)] [InlineData(InputAction.MovementStrafeRight)]
public void RetailCancelActionsClearAutorun(InputAction action) public void RetailCancelActionsClearAutorun(InputAction action)
{ {
var source = new DispatcherMovementInputSource(); var source = CreateSource();
source.HandlePressedAction(InputAction.MovementRunLock); source.HandlePressedAction(InputAction.MovementRunLock);
Assert.False(source.HandlePressedAction(action)); Assert.False(source.HandlePressedAction(action));
@ -89,7 +89,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact] [Fact]
public void ForwardDoesNotCancelAutorunAndResetDoes() public void ForwardDoesNotCancelAutorunAndResetDoes()
{ {
var source = new DispatcherMovementInputSource(); var source = CreateSource();
source.HandlePressedAction(InputAction.MovementRunLock); source.HandlePressedAction(InputAction.MovementRunLock);
Assert.False(source.HandlePressedAction(InputAction.MovementForward)); Assert.False(source.HandlePressedAction(InputAction.MovementForward));
@ -102,7 +102,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact] [Fact]
public void BindingIsIdempotentOnlyForTheSameDispatcher() public void BindingIsIdempotentOnlyForTheSameDispatcher()
{ {
var source = new DispatcherMovementInputSource(); var source = CreateSource();
var (first, _, _) = CreateDispatcher(); var (first, _, _) = CreateDispatcher();
var (second, _, _) = CreateDispatcher(); var (second, _, _) = CreateDispatcher();
@ -115,7 +115,7 @@ public sealed class DispatcherMovementInputSourceTests
[Fact] [Fact]
public void UnbindRequiresExactDispatcherAndRestoresNeutralCapture() public void UnbindRequiresExactDispatcherAndRestoresNeutralCapture()
{ {
var source = new DispatcherMovementInputSource(); var source = CreateSource();
var (first, _, _) = CreateDispatcher(); var (first, _, _) = CreateDispatcher();
var (other, _, _) = CreateDispatcher(); var (other, _, _) = CreateDispatcher();
source.Bind(first); source.Bind(first);
@ -142,6 +142,10 @@ public sealed class DispatcherMovementInputSourceTests
return (dispatcher, keyboard, mouse); return (dispatcher, keyboard, mouse);
} }
private static DispatcherMovementInputSource CreateSource(
IInputCaptureSource? capture = null) =>
new(new RuntimeLocalPlayerMovementState(), capture);
private sealed class FakeKeyboard : IKeyboardSource private sealed class FakeKeyboard : IKeyboardSource
{ {
#pragma warning disable CS0067 #pragma warning disable CS0067

View file

@ -30,7 +30,7 @@ public sealed class GameplayInputFrameControllerTests
var combat = new FakeCombat(calls); var combat = new FakeCombat(calls);
var controller = new GameplayInputFrameController( var controller = new GameplayInputFrameController(
dispatcher, dispatcher,
new DispatcherMovementInputSource(), new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook, mouseLook,
combat); combat);
@ -45,7 +45,7 @@ public sealed class GameplayInputFrameControllerTests
var calls = new List<string>(); var calls = new List<string>();
var controller = new GameplayInputFrameController( var controller = new GameplayInputFrameController(
dispatcher: null, dispatcher: null,
new DispatcherMovementInputSource(), new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook: null, mouseLook: null,
new FakeCombat(calls)); new FakeCombat(calls));
@ -60,7 +60,7 @@ public sealed class GameplayInputFrameControllerTests
var calls = new List<string>(); var calls = new List<string>();
var controller = new GameplayInputFrameController( var controller = new GameplayInputFrameController(
dispatcher: null, dispatcher: null,
new DispatcherMovementInputSource(), new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook: null, mouseLook: null,
new FakeCombat(calls, consumes: true)); new FakeCombat(calls, consumes: true));
@ -76,7 +76,8 @@ public sealed class GameplayInputFrameControllerTests
public void ResetSessionReleasesMouseLookAndAutorun() public void ResetSessionReleasesMouseLookAndAutorun()
{ {
var calls = new List<string>(); var calls = new List<string>();
var movement = new DispatcherMovementInputSource(); var movement = new DispatcherMovementInputSource(
new RuntimeLocalPlayerMovementState());
movement.HandlePressedAction(InputAction.MovementRunLock); movement.HandlePressedAction(InputAction.MovementRunLock);
var mouseLook = new FakeMouseLook(calls); var mouseLook = new FakeMouseLook(calls);
var controller = new GameplayInputFrameController( var controller = new GameplayInputFrameController(
@ -98,7 +99,7 @@ public sealed class GameplayInputFrameControllerTests
var mouseLook = new FakeMouseLook(calls, active: true, consumes: true); var mouseLook = new FakeMouseLook(calls, active: true, consumes: true);
var controller = new GameplayInputFrameController( var controller = new GameplayInputFrameController(
dispatcher: null, dispatcher: null,
new DispatcherMovementInputSource(), new DispatcherMovementInputSource(new RuntimeLocalPlayerMovementState()),
mouseLook, mouseLook,
new FakeCombat(calls)); new FakeCombat(calls));

View file

@ -782,7 +782,8 @@ public sealed class CurrentGameRuntimeAdapterTests
CombatMode = new RecordingCombatModeOperations(); CombatMode = new RecordingCombatModeOperations();
_combatModeBinding = _combatModeBinding =
CombatModeOperations.BindOwned(CombatMode); CombatModeOperations.BindOwned(CombatMode);
MovementInput = new DispatcherMovementInputSource(); MovementState = new RuntimeLocalPlayerMovementState();
MovementInput = new DispatcherMovementInputSource(MovementState);
GameplayInput = new GameplayInputFrameController( GameplayInput = new GameplayInputFrameController(
dispatcher: null, dispatcher: null,
MovementInput, MovementInput,
@ -832,11 +833,10 @@ public sealed class CurrentGameRuntimeAdapterTests
Character, Character,
Communication, Communication,
Actions, Actions,
new LocalPlayerControllerSlot(), MovementState,
WorldReveal, WorldReveal,
Clock, Clock,
selectionController, selectionController);
GameplayInput);
} }
public RuntimeOptions Options { get; } public RuntimeOptions Options { get; }
@ -853,6 +853,7 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeSpellCastOperationsSlot SpellCastOperations { get; } public RuntimeSpellCastOperationsSlot SpellCastOperations { get; }
public RuntimeActionState Actions { get; } public RuntimeActionState Actions { get; }
public SelectionState Selection => Actions.Selection; public SelectionState Selection => Actions.Selection;
public RuntimeLocalPlayerMovementState MovementState { get; }
public DispatcherMovementInputSource MovementInput { get; } public DispatcherMovementInputSource MovementInput { get; }
public GameplayInputFrameController GameplayInput { get; } public GameplayInputFrameController GameplayInput { get; }
public RecordingCombatModeOperations CombatMode { get; } public RecordingCombatModeOperations CombatMode { get; }
@ -874,6 +875,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Actions.Dispose(); Actions.Dispose();
InventoryState.Dispose(); InventoryState.Dispose();
Entities.Clear(); Entities.Clear();
MovementState.Dispose();
} }
} }

View file

@ -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<Match>());
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.");
}
}

View file

@ -625,11 +625,11 @@ public sealed class UpdateFrameOrchestratorTests
== typeof(AcDream.App.Interaction.SelectionInteractionController)); == typeof(AcDream.App.Interaction.SelectionInteractionController));
FieldInfo outboundDiagnostics = Assert.Single( FieldInfo outboundDiagnostics = Assert.Single(
typeof(AcDream.App.Input.LocalPlayerOutboundController).GetFields( typeof(LocalPlayerOutboundController).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic), BindingFlags.Instance | BindingFlags.NonPublic),
field => field.Name == "_diagnostic"); field => field.Name == "_diagnostic");
Assert.Equal( Assert.Equal(
typeof(AcDream.App.Input.IMovementTruthDiagnosticSink), typeof(IMovementTruthDiagnosticSink),
outboundDiagnostics.FieldType); outboundDiagnostics.FieldType);
Assert.DoesNotContain( Assert.DoesNotContain(
typeof(AcDream.App.Input.MovementTruthDiagnosticController).GetFields( typeof(AcDream.App.Input.MovementTruthDiagnosticController).GetFields(

View file

@ -0,0 +1 @@
global using AcDream.Runtime.Gameplay;

View file

@ -1,12 +1,12 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Net; using System.Net;
using System.Numerics; using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.Input; namespace AcDream.Runtime.Tests.Gameplay;
public sealed class LocalPlayerImmediatePositionTests public sealed class LocalPlayerImmediatePositionTests
{ {

View file

@ -1,10 +1,10 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using AcDream.App.Input;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets; using AcDream.Core.Net.Packets;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.Input; namespace AcDream.Runtime.Tests.Gameplay;
public sealed class LocalPlayerOutboundCombatStyleTests public sealed class LocalPlayerOutboundCombatStyleTests
{ {

View file

@ -1,10 +1,9 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion; 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 public sealed class PlayerMouseLookMovementTests
{ {

View file

@ -1,10 +1,9 @@
using System; using System;
using System.Numerics; using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using Xunit; using AcDream.Runtime.Gameplay;
namespace AcDream.Core.Tests.Input; namespace AcDream.Runtime.Tests.Gameplay;
public class PlayerMovementControllerTests public class PlayerMovementControllerTests
{ {

View file

@ -1,9 +1,8 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using Xunit; using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.Physics; namespace AcDream.Runtime.Tests.Gameplay;
public sealed class PlayerOutboundPositionTests public sealed class PlayerOutboundPositionTests
{ {

View file

@ -9,13 +9,15 @@ namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeGameplayOwnershipTests public sealed class RuntimeGameplayOwnershipTests
{ {
[Fact] [Fact]
public void CombinedLedgerConvergesEveryJ4OwnerAndSubscription() public void CombinedLedgerConvergesEveryGameplayOwnerAndSubscription()
{ {
using var entities = new RuntimeEntityObjectLifetime(); using var entities = new RuntimeEntityObjectLifetime();
var inventory = new RuntimeInventoryState(entities); var inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState(); var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState(); var communication = new RuntimeCommunicationState();
var actions = RuntimeActionTestFactory.Create(inventory.Transactions); var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
var movement = new RuntimeLocalPlayerMovementState();
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
inventory.Shortcuts.Changed += static () => { }; inventory.Shortcuts.Changed += static () => { };
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true); inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true);
@ -73,7 +75,8 @@ public sealed class RuntimeGameplayOwnershipTests
inventory, inventory,
character, character,
communication, communication,
actions); actions,
movement);
Assert.False(populated.IsConverged); Assert.False(populated.IsConverged);
Assert.Equal(1, populated.Inventory.ShortcutCount); Assert.Equal(1, populated.Inventory.ShortcutCount);
@ -86,7 +89,9 @@ public sealed class RuntimeGameplayOwnershipTests
AcDream.Core.Combat.CombatMode.Melee, AcDream.Core.Combat.CombatMode.Melee,
populated.Actions.CombatMode); populated.Actions.CombatMode);
Assert.Equal(1, populated.Actions.TrackedTargetHealthCount); Assert.Equal(1, populated.Actions.TrackedTargetHealthCount);
Assert.True(populated.Movement.AutoRunActive);
movement.Dispose();
actions.Dispose(); actions.Dispose();
communication.Dispose(); communication.Dispose();
character.Dispose(); character.Dispose();
@ -98,7 +103,8 @@ public sealed class RuntimeGameplayOwnershipTests
inventory, inventory,
character, character,
communication, communication,
actions); actions,
movement);
Assert.True(retired.IsConverged); Assert.True(retired.IsConverged);
Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount); Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount);
@ -108,13 +114,14 @@ public sealed class RuntimeGameplayOwnershipTests
} }
[Fact] [Fact]
public void BorrowerFailuresCannotStrandAnyJ4OwnerDuringTerminalDisposal() public void BorrowerFailuresCannotStrandAnyGameplayOwnerDuringTerminalDisposal()
{ {
using var entities = new RuntimeEntityObjectLifetime(); using var entities = new RuntimeEntityObjectLifetime();
var inventory = new RuntimeInventoryState(entities); var inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState(); var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState(); var communication = new RuntimeCommunicationState();
var actions = RuntimeActionTestFactory.Create(inventory.Transactions); var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
var movement = new RuntimeLocalPlayerMovementState();
inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u);
@ -132,6 +139,7 @@ public sealed class RuntimeGameplayOwnershipTests
communication.Chat.OnSystemMessage("failure-isolated event", 0u); communication.Chat.OnSystemMessage("failure-isolated event", 0u);
Assert.Equal(1, communication.DispatchFailureCount); Assert.Equal(1, communication.DispatchFailureCount);
movement.Dispose();
actions.Dispose(); actions.Dispose();
Assert.Throws<AggregateException>(inventory.Dispose); Assert.Throws<AggregateException>(inventory.Dispose);
Assert.Throws<AggregateException>(character.Dispose); Assert.Throws<AggregateException>(character.Dispose);
@ -142,7 +150,8 @@ public sealed class RuntimeGameplayOwnershipTests
inventory, inventory,
character, character,
communication, communication,
actions); actions,
movement);
Assert.True(retired.IsConverged); Assert.True(retired.IsConverged);
Assert.Equal(1, retired.Communication.DispatchFailureCount); Assert.Equal(1, retired.Communication.DispatchFailureCount);

View file

@ -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<ObjectDisposedException>(
() => 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);
}
}