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

@ -20,18 +20,39 @@ public readonly record struct MovementInput(
/// <summary>
/// Result of a single frame's movement update.
///
/// <para>
/// <b>Wire vs. local animation command.</b> ACE's <c>MovementData</c>
/// (<c>ACE.Server/Network/Motion/MovementData.cs</c>) only computes
/// <c>interpState.ForwardSpeed</c> for raw <c>WalkForward</c>/
/// <c>WalkBackwards</c> — on every other command the <c>else</c> branch
/// passes through command without setting speed, leaving observers with
/// <c>speed=0</c>. The client therefore has to send <c>WalkForward</c>
/// (with <c>HoldKey.Run</c> for running) and let ACE auto-upgrade to
/// <c>RunForward</c> for broadcast. But the LOCAL view wants the run
/// cycle immediately, so we carry a separate
/// <see cref="LocalAnimationCommand"/> for the player's own renderer.
/// </para>
/// <para>
/// <see cref="IsRunning"/> — true when the player is holding Shift to run.
/// Used by the GameWindow when building the outbound MoveToState's
/// CURRENT_HOLD_KEY (2=Run) vs (1=None).
/// </para>
/// </summary>
public readonly record struct MovementResult(
Vector3 Position,
uint CellId,
bool IsOnGround,
bool MotionStateChanged,
uint? ForwardCommand,
uint? ForwardCommand, // wire-side command (WalkForward / WalkBackward / …)
uint? SidestepCommand,
uint? TurnCommand,
float? ForwardSpeed,
float? SidestepSpeed,
float? TurnSpeed,
bool IsRunning = false,
uint? LocalAnimationCommand = null, // which cycle to play on the local player (RunForward when running)
bool JustLanded = false, // true on the single frame we transitioned airborne → grounded
float? JumpExtent = null, // non-null when a jump was triggered this frame
Vector3? JumpVelocity = null); // world-space launch velocity (sent in jump packet)
@ -101,6 +122,11 @@ public sealed class PlayerMovementController
private float _jumpExtent;
private const float JumpChargeRate = 1.0f; // 0→1 over 1 second
// Airborne → grounded transition detection. Flipped on every frame where
// the body transitions from airborne to on-walkable; used by the GameWindow
// to drive the landing animation cycle.
private bool _wasAirborneLastFrame;
// Previous frame's motion commands for change detection.
private uint? _prevForwardCmd;
private uint? _prevSidestepCmd;
@ -120,7 +146,13 @@ public sealed class PlayerMovementController
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
_weenie = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
// Default skills — tuned toward mid-retail feel (jump ≈ 3m at full charge,
// run rate ≈ 2.4x). Real characters' skills come from PlayerDescription
// (0xF7B0/0x0013) which we don't parse yet; override via env vars:
// ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200;
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 200;
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
_motion = new MotionInterpreter(_body, _weenie);
}
@ -247,10 +279,11 @@ public sealed class PlayerMovementController
else if (input.Backward)
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f);
// Full-speed strafe to match retail sidestep pace.
if (input.StrafeRight)
localX = MotionInterpreter.SidestepAnimSpeed * 0.5f;
localX = MotionInterpreter.SidestepAnimSpeed;
else if (input.StrafeLeft)
localX = -MotionInterpreter.SidestepAnimSpeed * 0.5f;
localX = -MotionInterpreter.SidestepAnimSpeed;
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
}
@ -307,6 +340,7 @@ public sealed class PlayerMovementController
// Apply resolved position.
_body.Position = resolveResult.Position;
bool justLanded = false;
if (resolveResult.IsOnGround)
{
if (_body.Velocity.Z <= 0f)
@ -320,7 +354,10 @@ public sealed class PlayerMovementController
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
if (wasAirborne)
{
_motion.HitGround();
justLanded = true;
}
}
else
{
@ -336,6 +373,7 @@ public sealed class PlayerMovementController
_body.calc_acceleration();
}
_wasAirborneLastFrame = !_body.OnWalkable;
CellId = resolveResult.CellId;
// ── 6. Determine outbound motion commands ─────────────────────────────
@ -346,26 +384,52 @@ public sealed class PlayerMovementController
uint? outTurnCmd = null;
float? outTurnSpeed = null;
// Retail-faithful wire commands. ACE's MovementData constructor only
// computes interpState.ForwardSpeed for WalkForward / WalkBackwards
// (Network/Motion/MovementData.cs:104-119) — for any other command
// the else-branch passes through without setting speed, so observers
// dead-reckon at speed=0. The wire therefore must be:
// - Forward (walk): WalkForward @ 1.0
// - Forward (run): WalkForward @ run_rate + HoldKey.Run
// (ACE auto-upgrades to RunForward for observers)
// - Backward: WalkBackward @ 1.0
// Our own local animation still wants the actual RunForward cycle
// though — that's carried separately in LocalAnimationCommand below.
uint? localAnimCmd = null;
if (input.Forward)
{
outForwardCmd = input.Run ? MotionCommand.RunForward : MotionCommand.WalkForward;
outForwardSpeed = 1.0f;
outForwardCmd = MotionCommand.WalkForward;
if (input.Run && _weenie.InqRunRate(out float runRate))
{
outForwardSpeed = runRate;
localAnimCmd = MotionCommand.RunForward; // local cycle is RunForward
}
else
{
outForwardSpeed = 1.0f;
localAnimCmd = MotionCommand.WalkForward;
}
}
else if (input.Backward)
{
outForwardCmd = MotionCommand.WalkForward; // backward = WalkForward at negative speed
outForwardSpeed = -0.65f;
outForwardCmd = MotionCommand.WalkBackward;
outForwardSpeed = 1.0f;
localAnimCmd = MotionCommand.WalkBackward;
}
// Strafe: retail uses speed=1.0 for SideStep (see holtburger
// common.rs::locomotion_command_for_state). 0.5 was our earlier guess
// and made strafing feel lethargic; the retail feel is full-speed
// sidestep matching the walk forward pace.
if (input.StrafeRight)
{
outSidestepCmd = MotionCommand.SideStepRight;
outSidestepSpeed = 0.5f;
outSidestepSpeed = 1.0f;
}
else if (input.StrafeLeft)
{
outSidestepCmd = MotionCommand.SideStepLeft;
outSidestepSpeed = 0.5f;
outSidestepSpeed = 1.0f;
}
// Turn commands from KEYBOARD only (A/D). Mouse turning is applied
@ -419,6 +483,9 @@ public sealed class PlayerMovementController
ForwardSpeed: outForwardSpeed,
SidestepSpeed: outSidestepSpeed,
TurnSpeed: outTurnSpeed,
IsRunning: input.Run && input.Forward,
LocalAnimationCommand: localAnimCmd,
JustLanded: justLanded,
JumpExtent: outJumpExtent,
JumpVelocity: outJumpVelocity);
}