fix(#170): armed moveto always ticks UseTime — the SERVERVEL branch starved the chase

The "sustain the run" residual. The handoff's "Ready stop-node backlog
drains a beat slower than retail" framing was DISPROVEN: a new full-stack
offline harness (RemoteChaseEndToEndHarnessTests — real MoveToManager +
MotionInterpreter + AnimationSequencer + MotionTableDispatchSink + the
manual omega integration, wired field-for-field like
EnsureRemoteMotionBindings and ticked in TickAnimations' exact phase
order) proves the Core turn/run/drain pipeline healthy: the chase turn
completes in <1 s both directions, BeginMoveForward installs per arm, the
run sustains across re-arms and attack swings, and pending_motions fully
empties (retail cdb invariant add_to_queue == MotionDone).

The real mechanism (launch-drainq.log, corrected per-guid attribution —
the previous session's timeline mis-attributed [mvto] lines that fire in
the network phase): funnel per chasing scamp was 16 mt-6 arms -> 11
dispatched turns -> ONE BeginMoveForward. Any NPC receiving
UpdatePositions gets HasServerVelocity=true (synthesized from position
deltas even when the wire carries no velocity), and the grounded per-tick
branch routed those to the SERVERVEL leg, which SKIPS
MoveToManager.UseTime — [npc-tick] literally logged
"branch=SERVERVEL (skips UseTime) mtState=MoveToObject". The armed
moveto was starved for exactly the duration of the server-side chase:
legs stayed in Ready while the body glided on synthesized velocity (the
#170 slide); the manager only woke in UP-silent gaps (creature stopped
server-side) and its stale-heading turn was interrupted by the next UM
before reaching BeginMoveForward.

Retail runs MovementManager::UseTime UNCONDITIONALLY every tick
(CPhysicsObj::UpdateObjectInternal 0x005156b0, call @0x00515998) and has
no wire-velocity leg-driver anywhere; between UPs a moveto-driven body
translates from the motion state (get_state_velocity) with UP hard-snaps
correcting drift. Fix: an armed moveto (MovementTypeState != Invalid)
always takes the MOVETO leg; SERVERVEL remains only as the legacy
fallback for entities without a moveto (scripted paths / missiles).

Register: TS-41 (the narrowed SERVERVEL stopgap), TS-42 (drain-order
divergence also pinned this session: acdream drains AnimDone->MotionDone
AFTER HandleTargetting/MoveTo.UseTime; retail's process_hooks
@0x00512d3d runs BEFORE TargetManager/MovementManager in
UpdateObjectInternal — one frame of extra latency, R6 scope).

New conformance: RemoteChaseEndToEndHarnessTests (3 scenarios + theory)
+ RemoteChaseDrainBisectTests (the drain-chain pin; its first run also
demonstrated the TS-40 InWorld=false link-strip wedge shape — harness
bodies must replicate the live RemoteMotion construction).

ISSUES #170 updated (awaiting user visual gate; probes stay until then);
handoff doc superseded-note added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-04 21:59:59 +02:00
parent e744192361
commit 1051fc83c6
6 changed files with 811 additions and 20 deletions

View file

@ -48,25 +48,33 @@ Copy this block when adding a new issue:
## #170 — Remote creature chase+attack renders wrong vs retail (glide, over-frequency, uniform attack anims) ## #170 — Remote creature chase+attack renders wrong vs retail (glide, over-frequency, uniform attack anims)
**Status:** OPEN — **PARTIAL FIX landed `427332ac` (2026-07-04); residual = "sustain the run".** **Status:** OPEN — **residual fix landed 2026-07-04 ("sustain the run" — SERVERVEL
Root-caused end-to-end with live retail cdb tracing + acdream probes. The chase run starvation); AWAITING VISUAL GATE.** Fix #1 (`427332ac`): the per-frame
cycle is manufactured client-side from mt-6 (`HandleUpdateTarget → MoveToObject_Internal `apply_current_movement` re-dispatch flooded `pending_motions` to ~1.3M → deleted;
→ TurnToHeading node completes via UseTime → BeginMoveForward → RunForward substate`); flood 1.3M→~1, "stuck attack" gone (user-confirmed), run installs 1→10. Fix #2 (this
`BeginTurnToHeading` bails while `CMotionInterp.motions_pending` is non-empty session): **the "slow Ready drain" framing was WRONG** — a full-stack offline harness
(retail-faithful). acdream's `pending_motions` EXPLODED to ~1.3M entries because the (`RemoteChaseEndToEndHarnessTests`, real MoveToManager + MotionInterpreter +
NPC per-tick called `apply_current_movement` EVERY FRAME (re-dispatching stance+attack+ AnimationSequencer + omega integration in GameWindow's exact tick order) proved the
stops), so `MotionsPending()` stayed permanently true → the chase turn never started → Core drain/turn/run pipeline healthy (turn completes <1 s, run sustains, add==done).
no RunForward → slide. FIX = delete the per-frame `apply_current_movement` (GameWindow Corrected per-guid log attribution (launch-drainq.log) showed the REAL funnel: 16 arms
~9992; retail dispatches per UM, `add_to_queue==MotionDone`). VERIFIED: flood 1.3M→~1, → 11 turns dispatched → **1 run install**, because the per-tick branch arbitration
"stuck attack" GONE (user-confirmed), run installs (BeginMoveForward 1→10). **RESIDUAL routed any UP-receiving NPC to the SERVERVEL leg (`HasServerVelocity` synthesized from
(next session):** still blocked motionsPending=True 94% by a `Ready` (0x41000003) position deltas) which **skips `MoveToManager.UseTime`** — the armed moveto was starved
stop-node backlog that drains a beat slower than retail (~10 run-starts vs retail 21 → for exactly the duration of the server-side chase (`[npc-tick] branch=SERVERVEL (skips
twitches+glides). Fix the R2/R3 `Ready`-stop DRAIN so acdream fully empties like retail UseTime) mtState=MoveToObject`), legs stayed Ready while the body glided on synthesized
(retail is the oracle — cdb the drain). **SSOT + pickup:** velocity; the manager only woke in UP-silent gaps and was interrupted by the next UM.
Retail runs `MovementManager::UseTime` UNCONDITIONALLY per tick
(`CPhysicsObj::UpdateObjectInternal` 0x005156b0 @0x00515998) and has no wire-velocity
leg-driver. FIX: an armed moveto (`MovementTypeState != Invalid`) always takes the
MOVETO leg; SERVERVEL remains only for non-moveto entities (register TS-41; drain-order
one-frame divergence also pinned + filed as TS-42). **SSOT + pickup:**
`docs/research/2026-07-04-170-creature-run-handoff.md` + `docs/research/2026-07-04-170-creature-run-handoff.md` +
`docs/research/2026-07-04-170-pickup-prompt.md`. SUPERSEDES the earlier `docs/research/2026-07-04-170-pickup-prompt.md` (residual sections superseded by this
MovementManager-coexistence hypothesis (`eb423fb7`, wrong) and the velocity fix entry). SUPERSEDES the earlier MovementManager-coexistence hypothesis (`eb423fb7`,
(`d2ccc80e`, correct but position-only). #159 was a red herring here. wrong shape — but the starvation IS a coexistence bug at the tick-arbitration altitude)
and keeps the `d2ccc80e` velocity fix. #159 was a red herring here. NEXT: user visual
gate (retail side-by-side, chase a fleeing player) → then strip the #170 probes
(`s_mvtoDiag`, `s_drainDiag`, `[npc-tick]`, `UM ↳ actions`) and close.
**Severity:** MEDIUM (visual — remote combat / aggro) **Severity:** MEDIUM (visual — remote combat / aggro)
**Filed:** 2026-07-03 (user retail side-by-side during the R5-V2 visual gate) **Filed:** 2026-07-03 (user retail side-by-side during the R5-V2 visual gate)
**Component:** animation, remote entity, combat **Component:** animation, remote entity, combat

View file

@ -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-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) |
## 4. Temporary stopgap (TS) — 36 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)
| # | 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 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -221,6 +221,8 @@ accepted-divergence entries (#96, #49, #50).
| 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-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-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-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-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) |
--- ---

