feat(L.2b): outbound movement wire parity — RawMotionState default-difference, JumpPack, contact byte
Ports the three retail outbound-movement packers verbatim (decomp-derived golden bytes, confirmed via the Ghidra bridge, cross-checked vs holtburger): - D1 — RawMotionState::Pack (0x0051ed10): new AcDream.Core.Physics.RawMotionState data type (11 fields + actions, retail defaults) + RawMotionStatePacker that sets a flag bit only when the field DIFFERS from its default. MoveToState.Build now takes a RawMotionState instead of presence-based nullable params, so the over-sent forwardSpeed=1.0 / currentHoldKey=None / default per-axis holdkeys are no longer emitted. num_actions packs into bits 11-15 (not "bits 11-31"). - D3 — MoveToStatePack::Pack (0x005168f0) trailing byte = (standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0); explicit contact/ standingLongjump params (standingLongjump=false honestly until the feature lands). - D4 — JumpAction rewritten to retail JumpPack::Pack (0x00516d10): extent, velocity, full Position, four u16 timestamps, align. Removed the spurious objectGuid/spellId u32s; Position is now packed (it was absent). Body 56 bytes. - Position::Pack (0x005a9640) / Frame::Pack (0x00535130) verified already-correct (cellId, origin xyz, quaternion wxyz); locked with a golden test, no change. GameWindow callers adapted minimally: build the RawMotionState from the existing MovementResult values (behavior preserved except the intended D1 omissions) and pass cellId/position/rotation to the Jump send. Pre-existing MotionInterpreter placeholder struct RawMotionState renamed LegacyRawMotionState (D6/Phase-2 scope, pure rename) to free the name for the retail-faithful type. D5 audit: confirmed a real divergence — retail SendMovementEvent (0x006b4680) stamps only last_sent_position_time after an MTS while SendPositionEvent (0x006b4770) stamps all three; acdream's NotePositionSent stamps all three on both paths. Left unchanged (comments added at both call sites), recorded as register TS-33, deferred to a dedicated cadence-port slice. Tests: RawMotionStatePackTests / MoveToStateGoldenTests / JumpActionTests / PositionPackTests + updated MoveToStateTests / AutonomousPositionTests. Full suite green (Core.Net.Tests 372, full solution 3228 passed / 4 pre-existing skips). Register: TS-24/TS-25 refreshed (packer now supports actions/style; runtime emission still deferred), TS-33 added. Roadmap L.2b shipped note added. Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md (2-6) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2c8620ea94
commit
78e163a41e
15 changed files with 1173 additions and 212 deletions
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -8263,29 +8263,45 @@ public sealed class GameWindow : IDisposable
|
|||
// semantic.
|
||||
if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
|
||||
{
|
||||
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
|
||||
// Invalid = 0, None = 1, Run = 2
|
||||
// HoldKey axis values — retail enum (acclient.h enum
|
||||
// HoldKey): Invalid = 0, None = 1, Run = 2.
|
||||
// Retail always sends CURRENT_HOLD_KEY (and uses the same
|
||||
// value for every active per-axis hold key — see
|
||||
// holtburger's build_motion_state_raw_motion_state).
|
||||
// When the player is running forward, 2=Run; otherwise 1=None.
|
||||
const uint HoldKeyNone = 1u;
|
||||
const uint HoldKeyRun = 2u;
|
||||
uint axisHoldKey = result.IsRunning ? HoldKeyRun : HoldKeyNone;
|
||||
// When the player is running forward, Run; otherwise None.
|
||||
var axisHoldKey = result.IsRunning
|
||||
? AcDream.Core.Physics.HoldKey.Run
|
||||
: AcDream.Core.Physics.HoldKey.None;
|
||||
|
||||
// D1/L.2b (2026-06-30): build the COMPLETE RawMotionState
|
||||
// snapshot (matching retail's CPhysicsObj::InqRawMotionState())
|
||||
// and let RawMotionStatePacker's default-difference comparison
|
||||
// decide what actually goes on the wire — see
|
||||
// RawMotionStatePacker (ports RawMotionState::Pack, 0x0051ed10).
|
||||
// This slice changes ONLY the packing; the state values
|
||||
// below are exactly what the pre-slice code passed in.
|
||||
var rawMotionState = new AcDream.Core.Physics.RawMotionState
|
||||
{
|
||||
CurrentHoldKey = axisHoldKey,
|
||||
// CurrentStyle: not tracked by acdream today — leave at
|
||||
// the retail default (0x8000003D) so the packer omits it.
|
||||
ForwardCommand = result.ForwardCommand ?? AcDream.Core.Physics.RawMotionState.Default.ForwardCommand,
|
||||
ForwardHoldKey = result.ForwardCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
|
||||
ForwardSpeed = result.ForwardSpeed ?? AcDream.Core.Physics.RawMotionState.Default.ForwardSpeed,
|
||||
SidestepCommand = result.SidestepCommand ?? AcDream.Core.Physics.RawMotionState.Default.SidestepCommand,
|
||||
SidestepHoldKey = result.SidestepCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
|
||||
SidestepSpeed = result.SidestepSpeed ?? AcDream.Core.Physics.RawMotionState.Default.SidestepSpeed,
|
||||
TurnCommand = result.TurnCommand ?? AcDream.Core.Physics.RawMotionState.Default.TurnCommand,
|
||||
TurnHoldKey = result.TurnCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
|
||||
TurnSpeed = result.TurnSpeed ?? AcDream.Core.Physics.RawMotionState.Default.TurnSpeed,
|
||||
// Actions: acdream does not yet emit discrete motion
|
||||
// events on this path (D2, deferred to Phase 2+).
|
||||
};
|
||||
|
||||
var seq = _liveSession.NextGameActionSequence();
|
||||
var body = AcDream.Core.Net.Messages.MoveToState.Build(
|
||||
gameActionSequence: seq,
|
||||
forwardCommand: result.ForwardCommand,
|
||||
forwardSpeed: result.ForwardSpeed,
|
||||
sidestepCommand: result.SidestepCommand,
|
||||
sidestepSpeed: result.SidestepSpeed,
|
||||
turnCommand: result.TurnCommand,
|
||||
turnSpeed: result.TurnSpeed,
|
||||
holdKey: axisHoldKey, // always present
|
||||
forwardHoldKey: result.ForwardCommand.HasValue ? axisHoldKey : (uint?)null,
|
||||
sidestepHoldKey: result.SidestepCommand.HasValue ? axisHoldKey : (uint?)null,
|
||||
turnHoldKey: result.TurnCommand.HasValue ? axisHoldKey : (uint?)null,
|
||||
rawMotionState: rawMotionState,
|
||||
cellId: wireCellId,
|
||||
position: wirePos,
|
||||
rotation: wireRot,
|
||||
|
|
@ -8293,15 +8309,25 @@ public sealed class GameWindow : IDisposable
|
|||
serverControlSequence: _liveSession.ServerControlSequence,
|
||||
teleportSequence: _liveSession.TeleportSequence,
|
||||
forcePositionSequence: _liveSession.ForcePositionSequence,
|
||||
contactLongJump: contactByte);
|
||||
contact: contactByte != 0,
|
||||
// Standing longjump is not implemented yet — honest
|
||||
// current value, not a guess (D3).
|
||||
standingLongjump: false);
|
||||
DumpMovementTruthOutbound(
|
||||
"MTS", seq, result, wirePos, wireCellId, contactByte);
|
||||
_liveSession.SendGameAction(body);
|
||||
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
|
||||
// HeartbeatDue resets its interval from THIS send — mirrors retail's
|
||||
// SendMovementEvent (acclient_2013_pseudo_c.txt:0x006b4680) writing
|
||||
// last_sent_position_time + last_sent_position + contact_plane
|
||||
// after each MTS send.
|
||||
// D5 audit (2026-06-30, this slice): retail's
|
||||
// SendMovementEvent (acclient_2013_pseudo_c.txt, 0x006b4680)
|
||||
// stamps ONLY last_sent_position_time after an MTS send — it
|
||||
// does NOT update last_sent_position or
|
||||
// last_sent_contact_plane (confirmed via Ghidra
|
||||
// decompile-by-address during this slice). Only
|
||||
// SendPositionEvent (the AP path, below) stamps all three.
|
||||
// acdream's NotePositionSent call here stamps all three on
|
||||
// BOTH paths — this is a real, audit-confirmed divergence
|
||||
// from retail. Per this slice's scope (D5 = audit only,
|
||||
// change only with explicit sign-off), left UNCHANGED;
|
||||
// reported to the lead engineer instead of fixed here.
|
||||
_playerController.NotePositionSent(
|
||||
worldPos: _playerController.Position,
|
||||
cellId: _playerController.CellId,
|
||||
|
|
@ -8327,9 +8353,13 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSession.SendGameAction(body);
|
||||
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
|
||||
// HeartbeatDue resets its interval from THIS send — mirrors retail's
|
||||
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348)
|
||||
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348,
|
||||
// decomp address 0x006b4770, re-confirmed via the Ghidra
|
||||
// decompile-by-address bridge during the L.2b slice 2026-06-30)
|
||||
// writing last_sent_position_time + last_sent_position +
|
||||
// last_sent_contact_plane after each AP.
|
||||
// last_sent_contact_plane after each AP. Unlike the MTS path
|
||||
// above (SendMovementEvent, 0x006b4680), which retail stamps
|
||||
// ONLY last_sent_position_time, this AP path is correct.
|
||||
_playerController.NotePositionSent(
|
||||
worldPos: _playerController.Position,
|
||||
cellId: _playerController.CellId,
|
||||
|
|
@ -8339,11 +8369,17 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
|
||||
{
|
||||
// D4/L.2b (2026-06-30): JumpPack::Pack (0x00516d10) packs the
|
||||
// full Position, not an objectGuid/spellId — pass the same
|
||||
// wireCellId/wirePos/wireRot the MoveToState send above uses.
|
||||
var seq = _liveSession.NextGameActionSequence();
|
||||
var jumpBody = AcDream.Core.Net.Messages.JumpAction.Build(
|
||||
gameActionSequence: seq,
|
||||
extent: result.JumpExtent.Value,
|
||||
velocity: result.JumpVelocity.Value,
|
||||
cellId: wireCellId,
|
||||
position: wirePos,
|
||||
rotation: wireRot,
|
||||
instanceSequence: _liveSession.InstanceSequence,
|
||||
serverControlSequence: _liveSession.ServerControlSequence,
|
||||
teleportSequence: _liveSession.TeleportSequence,
|
||||
|
|
|
|||
|
|
@ -9,18 +9,27 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// the extent + velocity to validate the jump and replicate it to nearby
|
||||
/// clients.
|
||||
///
|
||||
/// Wire layout (from holtburger JumpActionData::pack):
|
||||
/// u32 0xF7B1 (GameAction envelope)
|
||||
/// u32 sequence
|
||||
/// u32 0xF61B (Jump sub-opcode)
|
||||
/// f32 extent (0.0–1.0 charge power)
|
||||
/// f32 velocity.x, f32 velocity.y, f32 velocity.z
|
||||
/// u16 instanceSequence
|
||||
/// u16 serverControlSequence
|
||||
/// u16 teleportSequence
|
||||
/// u16 forcePositionSequence
|
||||
/// u32 objectGuid (0 for normal jump)
|
||||
/// u32 spellId (0 for normal jump)
|
||||
/// <para>
|
||||
/// Wire layout — ports retail's <c>JumpPack::Pack</c> verbatim
|
||||
/// (<c>0x00516d10</c>, decomp lines ~284934-284963). Confirmed against the
|
||||
/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>GameAction envelope</b>: u32 0xF7B1, u32 sequence, u32 0xF61B</item>
|
||||
/// <item><b>f32 extent</b> (0.0-1.0 charge power)</item>
|
||||
/// <item><b>f32 velocity.x, velocity.y, velocity.z</b></item>
|
||||
/// <item><b>Position::Pack</b>: u32 cellId, f32 x, f32 y, f32 z,
|
||||
/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)</item>
|
||||
/// <item><b>Sequences</b>: u16 instance, u16 serverControl,
|
||||
/// u16 teleport, u16 forcePosition</item>
|
||||
/// <item><b>Align to 4 bytes</b></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>D4 fix.</b> Retail's <c>JumpPack</c> does NOT pack an objectGuid or a
|
||||
/// spellId, and DOES pack the full <c>Position</c> — the pre-slice code had
|
||||
/// it backwards (two spurious trailing zero u32s, no Position at all).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class JumpAction
|
||||
{
|
||||
|
|
@ -28,29 +37,45 @@ public static class JumpAction
|
|||
public const uint JumpOpcode = 0xF61Bu;
|
||||
|
||||
public static byte[] Build(
|
||||
uint gameActionSequence,
|
||||
float extent,
|
||||
Vector3 velocity,
|
||||
ushort instanceSequence,
|
||||
ushort serverControlSequence,
|
||||
ushort teleportSequence,
|
||||
ushort forcePositionSequence)
|
||||
uint gameActionSequence,
|
||||
float extent,
|
||||
Vector3 velocity,
|
||||
uint cellId,
|
||||
Vector3 position,
|
||||
Quaternion rotation,
|
||||
ushort instanceSequence,
|
||||
ushort serverControlSequence,
|
||||
ushort teleportSequence,
|
||||
ushort forcePositionSequence)
|
||||
{
|
||||
var w = new PacketWriter(48);
|
||||
var w = new PacketWriter(80);
|
||||
|
||||
w.WriteUInt32(GameActionOpcode);
|
||||
w.WriteUInt32(gameActionSequence);
|
||||
w.WriteUInt32(JumpOpcode);
|
||||
|
||||
w.WriteFloat(extent);
|
||||
w.WriteFloat(velocity.X);
|
||||
w.WriteFloat(velocity.Y);
|
||||
w.WriteFloat(velocity.Z);
|
||||
|
||||
// --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) ---
|
||||
w.WriteUInt32(cellId);
|
||||
w.WriteFloat(position.X);
|
||||
w.WriteFloat(position.Y);
|
||||
w.WriteFloat(position.Z);
|
||||
// Quaternion wire order: W, X, Y, Z
|
||||
w.WriteFloat(rotation.W);
|
||||
w.WriteFloat(rotation.X);
|
||||
w.WriteFloat(rotation.Y);
|
||||
w.WriteFloat(rotation.Z);
|
||||
|
||||
w.WriteUInt16(instanceSequence);
|
||||
w.WriteUInt16(serverControlSequence);
|
||||
w.WriteUInt16(teleportSequence);
|
||||
w.WriteUInt16(forcePositionSequence);
|
||||
w.WriteUInt32(0); // objectGuid — 0 for normal jump
|
||||
w.WriteUInt32(0); // spellId — 0 for normal jump
|
||||
|
||||
w.AlignTo4();
|
||||
|
||||
return w.ToArray();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Net.Packets;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
|
|
@ -11,61 +12,39 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// state and to drive interpolated position for other nearby clients.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (ported from
|
||||
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs</c>
|
||||
/// <c>MoveToStateActionData::pack</c> and
|
||||
/// <c>types.rs RawMotionState::pack</c>):
|
||||
/// Wire layout — ports retail's <c>MoveToStatePack::Pack</c> verbatim
|
||||
/// (<c>0x005168f0</c>, decomp lines ~284694-284722). Confirmed against the
|
||||
/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>GameAction envelope</b>: u32 0xF7B1, u32 sequence, u32 0xF61C</item>
|
||||
/// <item><b>RawMotionState</b>: u32 packed_flags (bits 0-10 = flag bits,
|
||||
/// bits 11-31 = command list length), then conditional u32/f32 fields
|
||||
/// in flag-bit order (see <c>RawMotionFlags</c> in types.rs)</item>
|
||||
/// <item><b>WorldPosition</b>: u32 cellId, f32 x, f32 y, f32 z,
|
||||
/// f32 rotW, f32 rotX, f32 rotY, f32 rotZ</item>
|
||||
/// <item><b>RawMotionState::Pack</b>: see <see cref="RawMotionStatePacker"/>
|
||||
/// — default-difference flags dword + conditional fields + actions.</item>
|
||||
/// <item><b>Position::Pack</b>: u32 cellId, f32 x, f32 y, f32 z,
|
||||
/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)</item>
|
||||
/// <item><b>Sequences</b>: u16 instance, u16 serverControl,
|
||||
/// u16 teleport, u16 forcePosition</item>
|
||||
/// <item><b>Contact byte</b>: u8 (1 = on ground, 0 = airborne)</item>
|
||||
/// <item><b>Trailing byte</b>: <c>(standingLongjump ? 0x02 : 0) |
|
||||
/// (contact ? 0x01 : 0)</c> — <c>MoveToStatePack::Pack</c> trailing
|
||||
/// byte expression <c>(longjump_mode == 0) - 1U & 2 |
|
||||
/// contact != 0</c>.</item>
|
||||
/// <item><b>Align to 4 bytes</b></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// The command list length is packed into bits 11-31 of the flags dword.
|
||||
/// We always send 0 commands (no discrete motion events), so those bits stay 0.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MoveToState
|
||||
{
|
||||
public const uint GameActionOpcode = 0xF7B1u;
|
||||
public const uint MoveToStateAction = 0xF61Cu;
|
||||
|
||||
// RawMotionFlags bit positions (from holtburger types.rs)
|
||||
private const uint FlagCurrentHoldKey = 0x001u;
|
||||
private const uint FlagCurrentStyle = 0x002u;
|
||||
private const uint FlagForwardCommand = 0x004u;
|
||||
private const uint FlagForwardHoldKey = 0x008u;
|
||||
private const uint FlagForwardSpeed = 0x010u;
|
||||
private const uint FlagSidestepCommand = 0x020u;
|
||||
private const uint FlagSidestepHoldKey = 0x040u;
|
||||
private const uint FlagSidestepSpeed = 0x080u;
|
||||
private const uint FlagTurnCommand = 0x100u;
|
||||
private const uint FlagTurnHoldKey = 0x200u;
|
||||
private const uint FlagTurnSpeed = 0x400u;
|
||||
|
||||
/// <summary>
|
||||
/// Build a MoveToState GameAction body.
|
||||
/// </summary>
|
||||
/// <param name="gameActionSequence">Monotonically increasing counter from
|
||||
/// <see cref="WorldSession.NextGameActionSequence"/>.</param>
|
||||
/// <param name="forwardCommand">Raw motion command (u32 in AC's command space),
|
||||
/// e.g. 0x45000005 = WalkForward. Null = no forward command.</param>
|
||||
/// <param name="forwardSpeed">Normalised speed scalar (0.0-1.0). Only written
|
||||
/// when <paramref name="forwardCommand"/> is non-null.</param>
|
||||
/// <param name="sidestepCommand">Sidestep command or null.</param>
|
||||
/// <param name="sidestepSpeed">Sidestep speed or null.</param>
|
||||
/// <param name="turnCommand">Turn command or null.</param>
|
||||
/// <param name="turnSpeed">Turn speed or null.</param>
|
||||
/// <param name="holdKey">Hold-key state (1=None, 2=Run). Null = omit.</param>
|
||||
/// <param name="rawMotionState">Complete raw-motion snapshot, matching
|
||||
/// retail's <c>CPhysicsObj::InqRawMotionState()</c>. Fields equal to
|
||||
/// <see cref="RawMotionState.Default"/> are omitted from the wire
|
||||
/// (see <see cref="RawMotionStatePacker"/>).</param>
|
||||
/// <param name="cellId">Landblock cell ID (u32).</param>
|
||||
/// <param name="position">World-space position relative to the landblock.</param>
|
||||
/// <param name="rotation">Rotation quaternion. AC wire order is W, X, Y, Z.</param>
|
||||
|
|
@ -73,27 +52,22 @@ public static class MoveToState
|
|||
/// <param name="serverControlSequence">Server-control sequence number.</param>
|
||||
/// <param name="teleportSequence">Teleport sequence number.</param>
|
||||
/// <param name="forcePositionSequence">Force-position sequence number.</param>
|
||||
/// <param name="contactLongJump">1 if the character is on the ground, 0 if airborne.</param>
|
||||
/// <param name="contact">True if the character is on the ground.</param>
|
||||
/// <param name="standingLongjump">True during a standing (charged,
|
||||
/// stationary) longjump wind-up. Not yet implemented in acdream —
|
||||
/// callers pass <c>false</c> honestly until that feature lands.</param>
|
||||
public static byte[] Build(
|
||||
uint gameActionSequence,
|
||||
uint? forwardCommand,
|
||||
float? forwardSpeed,
|
||||
uint? sidestepCommand,
|
||||
float? sidestepSpeed,
|
||||
uint? turnCommand,
|
||||
float? turnSpeed,
|
||||
uint? holdKey,
|
||||
uint cellId,
|
||||
Vector3 position,
|
||||
Quaternion rotation,
|
||||
ushort instanceSequence,
|
||||
ushort serverControlSequence,
|
||||
ushort teleportSequence,
|
||||
ushort forcePositionSequence,
|
||||
byte contactLongJump = 1,
|
||||
uint? forwardHoldKey = null,
|
||||
uint? sidestepHoldKey = null,
|
||||
uint? turnHoldKey = null)
|
||||
uint gameActionSequence,
|
||||
RawMotionState rawMotionState,
|
||||
uint cellId,
|
||||
Vector3 position,
|
||||
Quaternion rotation,
|
||||
ushort instanceSequence,
|
||||
ushort serverControlSequence,
|
||||
ushort teleportSequence,
|
||||
ushort forcePositionSequence,
|
||||
bool contact = true,
|
||||
bool standingLongjump = false)
|
||||
{
|
||||
var w = new PacketWriter(128);
|
||||
|
||||
|
|
@ -102,43 +76,10 @@ public static class MoveToState
|
|||
w.WriteUInt32(gameActionSequence);
|
||||
w.WriteUInt32(MoveToStateAction);
|
||||
|
||||
// --- RawMotionState ---
|
||||
// Build the flags word. Command list length (bits 11-31) is always 0.
|
||||
// Field order matches holtburger's RawMotionState::pack — for any axis
|
||||
// where we send a COMMAND + SPEED, retail expects the matching
|
||||
// *_HOLD_KEY to accompany them (see holtburger's
|
||||
// build_motion_state_raw_motion_state). Without the per-axis hold
|
||||
// keys the server gets the flags but can't classify the input as a
|
||||
// continuously-held key, so other players see the character sliding
|
||||
// forward without an animation cycle.
|
||||
uint flags = 0u;
|
||||
if (holdKey.HasValue) flags |= FlagCurrentHoldKey;
|
||||
if (forwardCommand.HasValue) flags |= FlagForwardCommand;
|
||||
if (forwardHoldKey.HasValue) flags |= FlagForwardHoldKey;
|
||||
if (forwardSpeed.HasValue) flags |= FlagForwardSpeed;
|
||||
if (sidestepCommand.HasValue) flags |= FlagSidestepCommand;
|
||||
if (sidestepHoldKey.HasValue) flags |= FlagSidestepHoldKey;
|
||||
if (sidestepSpeed.HasValue) flags |= FlagSidestepSpeed;
|
||||
if (turnCommand.HasValue) flags |= FlagTurnCommand;
|
||||
if (turnHoldKey.HasValue) flags |= FlagTurnHoldKey;
|
||||
if (turnSpeed.HasValue) flags |= FlagTurnSpeed;
|
||||
// --- RawMotionState::Pack (0x0051ed10) ---
|
||||
RawMotionStatePacker.Pack(w, rawMotionState);
|
||||
|
||||
w.WriteUInt32(flags); // bits 0-10 = flags, bits 11-31 = 0 (no command list)
|
||||
|
||||
// Conditional fields in RawMotionFlags bit order:
|
||||
if (holdKey.HasValue) w.WriteUInt32(holdKey.Value);
|
||||
// FlagCurrentStyle (0x2): not sent — we don't track stance changes here
|
||||
if (forwardCommand.HasValue) w.WriteUInt32(forwardCommand.Value);
|
||||
if (forwardHoldKey.HasValue) w.WriteUInt32(forwardHoldKey.Value);
|
||||
if (forwardSpeed.HasValue) w.WriteFloat(forwardSpeed.Value);
|
||||
if (sidestepCommand.HasValue) w.WriteUInt32(sidestepCommand.Value);
|
||||
if (sidestepHoldKey.HasValue) w.WriteUInt32(sidestepHoldKey.Value);
|
||||
if (sidestepSpeed.HasValue) w.WriteFloat(sidestepSpeed.Value);
|
||||
if (turnCommand.HasValue) w.WriteUInt32(turnCommand.Value);
|
||||
if (turnHoldKey.HasValue) w.WriteUInt32(turnHoldKey.Value);
|
||||
if (turnSpeed.HasValue) w.WriteFloat(turnSpeed.Value);
|
||||
|
||||
// --- WorldPosition (32 bytes) ---
|
||||
// --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) ---
|
||||
w.WriteUInt32(cellId);
|
||||
w.WriteFloat(position.X);
|
||||
w.WriteFloat(position.Y);
|
||||
|
|
@ -155,8 +96,10 @@ public static class MoveToState
|
|||
w.WriteUInt16(teleportSequence);
|
||||
w.WriteUInt16(forcePositionSequence);
|
||||
|
||||
// --- Contact byte + 4-byte align ---
|
||||
w.WriteByte(contactLongJump);
|
||||
// --- Trailing byte (MoveToStatePack::Pack, 0x005168f0): ---
|
||||
// ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0)
|
||||
byte trailing = (byte)((standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0));
|
||||
w.WriteByte(trailing);
|
||||
w.AlignTo4();
|
||||
|
||||
return w.ToArray();
|
||||
|
|
|
|||
98
src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs
Normal file
98
src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using AcDream.Core.Net.Packets;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Ports retail's <c>RawMotionState::Pack</c> verbatim
|
||||
/// (<c>0x0051ed10</c>, decomp lines ~293761-294013; bitfield layout
|
||||
/// <c>acclient.h RawMotionState::PackBitfield</c>, line 46474). Confirmed
|
||||
/// against the Ghidra decompile-by-address bridge during this slice
|
||||
/// (2026-06-30).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>D1 fix.</b> Retail does NOT set a field's bit merely because the
|
||||
/// caller supplied a value — it compares every field against its retail
|
||||
/// DEFAULT (<see cref="RawMotionState.Default"/>) and sets the bit (and
|
||||
/// emits the field) only when the live value DIFFERS. This packer mirrors
|
||||
/// that default-difference comparison exactly, field by field, in bit
|
||||
/// order.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Flags dword layout (bits 0-15 only; bits 16-31 are unused — retail's
|
||||
/// <c>num_actions</c> is a 5-bit field occupying bits 11-15, not "the rest
|
||||
/// of the dword"):
|
||||
/// </para>
|
||||
/// <list type="table">
|
||||
/// <item><term>0x001</term><description>current_holdkey != HoldKey.None</description></item>
|
||||
/// <item><term>0x002</term><description>current_style != 0x8000003D</description></item>
|
||||
/// <item><term>0x004</term><description>forward_command != 0x41000003</description></item>
|
||||
/// <item><term>0x008</term><description>forward_holdkey != HoldKey.Invalid</description></item>
|
||||
/// <item><term>0x010</term><description>forward_speed != 1.0f</description></item>
|
||||
/// <item><term>0x020</term><description>sidestep_command != 0</description></item>
|
||||
/// <item><term>0x040</term><description>sidestep_holdkey != HoldKey.Invalid</description></item>
|
||||
/// <item><term>0x080</term><description>sidestep_speed != 1.0f</description></item>
|
||||
/// <item><term>0x100</term><description>turn_command != 0</description></item>
|
||||
/// <item><term>0x200</term><description>turn_holdkey != HoldKey.Invalid</description></item>
|
||||
/// <item><term>0x400</term><description>turn_speed != 1.0f</description></item>
|
||||
/// <item><term>0xF800</term><description>num_actions (bits 11-15, count not values)</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class RawMotionStatePacker
|
||||
{
|
||||
private const uint FlagCurrentHoldKey = 0x001u;
|
||||
private const uint FlagCurrentStyle = 0x002u;
|
||||
private const uint FlagForwardCommand = 0x004u;
|
||||
private const uint FlagForwardHoldKey = 0x008u;
|
||||
private const uint FlagForwardSpeed = 0x010u;
|
||||
private const uint FlagSidestepCommand = 0x020u;
|
||||
private const uint FlagSidestepHoldKey = 0x040u;
|
||||
private const uint FlagSidestepSpeed = 0x080u;
|
||||
private const uint FlagTurnCommand = 0x100u;
|
||||
private const uint FlagTurnHoldKey = 0x200u;
|
||||
private const uint FlagTurnSpeed = 0x400u;
|
||||
private const int NumActionsShift = 11;
|
||||
|
||||
public static void Pack(PacketWriter w, RawMotionState state)
|
||||
{
|
||||
var defaults = RawMotionState.Default;
|
||||
|
||||
uint flags = 0u;
|
||||
if (state.CurrentHoldKey != defaults.CurrentHoldKey) flags |= FlagCurrentHoldKey;
|
||||
if (state.CurrentStyle != defaults.CurrentStyle) flags |= FlagCurrentStyle;
|
||||
if (state.ForwardCommand != defaults.ForwardCommand) flags |= FlagForwardCommand;
|
||||
if (state.ForwardHoldKey != defaults.ForwardHoldKey) flags |= FlagForwardHoldKey;
|
||||
if (state.ForwardSpeed != defaults.ForwardSpeed) flags |= FlagForwardSpeed;
|
||||
if (state.SidestepCommand != defaults.SidestepCommand) flags |= FlagSidestepCommand;
|
||||
if (state.SidestepHoldKey != defaults.SidestepHoldKey) flags |= FlagSidestepHoldKey;
|
||||
if (state.SidestepSpeed != defaults.SidestepSpeed) flags |= FlagSidestepSpeed;
|
||||
if (state.TurnCommand != defaults.TurnCommand) flags |= FlagTurnCommand;
|
||||
if (state.TurnHoldKey != defaults.TurnHoldKey) flags |= FlagTurnHoldKey;
|
||||
if (state.TurnSpeed != defaults.TurnSpeed) flags |= FlagTurnSpeed;
|
||||
|
||||
int numActions = state.Actions.Count;
|
||||
flags |= (uint)(numActions << NumActionsShift);
|
||||
|
||||
w.WriteUInt32(flags);
|
||||
|
||||
if ((flags & FlagCurrentHoldKey) != 0) w.WriteUInt32((uint)state.CurrentHoldKey);
|
||||
if ((flags & FlagCurrentStyle) != 0) w.WriteUInt32(state.CurrentStyle);
|
||||
if ((flags & FlagForwardCommand) != 0) w.WriteUInt32(state.ForwardCommand);
|
||||
if ((flags & FlagForwardHoldKey) != 0) w.WriteUInt32((uint)state.ForwardHoldKey);
|
||||
if ((flags & FlagForwardSpeed) != 0) w.WriteFloat(state.ForwardSpeed);
|
||||
if ((flags & FlagSidestepCommand) != 0) w.WriteUInt32(state.SidestepCommand);
|
||||
if ((flags & FlagSidestepHoldKey) != 0) w.WriteUInt32((uint)state.SidestepHoldKey);
|
||||
if ((flags & FlagSidestepSpeed) != 0) w.WriteFloat(state.SidestepSpeed);
|
||||
if ((flags & FlagTurnCommand) != 0) w.WriteUInt32(state.TurnCommand);
|
||||
if ((flags & FlagTurnHoldKey) != 0) w.WriteUInt32((uint)state.TurnHoldKey);
|
||||
if ((flags & FlagTurnSpeed) != 0) w.WriteFloat(state.TurnSpeed);
|
||||
|
||||
foreach (var action in state.Actions)
|
||||
{
|
||||
w.WriteUInt16(action.Command);
|
||||
ushort stampWord = (ushort)((action.Stamp & 0x7FFF) | (action.Autonomous ? 0x8000 : 0));
|
||||
w.WriteUInt16(stampWord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -129,11 +129,23 @@ public enum WeenieError : uint
|
|||
// ── Motion state structs ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Raw (network-derived) motion state for the local player.
|
||||
/// Raw (network-derived) motion state for the local player, as consumed by
|
||||
/// the (still-approximate) <see cref="MotionInterpreter"/> input-state path.
|
||||
/// Struct layout in chunk_00520000 starts at offset +0x14 (struct field +0x20 =
|
||||
/// ForwardCommand, +0x28 = ForwardSpeed, etc.).
|
||||
///
|
||||
/// <para>
|
||||
/// Renamed from <c>RawMotionState</c> during the L.2b wire-parity slice
|
||||
/// (2026-06-30) to free that name for the retail-faithful, full-field
|
||||
/// <c>AcDream.Core.Physics.RawMotionState</c> (mirrors
|
||||
/// <c>acclient.h RawMotionState::PackBitfield</c>) used by the outbound
|
||||
/// wire packer. This partial type is D6/Phase-2 territory (the
|
||||
/// <c>adjust_motion</c>/<c>apply_raw_movement</c> input-state port) —
|
||||
/// out of scope for this slice. It is expected to be folded into or
|
||||
/// replaced by the retail-faithful type when D6 lands.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public struct RawMotionState
|
||||
public struct LegacyRawMotionState
|
||||
{
|
||||
/// <summary>Forward/backward motion command (offset +0x20).</summary>
|
||||
public uint ForwardCommand;
|
||||
|
|
@ -149,7 +161,7 @@ public struct RawMotionState
|
|||
public float TurnSpeed;
|
||||
|
||||
/// <summary>Initialize to the idle/ready state (1.0 speed, Ready command).</summary>
|
||||
public static RawMotionState Default() => new()
|
||||
public static LegacyRawMotionState Default() => new()
|
||||
{
|
||||
ForwardCommand = MotionCommand.Ready,
|
||||
ForwardSpeed = 1.0f,
|
||||
|
|
@ -261,7 +273,7 @@ public sealed class MotionInterpreter
|
|||
public IWeenieObject? WeenieObj { get; set; }
|
||||
|
||||
/// <summary>Raw (network-derived) motion state (struct offsets +0x14..+0x44).</summary>
|
||||
public RawMotionState RawState;
|
||||
public LegacyRawMotionState RawState;
|
||||
|
||||
/// <summary>Interpreted motion state derived from raw (struct offsets +0x44..+0x7C).</summary>
|
||||
public InterpretedMotionState InterpretedState;
|
||||
|
|
@ -318,7 +330,7 @@ public sealed class MotionInterpreter
|
|||
|
||||
public MotionInterpreter()
|
||||
{
|
||||
RawState = RawMotionState.Default();
|
||||
RawState = LegacyRawMotionState.Default();
|
||||
InterpretedState = InterpretedMotionState.Default();
|
||||
}
|
||||
|
||||
|
|
@ -326,7 +338,7 @@ public sealed class MotionInterpreter
|
|||
{
|
||||
PhysicsObj = physicsObj;
|
||||
WeenieObj = weenieObj;
|
||||
RawState = RawMotionState.Default();
|
||||
RawState = LegacyRawMotionState.Default();
|
||||
InterpretedState = InterpretedMotionState.Default();
|
||||
}
|
||||
|
||||
|
|
|
|||
71
src/AcDream.Core/Physics/RawMotionState.cs
Normal file
71
src/AcDream.Core/Physics/RawMotionState.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Retail hold-key enum (<c>acclient.h</c> <c>enum HoldKey</c>, line 3394).
|
||||
/// </summary>
|
||||
public enum HoldKey : uint
|
||||
{
|
||||
Invalid = 0x0,
|
||||
None = 0x1,
|
||||
Run = 0x2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry in <see cref="RawMotionState.Actions"/> — a discrete motion
|
||||
/// event (jump, attack, etc.) layered on top of the continuous
|
||||
/// forward/sidestep/turn axes.
|
||||
///
|
||||
/// Retail packs each action as <c>u16 command</c> then
|
||||
/// <c>u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0))</c>
|
||||
/// (<c>RawMotionState::Pack</c>, 0x0051ed10, decomp lines ~293998-294010).
|
||||
/// </summary>
|
||||
public readonly record struct RawMotionAction(ushort Command, ushort Stamp, bool Autonomous);
|
||||
|
||||
/// <summary>
|
||||
/// Pure-data mirror of retail's <c>RawMotionState</c> struct
|
||||
/// (<c>acclient.h</c> <c>RawMotionState::PackBitfield</c>, line 46474;
|
||||
/// packer at <c>RawMotionState::Pack</c>, 0x0051ed10).
|
||||
///
|
||||
/// <para>
|
||||
/// This type carries the COMPLETE motion-state snapshot the way retail's
|
||||
/// <c>CPhysicsObj::InqRawMotionState()</c> would return it. The packer
|
||||
/// (<see cref="AcDream.Core.Net"/>) compares every field against
|
||||
/// <see cref="Default"/> and only emits the fields that differ — see
|
||||
/// <c>RawMotionState::Pack</c> for the exact default-difference logic.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// PURE DATA: no PacketWriter / GL / Net dependency. Lives in
|
||||
/// <c>AcDream.Core.Physics</c> so the (future) motion interpreter, which is
|
||||
/// also Core.Physics, can populate it without taking a Core.Net dependency
|
||||
/// (Code Structure Rule #2).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RawMotionState
|
||||
{
|
||||
public HoldKey CurrentHoldKey { get; init; } = HoldKey.None;
|
||||
public uint CurrentStyle { get; init; } = 0x8000003Du;
|
||||
public uint ForwardCommand { get; init; } = 0x41000003u;
|
||||
public HoldKey ForwardHoldKey { get; init; } = HoldKey.Invalid;
|
||||
public float ForwardSpeed { get; init; } = 1.0f;
|
||||
public uint SidestepCommand { get; init; } = 0u;
|
||||
public HoldKey SidestepHoldKey { get; init; } = HoldKey.Invalid;
|
||||
public float SidestepSpeed { get; init; } = 1.0f;
|
||||
public uint TurnCommand { get; init; } = 0u;
|
||||
public HoldKey TurnHoldKey { get; init; } = HoldKey.Invalid;
|
||||
public float TurnSpeed { get; init; } = 1.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Discrete action list. Retail packs <c>num_actions</c> (count, not the
|
||||
/// values) into bits 11-15 of the flags dword, then emits each action's
|
||||
/// command+stamp pair unconditionally after the continuous-axis fields.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawMotionAction> Actions { get; init; } = Array.Empty<RawMotionAction>();
|
||||
|
||||
/// <summary>
|
||||
/// Retail defaults — a field bit is set in the packed flags dword ONLY
|
||||
/// when the live value differs from these (<c>RawMotionState::Pack</c>,
|
||||
/// 0x0051ed10).
|
||||
/// </summary>
|
||||
public static readonly RawMotionState Default = new();
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
170
tests/AcDream.Core.Net.Tests/Messages/JumpActionTests.cs
Normal file
170
tests/AcDream.Core.Net.Tests/Messages/JumpActionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
254
tests/AcDream.Core.Net.Tests/Messages/MoveToStateGoldenTests.cs
Normal file
254
tests/AcDream.Core.Net.Tests/Messages/MoveToStateGoldenTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
63
tests/AcDream.Core.Net.Tests/Messages/PositionPackTests.cs
Normal file
63
tests/AcDream.Core.Net.Tests/Messages/PositionPackTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
212
tests/AcDream.Core.Net.Tests/Messages/RawMotionStatePackTests.cs
Normal file
212
tests/AcDream.Core.Net.Tests/Messages/RawMotionStatePackTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue