diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 824c9362..ae0c01ac 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -175,7 +175,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 4. Temporary stopgap (TS) — 32 rows
+## 4. Temporary stopgap (TS) — 33 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -202,8 +202,8 @@ accepted-divergence entries (#96, #49, #50).
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands; "we don't parse yet" comment is STALE (K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:341` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
| TS-22 | `adjust_motion` not ported — backward (×−0.65) / strafe (×−1) translation hand-mirrored at controller call sites; `get_state_velocity` returns (0,0,0) for backward/strafe-left | `src/AcDream.App/Input/PlayerMovementController.cs:1021` | Duplication exists because LeaveGround through the unported path wiped strafe/backward jump velocity (straight-up backward jumps) | Any NEW `get_state_velocity` consumer during backward/strafe motion silently gets zero velocity (the exact prior bug class); hand-mirrored formulas can drift from the grounded block they copy | FUN_00528010 (adjust_motion); FUN_00528960 |
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1128` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 |
-| TS-24 | RawMotionState command list always empty (bits 11-31 = 0) — discrete motion events (emotes, one-shots) never packed outbound | `src/AcDream.Core.Net/Messages/MoveToState.cs:34` | Discrete client-initiated motions aren't implemented yet; documented builder scope | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | RawMotionState pack (holtburger types.rs) |
-| TS-25 | `FlagCurrentStyle` (stance, bit 0x2) never written to outbound MoveToState | `src/AcDream.Core.Net/Messages/MoveToState.cs:130` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | RawMotionFlags CurrentStyle 0x2 (holtburger) |
+| TS-24 | RawMotionState action list always empty at runtime — the packer now emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), but the outbound caller builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91` | Discrete client-initiated motions (D2) not wired yet; packer-ready, runtime emission is Phase 2+ | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
+| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write) |
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
@@ -211,6 +211,7 @@ accepted-divergence entries (#96, #49, #50).
| TS-30 | Numbered chat tabs (element ids `0x10000522`–`0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling |
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
+| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping) is a dedicated follow-up slice | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0 |
---
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 7f2e0b71..593a1406 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -612,6 +612,11 @@ change must keep both phase plans in sync.
- **L.1b — Command router + motion-state cleanup.** Extract tested
`SetCycle` vs `PlayAction` routing, add missing `MotionCommand` constants,
and split death `Sanctuary` action from persistent `Dead` substate.
+ *(Partial, shipped 2026-06-30:* dual `IMotionCommandCatalog` seam —
+ `AceModernCommandCatalog` runtime default + full-extraction
+ `Retail2013CommandCatalog` conformance; deleted the blind 0x016E–0x0197
+ override; exposed + filed the `CombatAnimationPlanner` 2013-numbering bug
+ as ISSUES #159. Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.)
- **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath
style transitions, attack swings by stance/power/height, hit reactions,
evades/blocks/parries, and death handoff.
@@ -673,6 +678,20 @@ diagnostic scaffolding, not yet the final collision system.
observer view. ACE accepting a position is a compatibility check, not proof
of fine-grained retail collision parity.
+**Shipped (2026-06-30) — L.2b outbound wire parity (decomp-verbatim, tests-first):**
+- **D1** — `MoveToState` now packs `RawMotionState` by retail default-difference
+ (`RawMotionState::Pack` 0x0051ed10): a field bit is set only when the value
+ differs from its retail default, so over-sent `forwardSpeed=1.0` / `holdKey=None`
+ are gone. New `AcDream.Core.Physics.RawMotionState` data type + `RawMotionStatePacker`.
+- **D3** — MoveToState trailing byte = `(standingLongjump?2:0)|(contact?1:0)`
+ (`MoveToStatePack::Pack` 0x005168f0); explicit `contact`/`standingLongjump` params.
+- **D4** — `JumpAction` rewritten to retail `JumpPack` (0x00516d10): extent · velocity ·
+ full Position · 4 timestamps; spurious objectGuid/spellId removed.
+- **D5** — audited + confirmed divergent (MTS over-stamping vs retail `SendMovementEvent`);
+ left unchanged, recorded as register **TS-33**, deferred to a dedicated cadence-port slice.
+- Golden-byte tests (Core.Net.Tests) for all packers; full suite green.
+- Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.
+
**Acceptance:**
- A developer can trace the active movement path: input/motion -> body
prediction -> `ResolveWithTransition` -> contact/cell result -> outbound
diff --git a/docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md b/docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md
index c2841a0e..4b9cb126 100644
--- a/docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md
+++ b/docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md
@@ -261,7 +261,15 @@ address in each test) and cross-checked against holtburger's Rust packers.
`RawMotionState` (run-vs-walk `forward_command`, hold-key derivation, backward
/ sidestep-left speed normalization). This slice makes the *packer* verbatim;
state construction is `adjust_motion`/`apply_raw_movement` (audit Phase 2).
-- **D5:** heartbeat-clock stamping is *audited* here, changed only if proven.
+- **D5 (CONFIRMED 2026-06-30, deferred):** the audit found a real divergence —
+ retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time`
+ after an MTS, while `SendPositionEvent` (0x006b4770, AP) stamps all three
+ (time + position + contact-plane); acdream's `NotePositionSent` stamps all three
+ after both. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where
+ retail uses `ShouldSendPositionEvent` (0x006b45e0). Left unchanged this slice (code
+ comments added at both call sites); recorded as register row **TS-33** and deferred
+ to a dedicated cadence-port follow-up slice (port `ShouldSendPositionEvent` + split
+ MTS/AP stamping).
- **D2:** actions-list / current_style packing is structurally supported and
tested, but the runtime emits 0 actions; populated emission is Phase 2+.
- **D9/D8/D10/D11/D12, D7:** later L.1/L.2 slices.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 0cf19a71..015f3b92 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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,
diff --git a/src/AcDream.Core.Net/Messages/JumpAction.cs b/src/AcDream.Core.Net/Messages/JumpAction.cs
index d5bc6d74..8740a759 100644
--- a/src/AcDream.Core.Net/Messages/JumpAction.cs
+++ b/src/AcDream.Core.Net/Messages/JumpAction.cs
@@ -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)
+///
+/// Wire layout — ports retail's JumpPack::Pack verbatim
+/// (0x00516d10, decomp lines ~284934-284963). Confirmed against the
+/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
+///
+///
+/// - GameAction envelope: u32 0xF7B1, u32 sequence, u32 0xF61B
+/// - f32 extent (0.0-1.0 charge power)
+/// - f32 velocity.x, velocity.y, velocity.z
+/// - Position::Pack: u32 cellId, f32 x, f32 y, f32 z,
+/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)
+/// - Sequences: u16 instance, u16 serverControl,
+/// u16 teleport, u16 forcePosition
+/// - Align to 4 bytes
+///
+///
+///
+/// D4 fix. Retail's JumpPack does NOT pack an objectGuid or a
+/// spellId, and DOES pack the full Position — the pre-slice code had
+/// it backwards (two spurious trailing zero u32s, no Position at all).
+///
///
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();
}
diff --git a/src/AcDream.Core.Net/Messages/MoveToState.cs b/src/AcDream.Core.Net/Messages/MoveToState.cs
index 08356961..e705d79b 100644
--- a/src/AcDream.Core.Net/Messages/MoveToState.cs
+++ b/src/AcDream.Core.Net/Messages/MoveToState.cs
@@ -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.
///
///
-/// Wire layout (ported from
-/// references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs
-/// MoveToStateActionData::pack and
-/// types.rs RawMotionState::pack):
+/// Wire layout — ports retail's MoveToStatePack::Pack verbatim
+/// (0x005168f0, decomp lines ~284694-284722). Confirmed against the
+/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
///
///
/// - GameAction envelope: u32 0xF7B1, u32 sequence, u32 0xF61C
-/// - RawMotionState: u32 packed_flags (bits 0-10 = flag bits,
-/// bits 11-31 = command list length), then conditional u32/f32 fields
-/// in flag-bit order (see RawMotionFlags in types.rs)
-/// - WorldPosition: u32 cellId, f32 x, f32 y, f32 z,
-/// f32 rotW, f32 rotX, f32 rotY, f32 rotZ
+/// - RawMotionState::Pack: see
+/// — default-difference flags dword + conditional fields + actions.
+/// - Position::Pack: u32 cellId, f32 x, f32 y, f32 z,
+/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)
/// - Sequences: u16 instance, u16 serverControl,
/// u16 teleport, u16 forcePosition
-/// - Contact byte: u8 (1 = on ground, 0 = airborne)
+/// - Trailing byte: (standingLongjump ? 0x02 : 0) |
+/// (contact ? 0x01 : 0) — MoveToStatePack::Pack trailing
+/// byte expression (longjump_mode == 0) - 1U & 2 |
+/// contact != 0.
/// - Align to 4 bytes
///
-///
-///
-/// 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.
-///
///
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;
-
///
/// Build a MoveToState GameAction body.
///
/// Monotonically increasing counter from
/// .
- /// Raw motion command (u32 in AC's command space),
- /// e.g. 0x45000005 = WalkForward. Null = no forward command.
- /// Normalised speed scalar (0.0-1.0). Only written
- /// when is non-null.
- /// Sidestep command or null.
- /// Sidestep speed or null.
- /// Turn command or null.
- /// Turn speed or null.
- /// Hold-key state (1=None, 2=Run). Null = omit.
+ /// Complete raw-motion snapshot, matching
+ /// retail's CPhysicsObj::InqRawMotionState(). Fields equal to
+ /// are omitted from the wire
+ /// (see ).
/// Landblock cell ID (u32).
/// World-space position relative to the landblock.
/// Rotation quaternion. AC wire order is W, X, Y, Z.
@@ -73,27 +52,22 @@ public static class MoveToState
/// Server-control sequence number.
/// Teleport sequence number.
/// Force-position sequence number.
- /// 1 if the character is on the ground, 0 if airborne.
+ /// True if the character is on the ground.
+ /// True during a standing (charged,
+ /// stationary) longjump wind-up. Not yet implemented in acdream —
+ /// callers pass false honestly until that feature lands.
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();
diff --git a/src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs b/src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs
new file mode 100644
index 00000000..bf73a84e
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs
@@ -0,0 +1,98 @@
+using AcDream.Core.Net.Packets;
+using AcDream.Core.Physics;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Ports retail's RawMotionState::Pack verbatim
+/// (0x0051ed10, decomp lines ~293761-294013; bitfield layout
+/// acclient.h RawMotionState::PackBitfield, line 46474). Confirmed
+/// against the Ghidra decompile-by-address bridge during this slice
+/// (2026-06-30).
+///
+///
+/// D1 fix. Retail does NOT set a field's bit merely because the
+/// caller supplied a value — it compares every field against its retail
+/// 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.
+///
+///
+///
+/// Flags dword layout (bits 0-15 only; bits 16-31 are unused — retail's
+/// num_actions is a 5-bit field occupying bits 11-15, not "the rest
+/// of the dword"):
+///
+///
+/// - 0x001current_holdkey != HoldKey.None
+/// - 0x002current_style != 0x8000003D
+/// - 0x004forward_command != 0x41000003
+/// - 0x008forward_holdkey != HoldKey.Invalid
+/// - 0x010forward_speed != 1.0f
+/// - 0x020sidestep_command != 0
+/// - 0x040sidestep_holdkey != HoldKey.Invalid
+/// - 0x080sidestep_speed != 1.0f
+/// - 0x100turn_command != 0
+/// - 0x200turn_holdkey != HoldKey.Invalid
+/// - 0x400turn_speed != 1.0f
+/// - 0xF800num_actions (bits 11-15, count not values)
+///
+///
+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);
+ }
+ }
+}
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index ec1006e1..cffb09fb 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -129,11 +129,23 @@ public enum WeenieError : uint
// ── Motion state structs ───────────────────────────────────────────────────────
///
-/// Raw (network-derived) motion state for the local player.
+/// Raw (network-derived) motion state for the local player, as consumed by
+/// the (still-approximate) input-state path.
/// Struct layout in chunk_00520000 starts at offset +0x14 (struct field +0x20 =
/// ForwardCommand, +0x28 = ForwardSpeed, etc.).
+///
+///
+/// Renamed from RawMotionState during the L.2b wire-parity slice
+/// (2026-06-30) to free that name for the retail-faithful, full-field
+/// AcDream.Core.Physics.RawMotionState (mirrors
+/// acclient.h RawMotionState::PackBitfield) used by the outbound
+/// wire packer. This partial type is D6/Phase-2 territory (the
+/// adjust_motion/apply_raw_movement 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.
+///
///
-public struct RawMotionState
+public struct LegacyRawMotionState
{
/// Forward/backward motion command (offset +0x20).
public uint ForwardCommand;
@@ -149,7 +161,7 @@ public struct RawMotionState
public float TurnSpeed;
/// Initialize to the idle/ready state (1.0 speed, Ready command).
- 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; }
/// Raw (network-derived) motion state (struct offsets +0x14..+0x44).
- public RawMotionState RawState;
+ public LegacyRawMotionState RawState;
/// Interpreted motion state derived from raw (struct offsets +0x44..+0x7C).
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();
}
diff --git a/src/AcDream.Core/Physics/RawMotionState.cs b/src/AcDream.Core/Physics/RawMotionState.cs
new file mode 100644
index 00000000..33a1536a
--- /dev/null
+++ b/src/AcDream.Core/Physics/RawMotionState.cs
@@ -0,0 +1,71 @@
+namespace AcDream.Core.Physics;
+
+///
+/// Retail hold-key enum (acclient.h enum HoldKey, line 3394).
+///
+public enum HoldKey : uint
+{
+ Invalid = 0x0,
+ None = 0x1,
+ Run = 0x2,
+}
+
+///
+/// One entry in — a discrete motion
+/// event (jump, attack, etc.) layered on top of the continuous
+/// forward/sidestep/turn axes.
+///
+/// Retail packs each action as u16 command then
+/// u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0))
+/// (RawMotionState::Pack, 0x0051ed10, decomp lines ~293998-294010).
+///
+public readonly record struct RawMotionAction(ushort Command, ushort Stamp, bool Autonomous);
+
+///
+/// Pure-data mirror of retail's RawMotionState struct
+/// (acclient.h RawMotionState::PackBitfield, line 46474;
+/// packer at RawMotionState::Pack, 0x0051ed10).
+///
+///
+/// This type carries the COMPLETE motion-state snapshot the way retail's
+/// CPhysicsObj::InqRawMotionState() would return it. The packer
+/// () compares every field against
+/// and only emits the fields that differ — see
+/// RawMotionState::Pack for the exact default-difference logic.
+///
+///
+///
+/// PURE DATA: no PacketWriter / GL / Net dependency. Lives in
+/// AcDream.Core.Physics so the (future) motion interpreter, which is
+/// also Core.Physics, can populate it without taking a Core.Net dependency
+/// (Code Structure Rule #2).
+///
+///
+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;
+
+ ///
+ /// Discrete action list. Retail packs num_actions (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.
+ ///
+ public IReadOnlyList Actions { get; init; } = Array.Empty();
+
+ ///
+ /// Retail defaults — a field bit is set in the packed flags dword ONLY
+ /// when the live value differs from these (RawMotionState::Pack,
+ /// 0x0051ed10).
+ ///
+ public static readonly RawMotionState Default = new();
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
index 630cae6f..9652f3e1 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
@@ -122,6 +122,69 @@ public class AutonomousPositionTests
Assert.Equal(0, body[52]);
}
+ [Fact]
+ public void Build_TimestampOrder_MatchesAutonomousPositionPackPack()
+ {
+ // AutonomousPositionPack::Pack (0x00516af0, confirmed via Ghidra
+ // decompile-by-address during this slice 2026-06-30): after
+ // Position::Pack (32 bytes), four u16 timestamps in order
+ // instance_timestamp, server_control_timestamp, teleport_timestamp,
+ // force_position_ts — then a CONTACT-ONLY byte (no longjump bit;
+ // that bit only exists in MoveToStatePack), then ALIGN_PTR.
+ var body = AutonomousPosition.Build(
+ gameActionSequence: 8,
+ cellId: 0xA9B40001u,
+ position: Vector3.Zero,
+ rotation: Quaternion.Identity,
+ instanceSequence: 0x1111,
+ serverControlSequence: 0x2222,
+ teleportSequence: 0x3333,
+ forcePositionSequence: 0x4444);
+
+ // 12 (envelope) + 32 (Position) = 44.
+ int tsOffset = 44;
+ ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset));
+ ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2));
+ ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4));
+ ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6));
+
+ Assert.Equal((ushort)0x1111, instance);
+ Assert.Equal((ushort)0x2222, serverControl);
+ Assert.Equal((ushort)0x3333, teleport);
+ Assert.Equal((ushort)0x4444, forcePosition);
+ }
+
+ [Fact]
+ public void Build_ContactByte_IsContactOnly_NoLongjumpBit()
+ {
+ // contact != 0 -> byte = 1 (bool cast, not an OR'd bitmask like MTS).
+ var bodyOnGround = AutonomousPosition.Build(
+ gameActionSequence: 9,
+ cellId: 0xA9B40001u,
+ position: Vector3.Zero,
+ rotation: Quaternion.Identity,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0,
+ lastContact: 1);
+
+ Assert.Equal(1, bodyOnGround[52]);
+
+ var bodyAirborne = AutonomousPosition.Build(
+ gameActionSequence: 10,
+ cellId: 0xA9B40001u,
+ position: Vector3.Zero,
+ rotation: Quaternion.Identity,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0,
+ lastContact: 0);
+
+ Assert.Equal(0, bodyAirborne[52]);
+ }
+
[Fact]
public void Build_ContainsIdentityRotation_AfterPosition()
{
diff --git a/tests/AcDream.Core.Net.Tests/Messages/JumpActionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/JumpActionTests.cs
new file mode 100644
index 00000000..779ab40c
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/JumpActionTests.cs
@@ -0,0 +1,170 @@
+using System;
+using System.Buffers.Binary;
+using System.Numerics;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Golden-byte tests for the retail-faithful
+/// layout, porting JumpPack::Pack (0x00516d10, decomp lines
+/// ~284934-284963). Confirmed verbatim against the Ghidra decompile-by-address
+/// bridge (http://127.0.0.1:8081/decompile_function?address=0x00516d10)
+/// during this slice (2026-06-30):
+///
+///
+/// extent (f32), velocity.x/y/z (f32 x3), Position::Pack (cellId + Frame),
+/// instance_timestamp (u16), server_control_timestamp (u16),
+/// teleport_timestamp (u16), force_position_ts (u16), ALIGN_PTR.
+///
+///
+/// D4: retail does NOT pack an objectGuid or spellId. The pre-slice acdream
+/// code wrote two spurious trailing u32 0 fields and omitted Position
+/// entirely — both are fixed here.
+///
+public class JumpActionTests
+{
+ [Fact]
+ public void Build_ProducesValidGameAction()
+ {
+ var body = JumpAction.Build(
+ gameActionSequence: 9,
+ extent: 0.5f,
+ velocity: new Vector3(1f, 2f, 3f),
+ cellId: 0xA9B40001u,
+ position: new Vector3(96f, 96f, 50f),
+ rotation: Quaternion.Identity,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0);
+
+ uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
+ Assert.Equal(0xF7B1u, opcode);
+
+ uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
+ Assert.Equal(9u, seq);
+
+ uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
+ Assert.Equal(0xF61Bu, actionType);
+ }
+
+ [Fact]
+ public void Build_ExtentAndVelocity_FollowEnvelope()
+ {
+ var body = JumpAction.Build(
+ gameActionSequence: 1,
+ extent: 0.75f,
+ velocity: new Vector3(1.5f, -2.5f, 9.81f),
+ cellId: 0xA9B40001u,
+ position: Vector3.Zero,
+ rotation: Quaternion.Identity,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0);
+
+ // 12-byte envelope, then extent(4), vx(4), vy(4), vz(4).
+ float extent = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(12));
+ float vx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16));
+ float vy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20));
+ float vz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24));
+
+ Assert.Equal(0.75f, extent);
+ Assert.Equal(1.5f, vx);
+ Assert.Equal(-2.5f, vy);
+ Assert.Equal(9.81f, vz);
+ }
+
+ [Fact]
+ public void Build_PositionFollowsVelocity_CellIdThenOriginThenQuaternion()
+ {
+ var body = JumpAction.Build(
+ gameActionSequence: 2,
+ extent: 0f,
+ velocity: Vector3.Zero,
+ cellId: 0xDEADBEEFu,
+ position: new Vector3(12.5f, 34.0f, 56.75f),
+ rotation: Quaternion.Identity,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0);
+
+ // 12 (envelope) + 4 (extent) + 12 (velocity) = offset 28 -> Position::Pack.
+ int positionOffset = 28;
+ uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(positionOffset));
+ float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 4));
+ float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 8));
+ float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 12));
+ float qw = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 16));
+ float qx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 20));
+ float qy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 24));
+ float qz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 28));
+
+ Assert.Equal(0xDEADBEEFu, cellId);
+ Assert.Equal(12.5f, x);
+ Assert.Equal(34.0f, y);
+ Assert.Equal(56.75f, z);
+ Assert.Equal(1.0f, qw);
+ Assert.Equal(0.0f, qx);
+ Assert.Equal(0.0f, qy);
+ Assert.Equal(0.0f, qz);
+ }
+
+ [Fact]
+ public void Build_TimestampsFollowPosition_InRetailOrder()
+ {
+ var body = JumpAction.Build(
+ gameActionSequence: 3,
+ extent: 0f,
+ velocity: Vector3.Zero,
+ cellId: 0xA9B40001u,
+ position: Vector3.Zero,
+ rotation: Quaternion.Identity,
+ instanceSequence: 0x1111,
+ serverControlSequence: 0x2222,
+ teleportSequence: 0x3333,
+ forcePositionSequence: 0x4444);
+
+ // Position::Pack is 32 bytes (cellId + Frame). Timestamps start at
+ // 28 (extent/velocity/envelope) + 32 = 60.
+ int tsOffset = 60;
+ ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset));
+ ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2));
+ ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4));
+ ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6));
+
+ Assert.Equal((ushort)0x1111, instance);
+ Assert.Equal((ushort)0x2222, serverControl);
+ Assert.Equal((ushort)0x3333, teleport);
+ Assert.Equal((ushort)0x4444, forcePosition);
+ }
+
+ [Fact]
+ public void Build_NoObjectGuidOrSpellId_JumpPackBodyLengthIs56()
+ {
+ // JumpPack::Pack body (everything AFTER the 12-byte GameAction
+ // envelope): extent(4) + velocity(12) + Position(32) +
+ // 4x u16 timestamps(8) = 56 bytes, already a multiple of 4 -> no
+ // align padding. Total wire length = 12 (envelope) + 56 = 68.
+ // Retail's JumpPack has NO objectGuid/spellId fields (D4) — the
+ // pre-slice code's two spurious trailing u32 writes are gone.
+ var body = JumpAction.Build(
+ gameActionSequence: 4,
+ extent: 0f,
+ velocity: Vector3.Zero,
+ cellId: 0xA9B40001u,
+ position: Vector3.Zero,
+ rotation: Quaternion.Identity,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0);
+
+ Assert.Equal(56, body.Length - 12);
+ Assert.Equal(68, body.Length);
+ Assert.Equal(0, body.Length % 4);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateGoldenTests.cs b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateGoldenTests.cs
new file mode 100644
index 00000000..83c0bd19
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateGoldenTests.cs
@@ -0,0 +1,254 @@
+using System;
+using System.Buffers.Binary;
+using System.Numerics;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Full-message golden-byte tests for the refactored
+/// (D1 default-difference RawMotionState packing + D3 trailing byte), porting
+/// MoveToStatePack::Pack (0x005168f0, decomp lines ~284694-284722).
+/// Confirmed verbatim against the Ghidra decompile-by-address bridge during
+/// this slice (2026-06-30):
+///
+///
+/// MoveToStatePack::Pack:
+/// RawMotionState::Pack(...)
+/// Position::Pack(...)
+/// instance_timestamp(u16), server_control_timestamp(u16),
+/// teleport_timestamp(u16), force_position_ts(u16)
+/// trailing byte = ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0)
+/// ALIGN_PTR
+///
+///
+public class MoveToStateGoldenTests
+{
+ private static readonly Vector3 Pos = new(96f, 96f, 50f);
+ private static readonly Quaternion Rot = Quaternion.Identity;
+
+ [Fact]
+ public void Build_DefaultRawMotionState_FlagsAreZero_EnvelopePlusPositionPlusTimestampsPlusTrailingByte()
+ {
+ var body = MoveToState.Build(
+ gameActionSequence: 1,
+ rawMotionState: RawMotionState.Default,
+ cellId: 0xA9B40001u,
+ position: Pos,
+ rotation: Rot,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0,
+ contact: true,
+ standingLongjump: false);
+
+ // 12 (envelope) + 4 (flags=0, no fields) + 32 (Position) + 8 (timestamps)
+ // + 1 (trailing byte) = 57, aligned to 60.
+ Assert.Equal(60, body.Length);
+
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
+ Assert.Equal(0u, flags);
+
+ uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16));
+ Assert.Equal(0xA9B40001u, cellId);
+
+ // trailing byte at 12+4+32+8 = 56: contact=true, longjump=false -> 0x01
+ Assert.Equal(0x01, body[56]);
+ }
+
+ [Fact]
+ public void Build_WalkForward_OmitsForwardSpeedDefault()
+ {
+ // Walk-forward at default speed 1.0 -> forward_speed bit OMITTED (D1 fix).
+ var state = new RawMotionState
+ {
+ CurrentHoldKey = HoldKey.None, // default, omitted
+ ForwardCommand = 0x45000005u, // WalkForward
+ ForwardHoldKey = HoldKey.None, // differs from Invalid -> set
+ ForwardSpeed = 1.0f, // default, omitted
+ };
+
+ var body = MoveToState.Build(
+ gameActionSequence: 2,
+ rawMotionState: state,
+ cellId: 0xA9B40001u,
+ position: Pos,
+ rotation: Rot,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0,
+ contact: true,
+ standingLongjump: false);
+
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
+ Assert.Equal(0x0000000Cu, flags); // ForwardCommand | ForwardHoldKey only
+
+ // RawMotionState body = flags(4) + fwd_cmd(4) + fwd_holdkey(4) = 12.
+ uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12 + 12));
+ Assert.Equal(0xA9B40001u, cellId);
+ }
+
+ [Fact]
+ public void Build_RunForward_IncludesHoldKeyAndSpeed()
+ {
+ var state = new RawMotionState
+ {
+ CurrentHoldKey = HoldKey.Run,
+ ForwardCommand = 0x44000007u, // RunForward
+ ForwardHoldKey = HoldKey.Run,
+ ForwardSpeed = 2.94f,
+ };
+
+ var body = MoveToState.Build(
+ gameActionSequence: 3,
+ rawMotionState: state,
+ cellId: 0xA9B40001u,
+ position: Pos,
+ rotation: Rot,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0,
+ contact: true,
+ standingLongjump: false);
+
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
+ Assert.Equal(0x0000001Du, flags); // holdkey | fwd_cmd | fwd_holdkey | fwd_speed
+
+ // RawMotionState body = flags(4) + holdkey(4) + fwd_cmd(4) + fwd_holdkey(4) + fwd_speed(4) = 20.
+ uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12 + 20));
+ Assert.Equal(0xA9B40001u, cellId);
+ }
+
+ [Fact]
+ public void Build_Sidestep_SetsSidestepBitsOnly()
+ {
+ var state = new RawMotionState
+ {
+ SidestepCommand = 0x6500000Fu, // SideStepRight
+ SidestepHoldKey = HoldKey.None,
+ SidestepSpeed = 1.0f, // default -> omitted
+ };
+
+ var body = MoveToState.Build(
+ gameActionSequence: 4,
+ rawMotionState: state,
+ cellId: 0xA9B40001u,
+ position: Pos,
+ rotation: Rot,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0,
+ contact: true,
+ standingLongjump: false);
+
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
+ Assert.Equal(0x00000060u, flags); // SidestepCommand(0x20) | SidestepHoldKey(0x40)
+ }
+
+ [Fact]
+ public void Build_Turn_SetsTurnBitsOnly()
+ {
+ var state = new RawMotionState
+ {
+ TurnCommand = 0x6500000Du, // TurnRight
+ TurnHoldKey = HoldKey.None,
+ TurnSpeed = 1.0f, // default -> omitted
+ };
+
+ var body = MoveToState.Build(
+ gameActionSequence: 5,
+ rawMotionState: state,
+ cellId: 0xA9B40001u,
+ position: Pos,
+ rotation: Rot,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0,
+ contact: true,
+ standingLongjump: false);
+
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
+ Assert.Equal(0x00000300u, flags); // TurnCommand(0x100) | TurnHoldKey(0x200)
+ }
+
+ [Theory]
+ [InlineData(false, false, 0x00)]
+ [InlineData(true, false, 0x01)]
+ [InlineData(false, true, 0x02)]
+ [InlineData(true, true, 0x03)]
+ public void Build_TrailingByte_AllFourContactLongjumpCombinations(bool contact, bool standingLongjump, byte expected)
+ {
+ // MoveToStatePack::Pack trailing byte:
+ // ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0)
+ var body = MoveToState.Build(
+ gameActionSequence: 6,
+ rawMotionState: RawMotionState.Default,
+ cellId: 0xA9B40001u,
+ position: Pos,
+ rotation: Rot,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0,
+ contact: contact,
+ standingLongjump: standingLongjump);
+
+ // flags(4) + Position(32) + timestamps(8) = 44; trailing byte at 12+44=56.
+ Assert.Equal(expected, body[56]);
+ }
+
+ [Fact]
+ public void Build_TimestampOrder_MatchesMoveToStatePackPack()
+ {
+ var body = MoveToState.Build(
+ gameActionSequence: 7,
+ rawMotionState: RawMotionState.Default,
+ cellId: 0xA9B40001u,
+ position: Pos,
+ rotation: Rot,
+ instanceSequence: 0x1111,
+ serverControlSequence: 0x2222,
+ teleportSequence: 0x3333,
+ forcePositionSequence: 0x4444,
+ contact: true,
+ standingLongjump: false);
+
+ // 12 (envelope) + 4 (flags) + 32 (Position) = 48.
+ int tsOffset = 48;
+ ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset));
+ ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2));
+ ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4));
+ ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6));
+
+ Assert.Equal((ushort)0x1111, instance);
+ Assert.Equal((ushort)0x2222, serverControl);
+ Assert.Equal((ushort)0x3333, teleport);
+ Assert.Equal((ushort)0x4444, forcePosition);
+ }
+
+ [Fact]
+ public void Build_IsAlignedTo4Bytes()
+ {
+ var body = MoveToState.Build(
+ gameActionSequence: 8,
+ rawMotionState: RawMotionState.Default,
+ cellId: 0xA9B40001u,
+ position: Pos,
+ rotation: Rot,
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0,
+ contact: true,
+ standingLongjump: false);
+
+ Assert.Equal(0, body.Length % 4);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
index 53f95d60..d5729940 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs
@@ -2,26 +2,36 @@ using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
+///
+/// Envelope + structural tests for . Golden
+/// byte-layout tests for the RawMotionState default-difference packing (D1)
+/// and the trailing contact/longjump byte (D3) live in
+/// and .
+///
+/// D1/D3 refactor (2026-06-30): Build now takes a
+/// snapshot (matching retail's
+/// CPhysicsObj::InqRawMotionState()) instead of flat nullable
+/// per-axis params, plus explicit contact/standingLongjump
+/// booleans for the trailing byte — see MoveToStatePack::Pack
+/// (0x005168f0).
+///
public class MoveToStateTests
{
+ private static readonly Vector3 Pos = new(96f, 96f, 50f);
+
[Fact]
public void Build_IdleState_ProducesValidGameAction()
{
var body = MoveToState.Build(
gameActionSequence: 1,
- forwardCommand: null,
- forwardSpeed: null,
- sidestepCommand: null,
- sidestepSpeed: null,
- turnCommand: null,
- turnSpeed: null,
- holdKey: null,
+ rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
- position: new Vector3(96f, 96f, 50f),
+ position: Pos,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
@@ -42,31 +52,33 @@ public class MoveToStateTests
}
[Fact]
- public void Build_WalkForward_IncludesForwardCommandInFlags()
+ public void Build_WalkForward_DefaultSpeedOmitted_OnlyCommandAndHoldKeyFlagsSet()
{
+ // D1 fix: forward_speed == 1.0 (the retail default) is OMITTED from
+ // the flags, unlike the pre-slice presence-based packer which always
+ // set the ForwardSpeed bit whenever a caller supplied a value.
+ var state = new RawMotionState
+ {
+ ForwardCommand = 0x45000005u, // WalkForward
+ ForwardHoldKey = HoldKey.None, // differs from Invalid -> set
+ ForwardSpeed = 1.0f, // default -> omitted
+ };
+
var body = MoveToState.Build(
gameActionSequence: 2,
- forwardCommand: 0x45000005u, // WalkForward
- forwardSpeed: 1.0f,
- sidestepCommand: null,
- sidestepSpeed: null,
- turnCommand: null,
- turnSpeed: null,
- holdKey: null,
+ rawMotionState: state,
cellId: 0xA9B40001u,
- position: new Vector3(96f, 96f, 50f),
+ position: Pos,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
- // After the 12-byte GameAction header comes RawMotionState.
- // First u32 is the packed flags word. ForwardCommand flag = 0x4.
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.True((flags & 0x4u) != 0, "ForwardCommand flag (0x4) should be set");
- // ForwardSpeed flag = 0x10
- Assert.True((flags & 0x10u) != 0, "ForwardSpeed flag (0x10) should be set");
+ Assert.True((flags & 0x8u) != 0, "ForwardHoldKey flag (0x8) should be set");
+ Assert.False((flags & 0x10u) != 0, "ForwardSpeed flag (0x10) must be OMITTED at the retail default 1.0");
}
[Fact]
@@ -74,13 +86,7 @@ public class MoveToStateTests
{
var body = MoveToState.Build(
gameActionSequence: 3,
- forwardCommand: null,
- forwardSpeed: null,
- sidestepCommand: null,
- sidestepSpeed: null,
- turnCommand: null,
- turnSpeed: null,
- holdKey: null,
+ rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@@ -94,19 +100,14 @@ public class MoveToStateTests
}
[Fact]
- public void Build_IdleState_WorldPositionFollowsMotionState()
+ public void Build_IdleState_WorldPositionFollowsZeroFlagMotionState()
{
- // With no motion state, flags = 0 and no conditional fields are written.
- // So WorldPosition starts at offset 12 (envelope) + 4 (flags) = 16.
+ // With the default raw motion state, flags = 0 and no conditional
+ // fields are written. So WorldPosition starts at offset 12
+ // (envelope) + 4 (flags) = 16.
var body = MoveToState.Build(
gameActionSequence: 4,
- forwardCommand: null,
- forwardSpeed: null,
- sidestepCommand: null,
- sidestepSpeed: null,
- turnCommand: null,
- turnSpeed: null,
- holdKey: null,
+ rawMotionState: RawMotionState.Default,
cellId: 0xDEADBEEFu,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@@ -124,13 +125,7 @@ public class MoveToStateTests
{
var body = MoveToState.Build(
gameActionSequence: 5,
- forwardCommand: null,
- forwardSpeed: null,
- sidestepCommand: null,
- sidestepSpeed: null,
- turnCommand: null,
- turnSpeed: null,
- holdKey: null,
+ rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@@ -143,17 +138,11 @@ public class MoveToStateTests
}
[Fact]
- public void Build_UsesExplicitAirborneContactByte()
+ public void Build_UsesExplicitAirborneContact()
{
var body = MoveToState.Build(
gameActionSequence: 7,
- forwardCommand: null,
- forwardSpeed: null,
- sidestepCommand: null,
- sidestepSpeed: null,
- turnCommand: null,
- turnSpeed: null,
- holdKey: null,
+ rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@@ -161,23 +150,20 @@ public class MoveToStateTests
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
- contactLongJump: 0);
+ contact: false);
+ // flags(4) + Position(32) + timestamps(8) = 44; trailing byte at 12+44=56.
Assert.Equal(0, body[56]);
}
[Fact]
public void Build_WithHoldKey_IncludesHoldKeyFlag()
{
+ var state = new RawMotionState { CurrentHoldKey = HoldKey.Run };
+
var body = MoveToState.Build(
gameActionSequence: 6,
- forwardCommand: null,
- forwardSpeed: null,
- sidestepCommand: null,
- sidestepSpeed: null,
- turnCommand: null,
- turnSpeed: null,
- holdKey: 2u, // Run
+ rawMotionState: state,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
diff --git a/tests/AcDream.Core.Net.Tests/Messages/PositionPackTests.cs b/tests/AcDream.Core.Net.Tests/Messages/PositionPackTests.cs
new file mode 100644
index 00000000..95d8d983
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/PositionPackTests.cs
@@ -0,0 +1,63 @@
+using System.Buffers.Binary;
+using System.Numerics;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Pins the shared WorldPosition/Position block byte order used by
+/// , , and
+/// : Position::Pack (0x005a9640) wraps
+/// cellId(u32) then Frame::Pack (0x00535130) =
+/// origin.x/y/z(f32) then qw/qx/qy/qz(f32). Confirmed verbatim
+/// against the Ghidra decompile-by-address bridge during this slice
+/// (2026-06-30):
+///
+///
+/// Position::Pack: objcell_id(u32), Frame::Pack(...)
+/// Frame::Pack: m_fOrigin.x/y/z(f32 x3), qw(f32), qx(f32), qy(f32), qz(f32)
+///
+///
+/// 32 bytes total (4 + 12 + 16). This was already correct pre-slice — these
+/// tests lock the byte order with a golden-value assertion rather than
+/// changing behavior.
+///
+public class PositionPackTests
+{
+ [Fact]
+ public void MoveToState_PositionBlock_OrderIsCellIdThenOriginThenQuaternion()
+ {
+ var body = MoveToState.Build(
+ gameActionSequence: 1,
+ rawMotionState: RawMotionState.Default,
+ cellId: 0xA9B40001u,
+ position: new Vector3(1.5f, 2.5f, 3.5f),
+ rotation: new Quaternion(0.1f, 0.2f, 0.3f, 0.9f), // X,Y,Z,W ctor order
+ instanceSequence: 0,
+ serverControlSequence: 0,
+ teleportSequence: 0,
+ forcePositionSequence: 0);
+
+ // No motion state -> flags(4) only before the Position block at offset 16.
+ int off = 12 + 4;
+ uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(off));
+ float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 4));
+ float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 8));
+ float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 12));
+ float qw = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 16));
+ float qx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 20));
+ float qy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 24));
+ float qz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 28));
+
+ Assert.Equal(0xA9B40001u, cellId);
+ Assert.Equal(1.5f, x);
+ Assert.Equal(2.5f, y);
+ Assert.Equal(3.5f, z);
+ Assert.Equal(0.9f, qw);
+ Assert.Equal(0.1f, qx);
+ Assert.Equal(0.2f, qy);
+ Assert.Equal(0.3f, qz);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/RawMotionStatePackTests.cs b/tests/AcDream.Core.Net.Tests/Messages/RawMotionStatePackTests.cs
new file mode 100644
index 00000000..29025841
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/RawMotionStatePackTests.cs
@@ -0,0 +1,212 @@
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Net.Packets;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Golden-byte tests for , porting
+/// retail's RawMotionState::Pack (0x0051ed10, decomp lines
+/// ~293761-294013; bitfield layout acclient.h RawMotionState::PackBitfield,
+/// line 46474). Confirmed verbatim against the Ghidra decompile-by-address
+/// bridge (http://127.0.0.1:8081/decompile_function?address=0x0051ed10)
+/// during this slice (2026-06-30).
+///
+///
+/// Retail compares every field against its DEFAULT and only sets the
+/// matching bit (and emits the field) when the live value DIFFERS. This is
+/// the D1 fix — the old presence-based packer over-sent defaulted fields.
+///
+///
+public class RawMotionStatePackTests
+{
+ private static byte[] Pack(RawMotionState state)
+ {
+ var w = new PacketWriter(64);
+ RawMotionStatePacker.Pack(w, state);
+ return w.ToArray();
+ }
+
+ [Fact]
+ public void Pack_DefaultState_EmitsOnlyZeroFlags()
+ {
+ // Every field equals its retail default -> flags dword is 0,
+ // no conditional fields, no actions. 4 bytes total.
+ var body = Pack(RawMotionState.Default);
+
+ Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, body);
+ }
+
+ [Fact]
+ public void Pack_ShiftWalk_OmitsForwardSpeedAndCurrentHoldKey()
+ {
+ // Shift-walk: current_holdkey stays None (default omitted),
+ // forward_command = WalkForward (0x45000005), forward_holdkey =
+ // None (1) -- DIFFERS from default Invalid(0) so IS sent --
+ // forward_speed stays 1.0 (default, omitted). Rest default.
+ //
+ // Flags expected: ForwardCommand(0x004) | ForwardHoldKey(0x008) = 0x00C.
+ // Body: flags(u32) + forward_command(u32) + forward_holdkey(u32).
+ var state = new RawMotionState
+ {
+ CurrentHoldKey = HoldKey.None, // default -> omitted
+ ForwardCommand = 0x45000005u, // WalkForward -> differs -> set
+ ForwardHoldKey = HoldKey.None, // differs from Invalid -> set
+ ForwardSpeed = 1.0f, // default -> omitted (the D1 fix)
+ };
+
+ var body = Pack(state);
+
+ Assert.Equal(12, body.Length);
+
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
+ Assert.Equal(0x0000000Cu, flags);
+
+ uint fwdCommand = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
+ Assert.Equal(0x45000005u, fwdCommand);
+
+ uint fwdHoldKey = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
+ Assert.Equal(1u, fwdHoldKey); // HoldKey.None
+
+ byte[] expected =
+ {
+ 0x0C, 0x00, 0x00, 0x00,
+ 0x05, 0x00, 0x00, 0x45,
+ 0x01, 0x00, 0x00, 0x00,
+ };
+ Assert.Equal(expected, body);
+ }
+
+ [Fact]
+ public void Pack_RunForward_SetsHoldKeyForwardCommandHoldKeyAndSpeed()
+ {
+ // Run-forward: current_holdkey = Run(2) (differs from None -> set),
+ // forward_command = RunForward-ish 0x44000007 (differs -> set),
+ // forward_holdkey = Run(2) (differs from Invalid -> set),
+ // forward_speed = 3.0 (differs from 1.0 -> set).
+ //
+ // Flags expected: CurrentHoldKey(0x001) | ForwardCommand(0x004) |
+ // ForwardHoldKey(0x008) | ForwardSpeed(0x010) = 0x01D.
+ var state = new RawMotionState
+ {
+ CurrentHoldKey = HoldKey.Run,
+ ForwardCommand = 0x44000007u,
+ ForwardHoldKey = HoldKey.Run,
+ ForwardSpeed = 3.0f,
+ };
+
+ var body = Pack(state);
+
+ Assert.Equal(20, body.Length);
+
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
+ Assert.Equal(0x0000001Du, flags);
+
+ byte[] expected =
+ {
+ 0x1D, 0x00, 0x00, 0x00, // flags
+ 0x02, 0x00, 0x00, 0x00, // current_holdkey = Run(2)
+ 0x07, 0x00, 0x00, 0x44, // forward_command = 0x44000007
+ 0x02, 0x00, 0x00, 0x00, // forward_holdkey = Run(2)
+ 0x00, 0x00, 0x40, 0x40, // forward_speed = 3.0f (0x40400000 LE)
+ };
+ Assert.Equal(expected, body);
+ }
+
+ [Fact]
+ public void Pack_NonDefaultCurrentStyle_SetsStyleBitAndEmitsValue()
+ {
+ // current_style differs from 0x8000003D -> bit 0x002 set, value emitted
+ // immediately after the flags dword (bit order: holdkey, style, ...).
+ var state = new RawMotionState
+ {
+ CurrentStyle = 0x80000042u,
+ };
+
+ var body = Pack(state);
+
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
+ Assert.Equal(0x002u, flags);
+
+ uint style = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
+ Assert.Equal(0x80000042u, style);
+
+ Assert.Equal(8, body.Length);
+ }
+
+ [Fact]
+ public void Pack_PopulatedActionsList_SetsNumActionsBitsAndEmitsPairs()
+ {
+ // num_actions occupies bits 11-15 (mask 0xF800) of the flags dword.
+ // Two actions -> num_actions = 2 -> bits = 2 << 11 = 0x1000.
+ // Each action emits u16 command then u16 (stamp & 0x7FFF) |
+ // (autonomous ? 0x8000 : 0) (decomp ~293998-294010).
+ var state = new RawMotionState
+ {
+ Actions = new[]
+ {
+ new RawMotionAction(Command: 0x0150, Stamp: 0x0001, Autonomous: false),
+ new RawMotionAction(Command: 0x0163, Stamp: 0x7FFF, Autonomous: true),
+ },
+ };
+
+ var body = Pack(state);
+
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
+ Assert.Equal(0x1000u, flags); // num_actions=2 << 11, no continuous-axis bits
+
+ // Body: flags(4) + action0(4) + action1(4) = 12 bytes.
+ Assert.Equal(12, body.Length);
+
+ ushort cmd0 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(4));
+ ushort stamp0 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(6));
+ Assert.Equal((ushort)0x0150, cmd0);
+ Assert.Equal((ushort)0x0001, stamp0); // autonomous=false -> 0x8000 bit clear
+
+ ushort cmd1 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(8));
+ ushort stamp1 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(10));
+ Assert.Equal((ushort)0x0163, cmd1);
+ Assert.Equal((ushort)0xFFFF, stamp1); // (0x7FFF & 0x7FFF) | 0x8000 = 0xFFFF
+ }
+
+ [Fact]
+ public void Pack_SidestepAndTurnNonDefault_SetExpectedBitsInOrder()
+ {
+ // sidestep_command(0x020), sidestep_holdkey(0x040), sidestep_speed(0x080),
+ // turn_command(0x100), turn_holdkey(0x200), turn_speed(0x400) all non-default.
+ var state = new RawMotionState
+ {
+ SidestepCommand = 0x44000009u,
+ SidestepHoldKey = HoldKey.Run,
+ SidestepSpeed = 1.248f,
+ TurnCommand = 0x4400000Du,
+ TurnHoldKey = HoldKey.Run,
+ TurnSpeed = 1.5f,
+ };
+
+ var body = Pack(state);
+
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
+ Assert.Equal(0x000007E0u, flags); // 0x20|0x40|0x80|0x100|0x200|0x400
+
+ // Body order after flags: sidestep_command, sidestep_holdkey,
+ // sidestep_speed, turn_command, turn_holdkey, turn_speed.
+ uint ssCmd = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
+ uint ssHold = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
+ float ssSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(12));
+ uint turnCmd = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16));
+ uint turnHold = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20));
+ float turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24));
+
+ Assert.Equal(0x44000009u, ssCmd);
+ Assert.Equal(2u, ssHold);
+ Assert.Equal(1.248f, ssSpeed);
+ Assert.Equal(0x4400000Du, turnCmd);
+ Assert.Equal(2u, turnHold);
+ Assert.Equal(1.5f, turnSpeed);
+
+ Assert.Equal(28, body.Length);
+ }
+}