feat(net): Phase 6.6 — parse UpdateMotion (0xF74C) into MotionUpdated event
Server sends UpdateMotion whenever an entity's motion state changes: NPCs starting a walk cycle, creatures switching to a combat stance, doors opening, a player waving, etc. Phase 6.1-6.4 already handles rendering different (stance, forward-command) pairs for the INITIAL CreateObject, but without this message NPCs freeze in whatever pose they spawned with and never transition to walking/fighting. Added UpdateMotion.TryParse with the same ServerMotionState the CreateObject path uses, reached via a slightly different outer layout (guid + instance seq + header'd MovementData; the MovementData starts with the 8-byte sequence/autonomous header this time rather than being preceded by a length field). Only the (stance, forward- command) pair is extracted — same subset CreateObject grabs. WorldSession dispatches MotionUpdated(guid, state) when a 0xF74C body parses successfully. The App-side wiring (guid→entity lookup and AnimatedEntity cycle swap) is intentionally deferred to a separate commit because it touches GameWindow which is currently being edited by the Phase 9.1 translucent-pass work. 89 Core.Net tests (was 83, +6 for UpdateMotion coverage). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4752b8a528
commit
a71db90310
12 changed files with 675 additions and 45 deletions
142
src/AcDream.Core.Net/Messages/UpdateMotion.cs
Normal file
142
src/AcDream.Core.Net/Messages/UpdateMotion.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound <c>UpdateMotion</c> GameMessage (opcode <c>0xF74C</c>). The server
|
||||
/// sends this whenever an already-spawned entity changes its motion state —
|
||||
/// NPCs starting a walk cycle, creatures switching to attack stance, doors
|
||||
/// opening, a player waving, etc. acdream's animation system needs to
|
||||
/// consume these so the motion tick can switch the entity's cycle to the
|
||||
/// new (stance, forward-command) pair instead of sitting on whatever the
|
||||
/// initial CreateObject said.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (see
|
||||
/// <c>references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageUpdateMotion.cs</c>
|
||||
/// and <c>references/ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write</c>
|
||||
/// with <c>header = true</c>):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>u32 opcode</b> — 0xF74C</item>
|
||||
/// <item><b>u32 objectGuid</b> — which entity this update is for</item>
|
||||
/// <item><b>u16 instanceSequence</b> — Sequences.ObjectInstance, tracked but not used for pose</item>
|
||||
/// <item><b>MovementData with header</b>:
|
||||
/// <list type="bullet">
|
||||
/// <item>u16 movementSequence</item>
|
||||
/// <item>u16 serverControlSequence</item>
|
||||
/// <item>u8 isAutonomous, then align to 4 bytes</item>
|
||||
/// <item>u8 movementType</item>
|
||||
/// <item>u8 motionFlags</item>
|
||||
/// <item>u16 currentStyle (MotionStance)</item>
|
||||
/// <item>InterpretedMotionState when movementType == Invalid (0):
|
||||
/// u32 flagsAndCommandCount, then each present field in flag order
|
||||
/// (CurrentStyle u16, ForwardCommand u16, SidestepCommand u16,
|
||||
/// TurnCommand u16, forward speed f32, sidestep speed f32,
|
||||
/// turn speed f32), commands list, align.</item>
|
||||
/// </list>
|
||||
/// </item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// We only extract the two fields the animation system actually consumes:
|
||||
/// the current <c>Stance</c> and the <c>ForwardCommand</c>. Everything else
|
||||
/// is skipped. The outer message doesn't carry a length for MovementData,
|
||||
/// so our parser reads exactly as far as it needs and leaves subsequent
|
||||
/// bytes untouched.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class UpdateMotion
|
||||
{
|
||||
public const uint Opcode = 0xF74Cu;
|
||||
|
||||
/// <summary>
|
||||
/// Extracted payload: the guid of the entity whose motion changed and
|
||||
/// the (stance, forward-command) pair describing its new pose. The
|
||||
/// command is nullable because the <c>ForwardCommand</c> flag may be
|
||||
/// unset in the InterpretedMotionState; the stance is always present
|
||||
/// (even if 0, meaning "no specific stance").
|
||||
/// </summary>
|
||||
public readonly record struct Parsed(
|
||||
uint Guid,
|
||||
CreateObject.ServerMotionState MotionState);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a reassembled UpdateMotion body. <paramref name="body"/> must
|
||||
/// start with the 4-byte opcode. Returns null on malformed input
|
||||
/// (truncated fields, wrong opcode, malformed InterpretedMotionState).
|
||||
/// </summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
try
|
||||
{
|
||||
int pos = 0;
|
||||
|
||||
if (body.Length - pos < 4) return null;
|
||||
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
if (opcode != Opcode) return null;
|
||||
|
||||
if (body.Length - pos < 4) return null;
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
|
||||
// ObjectInstance sequence (u16) — tracked but not used for pose.
|
||||
if (body.Length - pos < 2) return null;
|
||||
pos += 2;
|
||||
|
||||
// MovementData header: u16 movementSequence, u16 serverControlSequence,
|
||||
// u8 isAutonomous, then align to 4. The header bytes total 6 (2+2+1+1 pad),
|
||||
// because 2+2+1 = 5, and aligning to 4 from offset 5 needs 3 pad bytes...
|
||||
// Actually, ACE's writer.Align() pads the CURRENT BaseStream position
|
||||
// after writing the byte, so after u16 + u16 + u8 we're at 5 bytes into
|
||||
// MovementData; alignment rounds up to 8. So the header slot is 8 bytes.
|
||||
if (body.Length - pos < 8) return null;
|
||||
pos += 8;
|
||||
|
||||
// movementType u8, motionFlags u8, currentStyle u16
|
||||
if (body.Length - pos < 4) return null;
|
||||
byte movementType = body[pos]; pos += 1;
|
||||
byte _motionFlags = body[pos]; pos += 1;
|
||||
ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
pos += 2;
|
||||
|
||||
ushort? forwardCommand = null;
|
||||
|
||||
if (movementType == 0)
|
||||
{
|
||||
// InterpretedMotionState — same layout as in CreateObject's
|
||||
// MovementInvalid branch, just reached via the header'd path.
|
||||
// Only ForwardCommand is pulled out; the rest is deliberately
|
||||
// ignored because the animation system consumes nothing else.
|
||||
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
uint flags = packed & 0x7Fu;
|
||||
|
||||
// CurrentStyle (0x1) — prefer the InterpretedMotionState's copy
|
||||
// if present, matching the CreateObject parser's behavior.
|
||||
if ((flags & 0x1u) != 0)
|
||||
{
|
||||
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
pos += 2;
|
||||
}
|
||||
|
||||
// ForwardCommand (0x2)
|
||||
if ((flags & 0x2u) != 0)
|
||||
{
|
||||
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
pos += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,22 @@ public sealed class WorldSession : IDisposable
|
|||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
public event Action<EntitySpawn>? EntitySpawned;
|
||||
|
||||
/// <summary>
|
||||
/// Payload for <see cref="MotionUpdated"/>: the server guid of the entity
|
||||
/// whose motion changed and its new server-side stance + forward command.
|
||||
/// The renderer uses these to drive per-entity cycle switching.
|
||||
/// </summary>
|
||||
public readonly record struct EntityMotionUpdate(
|
||||
uint Guid,
|
||||
CreateObject.ServerMotionState MotionState);
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the session parses a 0xF74C UpdateMotion game message.
|
||||
/// Subscribers can look up the entity by guid and transition its
|
||||
/// animation cycle to the new (stance, forward-command) pair.
|
||||
/// </summary>
|
||||
public event Action<EntityMotionUpdate>? MotionUpdated;
|
||||
|
||||
/// <summary>Raised every time the state machine transitions.</summary>
|
||||
public event Action<State>? StateChanged;
|
||||
|
||||
|
|
@ -243,6 +259,22 @@ public sealed class WorldSession : IDisposable
|
|||
parsed.Value.MotionTableId));
|
||||
}
|
||||
}
|
||||
else if (op == UpdateMotion.Opcode)
|
||||
{
|
||||
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
|
||||
// already-spawned entity changes its motion state — NPCs
|
||||
// starting a walk cycle, creatures entering combat, doors
|
||||
// opening, etc. We dispatch a lightweight event with the
|
||||
// new (stance, forward-command) pair so the animation
|
||||
// system can swap the entity's cycle.
|
||||
var motion = UpdateMotion.TryParse(body);
|
||||
if (motion is not null)
|
||||
{
|
||||
MotionUpdated?.Invoke(new EntityMotionUpdate(
|
||||
motion.Value.Guid,
|
||||
motion.Value.MotionState));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue