fix(#171): gate NPC UP hard-snaps while stuck — the sticky/snap tug-of-war (gate-1 residuals)

Gate 1 (2026-07-04): "better in general" + three residuals — monsters
pushed INTO the player, attacks with stale facing, position
"flashing/flapping instead of gliding". All three are ONE mechanism:
the legacy NPC UpdatePosition handler hard-snaps position, orientation,
and velocity/cycle UNCONDITIONALLY, fighting the armed sticky every UP
(ACE's authoritative rest pose sits ~0.6 m out and its server-side
facing lags the strafing target; the client stick holds 0.3 m + live
facing — oscillation at UP cadence).

Retail is immune by architecture, not by tuning: UP corrections flow
through the InterpolationManager into the SAME per-tick
PositionManager::adjust_offset chain where StickyManager::adjust_offset
OVERWRITES them while armed (0x00555190 chain order; 0x00555430 assigns
m_fOrigin). A server correction can never fight an armed stick
frame-by-frame. The remote-player branch already has exactly that
(queue -> combiner -> sticky overwrite); the legacy NPC path snaps
outside the chain.

Fix: suppress the NPC UP position/orientation/velocity-adoption snaps
while the entity is stuck (PositionManager.GetStickyObjectId() != 0) —
the retail chain semantics translated to the snap architecture.
LastServerPos/Time + cell bookkeeping still record; server truth
reasserts on the first UP after unstick, bounded by the 1 s sticky
lease. Register row TS-44 (same commit); retires with the S6/R6
interp-queue unification of the NPC path.

Apparatus: ACDREAM_PROBE_STICKY=1 — per-guid [sticky] lifecycle lines
(STICK / UNSTICK / LEASE-EXPIRE / TARGET-status teardown), per-armed-
tick steer lines (signed gap dist, applied delta, heading delta, live
resolve), and [sticky-snap-skip] at the suppressed-snap site.
PhysicsDiagnostics.ProbeStickyEnabled owns the flag (rule #5).

Full suite 4038 green. Awaiting gate 2 (pack melee vs retail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 09:34:25 +02:00
parent 5bd2b8bc8b
commit 7a82317614
5 changed files with 90 additions and 4 deletions

View file

@ -5675,7 +5675,34 @@ public sealed class GameWindow : IDisposable
rmState.ServerVelocity = System.Numerics.Vector3.Zero;
rmState.HasServerVelocity = false;
}
rmState.Body.Position = worldPos;
// R5-V3 #171 residual (2026-07-04 gate: "flashing/flapping",
// stale facing, pushed-into-player): while an entity is STUCK,
// the sticky steer owns its frame — retail's UP corrections flow
// through the InterpolationManager into the SAME adjust_offset
// chain where StickyManager OVERWRITES them while armed
// (PositionManager::adjust_offset 0x00555190 order; sticky
// assigns m_fOrigin 0x00555430), so a server correction can
// never fight an armed stick frame-by-frame. This legacy NPC
// path hard-snaps OUTSIDE that chain, producing a visible
// snap-out/steer-back oscillation at UP cadence (position) and
// a stale-facing stomp (orientation). Faithful translation to
// the snap architecture: suppress the position/orientation/
// velocity snaps while stuck. LastServerPos/Time bookkeeping
// still records below — server truth reasserts on the first UP
// after unstick (bounded by the 1 s sticky lease). Register
// row with TS-41/TS-44.
bool snapSuppressedByStick =
(rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
if (snapSuppressedByStick
&& AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled)
{
float snapDist = System.Numerics.Vector3.Distance(
worldPos, rmState.Body.Position);
Console.WriteLine(FormattableString.Invariant(
$"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})"));
}
if (!snapSuppressedByStick)
rmState.Body.Position = worldPos;
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
// at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on
@ -5711,7 +5738,13 @@ public sealed class GameWindow : IDisposable
// turn starts incorporates the pre-turn interval and produces
// a halved "observed" rate → visible slow-start. Formula-only
// is stable and simple; hard-snap fixes any drift.
rmState.Body.Orientation = rot;
// R5-V3 #171 residual: gated on the stick (see the position-snap
// comment above) — ACE's server-side facing lags the strafing
// target; stomping it over the sticky's per-tick face-tracking
// was the "monster attacking while facing a different direction"
// gate report.
if (!snapSuppressedByStick)
rmState.Body.Orientation = rot;
rmState.LastServerPos = worldPos;
rmState.LastServerPosTime = nowSec;
// Align the body's physics clock with our clock so update_object
@ -5750,12 +5783,14 @@ public sealed class GameWindow : IDisposable
}
}
}
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity)
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity
&& !snapSuppressedByStick)
{
rmState.Body.Velocity = rmState.ServerVelocity;
}
if (rmState.HasServerVelocity
&& !snapSuppressedByStick
&& _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity))
{
// NPC/monster remotes: PlanFromVelocity cycle selection from

View file

@ -82,6 +82,10 @@ public sealed class StickyManager
if (TargetId == 0)
return;
if (PhysicsDiagnostics.ProbeStickyEnabled)
Console.WriteLine(FormattableString.Invariant(
$"[sticky] guid=0x{_host.Id:X8} UNSTICK target=0x{TargetId:X8}"));
TargetId = 0;
Initialized = false;
_host.ClearTarget();
@ -120,6 +124,11 @@ public sealed class StickyManager
TargetId = objectId;
Initialized = false;
StickyTimeoutTime = _host.CurTime + StickyTime;
if (PhysicsDiagnostics.ProbeStickyEnabled)
Console.WriteLine(FormattableString.Invariant(
$"[sticky] guid=0x{_host.Id:X8} STICK target=0x{objectId:X8} tgtR={targetRadius:F2} ownR={_host.Radius:F2} lease={StickyTime:F1}s"));
// set_target(context_id=0, objectId, radius=0.5, quantum=0.5).
_host.SetTarget(0, objectId, 0.5f, 0.5);
}
@ -140,6 +149,10 @@ public sealed class StickyManager
// C0|C3 clear = cur_time > timeout; ACE `>` too), not >=.
if (_host.CurTime > StickyTimeoutTime)
{
if (PhysicsDiagnostics.ProbeStickyEnabled)
Console.WriteLine(FormattableString.Invariant(
$"[sticky] guid=0x{_host.Id:X8} LEASE-EXPIRE target=0x{TargetId:X8}"));
TargetId = 0;
Initialized = false;
_host.ClearTarget();
@ -170,6 +183,10 @@ public sealed class StickyManager
if (TargetId != 0)
{
if (PhysicsDiagnostics.ProbeStickyEnabled)
Console.WriteLine(FormattableString.Invariant(
$"[sticky] guid=0x{_host.Id:X8} TARGET-{info.Status} teardown target=0x{TargetId:X8}"));
TargetId = 0;
Initialized = false;
_host.ClearTarget();
@ -233,5 +250,9 @@ public sealed class StickyManager
if (heading < -MoveToMath.Epsilon)
heading += 360f;
offset.SetHeading(heading);
if (PhysicsDiagnostics.ProbeStickyEnabled)
Console.WriteLine(FormattableString.Invariant(
$"[sticky] guid=0x{_host.Id:X8} ADJ dist={dist:F3} delta={delta:F3} speed={speed:F1} hdgDelta={heading:F1} live={(target is not null ? 1 : 0)}"));
}
}

View file

@ -80,6 +80,20 @@ public static class PhysicsDiagnostics
public static bool ProbeCellSetEnabled { get; set; }
= Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELLSET") == "1";
/// <summary>
/// R5-V3 #171 residuals (2026-07-04) — sticky-melee timeline probe.
/// One <c>[sticky]</c> line per StickyManager lifecycle event (STICK /
/// UNSTICK / LEASE-EXPIRE / TARGET-status teardown) and per armed
/// <c>AdjustOffset</c> tick (guid, signed gap distance, applied delta,
/// heading delta), plus <c>[sticky-snap-skip]</c> lines at the NPC
/// UpdatePosition handler when a server hard-snap is suppressed because
/// the entity is stuck. Heavy while a pack is stuck (~60 Hz × stuck
/// count); capture-session only. All lines carry the guid
/// (feedback_probe_identity_attribution).
/// </summary>
public static bool ProbeStickyEnabled { get; set; }
= Environment.GetEnvironmentVariable("ACDREAM_PROBE_STICKY") == "1";
public static void LogCellSetBuild(
uint seedCellId,
System.Numerics.Vector3 sphereCenter,
@ -562,6 +576,7 @@ public static class PhysicsDiagnostics
ProbeCellEnabled = false;
ProbeBuildingEnabled = false;
ProbeCellSetEnabled = false;
ProbeStickyEnabled = false;
ProbeAutoWalkEnabled = false;
ProbeUseabilityFallbackEnabled= false;
DumpSteepRoofEnabled = false;