From a46c8e65b2af614384559b18391f4df640762fab Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 22:11:20 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 32 ++++++++++++++ .../Net/LiveSessionRuntimeFactory.cs | 43 +++++++++++++++---- .../Physics/PhysicsDiagnostics.cs | 12 ++++++ .../Physics/RuntimeRemotePhysicsUpdater.cs | 24 ++++++++++- 4 files changed, 101 insertions(+), 10 deletions(-) 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,