diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index eab206b1..d6917dbd 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -398,7 +398,13 @@ retail; flat-ground landings unchanged; no micro-bounce death spiral
## #164 — UM action-replay dispatches drop the per-action Autonomous bit
-**Status:** OPEN (2026-07-03, filed during #161)
+**Status:** DONE (2026-07-04, R5-V4) — `DispatchInterpretedMotion` now threads
+the action's autonomy into the dispatch params (`Autonomous` = the 0x1000
+splice, raw 305982); the stored `InterpretedState.Actions` node carries the
+real autonomy. Conformance:
+`MotionInterpreterFunnelTests.Actions_ReplayCarriesAutonomyIntoTheInterpretedList`.
+(Move to Recently closed on next ISSUES tidy.) Original finding below.
+**Filed:** OPEN (2026-07-03, filed during #161)
**Finding:** retail's `move_to_interpreted_state` action loop sets the
dispatch params' Autonomous bit (0x1000) from each action's autonomy flag
(raw 305982: `var_28 ^= ((autonomous << 0xc) ^ var_28) & 0x1000`), which
diff --git a/docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md b/docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md
index 915d2782..16869338 100644
--- a/docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md
+++ b/docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md
@@ -111,6 +111,18 @@ commit.** **VISUAL GATE**: a sticky scenario (server /follow-style sticky moveto
or a moving-platform stick) HOLDS the target instead of stopping-and-drifting.
### V4 (capstone) — MovementManager facade + #164 + head-stance dispatch + docs
+
+> **STATUS 2026-07-04: the three BEHAVIORAL items SHIPPED** (same session as
+> the V3 gates): head style-on-change at both GameWindow routing heads
+> (@00524502-0052452c, all movement types), #164 action-replay Autonomous
+> bit (raw 305982), mt-0 wire flags consumed (0x1 stick_to_object
+> 0x005127e0 → PositionManager.StickTo at both case-0 tails; 0x2 →
+> `Motion.StandingLongJump`, unconditional per @0052458e). Conformance:
+> stance-on-arm harness scenario + the autonomy funnel test; suite 4041
+> green. **The MovementManager facade DEFERRED to its own slice** (the
+> handoff's own "optional if the arc runs long" clause — this arc ran two
+> full gate cycles); it remains the structural capstone for a fresh
+> session, unblocked, spec below.
- Collapse per-entity `Motion`+`MoveTo` into a `MovementManager` owner
(structural; keep 183-case/funnel/moveto green UNMODIFIED). Optional if the
arc runs long — the retirements above don't need it.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 5cd5787a..04bc8768 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -4474,6 +4474,27 @@ public sealed class GameWindow : IDisposable
+ delta.GetHeading());
}
+ ///
+ /// R5-V4: retail CPhysicsObj::stick_to_object (0x005127e0) — the
+ /// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the
+ /// motionFlags 0x1 trailer guid): resolve the target, read its PartArray
+ /// radius/height (0 when shapeless — retail's null-PartArray arm), then
+ /// PositionManager::StickTo. Unresolvable target → no stick at
+ /// all (retail's GetObjectA-null path). Retail resolves the target's
+ /// top-level PARENT first (parent ?? self); acdream's entity
+ /// table is flat (no client-side parenting), so the guid is used as-is —
+ /// the same convention as RouteServerMoveTo's TopLevelId.
+ ///
+ private void StickToObjectFromWire(EntityPhysicsHost? host, uint targetGuid)
+ {
+ if (host is null)
+ return;
+ if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var tgtEnt))
+ return;
+ var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
+ host.PositionManager.StickTo(targetGuid, radius, height);
+ }
+
private bool RemoveLiveEntityByServerGuid(uint serverGuid)
{
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
@@ -4936,6 +4957,24 @@ public sealed class GameWindow : IDisposable
_playerController.Motion.InterruptCurrentMovement?.Invoke();
_playerController.Motion.UnstickFromObject?.Invoke();
+ // R5-V4a: unpack_movement HEAD style-on-change
+ // (0x00524440 @00524502-0052452c): wire style index →
+ // command word (command_ids[]; style-class indices map to
+ // 0x80000000|index — the funnel's S0-verified conversion)
+ // and `if (current style != wire style) DoMotion(style,
+ // ctor defaults)` BEFORE the movement-type switch — for
+ // EVERY type. acdream previously applied style only via
+ // the mt-0 funnel copy, so a chase/turn UM (mt 6-9)
+ // carrying a stance change started the move in the OLD
+ // stance (the RetailObserverTraceConformanceTests "S3
+ // wires the unpack-level style-on-change" exclusion —
+ // this is that wiring).
+ uint wireStylePlayer = stance != 0
+ ? (0x80000000u | (uint)stance) : 0x8000003Du;
+ if (_playerController.Motion.InterpretedState.CurrentStyle != wireStylePlayer)
+ _playerController.Motion.DoMotion(wireStylePlayer,
+ new AcDream.Core.Physics.Motion.MovementParameters());
+
if (_playerController.MoveTo is { } playerMoveTo
&& RouteServerMoveTo(playerMoveTo, _playerController.Motion,
_playerController.CellId, update))
@@ -4947,6 +4986,23 @@ public sealed class GameWindow : IDisposable
}
return;
}
+
+ // R5-V4: retail unpack_movement case-0 TAIL for the local
+ // player (@00524583-0052458e) — stick_to_object when the
+ // motionFlags 0x1 trailer carried a guid, then
+ // standing_longjump ← motionFlags 0x2 (UNCONDITIONAL —
+ // an absent flag clears it). The interpreted-state COPY
+ // stays skipped for the local player (the
+ // sequencer-authority posture above); these two writes
+ // are the only case-0 effects that apply here.
+ if (update.MotionState.MovementType == 0)
+ {
+ if (update.MotionState.StickyObjectGuid is { } playerSticky
+ && playerSticky != 0)
+ StickToObjectFromWire(_playerHost, playerSticky);
+ _playerController.Motion.StandingLongJump =
+ update.MotionState.StandingLongJump;
+ }
}
}
else
@@ -4996,6 +5052,18 @@ public sealed class GameWindow : IDisposable
remoteMot.Motion.InterruptCurrentMovement?.Invoke();
remoteMot.Motion.UnstickFromObject?.Invoke();
+ // R5-V4a: unpack_movement head style-on-change — see the
+ // player-side comment (@00524502-0052452c). Runs BEFORE
+ // the type routing for every movement type; the mt-0
+ // funnel below still performs its own full style
+ // adoption (retail has BOTH — the head fires on CHANGE
+ // only, so an unchanged stance is a no-op here).
+ uint wireStyleRemote = stance != 0
+ ? (0x80000000u | (uint)stance) : 0x8000003Du;
+ if (remoteMot.Motion.InterpretedState.CurrentStyle != wireStyleRemote)
+ remoteMot.Motion.DoMotion(wireStyleRemote,
+ new AcDream.Core.Physics.Motion.MovementParameters());
+
// 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.
@@ -5086,6 +5154,18 @@ public sealed class GameWindow : IDisposable
// motion-table stack (GetObjectSequence + is_allowed
// decide) — mt-0 only post-V4 (types 6-9 returned above).
remoteMot.Motion.MoveToInterpretedState(ims, sink);
+
+ // R5-V4: retail unpack_movement case-0 TAIL order
+ // (@00524583-0052458e): move_to_interpreted_state FIRST
+ // (above), THEN stick_to_object when the motionFlags 0x1
+ // trailer carried a guid, THEN standing_longjump ←
+ // motionFlags 0x2 (UNCONDITIONAL — an absent flag
+ // CLEARS it).
+ if (update.MotionState.StickyObjectGuid is { } stickyGuid
+ && stickyGuid != 0)
+ StickToObjectFromWire(remoteMot.Host, stickyGuid);
+ remoteMot.Motion.StandingLongJump =
+ update.MotionState.StandingLongJump;
}
}
diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index 24e7bdb3..25409ca6 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -259,10 +259,16 @@ public static class CreateObject
// InterpretedMotionState::UnPack (r4-moveto-decomp.md §2f
// @0052455d: `if (header & 0x100) sticky_object_guid = read_dword()`
// — bit 0x100 of the combined header word is motionFlags byte1&0x1).
- // Carried unconsumed until R5's PositionManager::StickTo body binds
- // it; parsing it here just keeps the buffer cursor honest past this
- // field (deliverable C — no behavior).
- uint? StickyObjectGuid = null)
+ // R5-V4 consumes it: the GameWindow mt-0 tail routes it into
+ // CPhysicsObj::stick_to_object's port (target PartArray radii →
+ // PositionManager.StickTo — decomp 0x005127e0, call @00524589).
+ uint? StickyObjectGuid = null,
+ // R5-V4 (closes the "documented but NOT consumed" note in
+ // UpdateMotion.cs): motionFlags & 0x2 — retail `unpack_movement`
+ // case 0 writes it onto `motion_interpreter->standing_longjump`
+ // UNCONDITIONALLY (@0052458e: absent flag CLEARS it). Consumed at
+ // the GameWindow mt-0 tails (remote + player).
+ bool StandingLongJump = false)
{
///
/// ACE/retail movement types 6 and 7 are server-controlled
diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs
index 2040484e..2049bdbc 100644
--- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs
+++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs
@@ -151,13 +151,11 @@ public static class UpdateMotion
// `_motionFlags`. StickToObject now drives the sticky-guid
// trailer parse below (mt=0 only, per ACE MovementInvalid.Write
// + decomp §2f case 0 @0052455d). StandingLongJump (0x2) is
- // DOCUMENTED here but NOT consumed — retail's
+ // carried on ServerMotionState.StandingLongJump (R5-V4) and
+ // consumed at the GameWindow mt-0 tails — retail's
// `unpack_movement` case 0 writes it onto
- // `motion_interpreter->standing_longjump` (§2f @0052458e), an
- // R5 unpack_movement item (this parser has no MotionInterpreter
- // reference to write it onto; the bit is simply not read past
- // this comment, matching "not consumed" rather than
- // mis-consumed).
+ // `motion_interpreter->standing_longjump` (§2f @0052458e,
+ // UNCONDITIONAL — an absent flag clears it).
byte motionFlags = body[pos]; pos += 1;
ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
@@ -323,7 +321,11 @@ public static class UpdateMotion
moveToRunRate,
moveToPath,
turnToPath,
- stickyObjectGuid),
+ stickyObjectGuid,
+ // R5-V4: MotionFlags 0x2 — consumed at the GameWindow mt-0
+ // tails (retail unpack_movement @0052458e writes it onto
+ // minterp->standing_longjump unconditionally).
+ (motionFlags & 0x2) != 0),
instanceSequence, movementSequence, serverControlSequence, isAutonomous);
}
catch
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index 9d42e51c..d5e1dd73 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -2860,7 +2860,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
if (IsLocalPlayer && a.Autonomous) continue;
ServerActionStamp = incoming;
- DispatchInterpretedMotion(a.Command, a.Speed, sink);
+ DispatchInterpretedMotion(a.Command, a.Speed, a.Autonomous, sink);
}
}
@@ -3291,10 +3291,16 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// ModifyInterpretedState = true, so replayed actions DO enter the
/// interpreted actions list via InterpretedMotionState::AddAction),
/// unlike the apply pass's rewritten params (see
- /// ). Known residual gap: retail
- /// also sets the params' Autonomous bit (0x1000) per action (raw
- /// 305982) — not yet threaded (tracked in ISSUES, #161 follow-up note).
+ /// ). R5-V4 (#164): the params'
+ /// Autonomous bit (0x1000) is spliced from the ACTION's autonomy
+ /// flag — retail raw 305982:
+ /// var_28 ^= ((action.autonomous << 0xc) ^ var_28) & 0x1000
+ /// — so a replayed action enters the state lists
+ /// (AddAction(motion, speed, stamp, autonomous)) with its real
+ /// autonomy instead of the ctor-default false.
///
- private WeenieError DispatchInterpretedMotion(uint motion, float speed, IInterpretedMotionSink? sink)
- => DoInterpretedMotion(motion, new MovementParameters { Speed = speed }, sink);
+ private WeenieError DispatchInterpretedMotion(
+ uint motion, float speed, bool autonomous, IInterpretedMotionSink? sink)
+ => DoInterpretedMotion(
+ motion, new MovementParameters { Speed = speed, Autonomous = autonomous }, sink);
}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs
index 43101d16..d184fed1 100644
--- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs
@@ -251,11 +251,22 @@ internal sealed class RemoteChaseHarness
bool sticky = false,
bool useSpheres = false,
float targetRadius = 0f,
- float targetHeight = 0f)
+ float targetHeight = 0f,
+ uint wireStance = 0u)
{
Interp.InterruptCurrentMovement?.Invoke();
Interp.UnstickFromObject?.Invoke();
+ // R5-V4a: the unpack_movement HEAD style-on-change (GameWindow's
+ // routing-head mirror — 0x00524440 @00524502-0052452c): fires for
+ // EVERY movement type, BEFORE the type routing, on CHANGE only.
+ if (wireStance != 0u)
+ {
+ uint wireStyle = 0x80000000u | wireStance;
+ if (Interp.InterpretedState.CurrentStyle != wireStyle)
+ Interp.DoMotion(wireStyle, new MovementParameters());
+ }
+
var mp = new MovementParameters
{
CanWalk = true,
@@ -871,4 +882,47 @@ public sealed class RemoteChaseEndToEndHarnessTests
$"chase did not re-stick after the re-arm; mt={h.Mgr.MovementTypeState} " +
$"dist={h.DistToPlayer:F2}");
}
+
+ ///
+ /// R5-V4a: the unpack-head style-on-change — a chase arm (mt-6) whose UM
+ /// carries a CHANGED stance applies the stance FIRST (retail
+ /// unpack_movement @00524502-0052452c dispatches DoMotion(style) before
+ /// the movement-type switch), so the creature draws into Combat and THEN
+ /// runs — instead of chasing in the old NonCombat stance until the next
+ /// mt-0 UM. Closes the RetailObserverTraceConformanceTests "S3 wires the
+ /// unpack-level style-on-change" exclusion's production gap.
+ ///
+ [Fact]
+ public void ChaseArm_WithStanceChange_AppliesStanceBeforeTheChase()
+ {
+ var h = new RemoteChaseHarness(_out);
+ h.PlayerPos = new Vector3(0f, 12f, 0f);
+ h.PlayerVelocity = Vector3.Zero;
+
+ // settle in NonCombat — NO prior stance UM (the pre-V4 gap: nothing
+ // but an mt-0 could change the stance).
+ for (int i = 0; i < Seconds(0.5f); i++) h.Tick();
+ Assert.Equal(RemoteChaseHarness.NonCombat, h.Seq.Manager.State.Style);
+
+ // the arm carries the Combat stance in its UM header (mt-6).
+ h.UmMoveToObject(wireStance: RemoteChaseHarness.Combat & 0xFFFFu);
+
+ // the stance adopts at the head — before any chase motion completes.
+ Assert.Equal(RemoteChaseHarness.Combat, h.Interp.InterpretedState.CurrentStyle);
+
+ // ...and the chase still installs its forward cycle normally, now in
+ // the Combat style family.
+ int installTick = -1;
+ for (int i = 0; i < Seconds(6f) && installTick < 0; i++)
+ {
+ h.Tick();
+ if (h.Seq.Manager.State.Substate == RemoteChaseHarness.Run
+ || h.Seq.Manager.State.Substate == RemoteChaseHarness.Walk)
+ installTick = i;
+ }
+ Assert.True(installTick >= 0,
+ $"forward cycle never installed after the stance-carrying arm; " +
+ $"mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8}");
+ Assert.Equal(RemoteChaseHarness.Combat, h.Seq.Manager.State.Style);
+ }
}
diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs
index e4a9de98..7c3cb617 100644
--- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs
@@ -340,6 +340,26 @@ public class MotionInterpreterFunnelTests
Assert.DoesNotContain(sink.Calls, c => c.StartsWith("DIM 10000062"));
}
+ [Fact]
+ public void Actions_ReplayCarriesAutonomyIntoTheInterpretedList()
+ {
+ // R5-V4 (#164): retail splices the ACTION's autonomy into the
+ // dispatch params (bit 0x1000 — raw 305982:
+ // `var_28 ^= ((action.autonomous << 0xc) ^ var_28) & 0x1000`), so a
+ // replayed action enters the interpreted actions list
+ // (InterpretedMotionState::AddAction consumes p.Autonomous) with its
+ // REAL autonomy. Pre-V4 the replay params were ctor-default →
+ // Autonomous always false.
+ var mi = GroundedInterp(); // remote posture: IsLocalPlayer = false
+ var sink = new RecordingSink();
+
+ mi.MoveToInterpretedState(
+ Ims(actions: new[] { new InboundMotionAction(0x10000062u, 5, Autonomous: true, 1f) }), sink);
+
+ var entry = Assert.Single(mi.InterpretedState.Actions, a => a.Command == 0x0062);
+ Assert.True(entry.Autonomous);
+ }
+
[Fact]
public void InboundState_Defaults_MatchRetailUnPack()
{
diff --git a/tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs
index d619d8f5..80200142 100644
--- a/tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs
@@ -30,7 +30,14 @@ namespace AcDream.Core.Tests.Physics;
/// tests in MotionInterpreterFunnelTests instead.
/// - motion==0x80000000 entries (the unpack-level DoMotion(command_ids[0])
/// style call for wire style index 0) — outside
-/// move_to_interpreted_state; S3 wires the unpack-level style-on-change.
+/// move_to_interpreted_state, so it can never appear in a
+/// MoveToInterpretedState replay; the filter stays for that trace-
+/// mechanics reason. The PRODUCTION gap the old note pointed at ("S3
+/// wires the unpack-level style-on-change") CLOSED in R5-V4: the
+/// GameWindow routing heads dispatch DoMotion(style) on change for every
+/// movement type (unpack_movement @00524502-0052452c; conformance:
+/// RemoteChaseEndToEndHarnessTests.ChaseArm_WithStanceChange_
+/// AppliesStanceBeforeTheChase).
///
public class RetailObserverTraceConformanceTests
{