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>
This commit is contained in:
Erik 2026-07-05 09:51:24 +02:00
parent 7a82317614
commit 699669502c
3 changed files with 40 additions and 1 deletions

View file

@ -99,7 +99,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 3. Documented approximation (AP) — 72 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system) ## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -179,6 +179,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) | | AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) |
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | | AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) | | AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = (speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded) ## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded)

View file

@ -236,9 +236,27 @@ public sealed class StickyManager
// Don't overshoot: clamp the per-tick step to the remaining (signed) // Don't overshoot: clamp the per-tick step to the remaining (signed)
// distance — a negative dist inverts the direction (back off). // distance — a negative dist inverts the direction (back off).
//
// DEEP-OVERLAP SIGN PIN (#171 gate-3, register AP row): ACE's literal
// line is only `if (delta >= |dist|) delta = dist;` — when the
// overlap is DEEPER than one tick's step, delta keeps its positive
// sign and steers TOWARD the target center, a runaway whose
// equilibrium is centers-coincident (gate-3 probe: 1661 deep-overlap
// ticks, all inward, monsters converged to centerDist≈0 — the
// "monster inside the player" report; retail side-by-side shows
// separation). ACE servers essentially never reach that branch
// (quantum ≥1/30 × speed ≈31 → 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 sign-correct clamp below is the minimal
// interpretation consistent with the mush structure AND observed
// retail — identical to ACE everywhere except deep-overlap, where it
// backs off rate-limited instead of creeping inward.
float delta = speed * (float)quantum; float delta = speed * (float)quantum;
if (delta >= MathF.Abs(dist)) if (delta >= MathF.Abs(dist))
delta = dist; delta = dist;
else if (dist < 0f)
delta = -delta;
offset.Origin *= delta; offset.Origin *= delta;
// Bounded turn to face the target (relative heading this tick). // Bounded turn to face the target (relative heading this tick).

View file

@ -191,6 +191,26 @@ public sealed class StickyManagerTests
Assert.Equal(-0.4f, frame.Origin.X, 3); 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] [Fact]
public void AdjustOffset_UsesCachedPosition_WhenTargetUnresolvable() public void AdjustOffset_UsesCachedPosition_WhenTargetUnresolvable()
{ {