diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index aca7906a..709ddd7c 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -98,6 +98,38 @@ Copy this block when adding a new issue:
---
+## #270 — Stuck spell animations + intermittently missing monster attack animations
+
+**Status:** LOCAL CAUSE FIXED 2026-07-30 (pending user re-test); remote
+attack-animation misses under investigation with the [remote-edge] probe
+**Severity:** HIGH (combat/casting presentation)
+**Component:** motion re-dispatch cadence / remote action animations
+
+**Local stuck casts — root cause CONFIRMED and fixed:** Campaign P P1 wired
+`ApplyMovementStats` to call `MotionInterpreter.ReportExhaustion()` on EVERY
+movement-stats application — i.e. every stamina regen/drain tick. 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 while the user was in magic mode.
+Retail fires `CPhysicsObj::report_exhaustion` from exactly ONE site —
+`CommandInterpreter::HandleExhaustion` (0x006b3c70), a notification handler
+invoked on the stamina-EXHAUSTION EVENT. Fix: the re-apply now fires only
+when the exhausted state (stamina == 0) transitions; skills/burden/stamina
+still reach `PlayerWeenie` immediately (the next natural dispatch picks up
+rate changes, exactly retail).
+
+**Monster attack misses — theory + probe:** every logged attack UM
+(cmd=0x00D3, spd=2.0) DID dispatch (`SetCycle 0x400000D3`), so the wire →
+animation path works. Working theory: remote ground-contact edges (bounce
+hops / contact flickers on dead-reckoned bodies) each drain the mover's
+pending action animations (retail `HandleEnterWorld` semantics — faithful
+per edge, wrong if we generate edges retail doesn't). A `[remote-edge]`
+probe (rides `ACDREAM_DUMP_MOTION=1`) now logs every remote
+HitGround/LeaveGround with the guid — one repro session with a missed swing
+will convict or acquit.
+
+---
+
## #269 — Slope-stop slide runs too far (post-bounce-rework residual)
**Status:** OPEN (user live gate 2026-07-30 — "almost pass with merits")
diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
index f4e946a2..52776715 100644
--- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
+++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
@@ -290,13 +290,33 @@ internal sealed class LiveSessionRuntimeFactory
}
///
- /// Re-applies the current
- /// snapshot (skills/burden/stamina) to the live player controller and
- /// forces an immediate movement re-evaluation via
- /// MotionInterpreter.ReportExhaustion — the retail
- /// CMotionInterp::ReportExhaustion dual-dispatch re-apply, now
- /// wired to a real consumer (Campaign P Slice P1).
+ /// Tracks the previous "stamina exhausted" state so
+ /// can fire retail's exhaustion
+ /// notification on the EDGE only. Null = no stamina reading applied yet.
///
+ private bool? _lastStaminaExhausted;
+
+ ///
+ /// Re-applies the current
+ /// snapshot (skills/burden/stamina) to the live player controller.
+ ///
+ ///
+ /// Stuck-cast fix (2026-07-30): retail fires
+ /// CPhysicsObj::report_exhaustion from exactly ONE site —
+ /// CommandInterpreter::HandleExhaustion (0x006b3c70), a
+ /// notification-handler vtable slot invoked on the stamina-exhaustion
+ /// EVENT — not on every vitals refresh. The P1 wiring called
+ /// ReportExhaustion() 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 immediately
+ /// via — the next
+ /// natural dispatch picks up the new rates, exactly as retail.
+ ///
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} "
diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
index 9331ccd7..b38e319e 100644
--- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
+++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
@@ -61,6 +61,18 @@ public static class PhysicsDiagnostics
public static bool ProbeCellEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL") == "1";
+ ///
+ /// Stuck-cast/missing-attack investigation (2026-07-30). When true,
+ /// every REMOTE ground-contact edge (HitGround / LeaveGround — each of
+ /// which drains the mover's pending action animations via retail's
+ /// HandleEnterWorld) emits one [remote-edge] line with the
+ /// server guid. Correlates eaten attack animations with spurious
+ /// contact flickers. Rides ACDREAM_DUMP_MOTION=1 so one flag
+ /// captures the whole animation story.
+ ///
+ public static bool DumpMotionEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1";
+
///
/// L.2d slice 1 (2026-05-13). When true, every BSP-shadow-entry hit
/// attributed by TransitionTypes.FindObjCollisions emits a
diff --git a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
index c6da0114..3f0adf7a 100644
--- a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
+++ b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
@@ -747,6 +747,26 @@ internal sealed class RuntimeRemotePhysicsUpdater
rm.Body.Position = resolved.Position;
if (resolved.CellId != 0)
committedCellId = resolved.CellId;
+ // [remote-edge] probe (stuck-cast/missing-attack investigation,
+ // 2026-07-30): each ground edge drains the mover's pending
+ // action animations (retail HandleEnterWorld) — one line per
+ // edge correlates eaten attack gestures with contact flickers.
+ Action hitGround = rm.Movement.HitGround;
+ Action leaveGround = rm.Motion.LeaveGround;
+ if (AcDream.Core.Physics.PhysicsDiagnostics.DumpMotionEnabled)
+ {
+ uint edgeGuid = record.ServerGuid;
+ hitGround = () =>
+ {
+ Console.WriteLine($"[remote-edge] guid={edgeGuid:X8} HitGround");
+ rm.Movement.HitGround();
+ };
+ leaveGround = () =>
+ {
+ Console.WriteLine($"[remote-edge] guid={edgeGuid:X8} LeaveGround");
+ rm.Motion.LeaveGround();
+ };
+ }
if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
rm.Body,
resolved.InContact,
@@ -755,8 +775,8 @@ internal sealed class RuntimeRemotePhysicsUpdater
resolved.CollisionNormal,
previousContact,
previousOnWalkable,
- rm.Movement.HitGround,
- rm.Motion.LeaveGround,
+ hitGround,
+ leaveGround,
() => IsCurrentOwner(
record,
rm,