feat(physics): R5-V4 behavioral slice — head stance dispatch (all mt) + #164 autonomy bit + mt-0 wire flags

Three unpack_movement parity items (facade deferred per the handoff's
own optional clause — see r5-wiring-handoff §V4 status):

1. HEAD style-on-change (0x00524440 @00524502-0052452c): both GameWindow
   routing heads (remote + player) now dispatch DoMotion(style, ctor
   defaults) when the UM's stance differs from the interp's current
   style, BEFORE the movement-type routing — for EVERY type. Previously
   style applied only through the mt-0 funnel copy, so a chase/turn UM
   (mt 6-9) carrying a stance change started the move in the OLD stance
   (a monster charged in NonCombat posture until the next mt-0). The
   RetailObserverTraceConformanceTests exclusion note updated — the
   trace filter stays (head calls can't appear in a
   MoveToInterpretedState replay) but the production gap it pointed at
   is closed.

2. #164 (closes): the action-replay loop threads each action's autonomy
   into the dispatch params (Autonomous = the 0x1000 splice, raw
   305982) — replayed actions enter the interpreted actions list with
   their real autonomy instead of ctor-default false.

3. mt-0 wire flags consumed (UpdateMotion parsed them since R4-V3):
   0x1 StickToObject → CPhysicsObj::stick_to_object port (0x005127e0:
   resolve target, PartArray radii — 0 when shapeless, guid-as-is for
   acdream's flat entity table — → PositionManager.StickTo; unresolvable
   target → no stick), at BOTH case-0 tails in retail order
   (@00524583-0052458e: funnel apply → stick → longjump flag);
   0x2 StandingLongJump → Motion.StandingLongJump, UNCONDITIONAL write
   (absent flag clears — retail @0052458e). ServerMotionState gains the
   StandingLongJump field.

Conformance: ChaseArm_WithStanceChange_AppliesStanceBeforeTheChase
(harness mirrors the routing-head dispatch) +
Actions_ReplayCarriesAutonomyIntoTheInterpretedList. Suite 4041 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 10:18:26 +02:00
parent 530453067a
commit f423884bd1
9 changed files with 213 additions and 20 deletions

View file

@ -4474,6 +4474,27 @@ public sealed class GameWindow : IDisposable
+ delta.GetHeading());
}
/// <summary>
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (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
/// <c>PositionManager::StickTo</c>. Unresolvable target → no stick at
/// all (retail's GetObjectA-null path). Retail resolves the target's
/// top-level PARENT first (<c>parent ?? self</c>); acdream's entity
/// table is flat (no client-side parenting), so the guid is used as-is —
/// the same convention as RouteServerMoveTo's TopLevelId.
/// </summary>
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;
}
}

View file

@ -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)
{
/// <summary>
/// ACE/retail movement types 6 and 7 are server-controlled

View file

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

View file

@ -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
/// <c>ModifyInterpretedState = true</c>, so replayed actions DO enter the
/// interpreted actions list via <c>InterpretedMotionState::AddAction</c>),
/// unlike the apply pass's rewritten params (see
/// <see cref="ApplyInterpretedMovement"/>). Known residual gap: retail
/// also sets the params' <c>Autonomous</c> bit (0x1000) per action (raw
/// 305982) — not yet threaded (tracked in ISSUES, #161 follow-up note).
/// <see cref="ApplyInterpretedMovement"/>). R5-V4 (#164): the params'
/// <c>Autonomous</c> bit (0x1000) is spliced from the ACTION's autonomy
/// flag — retail raw 305982:
/// <c>var_28 ^= ((action.autonomous &lt;&lt; 0xc) ^ var_28) &amp; 0x1000</c>
/// — so a replayed action enters the state lists
/// (<c>AddAction(motion, speed, stamp, autonomous)</c>) with its real
/// autonomy instead of the ctor-default false.
/// </summary>
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);
}