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 {