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.
This commit is contained in:
Erik 2026-04-18 15:01:32 +02:00
parent d951304875
commit 3308cddda7
6 changed files with 272 additions and 31 deletions

View file

@ -90,7 +90,10 @@ public static class MoveToState
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence,
byte contactLongJump = 1)
byte contactLongJump = 1,
uint? forwardHoldKey = null,
uint? sidestepHoldKey = null,
uint? turnHoldKey = null)
{
var w = new PacketWriter(128);
@ -101,14 +104,24 @@ public static class MoveToState
// --- 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 (forwardSpeed.HasValue) flags |= FlagForwardSpeed;
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 (sidestepSpeed.HasValue) flags |= FlagSidestepSpeed;
if (turnCommand.HasValue) flags |= FlagTurnCommand;
if (turnSpeed.HasValue) flags |= FlagTurnSpeed;
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)
@ -116,13 +129,13 @@ public static class MoveToState
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);
// FlagForwardHoldKey (0x8): not sent
if (forwardHoldKey.HasValue) w.WriteUInt32(forwardHoldKey.Value);
if (forwardSpeed.HasValue) w.WriteFloat(forwardSpeed.Value);
if (sidestepCommand.HasValue) w.WriteUInt32(sidestepCommand.Value);
// FlagSidestepHoldKey (0x40): not sent
if (sidestepHoldKey.HasValue) w.WriteUInt32(sidestepHoldKey.Value);
if (sidestepSpeed.HasValue) w.WriteFloat(sidestepSpeed.Value);
if (turnCommand.HasValue) w.WriteUInt32(turnCommand.Value);
// FlagTurnHoldKey (0x200): not sent
if (turnHoldKey.HasValue) w.WriteUInt32(turnHoldKey.Value);
if (turnSpeed.HasValue) w.WriteFloat(turnSpeed.Value);
// --- WorldPosition (32 bytes) ---