feat(L.2b): outbound movement wire parity — RawMotionState default-difference, JumpPack, contact byte

Ports the three retail outbound-movement packers verbatim (decomp-derived
golden bytes, confirmed via the Ghidra bridge, cross-checked vs holtburger):

- D1 — RawMotionState::Pack (0x0051ed10): new AcDream.Core.Physics.RawMotionState
  data type (11 fields + actions, retail defaults) + RawMotionStatePacker that
  sets a flag bit only when the field DIFFERS from its default. MoveToState.Build
  now takes a RawMotionState instead of presence-based nullable params, so the
  over-sent forwardSpeed=1.0 / currentHoldKey=None / default per-axis holdkeys are
  no longer emitted. num_actions packs into bits 11-15 (not "bits 11-31").
- D3 — MoveToStatePack::Pack (0x005168f0) trailing byte =
  (standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0); explicit contact/
  standingLongjump params (standingLongjump=false honestly until the feature lands).
- D4 — JumpAction rewritten to retail JumpPack::Pack (0x00516d10): extent,
  velocity, full Position, four u16 timestamps, align. Removed the spurious
  objectGuid/spellId u32s; Position is now packed (it was absent). Body 56 bytes.
- Position::Pack (0x005a9640) / Frame::Pack (0x00535130) verified already-correct
  (cellId, origin xyz, quaternion wxyz); locked with a golden test, no change.

GameWindow callers adapted minimally: build the RawMotionState from the existing
MovementResult values (behavior preserved except the intended D1 omissions) and
pass cellId/position/rotation to the Jump send. Pre-existing MotionInterpreter
placeholder struct RawMotionState renamed LegacyRawMotionState (D6/Phase-2 scope,
pure rename) to free the name for the retail-faithful type.

D5 audit: confirmed a real divergence — retail SendMovementEvent (0x006b4680)
stamps only last_sent_position_time after an MTS while SendPositionEvent
(0x006b4770) stamps all three; acdream's NotePositionSent stamps all three on
both paths. Left unchanged (comments added at both call sites), recorded as
register TS-33, deferred to a dedicated cadence-port slice.

Tests: RawMotionStatePackTests / MoveToStateGoldenTests / JumpActionTests /
PositionPackTests + updated MoveToStateTests / AutonomousPositionTests. Full
suite green (Core.Net.Tests 372, full solution 3228 passed / 4 pre-existing skips).

Register: TS-24/TS-25 refreshed (packer now supports actions/style; runtime
emission still deferred), TS-33 added. Roadmap L.2b shipped note added.
Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md (2-6)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-30 22:30:01 +02:00
parent 2c8620ea94
commit 78e163a41e
15 changed files with 1173 additions and 212 deletions

View file

