acdream/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseDrainBisectTests.cs
Erik 1051fc83c6 fix(#170): armed moveto always ticks UseTime — the SERVERVEL branch starved the chase
The "sustain the run" residual. The handoff's "Ready stop-node backlog
drains a beat slower than retail" framing was DISPROVEN: a new full-stack
offline harness (RemoteChaseEndToEndHarnessTests — real MoveToManager +
MotionInterpreter + AnimationSequencer + MotionTableDispatchSink + the
manual omega integration, wired field-for-field like
EnsureRemoteMotionBindings and ticked in TickAnimations' exact phase
order) proves the Core turn/run/drain pipeline healthy: the chase turn
completes in <1 s both directions, BeginMoveForward installs per arm, the
run sustains across re-arms and attack swings, and pending_motions fully
empties (retail cdb invariant add_to_queue == MotionDone).

The real mechanism (launch-drainq.log, corrected per-guid attribution —
the previous session's timeline mis-attributed [mvto] lines that fire in
the network phase): funnel per chasing scamp was 16 mt-6 arms -> 11
dispatched turns -> ONE BeginMoveForward. Any NPC receiving
UpdatePositions gets HasServerVelocity=true (synthesized from position
deltas even when the wire carries no velocity), and the grounded per-tick
branch routed those to the SERVERVEL leg, which SKIPS
MoveToManager.UseTime — [npc-tick] literally logged
"branch=SERVERVEL (skips UseTime) mtState=MoveToObject". The armed
moveto was starved for exactly the duration of the server-side chase:
legs stayed in Ready while the body glided on synthesized velocity (the
#170 slide); the manager only woke in UP-silent gaps (creature stopped
server-side) and its stale-heading turn was interrupted by the next UM
before reaching BeginMoveForward.

Retail runs MovementManager::UseTime UNCONDITIONALLY every tick
(CPhysicsObj::UpdateObjectInternal 0x005156b0, call @0x00515998) and has
no wire-velocity leg-driver anywhere; between UPs a moveto-driven body
translates from the motion state (get_state_velocity) with UP hard-snaps
correcting drift. Fix: an armed moveto (MovementTypeState != Invalid)
always takes the MOVETO leg; SERVERVEL remains only as the legacy
fallback for entities without a moveto (scripted paths / missiles).

Register: TS-41 (the narrowed SERVERVEL stopgap), TS-42 (drain-order
divergence also pinned this session: acdream drains AnimDone->MotionDone
AFTER HandleTargetting/MoveTo.UseTime; retail's process_hooks
@0x00512d3d runs BEFORE TargetManager/MovementManager in
UpdateObjectInternal — one frame of extra latency, R6 scope).

New conformance: RemoteChaseEndToEndHarnessTests (3 scenarios + theory)
+ RemoteChaseDrainBisectTests (the drain-chain pin; its first run also
demonstrated the TS-40 InWorld=false link-strip wedge shape — harness
bodies must replicate the live RemoteMotion construction).

ISSUES #170 updated (awaiting user visual gate; probes stay until then);
handoff doc superseded-note added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:59:59 +02:00

105 lines
4.6 KiB
C#

using System;
using System.Linq;
using System.Text;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics.Motion;
// ─────────────────────────────────────────────────────────────────────────────
// #170 drain-chain conformance: a stance-change UM's completion must flow
// CSequence link-anim completion → AnimDone hook → ConsumePendingHooks →
// MotionTableManager.AnimationDone countdown → MotionDoneTarget →
// CMotionInterp.MotionDone pop, fully emptying BOTH queues (retail cdb
// invariant: add_to_queue == MotionDone).
//
// History: the first harness run wedged here EXACTLY like the live #170
// signature ([drainq] q=[0x8000003C ...] stuck) — because the harness body
// was unseeded (InWorld=false), so the TS-40 detached-object guard stripped
// every dispatched transition link while the manager kept counting its ticks.
// With the live-faithful RemoteMotion construction the chain drains in ~1 s.
// Kept as the regression pin for that whole chain (and as the canonical
// demonstration of what a link-strip-without-tick-zeroing wedge looks like).
// ─────────────────────────────────────────────────────────────────────────────
public sealed class RemoteChaseDrainBisectTests
{
private readonly ITestOutputHelper _out;
public RemoteChaseDrainBisectTests(ITestOutputHelper output) => _out = output;
[Fact]
public void StanceChange_DrainChain_TickByTick()
{
var h = new RemoteChaseHarness();
// settle spawn
for (int i = 0; i < 30; i++) h.Tick();
DumpState(h, "pre-stance");
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
DumpState(h, "post-UM (t=0)");
for (int i = 1; i <= 90; i++)
{
TickWithHookTrace(h, i);
if (i % 6 == 0 || i <= 3)
DumpState(h, $"tick {i} (t={i / 60f:F2})");
if (!h.Interp.MotionsPending() && h.Seq.Manager.PendingAnimations.Any() == false)
{
DumpState(h, $"DRAINED at tick {i}");
return; // chain healthy
}
}
DumpState(h, "END — still wedged");
Assert.Fail("drain chain wedged after stance UM — see output for where");
}
private void TickWithHookTrace(RemoteChaseHarness h, int i)
{
// Replicate RemoteChaseHarness.Tick but with hook visibility: we call
// the same phases, intercepting the hook list.
h.Now += RemoteChaseHarness.Dt;
h.Mgr.UseTime();
if (h.Body.OnWalkable)
h.Body.set_local_velocity(h.Interp.get_state_velocity(), false);
h.Seq.Advance(RemoteChaseHarness.Dt);
var hooks = h.Seq.ConsumePendingHooks();
if (hooks.Count > 0)
{
var sb = new StringBuilder();
sb.Append(FormattableString.Invariant($"tick {i}: hooks=["));
for (int k = 0; k < hooks.Count; k++)
{
if (k > 0) sb.Append(", ");
sb.Append(hooks[k]?.GetType().Name ?? "null");
}
sb.Append(']');
_out.WriteLine(sb.ToString());
}
for (int k = 0; k < hooks.Count; k++)
{
if (hooks[k] is DatReaderWriter.Types.AnimationDoneHook)
h.Seq.Manager.AnimationDone(success: true);
}
h.Seq.Manager.UseTime();
}
private void DumpState(RemoteChaseHarness h, string tag)
{
var mgrQ = string.Join(" ", h.Seq.Manager.PendingAnimations
.Select(p => FormattableString.Invariant($"(0x{p.Motion:X8},t={p.NumAnims})")));
var core = h.Seq.Core;
var seqList = new StringBuilder();
for (var n = core.AnimList.First; n is not null; n = n.Next)
{
if (seqList.Length > 0) seqList.Append(',');
seqList.Append(FormattableString.Invariant($"fr={n.Value.Framerate:F0}"));
if (ReferenceEquals(n, core.FirstCyclicNode)) seqList.Append('*');
if (ReferenceEquals(n, core.CurrAnimNode)) seqList.Append('^');
}
_out.WriteLine(FormattableString.Invariant(
$"[{tag}] interpPending={h.Interp.MotionsPending()} mgrQ=[{mgrQ}] counter={h.Seq.Manager.AnimationCounter} seq=[{seqList}] frame={core.FrameNumber:F1} substate=0x{h.Seq.Manager.State.Substate:X8} style=0x{h.Seq.Manager.State.Style:X8}"));
}
}