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