feat(physics): R5-V1 — port PositionManager/Sticky/Constraint + TargetManager (Core, unwired)

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>
This commit is contained in:
Erik 2026-07-03 19:34:49 +02:00
parent 517bbfdae4
commit 3d89446d98
25 changed files with 7279 additions and 12 deletions

View file

@ -0,0 +1,133 @@
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);
}
}

View file

@ -0,0 +1,112 @@
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="PositionManager"/> facade (retail
/// 0x00555160-0x005553d0). Lazy sub-manager creation + fan-out. (Test class is
/// suffixed "Facade" to read distinctly from the renamed
/// <c>RemoteMotionCombiner</c> combiner tests.)
/// </summary>
public sealed class PositionManagerFacadeTests
{
private static (R5Host self, R5Host target, PositionManager pm) Setup()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world);
var target = new R5Host(20u, world);
return (self, target, new PositionManager(self));
}
[Fact]
public void UnStick_BeforeAnyStick_IsNoOp()
{
var (_, _, pm) = Setup();
pm.UnStick(); // no sticky manager yet — must not throw
Assert.Null(pm.Sticky);
Assert.Equal(0u, pm.GetStickyObjectId());
}
[Fact]
public void StickTo_LazilyCreatesSticky_AndForwards()
{
var (self, target, pm) = Setup();
pm.StickTo(target.Id, radius: 0.5f, height: 1.0f);
Assert.NotNull(pm.Sticky);
Assert.Equal(target.Id, pm.GetStickyObjectId());
}
[Fact]
public void IsFullyConstrained_FalseWhenNoConstraintManager()
{
var (_, _, pm) = Setup();
Assert.False(pm.IsFullyConstrained());
Assert.Null(pm.Constraint);
}
[Fact]
public void ConstrainTo_LazilyCreatesConstraint()
{
var (self, _, pm) = Setup();
self.SetOrigin(Vector3.Zero);
pm.ConstrainTo(new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity), 2f, 10f);
Assert.NotNull(pm.Constraint);
Assert.True(pm.Constraint!.IsConstrained);
}
[Fact]
public void HandleUpdateTarget_ForwardsToSticky()
{
var (self, target, pm) = Setup();
pm.StickTo(target.Id, 0.5f, 1.0f);
var tp = new Position(1u, new Vector3(2f, 0f, 0f), Quaternion.Identity);
pm.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
Assert.True(pm.Sticky!.Initialized);
}
[Fact]
public void AdjustOffset_ChainsStickyThenConstraint()
{
var (self, target, pm) = Setup();
self.Radius = 0.5f;
self.MinterpMaxSpeed = 1.0f;
self.InContact = true;
// Sticky toward +X: produces an offset the constraint then tapers.
pm.StickTo(target.Id, 0.5f, 1.0f);
target.SetOrigin(new Vector3(5f, 0f, 0f));
var tp = new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity);
pm.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
// Arm a constraint whose band tapers by 0.5.
self.SetOrigin(Vector3.Zero);
pm.ConstrainTo(new Position(1u, new Vector3(6f, 0f, 0f), Quaternion.Identity),
startDistance: 2f, maxDistance: 10f); // offset 6 → taper (10-6)/(10-2)=0.5
var frame = new MotionDeltaFrame();
pm.AdjustOffset(frame, quantum: 0.1);
// Sticky writes +0.5 X; constraint tapers by 0.5 → 0.25.
Assert.Equal(0.25f, frame.Origin.X, 3);
}
[Fact]
public void UseTime_DrivesStickyTimeout()
{
var (self, target, pm) = Setup();
self.CurTime = 100.0;
pm.StickTo(target.Id, 0.5f, 1.0f); // deadline 101
self.CurTime = 101.0;
pm.UseTime();
Assert.Equal(0u, pm.GetStickyObjectId()); // unstuck
}
}

View file

@ -0,0 +1,98 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R5 conformance harness — a scriptable <see cref="IPhysicsObjHost"/> fake
/// backed by a shared <see cref="World"/> so <see cref="GetObjectA"/> resolves
/// OTHER hosts (the cross-entity seam the voyeur round-trip needs). Each host
/// lazily owns a <see cref="TargetManager"/> and a <see cref="PositionManager"/>
/// (the two R5 managers under test), and records every
/// <see cref="HandleUpdateTarget"/> the managers fan out so tests can assert on
/// delivery.
///
/// <para>Position/velocity/radius/contact/max-speed and both clocks are mutable
/// fields tests drive directly (retail's <c>CPhysicsObj</c> accessors). The
/// <c>set_target</c>/<c>clear_target</c>/<c>add_voyeur</c>/<c>remove_voyeur</c>/
/// <c>receive_target_update</c> seams forward to the owned
/// <see cref="TargetManager"/> exactly as retail's <c>CPhysicsObj</c> does.</para>
/// </summary>
internal sealed class R5Host : IPhysicsObjHost
{
public readonly Dictionary<uint, R5Host> World;
public R5Host(uint id, Dictionary<uint, R5Host> world)
{
Id = id;
World = world;
World[id] = this;
}
// ── scriptable CPhysicsObj state ───────────────────────────────────────
public uint Id { get; }
public Position Position { get; set; } = new(1u, Vector3.Zero, Quaternion.Identity);
public Vector3 Velocity { get; set; } = Vector3.Zero;
public float Radius { get; set; } = 0.5f;
public bool InContact { get; set; } = true;
public float? MinterpMaxSpeed { get; set; } = 1.0f;
public double CurTime { get; set; }
public double PhysicsTimerTime { get; set; }
/// <summary>Set to false to simulate a target that isn't currently
/// resolvable (out of streaming view) — <see cref="GetObjectA"/> returns
/// null for it even while it's in <see cref="World"/>.</summary>
public bool Resolvable { get; set; } = true;
// ── owned R5 managers ──────────────────────────────────────────────────
private TargetManager? _targetManager;
public TargetManager TargetManager => _targetManager ??= new TargetManager(this);
public TargetManager? TargetManagerOrNull => _targetManager;
private PositionManager? _positionManager;
public PositionManager PositionManager => _positionManager ??= new PositionManager(this);
// ── recorded fan-outs ──────────────────────────────────────────────────
public readonly List<TargetInfo> HandleUpdateTargetCalls = new();
public int InterruptCurrentMovementCalls;
// ── IPhysicsObjHost ────────────────────────────────────────────────────
public IPhysicsObjHost? GetObjectA(uint id)
=> World.TryGetValue(id, out var h) && h.Resolvable ? h : null;
public void HandleUpdateTarget(TargetInfo info)
{
HandleUpdateTargetCalls.Add(info);
// Retail CPhysicsObj::HandleUpdateTarget also fans to the position
// manager's sticky sub-manager (context 0). Mirror that so sticky tests
// that rely on the full CPhysicsObj fan-out see the callback.
if (info.ContextId == 0)
_positionManager?.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement() => InterruptCurrentMovementCalls++;
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
=> TargetManager.SetTarget(contextId, objectId, radius, quantum);
public void ClearTarget() => _targetManager?.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info) => _targetManager?.ReceiveUpdate(info);
public void AddVoyeur(uint watcherId, float radius, double quantum)
=> TargetManager.AddVoyeur(watcherId, radius, quantum);
public void RemoveVoyeur(uint watcherId) => _targetManager?.RemoveVoyeur(watcherId);
// ── test helpers ───────────────────────────────────────────────────────
public void SetOrigin(Vector3 origin)
=> Position = new Position(Position.ObjCellId, origin, Position.Frame.Orientation);
public void AdvanceClocks(double seconds)
{
CurTime += seconds;
PhysicsTimerTime += seconds;
}
}

View file

@ -0,0 +1,220 @@
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="StickyManager"/> (retail 0x00555400-0x00555866).
/// Lifecycle (StickTo/UnStick/timeout/HandleUpdateTarget) + the decoded
/// <c>adjust_offset</c> steering math (port-plan §2a).
/// </summary>
public sealed class StickyManagerTests
{
private static (R5Host self, R5Host target, StickyManager sticky) Setup(
uint targetId = 20u, float targetRadius = 0.5f)
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world);
var target = new R5Host(targetId, world);
var sticky = new StickyManager(self);
return (self, target, sticky);
}
private static StickyManager StuckAndInitialized(
R5Host self, R5Host target, Vector3 targetOrigin, float targetRadius = 0.5f)
{
var sticky = new StickyManager(self);
sticky.StickTo(target.Id, targetRadius, targetHeight: 1.0f);
target.SetOrigin(targetOrigin);
var tp = new Position(1u, targetOrigin, Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
return sticky;
}
[Fact]
public void StickTo_SetsTargetAndTimeout_AndSubscribesVoyeurOnTarget()
{
var (self, target, sticky) = Setup();
self.CurTime = 100.0;
sticky.StickTo(target.Id, targetRadius: 0.5f, targetHeight: 2.0f);
Assert.Equal(target.Id, sticky.TargetId);
Assert.Equal(0.5f, sticky.TargetRadius);
Assert.False(sticky.Initialized);
Assert.Equal(101.0, sticky.StickyTimeoutTime); // now + StickyTime(1.0)
// set_target(0, target, 0.5, 0.5) → watcher subscribes ON the target.
Assert.NotNull(target.TargetManagerOrNull);
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
}
[Fact]
public void HandleUpdateTarget_Ok_MarksInitializedAndCachesPosition()
{
var (self, target, sticky) = Setup();
sticky.StickTo(target.Id, 0.5f, 1.0f);
var tp = new Position(1u, new Vector3(3f, 0f, 0f), Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
Assert.True(sticky.Initialized);
Assert.Equal(new Vector3(3f, 0f, 0f), sticky.TargetPosition.Frame.Origin);
}
[Fact]
public void HandleUpdateTarget_ForeignObject_Ignored()
{
var (self, target, sticky) = Setup();
sticky.StickTo(target.Id, 0.5f, 1.0f);
var tp = new Position(1u, Vector3.Zero, Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(999u, TargetStatus.Ok, tp, tp));
Assert.False(sticky.Initialized);
Assert.Equal(target.Id, sticky.TargetId); // still stuck to the real target
}
[Fact]
public void HandleUpdateTarget_NonOkStatus_TearsDown()
{
var (self, target, sticky) = Setup();
sticky.StickTo(target.Id, 0.5f, 1.0f);
int interruptsBefore = self.InterruptCurrentMovementCalls;
var tp = new Position(1u, Vector3.Zero, Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.ExitWorld, tp, tp));
Assert.Equal(0u, sticky.TargetId);
Assert.False(sticky.Initialized);
Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls);
}
[Fact]
public void UseTime_BeforeDeadline_KeepsStick()
{
var (self, target, sticky) = Setup();
self.CurTime = 100.0;
sticky.StickTo(target.Id, 0.5f, 1.0f); // deadline 101.0
self.CurTime = 100.9;
sticky.UseTime();
Assert.Equal(target.Id, sticky.TargetId);
}
[Fact]
public void UseTime_AtDeadline_UnsticksAndInterrupts()
{
var (self, target, sticky) = Setup();
self.CurTime = 100.0;
sticky.StickTo(target.Id, 0.5f, 1.0f); // deadline 101.0
int interruptsBefore = self.InterruptCurrentMovementCalls;
self.CurTime = 101.0;
sticky.UseTime();
Assert.Equal(0u, sticky.TargetId);
Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls);
}
[Fact]
public void ReStick_TearsDownPreviousBeforeSettingNew()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world);
var a = new R5Host(20u, world);
var b = new R5Host(21u, world);
var sticky = new StickyManager(self);
sticky.StickTo(a.Id, 0.5f, 1.0f);
int interruptsBefore = self.InterruptCurrentMovementCalls;
sticky.StickTo(b.Id, 0.5f, 1.0f);
Assert.Equal(b.Id, sticky.TargetId);
Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls); // old stick torn down
}
[Fact]
public void AdjustOffset_NotInitialized_IsNoOp()
{
var (self, target, sticky) = Setup();
sticky.StickTo(target.Id, 0.5f, 1.0f); // no HandleUpdateTarget → not initialized
var frame = new MotionDeltaFrame { Origin = new Vector3(9f, 9f, 9f) };
sticky.AdjustOffset(frame, 0.1);
Assert.Equal(new Vector3(9f, 9f, 9f), frame.Origin); // untouched
}
[Fact]
public void AdjustOffset_SteersTowardTarget_ClampedToStep()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
var target = new R5Host(20u, world);
var sticky = StuckAndInitialized(self, target, new Vector3(5f, 0f, 0f));
var frame = new MotionDeltaFrame();
sticky.AdjustOffset(frame, quantum: 0.1);
// dir = +X (east); speed = 1.0 * 5 = 5; delta = 5 * 0.1 = 0.5 (< dist 3.7).
Assert.Equal(0.5f, frame.Origin.X, 3);
Assert.Equal(0f, frame.Origin.Y, 3);
Assert.Equal(0f, frame.Origin.Z, 3); // horizontal-only
// heading toward +X (east) = 90° compass.
Assert.Equal(90f, frame.GetHeading(), 1);
}
[Fact]
public void AdjustOffset_TooClose_BacksOff_SignedDistance()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
var target = new R5Host(20u, world);
// centerDist 0.9, cyl = 0.9-0.5-0.5 = -0.1, minus 0.3 → dist = -0.4.
var sticky = StuckAndInitialized(self, target, new Vector3(0.9f, 0f, 0f));
var frame = new MotionDeltaFrame();
sticky.AdjustOffset(frame, quantum: 0.1);
// delta = 5*0.1 = 0.5 >= |dist|=0.4 → delta = dist = -0.4; dir +X * -0.4 → back off (-X).
Assert.Equal(-0.4f, frame.Origin.X, 3);
}
[Fact]
public void AdjustOffset_UsesCachedPosition_WhenTargetUnresolvable()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
var target = new R5Host(20u, world);
var sticky = new StickyManager(self);
sticky.StickTo(target.Id, 0.5f, 1.0f);
// Cache a position via HandleUpdateTarget, then make the target vanish.
var tp = new Position(1u, new Vector3(4f, 0f, 0f), Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
target.Resolvable = false; // GetObjectA(target) → null → fall back to cached
var frame = new MotionDeltaFrame();
sticky.AdjustOffset(frame, quantum: 0.1);
Assert.Equal(0.5f, frame.Origin.X, 3); // still steers toward cached (4,0,0)
}
[Fact]
public void AdjustOffset_NoMinterp_UsesFallbackSpeed()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = null };
var target = new R5Host(20u, world);
var sticky = StuckAndInitialized(self, target, new Vector3(50f, 0f, 0f));
var frame = new MotionDeltaFrame();
sticky.AdjustOffset(frame, quantum: 0.1);
// fallback speed 15 → delta = 15 * 0.1 = 1.5 (dist ≈ 49 → not clamped).
Assert.Equal(1.5f, frame.Origin.X, 3);
}
}

