The retail movement-manager family the R4 MoveToManager port left as do-not-invent seams (decomp §9f/§9g). Faithful C# ports of retail's PositionManager facade + StickyManager + ConstraintManager + the TargetManager voyeur system, with full conformance tests. NO wiring yet — purely additive, no behavior change. Wiring (retiring TS-39 sticky + AP-79 target adapter) is R5-V2/V3. New Core classes (src/AcDream.Core/Physics/Motion/): - StickyManager (0x00555400): follow-a-target steering. adjust_offset's dense x87 mush decoded via ACE (StickyRadius 0.3, StickyTime 1.0, follow speed ×5 / fallback 15) — speed-clamped signed-distance steer + bounded turn-to-face; 1 s watchdog; Ok→initialized / non-Ok→teardown. - ConstraintManager (0x00556090): the server-position rubber-band leash. 90% IsFullyConstrained jump gate + grounded linear brake taper. Structural only — acdream never ARMS it (retail arms from SmartBox::HandleReceivedPosition, which acdream lacks, with two x87 constants BN elided). IsFullyConstrained stays false = TS-35 behavior; leash-arming + the unknown constants are a deferred issue. - PositionManager facade (0x00555160): lazy Sticky/Constraint + fan-out. - TargetManager (0x0051a370) + TargettedVoyeurInfo: the peer-to-peer voyeur subscription system (0.5 s throttle, 10 s staleness, send-on-drift-past-radius, dead-reckon GetInterpolatedPosition). A faithful superset of the AP-79 adapter — SetTarget subscribes ON the target; the target's HandleTargetting pushes updates back. - IPhysicsObjHost: the CPhysicsObj back-pointer seam (position/velocity/ radius/contact/GetObjectA + target-tracking fan-out) the App wires per entity in V2/V3. MotionDeltaFrame: mutable retail-Frame delta accumulator. Supporting: - TargetInfo extended to the full retail 10-field struct (additive defaults keep the R4 4-arg call sites compiling). - MoveToMath: signed CylinderDistanceNoZ, NormalizeCheckSmall, GlobalToLocalVec. - Rename: the misnamed AcDream.Core.Physics.PositionManager (a remote anim+interp per-frame combiner, NOT the retail facade) → RemoteMotion Combiner, freeing the name and removing the ambiguity that breaks every file importing both Physics + Physics.Motion (GameWindow will in V2/V3). Tests: 42 new conformance cases (Sticky/Constraint/Position facade + TargetManager incl. the full cross-entity voyeur round-trip). Full suite 4006 green (+2 skipped), no regressions. Decomp + ACE cross-ref + port plan: docs/research/2026-07-03-r5-managers/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
133 lines
4.2 KiB
C#
133 lines
4.2 KiB
C#
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Physics.Motion;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics.Motion;
|
|
|
|
/// <summary>
|
|
/// R5 conformance — <see cref="ConstraintManager"/> (retail 0x00556090-…).
|
|
/// The leash-band taper + the 90 % IsFullyConstrained jump gate (port-plan §2b/§2c).
|
|
/// </summary>
|
|
public sealed class ConstraintManagerTests
|
|
{
|
|
private static (R5Host host, ConstraintManager cm) Setup()
|
|
{
|
|
var world = new Dictionary<uint, R5Host>();
|
|
var host = new R5Host(10u, world);
|
|
return (host, new ConstraintManager(host));
|
|
}
|
|
|
|
private static Position Anchor(float x) => new(1u, new Vector3(x, 0f, 0f), Quaternion.Identity);
|
|
|
|
[Fact]
|
|
public void ConstrainTo_InitializesOffsetToCurrentDistanceFromAnchor()
|
|
{
|
|
var (host, cm) = Setup();
|
|
host.SetOrigin(Vector3.Zero);
|
|
|
|
cm.ConstrainTo(Anchor(5f), startDistance: 2f, maxDistance: 10f);
|
|
|
|
Assert.True(cm.IsConstrained);
|
|
Assert.Equal(5f, cm.ConstraintPosOffset, 3); // distance(anchor(5,0,0), self(0,0,0))
|
|
}
|
|
|
|
[Fact]
|
|
public void IsFullyConstrained_TrueOnlyBeyond90PercentOfMax()
|
|
{
|
|
var (host, cm) = Setup();
|
|
host.SetOrigin(Vector3.Zero);
|
|
|
|
cm.ConstrainTo(Anchor(8f), 2f, 10f); // offset 8, 90% of 10 = 9 → 8 < 9
|
|
Assert.False(cm.IsFullyConstrained());
|
|
|
|
cm.ConstrainTo(Anchor(9.5f), 2f, 10f); // offset 9.5 > 9
|
|
Assert.True(cm.IsFullyConstrained());
|
|
}
|
|
|
|
[Fact]
|
|
public void IsFullyConstrained_FalseWhenNotConstrainedYet()
|
|
{
|
|
var (_, cm) = Setup();
|
|
Assert.False(cm.IsFullyConstrained()); // max 0 → 0 < 0 is false
|
|
}
|
|
|
|
[Fact]
|
|
public void AdjustOffset_InBand_AppliesLinearTaper()
|
|
{
|
|
var (host, cm) = Setup();
|
|
host.SetOrigin(Vector3.Zero);
|
|
host.InContact = true;
|
|
cm.ConstrainTo(Anchor(5f), startDistance: 2f, maxDistance: 10f); // offset 5 in (2,10)
|
|
|
|
var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) };
|
|
cm.AdjustOffset(frame, 0.1);
|
|
|
|
// taper = (10-5)/(10-2) = 5/8 = 0.625.
|
|
Assert.Equal(0.625f, frame.Origin.X, 3);
|
|
Assert.Equal(0.625f, cm.ConstraintPosOffset, 3); // recomputed = |offset|
|
|
}
|
|
|
|
[Fact]
|
|
public void AdjustOffset_PastMax_HardClampsToZero()
|
|
{
|
|
var (host, cm) = Setup();
|
|
host.SetOrigin(Vector3.Zero);
|
|
host.InContact = true;
|
|
cm.ConstrainTo(Anchor(20f), 2f, 10f); // offset 20 >= max 10
|
|
|
|
var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) };
|
|
cm.AdjustOffset(frame, 0.1);
|
|
|
|
Assert.Equal(Vector3.Zero, frame.Origin);
|
|
}
|
|
|
|
[Fact]
|
|
public void AdjustOffset_BelowStart_PassesThrough()
|
|
{
|
|
var (host, cm) = Setup();
|
|
host.SetOrigin(Vector3.Zero);
|
|
host.InContact = true;
|
|
cm.ConstrainTo(Anchor(1f), startDistance: 2f, maxDistance: 10f); // offset 1 < start 2
|
|
|
|
var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) };
|
|
cm.AdjustOffset(frame, 0.1);
|
|
|
|
Assert.Equal(1f, frame.Origin.X, 3); // unscaled
|
|
Assert.Equal(1f, cm.ConstraintPosOffset, 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void AdjustOffset_Airborne_SkipsClampButStillTracksLength()
|
|
{
|
|
var (host, cm) = Setup();
|
|
host.SetOrigin(Vector3.Zero);
|
|
host.InContact = false; // airborne
|
|
cm.ConstrainTo(Anchor(20f), 2f, 10f); // would clamp if grounded
|
|
|
|
var frame = new MotionDeltaFrame { Origin = new Vector3(3f, 4f, 0f) };
|
|
cm.AdjustOffset(frame, 0.1);
|
|
|
|
Assert.Equal(new Vector3(3f, 4f, 0f), frame.Origin); // untouched while airborne
|
|
Assert.Equal(5f, cm.ConstraintPosOffset, 3); // |(3,4,0)| = 5, still updated
|
|
}
|
|
|
|
[Fact]
|
|
public void AdjustOffset_NotConstrained_IsNoOp()
|
|
{
|
|
var (host, cm) = Setup();
|
|
var frame = new MotionDeltaFrame { Origin = new Vector3(7f, 0f, 0f) };
|
|
cm.AdjustOffset(frame, 0.1);
|
|
Assert.Equal(new Vector3(7f, 0f, 0f), frame.Origin);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnConstrain_ClearsFlag()
|
|
{
|
|
var (host, cm) = Setup();
|
|
cm.ConstrainTo(Anchor(5f), 2f, 10f);
|
|
cm.UnConstrain();
|
|
Assert.False(cm.IsConstrained);
|
|
}
|
|
}
|