fix(#171): R5-V3 — bind sticky melee (StickyManager live) + real arrival radii
Group-melee interpenetration + facing drift: the R5-V1-ported StickyManager/PositionManager were Core-only — the StickTo/Unstick seams were unbound and every arrival radius was 0, so ACE's Sticky|UseSpheres melee chases closed ~one body-radius too deep and froze at stale arrival poses until the next wire re-arm. Retires TS-39. Wiring (anchors re-verified against the named decomp this session): - EntityPhysicsHost owns a PositionManager; HandleUpdateTarget fans MoveToManager then PositionManager (CPhysicsObj::HandleUpdateTarget 0x00512bc0 order). - Seams bound remote + player: MoveToManager.StickTo (BeginNextNode sticky arrival @0x00529d3a), Unstick (PerformMovement head), and MotionInterpreter.UnstickFromObject (UM funnel head, 0x0050eaea). - AdjustOffset at the retail UpdatePositionInternal slot (@0x00512d0e): NPC branch composes pre-sweep (steer is swept by ResolveWithTransition); remote-player branch chains the combiner offset through the shared delta frame (the interp stage) so sticky OVERWRITES when armed (0x00555430 assigns m_fOrigin, not accumulates); player inside the 30 Hz physics quantum before UpdatePhysicsInternal. - UseTime (the 1 s lease watchdog) at the UpdateObjectInternal tail (@0x005159b3): unconditional per remote; player gated on the physics tick (retail's MinQuantum gate skips UseTime too). - Real setup cylsphere radii (CPartArray::GetRadius/GetHeight 0x005180a0/0x005180b0 = setup radius/height x ObjScale from the spawn record): own via EnsureRemoteMotionBindings + player wiring; target via RouteServerMoveTo AND the speculative use-walk install (retail resolves the target PartArray at EVERY MoveToObject site — ACE PhysicsObj.cs:951). - Teardown parity: exit_world (0x00514e60) UnStick + ClearTarget before the ExitWorld notify; player teleport fires teleport_hook's tail (UnStick in SetPosition + EntityPhysicsHost.NotifyTeleported = ClearTarget + NotifyVoyeurOfEvent(Teleported) @0x00514f1b) so mobs stuck to the player drop their sticks on a recall. - SERVERVEL arbitration also yields to a stuck entity (same starvation class as the #170 fix — sticky owns the between-snap translation). - StickyManager.UseTime aligned to retail's strict > deadline (0x00555626; ACE >): two V1 tests had pinned the >= edge — corrected. Register: TS-39 deleted; TS-41 narrowed (stickyArmed gate); TS-43 added (remote teleport_hook gap — self-corrects within the 1 s lease); AP-23 narrowed (real radii at the speculative site; only the use-radius buckets remain invented). Conformance: 2 new full-stack sticky scenarios in RemoteChaseEndToEndHarnessTests (arrive -> stick -> strafing-target gap+facing track -> lease expiry; unstick-on-rearm -> re-stick). Full suite 4038 green. Pre-commit adversarial diff review (3 lenses + per-finding refuters) confirmed and fixed 4 findings: ObjScale-dead radius read, player UseTime order inversion, missing teleport voyeur notify, speculative- site radius asymmetry. Awaiting the user visual gate: pack melee side-by-side vs retail (attackers reshuffle + keep facing; some overlap is ACE-server-side). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d413ac2a29
commit
5bd2b8bc8b
10 changed files with 586 additions and 32 deletions
|
|
@ -77,6 +77,12 @@ internal sealed class RemoteChaseHarness
|
|||
|
||||
public const float Dt = 1f / 60f;
|
||||
|
||||
// R5-V3 (#171): setup cylsphere radii for the sticky/UseSpheres scenarios
|
||||
// (GameWindow threads the real values via GetSetupCylinder; these are
|
||||
// creature-typical stand-ins).
|
||||
public const float OwnRadius = 0.3f;
|
||||
public const float StickyTargetRadius = 0.5f;
|
||||
|
||||
// Field-for-field mirror of GameWindow's RemoteMotion construction
|
||||
// (GameWindow.cs:592-618): Contact+OnWalkable+Active, InWorld=true (the
|
||||
// R4-V5 door fix — without it the interp's detached-object guard strips
|
||||
|
|
@ -95,6 +101,14 @@ internal sealed class RemoteChaseHarness
|
|||
public readonly MotionTableDispatchSink Sink;
|
||||
public readonly MoveToManager Mgr;
|
||||
|
||||
/// <summary>R5-V3 (#171): the creature's PositionManager facade — the
|
||||
/// EntityPhysicsHost-owned twin (GameWindow binds MoveToManager.StickTo/
|
||||
/// Unstick + MotionInterpreter.UnstickFromObject to it and drives
|
||||
/// AdjustOffset/UseTime per tick).</summary>
|
||||
public readonly PositionManager Pm;
|
||||
private readonly CreatureHost _creatureHost;
|
||||
private readonly TargetHost _playerHost;
|
||||
|
||||
/// <summary>GameWindow's RemoteMotion.ObservedOmega twin.</summary>
|
||||
public Vector3 ObservedOmega;
|
||||
|
||||
|
|
@ -162,8 +176,8 @@ internal sealed class RemoteChaseHarness
|
|||
getHeading: () => MoveToMath.GetHeading(Body.Orientation),
|
||||
setHeading: (h, _) => Body.Orientation =
|
||||
MoveToMath.SetHeading(Body.Orientation, h),
|
||||
getOwnRadius: () => 0f,
|
||||
getOwnHeight: () => 0f,
|
||||
getOwnRadius: () => OwnRadius,
|
||||
getOwnHeight: () => 1f,
|
||||
contact: () => Body.OnWalkable,
|
||||
isInterpolating: () => false,
|
||||
getVelocity: () => Body.Velocity,
|
||||
|
|
@ -184,6 +198,17 @@ internal sealed class RemoteChaseHarness
|
|||
Interp.InterruptCurrentMovement =
|
||||
() => Mgr.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
|
||||
// ── R5-V3 (#171): the PositionManager/sticky wiring — GameWindow's
|
||||
// V3 additions verbatim: host-owned facade + the three seam binds
|
||||
// (BeginNextNode arrival StickTo, PerformMovement-head Unstick,
|
||||
// UM-funnel-head unstick_from_object). ──
|
||||
_playerHost = new TargetHost(this);
|
||||
_creatureHost = new CreatureHost(this);
|
||||
Pm = new PositionManager(_creatureHost);
|
||||
Mgr.StickTo = (tlid, radius, height) => Pm.StickTo(tlid, radius, height);
|
||||
Mgr.Unstick = Pm.UnStick;
|
||||
Interp.UnstickFromObject = Pm.UnStick;
|
||||
|
||||
// ── The anim-loop MotionDone binding (GameWindow.cs:10266) ──
|
||||
Seq.MotionDoneTarget = (m, ok) => Interp.MotionDone(m, ok);
|
||||
|
||||
|
|
@ -222,7 +247,11 @@ internal sealed class RemoteChaseHarness
|
|||
public void UmMoveToObject(
|
||||
float speed = 2.08f,
|
||||
float distanceToObject = 0.6f,
|
||||
float walkRunThreshold = 15f)
|
||||
float walkRunThreshold = 15f,
|
||||
bool sticky = false,
|
||||
bool useSpheres = false,
|
||||
float targetRadius = 0f,
|
||||
float targetHeight = 0f)
|
||||
{
|
||||
Interp.InterruptCurrentMovement?.Invoke();
|
||||
Interp.UnstickFromObject?.Invoke();
|
||||
|
|
@ -238,7 +267,10 @@ internal sealed class RemoteChaseHarness
|
|||
DistanceToObject = distanceToObject,
|
||||
WalkRunThreshhold = walkRunThreshold,
|
||||
FailDistance = float.MaxValue,
|
||||
UseSpheres = false,
|
||||
// R5-V3 (#171): ACE arms every melee chase Sticky + UseSpheres
|
||||
// (Monster_Navigation.cs:406-419; UseSpheres is the ACE default).
|
||||
Sticky = sticky,
|
||||
UseSpheres = useSpheres,
|
||||
};
|
||||
var ms = new MovementStruct
|
||||
{
|
||||
|
|
@ -247,6 +279,10 @@ internal sealed class RemoteChaseHarness
|
|||
TopLevelId = PlayerGuid,
|
||||
Pos = new CorePosition(1u, PlayerPos, Quaternion.Identity),
|
||||
Params = mp,
|
||||
// R5-V3: the RouteServerMoveTo radius threading twin — retail
|
||||
// reads the TARGET's PartArray radius/height at the call site.
|
||||
Radius = targetRadius,
|
||||
Height = targetHeight,
|
||||
};
|
||||
Mgr.PerformMovement(ms);
|
||||
}
|
||||
|
|
@ -256,13 +292,84 @@ internal sealed class RemoteChaseHarness
|
|||
if (!_targetArmed) return;
|
||||
_lastDeliveryTime = Now;
|
||||
var pos = new CorePosition(1u, PlayerPos, Quaternion.Identity);
|
||||
Mgr.HandleUpdateTarget(new TargetInfo
|
||||
var info = new TargetInfo
|
||||
{
|
||||
ObjectId = PlayerGuid,
|
||||
Status = TargetStatus.Ok,
|
||||
TargetPosition = pos,
|
||||
InterpolatedPosition = pos,
|
||||
});
|
||||
};
|
||||
// R5-V3 fan order (EntityPhysicsHost.HandleUpdateTarget — retail
|
||||
// CPhysicsObj::HandleUpdateTarget 0x00512bc0): MoveToManager first,
|
||||
// then PositionManager (the sticky consumer).
|
||||
Mgr.HandleUpdateTarget(info);
|
||||
Pm.HandleUpdateTarget(info);
|
||||
}
|
||||
|
||||
// ── R5-V3 (#171): the IPhysicsObjHost twins of GameWindow's
|
||||
// EntityPhysicsHost (creature side) and ResolvePhysicsHost's minimal
|
||||
// target host (player side). ──
|
||||
|
||||
private sealed class CreatureHost : IPhysicsObjHost
|
||||
{
|
||||
private readonly RemoteChaseHarness _h;
|
||||
public CreatureHost(RemoteChaseHarness h) => _h = h;
|
||||
public uint Id => CreatureGuid;
|
||||
public CorePosition Position => new(1u, _h.Body.Position, _h.Body.Orientation);
|
||||
public Vector3 Velocity => _h.Body.Velocity;
|
||||
public float Radius => OwnRadius;
|
||||
public bool InContact => _h.Body.OnWalkable;
|
||||
public float? MinterpMaxSpeed => _h.Interp.GetMaxSpeed();
|
||||
public double CurTime => _h.Now;
|
||||
public double PhysicsTimerTime => _h.Now;
|
||||
public IPhysicsObjHost? GetObjectA(uint id)
|
||||
=> id == PlayerGuid ? _h._playerHost : null;
|
||||
|
||||
public void HandleUpdateTarget(TargetInfo info)
|
||||
{
|
||||
_h.Mgr.HandleUpdateTarget(info);
|
||||
_h.Pm.HandleUpdateTarget(info);
|
||||
}
|
||||
|
||||
public void InterruptCurrentMovement()
|
||||
=> _h.Mgr.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
|
||||
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
|
||||
{
|
||||
// The scripted TargetManager stand-in — StickyManager::StickTo
|
||||
// subscribes at quantum 0.5 (0x00555710) with a synchronous first
|
||||
// delivery (the AddVoyeur immediate snapshot).
|
||||
_h._targetArmed = objectId == PlayerGuid;
|
||||
_h._quantum = quantum;
|
||||
_h.DeliverTargetInfo();
|
||||
}
|
||||
|
||||
public void ClearTarget() => _h._targetArmed = false;
|
||||
public void ReceiveTargetUpdate(TargetInfo info) { }
|
||||
public void AddVoyeur(uint watcherId, float radius, double quantum) { }
|
||||
public void RemoveVoyeur(uint watcherId) { }
|
||||
}
|
||||
|
||||
private sealed class TargetHost : IPhysicsObjHost
|
||||
{
|
||||
private readonly RemoteChaseHarness _h;
|
||||
public TargetHost(RemoteChaseHarness h) => _h = h;
|
||||
public uint Id => PlayerGuid;
|
||||
public CorePosition Position => new(1u, _h.PlayerPos, Quaternion.Identity);
|
||||
public Vector3 Velocity => _h.PlayerVelocity;
|
||||
public float Radius => StickyTargetRadius;
|
||||
public bool InContact => true;
|
||||
public float? MinterpMaxSpeed => null;
|
||||
public double CurTime => _h.Now;
|
||||
public double PhysicsTimerTime => _h.Now;
|
||||
public IPhysicsObjHost? GetObjectA(uint id) => null;
|
||||
public void HandleUpdateTarget(TargetInfo info) { }
|
||||
public void InterruptCurrentMovement() { }
|
||||
public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { }
|
||||
public void ClearTarget() { }
|
||||
public void ReceiveTargetUpdate(TargetInfo info) { }
|
||||
public void AddVoyeur(uint watcherId, float radius, double quantum) { }
|
||||
public void RemoveVoyeur(uint watcherId) { }
|
||||
}
|
||||
|
||||
// ── The per-tick pipeline (GameWindow.TickAnimations order) ────────────
|
||||
|
|
@ -300,10 +407,28 @@ internal sealed class RemoteChaseHarness
|
|||
Quaternion.Multiply(Body.Orientation, deltaRot));
|
||||
}
|
||||
|
||||
// 5. Position integration (UpdatePhysicsInternal, simplified: grounded,
|
||||
// no gravity participation for this scenario).
|
||||
// 5. R5-V3 (#171): the sticky steer — GameWindow's legacy-branch slot
|
||||
// (retail UpdatePositionInternal PositionManager::adjust_offset
|
||||
// @0x00512d0e, composed BEFORE the velocity integration). Origin is
|
||||
// mover-local; the rotation carries a RELATIVE heading (identity =
|
||||
// untouched = no turn).
|
||||
var pmDelta = new MotionDeltaFrame();
|
||||
Pm.AdjustOffset(pmDelta, Dt);
|
||||
if (pmDelta.Origin != Vector3.Zero)
|
||||
Body.Position += Vector3.Transform(pmDelta.Origin, Body.Orientation);
|
||||
if (!pmDelta.Orientation.IsIdentity)
|
||||
Body.Orientation = MoveToMath.SetHeading(
|
||||
Body.Orientation,
|
||||
MoveToMath.GetHeading(Body.Orientation) + pmDelta.GetHeading());
|
||||
|
||||
// 5b. Position integration (UpdatePhysicsInternal, simplified: grounded,
|
||||
// no gravity participation for this scenario).
|
||||
Body.Position += Body.Velocity * Dt;
|
||||
|
||||
// 5c. R5-V3: PositionManager::UseTime (retail UpdateObjectInternal
|
||||
// tail @0x005159b3) — the sticky 1 s lease watchdog.
|
||||
Pm.UseTime();
|
||||
|
||||
// 6. Anim loop (GameWindow.cs:10247-10309): advance, drain AnimDone
|
||||
// hooks into the manager countdown, zero-tick sweep.
|
||||
Seq.Advance(Dt);
|
||||
|
|
@ -630,4 +755,120 @@ public sealed class RemoteChaseEndToEndHarnessTests
|
|||
$"mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8} " +
|
||||
$"pending={h.Interp.MotionsPending()}");
|
||||
}
|
||||
|
||||
// ── R5-V3 (#171): sticky melee scenarios ────────────────────────────────
|
||||
|
||||
private static float AbsHeadingDiff(float a, float b)
|
||||
{
|
||||
float d = MathF.Abs(a - b) % 360f;
|
||||
return d > 180f ? 360f - d : d;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The #171 fix's core: ACE arms melee chases Sticky+UseSpheres with the
|
||||
/// target's real radii; on arrival BeginNextNode hands off to
|
||||
/// PositionManager::StickTo (0x00529d3a) and StickyManager::adjust_offset
|
||||
/// (0x00555430) holds a 0.3 m edge gap + live facing against a strafing
|
||||
/// target — for the 1 s lease, which is set ONCE at StickTo and NOT
|
||||
/// refreshed by target updates (retail 0x00555710/0x00555610): a stick
|
||||
/// not re-issued by a fresh server arm tears itself down.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StickyArrival_TracksStrafingTarget_ThenLeaseExpires()
|
||||
{
|
||||
var h = new RemoteChaseHarness(_out);
|
||||
h.PlayerPos = new Vector3(0f, 10f, 0f); // dead ahead (North)
|
||||
h.PlayerVelocity = Vector3.Zero;
|
||||
|
||||
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
|
||||
for (int i = 0; i < Seconds(1.5f); i++) h.Tick();
|
||||
|
||||
h.UmMoveToObject(sticky: true, useSpheres: true,
|
||||
targetRadius: RemoteChaseHarness.StickyTargetRadius, targetHeight: 1.2f);
|
||||
|
||||
int stickTick = -1;
|
||||
for (int i = 0; i < Seconds(8f) && stickTick < 0; i++)
|
||||
{
|
||||
h.Tick();
|
||||
if (h.Pm.GetStickyObjectId() == RemoteChaseHarness.PlayerGuid)
|
||||
stickTick = i;
|
||||
}
|
||||
h.Snapshot("stuck");
|
||||
Assert.True(stickTick >= 0,
|
||||
$"sticky never armed within 8 s of the arm; mt={h.Mgr.MovementTypeState} " +
|
||||
$"dist={h.DistToPlayer:F2}");
|
||||
// Arrival cleaned the moveto BEFORE the handoff (BeginNextNode reads
|
||||
// Sought* pre-CleanUp; CleanUp resets movement_type to Invalid).
|
||||
Assert.Equal(MovementType.Invalid, h.Mgr.MovementTypeState);
|
||||
|
||||
// The target strafes — the follower must track gap AND facing
|
||||
// (adjust_offset resolves the LIVE target via GetObjectA per tick).
|
||||
h.PlayerVelocity = new Vector3(2f, 0f, 0f);
|
||||
for (int i = 0; i < Seconds(0.8f); i++) h.Tick();
|
||||
h.Snapshot("strafed");
|
||||
|
||||
Assert.Equal(RemoteChaseHarness.PlayerGuid, h.Pm.GetStickyObjectId()); // inside the lease
|
||||
float gap = MoveToMath.CylinderDistanceNoZ(
|
||||
RemoteChaseHarness.OwnRadius, h.Body.Position,
|
||||
RemoteChaseHarness.StickyTargetRadius, h.PlayerPos);
|
||||
Assert.True(MathF.Abs(gap - StickyManager.StickyRadius) < 0.1f,
|
||||
$"stick gap {gap:F2} m — expected ≈{StickyManager.StickyRadius:F1} m (StickyRadius)");
|
||||
float bearing = MoveToMath.PositionHeading(h.Body.Position, h.PlayerPos);
|
||||
Assert.True(AbsHeadingDiff(h.Heading, bearing) < 5f,
|
||||
$"facing {h.Heading:F1}° vs bearing {bearing:F1}° — sticky facing not tracking");
|
||||
|
||||
// Lease expiry: >1 s since StickTo with no re-arm → the UseTime
|
||||
// watchdog drops the stick (retail parity — ACE re-arms each attack
|
||||
// cycle, renewing the stick server-side).
|
||||
h.PlayerVelocity = Vector3.Zero;
|
||||
for (int i = 0; i < Seconds(0.5f); i++) h.Tick();
|
||||
Assert.Equal(0u, h.Pm.GetStickyObjectId());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pack-melee reshuffle cycle: every fresh server arm
|
||||
/// (PerformMovement) tears the previous stick down at the HEAD
|
||||
/// (unstick_from_object → PositionManager::UnStick — the retail
|
||||
/// PerformMovement:414 + UM-funnel-head sites), then the new chase
|
||||
/// re-arrives and re-sticks.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NextPerformMovement_Unsticks_ThenRearrivalResticks()
|
||||
{
|
||||
var h = new RemoteChaseHarness(_out);
|
||||
h.PlayerPos = new Vector3(0f, 8f, 0f);
|
||||
h.PlayerVelocity = Vector3.Zero;
|
||||
|
||||
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
|
||||
for (int i = 0; i < Seconds(1.5f); i++) h.Tick();
|
||||
|
||||
h.UmMoveToObject(sticky: true, useSpheres: true,
|
||||
targetRadius: RemoteChaseHarness.StickyTargetRadius, targetHeight: 1.2f);
|
||||
for (int i = 0; i < Seconds(8f); i++)
|
||||
{
|
||||
h.Tick();
|
||||
if (h.Pm.GetStickyObjectId() != 0u) break;
|
||||
}
|
||||
Assert.Equal(RemoteChaseHarness.PlayerGuid, h.Pm.GetStickyObjectId());
|
||||
|
||||
// The target breaks away; ACE re-arms the chase — the arm must
|
||||
// UNSTICK immediately (stale stick torn down before the new moveto).
|
||||
h.PlayerPos += new Vector3(0f, 8f, 0f);
|
||||
h.UmMoveToObject(sticky: true, useSpheres: true,
|
||||
targetRadius: RemoteChaseHarness.StickyTargetRadius, targetHeight: 1.2f);
|
||||
Assert.Equal(0u, h.Pm.GetStickyObjectId());
|
||||
Assert.Equal(MovementType.MoveToObject, h.Mgr.MovementTypeState);
|
||||
|
||||
// ...and the new chase re-arrives and re-sticks.
|
||||
int restick = -1;
|
||||
for (int i = 0; i < Seconds(8f) && restick < 0; i++)
|
||||
{
|
||||
h.Tick();
|
||||
if (h.Pm.GetStickyObjectId() == RemoteChaseHarness.PlayerGuid)
|
||||
restick = i;
|
||||
}
|
||||
Assert.True(restick >= 0,
|
||||
$"chase did not re-stick after the re-arm; mt={h.Mgr.MovementTypeState} " +
|
||||
$"dist={h.DistToPlayer:F2}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue