The retail server-directed-movement brain (0x00529010-0x0052a987), Core-only with every App dependency as a ctor/property seam for the V4/V5 cutovers: node-plan builders for all four movement types (TurnToObject's desired-heading clobber quirk VERBATIM; TurnToHeading's immediate BeginNextNode - ACE's one-tick-late gap not copied), PerformMovement (cancel 0x36 + unstick first), BeginNextNode with the sticky handoff order (radius/height/tlid read BEFORE CleanUp), BeginMoveForward (GetCommand walk/run cascade + stored-params write-back + progress-clock seed), HandleMoveToPosition (chase arrival dist <= distance_to_object per the adjudicated BN inversion; fail distance -> 0x3D; progress >= 0.25 units/s over >= 1 s, incremental AND overall; fail_progress_count write-only - retail has NO give-up threshold and none was invented), HandleTurnToHeading (20/340 aux deadband; the Ghidra-confirmed heading_diff mirror), HandleUpdateTarget 0x0052a7d0 (deferred-start: object moves wait for the first Ok callback; retargets reset the progress clock without requeueing), UseTime's initialized gate, InitializeLocalVariables per retail (flags word + context_id zeroed, floats stale, FLT_MAX resets - not ACE's transpositions). TDD catch: default(Quaternion) is the ZERO quaternion, not identity - a fresh manager's heading computations would silently read 90 degrees; explicit IdentityPosition resets match the decomp's identity-Frame semantics. Also pinned: retail's explicit double adjust_motion in _DoMotion/_StopMotion; entry points never drain pending_actions (only PerformMovement's cancel does) - re-issues must route through PerformMovement, documented + tested. 101 new conformance tests incl. three end-to-end scripted drives (chase turn->run->walk-demote->arrive; flee; frozen-heading TurnToObject through retarget). Full suite: 3,961 passed. Implemented by a dedicated agent against the V0-pinned spec; scope + suite independently verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
138 lines
6.2 KiB
C#
138 lines
6.2 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Physics.Motion;
|
|
|
|
namespace AcDream.Core.Tests.Physics.Motion;
|
|
|
|
/// <summary>
|
|
/// R4-V2 — shared scripted fake interp-sink/provider harness for
|
|
/// <see cref="MoveToManager"/> conformance tests (r4-port-plan.md §3 V2:
|
|
/// "Use a scripted fake interp-sink/provider harness — NO real sequencer
|
|
/// needed; the manager drives the interp seams; assert the call sequences
|
|
/// + state"). Wraps a REAL <see cref="MotionInterpreter"/> bound to a
|
|
/// minimal always-grounded <see cref="PhysicsBody"/> (so
|
|
/// <c>_DoMotion</c>/<c>_StopMotion</c>'s <c>adjust_motion</c> +
|
|
/// <c>DoInterpretedMotion</c>/<c>StopInterpretedMotion</c> chain runs for
|
|
/// real, dispatch treated as always-succeeding since no
|
|
/// <see cref="IInterpretedMotionSink"/> is wired — matching
|
|
/// <c>DoInterpretedMotion</c>'s documented null-sink posture), and exposes
|
|
/// every <see cref="MoveToManager"/> ctor seam as a mutable, inspectable
|
|
/// field so tests can script position/heading/contact/target-tracker
|
|
/// behavior and assert on call sequences.
|
|
/// </summary>
|
|
internal sealed class MoveToManagerHarness
|
|
{
|
|
public readonly MotionInterpreter Interp = new();
|
|
public readonly PhysicsBody Body = new();
|
|
|
|
/// <summary>Scripted world position + cell (defaults to cell 1, origin).</summary>
|
|
public Position WorldPosition = new(1u, Vector3.Zero, Quaternion.Identity);
|
|
|
|
/// <summary>Scripted compass heading, degrees (P5 convention).</summary>
|
|
public float Heading;
|
|
|
|
/// <summary>Records every <c>SetHeading(heading, send)</c> call.</summary>
|
|
public readonly List<(float Heading, bool Send)> SetHeadingCalls = new();
|
|
|
|
public float OwnRadius = 0.5f;
|
|
public float OwnHeight = 2.0f;
|
|
|
|
public bool ContactValue = true;
|
|
public bool IsInterpolatingValue;
|
|
public Vector3 Velocity = Vector3.Zero;
|
|
|
|
public uint SelfId = 0x50000001u;
|
|
|
|
/// <summary>Records every <c>StopCompletely()</c> call (count only —
|
|
/// retail's <c>CPhysicsObj::StopCompletely</c> takes no args at this
|
|
/// seam level).</summary>
|
|
public int StopCompletelyCalls;
|
|
|
|
public readonly List<(uint ContextId, uint ObjectId, float Radius, double Quantum)> SetTargetCalls = new();
|
|
public int ClearTargetCalls;
|
|
public double TargetQuantum;
|
|
public readonly List<double> SetTargetQuantumCalls = new();
|
|
|
|
public int UnstickCalls;
|
|
public readonly List<(uint Tlid, float Radius, float Height)> StickToCalls = new();
|
|
public readonly List<WeenieError> MoveToCompleteCalls = new();
|
|
|
|
/// <summary>Scripted clock — advances by <see cref="TickSeconds"/> only
|
|
/// when a test calls <see cref="Tick"/>; reading <c>CurTime</c> alone
|
|
/// (e.g. multiple reads within one manager call) does NOT advance it,
|
|
/// matching retail's <c>Timer::cur_time</c> being a stable snapshot for
|
|
/// the duration of one dispatch.</summary>
|
|
public double CurTime;
|
|
public const double TickSeconds = 1.0 / 30.0;
|
|
|
|
public readonly MoveToManager Manager;
|
|
|
|
public MoveToManagerHarness()
|
|
{
|
|
Interp.PhysicsObj = Body;
|
|
Body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active;
|
|
|
|
Manager = new MoveToManager(
|
|
Interp,
|
|
stopCompletely: () => StopCompletelyCalls++,
|
|
getPosition: () => WorldPosition,
|
|
getHeading: () => Heading,
|
|
setHeading: (h, send) => { SetHeadingCalls.Add((h, send)); Heading = h; },
|
|
getOwnRadius: () => OwnRadius,
|
|
getOwnHeight: () => OwnHeight,
|
|
contact: () => ContactValue,
|
|
isInterpolating: () => IsInterpolatingValue,
|
|
getVelocity: () => Velocity,
|
|
getSelfId: () => SelfId,
|
|
setTarget: (ctx, obj, radius, quantum) => SetTargetCalls.Add((ctx, obj, radius, quantum)),
|
|
clearTarget: () => ClearTargetCalls++,
|
|
getTargetQuantum: () => TargetQuantum,
|
|
setTargetQuantum: q => { TargetQuantum = q; SetTargetQuantumCalls.Add(q); },
|
|
curTime: () => CurTime);
|
|
|
|
Manager.StickTo = (tlid, radius, height) => StickToCalls.Add((tlid, radius, height));
|
|
Manager.MoveToComplete = err => MoveToCompleteCalls.Add(err);
|
|
Manager.Unstick = () => UnstickCalls++;
|
|
}
|
|
|
|
/// <summary>Advance the scripted clock by one physics tick (1/30 s).</summary>
|
|
public void Tick() => CurTime += TickSeconds;
|
|
|
|
/// <summary>Advance the scripted clock by an arbitrary amount.</summary>
|
|
public void Advance(double seconds) => CurTime += seconds;
|
|
|
|
/// <summary>
|
|
/// Drains the REAL <see cref="MotionInterpreter"/>'s
|
|
/// <c>pending_motions</c> queue via synthetic <c>MotionDone</c>
|
|
/// callbacks — standing in for "the dispatched motion's animation-table
|
|
/// cycle finished", which a live <c>AnimationSequencer</c>/
|
|
/// <c>MotionTableManager</c> would signal in production. Every
|
|
/// <c>_DoMotion</c>/<c>_StopMotion</c> call that succeeds enqueues a
|
|
/// node (retail <c>AddToQueue</c>, decomp's <c>DoInterpretedMotion</c>
|
|
/// body); without draining, <see cref="MotionInterpreter.MotionsPending"/>
|
|
/// stays true forever in this bare harness, which would wedge
|
|
/// <see cref="MoveToManager.BeginTurnToHeading"/>'s wait-for-anims gate
|
|
/// and <see cref="MoveToManager.HandleMoveToPosition"/> Phase 1's
|
|
/// "animating, stop aux" branch permanently. Call after any manager
|
|
/// method that dispatches a motion, before asserting on the NEXT tick's
|
|
/// behavior.
|
|
/// </summary>
|
|
public void DrainPendingMotions()
|
|
{
|
|
while (Interp.MotionsPending())
|
|
Interp.MotionDone(0, true);
|
|
}
|
|
|
|
/// <summary>Current interpreted forward command — the observable proxy
|
|
/// for "what motion did MoveToManager just dispatch via _DoMotion",
|
|
/// since <see cref="MotionInterpreter.DoInterpretedMotion(uint,MovementParameters)"/>
|
|
/// writes through to <see cref="InterpretedMotionState.ApplyMotion"/>
|
|
/// when <c>ModifyInterpretedState</c> is set (default true).</summary>
|
|
public uint ForwardCommand => Interp.InterpretedState.ForwardCommand;
|
|
|
|
public uint TurnCommand => Interp.InterpretedState.TurnCommand;
|
|
|
|
public float ForwardSpeed => Interp.InterpretedState.ForwardSpeed;
|
|
}
|