acdream/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.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

633 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using System.Text;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Xunit;
using Xunit.Abstractions;
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
using CorePosition = AcDream.Core.Physics.Position;
namespace AcDream.Core.Tests.Physics.Motion;
// ─────────────────────────────────────────────────────────────────────────────
// #170 "sustain the run" — full-stack remote-chase harness.
//
// The R4-V2 MoveToManagerHarness scripts Heading directly (setHeading writes
// the scalar) and drains pending_motions synthetically, so the two legs where
// the live #170 residual actually lives have ZERO coverage:
//
// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink.
// TurnApplied → ObservedOmega → per-tick quaternion integration →
// MoveToMath.GetHeading → HandleTurnToHeading's HeadingGreater test;
// 2. the REAL drain: MotionTableManager pending_animations countdown fed by
// CSequence AnimDone hooks (link-anim completions), popping
// CMotionInterp.pending_motions via the MotionDoneTarget seam.
//
// This harness wires a real MotionInterpreter + AnimationSequencer +
// MotionTableDispatchSink + MoveToManager EXACTLY like GameWindow's
// EnsureRemoteMotionBindings (GameWindow.cs:4251) and ticks them in
// GameWindow.TickAnimations' per-entity order for the grounded branch=MOVETO
// path (GameWindow.cs:9697 HandleTargetting → 9994 TickRemoteMoveTo →
// 10024 get_state_velocity refresh → 10050 manual omega integration →
// 10247 Sequencer.Advance → 10306 AnimationDone per AnimDoneHook →
// 10309 Manager.UseTime). Wire events (aggro stance UM, mt-6 arms, attack
// UMs) replay the exact live sequence captured in launch-drainq.log
// (2026-07-04, guid 0x80000BE5, Mite Scamp chasing the fleeing player).
//
// Retail acceptance bar (live cdb, 2026-07-04 session): BeginMoveForward ≈
// MoveToObject arms (21/22), pending_motions add == MotionDone exactly, the
// chase turn completes within a couple of seconds and the run SUSTAINS
// between attack swings.
// ─────────────────────────────────────────────────────────────────────────────
internal sealed class ChaseLoader : IAnimationLoader
{
private readonly Dictionary<uint, Animation> _anims = new();
public void Register(uint id, Animation anim) => _anims[id] = anim;
public Animation? LoadAnimation(uint id) =>
_anims.TryGetValue(id, out var a) ? a : null;
}
/// <summary>
/// The full-stack per-entity pipeline replica. Field-for-field mirror of the
/// GameWindow wiring for one grounded remote NPC (branch=MOVETO), plus a
/// scripted stand-in for the TargetManager voyeur delivery (SetTarget →
/// synchronous first delivery + one delivery per quantum thereafter — the
/// live cadence observed in launch-drainq.log: HandleUpdateTarget directly
/// after the arm, then sparse).
/// </summary>
internal sealed class RemoteChaseHarness
{
// Styles / substates (full command words, as the wire produces them).
public const uint NonCombat = 0x8000003Du;
public const uint Combat = 0x8000003Cu; // the aggro stance in the live capture
public const uint Ready = 0x41000003u;
public const uint Walk = 0x45000005u;
public const uint Run = 0x44000007u;
public const uint TurnRight = 0x6500000Du;
public const uint AttackAction = 0x10000063u; // one of the live scamp swings
public const uint CreatureGuid = 0x80000BE5u;
public const uint PlayerGuid = 0x5000000Au;
public const float Dt = 1f / 60f;
// Field-for-field mirror of GameWindow's RemoteMotion construction
// (GameWindow.cs:592-618): Contact+OnWalkable+Active, InWorld=true (the
// R4-V5 door fix — without it the interp's detached-object guard strips
// every dispatched transition link), and the R4-V5 #160 RemoteWeenie
// (null weenie degrades run-rate to 1.0).
public readonly PhysicsBody Body = new()
{
State = PhysicsStateFlags.ReportCollisions,
TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable
| TransientStateFlags.Active,
InWorld = true,
};
public readonly MotionInterpreter Interp;
public readonly AnimationSequencer Seq;
public readonly MotionTableDispatchSink Sink;
public readonly MoveToManager Mgr;
/// <summary>GameWindow's RemoteMotion.ObservedOmega twin.</summary>
public Vector3 ObservedOmega;
/// <summary>Scripted player (chase target) world position.</summary>
public Vector3 PlayerPos;
public Vector3 PlayerVelocity;
// Scripted targeting stand-in (GameWindow: EntityPhysicsHost/TargetManager).
private bool _targetArmed;
private double _lastDeliveryTime = double.NegativeInfinity;
private double _quantum = 0.5;
public double Now;
// Counters (the live-probe equivalents).
public int BeginTurnBlocked;
public int BeginTurnUnblocked;
public int RunInstalls; // substate transitions INTO RunForward/Walk fwd
public int TicksInRun;
public int TicksInWalkFwd;
public int TotalTicks;
public int MaxRunStreak;
private int _runStreak;
private uint _prevSubstate;
public readonly List<string> Trace = new();
private readonly ITestOutputHelper? _log;
public RemoteChaseHarness(ITestOutputHelper? log = null)
{
_log = log;
var (setup, mtable, loader) = BuildFixture();
Seq = new AnimationSequencer(setup, mtable, loader);
// ── GameWindow spawn path (OnLiveEntitySpawnedLocked ~3781) ──
Seq.InitializeState();
Seq.SetCycle(NonCombat, Ready);
Body.Orientation = MoveToMath.SetHeading(Quaternion.Identity, 0f); // face North
Interp = new MotionInterpreter(Body)
{
WeenieObj = new RemoteWeenie(),
};
// ── EnsureRemoteMotionBindings (GameWindow.cs:4251) verbatim ──
Sink = new MotionTableDispatchSink(Seq)
{
TurnApplied = (turnMotion, turnSpeed) =>
{
float signed = (turnMotion & 0xFFu) == 0x0E
? -MathF.Abs(turnSpeed)
: turnSpeed;
ObservedOmega = new Vector3(0f, 0f, -(MathF.PI / 2f) * signed);
},
TurnStopped = () => ObservedOmega = Vector3.Zero,
};
Interp.DefaultSink = Sink;
Interp.RemoveLinkAnimations = Seq.RemoveAllLinkAnimations;
Interp.InitializeMotionTables = () => Seq.Manager.InitializeState();
Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions;
Mgr = new MoveToManager(
Interp,
stopCompletely: () => Interp.StopCompletely(),
getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation),
getHeading: () => MoveToMath.GetHeading(Body.Orientation),
setHeading: (h, _) => Body.Orientation =
MoveToMath.SetHeading(Body.Orientation, h),
getOwnRadius: () => 0f,
getOwnHeight: () => 0f,
contact: () => Body.OnWalkable,
isInterpolating: () => false,
getVelocity: () => Body.Velocity,
getSelfId: () => CreatureGuid,
setTarget: (ctx, tlid, radius, q) =>
{
_targetArmed = tlid == PlayerGuid;
// TargetManager delivers the FIRST info synchronously on
// SetTarget (live log: HandleUpdateTarget printed directly
// after the arm, same network phase).
DeliverTargetInfo();
},
clearTarget: () => _targetArmed = false,
getTargetQuantum: () => _quantum,
setTargetQuantum: q => _quantum = q,
curTime: () => Now);
Interp.InterruptCurrentMovement =
() => Mgr.CancelMoveTo(WeenieError.ActionCancelled);
// ── The anim-loop MotionDone binding (GameWindow.cs:10266) ──
Seq.MotionDoneTarget = (m, ok) => Interp.MotionDone(m, ok);
_prevSubstate = Seq.Manager.State.Substate;
}
// ── Wire events ─────────────────────────────────────────────────────────
/// <summary>mt-0 UM (funnel apply): interrupt + unstick head, then
/// MoveToInterpretedState — GameWindow.cs:4893 + 5008.</summary>
public void UmInterpreted(uint stance, uint forward, float forwardSpeed = 1f,
params InboundMotionAction[] actions)
{
Interp.InterruptCurrentMovement?.Invoke();
Interp.UnstickFromObject?.Invoke();
var ims = new InboundInterpretedState
{
CurrentStyle = stance,
ForwardCommand = forward,
ForwardSpeed = forwardSpeed,
SideStepCommand = 0u,
SideStepSpeed = 1f,
TurnCommand = 0u,
TurnSpeed = 1f,
};
if (actions.Length > 0)
ims.Actions = new List<InboundMotionAction>(actions);
Interp.MoveToInterpretedState(ims, Sink);
}
/// <summary>mt-6 UM (MoveToObject arm): interrupt + unstick head, then the
/// RouteServerMoveTo MovementStruct — GameWindow.cs:4483-4508. Params match
/// the live scamp chase (spd 2.08, threshold 15, object distance 0.6).</summary>
public void UmMoveToObject(
float speed = 2.08f,
float distanceToObject = 0.6f,
float walkRunThreshold = 15f)
{
Interp.InterruptCurrentMovement?.Invoke();
Interp.UnstickFromObject?.Invoke();
var mp = new MovementParameters
{
CanWalk = true,
CanRun = true,
CanCharge = true,
CanSidestep = false,
CanWalkBackwards = false,
Speed = speed,
DistanceToObject = distanceToObject,
WalkRunThreshhold = walkRunThreshold,
FailDistance = float.MaxValue,
UseSpheres = false,
};
var ms = new MovementStruct
{
Type = MovementType.MoveToObject,
ObjectId = PlayerGuid,
TopLevelId = PlayerGuid,
Pos = new CorePosition(1u, PlayerPos, Quaternion.Identity),
Params = mp,
};
Mgr.PerformMovement(ms);
}
private void DeliverTargetInfo()
{
if (!_targetArmed) return;
_lastDeliveryTime = Now;
var pos = new CorePosition(1u, PlayerPos, Quaternion.Identity);
Mgr.HandleUpdateTarget(new TargetInfo
{
ObjectId = PlayerGuid,
Status = TargetStatus.Ok,
TargetPosition = pos,
InterpolatedPosition = pos,
});
}
// ── The per-tick pipeline (GameWindow.TickAnimations order) ────────────
public void Tick()
{
Now += Dt;
TotalTicks++;
// Player (chase target) moves per its scripted velocity.
PlayerPos += PlayerVelocity * Dt;
// 1. Voyeur delivery (player host HandleTargetting → this entity's
// HandleUpdateTarget; GameWindow.cs:8094 runs before the per-remote
// loop, 9697 in-loop).
if (_targetArmed && Now - _lastDeliveryTime >= _quantum)
DeliverTargetInfo();
// 2. MoveToManager drive (TickRemoteMoveTo, GameWindow.cs:9994).
Mgr.UseTime();
// 3. get_state_velocity → body velocity (the d2ccc80e refresh,
// GameWindow.cs:10024).
if (Body.OnWalkable)
Body.set_local_velocity(Interp.get_state_velocity(), false);
// 4. Manual omega integration (GameWindow.cs:10050-10058 verbatim).
if (ObservedOmega.LengthSquared() > 1e-8f)
{
float omegaMag = ObservedOmega.Length();
var axis = ObservedOmega / omegaMag;
float angle = omegaMag * Dt;
var deltaRot = Quaternion.CreateFromAxisAngle(axis, angle);
Body.Orientation = Quaternion.Normalize(
Quaternion.Multiply(Body.Orientation, deltaRot));
}
// 5. Position integration (UpdatePhysicsInternal, simplified: grounded,
// no gravity participation for this scenario).
Body.Position += Body.Velocity * Dt;
// 6. Anim loop (GameWindow.cs:10247-10309): advance, drain AnimDone
// hooks into the manager countdown, zero-tick sweep.
Seq.Advance(Dt);
var hooks = Seq.ConsumePendingHooks();
for (int i = 0; i < hooks.Count; i++)
{
if (hooks[i] is DatReaderWriter.Types.AnimationDoneHook)
Seq.Manager.AnimationDone(success: true);
}
Seq.Manager.UseTime();
// ── Observables ──
uint substate = Seq.Manager.State.Substate;
if (substate == Run) TicksInRun++;
if (substate == Walk) TicksInWalkFwd++;
bool inFwd = substate == Run || substate == Walk;
if (inFwd)
{
_runStreak++;
if (_runStreak > MaxRunStreak) MaxRunStreak = _runStreak;
}
else
{
_runStreak = 0;
}
if (inFwd && _prevSubstate != Run && _prevSubstate != Walk)
RunInstalls++;
_prevSubstate = substate;
}
public float Heading => MoveToMath.GetHeading(Body.Orientation);
public float DistToPlayer => Vector3.Distance(Body.Position, PlayerPos);
public void Snapshot(string tag)
{
string line = FormattableString.Invariant(
$"t={Now,6:F2} {tag,-14} mt={Mgr.MovementTypeState,-14} substate=0x{Seq.Manager.State.Substate:X8} heading={Heading,6:F1} dist={DistToPlayer,6:F2} pending={Interp.MotionsPending()} omega.Z={ObservedOmega.Z,6:F3}");
Trace.Add(line);
_log?.WriteLine(line);
}
// ── Fixture ─────────────────────────────────────────────────────────────
private static Animation MakeAnim(int numFrames)
{
var anim = new Animation();
for (int f = 0; f < numFrames; f++)
{
var pf = new AnimationFrame(1u);
pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
anim.PartFrames.Add(pf);
}
return anim;
}
private static MotionData MakeMd(uint animId, float framerate = 30f,
Vector3? velocity = null, Vector3? omega = null)
{
var md = new MotionData();
QualifiedDataId<Animation> qid = animId;
md.Anims.Add(new AnimData
{
AnimId = qid,
LowFrame = 0,
HighFrame = -1,
Framerate = framerate,
});
if (velocity is { } v)
{
md.Velocity = v;
md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasVelocity;
}
if (omega is { } o)
{
md.Omega = o;
md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasOmega;
}
return md;
}
private static void AddLink(MotionTable mt, uint style, uint from, uint to, MotionData md)
{
int outer = (int)((style << 16) | (from & 0xFFFFFFu));
if (!mt.Links.TryGetValue(outer, out var cmd))
{
cmd = new MotionCommandData();
mt.Links[outer] = cmd;
}
cmd.MotionData[(int)to] = md;
}
/// <summary>
/// Two-stance creature table shaped like the Mite Scamp's: NonCombat +
/// Combat styles (default substate Ready in both), Walk/Run cycles with
/// entry/exit links (15 frames @30fps = 0.5 s links, the realistic stop/
/// start durations), a stance-transition link, an attack action link, and
/// a global TurnRight physics-only modifier.
/// </summary>
private static (Setup, MotionTable, ChaseLoader) BuildFixture()
{
const uint ReadyAnimNC = 0x200u;
const uint ReadyAnimC = 0x201u;
const uint WalkAnim = 0x202u;
const uint RunAnim = 0x203u;
const uint ReadyToWalk = 0x204u;
const uint WalkToReady = 0x205u;
const uint ReadyToRun = 0x206u;
const uint RunToReady = 0x207u;
const uint StanceLink = 0x208u; // NonCombat.Ready → Combat (draw)
const uint AttackLink = 0x209u; // Combat.Ready → attack swing
const uint TurnAnim = 0x20Au;
var setup = new Setup();
setup.Parts.Add(0x01000000u);
setup.DefaultScale.Add(Vector3.One);
var loader = new ChaseLoader();
loader.Register(ReadyAnimNC, MakeAnim(30));
loader.Register(ReadyAnimC, MakeAnim(30));
loader.Register(WalkAnim, MakeAnim(30));
loader.Register(RunAnim, MakeAnim(30));
loader.Register(ReadyToWalk, MakeAnim(15));
loader.Register(WalkToReady, MakeAnim(15));
loader.Register(ReadyToRun, MakeAnim(15));
loader.Register(RunToReady, MakeAnim(15));
loader.Register(StanceLink, MakeAnim(15));
loader.Register(AttackLink, MakeAnim(45)); // 1.5 s swing
loader.Register(TurnAnim, MakeAnim(30));
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombat };
mt.StyleDefaults[(DRWMotionCommand)NonCombat] = (DRWMotionCommand)Ready;
mt.StyleDefaults[(DRWMotionCommand)Combat] = (DRWMotionCommand)Ready;
static int CycleKey(uint style, uint substate)
=> (int)((style << 16) | (substate & 0xFFFFFFu));
mt.Cycles[CycleKey(NonCombat, Ready)] = MakeMd(ReadyAnimNC);
mt.Cycles[CycleKey(Combat, Ready)] = MakeMd(ReadyAnimC);
foreach (uint style in new[] { NonCombat, Combat })
{
mt.Cycles[CycleKey(style, Walk)] =
MakeMd(WalkAnim, velocity: new Vector3(0f, 3.12f, 0f));
mt.Cycles[CycleKey(style, Run)] =
MakeMd(RunAnim, velocity: new Vector3(0f, 4.0f, 0f));
AddLink(mt, style, Ready, Walk, MakeMd(ReadyToWalk));
AddLink(mt, style, Walk, Ready, MakeMd(WalkToReady));
AddLink(mt, style, Ready, Run, MakeMd(ReadyToRun));
AddLink(mt, style, Run, Ready, MakeMd(RunToReady));
}
AddLink(mt, NonCombat, Ready, Combat, MakeMd(StanceLink));
AddLink(mt, Combat, Ready, NonCombat, MakeMd(StanceLink));
AddLink(mt, Combat, Ready, AttackAction, MakeMd(AttackLink));
// Global (unstyled) TurnRight modifier — physics-only in Branch 4.
mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd(TurnAnim);
return (setup, mt, loader);
}
}
public sealed class RemoteChaseEndToEndHarnessTests
{
private readonly ITestOutputHelper _out;
public RemoteChaseEndToEndHarnessTests(ITestOutputHelper output) => _out = output;
private const float Dt = RemoteChaseHarness.Dt;
private static int Seconds(float s) => (int)MathF.Round(s / Dt);
/// <summary>
/// The core chase cycle: aggro stance change, one mt-6 arm at a target
/// 90° off the creature's facing, 15 m away, stationary. Retail bar: the
/// stance links play out (~1 s), the chase turn starts, completes at the
/// turn rate (90° at π/2·2.08 rad/s ≈ 0.5 s), and BeginMoveForward
/// installs the forward cycle. Total budget: 4 s of ticks is generous.
/// </summary>
[Theory]
[InlineData(90f)] // target to the East → TurnRight path
[InlineData(270f)] // target to the West → TurnLeft path
[InlineData(170f)] // near-reversal → long right turn
public void SingleArm_TurnCompletes_AndForwardInstalls(float bearingDeg)
{
var h = new RemoteChaseHarness(_out);
float rad = (90f - bearingDeg) * MathF.PI / 180f; // compass → math angle
h.PlayerPos = new Vector3(MathF.Cos(rad), MathF.Sin(rad), 0f) * 15f;
h.PlayerVelocity = Vector3.Zero;
// settle spawn state
for (int i = 0; i < Seconds(0.5f); i++) h.Tick();
h.Snapshot("spawned");
// aggro: stance UM (the live capture's mt-0 stance=0x3C fwd=Ready)
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
for (int i = 0; i < Seconds(1.5f); i++) h.Tick();
h.Snapshot("post-stance");
// the arm
h.UmMoveToObject();
h.Snapshot("armed");
int installTick = -1;
for (int i = 0; i < Seconds(6f); i++)
{
h.Tick();
if (installTick < 0
&& (h.Seq.Manager.State.Substate == RemoteChaseHarness.Run
|| h.Seq.Manager.State.Substate == RemoteChaseHarness.Walk))
{
installTick = i;
h.Snapshot("fwd-install");
}
if (i % Seconds(0.5f) == 0) h.Snapshot("tick");
}
h.Snapshot("end");
Assert.True(installTick >= 0,
$"forward cycle never installed within 6 s of the arm (bearing {bearingDeg}°); " +
$"final: mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8} " +
$"heading={h.Heading:F1} pending={h.Interp.MotionsPending()} omega.Z={h.ObservedOmega.Z:F3}");
Assert.True(installTick <= Seconds(4f),
$"forward cycle took {installTick * Dt:F2} s to install (bearing {bearingDeg}°) — " +
"retail installs within the turn duration (~1-2 s)");
}
/// <summary>
/// The live failure scenario: the player FLEES at 4 m/s and ACE re-arms
/// mt-6 every 2 s (launch-drainq.log cadence). Retail bar: BeginMoveForward
/// ≈ one per arm (21/22 in the live cdb trace) and the run is SUSTAINED —
/// the forward substate holds for most of the chase.
/// </summary>
[Fact]
public void FleeingTarget_RunSustainedAcrossRearms()
{
var h = new RemoteChaseHarness(_out);
h.PlayerPos = new Vector3(0f, 10f, 0f); // dead ahead, 10 m
h.PlayerVelocity = new Vector3(0f, 4f, 0f); // fleeing straight away
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
for (int i = 0; i < Seconds(1.5f); i++) h.Tick();
int arms = 0;
int chaseTicks = Seconds(12f);
for (int i = 0; i < chaseTicks; i++)
{
if (i % Seconds(2f) == 0)
{
h.UmMoveToObject();
arms++;
h.Snapshot($"arm#{arms}");
}
h.Tick();
if (i % Seconds(1f) == 0) h.Snapshot("tick");
}
h.Snapshot("end");
int fwdTicks = h.TicksInRun + h.TicksInWalkFwd;
float fwdFraction = fwdTicks / (float)chaseTicks;
_out.WriteLine(FormattableString.Invariant(
$"arms={arms} installs={h.RunInstalls} fwdTicks={fwdTicks}/{chaseTicks} ({fwdFraction:P0}) maxStreak={h.MaxRunStreak * Dt:F2}s"));
Assert.True(h.RunInstalls >= arms - 1,
$"run installed only {h.RunInstalls}× across {arms} arms — " +
"retail reinstalls per arm (21/22)");
Assert.True(fwdFraction > 0.5f,
$"forward substate held only {fwdFraction:P0} of the chase — the run is not " +
"sustained (live #170 symptom: short bursts + idle glide)");
}
/// <summary>
/// Attack interleave: mid-chase, an attack UM (mt-0, action list carrying
/// the swing, fwd=Ready — the wire shape from the live capture) interrupts
/// the moveto (retail-faithful), the swing plays, and the next mt-6 re-arm
/// must reinstall the run. Also asserts the #170 drain criterion:
/// pending_motions fully empties after the swing (add == done — the retail
/// cdb invariant).
/// </summary>
[Fact]
public void AttackUm_ThenRearm_RunReinstalls_AndQueueDrains()
{
var h = new RemoteChaseHarness(_out);
h.PlayerPos = new Vector3(0f, 12f, 0f);
h.PlayerVelocity = Vector3.Zero;
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
for (int i = 0; i < Seconds(1.5f); i++) h.Tick();
h.UmMoveToObject();
for (int i = 0; i < Seconds(3f); i++) h.Tick();
h.Snapshot("chasing");
// the swing: interrupt + action (stamp 1, autonomous false)
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready, 1f,
new InboundMotionAction(RemoteChaseHarness.AttackAction, Stamp: 1,
Autonomous: false, Speed: 1f));
for (int i = 0; i < Seconds(2.5f); i++) h.Tick();
h.Snapshot("post-swing");
Assert.False(h.Interp.MotionsPending(),
"pending_motions did not fully empty after the swing — the #170 " +
"residual (retail: add_to_queue == MotionDone exactly)");
// the player breaks away (out of attack range), then ACE re-arms —
// the run must come back
h.PlayerPos += new Vector3(0f, 10f, 0f);
h.UmMoveToObject();
int installTick = -1;
for (int i = 0; i < Seconds(5f); i++)
{
h.Tick();
if (installTick < 0
&& (h.Seq.Manager.State.Substate == RemoteChaseHarness.Run
|| h.Seq.Manager.State.Substate == RemoteChaseHarness.Walk))
{
installTick = i;
}
}
h.Snapshot("post-rearm");
Assert.True(installTick >= 0,
$"run did not reinstall after the post-swing re-arm; " +
$"mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8} " +
$"pending={h.Interp.MotionsPending()}");
}
}