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:
parent
2c8620ea94
commit
78e163a41e
15 changed files with 1173 additions and 212 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -9,18 +9,27 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// the extent + velocity to validate the jump and replicate it to nearby
|
||||
/// clients.
|
||||
///
|
||||
/// Wire layout (from holtburger JumpActionData::pack):
|
||||
/// u32 0xF7B1 (GameAction envelope)
|
||||
/// u32 sequence
|
||||
/// u32 0xF61B (Jump sub-opcode)
|
||||
/// f32 extent (0.0–1.0 charge power)
|
||||
/// f32 velocity.x, f32 velocity.y, f32 velocity.z
|
||||
/// u16 instanceSequence
|
||||
/// u16 serverControlSequence
|
||||
/// u16 teleportSequence
|
||||
/// u16 forcePositionSequence
|
||||
/// u32 objectGuid (0 for normal jump)
|
||||
/// u32 spellId (0 for normal jump)
|
||||
/// <para>
|
||||
/// Wire layout — ports retail's <c>JumpPack::Pack</c> verbatim
|
||||
/// (<c>0x00516d10</c>, decomp lines ~284934-284963). Confirmed against the
|
||||
/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>GameAction envelope</b>: u32 0xF7B1, u32 sequence, u32 0xF61B</item>
|
||||
/// <item><b>f32 extent</b> (0.0-1.0 charge power)</item>
|
||||
/// <item><b>f32 velocity.x, velocity.y, velocity.z</b></item>
|
||||
/// <item><b>Position::Pack</b>: u32 cellId, f32 x, f32 y, f32 z,
|
||||
/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)</item>
|
||||
/// <item><b>Sequences</b>: u16 instance, u16 serverControl,
|
||||
/// u16 teleport, u16 forcePosition</item>
|
||||
/// <item><b>Align to 4 bytes</b></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>D4 fix.</b> Retail's <c>JumpPack</c> does NOT pack an objectGuid or a
|
||||
/// spellId, and DOES pack the full <c>Position</c> — the pre-slice code had
|
||||
/// it backwards (two spurious trailing zero u32s, no Position at all).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class JumpAction
|
||||
{
|
||||
|
|
@ -28,29 +37,45 @@ public static class JumpAction
|
|||
public const uint JumpOpcode = 0xF61Bu;
|
||||
|
||||
public static byte[] Build(
|
||||
uint gameActionSequence,
|
||||
float extent,
|
||||
Vector3 velocity,
|
||||
ushort instanceSequence,
|
||||
ushort serverControlSequence,
|
||||
ushort teleportSequence,
|
||||
ushort forcePositionSequence)
|
||||
uint gameActionSequence,
|
||||
float extent,
|
||||
Vector3 velocity,
|
||||
uint cellId,
|
||||
Vector3 position,
|
||||
Quaternion rotation,
|
||||
ushort instanceSequence,
|
||||
ushort serverControlSequence,
|
||||
ushort teleportSequence,
|
||||
ushort forcePositionSequence)
|
||||
{
|
||||
var w = new PacketWriter(48);
|
||||
var w = new PacketWriter(80);
|
||||
|
||||
w.WriteUInt32(GameActionOpcode);
|
||||
w.WriteUInt32(gameActionSequence);
|
||||
w.WriteUInt32(JumpOpcode);
|
||||
|
||||
w.WriteFloat(extent);
|
||||
w.WriteFloat(velocity.X);
|
||||
w.WriteFloat(velocity.Y);
|
||||
w.WriteFloat(velocity.Z);
|
||||
|
||||
// --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) ---
|
||||
w.WriteUInt32(cellId);
|
||||
w.WriteFloat(position.X);
|
||||
w.WriteFloat(position.Y);
|
||||
w.WriteFloat(position.Z);
|
||||
// Quaternion wire order: W, X, Y, Z
|
||||
w.WriteFloat(rotation.W);
|
||||
w.WriteFloat(rotation.X);
|
||||
w.WriteFloat(rotation.Y);
|
||||
w.WriteFloat(rotation.Z);
|
||||
|
||||
w.WriteUInt16(instanceSequence);
|
||||
w.WriteUInt16(serverControlSequence);
|
||||
w.WriteUInt16(teleportSequence);
|
||||
w.WriteUInt16(forcePositionSequence);
|
||||
w.WriteUInt32(0); // objectGuid — 0 for normal jump
|
||||
w.WriteUInt32(0); // spellId — 0 for normal jump
|
||||
|
||||
w.AlignTo4();
|
||||
|
||||
return w.ToArray();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Net.Packets;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
|
|
@ -11,61 +12,39 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// state and to drive interpolated position for other nearby clients.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (ported from
|
||||
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs</c>
|
||||
/// <c>MoveToStateActionData::pack</c> and
|
||||
/// <c>types.rs RawMotionState::pack</c>):
|
||||
/// Wire layout — ports retail's <c>MoveToStatePack::Pack</c> verbatim
|
||||
/// (<c>0x005168f0</c>, decomp lines ~284694-284722). Confirmed against the
|
||||
/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>GameAction envelope</b>: u32 0xF7B1, u32 sequence, u32 0xF61C</item>
|
||||
/// <item><b>RawMotionState</b>: u32 packed_flags (bits 0-10 = flag bits,
|
||||
/// bits 11-31 = command list length), then conditional u32/f32 fields
|
||||
/// in flag-bit order (see <c>RawMotionFlags</c> in types.rs)</item>
|
||||
/// <item><b>WorldPosition</b>: u32 cellId, f32 x, f32 y, f32 z,
|
||||
/// f32 rotW, f32 rotX, f32 rotY, f32 rotZ</item>
|
||||
/// <item><b>RawMotionState::Pack</b>: see <see cref="RawMotionStatePacker"/>
|
||||
/// — default-difference flags dword + conditional fields + actions.</item>
|
||||
/// <item><b>Position::Pack</b>: u32 cellId, f32 x, f32 y, f32 z,
|
||||
/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)</item>
|
||||
/// <item><b>Sequences</b>: u16 instance, u16 serverControl,
|
||||
/// u16 teleport, u16 forcePosition</item>
|
||||
/// <item><b>Contact byte</b>: u8 (1 = on ground, 0 = airborne)</item>
|
||||
/// <item><b>Trailing byte</b>: <c>(standingLongjump ? 0x02 : 0) |
|
||||
/// (contact ? 0x01 : 0)</c> — <c>MoveToStatePack::Pack</c> trailing
|
||||
/// byte expression <c>(longjump_mode == 0) - 1U & 2 |
|
||||
/// contact != 0</c>.</item>
|
||||
/// <item><b>Align to 4 bytes</b></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// The command list length is packed into bits 11-31 of the flags dword.
|
||||
/// We always send 0 commands (no discrete motion events), so those bits stay 0.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MoveToState
|
||||
{
|
||||
public const uint GameActionOpcode = 0xF7B1u;
|
||||
public const uint MoveToStateAction = 0xF61Cu;
|
||||
|
||||
// RawMotionFlags bit positions (from holtburger types.rs)
|
||||
private const uint FlagCurrentHoldKey = 0x001u;
|
||||
private const uint FlagCurrentStyle = 0x002u;
|
||||
private const uint FlagForwardCommand = 0x004u;
|
||||
private const uint FlagForwardHoldKey = 0x008u;
|
||||
private const uint FlagForwardSpeed = 0x010u;
|
||||
private const uint FlagSidestepCommand = 0x020u;
|
||||
private const uint FlagSidestepHoldKey = 0x040u;
|
||||
private const uint FlagSidestepSpeed = 0x080u;
|
||||
private const uint FlagTurnCommand = 0x100u;
|
||||
private const uint FlagTurnHoldKey = 0x200u;
|
||||
private const uint FlagTurnSpeed = 0x400u;
|
||||
|
||||
/// <summary>
|
||||
/// Build a MoveToState GameAction body.
|
||||
/// </summary>
|
||||
/// <param name="gameActionSequence">Monotonically increasing counter from
|
||||
/// <see cref="WorldSession.NextGameActionSequence"/>.</param>
|
||||
/// <param name="forwardCommand">Raw motion command (u32 in AC's command space),
|
||||
/// e.g. 0x45000005 = WalkForward. Null = no forward command.</param>
|
||||
/// <param name="forwardSpeed">Normalised speed scalar (0.0-1.0). Only written
|
||||
/// when <paramref name="forwardCommand"/> is non-null.</param>
|
||||
/// <param name="sidestepCommand">Sidestep command or null.</param>
|
||||
/// <param name="sidestepSpeed">Sidestep speed or null.</param>
|
||||
/// <param name="turnCommand">Turn command or null.</param>
|
||||
/// <param name="turnSpeed">Turn speed or null.</param>
|
||||
/// <param name="holdKey">Hold-key state (1=None, 2=Run). Null = omit.</param>
|
||||
/// <param name="rawMotionState">Complete raw-motion snapshot, matching
|
||||
/// retail's <c>CPhysicsObj::InqRawMotionState()</c>. Fields equal to
|
||||
/// <see cref="RawMotionState.Default"/> are omitted from the wire
|
||||
/// (see <see cref="RawMotionStatePacker"/>).</param>
|
||||
/// <param name="cellId">Landblock cell ID (u32).</param>
|
||||
/// <param name="position">World-space position relative to the landblock.</param>
|
||||
/// <param name="rotation">Rotation quaternion. AC wire order is W, X, Y, Z.</param>
|
||||
|
|
@ -73,27 +52,22 @@ public static class MoveToState
|
|||
/// <param name="serverControlSequence">Server-control sequence number.</param>
|
||||
/// <param name="teleportSequence">Teleport sequence number.</param>
|
||||
/// <param name="forcePositionSequence">Force-position sequence number.</param>
|
||||
/// <param name="contactLongJump">1 if the character is on the ground, 0 if airborne.</param>
|
||||
/// <param name="contact">True if the character is on the ground.</param>
|
||||
/// <param name="standingLongjump">True during a standing (charged,
|
||||
/// stationary) longjump wind-up. Not yet implemented in acdream —
|
||||
/// callers pass <c>false</c> honestly until that feature lands.</param>
|
||||
public static byte[] Build(
|
||||
uint gameActionSequence,
|
||||
uint? forwardCommand,
|
||||
float? forwardSpeed,
|
||||
uint? sidestepCommand,
|
||||
float? sidestepSpeed,
|
||||
uint? turnCommand,
|
||||
float? turnSpeed,
|
||||
uint? holdKey,
|
||||
uint cellId,
|
||||
Vector3 position,
|
||||
Quaternion rotation,
|
||||
ushort instanceSequence,
|
||||
ushort serverControlSequence,
|
||||
ushort teleportSequence,
|
||||
ushort forcePositionSequence,
|
||||
byte contactLongJump = 1,
|
||||
uint? forwardHoldKey = null,
|
||||
uint? sidestepHoldKey = null,
|
||||
uint? turnHoldKey = null)
|
||||
uint gameActionSequence,
|
||||
RawMotionState rawMotionState,
|
||||
uint cellId,
|
||||
Vector3 position,
|
||||
Quaternion rotation,
|
||||
ushort instanceSequence,
|
||||
ushort serverControlSequence,
|
||||
ushort teleportSequence,
|
||||
ushort forcePositionSequence,
|
||||
bool contact = true,
|
||||
bool standingLongjump = false)
|
||||
{
|
||||
var w = new PacketWriter(128);
|
||||
|
||||
|
|
@ -102,43 +76,10 @@ public static class MoveToState
|
|||
w.WriteUInt32(gameActionSequence);
|
||||
w.WriteUInt32(MoveToStateAction);
|
||||
|
||||
// --- RawMotionState ---
|
||||
// Build the flags word. Command list length (bits 11-31) is always 0.
|
||||
// Field order matches holtburger's RawMotionState::pack — for any axis
|
||||
// where we send a COMMAND + SPEED, retail expects the matching
|
||||
// *_HOLD_KEY to accompany them (see holtburger's
|
||||
// build_motion_state_raw_motion_state). Without the per-axis hold
|
||||
// keys the server gets the flags but can't classify the input as a
|
||||
// continuously-held key, so other players see the character sliding
|
||||
// forward without an animation cycle.
|
||||
uint flags = 0u;
|
||||
if (holdKey.HasValue) flags |= FlagCurrentHoldKey;
|
||||
if (forwardCommand.HasValue) flags |= FlagForwardCommand;
|
||||
if (forwardHoldKey.HasValue) flags |= FlagForwardHoldKey;
|
||||
if (forwardSpeed.HasValue) flags |= FlagForwardSpeed;
|
||||
if (sidestepCommand.HasValue) flags |= FlagSidestepCommand;
|
||||
if (sidestepHoldKey.HasValue) flags |= FlagSidestepHoldKey;
|
||||
if (sidestepSpeed.HasValue) flags |= FlagSidestepSpeed;
|
||||
if (turnCommand.HasValue) flags |= FlagTurnCommand;
|
||||
if (turnHoldKey.HasValue) flags |= FlagTurnHoldKey;
|
||||
if (turnSpeed.HasValue) flags |= FlagTurnSpeed;
|
||||
// --- RawMotionState::Pack (0x0051ed10) ---
|
||||
RawMotionStatePacker.Pack(w, rawMotionState);
|
||||
|
||||
w.WriteUInt32(flags); // bits 0-10 = flags, bits 11-31 = 0 (no command list)
|
||||
|
||||
// Conditional fields in RawMotionFlags bit order:
|
||||
if (holdKey.HasValue) w.WriteUInt32(holdKey.Value);
|
||||
// FlagCurrentStyle (0x2): not sent — we don't track stance changes here
|
||||
if (forwardCommand.HasValue) w.WriteUInt32(forwardCommand.Value);
|
||||
if (forwardHoldKey.HasValue) w.WriteUInt32(forwardHoldKey.Value);
|
||||
if (forwardSpeed.HasValue) w.WriteFloat(forwardSpeed.Value);
|
||||
if (sidestepCommand.HasValue) w.WriteUInt32(sidestepCommand.Value);
|
||||
if (sidestepHoldKey.HasValue) w.WriteUInt32(sidestepHoldKey.Value);
|
||||
if (sidestepSpeed.HasValue) w.WriteFloat(sidestepSpeed.Value);
|
||||
if (turnCommand.HasValue) w.WriteUInt32(turnCommand.Value);
|
||||
if (turnHoldKey.HasValue) w.WriteUInt32(turnHoldKey.Value);
|
||||
if (turnSpeed.HasValue) w.WriteFloat(turnSpeed.Value);
|
||||
|
||||
// --- WorldPosition (32 bytes) ---
|
||||
// --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) ---
|
||||
w.WriteUInt32(cellId);
|
||||
w.WriteFloat(position.X);
|
||||
w.WriteFloat(position.Y);
|
||||
|
|
@ -155,8 +96,10 @@ public static class MoveToState
|
|||
w.WriteUInt16(teleportSequence);
|
||||
w.WriteUInt16(forcePositionSequence);
|
||||
|
||||
// --- Contact byte + 4-byte align ---
|
||||
w.WriteByte(contactLongJump);
|
||||
// --- Trailing byte (MoveToStatePack::Pack, 0x005168f0): ---
|
||||
// ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0)
|
||||
byte trailing = (byte)((standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0));
|
||||
w.WriteByte(trailing);
|
||||
w.AlignTo4();
|
||||
|
||||
return w.ToArray();
|
||||
|
|
|
|||
98
src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs
Normal file
98
src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using AcDream.Core.Net.Packets;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Ports retail's <c>RawMotionState::Pack</c> verbatim
|
||||
/// (<c>0x0051ed10</c>, decomp lines ~293761-294013; bitfield layout
|
||||
/// <c>acclient.h RawMotionState::PackBitfield</c>, line 46474). Confirmed
|
||||
/// against the Ghidra decompile-by-address bridge during this slice
|
||||
/// (2026-06-30).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>D1 fix.</b> Retail does NOT set a field's bit merely because the
|
||||
/// caller supplied a value — it compares every field against its retail
|
||||
/// DEFAULT (<see cref="RawMotionState.Default"/>) and sets the bit (and
|
||||
/// emits the field) only when the live value DIFFERS. This packer mirrors
|
||||
/// that default-difference comparison exactly, field by field, in bit
|
||||
/// order.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Flags dword layout (bits 0-15 only; bits 16-31 are unused — retail's
|
||||
/// <c>num_actions</c> is a 5-bit field occupying bits 11-15, not "the rest
|
||||
/// of the dword"):
|
||||
/// </para>
|
||||
/// <list type="table">
|
||||
/// <item><term>0x001</term><description>current_holdkey != HoldKey.None</description></item>
|
||||
/// <item><term>0x002</term><description>current_style != 0x8000003D</description></item>
|
||||
/// <item><term>0x004</term><description>forward_command != 0x41000003</description></item>
|
||||
/// <item><term>0x008</term><description>forward_holdkey != HoldKey.Invalid</description></item>
|
||||
/// <item><term>0x010</term><description>forward_speed != 1.0f</description></item>
|
||||
/// <item><term>0x020</term><description>sidestep_command != 0</description></item>
|
||||
/// <item><term>0x040</term><description>sidestep_holdkey != HoldKey.Invalid</description></item>
|
||||
/// <item><term>0x080</term><description>sidestep_speed != 1.0f</description></item>
|
||||
/// <item><term>0x100</term><description>turn_command != 0</description></item>
|
||||
/// <item><term>0x200</term><description>turn_holdkey != HoldKey.Invalid</description></item>
|
||||
/// <item><term>0x400</term><description>turn_speed != 1.0f</description></item>
|
||||
/// <item><term>0xF800</term><description>num_actions (bits 11-15, count not values)</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class RawMotionStatePacker
|
||||
{
|
||||
private const uint FlagCurrentHoldKey = 0x001u;
|
||||
private const uint FlagCurrentStyle = 0x002u;
|
||||
private const uint FlagForwardCommand = 0x004u;
|
||||
private const uint FlagForwardHoldKey = 0x008u;
|
||||
private const uint FlagForwardSpeed = 0x010u;
|
||||
private const uint FlagSidestepCommand = 0x020u;
|
||||
private const uint FlagSidestepHoldKey = 0x040u;
|
||||
private const uint FlagSidestepSpeed = 0x080u;
|
||||
private const uint FlagTurnCommand = 0x100u;
|
||||
private const uint FlagTurnHoldKey = 0x200u;
|
||||
private const uint FlagTurnSpeed = 0x400u;
|
||||
private const int NumActionsShift = 11;
|
||||
|
||||
public static void Pack(PacketWriter w, RawMotionState state)
|
||||
{
|
||||
var defaults = RawMotionState.Default;
|
||||
|
||||
uint flags = 0u;
|
||||
if (state.CurrentHoldKey != defaults.CurrentHoldKey) flags |= FlagCurrentHoldKey;
|
||||
if (state.CurrentStyle != defaults.CurrentStyle) flags |= FlagCurrentStyle;
|
||||
if (state.ForwardCommand != defaults.ForwardCommand) flags |= FlagForwardCommand;
|
||||
if (state.ForwardHoldKey != defaults.ForwardHoldKey) flags |= FlagForwardHoldKey;
|
||||
if (state.ForwardSpeed != defaults.ForwardSpeed) flags |= FlagForwardSpeed;
|
||||
if (state.SidestepCommand != defaults.SidestepCommand) flags |= FlagSidestepCommand;
|
||||
if (state.SidestepHoldKey != defaults.SidestepHoldKey) flags |= FlagSidestepHoldKey;
|
||||
if (state.SidestepSpeed != defaults.SidestepSpeed) flags |= FlagSidestepSpeed;
|
||||
if (state.TurnCommand != defaults.TurnCommand) flags |= FlagTurnCommand;
|
||||
if (state.TurnHoldKey != defaults.TurnHoldKey) flags |= FlagTurnHoldKey;
|
||||
if (state.TurnSpeed != defaults.TurnSpeed) flags |= FlagTurnSpeed;
|
||||
|
||||
int numActions = state.Actions.Count;
|
||||
flags |= (uint)(numActions << NumActionsShift);
|
||||
|
||||
w.WriteUInt32(flags);
|
||||
|
||||
if ((flags & FlagCurrentHoldKey) != 0) w.WriteUInt32((uint)state.CurrentHoldKey);
|
||||
if ((flags & FlagCurrentStyle) != 0) w.WriteUInt32(state.CurrentStyle);
|
||||
if ((flags & FlagForwardCommand) != 0) w.WriteUInt32(state.ForwardCommand);
|
||||
if ((flags & FlagForwardHoldKey) != 0) w.WriteUInt32((uint)state.ForwardHoldKey);
|
||||
if ((flags & FlagForwardSpeed) != 0) w.WriteFloat(state.ForwardSpeed);
|
||||
if ((flags & FlagSidestepCommand) != 0) w.WriteUInt32(state.SidestepCommand);
|
||||
if ((flags & FlagSidestepHoldKey) != 0) w.WriteUInt32((uint)state.SidestepHoldKey);
|
||||
if ((flags & FlagSidestepSpeed) != 0) w.WriteFloat(state.SidestepSpeed);
|
||||
if ((flags & FlagTurnCommand) != 0) w.WriteUInt32(state.TurnCommand);
|
||||
if ((flags & FlagTurnHoldKey) != 0) w.WriteUInt32((uint)state.TurnHoldKey);
|
||||
if ((flags & FlagTurnSpeed) != 0) w.WriteFloat(state.TurnSpeed);
|
||||
|
||||
foreach (var action in state.Actions)
|
||||
{
|
||||
w.WriteUInt16(action.Command);
|
||||
ushort stampWord = (ushort)((action.Stamp & 0x7FFF) | (action.Autonomous ? 0x8000 : 0));
|
||||
w.WriteUInt16(stampWord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -129,11 +129,23 @@ public enum WeenieError : uint
|
|||
// ── Motion state structs ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Raw (network-derived) motion state for the local player.
|
||||
/// Raw (network-derived) motion state for the local player, as consumed by
|
||||
/// the (still-approximate) <see cref="MotionInterpreter"/> input-state path.
|
||||
/// Struct layout in chunk_00520000 starts at offset +0x14 (struct field +0x20 =
|
||||
/// ForwardCommand, +0x28 = ForwardSpeed, etc.).
|
||||
///
|
||||
/// <para>
|
||||
/// Renamed from <c>RawMotionState</c> during the L.2b wire-parity slice
|
||||
/// (2026-06-30) to free that name for the retail-faithful, full-field
|
||||
/// <c>AcDream.Core.Physics.RawMotionState</c> (mirrors
|
||||
/// <c>acclient.h RawMotionState::PackBitfield</c>) used by the outbound
|
||||
/// wire packer. This partial type is D6/Phase-2 territory (the
|
||||
/// <c>adjust_motion</c>/<c>apply_raw_movement</c> input-state port) —
|
||||
/// out of scope for this slice. It is expected to be folded into or
|
||||
/// replaced by the retail-faithful type when D6 lands.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public struct RawMotionState
|
||||
public struct LegacyRawMotionState
|
||||
{
|
||||
/// <summary>Forward/backward motion command (offset +0x20).</summary>
|
||||
public uint ForwardCommand;
|
||||
|
|
@ -149,7 +161,7 @@ public struct RawMotionState
|
|||
public float TurnSpeed;
|
||||
|
||||
/// <summary>Initialize to the idle/ready state (1.0 speed, Ready command).</summary>
|
||||
public static RawMotionState Default() => new()
|
||||
public static LegacyRawMotionState Default() => new()
|
||||
{
|
||||
ForwardCommand = MotionCommand.Ready,
|
||||
ForwardSpeed = 1.0f,
|
||||
|
|
@ -261,7 +273,7 @@ public sealed class MotionInterpreter
|
|||
public IWeenieObject? WeenieObj { get; set; }
|
||||
|
||||
/// <summary>Raw (network-derived) motion state (struct offsets +0x14..+0x44).</summary>
|
||||
public RawMotionState RawState;
|
||||
public LegacyRawMotionState RawState;
|
||||
|
||||
/// <summary>Interpreted motion state derived from raw (struct offsets +0x44..+0x7C).</summary>
|
||||
public InterpretedMotionState InterpretedState;
|
||||
|
|
@ -318,7 +330,7 @@ public sealed class MotionInterpreter
|
|||
|
||||
public MotionInterpreter()
|
||||
{
|
||||
RawState = RawMotionState.Default();
|
||||
RawState = LegacyRawMotionState.Default();
|
||||
InterpretedState = InterpretedMotionState.Default();
|
||||
}
|
||||
|
||||
|
|
@ -326,7 +338,7 @@ public sealed class MotionInterpreter
|
|||
{
|
||||
PhysicsObj = physicsObj;
|
||||
WeenieObj = weenieObj;
|
||||
RawState = RawMotionState.Default();
|
||||
RawState = LegacyRawMotionState.Default();
|
||||
InterpretedState = InterpretedMotionState.Default();
|
||||
}
|
||||
|
||||
|
|
|
|||
71
src/AcDream.Core/Physics/RawMotionState.cs
Normal file
71
src/AcDream.Core/Physics/RawMotionState.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Retail hold-key enum (<c>acclient.h</c> <c>enum HoldKey</c>, line 3394).
|
||||
/// </summary>
|
||||
public enum HoldKey : uint
|
||||
{
|
||||
Invalid = 0x0,
|
||||
None = 0x1,
|
||||
Run = 0x2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry in <see cref="RawMotionState.Actions"/> — a discrete motion
|
||||
/// event (jump, attack, etc.) layered on top of the continuous
|
||||
/// forward/sidestep/turn axes.
|
||||
///
|
||||
/// Retail packs each action as <c>u16 command</c> then
|
||||
/// <c>u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0))</c>
|
||||
/// (<c>RawMotionState::Pack</c>, 0x0051ed10, decomp lines ~293998-294010).
|
||||
/// </summary>
|
||||
public readonly record struct RawMotionAction(ushort Command, ushort Stamp, bool Autonomous);
|
||||
|
||||
/// <summary>
|
||||
/// Pure-data mirror of retail's <c>RawMotionState</c> struct
|
||||
/// (<c>acclient.h</c> <c>RawMotionState::PackBitfield</c>, line 46474;
|
||||
/// packer at <c>RawMotionState::Pack</c>, 0x0051ed10).
|
||||
///
|
||||
/// <para>
|
||||
/// This type carries the COMPLETE motion-state snapshot the way retail's
|
||||
/// <c>CPhysicsObj::InqRawMotionState()</c> would return it. The packer
|
||||
/// (<see cref="AcDream.Core.Net"/>) compares every field against
|
||||
/// <see cref="Default"/> and only emits the fields that differ — see
|
||||
/// <c>RawMotionState::Pack</c> for the exact default-difference logic.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// PURE DATA: no PacketWriter / GL / Net dependency. Lives in
|
||||
/// <c>AcDream.Core.Physics</c> so the (future) motion interpreter, which is
|
||||
/// also Core.Physics, can populate it without taking a Core.Net dependency
|
||||
/// (Code Structure Rule #2).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RawMotionState
|
||||
{
|
||||
public HoldKey CurrentHoldKey { get; init; } = HoldKey.None;
|
||||
public uint CurrentStyle { get; init; } = 0x8000003Du;
|
||||
public uint ForwardCommand { get; init; } = 0x41000003u;
|
||||
public HoldKey ForwardHoldKey { get; init; } = HoldKey.Invalid;
|
||||
public float ForwardSpeed { get; init; } = 1.0f;
|
||||
public uint SidestepCommand { get; init; } = 0u;
|
||||
public HoldKey SidestepHoldKey { get; init; } = HoldKey.Invalid;
|
||||
public float SidestepSpeed { get; init; } = 1.0f;
|
||||
public uint TurnCommand { get; init; } = 0u;
|
||||
public HoldKey TurnHoldKey { get; init; } = HoldKey.Invalid;
|
||||
public float TurnSpeed { get; init; } = 1.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Discrete action list. Retail packs <c>num_actions</c> (count, not the
|
||||
/// values) into bits 11-15 of the flags dword, then emits each action's
|
||||
/// command+stamp pair unconditionally after the continuous-axis fields.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawMotionAction> Actions { get; init; } = Array.Empty<RawMotionAction>();
|
||||
|
||||
/// <summary>
|
||||
/// Retail defaults — a field bit is set in the packed flags dword ONLY
|
||||
/// when the live value differs from these (<c>RawMotionState::Pack</c>,
|
||||
/// 0x0051ed10).
|
||||
/// </summary>
|
||||
public static readonly RawMotionState Default = new();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue