Retail CPhysicsObj::RemoveLinkAnimations (0x0050fe20) is a tailcall to CPartArray::HandleEnterWorld (0x00517d70) -> MotionTableManager:: HandleEnterWorld (0x0051bdd0): remove_all_link_animations PLUS a full pending_animations drain (while (head) AnimationDone(0)), each pop relaying MotionDone so CMotionInterp pops its pending_motions node in lockstep. acdream bound the seam to the bare sequence strip, so every jump's LeaveGround removed the animations that queued manager nodes were counting down on — orphaning them (NumAnims>0, anims gone) and permanently damming BOTH queues. MotionsPending() then never drained (probe round: last player pending=False at the first MovementJump press; old jump motions still completing at rest minutes later) and BeginTurnToHeading/BeginMoveForward's verbatim motions_pending gates starved every armed moveto: ACE's mt-6 walk-to-door armed but the body never walked (wire-proven, seqs 98-101); the close-range use turn never completed so the deferred action was silently eaten. Doors only worked on a fresh session (shallow queue). Rebind both production sites (remote EnsureRemoteMotionBindings + the player's EnterPlayerModeNow block) to Manager.HandleEnterWorld(); the sequencer wrapper was a pure passthrough so the manager call is a strict superset. All six interp seam sites (LeaveGround, HitGround, Dead, and the detached-object guards) are the same retail chain. Harness mirrors updated; pins: Issue174LinkStripDrainTests (the seam drains both queues; fresh motions queue and complete after). Suites: Core 2535 / App 713 / UI 425 / Net 385 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
960 lines
41 KiB
C#
960 lines
41 KiB
C#
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;
|
||
|
||
/// <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>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;
|
||
// #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: () => 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);
|
||
// 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;
|
||
|
||
// 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. MovementManager drive (TickRemoteMoveTo — the R5-V5 facade
|
||
// relay, MovementManager::UseTime 0x005242f0).
|
||
Movement.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);
|
||
}
|
||
}
|