acdream/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs
Erik 699669502c fix(#171): sticky deep-overlap back-off sign pin — the runaway-to-center (gate-2 residual)
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>
2026-07-05 09:51:24 +02:00

247 lines
9.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}