Gate 2: pack behavior good except "sometimes the monster is in the character / too close vs retail". The ACDREAM_PROBE_STICKY capture nailed it frame-by-frame: 1661 deep-overlap AdjustOffset ticks, ALL steering inward (dist -0.65 -> -0.78 -> ... -> -1.9 at +0.13/tick), monsters converging to centerDist~0 — while the suppressed-snap lines show ACE's authoritative positions stayed properly OUTSIDE (drift up to 7.7 m). The radii were correct (tgtR=0.68, ownR=0.59-0.98). Root cause: ACE's literal decode of StickyManager::adjust_offset (`if (delta >= |dist|) delta = dist;`) leaves delta POSITIVE when the overlap exceeds one tick's step — steering TOWARD the target center, a runaway whose equilibrium is centers-coincident. ACE servers virtually never reach that branch (quantum >=1/30 -> threshold ~1 m); at render-rate quanta the threshold is ~0.13 m and pack jostle trips it constantly. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the retail oracle (side-by-side on the same ACE: monsters separate) refutes the ACE-literal reading. Pin: sign-correct clamp — `else if (dist < 0) delta = -delta` (back off rate-limited). Identical to ACE-literal in every shallow/outside case. Register row AP-82 (same commit) with the cdb verification note. Conformance: StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_ RateLimited. Full suite 4039 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
247 lines
9.3 KiB
C#
247 lines
9.3 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="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_PastDeadline_UnsticksAndInterrupts()
|
||
{
|
||
var (self, target, sticky) = Setup();
|
||
self.CurTime = 100.0;
|
||
sticky.StickTo(target.Id, 0.5f, 1.0f); // deadline 101.0
|
||
|
||
// AT the deadline the stick survives — retail 0x00555626 tears down
|
||
// strictly AFTER it (`test ah,0x41`: C0|C3 = less-or-equal keeps;
|
||
// ACE `>` too). The R5-V1 pin of `>=` was the wrong value.
|
||
self.CurTime = 101.0;
|
||
sticky.UseTime();
|
||
Assert.Equal(target.Id, sticky.TargetId);
|
||
|
||
int interruptsBefore = self.InterruptCurrentMovementCalls;
|
||
self.CurTime = 101.001;
|
||
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_DeepOverlap_BacksOff_RateLimited()
|
||
{
|
||
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.1 → cyl = 0.1-0.5-0.5 = -0.9, minus 0.3 → dist = -1.2
|
||
// (overlap DEEPER than one tick's step).
|
||
var sticky = StuckAndInitialized(self, target, new Vector3(0.1f, 0f, 0f));
|
||
|
||
var frame = new MotionDeltaFrame();
|
||
sticky.AdjustOffset(frame, quantum: 0.1);
|
||
|
||
// delta = 5*0.1 = 0.5 < |dist|=1.2 — ACE's literal branch kept +0.5
|
||
// here (INWARD: the #171 gate-3 runaway-to-center; 1661 probe ticks,
|
||
// all inward, equilibrium at centers-coincident). The sign pin backs
|
||
// off rate-limited: dir +X × −0.5.
|
||
Assert.Equal(-0.5f, 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);
|
||
}
|
||
}
|