acdream/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs
Erik f423884bd1 feat(physics): R5-V4 behavioral slice — head stance dispatch (all mt) + #164 autonomy bit + mt-0 wire flags
Three unpack_movement parity items (facade deferred per the handoff's
own optional clause — see r5-wiring-handoff §V4 status):

1. HEAD style-on-change (0x00524440 @00524502-0052452c): both GameWindow
   routing heads (remote + player) now dispatch DoMotion(style, ctor
   defaults) when the UM's stance differs from the interp's current
   style, BEFORE the movement-type routing — for EVERY type. Previously
   style applied only through the mt-0 funnel copy, so a chase/turn UM
   (mt 6-9) carrying a stance change started the move in the OLD stance
   (a monster charged in NonCombat posture until the next mt-0). The
   RetailObserverTraceConformanceTests exclusion note updated — the
   trace filter stays (head calls can't appear in a
   MoveToInterpretedState replay) but the production gap it pointed at
   is closed.

2. #164 (closes): the action-replay loop threads each action's autonomy
   into the dispatch params (Autonomous = the 0x1000 splice, raw
   305982) — replayed actions enter the interpreted actions list with
   their real autonomy instead of ctor-default false.

3. mt-0 wire flags consumed (UpdateMotion parsed them since R4-V3):
   0x1 StickToObject → CPhysicsObj::stick_to_object port (0x005127e0:
   resolve target, PartArray radii — 0 when shapeless, guid-as-is for
   acdream's flat entity table — → PositionManager.StickTo; unresolvable
   target → no stick), at BOTH case-0 tails in retail order
   (@00524583-0052458e: funnel apply → stick → longjump flag);
   0x2 StandingLongJump → Motion.StandingLongJump, UNCONDITIONAL write
   (absent flag clears — retail @0052458e). ServerMotionState gains the
   StandingLongJump field.

Conformance: ChaseArm_WithStanceChange_AppliesStanceBeforeTheChase
(harness mirrors the routing-head dispatch) +
Actions_ReplayCarriesAutonomyIntoTheInterpretedList. Suite 4041 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:18:26 +02:00

