acdream/src/AcDream.Core.Net/Messages/MoveToState.cs
Erik 3308cddda7 fix(movement+anim+session): clothing dedup, motion wire format, jump-skill default
Three separate fixes landed today, each addressing a specific bug the
user observed during live play:

1. NPC clothing changes by camera angle (InstancedMeshRenderer)
   - Group key was (GfxObjId) only, so every humanoid NPC using the
     same body mesh piled into one instance group; only the first
     instance's texture was used for the entire DrawInstanced batch,
     so which NPC's palette "won" changed as frustum culling and
     iteration order shuffled entries.
   - Now keyed by (GfxObjId, PaletteHash ^ SurfaceOverridesHash)
     so only compatible instances batch; each unique appearance gets
     its own draw call. Perf hit is small (humanoid NPCs each emit
     one more draw call); visually every NPC is now stable.

2. GpuWorldState dedup on respawn
   - Server re-sends CreateObject for the same guid on visibility
     refresh / landblock crossing / appearance update. AppendLiveEntity
     was blindly appending each time, so GpuWorldState accumulated
     multiple copies of the same entity, each with its own
     PaletteOverride / MeshRefs. That alone wasn't the clothing bug
     (that was #1) but it would have caused other overlap problems
     downstream.
   - Added RemoveEntityByServerGuid + WorldGameState.RemoveById;
     OnLiveEntitySpawnedLocked calls both before creating the new
     entity so respawns replace cleanly.

3. Motion wire format — run animation sync with retail observers
   - ACE's MovementData constructor only computes interpState.ForwardSpeed
     on the WalkForward/WalkBackwards branch; every other ForwardCommand
     falls into `else` and passes through WITHOUT speed set, giving
     observers speed=0. Sending RunForward directly meant retail
     clients saw us "run in place" while position drifted forward.
   - Wire: always WalkForward + HoldKey.Run for running. ACE
     auto-upgrades to RunForward with creature.GetRunRate() for
     broadcast — correct command + correct speed at observers.
   - Added per-axis FORWARD_HOLD_KEY / SIDE_STEP_HOLD_KEY /
     TURN_HOLD_KEY so every active axis carries HoldKey.Run when
     running (matches holtburger's build_motion_state_raw_motion_state).
   - Added LocalAnimationCommand to MovementResult so our own
     client still plays the RunForward cycle locally while the wire
     stays WalkForward. Wire vs. local animation command are now
     decoupled.
   - Walk-backward wire command changed from WalkForward@-0.65 to
     WalkBackward@1.0 (holtburger pattern).
   - Strafe speed changed from 0.5 to 1.0 on wire AND local physics
     (matches retail sidestep pace).

4. Jump height default + env-var tuning
   - Default jumpSkill bumped from 100 → 200 (jump ≈ 3m at full
     charge, closer to retail feel for a mid-level character).
   - ACDREAM_RUN_SKILL and ACDREAM_JUMP_SKILL env vars now override
     the defaults so the user can tune per-character until we parse
     PlayerDescription and plumb real skill values through.

5. JustLanded signal on MovementResult
   - Tracks airborne→grounded transition so future animation code
     can fire the landing cycle when we land. Just a bool flag for
     now — no consumer yet (the proper action-queue path will use it).

Not in this commit: jump animation itself. An earlier attempt to
SetCycle(Jump=0x2500003b) fed an Action-type motion into the SubState
cycle resolver, which produced a "torso" mis-render. Reverted. The
proper fix is porting the retail motion action-queue semantics into
AnimationSequencer — see docs/research/deepdives/r03-motion-animation.md
for the spec. That's the next session's work.

470 tests pass, build clean.
2026-04-18 15:01:32 +02:00

164 lines
7.7 KiB
C#

using System.Numerics;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Outbound <c>GameAction(MoveToState)</c> message — opcode <c>0xF61C</c>.
/// Sent whenever the client's motion state changes: starting or stopping
/// walking, switching direction, changing speed, entering or leaving combat
/// stance. The server uses this to update the player's authoritative motion
/// 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>):
/// </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>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>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="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>
/// <param name="instanceSequence">Instance sequence number from the server.</param>
/// <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>
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)
{
var w = new PacketWriter(128);
// --- GameAction envelope ---
w.WriteUInt32(GameActionOpcode);
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;
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) ---
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);
// --- Sequence numbers ---
w.WriteUInt16(instanceSequence);
w.WriteUInt16(serverControlSequence);
w.WriteUInt16(teleportSequence);
w.WriteUInt16(forcePositionSequence);
// --- Contact byte + 4-byte align ---
w.WriteByte(contactLongJump);
w.AlignTo4();
return w.ToArray();
}
}