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

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

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

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

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

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

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

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

View file

@ -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 1115) + 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 |
---

View file

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

View file

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

View file

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

View file

@ -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.01.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();
}

View file

@ -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 &amp; 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();

View 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);
}
}
}

View file

@ -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();
}

View 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 &amp; 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();
}

View file

@ -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()
{

View file

@ -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;
/// <summary>
/// Golden-byte tests for the retail-faithful <see cref="JumpAction.Build"/>
/// layout, porting <c>JumpPack::Pack</c> (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):
///
/// <code>
/// 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.
/// </code>
///
/// D4: retail does NOT pack an objectGuid or spellId. The pre-slice acdream
/// code wrote two spurious trailing <c>u32 0</c> fields and omitted Position
/// entirely — both are fixed here.
/// </summary>
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);
}
}

View file

@ -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;
/// <summary>
/// Full-message golden-byte tests for the refactored <see cref="MoveToState.Build"/>
/// (D1 default-difference RawMotionState packing + D3 trailing byte), porting
/// <c>MoveToStatePack::Pack</c> (0x005168f0, decomp lines ~284694-284722).
/// Confirmed verbatim against the Ghidra decompile-by-address bridge during
/// this slice (2026-06-30):
///
/// <code>
/// 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
/// </code>
/// </summary>
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);
}
}

View file

@ -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;
/// <summary>
/// Envelope + structural tests for <see cref="MoveToState.Build"/>. Golden
/// byte-layout tests for the RawMotionState default-difference packing (D1)
/// and the trailing contact/longjump byte (D3) live in
/// <see cref="MoveToStateGoldenTests"/> and <see cref="RawMotionStatePackTests"/>.
///
/// D1/D3 refactor (2026-06-30): <c>Build</c> now takes a
/// <see cref="RawMotionState"/> snapshot (matching retail's
/// <c>CPhysicsObj::InqRawMotionState()</c>) instead of flat nullable
/// per-axis params, plus explicit <c>contact</c>/<c>standingLongjump</c>
/// booleans for the trailing byte — see <c>MoveToStatePack::Pack</c>
/// (0x005168f0).
/// </summary>
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,

View file

@ -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;
/// <summary>
/// Pins the shared WorldPosition/Position block byte order used by
/// <see cref="MoveToState"/>, <see cref="AutonomousPosition"/>, and
/// <see cref="JumpAction"/>: <c>Position::Pack</c> (0x005a9640) wraps
/// <c>cellId(u32)</c> then <c>Frame::Pack</c> (0x00535130) =
/// <c>origin.x/y/z(f32)</c> then <c>qw/qx/qy/qz(f32)</c>. Confirmed verbatim
/// against the Ghidra decompile-by-address bridge during this slice
/// (2026-06-30):
///
/// <code>
/// Position::Pack: objcell_id(u32), Frame::Pack(...)
/// Frame::Pack: m_fOrigin.x/y/z(f32 x3), qw(f32), qx(f32), qy(f32), qz(f32)
/// </code>
///
/// 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.
/// </summary>
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);
}
}

View file

@ -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;
/// <summary>
/// Golden-byte tests for <see cref="RawMotionStatePacker.Pack"/>, porting
/// retail's <c>RawMotionState::Pack</c> (0x0051ed10, decomp lines
/// ~293761-294013; bitfield layout <c>acclient.h RawMotionState::PackBitfield</c>,
/// 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).
///
/// <para>
/// 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.
/// </para>
/// </summary>
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);
}
}