From 699669502c488c343601407f8e6eb5b34193433f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 09:51:24 +0200 Subject: [PATCH] =?UTF-8?q?fix(#171):=20sticky=20deep-overlap=20back-off?= =?UTF-8?q?=20sign=20pin=20=E2=80=94=20the=20runaway-to-center=20(gate-2?= =?UTF-8?q?=20residual)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../retail-divergence-register.md | 3 ++- .../Physics/Motion/StickyManager.cs | 18 +++++++++++++++++ .../Physics/Motion/StickyManagerTests.cs | 20 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6760c7de..bbcebbdb 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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 | |---|---|---|---|---|---| @@ -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-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-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) diff --git a/src/AcDream.Core/Physics/Motion/StickyManager.cs b/src/AcDream.Core/Physics/Motion/StickyManager.cs index 6db8d250..1646ce97 100644 --- a/src/AcDream.Core/Physics/Motion/StickyManager.cs +++ b/src/AcDream.Core/Physics/Motion/StickyManager.cs @@ -236,9 +236,27 @@ public sealed class StickyManager // Don't overshoot: clamp the per-tick step to the remaining (signed) // 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; if (delta >= MathF.Abs(dist)) delta = dist; + else if (dist < 0f) + delta = -delta; offset.Origin *= delta; // Bounded turn to face the target (relative heading this tick). diff --git a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs index aecaec49..744785df 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs @@ -191,6 +191,26 @@ public sealed class StickyManagerTests Assert.Equal(-0.4f, frame.Origin.X, 3); } + [Fact] + public void AdjustOffset_DeepOverlap_BacksOff_RateLimited() + { + var world = new Dictionary(); + 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() {