View file

@ -0,0 +1,255 @@
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="TargetManager"/> voyeur system (retail
/// 0x0051a370-0x0051ad90). Watcher role (SetTarget/ReceiveUpdate/timeout),
/// watched role (AddVoyeur/HandleTargetting/CheckAndUpdateVoyeur), and the full
/// cross-entity round-trip (port-plan §2d/§2e). Replaces the AP-79 adapter.
/// </summary>
public sealed class TargetManagerTests
{
private static (R5Host self, R5Host target, Dictionary<uint, R5Host> world) TwoHosts()
{
var world = new Dictionary<uint, R5Host>();
var self = new R5Host(10u, world);
var target = new R5Host(20u, world);
return (self, target, world);
}
// ── watcher role ───────────────────────────────────────────────────────
[Fact]
public void SetTarget_SubscribesOnTarget_AndReceivesImmediateSnapshot()
{
var (self, target, _) = TwoHosts();
target.SetOrigin(new Vector3(3f, 0f, 0f));
self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0);
// Watcher subscribed on the target.
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
// Immediate Ok snapshot fanned to the watcher's host.
Assert.Single(self.HandleUpdateTargetCalls);
Assert.Equal(TargetStatus.Ok, self.HandleUpdateTargetCalls[0].Status);
Assert.Equal(target.Id, self.HandleUpdateTargetCalls[0].ObjectId);
Assert.Equal(new Vector3(3f, 0f, 0f),
self.HandleUpdateTargetCalls[0].InterpolatedPosition.Frame.Origin);
Assert.Equal(TargetStatus.Ok, self.TargetManager.TargetInfo!.Value.Status);
}
[Fact]
public void SetTarget_Zero_SynthesizesTimedOut_LeavesTargetInfoNull()
{
var (self, _, _) = TwoHosts();
self.TargetManager.SetTarget(contextId: 7, objectId: 0, radius: 1.0f, quantum: 0.0);
Assert.Null(self.TargetManager.TargetInfo);
Assert.Single(self.HandleUpdateTargetCalls);
Assert.Equal(TargetStatus.TimedOut, self.HandleUpdateTargetCalls[0].Status);
Assert.Equal(7u, self.HandleUpdateTargetCalls[0].ContextId);
}
[Fact]
public void ClearTarget_UnsubscribesFromTarget()
{
var (self, target, _) = TwoHosts();
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
self.TargetManager.ClearTarget();
Assert.Null(self.TargetManager.TargetInfo);
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
}
[Fact]
public void ReceiveUpdate_ForWrongObject_Ignored()
{
var (self, target, _) = TwoHosts();
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
int before = self.HandleUpdateTargetCalls.Count;
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(999u, TargetStatus.Ok, p, p));
Assert.Equal(before, self.HandleUpdateTargetCalls.Count);
}
[Fact]
public void ReceiveUpdate_ExitWorld_ClearsTarget()
{
var (self, target, _) = TwoHosts();
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p));
Assert.Null(self.TargetManager.TargetInfo); // cleared
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed
}
[Fact]
public void ReceiveUpdate_ComputesInterpolatedHeadingTowardTarget()
{
var (self, target, _) = TwoHosts();
self.SetOrigin(Vector3.Zero);
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
var tp = new Position(1u, new Vector3(0f, 5f, 0f), Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
// self→target interp position (0,5,0) normalized = +Y.
var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading;
Assert.Equal(0f, heading.X, 3);
Assert.Equal(1f, heading.Y, 3);
}
[Fact]
public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds()
{
var (self, target, _) = TwoHosts();
target.Resolvable = false; // no immediate snapshot → status stays Undefined
self.CurTime = 0;
self.PhysicsTimerTime = 0;
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
Assert.Equal(TargetStatus.Undefined, self.TargetManager.TargetInfo!.Value.Status);
self.AdvanceClocks(11.0);
self.TargetManager.HandleTargetting();
Assert.Equal(TargetStatus.TimedOut, self.TargetManager.TargetInfo!.Value.Status);
Assert.Contains(self.HandleUpdateTargetCalls, c => c.Status == TargetStatus.TimedOut);
}
// ── watched role + gates ────────────────────────────────────────────────
[Fact]
public void HandleTargetting_Throttles_Within500ms()
{
var (self, target, _) = TwoHosts();
var w = new TargettedVoyeurInfo(self.Id, radius: 0.1f, quantum: 0.0);
// seed a voyeur directly + advance so a sweep WOULD send if not throttled.
target.TargetManager.AddVoyeur(self.Id, 0.1f, 0.0);
target.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius
target.PhysicsTimerTime = 0.3; // < 0.5 throttle
int before = self.HandleUpdateTargetCalls.Count;
target.TargetManager.HandleTargetting();
Assert.Equal(before, self.HandleUpdateTargetCalls.Count); // throttled, no sweep
}
[Fact]
public void CheckAndUpdateVoyeur_SendsOnlyPastRadius()
{
var (self, target, _) = TwoHosts();
// self must have a matching target_info for ReceiveUpdate to record.
self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0);
int afterSubscribe = self.HandleUpdateTargetCalls.Count; // includes immediate snapshot
// small move → within radius → no send
target.SetOrigin(new Vector3(0.5f, 0f, 0f));
target.AdvanceClocks(1.0);
target.TargetManager.HandleTargetting();
Assert.Equal(afterSubscribe, self.HandleUpdateTargetCalls.Count);
// large move → past radius → send
target.SetOrigin(new Vector3(2f, 0f, 0f));
target.AdvanceClocks(1.0);
target.TargetManager.HandleTargetting();
Assert.Equal(afterSubscribe + 1, self.HandleUpdateTargetCalls.Count);
var last = self.HandleUpdateTargetCalls[^1];
Assert.Equal(TargetStatus.Ok, last.Status);
Assert.Equal(2f, last.InterpolatedPosition.Frame.Origin.X, 3);
}
[Fact]
public void GetInterpolatedPosition_DeadReckonsWithVelocity()
{
var (_, target, _) = TwoHosts();
target.SetOrigin(new Vector3(1f, 2f, 0f));
target.Velocity = new Vector3(10f, 0f, 0f);
var p = target.TargetManager.GetInterpolatedPosition(quantum: 0.5);
Assert.Equal(new Vector3(6f, 2f, 0f), p.Frame.Origin); // 1 + 10*0.5 = 6
}
[Fact]
public void AddVoyeur_Existing_UpdatesInPlace_NoResend()
{
var (self, target, _) = TwoHosts();
target.SetOrigin(Vector3.Zero);
target.TargetManager.AddVoyeur(self.Id, radius: 1.0f, quantum: 0.0);
var voyeur = target.TargetManager.VoyeurTable![self.Id];
Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin);
target.SetOrigin(new Vector3(5f, 0f, 0f));
target.TargetManager.AddVoyeur(self.Id, radius: 2.5f, quantum: 0.3); // existing → update only
Assert.Equal(2.5f, voyeur.Radius);
Assert.Equal(0.3, voyeur.Quantum);
Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin); // NOT re-sent
}
[Fact]
public void RemoveVoyeur_RemovesEntry()
{
var (self, target, _) = TwoHosts();
target.TargetManager.AddVoyeur(self.Id, 1.0f, 0.0);
Assert.True(target.TargetManager.RemoveVoyeur(self.Id));
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
Assert.False(target.TargetManager.RemoveVoyeur(self.Id)); // already gone
}
[Fact]
public void NotifyVoyeurOfEvent_BroadcastsToAll_NoDistanceGate()
{
var world = new Dictionary<uint, R5Host>();
var target = new R5Host(20u, world);
var w1 = new R5Host(10u, world);
var w2 = new R5Host(11u, world);
// both watch the target (each gets a target_info via SetTarget)
w1.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
w2.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
int w1Before = w1.HandleUpdateTargetCalls.Count;
int w2Before = w2.HandleUpdateTargetCalls.Count;
target.TargetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
Assert.Equal(w1Before + 1, w1.HandleUpdateTargetCalls.Count);
Assert.Equal(w2Before + 1, w2.HandleUpdateTargetCalls.Count);
Assert.Equal(TargetStatus.Teleported, w1.HandleUpdateTargetCalls[^1].Status);
}
// ── full round-trip ─────────────────────────────────────────────────────
[Fact]
public void RoundTrip_WatcherTracksMovingTarget()
{
var (self, target, _) = TwoHosts();
target.SetOrigin(Vector3.Zero);
// Watcher sets target with a 1.0 radius (moveto-style, quantum 0).
self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0);
Assert.Single(self.HandleUpdateTargetCalls); // immediate snapshot at (0,0,0)
// Target walks to (3,0,0); its per-tick HandleTargetting notices the
// drift and pushes an update the watcher receives.
target.SetOrigin(new Vector3(3f, 0f, 0f));
target.AdvanceClocks(1.0);
target.TargetManager.HandleTargetting();
Assert.Equal(2, self.HandleUpdateTargetCalls.Count);
Assert.Equal(new Vector3(3f, 0f, 0f),
self.HandleUpdateTargetCalls[^1].InterpolatedPosition.Frame.Origin);
// The watcher's cached target position tracks the target.
Assert.Equal(3f, self.TargetManager.TargetInfo!.Value.InterpolatedPosition.Frame.Origin.X, 3);
}
}