fix #270: fire ReportExhaustion on the stamina-exhaustion edge only, not every stats tick

Retail calls CPhysicsObj::report_exhaustion from exactly one site -
CommandInterpreter::HandleExhaustion (0x006b3c70), a notification handler
for the stamina-exhaustion EVENT. Campaign P P1 wired it to every
movement-stats application instead (every stamina regen/drain tick), and
each call re-dispatches the current movement state through the animation
sink - truncating any in-flight action animation. The diagnostic session
log shows 490 spurious casting-stance re-queues in one short session:
'sometimes stuck in spell animations' was every stamina tick that
collided with a cast gesture's play window.

The re-apply now fires only when the exhausted state (stamina == 0)
transitions, matching retail's event semantics. Stats still reach
PlayerWeenie immediately via RuntimeMovementSkillProjection.ApplyTo.

Also adds the [remote-edge] probe (rides ACDREAM_DUMP_MOTION=1): one
line per remote HitGround/LeaveGround - each such edge drains the
mover's pending action animations (retail HandleEnterWorld), the
working theory for intermittently missing monster attack swings.

Complete Release suite: 10,026 passed / 5 skips / 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 22:11:20 +02:00
parent 5788fdaa02
commit a46c8e65b2
4 changed files with 101 additions and 10 deletions

View file

@ -290,13 +290,33 @@ internal sealed class LiveSessionRuntimeFactory
}
/// <summary>
/// Re-applies the current <see cref="RuntimeMovementSkillState"/>
/// snapshot (skills/burden/stamina) to the live player controller and
/// forces an immediate movement re-evaluation via
/// <c>MotionInterpreter.ReportExhaustion</c> — the retail
/// <c>CMotionInterp::ReportExhaustion</c> dual-dispatch re-apply, now
/// wired to a real consumer (Campaign P Slice P1).
/// Tracks the previous "stamina exhausted" state so
/// <see cref="ApplyMovementStats"/> can fire retail's exhaustion
/// notification on the EDGE only. Null = no stamina reading applied yet.
/// </summary>
private bool? _lastStaminaExhausted;
/// <summary>
/// Re-applies the current <see cref="RuntimeMovementSkillState"/>
/// snapshot (skills/burden/stamina) to the live player controller.
/// </summary>
/// <remarks>
/// Stuck-cast fix (2026-07-30): retail fires
/// <c>CPhysicsObj::report_exhaustion</c> from exactly ONE site —
/// <c>CommandInterpreter::HandleExhaustion</c> (0x006b3c70), a
/// notification-handler vtable slot invoked on the stamina-exhaustion
/// EVENT — not on every vitals refresh. The P1 wiring called
/// <c>ReportExhaustion()</c> on EVERY movement-stats application
/// (every stamina regen/drain tick), and each call re-dispatches the
/// current movement state through the animation sink — truncating any
/// in-flight action animation (cast gestures wedged mid-play; the
/// diagnostic session showed 490 spurious stance re-queues). The
/// re-apply now fires only when the exhausted state (stamina == 0)
/// actually TRANSITIONS, matching retail's event semantics. Skill/
/// burden changes still reach <see cref="PlayerWeenie"/> immediately
/// via <see cref="RuntimeMovementSkillProjection.ApplyTo"/> — the next
/// natural dispatch picks up the new rates, exactly as retail.
/// </remarks>
private void ApplyMovementStats(string reason)
{
PlayerMovementController? controller = _player.Controller.Controller;
@ -307,9 +327,16 @@ internal sealed class LiveSessionRuntimeFactory
return;
}
controller!.Motion.ReportExhaustion();
RuntimeMovementSkillSnapshot snapshot = _domain.Character.MovementSkills.Snapshot;
bool exhausted = snapshot.CurrentStamina == 0;
if (_lastStaminaExhausted != exhausted)
{
bool isEdge = _lastStaminaExhausted is not null;
_lastStaminaExhausted = exhausted;
if (isEdge)
controller!.Motion.ReportExhaustion();
}
_log(
$"player: applied server movement {reason} "
+ $"run={snapshot.RunSkill} jump={snapshot.JumpSkill} "