diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index d294e6c6..f6cb0796 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -86,8 +86,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:1212` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
-| AD-26 | Auto-walk arrival requires facing alignment (invented 5° arrive / 30° walk-while-turning bands); retail's check is `dist <= radius` exact | `src/AcDream.App/Input/PlayerMovementController.cs:575` | ACE does the final `Rotate(target)` server-side before the Use callback; without a local gate the body used items while facing away (user feedback 2026-05-15). Thresholds are NOT retail constants | Arrival delayed by the rotation phase; if heading convergence fights another yaw writer, `AutoWalkArrived` never fires and the queued Use/PickUp never completes | `MoveToManager::HandleMoveToPosition`; `apply_interpreted_movement` |
-| AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius |
+| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
@@ -124,7 +123,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
| AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1−SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive → D3DBLEND_ONE per-surface routing |
| 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/Rendering/GameWindow.cs:3250` | 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 use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + speculative turn-to-target | `src/AcDream.App/Rendering/GameWindow.cs:11120` | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required (B.6) | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for an auto-walk that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius |
+| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b, but with the object's real radius) | `src/AcDream.App/Rendering/GameWindow.cs:12274` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, 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 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:170` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) |
| 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 |
@@ -178,7 +177,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 |
| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) |
| AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) |
-| AP-79 | **P4 TargetTracker minimal adapter**: MoveToManager's setTarget/clearTarget seams store the tracked guid on RemoteMotion; GameWindow's per-tick block feeds `HandleUpdateTarget(Ok)` from the live entity table whenever the target moved beyond the set_target radius (and `ExitWorld` when the entity despawns) — retail's TargetManager is a full subscription system with per-target quantums and callback scheduling (R4-V4, 2026-07-03) | `src/AcDream.App/Rendering/GameWindow.cs` (the tracker block in TickAnimations + the setTarget seam in EnsureRemoteMotionBindings) | The manager's deferred-start/retarget lifecycle only needs position deltas at the tracked radius; the adapter delivers exactly that from data the client already holds | Update cadence is frame-quantized rather than quantum-scheduled; a target moving sub-radius never re-fires (retail same); multi-listener semantics absent until R5's TargetManager port | `TargetManager::ReceiveUpdate` chain (r4-moveto-decomp.md); retire in R5 |
+| AP-79 | **P4 TargetTracker minimal adapter**: MoveToManager's setTarget/clearTarget seams store the tracked guid on RemoteMotion (remotes, R4-V4) or the `_playerMoveToTarget*` GameWindow fields (local player, R4-V5); GameWindow's per-tick block (remotes) / pre-Update feed (player) delivers `HandleUpdateTarget(Ok)` from the live entity table whenever the target moved beyond the set_target radius (and `ExitWorld` when the entity despawns) — retail's TargetManager is a full subscription system with per-target quantums and callback scheduling (R4-V4 + R4-V5, 2026-07-03) | `src/AcDream.App/Rendering/GameWindow.cs` (the tracker block in TickAnimations + the setTarget seam in EnsureRemoteMotionBindings; the player twin: `_playerMoveToTarget*` fields, the EnterPlayerModeNow setTarget seam, and the pre-Update feed in OnUpdateFrame) | The manager's deferred-start/retarget lifecycle only needs position deltas at the tracked radius; the adapter delivers exactly that from data the client already holds | Update cadence is frame-quantized rather than quantum-scheduled; a target moving sub-radius never re-fires (retail same); multi-listener semantics absent until R5's TargetManager port | `TargetManager::ReceiveUpdate` chain (r4-moveto-decomp.md); retire in R5 |
| 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 (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); 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) |
## 4. Temporary stopgap (TS) — 36 rows (TS-37 is a retired-row historical note, not an active count)
@@ -216,9 +215,8 @@ accepted-divergence entries (#96, #49, #50).
| TS-30 | Numbered chat tabs (element ids `0x10000522`–`0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling |
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
-| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping) is a dedicated follow-up slice | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0 |
+| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change), AND acdream's frame-changed diff compares POSITION only (`ApproxPositionEqual`) where retail's `Frame::is_equal` compares the full frame incl. ORIENTATION — a stationary heading change (R4-V5: the MoveToManager's `HandleTurnToHeading` arrival snap, `set_heading(send:true)`) never triggers an AP, so the server keeps the stale facing until the player next moves. Masked against ACE (ACE rotates server-side on its own mt-8/9 / close-range-use paths and broadcasts the result) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`); `src/AcDream.App/Input/PlayerMovementController.cs` (`ApproxPositionEqual` + the heartbeat diff); the player MoveToManager `setHeading` seam in `EnterPlayerModeNow` (the `send` flag's would-be consumer) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping + full-frame `Frame::is_equal` diff) is a dedicated follow-up slice (R7 outbound) | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends, and a stationary server-commanded turn leaves observers with stale facing until the next movement; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0, `Frame::is_equal` (pc:700263) |
| 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; the WRITE side (per-cell contact-plane constraint tracking — a mover pinned between opposing walkable surfaces / doorway jamming) doesn't exist yet | A body that retail would consider fully constrained (and thus refuse to jump, 0x47) never gets blocked here — jumps succeed in geometry retail would reject. Low practical risk today: the constrained-mover scenario (doorway wedge, opposing-wall pin) is rare and mostly a physics-digest topic already tracked separately | `CPhysicsObj::IsFullyConstrained` 0x0050f730; `jump_is_allowed` 0x005282b0 |
-| TS-36 | `MotionInterpreter.InterruptCurrentMovement` is a no-op `Action?` seam — retail's `CPhysicsObj::interrupt_current_movement` (called unconditionally at the top of `jump()` and inside `StopCompletely`) has no real implementation to call yet | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`InterruptCurrentMovement`, R3-W3; called from `jump()`) | R4 (MoveToManager) is where `cancel_moveto`/in-flight-transition-interrupt actually lands; until then there is no in-flight transition to cancel (no MoveToManager exists), so the no-op is behaviorally inert, not a masked bug | Once MoveToManager exists (R4) and a jump fires mid-auto-walk/mid-moveto without this seam being wired to the real cancel, the queued transition would keep running alongside the new jump — silent double-motion | `CPhysicsObj::interrupt_current_movement`; `jump` 0x00528780 @305800 |
| 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 |
diff --git a/docs/research/2026-07-03-r4-moveto/V0-pins.md b/docs/research/2026-07-03-r4-moveto/V0-pins.md
index d738159b..94a0a039 100644
--- a/docs/research/2026-07-03-r4-moveto/V0-pins.md
+++ b/docs/research/2026-07-03-r4-moveto/V0-pins.md
@@ -293,6 +293,17 @@ the PDB-matched binary (see the pin).
echo the P1 gate will drop. V5 re-anchors run-rate sync to M13 (mt-6/7
`my_run_rate` wire read → `Motion.MyRunRate`) + PlayerDescription — or ships
the contingent AD row quoted in P1.
+ **V5 EXECUTED (2026-07-03): re-anchor taken, NO AD row needed.** Both
+ retail-mechanism feeds already existed: PlayerDescription run/jump skills
+ flow via `SetCharacterSkills` (K-fix7, wired since before V5 — the
+ "we don't parse PD yet" comment in the controller ctor was stale) into
+ `PlayerWeenie.InqRunRate`, which `apply_run_to_command`/`get_state_velocity`
+ PREFER over `MyRunRate`; and V5's shared `RouteServerMoveTo` performs the
+ M13 `Motion.MyRunRate = MoveToRunRate` write for the local player on
+ mt-6/7. `ApplyServerRunRate` was deleted outright — its
+ `InterpretedState.ForwardSpeed` overwrite was a pre-R3 mechanism that
+ fought the ported machinery (any DoMotion edge recomputes ForwardSpeed
+ through adjust_motion/apply_run_to_command).
- **LoseControlToServer seam (R5):** retail hands autonomy to the server at the
0xF74C dispatch whenever a non-autonomous movement event is applied to the
local player (raw 357214–357235). Note for R5/MovementManager — the retail
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index be458af6..61530c2a 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -198,13 +198,6 @@ public sealed class PlayerMovementController
private bool _prevTurnLeftHeld;
private bool _prevTurnRightHeld;
private bool _prevRunHeld;
- private int _prevAutoWalkTurnDir;
-
- // R4-V4: relocated from the DELETED RemoteMoveToDriver — B.6 auto-walk's
- // last two dependencies on it. Both die with auto-walk in R4-V5.
- private const float AutoWalkArrivalEpsilon = 0.05f;
- private static float AutoWalkTurnRateFor(bool running)
- => running ? (MathF.PI / 2.0f) * 1.5f : (MathF.PI / 2.0f); // BaseTurnRate x RunTurnFactor
// Heartbeat timer.
// Cadence is 1.0 sec to match holtburger's
@@ -267,83 +260,31 @@ public sealed class PlayerMovementController
private Vector3 _prevPhysicsPos;
private Vector3 _currPhysicsPos;
- // ── B.6 slice 2 (2026-05-14): local-player server-initiated auto-walk ──
- // When ACE sends a MoveToObject motion for the local player (out-of-range
- // Use / PickUp triggers ACE's server-side CreateMoveToChain), the wire
- // payload includes a destination, arrival predicates, and a run rate.
- // Retail's MovementManager::PerformMovement (0x00524440 case 6) runs a
- // LOCAL auto-walk in response: heading correction toward the target,
- // run-forward velocity at the wire's runRate, arrival detection via
- // MoveToManager::HandleMoveToPosition. Here we keep the active auto-walk
- // state and inject it into Update() as a synthesized Forward+Run input
- // so the existing motion-interpreter / body-velocity pipeline runs
- // unchanged. Spec: docs/superpowers/specs/2026-05-14-phase-b6-design.md.
- private bool _autoWalkActive;
- private Vector3 _autoWalkDestination;
- private float _autoWalkMinDistance;
- private float _autoWalkDistanceToObject;
- private bool _autoWalkMoveTowards;
- // 2026-05-16 (retail-faithful) — walk-vs-run is a ONE-SHOT
- // decision at chain start. Per user observation 2026-05-16: if
- // initial distance is at or above the walk-run threshold, the
- // body runs all the way to the target; otherwise it walks all
- // the way. No per-frame switching as the player closes distance.
- //
- // Formula matches retail's MovementParameters::get_command
- // (decomp 0x0052aa00, line 308000+):
- // running = (initialDist - distance_to_object) >= walk_run_threshhold
- // The "distance left to walk" (current minus use-radius) is
- // compared against the wire-supplied threshold (15m default,
- // retail constant at 0x005243b5). The retail function reads
- // `arg2` as the current distance but in practice is called at
- // chain setup with the initial distance, and the resulting
- // decision is cached for the rest of the chain — matching the
- // user-observed "run all the way / walk all the way" behaviour.
- private bool _autoWalkInitiallyRunning;
+ // ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ──
+ // The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase,
+ // 30° walk-while-turning band, one-shot walk/run decision, invented
+ // arrival epsilon — registers AD-26/AD-8-local) is DELETED; server
+ // MoveTos for the local player now run through the SAME verbatim
+ // MoveToManager remotes use (R4-V4), bound below by GameWindow's
+ // EnterPlayerModeNow beside the R3-W6 DefaultSink bind.
///
- /// True while a server-initiated auto-walk (MoveToObject inbound) is
- /// active on the local player. Update drives the body's velocity
- /// and motion state machine DIRECTLY from the wire-supplied path
- /// data, NOT via synthesized player-input. The
- /// motion-state-change detection downstream sees no user input
- /// during auto-walk, so no MoveToState wire packet is built — ACE's
- /// server-side MoveToChain can run uninterrupted until its callback
- /// fires.
+ /// R4-V5: the local player's verbatim retail MoveToManager
+ /// (decomp 0x00529010-0x0052a987), constructed + seam-bound by
+ /// GameWindow.EnterPlayerModeNow against this controller's
+ /// /body/Yaw (the same wiring shape
+ /// EnsureRemoteMotionBindings uses for remotes). GameWindow
+ /// routes inbound mt 6-9 movement events to
+ /// ;
+ /// ticks UseTime() at the slot the deleted
+ /// DriveServerAutoWalk occupied and relays HitGround()
+ /// (retail order: minterp first, then moveto — MovementManager::HitGround
+ /// 0x00524300). User input cancels a moveto through the retail chain:
+ /// key edge → DoMotion (ctor-default params, CancelMoveTo bit set) →
+ /// →
+ /// CancelMoveTo(ActionCancelled) (register TS-36 retired).
///
- public bool IsServerAutoWalking => _autoWalkActive;
-
- // 2026-05-16 (issue #75) — tracks whether the auto-walk overlay is
- // actually advancing the body this frame. False during the
- // turn-first phase (rotating in place toward target) and after
- // arrival. Drives the animation cycle override: walking animation
- // only plays when the body is actually moving forward.
- // R3-W6: _autoWalkMovingForwardThisFrame deleted with the LocalAnimationCommand override (forward legs come from DriveServerAutoWalk's own DoMotion).
-
- // 2026-05-16 (issue #69 fix) — turn direction this frame.
- // +1 = rotating counter-clockwise (Yaw increasing) → TurnLeft cycle
- // -1 = rotating clockwise (Yaw decreasing) → TurnRight cycle
- // 0 = aligned or not turning
- // Drives the animation cycle override during turn-first phase so
- // the body plays the actual turn animation instead of statue-pivoting.
- private int _autoWalkTurnDirectionThisFrame;
-
- ///
- /// Fires once when an auto-walk reaches its destination naturally
- /// (i.e. called with
- /// reason="arrived"). Does NOT fire on user-input cancel or
- /// on a re-target (BeginServerAutoWalk overwriting state).
- ///
- ///
- /// Host () subscribes to re-send
- /// the Use/PickUp action that triggered the auto-walk — without
- /// this, ACE's server-side MoveToChain may have already timed out
- /// by the time our local body arrives, so the action wouldn't
- /// fire. Re-sending the action close-range hits ACE's WithinUseRadius
- /// fast-path and completes immediately.
- ///
- ///
- public event Action? AutoWalkArrived;
+ public AcDream.Core.Physics.Motion.MoveToManager? MoveTo { get; set; }
public PlayerMovementController(PhysicsEngine physics)
{
@@ -355,8 +296,10 @@ public sealed class PlayerMovementController
};
// Default skills — tuned toward mid-retail feel. Real characters'
- // skills come from PlayerDescription (0xF7B0/0x0013) which we don't
- // parse yet; override via env vars:
+ // skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow
+ // pushes them via SetCharacterSkills once the controller exists
+ // (K-fix7; PD arrives at login before auto-entry). These env-var
+ // defaults only cover tests / pre-PD frames:
// ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
// K-fix6 (2026-04-26): bumped default jump skill from 200 → 300.
// Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1):
@@ -390,6 +333,17 @@ public sealed class PlayerMovementController
///
internal MotionInterpreter Motion => _motion;
+ /// R4-V5: CONTACT transient-state bit (retail
+ /// transient_state & 1) — the manager's
+ /// UseTime tick gate reads exactly this bit (decomp §6a @307781; a
+ /// strict subset of the funnel's Contact+OnWalkable gate).
+ internal bool BodyInContact => _body.InContact;
+
+ /// R4-V5: body orientation for the
+ /// manager's position seam (re-derived from every
+ /// Update — heading reads/writes go through Yaw, not this).
+ internal Quaternion BodyOrientation => _body.Orientation;
+
///
/// Wire the player's AnimationSequencer current cycle velocity into
/// . When attached,
@@ -417,97 +371,6 @@ public sealed class PlayerMovementController
_motion.GetCycleVelocity = accessor;
}
- ///
- /// Apply a server-echoed run rate (ForwardSpeed from UpdateMotion) to the
- /// player's MotionInterpreter. The server broadcasts the real RunRate
- /// derived from the character's Run skill; wiring it here ensures
- /// get_state_velocity produces the correct speed instead of the default 1.0.
- ///
- public void ApplyServerRunRate(float forwardSpeed)
- {
- _motion.InterpretedState.ForwardSpeed = forwardSpeed;
- _motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
- }
-
- ///
- /// B.6 slice 2 (2026-05-14). Install a server-initiated auto-walk
- /// against this body. will synthesize
- /// Forward+Run input and steer toward
- /// until the body reaches the
- /// arrival predicate (moveTowards: dist ≤ distanceToObject;
- /// !moveTowards: dist ≥ minDistance) or the user presses any
- /// movement key (which auto-cancels).
- ///
- ///
- /// Retail reference: MovementManager::PerformMovement
- /// (0x00524440) case 6 — unpacks the wire's target +
- /// origin + run rate and calls CPhysicsObj::MoveToObject on
- /// the local body. We do the equivalent at acdream's altitude:
- /// hold the destination + thresholds + run rate locally, let the
- /// existing per-tick motion machinery do the walking, and arrive
- /// when the horizontal distance hits the threshold.
- ///
- ///
- ///
- /// The run-rate parameter is the EFFECTIVE rate after the
- /// mtRun=0 fallback chain — the caller (GameWindow) is
- /// responsible for substituting a non-zero rate when ACE sends 0.0
- /// on the wire, per the trace finding in the design spec.
- ///
- ///
- public void BeginServerAutoWalk(
- Vector3 destinationWorld,
- float minDistance,
- float distanceToObject,
- bool moveTowards,
- bool canCharge)
- {
- _autoWalkActive = true;
- _autoWalkDestination = destinationWorld;
- _autoWalkMinDistance = minDistance;
- _autoWalkDistanceToObject = distanceToObject;
- _autoWalkMoveTowards = moveTowards;
-
- // Issue #77 fix (2026-05-18) — retail-faithful walk-vs-run.
- //
- // Retail's MovementParameters::get_command (decomp 0x0052aa00)
- // gates run on the CanCharge flag (bit 0x10 of
- // MovementParameters). Cleared → fall through to the inner
- // walk_run_threshold check, which ACE's 15 m wire default +
- // 0.6 m use-radius makes practically always walk for any
- // chase under 15.6 m. Set → unconditional HoldKey_Run.
- //
- // ACE's Creature.SetWalkRunThreshold sets CanCharge when
- // (server-side player→target distance) >= WalkRunThreshold /
- // 2 (= 7.5 m for the 15 m default), and clears it otherwise.
- // The CanCharge bit IS the wire-side walk-vs-run answer; we
- // just relay it.
- //
- // Previously we hardcoded a 1.0 m threshold against
- // initialDist - distanceToObject, which forced run at any
- // chase past ~1.6 m — including the 3-5 m "walk range" the
- // user expected to walk in (issue #77 reproduction). Honoring
- // CanCharge restores the retail bucket: walk under ~7.5 m,
- // run beyond.
- _autoWalkInitiallyRunning = canCharge;
- }
-
- ///
- /// B.6 slice 2 (2026-05-14). Cancel any active server-initiated
- /// auto-walk. Idempotent. is logged when
- /// is on so
- /// the trace shows why the auto-walk ended.
- ///
- public void EndServerAutoWalk(string reason)
- {
- if (!_autoWalkActive) return;
- _autoWalkActive = false;
- if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
- Console.WriteLine($"[autowalk-end] reason={reason}");
- if (reason == "arrived")
- AutoWalkArrived?.Invoke();
- }
-
///
/// 2026-05-16. Called by the network outbound layer after every
/// AutonomousPosition or MoveToState that carries the player's
@@ -531,270 +394,6 @@ public sealed class PlayerMovementController
_lastSentInitialized = true;
}
- ///
- /// B.6 slice 2 (2026-05-14). If a server-initiated auto-walk is
- /// active, either cancel it (user pressed a movement key) or
- /// synthesize a Forward+Run input with stepped
- /// toward the destination. Returns the (possibly modified) input
- /// for the rest of to consume.
- ///
- ///
- /// Heading correction matches the deleted RemoteMoveToDriver.Drive
- /// — ±its 20-degree HeadingSnapTolerance
- /// snap-on-aligned, otherwise rotate at
- /// pi/2 rad/s. Arrival
- /// predicate matches retail's
- /// MoveToManager::HandleMoveToPosition: chase arrives at
- /// distanceToObject; flee arrives at minDistance.
- ///
- ///
- ///
- /// 2026-05-16 (issue #75 refactor) — drive the body directly from
- /// the wire-supplied path data during server-initiated auto-walk,
- /// without synthesizing player-input. Replaces the earlier
- /// ApplyAutoWalkOverlay which returned a synthesized Forward+Run
- /// MovementInput; that synthesis leaked to the wire as an outbound
- /// MoveToState packet ("user is RunForward") which ACE read as
- /// user-took-manual-control and cancelled its own MoveToChain. The
- /// architecture now mirrors retail's MovementManager::PerformMovement
- /// case 6 (decomp 0x00524440): step the body's velocity + motion
- /// state directly; the user-input pipeline downstream sees no input
- /// because the user didn't press anything, so no MoveToState gets
- /// built.
- ///
- ///
- /// Returns true when this method consumed motion control for
- /// the frame (auto-walk active, no user override, no arrival).
- /// Caller () must skip the user-input motion +
- /// body-velocity sections to avoid them overriding the auto-walk's
- /// velocity assignment.
- ///
- ///
- private bool DriveServerAutoWalk(float dt, MovementInput input)
- {
- _autoWalkTurnDirectionThisFrame = 0;
- if (!_autoWalkActive) return false;
-
- // User-input cancellation. Any direct movement key takes over.
- // Mouse-only turning (no movement key) doesn't cancel — the
- // user might just be looking around mid-walk.
- bool userOverride = input.Forward || input.Backward
- || input.StrafeLeft || input.StrafeRight
- || input.TurnLeft || input.TurnRight;
- if (userOverride)
- {
- EndServerAutoWalk("user-input");
- return false;
- }
-
- // Horizontal distance to target — server owns Z, our local body
- // Z snaps to UpdatePosition broadcasts when ACE sends them.
- var pos = _body.Position;
- float dx = _autoWalkDestination.X - pos.X;
- float dy = _autoWalkDestination.Y - pos.Y;
- float dist = MathF.Sqrt(dx * dx + dy * dy);
-
- // Arrival predicate. With the 10 Hz heartbeat from 301281d the
- // server-side Player.Location tracks our body within ~100 ms, so
- // the previous "subtract 0.2 m safety margin" workaround is no
- // longer needed. Tiny 0.05 m margin remains to absorb the
- // sub-tick race between local arrival-fire and the next
- // heartbeat's outbound packet.
- //
- // ARRIVAL IS GATED ON ALIGNMENT: we only end the auto-walk once
- // the body is BOTH within use-radius AND facing the target.
- // Without the alignment gate, a Use on a close target while
- // facing away would end immediately and the body wouldn't turn
- // at all (user feedback 2026-05-15: 'when I'm close I'm not
- // facing'). The alignment check is computed below in the same
- // block as the heading-step; we defer the arrival fire-and-end
- // until after we've inspected `aligned`.
- float arrivalThreshold = _autoWalkMoveTowards
- ? _autoWalkDistanceToObject
- : _autoWalkMinDistance;
- // 2026-05-16 — retail "stop at the radius" semantics.
- // Previously had a 0.05 m TinyMargin inside the threshold to
- // ensure ACE's server-side WithinUseRadius poll saw us inside
- // the radius before our next AP heartbeat. With the
- // diff-driven AP cadence (Task B2) ACE sees the final position
- // the same frame we arrive — no margin needed. Retail's
- // arrival check is `dist <= radius` exact at
- // CMotionInterp::apply_interpreted_movement integration.
- bool withinArrival =
- (_autoWalkMoveTowards
- && dist <= arrivalThreshold)
- || (!_autoWalkMoveTowards
- && dist >= arrivalThreshold + AutoWalkArrivalEpsilon);
-
- // Step Yaw toward target. Convention from Update line 364:
- // _body.Orientation = Quaternion.CreateFromAxisAngle(Z, Yaw - π/2),
- // so local-forward (+Y) maps to world (cos Yaw, sin Yaw, 0).
- // Therefore Yaw that faces (dx,dy) is atan2(dy, dx).
- //
- // User feedback (2026-05-15): 'I should face that object and then
- // start moving. Now it starts running before facing is complete.'
- // Track the current heading delta — if we're more than the
- // walk-while-turning threshold off, suppress Forward this frame
- // so the body turns IN PLACE first. Once we're within the
- // threshold, the synthesised Forward+Run kicks in below.
- bool aligned = true;
- bool walkAligned = true;
- if (dist > 1e-4f)
- {
- float desiredYaw = MathF.Atan2(dy, dx);
- float delta = desiredYaw - Yaw;
- while (delta > MathF.PI) delta -= 2f * MathF.PI;
- while (delta < -MathF.PI) delta += 2f * MathF.PI;
-
- // Retail-faithful local rotation: rotate continuously at
- // TurnRate, never snap until overshoot would occur. Retail's
- // MoveToManager::HandleTurnToHeading (0x0052a0c0) only snaps
- // when heading_greater() detects we've crossed the target —
- // there's no "snap when close" tolerance band. The earlier
- // 20° snap was borrowed wrongly from RemoteMoveToDriver
- // (which is the sparse-update-fudge path for remotes).
- //
- // MathF.Min(|delta|, maxStep) naturally clamps the final
- // fractional step to exactly delta, so we land on the
- // target heading without overshoot.
- // 2026-05-16 — retail-faithful turn rate. Auto-walk's
- // run/walk decision (one-shot at chain start) drives the
- // turn rate: running rotation is 50% faster per
- // run_turn_factor at retail 0x007c8914.
- float maxStep = AutoWalkTurnRateFor(_autoWalkInitiallyRunning) * dt;
- float yawStep = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
- Yaw += yawStep;
- while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
- while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
-
- // 2026-05-16 (issue #69) — record rotation direction so the
- // animation override can pick the TurnLeft/TurnRight cycle.
- // Sign convention matches user-driven A/D in Update:
- // yawStep > 0 ⇔ TurnLeft (Yaw increases)
- // yawStep < 0 ⇔ TurnRight (Yaw decreases)
- // Small dead-zone avoids flickering between Turn cycles
- // when the residual delta is effectively zero.
- if (MathF.Abs(yawStep) > 1e-5f)
- _autoWalkTurnDirectionThisFrame = yawStep > 0f ? +1 : -1;
-
- // Two alignment thresholds:
- // walkWhileTurning (30°): outside this, body turns in place.
- // Inside, body walks forward while
- // finishing residual alignment.
- // fullyAligned (5°): the arrival-fire alignment. ACE
- // rotates server-side via Rotate(target)
- // BEFORE invoking the Use callback —
- // user reported 'it does not face it
- // completely', so the final-alignment
- // check must be tighter than the
- // walking gate.
- const float WalkWhileTurningRad = 30f * MathF.PI / 180f;
- const float FullyAlignedRad = 5f * MathF.PI / 180f;
- walkAligned = MathF.Abs(delta) <= WalkWhileTurningRad;
- aligned = MathF.Abs(delta) <= FullyAlignedRad;
- }
-
- // End the auto-walk once the body is BOTH within use radius
- // AND aligned with the target. This is the alignment-gated
- // arrival the comment above flagged: a close-range Use on a
- // target behind the player still rotates the body first.
- if (withinArrival && aligned)
- {
- EndServerAutoWalk("arrived");
- return false;
- }
-
- // Walk vs run uses the one-shot decision from BeginServerAutoWalk
- // (initial distance minus use-radius vs walkRunThreshold).
- // Held for the rest of the auto-walk so the body runs all
- // the way to a far target, or walks all the way to a near
- // one — matching user-observed retail behaviour.
- bool shouldRun = _autoWalkInitiallyRunning;
-
- // Turn-first gate: if not yet within the 30° walking band,
- // suppress forward motion so the body turns in place rather
- // than walking an arc. Also suppress when already within
- // arrival — we just turned to face it; no need to step forward
- // into it.
- bool moveForward = walkAligned && !withinArrival;
-
- if (!moveForward)
- {
- // Turn-in-place phase. Two sub-cases land here:
- // (a) initial turn — body must rotate to face the target
- // before we drive forward (walkAligned == false at chain
- // start, body is stationary).
- // (b) overshoot recovery — body crossed the destination, so
- // desiredYaw flipped ~180° and walkAligned dropped to
- // false; body needs to turn around before walking back.
- // (c) settling — body is within use-radius but not aligned
- // enough to fire arrival (withinArrival == true,
- // !aligned); body holds position while finishing rotation
- // so the arrival predicate fires on the next tick.
- //
- // Issue #77 fix: explicitly zero horizontal velocity. Without
- // this, in case (b) the body keeps the prior frame's running
- // velocity (RunAnimSpeed × runRate ≈ 11 m/s) and slides past
- // the destination by several meters before the turn-around
- // rotation completes — the "runs and slides away, runs back,
- // picks up" symptom reported in issue #77 / bug B. Cases (a)
- // and (c) zero a velocity that's already zero, so the change
- // is a no-op there.
- //
- // The motion-interpreter state also has to step out of
- // WalkForward so get_state_velocity (used downstream) reports
- // standing-velocity, not the prior frame's run-speed.
- // R3-W6 note: deliberately NOT StopCompletely — auto-walk is
- // KEEP-LIST until R4 (w6-cutover-map.md R4 risk note).
- _motion.DoMotion(MotionCommand.Ready, 1.0f);
- if (_body.OnWalkable)
- {
- float savedWorldVz = _body.Velocity.Z;
- _body.set_local_velocity(new Vector3(0f, 0f, savedWorldVz));
- }
- return true;
- }
-
- // Drive motion state machine + body velocity directly. This
- // mirrors what the user-input section would have done with
- // synthesized Forward+Run, but without putting anything into
- // MovementInput — so the outbound-packet pipeline never builds
- // a MoveToState packet for auto-walk frames.
- uint forwardCmd;
- float forwardCmdSpeed;
- if (shouldRun && _weenie.InqRunRate(out float runRate))
- {
- // Wire-compatible: WalkForward command @ runRate triggers
- // ACE's auto-upgrade to RunForward for observers. Same
- // shape as the user-input section's running path.
- forwardCmd = MotionCommand.WalkForward;
- forwardCmdSpeed = runRate;
- }
- else
- {
- forwardCmd = MotionCommand.WalkForward;
- forwardCmdSpeed = 1.0f;
- }
-
-
- // Update interpreted motion state — drives the animation cycle
- // via UpdatePlayerAnimation downstream + the MotionInterpreter's
- // state-velocity getter (used for our velocity assignment below).
- _motion.DoMotion(forwardCmd, forwardCmdSpeed);
-
- // Set body velocity directly. Only meaningful when grounded;
- // mirror the user-input section's `if (_body.OnWalkable)` gate
- // so we don't override gravity/jump velocity mid-air.
- if (_body.OnWalkable)
- {
- float savedWorldVz = _body.Velocity.Z;
- var stateVel = _motion.get_state_velocity();
- _body.set_local_velocity(new Vector3(0f, stateVel.Y, savedWorldVz));
- }
-
- return true;
- }
-
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
// [cell-transit] probe fires from a single chokepoint. Both the
// server-snap path (SetPosition) and the per-frame resolver path
@@ -867,7 +466,6 @@ public sealed class PlayerMovementController
_prevTurnLeftHeld = false;
_prevTurnRightHeld = false;
_prevRunHeld = false;
- _prevAutoWalkTurnDir = 0;
// Reset physics clock so any subsequent update_object calls start fresh.
_body.LastUpdateTime = 0.0;
@@ -884,22 +482,20 @@ public sealed class PlayerMovementController
{
_simTimeSeconds += dt;
- // 2026-05-16 (issue #75 refactor): server-initiated auto-walk
- // drives the body's velocity + motion state machine DIRECTLY.
- // When _autoWalkActive, DriveServerAutoWalk steps Yaw, computes
- // velocity from wire-supplied runRate, calls _motion.DoMotion,
- // and sets _body.set_local_velocity. The user-input motion +
- // velocity sections below are SKIPPED so they don't override
- // the auto-walk's assignments. Critically, no synthesized input
- // gets put back into `input` — the outbound-packet pipeline at
- // GameWindow.cs:6410 sees user-input null/Ready throughout the
- // auto-walk and never builds a MoveToState packet, leaving
- // ACE's server-side MoveToChain to run uninterrupted until its
- // TryUseItem/TryPickUp callback fires. Retail equivalent:
- // MovementManager::PerformMovement case 6 (decomp 0x00524440)
- // calls CPhysicsObj::MoveToObject server-side; the local body
- // is moved without ever touching CommandInterpreter input.
- bool autoWalkConsumedMotion = DriveServerAutoWalk(dt, input);
+ // R4-V5: tick the verbatim MoveToManager at the slot the deleted
+ // DriveServerAutoWalk occupied — after inbound wire routing, before
+ // the input-driven motion sections. UseTime runs the retail per-tick
+ // moveto drivers (HandleMoveToPosition aux-turn steering + arrival +
+ // fail-distance / HandleTurnToHeading); the motions it dispatches
+ // land in InterpretedState through _DoMotion → DoInterpretedMotion,
+ // and sections 1/2 below turn that state into Yaw rotation + body
+ // velocity — the same per-frame apply user input gets. No
+ // synthesized input exists, so the outbound-packet pipeline sees no
+ // user motion and never builds a MoveToState mid-moveto (the #75
+ // invariant, now by construction). Provisional tick placement until
+ // R6 ports retail's UpdateObjectInternal ordering (r4-port-plan.md
+ // §3 placement decision).
+ MoveTo?.UseTime();
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
@@ -929,11 +525,13 @@ public sealed class PlayerMovementController
// remotes use. The Shift edge is retail's set_hold_run
// (0x00528b70, caller 0x006b33ca shape: interrupt=true). RAW speeds
// stay 1.0 (apply_run_to_command applies the run rate — pre-scaling
- // would double-scale; TS-22 unchanged). Skipped during server
- // auto-walk, which drives the body itself (edge state still
- // tracked so release edges fire correctly when auto-walk ends).
+ // would double-scale; TS-22 unchanged). R4-V5: the ctor-default
+ // params carry retail's 0x1EE0F bitfield whose CancelMoveTo bit
+ // (0x8000) is SET — any key edge mid-moveto fires
+ // InterruptCurrentMovement → MoveTo.CancelMoveTo(ActionCancelled),
+ // the retail user-input cancel chain (TS-36 retired; set_hold_run's
+ // interrupt:true is the same chain for the Shift edge).
bool motionEdgeFired = false;
- if (!autoWalkConsumedMotion)
{
var p = new AcDream.Core.Physics.Motion.MovementParameters();
@@ -1011,11 +609,13 @@ public sealed class PlayerMovementController
// BaseTurnRateRadPerSec × turn_speed — the same π/2 formula the remote
// path uses (GameWindow ObservedOmega seed). Numerically identical to the
// former TurnRateFor(input.Run); AP-9 (π/2 base rate) is unchanged.
- // Skipped during auto-walk (server drives the turn). Mouse turn stays a
- // direct Yaw delta (deliberately generates no turn command — avoids
- // MoveToState spam from mouse jitter).
- if (!autoWalkConsumedMotion
- && _motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
+ // R4-V5: runs during a moveto too — the manager's aux-turn steering
+ // and TurnToHeading nodes dispatch TurnRight/TurnLeft through
+ // _DoMotion into the SAME interpreted turn state, so this one
+ // integrator rotates the player for both input and moveto turns.
+ // Mouse turn stays a direct Yaw delta (deliberately generates no
+ // turn command — avoids MoveToState spam from mouse jitter).
+ if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
{
Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver
* _motion.InterpretedState.TurnSpeed * dt;
@@ -1031,14 +631,12 @@ public sealed class PlayerMovementController
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
- // 2026-05-16 (issue #75): skip when DriveServerAutoWalk owns
- // motion control this frame — it has already called
- // _motion.DoMotion + _body.set_local_velocity from the auto-
- // walk's path data + runRate. Running this section would
- // overwrite the auto-walk velocity with the user-input
- // (Ready/Stand) velocity, freezing the body.
- if (!autoWalkConsumedMotion)
- {
+ // R4-V5: runs during a moveto too — the manager's BeginMoveForward
+ // dispatched WalkForward/RunForward through _DoMotion into the SAME
+ // interpreted state, so this one per-frame apply turns it into body
+ // velocity exactly like input-driven motion (the #75 "two writers"
+ // hazard is gone: there is only this writer now).
+
// D6.2: the forward/sidestep command determination + DoMotion +
// DoInterpretedMotion moved into the apply_raw_movement call above, so
// the interpreted state (and thus get_state_velocity) is already
@@ -1057,12 +655,17 @@ public sealed class PlayerMovementController
// that adjust_motion ran above. The hand-mirrored formulas are gone
// (register TS-22 retired).
var stateVel = _motion.get_state_velocity();
- // R3-W6: autonomous — this is local input-driven motion; the
- // default (false) silently cleared LastMoveWasAutonomous and
- // flipped the A3 dual dispatch to the interpreted branch.
- _body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz), autonomous: true);
+ // R3-W6: autonomous=true flags input-driven motion (the default
+ // false silently cleared LastMoveWasAutonomous and flipped the
+ // A3 dual dispatch to the interpreted branch). R4-V5: while the
+ // MoveToManager is driving, the last movement EVENT was the
+ // server's non-autonomous moveto — retail stores the wire
+ // autonomous byte on the unpack path (P1 pin,
+ // last_move_was_autonomous), so the per-frame stamp must not
+ // overwrite that with true mid-moveto.
+ bool autonomousMove = MoveTo is null || !MoveTo.IsMovingTo();
+ _body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz), autonomous: autonomousMove);
}
- } // end of `if (!autoWalkConsumedMotion)` — section 2
// ── 3. Jump (charged) ─────────────────────────────────────────────────
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
@@ -1334,6 +937,12 @@ public sealed class PlayerMovementController
if (wasAirborne)
{
_motion.HitGround();
+ // R4-V5: retail order — minterp first, then moveto
+ // (MovementManager::HitGround 0x00524300, decomp §2d);
+ // re-arms a moveto suspended by the airborne UseTime
+ // contact gate. LeaveGround has NO moveto side (COMDAT
+ // no-op, §2e) — do not add one.
+ MoveTo?.HitGround();
justLanded = true;
}
}
@@ -1527,24 +1136,11 @@ public sealed class PlayerMovementController
bool anyDirectional = input.Forward || input.Backward
|| input.StrafeLeft || input.StrafeRight;
- // R3-W6 (#69 kept alive through the retail pipeline): auto-walk's
- // turn-first phase now dispatches the turn cycle as an EDGE through
- // DoMotion/StopMotion instead of the deleted LocalAnimationCommand
- // override. Forward legs during auto-walk come from
- // DriveServerAutoWalk's own DoMotion(WalkForward) (KEEP-LIST, R4
- // replaces). Expected-diff: auto-walk-at-run plays walk-pace legs
- // until R4's MoveToManager drives CanCharge walk/run selection.
- if (_autoWalkTurnDirectionThisFrame != _prevAutoWalkTurnDir)
- {
- var pTurn = new AcDream.Core.Physics.Motion.MovementParameters();
- if (_autoWalkTurnDirectionThisFrame > 0)
- _motion.DoMotion(MotionCommand.TurnLeft, pTurn);
- else if (_autoWalkTurnDirectionThisFrame < 0)
- _motion.DoMotion(MotionCommand.TurnRight, pTurn);
- else
- _motion.StopMotion(MotionCommand.TurnRight, pTurn);
- _prevAutoWalkTurnDir = _autoWalkTurnDirectionThisFrame;
- }
+ // R4-V5: the #69 auto-walk turn-cycle edge synthesizer is DELETED —
+ // the MoveToManager's own aux-turn steering and TurnToHeading nodes
+ // dispatch TurnRight/TurnLeft through _DoMotion (the retail
+ // mechanism), so turn cycles during a moveto come from the same
+ // pipeline as everything else.
return new MovementResult(
Position: Position,
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 5c187026..2ac789d0 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -937,6 +937,18 @@ public sealed class GameWindow : IDisposable
// far-range sends fire the wire packet immediately at SendUse/SendPickUp
// time. Cleared before the deferred send fires — single-fire, no retry.
private (uint Guid, bool IsPickup)? _pendingPostArrivalAction;
+
+ // R4-V5 (pin P4): the local player's minimal TargetTracker adapter
+ // state — the exact twin of RemoteMotion's TrackedTarget* fields. The
+ // player MoveToManager's setTarget/clearTarget seams store the tracked
+ // guid here; the pre-Update feed in OnUpdateFrame delivers
+ // HandleUpdateTarget(Ok/ExitWorld) from the live entity table. Full
+ // TargetManager port is R5 (register row, landed with V4).
+ private uint _playerMoveToTargetGuid;
+ private float _playerMoveToTargetRadius;
+ private System.Numerics.Vector3 _playerMoveToTargetLastFedPos;
+ private bool _playerMoveToTargetFedOnce;
+ private double _playerMoveToTargetQuantum;
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
private const double ServerControlledVelocityStaleSeconds = 0.60;
private int _liveSpawnReceived; // diagnostics
@@ -4264,7 +4276,14 @@ public sealed class GameWindow : IDisposable
},
clearTarget: () => rmT.TrackedTargetGuid = 0,
getTargetQuantum: () => rmT.TargetQuantum,
- setTargetQuantum: q => rmT.TargetQuantum = q);
+ setTargetQuantum: q => rmT.TargetQuantum = q,
+ // R4-V5: real clock (same epoch-seconds base the per-remote
+ // tick uses). V4 left this on the ctor's per-CALL stub, which
+ // advanced 1/30 s per _curTime() READ — multiple reads inside
+ // one dispatch skewed the progress/fail-distance clocks
+ // (CheckProgressMade's 1 s window). The V2 harness contract
+ // says production always supplies a real clock.
+ curTime: () => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds);
// TS-36 (remote side): the interp's interrupt seam is retail's
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
// chain (V2's reentrancy tests prove the wiring is inert-safe).
@@ -4313,6 +4332,116 @@ public sealed class GameWindow : IDisposable
return true;
}
+ ///
+ /// R4-V5: shared retail unpack_movement type-6..9 routing
+ /// (0x00524440 cases 6/7/8/9, decomp §2f) — one body for remotes
+ /// (R4-V4) and the local player (R4-V5). Writes the wire run rate to
+ /// the interp (my_run_rate, unpack @300603/@300660 — plan M13),
+ /// builds the MovementStruct (mt 6/8 resolve the target guid
+ /// against the entity table; unresolvable degrades to
+ /// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f —
+ /// NOT an error), and calls PerformMovement. Returns true when
+ /// the event was a type-6..9 moveto (consumed); false for every other
+ /// movement type (caller falls through to its funnel / skip posture).
+ ///
+ private bool RouteServerMoveTo(
+ AcDream.Core.Physics.Motion.MoveToManager mgr,
+ AcDream.Core.Physics.MotionInterpreter interp,
+ uint cellId,
+ AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
+ {
+ if (update.MotionState.IsServerControlledMoveTo
+ && update.MotionState.MoveToPath is { } path)
+ {
+ // my_run_rate write (unpack_movement @300603).
+ if (update.MotionState.MoveToRunRate is { } mtRunRate)
+ interp.MyRunRate = mtRunRate;
+
+ var destWorld = AcDream.Core.Physics.Motion.MoveToMath
+ .OriginToWorld(
+ path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
+ _liveCenterX, _liveCenterY);
+ var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
+ path.Bitfield,
+ path.DistanceToObject,
+ path.MinDistance,
+ path.FailDistance,
+ update.MotionState.MoveToSpeed ?? 1f,
+ path.WalkRunThreshold,
+ path.DesiredHeading);
+
+ var ms = new AcDream.Core.Physics.MovementStruct
+ {
+ Params = mp,
+ };
+ // mt 6 with a resolvable target → MoveToObject (the P4 tracker
+ // feeds position updates per tick); else degrade to
+ // MoveToPosition at the wire origin (§2f).
+ if (update.MotionState.MovementType == 6
+ && path.TargetGuid is { } tgtGuid
+ && _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt))
+ {
+ ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
+ ms.ObjectId = tgtGuid;
+ ms.TopLevelId = tgtGuid;
+ ms.Pos = new AcDream.Core.Physics.Position(
+ cellId, tgtEnt.Position,
+ System.Numerics.Quaternion.Identity);
+ }
+ else
+ {
+ ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
+ ms.Pos = new AcDream.Core.Physics.Position(
+ cellId, destWorld,
+ System.Numerics.Quaternion.Identity);
+ }
+ mgr.PerformMovement(ms);
+ return true;
+ }
+
+ if (update.MotionState.IsServerControlledTurnTo
+ && update.MotionState.TurnToPath is { } turnPath)
+ {
+ var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
+ turnPath.Bitfield,
+ turnPath.Speed,
+ turnPath.DesiredHeading);
+
+ var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
+ if (update.MotionState.MovementType == 8
+ && turnPath.TargetGuid is { } turnTgt
+ && _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt))
+ {
+ ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
+ ms.ObjectId = turnTgt;
+ ms.TopLevelId = turnTgt;
+ ms.Pos = new AcDream.Core.Physics.Position(
+ cellId, turnEnt.Position,
+ System.Numerics.Quaternion.Identity);
+ }
+ else
+ {
+ ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
+ // Retail's mt-8 unresolvable-object fallback substitutes the
+ // STANDALONE wire heading into the params before degrading
+ // (decomp §2f case 8: `params.desired_heading = wire_heading`).
+ // Invisible against ACE (P6: both heading fields written from
+ // the same source) but required for the verbatim degrade —
+ // closes the V4 carry-over the adversarial review caught
+ // (TurnToPathData.WireHeading was parsed but never consumed).
+ if (update.MotionState.MovementType == 8
+ && turnPath.WireHeading is { } wireHeading)
+ {
+ mp.DesiredHeading = wireHeading;
+ }
+ }
+ mgr.PerformMovement(ms);
+ return true;
+ }
+
+ return false;
+ }
+
///
/// Phase 6.6: the server says an entity's motion has changed. Look up
/// the AnimatedEntity for that guid, re-resolve the idle cycle with the
@@ -4355,6 +4484,26 @@ public sealed class GameWindow : IDisposable
return;
}
+ // R4-V5 (pin P1): retail CPhysics::SetObjectMovement's autonomous
+ // gate (0x00509690 @0050972e, raw 271370-271431) — a movement event
+ // whose wire autonomous byte is set is DROPPED ENTIRELY (no state
+ // application, no interrupt) when the addressed object IsThePlayer.
+ // ACE reflects the client's own outbound MoveToState back to the
+ // sender with IsAutonomous=1 hardcoded (MovementData.cs:162 +
+ // Player_Networking.cs:365) and retail never lets that echo reach
+ // unpack_movement — which is what makes the unconditional
+ // unpack-head interrupt in the player branch below safe against
+ // ACE. Order matches retail: the sequence gates above run FIRST.
+ // last_move_was_autonomous is NOT stored for dropped events (stored
+ // only on the unpack path). This retires the row-less "don't cancel
+ // on non-MoveTo UM" adaptation that lived here pre-V5 (its causal
+ // story was stale — V0-pins.md P1). Run-rate sync is re-anchored to
+ // retail's own feeds: PlayerDescription skills (SetCharacterSkills,
+ // K-fix7) + the mt-6/7 my_run_rate wire write below (M13) — the
+ // former ApplyServerRunRate echo tap is deleted, not gated.
+ if (update.Guid == _playerServerGuid && update.IsAutonomous)
+ return;
+
// Re-resolve using the new stance/command. Keep the setup and
// motion-table we already know about — the server's motion
// updates override state within the same table, not swap tables.
@@ -4414,16 +4563,6 @@ public sealed class GameWindow : IDisposable
$"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}"));
}
- // Wire server-echoed RunRate first — used for the player's own
- // locomotion tuning regardless of whether a cycle resolves.
- if (_playerController is not null
- && update.Guid == _playerServerGuid
- && update.MotionState.ForwardSpeed.HasValue
- && update.MotionState.ForwardSpeed.Value > 0f)
- {
- _playerController.ApplyServerRunRate(update.MotionState.ForwardSpeed.Value);
- }
-
// ── Sequencer path (preferred) ──────────────────────────────────
// Call SetCycle directly. The sequencer already handles:
// - left→right / backward→forward remapping via adjust_motion
@@ -4518,9 +4657,8 @@ public sealed class GameWindow : IDisposable
// the paths above, but don't stomp the animation sequencer.
// B.6 slice 1 (2026-05-14): trace inbound motion for the
- // local player so we can characterize what ACE sends during
- // a server-initiated auto-walk. One line per inbound UM,
- // gated on ACDREAM_PROBE_AUTOWALK=1.
+ // local player. One line per inbound UM, gated on
+ // ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5).
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null";
@@ -4540,52 +4678,32 @@ public sealed class GameWindow : IDisposable
$"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}"));
}
- // B.6 slice 2 (2026-05-14): drive the local player's body
- // through a server-initiated auto-walk when ACE sends
- // MoveToObject (movement type 6) — retail-faithful per
- // MovementManager::PerformMovement 0x00524440 case 6. When
- // the inbound motion is NOT a MoveTo, cancel any active
- // auto-walk (server intent changed).
+ // R4-V5: retail unpack_movement dispatch for the local
+ // player — the SAME shape the remote branch uses below.
+ // Head (@300566): interrupt + unstick fire for EVERY
+ // movement event that reached unpack (the P1 gate above
+ // already dropped the autonomous echoes that would have
+ // made this unsafe against ACE); then types 6-9 route to
+ // the player's MoveToManager. mt-0 falls through to the
+ // existing skip-sequencer posture (the interpreted-state
+ // copy + LoseControlToServer autonomy handoff is
+ // R5/MovementManager scope — V0-pins.md P1 adjacent seam).
if (_playerController is not null)
{
- if (update.MotionState.IsServerControlledMoveTo
- && update.MotionState.MoveToPath is { } pathData)
+ _playerController.Motion.InterruptCurrentMovement?.Invoke();
+ _playerController.Motion.UnstickFromObject?.Invoke();
+
+ if (_playerController.MoveTo is { } playerMoveTo
+ && RouteServerMoveTo(playerMoveTo, _playerController.Motion,
+ _playerController.CellId, update))
{
- // Translate landblock-local origin → world space.
- var destWorld = AcDream.Core.Physics.Motion.MoveToMath
- .OriginToWorld(
- pathData.OriginCellId,
- pathData.OriginX,
- pathData.OriginY,
- pathData.OriginZ,
- _liveCenterX,
- _liveCenterY);
- bool canCharge = update.MotionState.CanCharge;
- _playerController.BeginServerAutoWalk(
- destWorld,
- pathData.MinDistance,
- pathData.DistanceToObject,
- update.MotionState.MoveTowards,
- canCharge);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
- $"[autowalk-begin] dest=({destWorld.X:F2},{destWorld.Y:F2},{destWorld.Z:F2}) minDist={pathData.MinDistance:F2} objDist={pathData.DistanceToObject:F2} canCharge={canCharge} towards={update.MotionState.MoveTowards}"));
+ $"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={playerMoveTo.IsMovingTo()} type={playerMoveTo.MovementTypeState}"));
}
+ return;
}
- // Note: do NOT cancel auto-walk on a non-MoveTo motion
- // arriving. The trace (2026-05-14, launch-slice2.log)
- // shows ACE follows every mt=0x06 MoveToObject
- // immediately with an mt=0x00 InterpretedMotionState
- // (cmd=0x0007 RunForward, fwdSpd=2.86) — the
- // companion locomotion echo, NOT a cancel. The two
- // travel as separate packets but both belong to the
- // same auto-walk. Cancelling on the InterpretedMotionState
- // killed the auto-walk on frame 1. Arrival detection
- // (inside ApplyAutoWalkOverlay) and user-input
- // cancellation (same) are the two natural end paths;
- // a fresh MoveToObject re-targets via BeginServerAutoWalk
- // overwrite.
}
}
else
@@ -4618,82 +4736,13 @@ public sealed class GameWindow : IDisposable
remoteMot.Motion.InterruptCurrentMovement?.Invoke();
remoteMot.Motion.UnstickFromObject?.Invoke();
- if (update.MotionState.IsServerControlledMoveTo
- && update.MotionState.MoveToPath is { } path
- && remoteMot.MoveTo is { } moveMgr)
+ // R4-V5: the type-6..9 routing body is shared with the
+ // local player (RouteServerMoveTo) — behavior identical
+ // to the R4-V4 inline blocks it was extracted from.
+ if (remoteMot.MoveTo is { } moveMgr
+ && RouteServerMoveTo(moveMgr, remoteMot.Motion,
+ remoteMot.CellId, update))
{
- // my_run_rate write (unpack_movement @300603).
- if (update.MotionState.MoveToRunRate is { } mtRunRate)
- remoteMot.Motion.MyRunRate = mtRunRate;
-
- var destWorld = AcDream.Core.Physics.Motion.MoveToMath
- .OriginToWorld(
- path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
- _liveCenterX, _liveCenterY);
- var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
- path.Bitfield,
- path.DistanceToObject,
- path.MinDistance,
- path.FailDistance,
- update.MotionState.MoveToSpeed ?? 1f,
- path.WalkRunThreshold,
- path.DesiredHeading);
-
- var ms = new AcDream.Core.Physics.MovementStruct
- {
- Params = mp,
- };
- // mt 6 with a resolvable target → MoveToObject (the
- // P4 tracker feeds position updates per tick); else
- // degrade to MoveToPosition at the wire origin (§2f).
- if (update.MotionState.MovementType == 6
- && path.TargetGuid is { } tgtGuid
- && _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt))
- {
- ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
- ms.ObjectId = tgtGuid;
- ms.TopLevelId = tgtGuid;
- ms.Pos = new AcDream.Core.Physics.Position(
- remoteMot.CellId, tgtEnt.Position,
- System.Numerics.Quaternion.Identity);
- }
- else
- {
- ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
- ms.Pos = new AcDream.Core.Physics.Position(
- remoteMot.CellId, destWorld,
- System.Numerics.Quaternion.Identity);
- }
- moveMgr.PerformMovement(ms);
- return;
- }
-
- if (update.MotionState.IsServerControlledTurnTo
- && update.MotionState.TurnToPath is { } turnPath
- && remoteMot.MoveTo is { } turnMgr)
- {
- var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
- turnPath.Bitfield,
- turnPath.Speed,
- turnPath.DesiredHeading);
-
- var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
- if (update.MotionState.MovementType == 8
- && turnPath.TargetGuid is { } turnTgt
- && _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt))
- {
- ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
- ms.ObjectId = turnTgt;
- ms.TopLevelId = turnTgt;
- ms.Pos = new AcDream.Core.Physics.Position(
- remoteMot.CellId, turnEnt.Position,
- System.Numerics.Quaternion.Identity);
- }
- else
- {
- ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
- }
- turnMgr.PerformMovement(ms);
return;
}
@@ -7841,6 +7890,44 @@ public sealed class GameWindow : IDisposable
? localEnt.Id
: 0u;
+ // R4-V5 (pin P4): feed the player MoveToManager's target tracker
+ // BEFORE Update ticks UseTime — same feed-then-tick order the
+ // per-remote block uses. One immediate delivery after set_target,
+ // then re-delivery when the target moved beyond the voyeur
+ // radius; despawn delivers ExitWorld (the manager cancels
+ // 0x37/0x38 itself).
+ if (_playerController.MoveTo is { } playerMtm
+ && _playerMoveToTargetGuid != 0)
+ {
+ if (_entitiesByServerGuid.TryGetValue(_playerMoveToTargetGuid, out var trackedEnt))
+ {
+ var tpos = trackedEnt.Position;
+ if (!_playerMoveToTargetFedOnce
+ || System.Numerics.Vector3.Distance(tpos, _playerMoveToTargetLastFedPos)
+ > _playerMoveToTargetRadius)
+ {
+ _playerMoveToTargetFedOnce = true;
+ _playerMoveToTargetLastFedPos = tpos;
+ var tp = new AcDream.Core.Physics.Position(
+ _playerController.CellId, tpos, System.Numerics.Quaternion.Identity);
+ playerMtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
+ _playerMoveToTargetGuid,
+ AcDream.Core.Physics.Motion.TargetStatus.Ok,
+ tp, tp));
+ }
+ }
+ else
+ {
+ var lp = new AcDream.Core.Physics.Position(
+ _playerController.CellId, _playerMoveToTargetLastFedPos,
+ System.Numerics.Quaternion.Identity);
+ playerMtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
+ _playerMoveToTargetGuid,
+ AcDream.Core.Physics.Motion.TargetStatus.ExitWorld,
+ lp, lp));
+ }
+ }
+
var result = _playerController.Update((float)dt, input);
// Update the player entity's position + rotation so it renders at
@@ -7952,23 +8039,20 @@ public sealed class GameWindow : IDisposable
var wireRot = YawToAcQuaternion(_playerController.Yaw);
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
- // 2026-05-16 (issue #75): wire-layer semantic gate —
- // user-MoveToState packets are ONLY for user-initiated
- // motion intent. During server-controlled auto-walk
- // (inbound MoveToObject), motion-state transitions
- // come from the auto-walk's animation override, not
- // from user input. Sending a MoveToState in that case
- // would tell ACE "user took control" and cancel its
- // own MoveToChain. This is NOT a band-aid like the
- // earlier grace-period — it's the wire-layer's
- // expression of retail's architectural split between
- // user-input motion and server-driven motion: they
- // share the local motion-state machine but only
- // user-input flows back to the wire. Without the
- // refactor (issue #75) this guard masked a synthesis
- // leak; with the refactor it expresses the proper
- // semantic.
- if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
+ // 2026-05-16 (issue #75) / R4-V5: user-MoveToState packets
+ // are ONLY for user-initiated motion intent — retail's
+ // architectural split between user-input motion and
+ // server-driven motion. Post-V5 the split holds BY
+ // CONSTRUCTION: MotionStateChanged derives exclusively from
+ // input edges (the MoveToManager's dispatches never touch
+ // the controller's edge detector), so a manager-driven
+ // moveto produces no outbound MoveToState; the moment the
+ // user presses a key, the edge's CancelMoveTo chain kills
+ // the moveto and THAT state change legitimately goes on
+ // the wire ("user took control" — which is now true). The
+ // former !IsServerAutoWalking guard is redundant and gone
+ // with B.6.
+ if (result.MotionStateChanged)
{
// HoldKey axis values — retail enum (acclient.h enum
// HoldKey): Invalid = 0, None = 1, Run = 2.
@@ -9874,6 +9958,14 @@ public sealed class GameWindow : IDisposable
rm.Body.Velocity = new System.Numerics.Vector3(
rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
rm.Motion.HitGround();
+ // R4-V5 (closes the V4 wiring-contract gap the
+ // adversarial review caught): retail order —
+ // minterp first, then moveto (MovementManager::
+ // HitGround 0x00524300, §2d). Re-arms a moveto
+ // suspended by the airborne UseTime contact gate;
+ // without it a chasing NPC that lands stalls
+ // until ACE's ~1 Hz re-emit.
+ rm.MoveTo?.HitGround();
// K-fix17 (2026-04-26): reset the sequencer cycle
// from Falling back to whatever the interpreted
@@ -11963,15 +12055,16 @@ public sealed class GameWindow : IDisposable
return;
}
- // B.6 (2026-05-15): install speculative local auto-walk against
- // the target so close-range Use rotates the body to face before
- // the action fires. For FAR targets, ACE's CreateMoveToChain
- // (Player_Move.cs:37-179) takes over via inbound MovementType=6
- // and our overlay is overwritten by ACE's wire-supplied radius.
+ // B.6/R4-V5: install a speculative local TurnToObject/MoveToObject
+ // through the player's MoveToManager so close-range Use rotates the
+ // body to face before the action fires. For FAR targets, ACE's
+ // CreateMoveToChain (Player_Move.cs:37-179) takes over via inbound
+ // MovementType=6, whose PerformMovement re-targets with ACE's
+ // wire-supplied radius.
//
- // 2026-05-16: simplified — close-range deferral now fires the
- // wire packet ONCE on AutoWalkArrived (turn-first done), not a
- // retry of an earlier failed send. No re-send path.
+ // Close-range deferral fires the wire packet ONCE on
+ // MoveToComplete(None) (turn-first done), not a retry of an
+ // earlier failed send. No re-send path.
bool closeRange = IsCloseRangeTarget(guid);
InstallSpeculativeTurnToTarget(guid);
@@ -12088,13 +12181,14 @@ public sealed class GameWindow : IDisposable
}
///
- /// 2026-05-16. Fires the deferred close-range Use/PickUp action
- /// once the local auto-walk overlay reports arrival (i.e. the body
- /// has finished rotating to face the target). Unlike the old
- /// OnAutoWalkArrivedReSendAction, this is a FIRST send — not a
- /// retry of an earlier failed send. Far-range Use/PickUp paths
- /// fire the wire packet immediately at / time
- /// and never touch _pendingPostArrivalAction.
+ /// 2026-05-16 / R4-V5. Fires the deferred close-range Use/PickUp action
+ /// once the player's moveto completes naturally (the MoveToManager's
+ /// MoveToComplete(None) client seam — the body has finished
+ /// rotating to face / walking to the target; a user-input cancel never
+ /// fires it). This is a FIRST send — not a retry of an earlier failed
+ /// send. Far-range Use/PickUp paths fire the wire packet immediately at
+ /// / time and never touch
+ /// _pendingPostArrivalAction.
///
private void OnAutoWalkArrivedSendDeferredAction()
{
@@ -12179,12 +12273,14 @@ public sealed class GameWindow : IDisposable
private void InstallSpeculativeTurnToTarget(uint targetGuid)
{
- if (_playerController is null) return;
+ if (_playerController?.MoveTo is not { } playerMoveTo) return;
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity))
return;
// Per-type use radius — same heuristic as the picker's
- // radiusForGuid callback.
+ // radiusForGuid callback (register AP-23, re-anchored here by
+ // R4-V5; survives because ACE's close-branch broadcasts nothing
+ // actionable).
float useRadius = 0.6f;
if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0)
{
@@ -12199,13 +12295,14 @@ public sealed class GameWindow : IDisposable
}
// Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit
- // from local distance so the speculative auto-walk uses the
+ // from local distance so the speculative moveto uses the
// same walk/run as the wire-triggered overwrite that arrives
// moments later. ACE's Creature.SetWalkRunThreshold sets
// CanCharge when player→target distance >= WalkRunThreshold /
// 2 = 7.5 m (the 15 m wire default halved). Match exactly so
// the speculative install doesn't flip walk↔run when ACE's
- // MoveToObject broadcast overwrites it.
+ // MoveToObject broadcast overwrites it (PerformMovement
+ // cancels + restarts — retail-consistent re-target).
const float AceCanChargeDistance = 7.5f;
var bodyPos = _playerController.Position;
float ddx = entity.Position.X - bodyPos.X;
@@ -12213,12 +12310,33 @@ public sealed class GameWindow : IDisposable
float distToTarget = MathF.Sqrt(ddx * ddx + ddy * ddy);
bool speculativeCanCharge = distToTarget >= AceCanChargeDistance;
- _playerController.BeginServerAutoWalk(
- destinationWorld: entity.Position,
- minDistance: 0f,
- distanceToObject: useRadius,
- moveTowards: true,
- canCharge: speculativeCanCharge);
+ // R4-V5: retail's client-initiated use flow issues TurnToObject /
+ // MoveToObject through the SAME manager the wire path uses (decomp
+ // §9a/§9b callers) — in-range targets get a pure turn-to-face;
+ // out-of-range targets get the local moveto that ACE's mt-6
+ // broadcast will re-target moments later. MovementParameters ctor
+ // defaults (0x1EE0F: MoveTowards, UseSpheres, threshold 15,
+ // MinDistance 0) match the old BeginServerAutoWalk install's
+ // semantics; only the AP-23 radius + the #77 CanCharge prediction
+ // are non-default.
+ var p = new AcDream.Core.Physics.Motion.MovementParameters
+ {
+ DistanceToObject = useRadius,
+ CanCharge = speculativeCanCharge,
+ };
+ var ms = new AcDream.Core.Physics.MovementStruct
+ {
+ ObjectId = targetGuid,
+ TopLevelId = targetGuid,
+ Pos = new AcDream.Core.Physics.Position(
+ _playerController.CellId, entity.Position,
+ System.Numerics.Quaternion.Identity),
+ Params = p,
+ Type = IsCloseRangeTarget(targetGuid)
+ ? AcDream.Core.Physics.MovementType.TurnToObject
+ : AcDream.Core.Physics.MovementType.MoveToObject,
+ };
+ playerMoveTo.PerformMovement(ms);
}
private uint? SelectClosestCombatTarget(bool showToast)
@@ -12764,10 +12882,82 @@ public sealed class GameWindow : IDisposable
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
- // B.6/B.7 (2026-05-16): fire the deferred close-range Use/PickUp
- // action (first send, not a retry) when the local auto-walk overlay
- // reports arrival (body finished rotating to face the target).
- _playerController.AutoWalkArrived += OnAutoWalkArrivedSendDeferredAction;
+ // R4-V5: the local player's verbatim MoveToManager — same seam
+ // wiring shape as EnsureRemoteMotionBindings, with three
+ // player-specific differences: (a) heading reads/writes go through
+ // the controller's Yaw (the authoritative facing the body
+ // quaternion is re-derived from every Update; a quaternion-only
+ // set_heading would be overwritten next frame) via the P5-pinned
+ // yaw↔heading bridge; (b) the contact seam reads the REAL Contact
+ // transient bit (retail UseTime gates transient_state & 1 —
+ // remotes force-assert Contact+OnWalkable every grounded tick, so
+ // OnWalkable was equivalent there); (c) isInterpolating is false —
+ // the local player has no InterpolationManager. Own radius/height
+ // stay 0 for parity with the V4 remote bind (P4 note: setup
+ // cylsphere lands with R5's TargetManager port). setHeading's
+ // `send` flag is currently UNCONSUMED (register TS-33): the AP
+ // heartbeat diffs position/plane/cell but not orientation (retail's
+ // Frame::is_equal compares the full frame), so a stationary heading
+ // snap doesn't reach the wire — masked against ACE, which rotates
+ // server-side on its own turn paths; the full-frame diff lands with
+ // the R7 outbound-cadence port.
+ var pcMoveTo = _playerController;
+ var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager(
+ pcMoveTo.Motion,
+ stopCompletely: () => pcMoveTo.Motion.StopCompletely(),
+ getPosition: () => new AcDream.Core.Physics.Position(
+ pcMoveTo.CellId, pcMoveTo.Position, pcMoveTo.BodyOrientation),
+ getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw),
+ setHeading: (h, _) => pcMoveTo.Yaw =
+ AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h),
+ getOwnRadius: () => 0f,
+ getOwnHeight: () => 0f,
+ contact: () => pcMoveTo.BodyInContact,
+ isInterpolating: () => false,
+ getVelocity: () => pcMoveTo.BodyVelocity,
+ getSelfId: () => _playerServerGuid,
+ setTarget: (_, tlid, radius, _) =>
+ {
+ _playerMoveToTargetGuid = tlid;
+ _playerMoveToTargetRadius = radius;
+ _playerMoveToTargetFedOnce = false;
+ },
+ clearTarget: () => _playerMoveToTargetGuid = 0,
+ getTargetQuantum: () => _playerMoveToTargetQuantum,
+ setTargetQuantum: q => _playerMoveToTargetQuantum = q,
+ curTime: () => pcMoveTo.SimTimeSeconds);
+ _playerMoveToTargetGuid = 0;
+ _playerMoveToTargetFedOnce = false;
+ _playerMoveToTargetQuantum = 0.0;
+
+ // AD-27 re-anchored (was the deleted AutoWalkArrived event): fire
+ // the deferred close-range Use/PickUp action when the moveto
+ // completes NATURALLY (MoveToComplete is the documented client
+ // seam; it never fires on CancelMoveTo, so a user-input cancel
+ // doesn't send the action — same contract the old "arrived"-only
+ // event had).
+ playerMoveTo.MoveToComplete = err =>
+ {
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ Console.WriteLine($"[autowalk-end] reason=complete err={err}");
+ if (err == AcDream.Core.Physics.WeenieError.None)
+ OnAutoWalkArrivedSendDeferredAction();
+ };
+ _playerController.MoveTo = playerMoveTo;
+
+ // TS-36 RETIRED: the interp's interrupt seam is retail's
+ // interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
+ // chain (raw 278189-278200). Every DoMotion/StopMotion/
+ // StopCompletely/jump/set_hold_run cancel site now genuinely
+ // cancels a running moveto (V2's reentrancy tests prove the chain
+ // is inert-safe).
+ _playerController.Motion.InterruptCurrentMovement = () =>
+ {
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled
+ && playerMoveTo.IsMovingTo())
+ Console.WriteLine("[autowalk-end] reason=interrupt");
+ playerMoveTo.CancelMoveTo(AcDream.Core.Physics.WeenieError.ActionCancelled);
+ };
// K-fix7 (2026-04-26): if PlayerDescription already arrived, the
// server's Run / Jump skill values are cached here — push them
diff --git a/src/AcDream.Core/Physics/Motion/MoveToManager.cs b/src/AcDream.Core/Physics/Motion/MoveToManager.cs
index a41f859f..c788d105 100644
--- a/src/AcDream.Core/Physics/Motion/MoveToManager.cs
+++ b/src/AcDream.Core/Physics/Motion/MoveToManager.cs
@@ -153,12 +153,17 @@ public sealed class MoveToManager
///
/// CLIENT ADDITION — NOT retail (decomp §7e / do-not-invent list):
/// CleanUpAndCallWeenie contains no weenie call in this build
- /// (the name is vestigial; body ≡ CleanUp + StopCompletely). This seam
- /// stands in for ACE's server-side OnMoveComplete notification so
- /// the App layer can re-anchor AD-27 (Use/PickUp re-send on arrival).
- /// Fires from ONLY (never from plain
- /// /). Optional — null is
- /// a silent no-op.
+ /// (the name is vestigial; body ≡ CleanUp + StopCompletely), and retail
+ /// notifies NOTHING on arrival (§4b's empty-queue completion is inline
+ /// CleanUp + StopCompletely). This seam stands in for ACE's server-side
+ /// OnMoveComplete notification so the App layer can re-anchor
+ /// AD-27 (Use/PickUp re-send on arrival). Fires with
+ /// on NATURAL COMPLETION — the
+ /// empty-queue exits (both sticky and
+ /// non-sticky) and 's instant-success
+ /// path — and NEVER from /plain
+ /// (a cancel is not an arrival; AD-27's re-send
+ /// must not fire on user interrupt). Optional — null is a silent no-op.
///
public Action? MoveToComplete { get; set; }
@@ -701,11 +706,17 @@ public sealed class MoveToManager
if (HasPhysicsObj) _stopCompletely();
StickTo?.Invoke(tlid, radius, height);
+ // CLIENT ADDITION (see MoveToComplete doc): natural completion.
+ // Reentrancy-safe: CleanUp reset movement_type to Invalid BEFORE
+ // the stop, so the stop's interrupt→CancelMoveTo chain no-oped.
+ MoveToComplete?.Invoke(WeenieError.None);
return;
}
CleanUp();
if (HasPhysicsObj) _stopCompletely();
+ // CLIENT ADDITION (see MoveToComplete doc): natural completion.
+ MoveToComplete?.Invoke(WeenieError.None);
}
///
@@ -1497,9 +1508,10 @@ public sealed class MoveToManager
/// raw 306740-306752). Despite the name, contains NO weenie call in this
/// build (decomp §7e — the compiled-out server-side callback; body ≡
/// + StopCompletely).
- /// is a documented CLIENT ADDITION firing HERE ONLY (see its doc)
- /// standing in for ACE's server-side OnMoveComplete — do NOT
- /// present it as retail behavior.
+ /// is a documented CLIENT ADDITION (see its doc) firing here and at
+ /// 's empty-queue completion, standing in for
+ /// ACE's server-side OnMoveComplete — do NOT present it as retail
+ /// behavior.
///
public void CleanUpAndCallWeenie(WeenieError error)
{
diff --git a/src/AcDream.Core/Physics/Motion/MoveToMath.cs b/src/AcDream.Core/Physics/Motion/MoveToMath.cs
index 9dac5942..67ed12c9 100644
--- a/src/AcDream.Core/Physics/Motion/MoveToMath.cs
+++ b/src/AcDream.Core/Physics/Motion/MoveToMath.cs
@@ -165,6 +165,37 @@ public static class MoveToMath
return Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yaw - MathF.PI / 2f);
}
+ ///
+ /// R4-V5: the scalar leg of for bodies whose
+ /// authoritative facing is a yaw ANGLE rather than a quaternion (the
+ /// local player: PlayerMovementController.Yaw, radians, Yaw=0
+ /// faces +X, re-synced into the body quaternion every Update). Same P5
+ /// bridge: heading = (90 - yawDeg) mod 360.
+ ///
+ public static float HeadingFromYaw(float yawRad)
+ {
+ float headingDeg = 90f - yawRad * (180f / MathF.PI);
+ headingDeg %= 360f;
+ if (headingDeg < 0f) headingDeg += 360f;
+ return headingDeg;
+ }
+
+ ///
+ /// R4-V5: exact inverse of — the
+ /// set_heading seam for yaw-authoritative bodies (the local
+ /// player's heading snap must write Yaw, NOT the body
+ /// quaternion, which the controller re-derives from Yaw every frame).
+ /// Returns radians wrapped to [-π, π] matching the controller's own
+ /// wrap discipline.
+ ///
+ public static float YawFromHeading(float headingDeg)
+ {
+ float yaw = (90f - headingDeg) * (MathF.PI / 180f);
+ while (yaw > MathF.PI) yaw -= 2f * MathF.PI;
+ while (yaw < -MathF.PI) yaw += 2f * MathF.PI;
+ return yaw;
+ }
+
///
/// Retail Position::cylinder_distance, the pure-math shape
/// consumed by MoveToManager::GetCurrentDistance
diff --git a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs
new file mode 100644
index 00000000..22952dad
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs
@@ -0,0 +1,282 @@
+using System;
+using System.Numerics;
+using AcDream.App.Input;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+
+using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
+using Position = AcDream.Core.Physics.Position;
+
+namespace AcDream.Core.Tests.Input;
+
+///
+/// R4-V5 — local-player MoveTo cutover: a bound
+/// to the controller exactly the way GameWindow.EnterPlayerModeNow
+/// binds it (Yaw-authoritative heading seams via the P5 bridge, Contact
+/// transient bit, SimTimeSeconds clock) drives the player's body through
+/// with NO user input — the
+/// manager's _DoMotion dispatches land in InterpretedState and the
+/// controller's per-frame sections turn them into Yaw rotation + body
+/// velocity. Also pins the TS-36 retirement: any input edge (movement key,
+/// jump) cancels the moveto through the retail
+/// InterruptCurrentMovement → CancelMoveTo chain, and a cancel never fires
+/// the MoveToComplete completion seam (AD-27's deferred-Use contract).
+///
+public class PlayerMoveToCutoverTests
+{
+ private const uint NC = 0x8000003Du;
+ private const uint Ready = 0x41000003u;
+ private const uint Walk = 0x45000005u;
+ private const uint Run = 0x44000007u;
+ private const uint TurnRight = 0x6500000Du;
+
+ private sealed class Loader : IAnimationLoader
+ {
+ private readonly System.Collections.Generic.Dictionary _anims = new();
+ public void Register(uint id, Animation anim) => _anims[id] = anim;
+ public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null;
+ }
+
+ private static Animation MakeAnim(int frames)
+ {
+ var anim = new Animation();
+ for (int f = 0; f < frames; f++)
+ {
+ var pf = new AnimationFrame(1);
+ pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
+ anim.PartFrames.Add(pf);
+ }
+ return anim;
+ }
+
+ private static MotionData MakeMd(uint animId)
+ {
+ var md = new MotionData();
+ QualifiedDataId qid = animId;
+ md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f });
+ return md;
+ }
+
+ private static AnimationSequencer MakeSequencer()
+ {
+ var setup = new Setup();
+ setup.Parts.Add(0x01000000u);
+ setup.DefaultScale.Add(Vector3.One);
+
+ var loader = new Loader();
+ loader.Register(0x300u, MakeAnim(4));
+ loader.Register(0x301u, MakeAnim(6));
+ loader.Register(0x302u, MakeAnim(6));
+ loader.Register(0x303u, MakeAnim(6));
+
+ var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC };
+ mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready;
+ mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u);
+ mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(0x301u);
+ mt.Cycles[(int)((NC << 16) | (Run & 0xFFFFFFu))] = MakeMd(0x302u);
+ // The MoveToManager's aux-turn steering / TurnToHeading nodes
+ // dispatch TurnRight (adjust_motion normalizes TurnLeft into it) —
+ // without a table cycle the real sink's false return becomes a
+ // _DoMotion error and the manager cancels the moveto (retail
+ // dispatch-failure semantics), so the fixture needs one.
+ mt.Cycles[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u);
+ return new AnimationSequencer(setup, mt, loader);
+ }
+
+ private static PhysicsEngine MakeFlatEngine()
+ {
+ var engine = new PhysicsEngine();
+ var heights = new byte[81];
+ Array.Fill(heights, (byte)50);
+ var heightTable = new float[256];
+ for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
+ var terrain = new TerrainSurface(heights, heightTable);
+ engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(),
+ Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f);
+ return engine;
+ }
+
+ private sealed class Rig
+ {
+ public required PlayerMovementController Controller;
+ public required MoveToManager MoveTo;
+ public int MoveToCompleteCount;
+ public WeenieError LastCompleteError;
+ }
+
+ /// The EnterPlayerModeNow bind set, verbatim shape: real sink,
+ /// Yaw-authoritative heading seams (P5 bridge), Contact bit, false
+ /// isInterpolating, SimTimeSeconds clock, TS-36 interrupt binding.
+ private static Rig MakeRig()
+ {
+ var controller = new PlayerMovementController(MakeFlatEngine());
+ controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.Yaw = 0f; // heading 90 = facing +X
+ var seq = MakeSequencer();
+ controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
+ controller.Motion.RemoveLinkAnimations = seq.RemoveAllLinkAnimations;
+ controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState();
+ controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions;
+
+ var rig = new Rig { Controller = controller, MoveTo = null! };
+ var moveTo = new MoveToManager(
+ controller.Motion,
+ stopCompletely: () => controller.Motion.StopCompletely(),
+ getPosition: () => new Position(
+ controller.CellId, controller.Position, controller.BodyOrientation),
+ getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw),
+ setHeading: (h, _) => controller.Yaw = MoveToMath.YawFromHeading(h),
+ getOwnRadius: () => 0f,
+ getOwnHeight: () => 0f,
+ contact: () => controller.BodyInContact,
+ isInterpolating: () => false,
+ getVelocity: () => controller.BodyVelocity,
+ getSelfId: () => 0x5000000Au,
+ setTarget: (_, _, _, _) => { },
+ clearTarget: () => { },
+ getTargetQuantum: () => 0.0,
+ setTargetQuantum: _ => { },
+ curTime: () => controller.SimTimeSeconds);
+ moveTo.MoveToComplete = err =>
+ {
+ rig.MoveToCompleteCount++;
+ rig.LastCompleteError = err;
+ };
+ controller.MoveTo = moveTo;
+ controller.Motion.InterruptCurrentMovement =
+ () => moveTo.CancelMoveTo(WeenieError.ActionCancelled);
+ rig.MoveTo = moveTo;
+ return rig;
+ }
+
+ /// Stands in for the player sequencer's MotionDone feed (bound
+ /// in production via the R3-W2 MotionTableManager seam) — without it,
+ /// pending_motions never drains in this fixture and the manager's
+ /// wait-for-anims gates wedge.
+ private static void Drain(PlayerMovementController c)
+ {
+ while (c.Motion.MotionsPending())
+ c.Motion.MotionDone(0, true);
+ }
+
+ [Fact]
+ public void ServerMoveToPosition_WalksToArrival_WithNoUserInput()
+ {
+ var rig = MakeRig();
+ var c = rig.Controller;
+ var dest = new Vector3(104f, 96f, 50f); // 8 m dead ahead (heading 90)
+
+ rig.MoveTo.PerformMovement(new MovementStruct
+ {
+ Type = MovementType.MoveToPosition,
+ Pos = new Position(c.CellId, dest, Quaternion.Identity),
+ Params = new MovementParameters { UseSpheres = false, DistanceToObject = 0.6f },
+ });
+ Drain(c);
+ Assert.True(rig.MoveTo.IsMovingTo());
+
+ for (int f = 0; f < 1200 && rig.MoveTo.IsMovingTo(); f++)
+ {
+ var result = c.Update(1f / 60f, new MovementInput());
+ // The #75 invariant, now by construction: manager-driven motion
+ // never registers as a user motion-state change, so no outbound
+ // MoveToState is built mid-moveto.
+ Assert.False(result.MotionStateChanged,
+ $"manager-driven frame {f} must not flag MotionStateChanged");
+ Drain(c);
+ }
+
+ Assert.False(rig.MoveTo.IsMovingTo(), "moveto must arrive within the frame budget");
+ float distLeft = Vector2.Distance(
+ new Vector2(c.Position.X, c.Position.Y), new Vector2(dest.X, dest.Y));
+ Assert.True(distLeft <= 1.0f,
+ $"body must stop at the arrival radius; {distLeft:F2} m left");
+ Assert.Equal(1, rig.MoveToCompleteCount);
+ Assert.Equal(WeenieError.None, rig.LastCompleteError);
+ Assert.Equal(Ready, c.Motion.InterpretedState.ForwardCommand);
+ }
+
+ [Fact]
+ public void ServerTurnToHeading_RotatesYaw_AndSnapsExact()
+ {
+ var rig = MakeRig();
+ var c = rig.Controller;
+
+ rig.MoveTo.PerformMovement(new MovementStruct
+ {
+ Type = MovementType.TurnToHeading,
+ Params = new MovementParameters { DesiredHeading = 180f }, // South = -Y
+ });
+ Drain(c);
+ Assert.True(rig.MoveTo.IsMovingTo());
+
+ for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++)
+ {
+ c.Update(1f / 60f, new MovementInput());
+ Drain(c);
+ }
+
+ Assert.False(rig.MoveTo.IsMovingTo(), "turn-to must complete within the frame budget");
+ // HandleTurnToHeading's arrival snap (the ONE heading snap in the
+ // family, 0052a146) writes through the Yaw seam: heading 180 → yaw
+ // -π/2 per the P5 bridge.
+ Assert.Equal(MoveToMath.YawFromHeading(180f), c.Yaw, 3);
+ Assert.Equal(1, rig.MoveToCompleteCount);
+ }
+
+ [Fact]
+ public void MovementKeyEdge_CancelsMoveTo_WithoutFiringComplete()
+ {
+ var rig = MakeRig();
+ var c = rig.Controller;
+
+ rig.MoveTo.PerformMovement(new MovementStruct
+ {
+ Type = MovementType.MoveToPosition,
+ Pos = new Position(c.CellId, new Vector3(150f, 96f, 50f), Quaternion.Identity),
+ Params = new MovementParameters { UseSpheres = false },
+ });
+ Drain(c);
+ Assert.True(rig.MoveTo.IsMovingTo());
+ c.Update(1f / 60f, new MovementInput());
+ Drain(c);
+ Assert.True(rig.MoveTo.IsMovingTo());
+
+ // W press edge → DoMotion(ctor-default params, CancelMoveTo bit set)
+ // → InterruptCurrentMovement → CancelMoveTo(ActionCancelled).
+ var result = c.Update(1f / 60f, new MovementInput { Forward = true });
+
+ Assert.False(rig.MoveTo.IsMovingTo(), "user input must cancel the moveto (TS-36)");
+ Assert.Equal(0, rig.MoveToCompleteCount);
+ // The cancel-frame's state change IS user intent — it must go on
+ // the wire (the former !IsServerAutoWalking guard is gone).
+ Assert.True(result.MotionStateChanged);
+ }
+
+ [Fact]
+ public void JumpRelease_CancelsMoveTo()
+ {
+ var rig = MakeRig();
+ var c = rig.Controller;
+
+ rig.MoveTo.PerformMovement(new MovementStruct
+ {
+ Type = MovementType.MoveToPosition,
+ Pos = new Position(c.CellId, new Vector3(150f, 96f, 50f), Quaternion.Identity),
+ Params = new MovementParameters { UseSpheres = false },
+ });
+ Drain(c);
+ Assert.True(rig.MoveTo.IsMovingTo());
+
+ // Charge one frame, release the next — jump() fires on the release
+ // edge and its interrupt site cancels the moveto (the exact
+ // silent-double-motion failure the TS-36 register row predicted).
+ c.Update(1f / 60f, new MovementInput { Jump = true });
+ c.Update(1f / 60f, new MovementInput());
+
+ Assert.False(rig.MoveTo.IsMovingTo(), "jump must cancel the moveto (TS-36)");
+ Assert.Equal(0, rig.MoveToCompleteCount);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs
index 3b7aa5f6..e810e84b 100644
--- a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs
+++ b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs
@@ -107,6 +107,21 @@ public class W6EdgeDrivenMovementTests
return controller;
}
+ ///
+ /// The full apply pass the W6b live bug rode in on. Pre-R4-V5 the
+ /// trigger was ApplyServerRunRate (the ACE autonomous-echo tap);
+ /// V5's P1 gate drops that echo before it reaches the player, and the
+ /// tap is deleted — but the regression these tests pin lives in
+ /// ApplyInterpretedMovement's entry-caching, which any
+ /// apply_current_movement pass (HitGround re-apply, future R6 per-tick
+ /// order) still exercises. Same two statements the deleted tap ran.
+ ///
+ private static void RunApplyPass(PlayerMovementController controller, float forwardSpeed)
+ {
+ controller.Motion.InterpretedState.ForwardSpeed = forwardSpeed;
+ controller.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
+ }
+
[Fact]
public void ApplyPass_WithRealSink_ForwardSelfHeals()
{
@@ -120,8 +135,9 @@ public class W6EdgeDrivenMovementTests
Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
- // The live killer: the UM-echo pass (~10Hz in the real client).
- controller.ApplyServerRunRate(4.5f);
+ // The live killer: a full apply pass mid-hold (was the ~10Hz
+ // UM-echo tap pre-V5; see RunApplyPass).
+ RunApplyPass(controller, 4.5f);
Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
Assert.True(controller.Motion.get_state_velocity().Length() > 1f,
@@ -139,7 +155,7 @@ public class W6EdgeDrivenMovementTests
for (int f = 0; f < 120; f++)
{
if (f % 6 == 3)
- controller.ApplyServerRunRate(4.5f); // ACE echo cadence
+ RunApplyPass(controller, 4.5f); // ex-ACE-echo cadence
pos = controller.Update(1f / 60f, input).Position;
}
@@ -163,7 +179,7 @@ public class W6EdgeDrivenMovementTests
// Shift pressed (walk) — the set_hold_run edge demotes to walk.
for (int f = 0; f < 30; f++)
{
- if (f == 10) controller.ApplyServerRunRate(1.0f); // echo mid-walk
+ if (f == 10) RunApplyPass(controller, 1.0f); // apply pass mid-walk
controller.Update(1f / 60f, new MovementInput { Forward = true, Run = false });
}
Assert.Equal(Walk, controller.Motion.InterpretedState.ForwardCommand);
@@ -171,7 +187,7 @@ public class W6EdgeDrivenMovementTests
// Shift released — promote back to run; survives another echo.
for (int f = 0; f < 30; f++)
{
- if (f == 10) controller.ApplyServerRunRate(4.5f);
+ if (f == 10) RunApplyPass(controller, 4.5f);
controller.Update(1f / 60f, new MovementInput { Forward = true, Run = true });
}
Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs
new file mode 100644
index 00000000..572f1865
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs
@@ -0,0 +1,123 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R4-V5 — the MoveToComplete CLIENT-ADDITION seam contract (see the
+/// seam's doc on ): fires with
+/// on NATURAL COMPLETION (the
+/// empty-queue exits, both sticky
+/// and non-sticky, plus CleanUpAndCallWeenie's instant-success path)
+/// and NEVER on . The App layer's
+/// AD-27 re-anchor (deferred Use/PickUp re-send on arrival) depends on
+/// exactly this split: arrival fires the deferred action once; a user-input
+/// cancel must not.
+///
+public sealed class MoveToManagerCompletionSeamTests
+{
+ /// Arms a position move 5 m dead ahead (heading 90 = +X,
+ /// facing it) and drives the scripted body to arrival via UseTime.
+ private static MoveToManagerHarness DriveToArrival()
+ {
+ var h = new MoveToManagerHarness();
+ h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
+ h.Heading = 90f;
+ h.Manager.MoveToPosition(
+ new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity),
+ new MovementParameters { UseSpheres = false, DistanceToObject = 0.6f });
+ h.DrainPendingMotions();
+
+ for (int i = 0; i < 200 && h.Manager.IsMovingTo(); i++)
+ {
+ h.Tick();
+ var cur = h.WorldPosition.Frame.Origin;
+ h.WorldPosition = new Position(
+ 1u, cur + new Vector3(0.2f, 0f, 0f), Quaternion.Identity);
+ h.Manager.UseTime();
+ h.DrainPendingMotions();
+ }
+
+ Assert.False(h.Manager.IsMovingTo(),
+ "scripted drive must reach arrival within the tick budget");
+ return h;
+ }
+
+ [Fact]
+ public void NaturalArrival_FiresMoveToComplete_OnceWithNone()
+ {
+ var h = DriveToArrival();
+
+ Assert.Single(h.MoveToCompleteCalls);
+ Assert.Equal(WeenieError.None, h.MoveToCompleteCalls[0]);
+ }
+
+ [Fact]
+ public void AfterArrival_ExtraUseTimeTicks_DoNotRefire()
+ {
+ var h = DriveToArrival();
+
+ for (int i = 0; i < 10; i++)
+ {
+ h.Tick();
+ h.Manager.UseTime();
+ }
+
+ Assert.Single(h.MoveToCompleteCalls);
+ }
+
+ [Fact]
+ public void CancelMoveTo_DoesNotFireMoveToComplete()
+ {
+ var h = new MoveToManagerHarness();
+ h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
+ h.Heading = 90f;
+ h.Manager.MoveToPosition(
+ new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
+ new MovementParameters { UseSpheres = false });
+ Assert.True(h.Manager.IsMovingTo());
+
+ h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
+
+ Assert.False(h.Manager.IsMovingTo());
+ Assert.Empty(h.MoveToCompleteCalls);
+ }
+
+ [Fact]
+ public void StickyCompletion_FiresMoveToComplete_AfterStickToHandoff()
+ {
+ // In-range sticky object move: MoveToObject arms the tracker; the
+ // first Ok callback finds the target already inside
+ // DistanceToObject, so no motion nodes are needed and BeginNextNode
+ // lands straight on the empty-queue STICKY exit — StickTo gets the
+ // pre-CleanUp tlid/radius/height, then the completion seam fires.
+ var h = new MoveToManagerHarness();
+ h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
+ h.Heading = 90f;
+ h.Manager.MoveToObject(
+ objectId: 0x1000u, topLevelId: 0x1000u, radius: 0.5f, height: 1f,
+ new MovementParameters { Sticky = true, DistanceToObject = 5f });
+ h.DrainPendingMotions();
+
+ var target = new Position(1u, new Vector3(1f, 0f, 0f), Quaternion.Identity);
+ h.Manager.HandleUpdateTarget(
+ new TargetInfo(0x1000u, TargetStatus.Ok, target, target));
+ h.DrainPendingMotions();
+
+ // Some builds need the turn/settle nodes ticked through first.
+ for (int i = 0; i < 50 && h.Manager.IsMovingTo(); i++)
+ {
+ h.Tick();
+ h.Manager.UseTime();
+ h.DrainPendingMotions();
+ }
+
+ Assert.False(h.Manager.IsMovingTo());
+ Assert.Single(h.StickToCalls);
+ Assert.Equal((0x1000u, 0.5f, 1f), h.StickToCalls[0]);
+ Assert.Single(h.MoveToCompleteCalls);
+ Assert.Equal(WeenieError.None, h.MoveToCompleteCalls[0]);
+ }
+}