View file

@ -1,5 +1,21 @@
# Handoff — #170 creature chase renders as slide (PARTIAL FIX landed; residual = "sustain the run") — 2026-07-04 # Handoff — #170 creature chase renders as slide (PARTIAL FIX landed; residual = "sustain the run") — 2026-07-04
> **⚠ SUPERSEDED (2026-07-04, second session): the residual is FIXED — pending visual gate.**
> The "Ready stop-node backlog drains a beat slower" framing below was DISPROVEN by a
> full-stack offline harness (`tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs`)
> plus corrected per-guid attribution of the launch-drainq.log evidence. The Core
> drain/turn/run machinery is healthy; both handoff hypotheses ((a) tick counts,
> (b) drain trigger rate) are moot. The real mechanism: the per-tick branch arbitration
> sent any UP-receiving NPC down the SERVERVEL leg, which **skips `MoveToManager.UseTime`**
> — the armed moveto was starved for the whole server-side chase (funnel: 16 arms →
> 11 dispatched turns → 1 run install), legs stayed Ready while the body glided on
> UP-synthesized velocity. Retail runs `MovementManager::UseTime` unconditionally
> (`UpdateObjectInternal` 0x005156b0 @0x00515998). Fix: armed movetos always take the
> MOVETO leg (GameWindow `TickAnimations`, `moveToArmed` gate; register TS-41 narrowed,
> TS-42 added for the one-frame drain-order divergence). Current status + next steps
> live in `docs/ISSUES.md` #170. This doc remains as the evidence record for the flood
> fix (`427332ac`) and the cdb apparatus.
Fresh session picks up HERE for #170. Worktree `vigorous-joliot-f0c3ad`, branch Fresh session picks up HERE for #170. Worktree `vigorous-joliot-f0c3ad`, branch
`claude/vigorous-joliot-f0c3ad`. Tree CLEAN at **`427332ac`** (the partial fix + `claude/vigorous-joliot-f0c3ad`. Tree CLEAN at **`427332ac`** (the partial fix +
env-gated probes). This session root-caused #170 end-to-end with **live retail cdb env-gated probes). This session root-caused #170 end-to-end with **live retail cdb

View file

@ -9957,7 +9957,34 @@ public sealed class GameWindow : IDisposable
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable | AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active; | AcDream.Core.Physics.TransientStateFlags.Active;
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity) // #170 residual fix (2026-07-04): an ARMED moveto always
// takes the MOVETO branch. The old arbitration routed the
// tick to SERVERVEL whenever UPs flowed (position-delta
// synthesis sets HasServerVelocity=true for any moving
// NPC), which SKIPPED MoveToManager.UseTime for exactly
// the duration of the server-side chase — the armed
// manager only ever ticked in UP-silent gaps (creature
// stopped server-side), turned toward a stale heading,
// and was interrupted by the next UM before reaching
// BeginMoveForward. Live funnel (launch-drainq.log,
// corrected per-guid attribution): 16 arms → 11 turns
// dispatched → 1 run install; [npc-tick] shows
// branch=SERVERVEL (skips UseTime) mtState=MoveToObject
// for chasing scamps. The legs stayed in Ready while the
// body glided on synthesized velocity — the #170 slide.
// Retail runs MovementManager::UseTime UNCONDITIONALLY
// per tick (CPhysicsObj::UpdateObjectInternal 0x005156b0,
// call at 0x00515998) and has NO wire-velocity leg-driver;
// between UPs a moveto-driven body translates from the
// motion state (get_state_velocity) with UP hard-snaps
// correcting drift. The SERVERVEL leg remains ONLY as the
// legacy fallback for entities WITHOUT an armed moveto
// (scripted-path NPCs / missiles carrying wire velocity).
// Full-stack conformance: RemoteChaseEndToEndHarnessTests
// (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)
{ {
if (Environment.GetEnvironmentVariable("ACDREAM_MVTO_DIAG") == "1") if (Environment.GetEnvironmentVariable("ACDREAM_MVTO_DIAG") == "1")
Console.WriteLine(System.FormattableString.Invariant( Console.WriteLine(System.FormattableString.Invariant(

View file

@ -0,0 +1,105 @@
using System;
using System.Linq;
using System.Text;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics.Motion;
// ─────────────────────────────────────────────────────────────────────────────
// #170 drain-chain conformance: a stance-change UM's completion must flow
// CSequence link-anim completion → AnimDone hook → ConsumePendingHooks →
// MotionTableManager.AnimationDone countdown → MotionDoneTarget →
// CMotionInterp.MotionDone pop, fully emptying BOTH queues (retail cdb
// invariant: add_to_queue == MotionDone).
//
// History: the first harness run wedged here EXACTLY like the live #170
// signature ([drainq] q=[0x8000003C ...] stuck) — because the harness body
// was unseeded (InWorld=false), so the TS-40 detached-object guard stripped
// every dispatched transition link while the manager kept counting its ticks.
// With the live-faithful RemoteMotion construction the chain drains in ~1 s.
// Kept as the regression pin for that whole chain (and as the canonical
// demonstration of what a link-strip-without-tick-zeroing wedge looks like).
// ─────────────────────────────────────────────────────────────────────────────
public sealed class RemoteChaseDrainBisectTests
{
private readonly ITestOutputHelper _out;
public RemoteChaseDrainBisectTests(ITestOutputHelper output) => _out = output;
[Fact]
public void StanceChange_DrainChain_TickByTick()
{
var h = new RemoteChaseHarness();
// settle spawn
for (int i = 0; i < 30; i++) h.Tick();
DumpState(h, "pre-stance");
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
DumpState(h, "post-UM (t=0)");
for (int i = 1; i <= 90; i++)
{
TickWithHookTrace(h, i);
if (i % 6 == 0 || i <= 3)
DumpState(h, $"tick {i} (t={i / 60f:F2})");
if (!h.Interp.MotionsPending() && h.Seq.Manager.PendingAnimations.Any() == false)
{
DumpState(h, $"DRAINED at tick {i}");
return; // chain healthy
}
}
DumpState(h, "END — still wedged");
Assert.Fail("drain chain wedged after stance UM — see output for where");
}
private void TickWithHookTrace(RemoteChaseHarness h, int i)
{
// Replicate RemoteChaseHarness.Tick but with hook visibility: we call
// the same phases, intercepting the hook list.
h.Now += RemoteChaseHarness.Dt;
h.Mgr.UseTime();
if (h.Body.OnWalkable)
h.Body.set_local_velocity(h.Interp.get_state_velocity(), false);
h.Seq.Advance(RemoteChaseHarness.Dt);
var hooks = h.Seq.ConsumePendingHooks();
if (hooks.Count > 0)
{
var sb = new StringBuilder();
sb.Append(FormattableString.Invariant($"tick {i}: hooks=["));
for (int k = 0; k < hooks.Count; k++)
{
if (k > 0) sb.Append(", ");
sb.Append(hooks[k]?.GetType().Name ?? "null");
}
sb.Append(']');
_out.WriteLine(sb.ToString());
}
for (int k = 0; k < hooks.Count; k++)
{
if (hooks[k] is DatReaderWriter.Types.AnimationDoneHook)
h.Seq.Manager.AnimationDone(success: true);
}
h.Seq.Manager.UseTime();
}
private void DumpState(RemoteChaseHarness h, string tag)
{
var mgrQ = string.Join(" ", h.Seq.Manager.PendingAnimations
.Select(p => FormattableString.Invariant($"(0x{p.Motion:X8},t={p.NumAnims})")));
var core = h.Seq.Core;
var seqList = new StringBuilder();
for (var n = core.AnimList.First; n is not null; n = n.Next)
{
if (seqList.Length > 0) seqList.Append(',');
seqList.Append(FormattableString.Invariant($"fr={n.Value.Framerate:F0}"));
if (ReferenceEquals(n, core.FirstCyclicNode)) seqList.Append('*');
if (ReferenceEquals(n, core.CurrAnimNode)) seqList.Append('^');
}
_out.WriteLine(FormattableString.Invariant(
$"[{tag}] interpPending={h.Interp.MotionsPending()} mgrQ=[{mgrQ}] counter={h.Seq.Manager.AnimationCounter} seq=[{seqList}] frame={core.FrameNumber:F1} substate=0x{h.Seq.Manager.State.Substate:X8} style=0x{h.Seq.Manager.State.Style:X8}"));
}
}

View file

@ -0,0 +1,633 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Text;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Xunit;
using Xunit.Abstractions;
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
using CorePosition = AcDream.Core.Physics.Position;
namespace AcDream.Core.Tests.Physics.Motion;
// ─────────────────────────────────────────────────────────────────────────────
// #170 "sustain the run" — full-stack remote-chase harness.
//
// The R4-V2 MoveToManagerHarness scripts Heading directly (setHeading writes
// the scalar) and drains pending_motions synthetically, so the two legs where
// the live #170 residual actually lives have ZERO coverage:
//
// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink.
// TurnApplied → ObservedOmega → per-tick quaternion integration →
// MoveToMath.GetHeading → HandleTurnToHeading's HeadingGreater test;
// 2. the REAL drain: MotionTableManager pending_animations countdown fed by
// CSequence AnimDone hooks (link-anim completions), popping
// CMotionInterp.pending_motions via the MotionDoneTarget seam.
//
// This harness wires a real MotionInterpreter + AnimationSequencer +
// MotionTableDispatchSink + MoveToManager EXACTLY like GameWindow's
// EnsureRemoteMotionBindings (GameWindow.cs:4251) and ticks them in
// GameWindow.TickAnimations' per-entity order for the grounded branch=MOVETO
// path (GameWindow.cs:9697 HandleTargetting → 9994 TickRemoteMoveTo →
// 10024 get_state_velocity refresh → 10050 manual omega integration →
// 10247 Sequencer.Advance → 10306 AnimationDone per AnimDoneHook →
// 10309 Manager.UseTime). Wire events (aggro stance UM, mt-6 arms, attack
// UMs) replay the exact live sequence captured in launch-drainq.log
// (2026-07-04, guid 0x80000BE5, Mite Scamp chasing the fleeing player).
//
// Retail acceptance bar (live cdb, 2026-07-04 session): BeginMoveForward ≈
// MoveToObject arms (21/22), pending_motions add == MotionDone exactly, the
// chase turn completes within a couple of seconds and the run SUSTAINS
// between attack swings.
// ─────────────────────────────────────────────────────────────────────────────
internal sealed class ChaseLoader : IAnimationLoader
{
private readonly Dictionary<uint, Animation> _anims = new();
public void Register(uint id, Animation anim) => _anims[id] = anim;
public Animation? LoadAnimation(uint id) =>
_anims.TryGetValue(id, out var a) ? a : null;
}
/// <summary>
/// The full-stack per-entity pipeline replica. Field-for-field mirror of the
/// GameWindow wiring for one grounded remote NPC (branch=MOVETO), plus a
/// scripted stand-in for the TargetManager voyeur delivery (SetTarget →
/// synchronous first delivery + one delivery per quantum thereafter — the
/// live cadence observed in launch-drainq.log: HandleUpdateTarget directly
/// after the arm, then sparse).
/// </summary>
internal sealed class RemoteChaseHarness
{
// Styles / substates (full command words, as the wire produces them).
public const uint NonCombat = 0x8000003Du;
public const uint Combat = 0x8000003Cu; // the aggro stance in the live capture
public const uint Ready = 0x41000003u;
public const uint Walk = 0x45000005u;
public const uint Run = 0x44000007u;
public const uint TurnRight = 0x6500000Du;
public const uint AttackAction = 0x10000063u; // one of the live scamp swings
public const uint CreatureGuid = 0x80000BE5u;
public const uint PlayerGuid = 0x5000000Au;
public const float Dt = 1f / 60f;
// 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
// every dispatched transition link), and the R4-V5 #160 RemoteWeenie
// (null weenie degrades run-rate to 1.0).
public readonly PhysicsBody Body = new()
{
State = PhysicsStateFlags.ReportCollisions,
TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable
| TransientStateFlags.Active,
InWorld = true,
};
public readonly MotionInterpreter Interp;
public readonly AnimationSequencer Seq;
public readonly MotionTableDispatchSink Sink;
public readonly MoveToManager Mgr;
/// <summary>GameWindow's RemoteMotion.ObservedOmega twin.</summary>
public Vector3 ObservedOmega;
/// <summary>Scripted player (chase target) world position.</summary>
public Vector3 PlayerPos;
public Vector3 PlayerVelocity;
// Scripted targeting stand-in (GameWindow: EntityPhysicsHost/TargetManager).
private bool _targetArmed;
private double _lastDeliveryTime = double.NegativeInfinity;
private double _quantum = 0.5;
public double Now;
// Counters (the live-probe equivalents).
public int BeginTurnBlocked;
public int BeginTurnUnblocked;
public int RunInstalls; // substate transitions INTO RunForward/Walk fwd
public int TicksInRun;
public int TicksInWalkFwd;
public int TotalTicks;
public int MaxRunStreak;
private int _runStreak;
private uint _prevSubstate;
public readonly List<string> Trace = new();
private readonly ITestOutputHelper? _log;
public RemoteChaseHarness(ITestOutputHelper? log = null)
{
_log = log;
var (setup, mtable, loader) = BuildFixture();
Seq = new AnimationSequencer(setup, mtable, loader);
// ── GameWindow spawn path (OnLiveEntitySpawnedLocked ~3781) ──
Seq.InitializeState();
Seq.SetCycle(NonCombat, Ready);
Body.Orientation = MoveToMath.SetHeading(Quaternion.Identity, 0f); // face North
Interp = new MotionInterpreter(Body)
{
WeenieObj = new RemoteWeenie(),
};
// ── EnsureRemoteMotionBindings (GameWindow.cs:4251) verbatim ──
Sink = new MotionTableDispatchSink(Seq)
{
TurnApplied = (turnMotion, turnSpeed) =>
{
float signed = (turnMotion & 0xFFu) == 0x0E
? -MathF.Abs(turnSpeed)
: turnSpeed;
ObservedOmega = new Vector3(0f, 0f, -(MathF.PI / 2f) * signed);
},
TurnStopped = () => ObservedOmega = Vector3.Zero,
};
Interp.DefaultSink = Sink;
Interp.RemoveLinkAnimations = Seq.RemoveAllLinkAnimations;
Interp.InitializeMotionTables = () => Seq.Manager.InitializeState();
Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions;
Mgr = new MoveToManager(
Interp,
stopCompletely: () => Interp.StopCompletely(),
getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation),
getHeading: () => MoveToMath.GetHeading(Body.Orientation),
setHeading: (h, _) => Body.Orientation =
MoveToMath.SetHeading(Body.Orientation, h),
getOwnRadius: () => 0f,
getOwnHeight: () => 0f,
contact: () => Body.OnWalkable,
isInterpolating: () => false,
getVelocity: () => Body.Velocity,
getSelfId: () => CreatureGuid,
setTarget: (ctx, tlid, radius, q) =>
{
_targetArmed = tlid == PlayerGuid;
// TargetManager delivers the FIRST info synchronously on
// SetTarget (live log: HandleUpdateTarget printed directly
// after the arm, same network phase).
DeliverTargetInfo();
},
clearTarget: () => _targetArmed = false,
getTargetQuantum: () => _quantum,
setTargetQuantum: q => _quantum = q,
curTime: () => Now);
Interp.InterruptCurrentMovement =
() => Mgr.CancelMoveTo(WeenieError.ActionCancelled);
// ── The anim-loop MotionDone binding (GameWindow.cs:10266) ──
Seq.MotionDoneTarget = (m, ok) => Interp.MotionDone(m, ok);
_prevSubstate = Seq.Manager.State.Substate;
}
// ── Wire events ─────────────────────────────────────────────────────────
/// <summary>mt-0 UM (funnel apply): interrupt + unstick head, then
/// MoveToInterpretedState — GameWindow.cs:4893 + 5008.</summary>
public void UmInterpreted(uint stance, uint forward, float forwardSpeed = 1f,
params InboundMotionAction[] actions)
{
Interp.InterruptCurrentMovement?.Invoke();
Interp.UnstickFromObject?.Invoke();
var ims = new InboundInterpretedState
{
CurrentStyle = stance,
ForwardCommand = forward,
ForwardSpeed = forwardSpeed,
SideStepCommand = 0u,
SideStepSpeed = 1f,
TurnCommand = 0u,
TurnSpeed = 1f,
};
if (actions.Length > 0)
ims.Actions = new List<InboundMotionAction>(actions);
Interp.MoveToInterpretedState(ims, Sink);
}
/// <summary>mt-6 UM (MoveToObject arm): interrupt + unstick head, then the
/// RouteServerMoveTo MovementStruct — GameWindow.cs:4483-4508. Params match
/// the live scamp chase (spd 2.08, threshold 15, object distance 0.6).</summary>
public void UmMoveToObject(
float speed = 2.08f,
float distanceToObject = 0.6f,
float walkRunThreshold = 15f)
{
Interp.InterruptCurrentMovement?.Invoke();
Interp.UnstickFromObject?.Invoke();
var mp = new MovementParameters
{
CanWalk = true,
CanRun = true,
CanCharge = true,
CanSidestep = false,
CanWalkBackwards = false,
Speed = speed,
DistanceToObject = distanceToObject,
WalkRunThreshhold = walkRunThreshold,
FailDistance = float.MaxValue,
UseSpheres = false,
};
var ms = new MovementStruct
{
Type = MovementType.MoveToObject,
ObjectId = PlayerGuid,
TopLevelId = PlayerGuid,
Pos = new CorePosition(1u, PlayerPos, Quaternion.Identity),
Params = mp,
};
Mgr.PerformMovement(ms);
}
private void DeliverTargetInfo()
{
if (!_targetArmed) return;
_lastDeliveryTime = Now;
var pos = new CorePosition(1u, PlayerPos, Quaternion.Identity);
Mgr.HandleUpdateTarget(new TargetInfo
{
ObjectId = PlayerGuid,
Status = TargetStatus.Ok,
TargetPosition = pos,
InterpolatedPosition = pos,
});
}
// ── The per-tick pipeline (GameWindow.TickAnimations order) ────────────
public void Tick()
{
Now += Dt;
TotalTicks++;
// Player (chase target) moves per its scripted velocity.
PlayerPos += PlayerVelocity * Dt;
// 1. Voyeur delivery (player host HandleTargetting → this entity's
// HandleUpdateTarget; GameWindow.cs:8094 runs before the per-remote
// loop, 9697 in-loop).
if (_targetArmed && Now - _lastDeliveryTime >= _quantum)
DeliverTargetInfo();
// 2. MoveToManager drive (TickRemoteMoveTo, GameWindow.cs:9994).
Mgr.UseTime();
// 3. get_state_velocity → body velocity (the d2ccc80e refresh,
// GameWindow.cs:10024).
if (Body.OnWalkable)
Body.set_local_velocity(Interp.get_state_velocity(), false);
// 4. Manual omega integration (GameWindow.cs:10050-10058 verbatim).
if (ObservedOmega.LengthSquared() > 1e-8f)
{
float omegaMag = ObservedOmega.Length();
var axis = ObservedOmega / omegaMag;
float angle = omegaMag * Dt;
var deltaRot = Quaternion.CreateFromAxisAngle(axis, angle);
Body.Orientation = Quaternion.Normalize(
Quaternion.Multiply(Body.Orientation, deltaRot));
}
// 5. Position integration (UpdatePhysicsInternal, simplified: grounded,
// no gravity participation for this scenario).
Body.Position += Body.Velocity * Dt;
// 6. Anim loop (GameWindow.cs:10247-10309): advance, drain AnimDone
// hooks into the manager countdown, zero-tick sweep.
Seq.Advance(Dt);
var hooks = Seq.ConsumePendingHooks();
for (int i = 0; i < hooks.Count; i++)
{
if (hooks[i] is DatReaderWriter.Types.AnimationDoneHook)
Seq.Manager.AnimationDone(success: true);
}
Seq.Manager.UseTime();
// ── Observables ──
uint substate = Seq.Manager.State.Substate;
if (substate == Run) TicksInRun++;
if (substate == Walk) TicksInWalkFwd++;
bool inFwd = substate == Run || substate == Walk;
if (inFwd)
{
_runStreak++;
if (_runStreak > MaxRunStreak) MaxRunStreak = _runStreak;
}
else
{
_runStreak = 0;
}
if (inFwd && _prevSubstate != Run && _prevSubstate != Walk)
RunInstalls++;
_prevSubstate = substate;
}
public float Heading => MoveToMath.GetHeading(Body.Orientation);
public float DistToPlayer => Vector3.Distance(Body.Position, PlayerPos);
public void Snapshot(string tag)
{
string line = FormattableString.Invariant(
$"t={Now,6:F2} {tag,-14} mt={Mgr.MovementTypeState,-14} substate=0x{Seq.Manager.State.Substate:X8} heading={Heading,6:F1} dist={DistToPlayer,6:F2} pending={Interp.MotionsPending()} omega.Z={ObservedOmega.Z,6:F3}");
Trace.Add(line);
_log?.WriteLine(line);
}
// ── Fixture ─────────────────────────────────────────────────────────────
private static Animation MakeAnim(int numFrames)
{
var anim = new Animation();
for (int f = 0; f < numFrames; f++)
{
var pf = new AnimationFrame(1u);
pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
anim.PartFrames.Add(pf);
}
return anim;
}
private static MotionData MakeMd(uint animId, float framerate = 30f,
Vector3? velocity = null, Vector3? omega = null)
{
var md = new MotionData();
QualifiedDataId<Animation> qid = animId;
md.Anims.Add(new AnimData
{
AnimId = qid,
LowFrame = 0,
HighFrame = -1,
Framerate = framerate,
});
if (velocity is { } v)
{
md.Velocity = v;
md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasVelocity;
}
if (omega is { } o)
{
md.Omega = o;
md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasOmega;
}
return md;
}
private static void AddLink(MotionTable mt, uint style, uint from, uint to, MotionData md)
{
int outer = (int)((style << 16) | (from & 0xFFFFFFu));
if (!mt.Links.TryGetValue(outer, out var cmd))
{
cmd = new MotionCommandData();
mt.Links[outer] = cmd;
}
cmd.MotionData[(int)to] = md;
}
/// <summary>
/// Two-stance creature table shaped like the Mite Scamp's: NonCombat +
/// Combat styles (default substate Ready in both), Walk/Run cycles with
/// entry/exit links (15 frames @30fps = 0.5 s links, the realistic stop/
/// start durations), a stance-transition link, an attack action link, and
/// a global TurnRight physics-only modifier.
/// </summary>
private static (Setup, MotionTable, ChaseLoader) BuildFixture()
{
const uint ReadyAnimNC = 0x200u;
const uint ReadyAnimC = 0x201u;
const uint WalkAnim = 0x202u;
const uint RunAnim = 0x203u;
const uint ReadyToWalk = 0x204u;
const uint WalkToReady = 0x205u;
const uint ReadyToRun = 0x206u;
const uint RunToReady = 0x207u;
const uint StanceLink = 0x208u; // NonCombat.Ready → Combat (draw)
const uint AttackLink = 0x209u; // Combat.Ready → attack swing
const uint TurnAnim = 0x20Au;
var setup = new Setup();
setup.Parts.Add(0x01000000u);
setup.DefaultScale.Add(Vector3.One);
var loader = new ChaseLoader();
loader.Register(ReadyAnimNC, MakeAnim(30));
loader.Register(ReadyAnimC, MakeAnim(30));
loader.Register(WalkAnim, MakeAnim(30));
loader.Register(RunAnim, MakeAnim(30));
loader.Register(ReadyToWalk, MakeAnim(15));
loader.Register(WalkToReady, MakeAnim(15));
loader.Register(ReadyToRun, MakeAnim(15));
loader.Register(RunToReady, MakeAnim(15));
loader.Register(StanceLink, MakeAnim(15));
loader.Register(AttackLink, MakeAnim(45)); // 1.5 s swing
loader.Register(TurnAnim, MakeAnim(30));
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombat };
mt.StyleDefaults[(DRWMotionCommand)NonCombat] = (DRWMotionCommand)Ready;
mt.StyleDefaults[(DRWMotionCommand)Combat] = (DRWMotionCommand)Ready;
static int CycleKey(uint style, uint substate)
=> (int)((style << 16) | (substate & 0xFFFFFFu));
mt.Cycles[CycleKey(NonCombat, Ready)] = MakeMd(ReadyAnimNC);
mt.Cycles[CycleKey(Combat, Ready)] = MakeMd(ReadyAnimC);
foreach (uint style in new[] { NonCombat, Combat })
{
mt.Cycles[CycleKey(style, Walk)] =
MakeMd(WalkAnim, velocity: new Vector3(0f, 3.12f, 0f));
mt.Cycles[CycleKey(style, Run)] =
MakeMd(RunAnim, velocity: new Vector3(0f, 4.0f, 0f));
AddLink(mt, style, Ready, Walk, MakeMd(ReadyToWalk));
AddLink(mt, style, Walk, Ready, MakeMd(WalkToReady));
AddLink(mt, style, Ready, Run, MakeMd(ReadyToRun));
AddLink(mt, style, Run, Ready, MakeMd(RunToReady));
}
AddLink(mt, NonCombat, Ready, Combat, MakeMd(StanceLink));
AddLink(mt, Combat, Ready, NonCombat, MakeMd(StanceLink));
AddLink(mt, Combat, Ready, AttackAction, MakeMd(AttackLink));
// Global (unstyled) TurnRight modifier — physics-only in Branch 4.
mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd(TurnAnim);
return (setup, mt, loader);
}
}
public sealed class RemoteChaseEndToEndHarnessTests
{
private readonly ITestOutputHelper _out;
public RemoteChaseEndToEndHarnessTests(ITestOutputHelper output) => _out = output;
private const float Dt = RemoteChaseHarness.Dt;
private static int Seconds(float s) => (int)MathF.Round(s / Dt);
/// <summary>
/// The core chase cycle: aggro stance change, one mt-6 arm at a target
/// 90° off the creature's facing, 15 m away, stationary. Retail bar: the
/// stance links play out (~1 s), the chase turn starts, completes at the
/// turn rate (90° at π/2·2.08 rad/s ≈ 0.5 s), and BeginMoveForward
/// installs the forward cycle. Total budget: 4 s of ticks is generous.
/// </summary>
[Theory]
[InlineData(90f)] // target to the East → TurnRight path
[InlineData(270f)] // target to the West → TurnLeft path
[InlineData(170f)] // near-reversal → long right turn
public void SingleArm_TurnCompletes_AndForwardInstalls(float bearingDeg)
{
var h = new RemoteChaseHarness(_out);
float rad = (90f - bearingDeg) * MathF.PI / 180f; // compass → math angle
h.PlayerPos = new Vector3(MathF.Cos(rad), MathF.Sin(rad), 0f) * 15f;
h.PlayerVelocity = Vector3.Zero;
// settle spawn state
for (int i = 0; i < Seconds(0.5f); i++) h.Tick();
h.Snapshot("spawned");
// aggro: stance UM (the live capture's mt-0 stance=0x3C fwd=Ready)
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
for (int i = 0; i < Seconds(1.5f); i++) h.Tick();
h.Snapshot("post-stance");
// the arm
h.UmMoveToObject();
h.Snapshot("armed");
int installTick = -1;
for (int i = 0; i < Seconds(6f); i++)
{
h.Tick();
if (installTick < 0
&& (h.Seq.Manager.State.Substate == RemoteChaseHarness.Run
|| h.Seq.Manager.State.Substate == RemoteChaseHarness.Walk))
{
installTick = i;
h.Snapshot("fwd-install");
}
if (i % Seconds(0.5f) == 0) h.Snapshot("tick");
}
h.Snapshot("end");
Assert.True(installTick >= 0,
$"forward cycle never installed within 6 s of the arm (bearing {bearingDeg}°); " +
$"final: mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8} " +
$"heading={h.Heading:F1} pending={h.Interp.MotionsPending()} omega.Z={h.ObservedOmega.Z:F3}");
Assert.True(installTick <= Seconds(4f),
$"forward cycle took {installTick * Dt:F2} s to install (bearing {bearingDeg}°) — " +
"retail installs within the turn duration (~1-2 s)");
}
/// <summary>
/// The live failure scenario: the player FLEES at 4 m/s and ACE re-arms
/// mt-6 every 2 s (launch-drainq.log cadence). Retail bar: BeginMoveForward
/// ≈ one per arm (21/22 in the live cdb trace) and the run is SUSTAINED —
/// the forward substate holds for most of the chase.
/// </summary>
[Fact]
public void FleeingTarget_RunSustainedAcrossRearms()
{
var h = new RemoteChaseHarness(_out);
h.PlayerPos = new Vector3(0f, 10f, 0f); // dead ahead, 10 m
h.PlayerVelocity = new Vector3(0f, 4f, 0f); // fleeing straight away
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
for (int i = 0; i < Seconds(1.5f); i++) h.Tick();
int arms = 0;
int chaseTicks = Seconds(12f);
for (int i = 0; i < chaseTicks; i++)
{
if (i % Seconds(2f) == 0)
{
h.UmMoveToObject();
arms++;
h.Snapshot($"arm#{arms}");
}
h.Tick();
if (i % Seconds(1f) == 0) h.Snapshot("tick");
}
h.Snapshot("end");
int fwdTicks = h.TicksInRun + h.TicksInWalkFwd;
float fwdFraction = fwdTicks / (float)chaseTicks;
_out.WriteLine(FormattableString.Invariant(
$"arms={arms} installs={h.RunInstalls} fwdTicks={fwdTicks}/{chaseTicks} ({fwdFraction:P0}) maxStreak={h.MaxRunStreak * Dt:F2}s"));
Assert.True(h.RunInstalls >= arms - 1,
$"run installed only {h.RunInstalls}× across {arms} arms — " +
"retail reinstalls per arm (21/22)");
Assert.True(fwdFraction > 0.5f,
$"forward substate held only {fwdFraction:P0} of the chase — the run is not " +
"sustained (live #170 symptom: short bursts + idle glide)");
}
/// <summary>
/// Attack interleave: mid-chase, an attack UM (mt-0, action list carrying
/// the swing, fwd=Ready — the wire shape from the live capture) interrupts
/// the moveto (retail-faithful), the swing plays, and the next mt-6 re-arm
/// must reinstall the run. Also asserts the #170 drain criterion:
/// pending_motions fully empties after the swing (add == done — the retail
/// cdb invariant).
/// </summary>
[Fact]
public void AttackUm_ThenRearm_RunReinstalls_AndQueueDrains()
{
var h = new RemoteChaseHarness(_out);
h.PlayerPos = new Vector3(0f, 12f, 0f);
h.PlayerVelocity = Vector3.Zero;
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready);
for (int i = 0; i < Seconds(1.5f); i++) h.Tick();
h.UmMoveToObject();
for (int i = 0; i < Seconds(3f); i++) h.Tick();
h.Snapshot("chasing");
// the swing: interrupt + action (stamp 1, autonomous false)
h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready, 1f,
new InboundMotionAction(RemoteChaseHarness.AttackAction, Stamp: 1,
Autonomous: false, Speed: 1f));
for (int i = 0; i < Seconds(2.5f); i++) h.Tick();
h.Snapshot("post-swing");
Assert.False(h.Interp.MotionsPending(),
"pending_motions did not fully empty after the swing — the #170 " +
"residual (retail: add_to_queue == MotionDone exactly)");
// the player breaks away (out of attack range), then ACE re-arms —
// the run must come back
h.PlayerPos += new Vector3(0f, 10f, 0f);
h.UmMoveToObject();
int installTick = -1;
for (int i = 0; i < Seconds(5f); i++)
{
h.Tick();
if (installTick < 0
&& (h.Seq.Manager.State.Substate == RemoteChaseHarness.Run
|| h.Seq.Manager.State.Substate == RemoteChaseHarness.Walk))
{
installTick = i;
}
}
h.Snapshot("post-rearm");
Assert.True(installTick >= 0,
$"run did not reinstall after the post-swing re-arm; " +
$"mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8} " +
$"pending={h.Interp.MotionsPending()}");
}
}