@ -8263,29 +8263,45 @@ public sealed class GameWindow : IDisposable
// semantic.
if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
{
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
// Invalid = 0, None = 1, Run = 2
// HoldKey axis values — retail enum (acclient.h enum
// HoldKey): Invalid = 0, None = 1, Run = 2.
// Retail always sends CURRENT_HOLD_KEY (and uses the same
// value for every active per-axis hold key — see
// holtburger's build_motion_state_raw_motion_state).
// When the player is running forward, 2=Run; otherwise 1=None.
const uint HoldKeyNone = 1u;
const uint HoldKeyRun = 2u;
uint axisHoldKey = result.IsRunning ? HoldKeyRun : HoldKeyNone;
// When the player is running forward, Run; otherwise None.
var axisHoldKey = result.IsRunning
? AcDream.Core.Physics.HoldKey.Run
: AcDream.Core.Physics.HoldKey.None;
// D1/L.2b (2026-06-30): build the COMPLETE RawMotionState
// snapshot (matching retail's CPhysicsObj::InqRawMotionState())
// and let RawMotionStatePacker's default-difference comparison
// decide what actually goes on the wire — see
// RawMotionStatePacker (ports RawMotionState::Pack, 0x0051ed10).
// This slice changes ONLY the packing; the state values
// below are exactly what the pre-slice code passed in.
var rawMotionState = new AcDream.Core.Physics.RawMotionState
{
CurrentHoldKey = axisHoldKey,
// CurrentStyle: not tracked by acdream today — leave at
// the retail default (0x8000003D) so the packer omits it.
ForwardCommand = result.ForwardCommand ?? AcDream.Core.Physics.RawMotionState.Default.ForwardCommand,
ForwardHoldKey = result.ForwardCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
ForwardSpeed = result.ForwardSpeed ?? AcDream.Core.Physics.RawMotionState.Default.ForwardSpeed,
SidestepCommand = result.SidestepCommand ?? AcDream.Core.Physics.RawMotionState.Default.SidestepCommand,
SidestepHoldKey = result.SidestepCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
SidestepSpeed = result.SidestepSpeed ?? AcDream.Core.Physics.RawMotionState.Default.SidestepSpeed,
TurnCommand = result.TurnCommand ?? AcDream.Core.Physics.RawMotionState.Default.TurnCommand,
TurnHoldKey = result.TurnCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
TurnSpeed = result.TurnSpeed ?? AcDream.Core.Physics.RawMotionState.Default.TurnSpeed,
// Actions: acdream does not yet emit discrete motion
// events on this path (D2, deferred to Phase 2+).
};
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.MoveToState.Build(
gameActionSequence: seq,
forwardCommand: result.ForwardCommand,
forwardSpeed: result.ForwardSpeed,
sidestepCommand: result.SidestepCommand,
sidestepSpeed: result.SidestepSpeed,
turnCommand: result.TurnCommand,
turnSpeed: result.TurnSpeed,
holdKey: axisHoldKey, // always present
forwardHoldKey: result.ForwardCommand.HasValue ? axisHoldKey : (uint?)null,
sidestepHoldKey: result.SidestepCommand.HasValue ? axisHoldKey : (uint?)null,
turnHoldKey: result.TurnCommand.HasValue ? axisHoldKey : (uint?)null,
rawMotionState: rawMotionState,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
@ -8293,15 +8309,25 @@ public sealed class GameWindow : IDisposable
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,
forcePositionSequence: _liveSession.ForcePositionSequence,
contactLongJump: contactByte);
contact: contactByte != 0,
// Standing longjump is not implemented yet — honest
// current value, not a guess (D3).
standingLongjump: false);
DumpMovementTruthOutbound(
"MTS", seq, result, wirePos, wireCellId, contactByte);
_liveSession.SendGameAction(body);
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
// HeartbeatDue resets its interval from THIS send — mirrors retail's
// SendMovementEvent (acclient_2013_pseudo_c.txt:0x006b4680) writing
// last_sent_position_time + last_sent_position + contact_plane
// after each MTS send.
// D5 audit (2026-06-30, this slice): retail's
// SendMovementEvent (acclient_2013_pseudo_c.txt, 0x006b4680)
// stamps ONLY last_sent_position_time after an MTS send — it
// does NOT update last_sent_position or
// last_sent_contact_plane (confirmed via Ghidra
// decompile-by-address during this slice). Only
// SendPositionEvent (the AP path, below) stamps all three.
// acdream's NotePositionSent call here stamps all three on
// BOTH paths — this is a real, audit-confirmed divergence
// from retail. Per this slice's scope (D5 = audit only,
// change only with explicit sign-off), left UNCHANGED;
// reported to the lead engineer instead of fixed here.
_playerController.NotePositionSent(
worldPos: _playerController.Position,
cellId: _playerController.CellId,
@ -8327,9 +8353,13 @@ public sealed class GameWindow : IDisposable
_liveSession.SendGameAction(body);
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
// HeartbeatDue resets its interval from THIS send — mirrors retail's
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348)
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348,
// decomp address 0x006b4770, re-confirmed via the Ghidra
// decompile-by-address bridge during the L.2b slice 2026-06-30)
// writing last_sent_position_time + last_sent_position +
// last_sent_contact_plane after each AP.
// last_sent_contact_plane after each AP. Unlike the MTS path
// above (SendMovementEvent, 0x006b4680), which retail stamps
// ONLY last_sent_position_time, this AP path is correct.
_playerController.NotePositionSent(
worldPos: _playerController.Position,
cellId: _playerController.CellId,
@ -8339,11 +8369,17 @@ public sealed class GameWindow : IDisposable
if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
{
// D4/L.2b (2026-06-30): JumpPack::Pack (0x00516d10) packs the
// full Position, not an objectGuid/spellId — pass the same
// wireCellId/wirePos/wireRot the MoveToState send above uses.
var seq = _liveSession.NextGameActionSequence();
var jumpBody = AcDream.Core.Net.Messages.JumpAction.Build(
gameActionSequence: seq,
extent: result.JumpExtent.Value,
velocity: result.JumpVelocity.Value,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
instanceSequence: _liveSession.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,