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.
164 lines
7.7 KiB
C#
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();
|
|
}
|
|
}
|