928 lines
39 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;
// R5-V3 (#171): setup cylsphere radii for the sticky/UseSpheres scenarios
// (GameWindow threads the real values via GetSetupCylinder; these are
// creature-typical stand-ins).
public const float OwnRadius = 0.3f;
public const float StickyTargetRadius = 0.5f;
// 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>R5-V3 (#171): the creature's PositionManager facade — the
/// EntityPhysicsHost-owned twin (GameWindow binds MoveToManager.StickTo/
/// Unstick + MotionInterpreter.UnstickFromObject to it and drives
/// AdjustOffset/UseTime per tick).</summary>
public readonly PositionManager Pm;
private readonly CreatureHost _creatureHost;
private readonly TargetHost _playerHost;
/// <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: () => OwnRadius,
getOwnHeight: () => 1f,
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);
// ── R5-V3 (#171): the PositionManager/sticky wiring — GameWindow's
// V3 additions verbatim: host-owned facade + the three seam binds
// (BeginNextNode arrival StickTo, PerformMovement-head Unstick,
// UM-funnel-head unstick_from_object). ──
_playerHost = new TargetHost(this);
_creatureHost = new CreatureHost(this);
Pm = new PositionManager(_creatureHost);
Mgr.StickTo = (tlid, radius, height) => Pm.StickTo(tlid, radius, height);
Mgr.Unstick = Pm.UnStick;
Interp.UnstickFromObject = Pm.UnStick;
// ── 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,
bool sticky = false,
bool useSpheres = false,
float targetRadius = 0f,
float targetHeight = 0f,
uint wireStance = 0u)
{
Interp.InterruptCurrentMovement?.Invoke();
Interp.UnstickFromObject?.Invoke();
// R5-V4a: the unpack_movement HEAD style-on-change (GameWindow's
// routing-head mirror — 0x00524440 @00524502-0052452c): fires for
// EVERY movement type, BEFORE the type routing, on CHANGE only.
if (wireStance != 0u)
{
uint wireStyle = 0x80000000u | wireStance;
if (Interp.InterpretedState.CurrentStyle != wireStyle)
Interp.DoMotion(wireStyle, new MovementParameters());
}
var mp = new MovementParameters
{
CanWalk = true,
CanRun = true,
CanCharge = true,
CanSidestep = false,
CanWalkBackwards = false,
Speed = speed,
DistanceToObject = distanceToObject,
WalkRunThreshhold = walkRunThreshold,
FailDistance = float.MaxValue,
// R5-V3 (#171): ACE arms every melee chase Sticky + UseSpheres
// (Monster_Navigation.cs:406-419; UseSpheres is the ACE default).
Sticky = sticky,
UseSpheres = useSpheres,
};
var ms = new MovementStruct
{
Type = MovementType.MoveToObject,
ObjectId = PlayerGuid,
TopLevelId = PlayerGuid,
Pos = new CorePosition(1u, PlayerPos, Quaternion.Identity),
Params = mp,
// R5-V3: the RouteServerMoveTo radius threading twin — retail
// reads the TARGET's PartArray radius/height at the call site.
Radius = targetRadius,
Height = targetHeight,
};
Mgr.PerformMovement(ms);
}
private void DeliverTargetInfo()
{
if (!_targetArmed) return;
_lastDeliveryTime = Now;
var pos = new CorePosition(1u, PlayerPos, Quaternion.Identity);
var info = new TargetInfo
{
ObjectId = PlayerGuid,
Status = TargetStatus.Ok,
TargetPosition = pos,
InterpolatedPosition = pos,
};
// R5-V3 fan order (EntityPhysicsHost.HandleUpdateTarget — retail
// CPhysicsObj::HandleUpdateTarget 0x00512bc0): MoveToManager first,
// then PositionManager (the sticky consumer).
Mgr.HandleUpdateTarget(info);
Pm.HandleUpdateTarget(info);
}
// ── R5-V3 (#171): the IPhysicsObjHost twins of GameWindow's
// EntityPhysicsHost (creature side) and ResolvePhysicsHost's minimal
// target host (player side). ──
private sealed class CreatureHost : IPhysicsObjHost
{
private readonly RemoteChaseHarness _h;
public CreatureHost(RemoteChaseHarness h) => _h = h;
public uint Id => CreatureGuid;
public CorePosition Position => new(1u, _h.Body.Position, _h.Body.Orientation);
public Vector3 Velocity => _h.Body.Velocity;
public float Radius => OwnRadius;
public bool InContact => _h.Body.OnWalkable;
public float? MinterpMaxSpeed => _h.Interp.GetMaxSpeed();
public double CurTime => _h.Now;
public double PhysicsTimerTime => _h.Now;
public IPhysicsObjHost? GetObjectA(uint id)
=> id == PlayerGuid ? _h._playerHost : null;
public void HandleUpdateTarget(TargetInfo info)
{
_h.Mgr.HandleUpdateTarget(info);
_h.Pm.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement()
=> _h.Mgr.CancelMoveTo(WeenieError.ActionCancelled);
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
{
// The scripted TargetManager stand-in — StickyManager::StickTo
// subscribes at quantum 0.5 (0x00555710) with a synchronous first
// delivery (the AddVoyeur immediate snapshot).
_h._targetArmed = objectId == PlayerGuid;
_h._quantum = quantum;
_h.DeliverTargetInfo();
}
public void ClearTarget() => _h._targetArmed = false;
public void ReceiveTargetUpdate(TargetInfo info) { }
public void AddVoyeur(uint watcherId, float radius, double quantum) { }
public void RemoveVoyeur(uint watcherId) { }
}
private sealed class TargetHost : IPhysicsObjHost
{
private readonly RemoteChaseHarness _h;
public TargetHost(RemoteChaseHarness h) => _h = h;
public uint Id => PlayerGuid;
public CorePosition Position => new(1u, _h.PlayerPos, Quaternion.Identity);
public Vector3 Velocity => _h.PlayerVelocity;
public float Radius => StickyTargetRadius;
public bool InContact => true;
public float? MinterpMaxSpeed => null;
public double CurTime => _h.Now;
public double PhysicsTimerTime => _h.Now;
public IPhysicsObjHost? GetObjectA(uint id) => null;
public void HandleUpdateTarget(TargetInfo info) { }
public void InterruptCurrentMovement() { }
public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { }
public void ClearTarget() { }
public void ReceiveTargetUpdate(TargetInfo info) { }
public void AddVoyeur(uint watcherId, float radius, double quantum) { }
public void RemoveVoyeur(uint watcherId) { }
}
// ── 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. R5-V3 (#171): the sticky steer — GameWindow's legacy-branch slot
// (retail UpdatePositionInternal PositionManager::adjust_offset
// @0x00512d0e, composed BEFORE the velocity integration). Origin is
// mover-local; the rotation carries a RELATIVE heading (identity =
// untouched = no turn).
var pmDelta = new MotionDeltaFrame();
Pm.AdjustOffset(pmDelta, Dt);
if (pmDelta.Origin != Vector3.Zero)
Body.Position += Vector3.Transform(pmDelta.Origin, Body.Orientation);
if (!pmDelta.Orientation.IsIdentity)
Body.Orientation = MoveToMath.SetHeading(
Body.Orientation,
MoveToMath.GetHeading(Body.Orientation) + pmDelta.GetHeading());
// 5b. Position integration (UpdatePhysicsInternal, simplified: grounded,
// no gravity participation for this scenario).
Body.Position += Body.Velocity * Dt;
// 5c. R5-V3: PositionManager::UseTime (retail UpdateObjectInternal
// tail @0x005159b3) — the sticky 1 s lease watchdog.
Pm.UseTime();
// 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()}");
}
// ── R5-V3 (#171): sticky melee scenarios ────────────────────────────────
private static float AbsHeadingDiff(float a, float b)
{
float d = MathF.Abs(a - b) % 360f;
return d > 180f ? 360f - d : d;
}
/// <summary>
/// The #171 fix's core: ACE arms melee chases Sticky+UseSpheres with the
/// target's real radii; on arrival BeginNextNode hands off to
/// PositionManager::StickTo (0x00529d3a) and StickyManager::adjust_offset
/// (0x00555430) holds a 0.3 m edge gap + live facing against a strafing
/// target — for the 1 s lease, which is set ONCE at StickTo and NOT
/// refreshed by target updates (retail 0x00555710/0x00555610): a stick
/// not re-issued by a fresh server arm tears itself down.
/// </summary>
[Fact]
public void StickyArrival_TracksStrafingTarget_ThenLeaseExpires()
{
var h = new RemoteChaseHarness(_out);
h.PlayerPos = new Vector3(0f, 10f, 0f); // dead ahead (North)
h.PlayerVelocity = Vector3.Zero;
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
for (int i = 0; i < Seconds(1.5f); i++) h.Tick();
h.UmMoveToObject(sticky: true, useSpheres: true,
targetRadius: RemoteChaseHarness.StickyTargetRadius, targetHeight: 1.2f);
int stickTick = -1;
for (int i = 0; i < Seconds(8f) && stickTick < 0; i++)
{
h.Tick();
if (h.Pm.GetStickyObjectId() == RemoteChaseHarness.PlayerGuid)
stickTick = i;
}
h.Snapshot("stuck");
Assert.True(stickTick >= 0,
$"sticky never armed within 8 s of the arm; mt={h.Mgr.MovementTypeState} " +
$"dist={h.DistToPlayer:F2}");
// Arrival cleaned the moveto BEFORE the handoff (BeginNextNode reads
// Sought* pre-CleanUp; CleanUp resets movement_type to Invalid).
Assert.Equal(MovementType.Invalid, h.Mgr.MovementTypeState);
// The target strafes — the follower must track gap AND facing
// (adjust_offset resolves the LIVE target via GetObjectA per tick).
h.PlayerVelocity = new Vector3(2f, 0f, 0f);
for (int i = 0; i < Seconds(0.8f); i++) h.Tick();
h.Snapshot("strafed");
Assert.Equal(RemoteChaseHarness.PlayerGuid, h.Pm.GetStickyObjectId()); // inside the lease
float gap = MoveToMath.CylinderDistanceNoZ(
RemoteChaseHarness.OwnRadius, h.Body.Position,
RemoteChaseHarness.StickyTargetRadius, h.PlayerPos);
Assert.True(MathF.Abs(gap - StickyManager.StickyRadius) < 0.1f,
$"stick gap {gap:F2} m — expected ≈{StickyManager.StickyRadius:F1} m (StickyRadius)");
float bearing = MoveToMath.PositionHeading(h.Body.Position, h.PlayerPos);
Assert.True(AbsHeadingDiff(h.Heading, bearing) < 5f,
$"facing {h.Heading:F1}° vs bearing {bearing:F1}° — sticky facing not tracking");
// Lease expiry: >1 s since StickTo with no re-arm → the UseTime
// watchdog drops the stick (retail parity — ACE re-arms each attack
// cycle, renewing the stick server-side).
h.PlayerVelocity = Vector3.Zero;
for (int i = 0; i < Seconds(0.5f); i++) h.Tick();
Assert.Equal(0u, h.Pm.GetStickyObjectId());
}
/// <summary>
/// The pack-melee reshuffle cycle: every fresh server arm
/// (PerformMovement) tears the previous stick down at the HEAD
/// (unstick_from_object → PositionManager::UnStick — the retail
/// PerformMovement:414 + UM-funnel-head sites), then the new chase
/// re-arrives and re-sticks.
/// </summary>
[Fact]
public void NextPerformMovement_Unsticks_ThenRearrivalResticks()
{
var h = new RemoteChaseHarness(_out);
h.PlayerPos = new Vector3(0f, 8f, 0f);
h.PlayerVelocity = Vector3.Zero;
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
for (int i = 0; i < Seconds(1.5f); i++) h.Tick();
h.UmMoveToObject(sticky: true, useSpheres: true,
targetRadius: RemoteChaseHarness.StickyTargetRadius, targetHeight: 1.2f);
for (int i = 0; i < Seconds(8f); i++)
{
h.Tick();
if (h.Pm.GetStickyObjectId() != 0u) break;
}
Assert.Equal(RemoteChaseHarness.PlayerGuid, h.Pm.GetStickyObjectId());
// The target breaks away; ACE re-arms the chase — the arm must
// UNSTICK immediately (stale stick torn down before the new moveto).
h.PlayerPos += new Vector3(0f, 8f, 0f);
h.UmMoveToObject(sticky: true, useSpheres: true,
targetRadius: RemoteChaseHarness.StickyTargetRadius, targetHeight: 1.2f);
Assert.Equal(0u, h.Pm.GetStickyObjectId());
Assert.Equal(MovementType.MoveToObject, h.Mgr.MovementTypeState);
// ...and the new chase re-arrives and re-sticks.
int restick = -1;
for (int i = 0; i < Seconds(8f) && restick < 0; i++)
{
h.Tick();
if (h.Pm.GetStickyObjectId() == RemoteChaseHarness.PlayerGuid)
restick = i;
}
Assert.True(restick >= 0,
$"chase did not re-stick after the re-arm; mt={h.Mgr.MovementTypeState} " +
$"dist={h.DistToPlayer:F2}");
}
/// <summary>
/// R5-V4a: the unpack-head style-on-change — a chase arm (mt-6) whose UM
/// carries a CHANGED stance applies the stance FIRST (retail
/// unpack_movement @00524502-0052452c dispatches DoMotion(style) before
/// the movement-type switch), so the creature draws into Combat and THEN
/// runs — instead of chasing in the old NonCombat stance until the next
/// mt-0 UM. Closes the RetailObserverTraceConformanceTests "S3 wires the
/// unpack-level style-on-change" exclusion's production gap.
/// </summary>
[Fact]
public void ChaseArm_WithStanceChange_AppliesStanceBeforeTheChase()
{
var h = new RemoteChaseHarness(_out);
h.PlayerPos = new Vector3(0f, 12f, 0f);
h.PlayerVelocity = Vector3.Zero;
// settle in NonCombat — NO prior stance UM (the pre-V4 gap: nothing
// but an mt-0 could change the stance).
for (int i = 0; i < Seconds(0.5f); i++) h.Tick();
Assert.Equal(RemoteChaseHarness.NonCombat, h.Seq.Manager.State.Style);
// the arm carries the Combat stance in its UM header (mt-6).
h.UmMoveToObject(wireStance: RemoteChaseHarness.Combat & 0xFFFFu);
// the stance adopts at the head — before any chase motion completes.
Assert.Equal(RemoteChaseHarness.Combat, h.Interp.InterpretedState.CurrentStyle);
// ...and the chase still installs its forward cycle normally, now in
// the Combat style family.
int installTick = -1;
for (int i = 0; i < Seconds(6f) && installTick < 0; i++)
{
h.Tick();
if (h.Seq.Manager.State.Substate == RemoteChaseHarness.Run
|| h.Seq.Manager.State.Substate == RemoteChaseHarness.Walk)
installTick = i;
}
Assert.True(installTick >= 0,
$"forward cycle never installed after the stance-carrying arm; " +
$"mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8}");
Assert.Equal(RemoteChaseHarness.Combat, h.Seq.Manager.State.Style);
}
}