acdream/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs
Erik f961d70023 feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates.

Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-20 09:10:31 +02:00

938 lines
40 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 →
// DAT MotionData.Omega → CSequence's complete root Frame →
// Frame::combine → 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
// the retail per-object order: CSequence update → PositionManager adjust →
// complete Frame combine → hook processing → Target/Movement/PartArray/
// Position manager tail. 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;
/// <summary>R5-V5: GameWindow's RemoteMotion.Movement twin — the ONE
/// per-entity MovementManager facade owning Interp + the MoveToManager
/// (retail CPhysicsObj::movement_manager).</summary>
public readonly MovementManager Movement;
/// <summary>The moveto child view (RemoteMotion.MoveTo twin) — non-null
/// after the ctor's MakeMoveToManager, kept so test bodies read
/// unchanged.</summary>
public MoveToManager Mgr => Movement.MoveTo!;
/// <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>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);
Interp.DefaultSink = Sink;
// #174: production binds the seam to Manager.HandleEnterWorld
// (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) —
// the bare sequence strip orphaned pending manager nodes.
Interp.RemoveLinkAnimations = () => Seq.Manager.HandleEnterWorld();
Interp.InitializeMotionTables = () => Seq.Manager.InitializeState();
Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions;
// ── R5-V5: the MovementManager facade owns Interp + the moveto —
// GameWindow's RemoteMotion ctor + EnsureRemoteMotionBindings
// factory shape verbatim (sticky binds inside the factory;
// MakeMoveToManager after the host/Pm exist). ──
Movement = new MovementManager(Interp)
{
MoveToFactory = () =>
{
var mtm = new MoveToManager(
Interp,
stopCompletely: () => Movement.PerformMovement(
new MovementStruct { Type = MovementType.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);
// R5-V3 (#171) sticky seam binds (BeginNextNode arrival
// StickTo @0x00529d3a, PerformMovement-head Unstick).
mtm.StickTo = (tlid, radius, height) => Pm.StickTo(tlid, radius, height);
mtm.Unstick = Pm.UnStick;
return mtm;
},
};
// TS-36: interrupt_current_movement → MovementManager::CancelMoveTo
// (the facade relay 0x005241b0) — EnsureRemoteMotionBindings twin.
Interp.InterruptCurrentMovement =
() => Movement.CancelMoveTo(WeenieError.ActionCancelled);
// ── R5-V3 (#171): the PositionManager/sticky wiring — GameWindow's
// V3 additions verbatim: host-owned facade + the UM-funnel-head
// unstick_from_object bind; then MakeMoveToManager (0x00524000)
// invokes the factory above, mirroring the production bind order. ──
_playerHost = new TargetHost(this);
_creatureHost = new CreatureHost(this);
Pm = new PositionManager(_creatureHost);
Movement.MakeMoveToManager();
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,
};
// R5-V5: RouteServerMoveTo twin — through the facade
// (MovementManager::PerformMovement 0x005240d0).
Movement.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): the MovementManager
// relay (@0x00512bf0 → moveto) first, then PositionManager (the
// sticky consumer).
Movement.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.Movement.HandleUpdateTarget(info);
_h.Pm.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement()
=> _h.Movement.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;
// CPartArray::Update emits one complete local Frame: authored PosFrames
// plus MotionData velocity/omega (CSequence::apply_physics 0x00524AB0).
var rootFrame = new Frame
{
Origin = Vector3.Zero,
Orientation = Quaternion.Identity,
};
Seq.Advance(Dt, rootFrame);
// PositionManager receives that same Frame and may replace its motion
// (sticky/interpolation) before Frame::combine applies translation with
// the OLD orientation, then post-multiplies the relative orientation.
var pmDelta = new MotionDeltaFrame
{
Origin = rootFrame.Origin,
Orientation = rootFrame.Orientation,
};
Pm.AdjustOffset(pmDelta, Dt);
if (pmDelta.Origin != Vector3.Zero)
Body.Position += Vector3.Transform(pmDelta.Origin, Body.Orientation);
if (!pmDelta.Orientation.IsIdentity)
Body.Orientation = Quaternion.Normalize(
Body.Orientation * pmDelta.Orientation);
// The fixture has no gravity or independent physical velocity, but run
// the same physics primitive so a future fixture cannot accidentally
// bypass the retail slot.
Body.calc_acceleration();
Body.UpdatePhysicsInternal(Dt);
// process_hooks precedes the manager tail. AnimationDone decrements the
// exact pending-animation node that owns the transition.
var hooks = Seq.ConsumePendingHooks();
for (int i = 0; i < hooks.Count; i++)
{
if (hooks[i] is DatReaderWriter.Types.AnimationDoneHook)
Seq.Manager.AnimationDone(success: true);
}
// UpdateObjectInternal manager tail: Target → Movement → PartArray →
// Position. A movement selected here contributes to next tick's Frame.
if (_targetArmed && Now - _lastDeliveryTime >= _quantum)
DeliverTargetInfo();
Movement.UseTime();
Seq.Manager.UseTime();
Pm.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={Seq.CurrentOmega.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.
// The signed DAT omega is the rotation source, matching retail's
// Humanoid motion table; no command-derived side channel exists.
mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd(
TurnAnim,
omega: new Vector3(0f, 0f, -1.5f));
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
/// authored turn rate (90° at 1.5·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.Seq.CurrentOmega.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);
}
}