From 5bd2b8bc8bd86cf64c6d936dc6d242fc9ccd3e5e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 4 Jul 2026 23:46:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(#171):=20R5-V3=20=E2=80=94=20bind=20sticky?= =?UTF-8?q?=20melee=20(StickyManager=20live)=20+=20real=20arrival=20radii?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ISSUES.md | 12 +- .../retail-divergence-register.md | 8 +- .../r5-wiring-handoff.md | 10 + .../Input/PlayerMovementController.cs | 54 ++++ .../Rendering/EntityPhysicsHost.cs | 42 ++- src/AcDream.App/Rendering/GameWindow.cs | 216 ++++++++++++++- .../Physics/Motion/StickyManager.cs | 4 +- .../Motion/PositionManagerFacadeTests.cs | 4 +- .../Motion/RemoteChaseEndToEndHarnessTests.cs | 257 +++++++++++++++++- .../Physics/Motion/StickyManagerTests.cs | 11 +- 10 files changed, 586 insertions(+), 32 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index dc78cb5b..e834d9b6 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -48,7 +48,17 @@ Copy this block when adding a new issue: ## #171 — Group melee: monsters interpenetrate + facing drifts (sticky melee unbound, arrival radii = 0) -**Status:** OPEN — investigated + fix APPROVED 2026-07-04 (user: "Ok I approve"). +**Status:** FIX LANDED (2026-07-04, R5-V3) — awaiting user visual gate. StickyManager/ +PositionManager (ported R5-V1) now BOUND: `StickTo`/`Unstick`/`UnstickFromObject` +seams → the host's `PositionManager` (remote + player), `AdjustOffset` composed +at the retail `UpdatePositionInternal` slot before the collision sweep, +`UseTime` (1 s lease watchdog) at the `UpdateObjectInternal` tail, real setup +cylsphere radii threaded (own via `EnsureRemoteMotionBindings`/player wiring, +target via `RouteServerMoveTo`), the SERVERVEL leg now also yields to a stuck +entity (TS-41), exit-world/teleport teardown wired (remote teleport gap filed +TS-43). TS-39 retired. Harness: 2 new sticky scenarios in +`RemoteChaseEndToEndHarnessTests` (arrive→stick→strafe-track→lease-expiry; +unstick-on-rearm→re-stick). **Severity:** MEDIUM (visual — group combat feel vs retail) **Filed:** 2026-07-04 (user report during the #170 visual gate: "some monsters were partly inside other monsters while hitting me… some orientation of the monsters diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b92e351a..6ff2adce 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -123,7 +123,7 @@ accepted-divergence entries (#96, #49, #50). | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | | AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1−SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive → D3DBLEND_ONE per-surface routing | | AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 | -| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b, but with the object's real radius) | `src/AcDream.App/Rendering/GameWindow.cs:12274` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | +| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | | AP-24 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:176` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) | | AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) | | AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 | @@ -180,7 +180,7 @@ accepted-divergence entries (#96, #49, #50). | 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) | -## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count) +## 4. Temporary stopgap (TS) — 38 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) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -216,13 +216,13 @@ accepted-divergence entries (#96, #49, #50). | TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | | TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) | | TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change), AND acdream's frame-changed diff compares POSITION only (`ApproxPositionEqual`) where retail's `Frame::is_equal` compares the full frame incl. ORIENTATION — a stationary heading change (R4-V5: the MoveToManager's `HandleTurnToHeading` arrival snap, `set_heading(send:true)`) never triggers an AP, so the server keeps the stale facing until the player next moves. Masked against ACE (ACE rotates server-side on its own mt-8/9 / close-range-use paths and broadcasts the result) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`); `src/AcDream.App/Input/PlayerMovementController.cs` (`ApproxPositionEqual` + the heartbeat diff); the player MoveToManager `setHeading` seam in `EnterPlayerModeNow` (the `send` flag's would-be consumer) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping + full-frame `Frame::is_equal` diff) is a dedicated follow-up slice (R7 outbound) | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends, and a stationary server-commanded turn leaves observers with stale facing until the next movement; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0, `Frame::is_equal` (pc:700263) | -| TS-39 | `MoveToManager.StickTo` and `Unstick` are unbound no-op seams (both remote and player bindings leave them null) — retail's sticky arrival hands off to `PositionManager::StickTo(tlid, radius, height)` (BeginNextNode §4b reads the three fields BEFORE CleanUp zeroes them — that ordering IS ported) and every `PerformMovement` head calls `unstick_from_object` → `PositionManager::UnStick`; acdream's sticky moveto (wire bit 0x80) completes-and-stops instead of sticking | `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`StickTo`/`Unstick` seam properties); GameWindow `EnsureRemoteMotionBindings` + `EnterPlayerModeNow` (neither binds them) | PositionManager/StickyManager bodies were NOT extracted in the R4 research (call shapes only, decomp §9f/§9g — do-not-invent); the seams + verbatim call ordering land now, the bodies with R5 | A server /follow-style sticky MoveTo arrives, walks to the target, then STOPS instead of sticking — the entity drifts off the target as it moves until the next re-emit; unstick ordering differences invisible until StickyManager exists | `PositionManager::StickTo` / `UnStick` call shapes (r4-moveto-decomp.md §4b/§3a); R5 StickyManager port retires this row | | TS-40 | Retail's `physics_obj->cell` ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` (local player placement) and `RemoteMotion` construction (remotes exist only for world entities); consumed by `CMotionInterp`'s detached-object link-strip guards (`if (cell == 0) RemoveLinkAnimations`, raw @305627). Replaces the UNREGISTERED `CellPosition.ObjCellId == 0` proxy, which only the local player ever seeded (#145 `SnapToCell`), so every REMOTE body read "detached" and every dispatched transition link (door swings, remote walk↔run links) was stripped the same tick it was appended — the 2026-07-03 door-snap bug | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | acdream has no per-body CObjCell pointer; a boolean placement flag carries exactly the guard's retail meaning until cell-pointer plumbing exists | A body used without either placement path (a future entity class constructing bodies directly) reads detached and loses transition links until its creation site sets the flag | `CMotionInterp::DoInterpretedMotion` 0x00528360 tail @305627; `CPhysicsObj::RemoveLinkAnimations` | | TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) | | TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 | | TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly | -| TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MoveToManager.UseTime` SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) | +| TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MoveToManager.UseTime` SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) | | TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `MoveTo.UseTime` FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) | +| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port | --- diff --git a/docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md b/docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md index 81bbf6ac..12941c9e 100644 --- a/docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md +++ b/docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md @@ -84,6 +84,16 @@ often. If chase feels choppier than AP-79, that's the throttle — retail-faithf but note it. ### V3 — wire PositionManager (sticky), retire TS-39, apply mt-0 flags + +> **STATUS 2026-07-04: SHIPPED (as the #171 fix), awaiting the visual gate** +> — sticky seams bound (StickTo/Unstick/UnstickFromObject, remote + player), +> AdjustOffset at the UpdatePositionInternal slot (combiner chained as the +> interp stage in the remote-player branch; pre-sweep compose in the NPC +> branch + player physics tick), UseTime at the UpdateObjectInternal tail, +> real setup cylsphere radii threaded, exit-world/teleport teardown (remote +> teleport gap = TS-43), SERVERVEL yields to a stuck entity (TS-41). TS-39 +> retired. **The mt-0 wire flags (0x1 StickToObject / 0x2 StandingLongJump) +> were NOT part of the user-approved #171 scope — they move to V4.** Add a `PositionManager` to each host. Bind `MoveToManager.StickTo → host.PositionManager.StickTo`, `MoveToManager.Unstick → host.PositionManager.UnStick`. The load-bearing part: integrate `host.PositionManager.AdjustOffset(deltaFrame, diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index d60c82a2..872bb01c 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -286,6 +286,21 @@ public sealed class PlayerMovementController /// public AcDream.Core.Physics.Motion.MoveToManager? MoveTo { get; set; } + /// + /// R5-V3 (#171): the player's PositionManager facade (retail + /// CPhysicsObj::position_manager — owned by the player's + /// EntityPhysicsHost, handed here by EnterPlayerModeNow). + /// drives it at the two retail per-tick points: + /// AdjustOffset inside the physics-tick block (retail + /// UpdatePositionInternal @0x00512d0e, BEFORE + /// UpdatePhysicsInternal so the sticky steer is part of the swept + /// motion) and UseTime after the completed-motions sweep (retail + /// UpdateObjectInternal tail @0x005159b3 — the sticky 1 s lease + /// watchdog). tears any + /// stick down (retail teleport_hook @0x00514eee). + /// + public AcDream.Core.Physics.Motion.PositionManager? PositionManager { get; set; } + public PlayerMovementController(PhysicsEngine physics) { _physics = physics; @@ -468,6 +483,12 @@ public sealed class PlayerMovementController // 0x00527e40: resets fwd/sidestep/turn COMMANDS, zeroes velocity, // enqueues the A9 jump-snapshot node) — not a bare DoMotion(Ready). _motion.StopCompletely(); + // R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager:: + // UnStick (@0x00514eee) right after the moveto cancel: a teleport + // tears down any active stick. (StopInterpolating/UnConstrain have no + // armed acdream counterparts — no local-player InterpolationManager, + // constraint leash unarmed per #167.) + PositionManager?.UnStick(); // Reset the edge tracker: the stop wiped the motion state, so keys // still physically held must re-fire as press edges on the next // Update (matches the pre-W6 level-triggered behavior of walking @@ -793,6 +814,29 @@ public sealed class PlayerMovementController // Integrate accumulated dt, clamped to MaxQuantum so a long // pause doesn't produce one giant integration step. float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum); + + // R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) — + // PositionManager::adjust_offset (@0x00512d0e) composes the + // sticky steer into this quantum's motion BEFORE + // UpdatePhysicsInternal, so the transition sweep below resolves + // it like any other movement (preIntegratePos was captured + // above). The delta's Origin is mover-LOCAL (rotate out by the + // body orientation); the heading is RELATIVE and writes Yaw — + // the authoritative facing the body quaternion is re-derived + // from every frame (a quaternion-only write would be clobbered). + // No-op while nothing is stuck (untouched delta frame). + if (PositionManager is { } ppm) + { + var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame(); + ppm.AdjustOffset(pmDelta, tickDt); + if (pmDelta.Origin != Vector3.Zero) + _body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation); + if (!pmDelta.Orientation.IsIdentity) + Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading( + AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw) + + pmDelta.GetHeading()); + } + _body.calc_acceleration(); _body.UpdatePhysicsInternal(tickDt); _physicsAccum -= tickDt; @@ -853,6 +897,16 @@ public sealed class PlayerMovementController { _prevPhysicsPos = oldTickEndPos; _currPhysicsPos = _body.Position; + + // R5-V3 (#171): retail UpdateObjectInternal TAIL — PositionManager:: + // UseTime (@0x005159b3) runs AFTER the quantum's integration + // (whose head hosts adjust_offset) and only on frames where a + // physics quantum executed (retail's update_object MinQuantum + // gate skips the whole UpdateObjectInternal). Diff-review find: + // a head-of-frame placement tore an expiring stick down BEFORE + // the crossing quantum's final steer/turn — retail applies that + // last adjust_offset, THEN the tail watchdog tears down. + PositionManager?.UseTime(); } // L.3a (2026-04-30): retail wall-bounce / velocity reflection. diff --git a/src/AcDream.App/Rendering/EntityPhysicsHost.cs b/src/AcDream.App/Rendering/EntityPhysicsHost.cs index 00e5e2b4..151cd0a8 100644 --- a/src/AcDream.App/Rendering/EntityPhysicsHost.cs +++ b/src/AcDream.App/Rendering/EntityPhysicsHost.cs @@ -24,9 +24,15 @@ namespace AcDream.App.Rendering; /// fan-out are injected by GameWindow so this /// class stays free of GameWindow's internals (code-structure rule #1). /// -/// PositionManager (sticky) is R5-V3 — this host gains a -/// PositionManager and fans to it then; -/// V2 delivers only to the MoveToManager. +/// R5-V3: owns a too (retail +/// CPhysicsObj::position_manager — retail creates it lazily via +/// get_position_manager; acdream constructs it eagerly, which is +/// behaviorally identical because the empty facade no-ops until its first +/// StickTo/ConstrainTo). fans +/// deliveries to the injected MoveToManager fan FIRST, then the +/// PositionManager — retail CPhysicsObj::HandleUpdateTarget order +/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager +/// @0x00512c1a). /// public sealed class EntityPhysicsHost : IPhysicsObjHost { @@ -68,6 +74,7 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost _interruptCurrentMovement = interruptCurrentMovement ?? throw new ArgumentNullException(nameof(interruptCurrentMovement)); _targetManager = new TargetManager(this); + PositionManager = new PositionManager(this); } // ── IPhysicsObjHost accessors ────────────────────────────────────────── @@ -84,9 +91,24 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost /// CPhysicsObj::target_manager). public TargetManager TargetManager => _targetManager; + /// R5-V3 — the owned facade (retail + /// CPhysicsObj::position_manager): sticky follow + (unarmed) + /// constraint leash. Seam targets: MoveToManager.StickTo/Unstick, + /// MotionInterpreter.UnstickFromObject, the per-tick + /// AdjustOffset/UseTime drivers. + public PositionManager PositionManager { get; } + // ── IPhysicsObjHost fan-out / target-tracking seams ──────────────────── public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id); - public void HandleUpdateTarget(TargetInfo info) => _handleUpdateTarget(info); + + public void HandleUpdateTarget(TargetInfo info) + { + // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order: + // MovementManager (the injected MoveToManager fan) first, then + // PositionManager (@0x00512c1a — the R5-V3 sticky consumer). + _handleUpdateTarget(info); + PositionManager.HandleUpdateTarget(info); + } public void InterruptCurrentMovement() => _interruptCurrentMovement(); public void SetTarget(uint contextId, uint objectId, float radius, double quantum) @@ -112,4 +134,16 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost /// stick/moveto). Called on despawn before the host is removed from the /// registry. public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld); + + /// R5-V3 (#171): retail CPhysicsObj::teleport_hook's tail + /// (0x00514ed0 @0x00514f1b-0x00514f28) — TargetManager::ClearTarget + /// (drop this entity's OWN subscription) then + /// NotifyVoyeurOfEvent(Teleported) (every entity watching THIS one + /// drops its stick/moveto — StickyManager::HandleUpdateTarget's + /// non-Ok teardown path). Called after a teleport placement. + public void NotifyTeleported() + { + _targetManager.ClearTarget(); + _targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported); + } } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5e81c121..5f523cf2 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4284,6 +4284,14 @@ public sealed class GameWindow : IDisposable // distance/heading math matches the harness exactly. var rmT = rm; var mtBody = rm.Body; + // R5-V3 (#171): real setup cylsphere radii — retail CPartArray:: + // GetRadius/GetHeight (0x005180a0/0x005180b0). Lazy per-call resolve + // (a dictionary hit) so the read never races the spawn path's + // CacheSetup. Feeds GetCurrentDistance's UseSpheres cylinder math + // (own side) and StickyManager::adjust_offset's own-radius gap term. + // Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with + // R5-V3"). + var selfEntity = ae.Entity; // R5-V2: forward-declared so the MoveToManager's target seams route // into the entity's TargetManager (retail CPhysicsObj::set_target → // TargetManager::SetTarget). Assigned right after the manager is built. @@ -4298,8 +4306,8 @@ public sealed class GameWindow : IDisposable mtBody.Orientation), setHeading: (h, _) => mtBody.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h), - getOwnRadius: () => 0f, // pin P4 note: setup cylsphere radius lands with R5-V3 - getOwnHeight: () => 0f, + getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, + getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height, contact: () => mtBody.OnWalkable, isInterpolating: () => rmT.Interp.IsActive, getVelocity: () => mtBody.Velocity, @@ -4326,7 +4334,8 @@ public sealed class GameWindow : IDisposable // delivers the identical position for a moveto's quantum-0 case). // HandleTargetting ticks per frame (added in the per-remote loop); // HandleUpdateTarget fans deliveries to this entity's MoveToManager - // (PositionManager sticky joins the fan-out in V3). + // and (R5-V3, inside the host) its PositionManager — retail's + // CPhysicsObj::HandleUpdateTarget order. host = new EntityPhysicsHost( serverGuid, getPosition: () => new AcDream.Core.Physics.Position( @@ -4335,7 +4344,7 @@ public sealed class GameWindow : IDisposable ? selfEnt.Position : mtBody.Position, mtBody.Orientation), getVelocity: () => mtBody.Velocity, - getRadius: () => 0f, // R5-V3: real setup cylsphere radius + getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, inContact: () => mtBody.OnWalkable, minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(), curTime: NowSeconds, @@ -4346,6 +4355,20 @@ public sealed class GameWindow : IDisposable AcDream.Core.Physics.WeenieError.ActionCancelled)); rm.Host = host; _physicsHosts[serverGuid] = host; + + // R5-V3 (#171, retires TS-39): bind the sticky seams to the host's + // PositionManager — + // * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo + // (retail MoveToManager::BeginNextNode @0x00529d3a); + // * PerformMovement's head unstick: unstick_from_object → + // PositionManager::UnStick (MoveToManager.PerformMovement:414); + // * the UM funnel head's unstick: CPhysicsObj::unstick_from_object + // (0x0050eaea) — invoked at the mt-0 routing sites (~4894) but + // unbound until now. + rm.MoveTo.StickTo = (tlid, radius, height) => + host.PositionManager.StickTo(tlid, radius, height); + rm.MoveTo.Unstick = host.PositionManager.UnStick; + rm.Motion.UnstickFromObject = host.PositionManager.UnStick; return rm.Sink; } @@ -4393,6 +4416,64 @@ public sealed class GameWindow : IDisposable return minimal; } + /// + /// R5-V3 (#171): retail CPartArray::GetRadius/GetHeight + /// (0x005180a0/0x005180b0 — setup->radius/height × scale; ACE + /// PartArray.cs:189-207 reads the same Setup-level fields). Returns + /// (0, 0) when the entity has no resolvable Setup (bare-GfxObj props) — + /// ACE's PartArray != null ? … : 0 fallback shape + /// (PhysicsObj.cs:951-952). Live spawns cache their Setup in + /// _physicsDataCache at spawn time (CacheSetup, ~3250), so this is + /// a dictionary hit for every creature. + /// + private (float Radius, float Height) GetSetupCylinder( + uint serverGuid, AcDream.Core.World.WorldEntity entity) + { + var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); + if (setup is null) + return (0f, 0f); + // Live spawns bake ObjScale into MeshRefs and never populate + // WorldEntity.Scale (a scenery-pipeline field, default 1.0) — the + // authoritative per-entity scale is the SPAWN RECORD's ObjScale, the + // same source the collision registration's entScale uses (~4137). + // entity.Scale remains the fallback for non-spawn entities (scenery — + // never a chase/sticky participant). Diff-review find: without this, + // a server-scaled creature variant read unscaled radii and kept the + // #171 interpenetration exactly for scaled bodies. + float scale = + _lastSpawnByGuid.TryGetValue(serverGuid, out var sp) + && sp.ObjScale is { } objScale && objScale > 0f + ? objScale + : (entity.Scale > 0f ? entity.Scale : 1f); + return (setup.Radius * scale, setup.Height * scale); + } + + /// + /// R5-V3 (#171): apply a + /// written by PositionManager.AdjustOffset onto a body — acdream's + /// stand-in for retail's Frame::combine in + /// CPhysicsObj::UpdatePositionInternal (0x00512c30, combine + /// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes + /// globaltolocalvec output — 0x00555430), so combining = rotating it + /// out by the body orientation. The rotation carries a RELATIVE heading; + /// an untouched (identity) rotation means "no turn" — retail distinguishes + /// an unwritten offset rotation from set_heading(0) by the identity + /// VALUE, not the angle, and the P5 pin (identity quaternion = heading 0) + /// makes compass addition the exact frame-combine here. + /// + private static void ApplyPositionManagerDelta( + AcDream.Core.Physics.PhysicsBody body, + AcDream.Core.Physics.Motion.MotionDeltaFrame delta) + { + if (delta.Origin != System.Numerics.Vector3.Zero) + body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation); + if (!delta.Orientation.IsIdentity) + body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading( + body.Orientation, + AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation) + + delta.GetHeading()); + } + private bool RemoveLiveEntityByServerGuid(uint serverGuid) { if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity)) @@ -4417,7 +4498,20 @@ public sealed class GameWindow : IDisposable // timeout never fires), so it must run before the host is pruned. if (_physicsHosts.TryGetValue(serverGuid, out var hostGone) && hostGone is EntityPhysicsHost ephGone) + { + // R5-V3 (#171): retail exit_world (0x00514e60) order — the + // departing entity first tears down its OWN stick + // (PositionManager::UnStick @0x00514e88 — drops its voyeur + // subscription on whatever it was stuck to) and its own target + // subscription (TargetManager::ClearTarget @0x00514e97 — same + // for a plain mid-chase moveto), THEN NotifyVoyeurOfEvent + // (ExitWorld) tells the entities watching IT. Without the first + // two, a despawning attacker leaves a dead voyeur entry on its + // target until send-failure pruning. + ephGone.PositionManager.UnStick(); + ephGone.ClearTarget(); ephGone.NotifyExitWorld(); + } _physicsHosts.Remove(serverGuid); _animatedEntities.Remove(existingEntity.Id); _classificationCache.InvalidateEntity(existingEntity.Id); @@ -4494,6 +4588,15 @@ public sealed class GameWindow : IDisposable ms.Type = AcDream.Core.Physics.MovementType.MoveToObject; ms.ObjectId = tgtGuid; ms.TopLevelId = tgtGuid; + // R5-V3 (#171): retail resolves the TARGET object's PartArray + // radius/height at the MoveToObject call site + // (CPhysicsObj::MoveToObject 0x005128e9/0x00512903; ACE + // PhysicsObj.cs:951-952) — they become SoughtObjectRadius/ + // Height, feeding GetCurrentDistance's edge-to-edge arrival + // (UseSpheres) and the sticky-arrival handoff's target radius. + // Was unset (0): every attacker closed ~one body-radius deeper + // than retail before stopping — the #171 dogpile term. + (ms.Radius, ms.Height) = GetSetupCylinder(tgtGuid, tgtEnt); ms.Pos = new AcDream.Core.Physics.Position( cellId, tgtEnt.Position, System.Numerics.Quaternion.Identity); @@ -5861,6 +5964,16 @@ public sealed class GameWindow : IDisposable } _playerController.SetPosition(snappedPos, resolved.CellId, CellLocalForSeed(snappedPos, resolved.CellId)); + // R5-V3 (#171, diff-review find): retail teleport_hook's TAIL + // (0x00514ed0 @0x00514f1b-0x00514f28) — after the manager teardown + // SetPosition just ran (moveto cancel + own UnStick), the teleporting + // object clears its OWN target subscription and tells every entity + // WATCHING IT that it teleported (NotifyVoyeurOfEvent(Teleported)). + // That notify is what tears down the mobs' sticks/target-tracking ON + // the player — without it, a melee pack stuck to the player keeps + // steering toward the post-teleport position at the 5× sticky follow + // speed for up to the 1 s lease (non-retail lurch on every recall). + _playerHost?.NotifyTeleported(); // Face the server-specified destination heading (retail drops you facing a fixed // direction). The render entity already got _pendingTeleportRot above; sync the // controller yaw so the camera + movement frame match it instead of the stale @@ -9778,7 +9891,29 @@ public sealed class GameWindow : IDisposable interp: rm.Interp, maxSpeed: maxSpeed, terrainNormal: terrainNormal); - rm.Body.Position += offset; + // R5-V3 (#171): retail chains Interpolation → Sticky over + // ONE shared delta frame (PositionManager::adjust_offset + // 0x00555190). The combiner's catch-up IS acdream's + // interpolation stage, so its offset SEEDS the frame + // (converted to mover-local) and StickyManager:: + // adjust_offset OVERWRITES it when armed+initialized + // (0x00555430 assigns m_fOrigin rather than accumulating). + // With no stick armed the frame comes back untouched and + // this reduces to the pre-V3 `Position += offset`. + if (rm.Host is { } plHost) + { + var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame + { + Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec( + rm.Body.Orientation, offset), + }; + plHost.PositionManager.AdjustOffset(pmDelta, dt); + ApplyPositionManagerDelta(rm.Body, pmDelta); + } + else + { + rm.Body.Position += offset; + } // Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1. // Prints per-tick body Z trajectory + queue state + projected @@ -9961,7 +10096,20 @@ public sealed class GameWindow : IDisposable // (turn→run→drain sustained when the manager is ticked). bool moveToArmed = rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }; - if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity && !moveToArmed) + // R5-V3 (#171): a STUCK entity is also client-side- + // driven — after the sticky arrival the moveto is + // cleaned (Invalid) but StickyManager::adjust_offset + // owns the between-snap translation exactly like an + // armed moveto. Routing it to SERVERVEL would glide + // the body on synthesized wire velocity AGAINST the + // sticky steer (the same starvation class as the #170 + // SERVERVEL fix — register TS-41). Retail has no + // SERVERVEL leg at all; it stays the fallback for + // entities with NO client-side movement driver. + bool stickyArmed = + (rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u; + if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity + && !moveToArmed && !stickyArmed) { double velocityAge = nowSec - rm.LastServerPosTime; if (velocityAge > ServerControlledVelocityStaleSeconds) @@ -10075,6 +10223,20 @@ public sealed class GameWindow : IDisposable // here to prevent UpdatePhysicsInternal's own omega pass from // double-integrating. var preIntegratePos = rm.Body.Position; + // R5-V3 (#171): the sticky steer — retail + // UpdatePositionInternal's PositionManager::adjust_offset + // slot (0x00512c30, call @0x00512d0e): the delta composes + // into this tick's motion BEFORE UpdatePhysicsInternal + + // the transition sweep below, so collision resolves the + // sticky movement like any other motion (preIntegratePos + // is captured first — the sweep covers the steer). No-op + // when nothing is stuck (untouched delta frame). + if (rm.Host is { } npcHost) + { + var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame(); + npcHost.PositionManager.AdjustOffset(pmDelta, dt); + ApplyPositionManagerDelta(rm.Body, pmDelta); + } rm.Body.calc_acceleration(); rm.Body.UpdatePhysicsInternal(dt); var postIntegratePos = rm.Body.Position; @@ -10196,6 +10358,15 @@ public sealed class GameWindow : IDisposable ae.Entity.ParentCellId = rm.CellId; ae.Entity.Rotation = rm.Body.Orientation; } + + // R5-V3 (#171): retail UpdateObjectInternal tail — + // PositionManager::UseTime (0x005156b0, call @0x005159b3, + // right after CPartArray::HandleMovement, UNCONDITIONAL for + // every entity in both grounded and airborne branches): the + // sticky 1 s lease watchdog (StickyManager::UseTime + // 0x00555610 — a stick not re-issued by a fresh server arm + // within 1 s tears itself down). No-op while nothing is stuck. + rm.Host?.PositionManager.UseTime(); } // ── Get per-part (origin, orientation) from either sequencer or legacy ── @@ -12527,6 +12698,15 @@ public sealed class GameWindow : IDisposable ? AcDream.Core.Physics.MovementType.TurnToObject : AcDream.Core.Physics.MovementType.MoveToObject, }; + // R5-V3 (#171, diff-review find): retail resolves the TARGET's + // PartArray radius/height at EVERY MoveToObject call site — the + // client-initiated use flow included (CPhysicsObj::MoveToObject + // 0x005128e9/0x00512903), not just the wire mt-6 route. Without + // this, the player's now-real own radius made the UseSpheres + // arrival hybrid (centerDist − playerRadius ≤ useRadius) — the + // auto-walk stopped ~one player-radius farther out than the AP-23 + // constants intended, and the two MoveToObject sites disagreed. + (ms.Radius, ms.Height) = GetSetupCylinder(targetGuid, entity); // Part of this install's AP-23 adaptation: store the autonomy flag // exactly as the wire mt-6 this install anticipates would (the P1 // unpack store, IsAutonomous=false) — without it the per-tick @@ -13091,8 +13271,9 @@ public sealed class GameWindow : IDisposable // remotes force-assert Contact+OnWalkable every grounded tick, so // OnWalkable was equivalent there); (c) isInterpolating is false — // the local player has no InterpolationManager. Own radius/height - // stay 0 for parity with the V4 remote bind (P4 note: setup - // cylsphere lands with R5's TargetManager port). setHeading's + // are the real setup cylsphere values (R5-V3 — lazy reads because + // this method caches the player Setup a few paragraphs further + // down). setHeading's // `send` flag is currently UNCONSUMED (register TS-33): the AP // heartbeat diffs position/plane/cell but not orientation (retail's // Frame::is_equal compares the full frame), so a stationary heading @@ -13111,8 +13292,8 @@ public sealed class GameWindow : IDisposable getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw), setHeading: (h, _) => pcMoveTo.Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h), - getOwnRadius: () => 0f, - getOwnHeight: () => 0f, + getOwnRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, + getOwnHeight: () => GetSetupCylinder(_playerServerGuid, playerEntity).Height, contact: () => pcMoveTo.BodyInContact, isInterpolating: () => false, getVelocity: () => pcMoveTo.BodyVelocity, @@ -13139,7 +13320,7 @@ public sealed class GameWindow : IDisposable ? pSelf.Position : pcMoveTo.Position, pcMoveTo.BodyOrientation), getVelocity: () => pcMoveTo.BodyVelocity, - getRadius: () => 0f, // R5-V3: real setup cylsphere radius + getRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, inContact: () => pcMoveTo.BodyInContact, minterpMaxSpeed: () => pcMoveTo.Motion.GetMaxSpeed(), curTime: () => pcMoveTo.SimTimeSeconds, @@ -13166,6 +13347,19 @@ public sealed class GameWindow : IDisposable }; _playerController.MoveTo = playerMoveTo; + // R5-V3 (#171, retires TS-39 — player side): bind the sticky seams to + // the player host's PositionManager (same trio as the remote bind in + // EnsureRemoteMotionBindings: BeginNextNode arrival StickTo + // @0x00529d3a, PerformMovement-head Unstick, UM-funnel-head + // unstick_from_object 0x0050eaea) and hand the facade to the + // controller, which drives AdjustOffset/UseTime at the retail + // UpdatePositionInternal/UpdateObjectInternal points. + playerMoveTo.StickTo = (tlid, radius, height) => + playerHost.PositionManager.StickTo(tlid, radius, height); + playerMoveTo.Unstick = playerHost.PositionManager.UnStick; + _playerController.Motion.UnstickFromObject = playerHost.PositionManager.UnStick; + _playerController.PositionManager = playerHost.PositionManager; + // TS-36 RETIRED: the interp's interrupt seam is retail's // interrupt_current_movement → MovementManager::CancelMoveTo(0x36) // chain (raw 278189-278200). Every DoMotion/StopMotion/ diff --git a/src/AcDream.Core/Physics/Motion/StickyManager.cs b/src/AcDream.Core/Physics/Motion/StickyManager.cs index e46ead5e..0d96e388 100644 --- a/src/AcDream.Core/Physics/Motion/StickyManager.cs +++ b/src/AcDream.Core/Physics/Motion/StickyManager.cs @@ -136,7 +136,9 @@ public sealed class StickyManager if (TargetId == 0) return; - if (_host.CurTime >= StickyTimeoutTime) + // Strictly AFTER the deadline (retail 0x00555626 `test ah,0x41` — + // C0|C3 clear = cur_time > timeout; ACE `>` too), not >=. + if (_host.CurTime > StickyTimeoutTime) { TargetId = 0; Initialized = false; diff --git a/tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs index 5b9312ee..6e2bdc62 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs @@ -104,7 +104,9 @@ public sealed class PositionManagerFacadeTests self.CurTime = 100.0; pm.StickTo(target.Id, 0.5f, 1.0f); // deadline 101 - self.CurTime = 101.0; + // Strictly past the deadline (retail 0x00555626 keeps the stick AT + // the deadline; teardown is `>` — ACE too). + self.CurTime = 101.001; pm.UseTime(); Assert.Equal(0u, pm.GetStickyObjectId()); // unstuck diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs index b26a3eb0..43101d16 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs @@ -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; + /// 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). + public readonly PositionManager Pm; + private readonly CreatureHost _creatureHost; + private readonly TargetHost _playerHost; + /// GameWindow's RemoteMotion.ObservedOmega twin. 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; + } + + /// + /// 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. + /// + [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()); + } + + /// + /// 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. + /// + [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}"); + } } diff --git a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs index 7e5e31f7..aecaec49 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs @@ -106,15 +106,22 @@ public sealed class StickyManagerTests } [Fact] - public void UseTime_AtDeadline_UnsticksAndInterrupts() + 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 - int interruptsBefore = self.InterruptCurrentMovementCalls; + // 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);