feat(R4-V4): REMOTE cutover - per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED (closes M1-remote, M4/M5/M6/M8-remote; retires AD-8, AD-9, AP-8, AP-9)
Every remote's server-directed movement now runs the verbatim retail MoveToManager. EnsureRemoteMotionBindings constructs it per remote with the full V2 seam set (position/heading/velocity providers, the P4 TargetTracker adapter via setTarget/clearTarget storing the tracked guid on RemoteMotion) and binds InterruptCurrentMovement -> CancelMoveTo(0x36) - the retail interrupt chain (TS-36 remote side). UM routing is retail's unpack_movement dispatch (0x00524440): the head-interrupt + unstick fire for EVERY movement type, then mt 6/7 build MovementParameters.FromWire(raw bitfield - now surfaced by the parser) + a MovementStruct (mt 6 resolves the target guid against the entity table, degrading to MoveToPosition at the wire origin per the plan's 2f; my_run_rate written from MoveToRunRate per @300603) -> MoveTo.PerformMovement; mt 8/9 likewise via FromWireTurnTo; ONLY mt 0 flows through the interpreted funnel (the PlanMoveToStart seed is DELETED - retail never routes MoveTo types through the interpreted state copy). Per tick: the P4 tracker feeds HandleUpdateTarget(Ok/ExitWorld) from the live entity table, then UseTime runs the manager's steering/ arrival/fail handlers, then apply_current_movement recomputes velocity - the same shape ordinary locomotion uses. The legacy driver branches, the stale-destination timer, and the ClampApproachVelocity band-aid are gone; arrival now uses retail cylinder distance (watch melee-range stop distance in the visual pass). DELETED: RemoteMoveToDriver.cs + its tests (OriginToWorld relocated verbatim to MoveToMath; the B.6 auto-walk's two surviving constants inlined into PlayerMovementController, dying with it in V5); ServerControlledLocomotion.PlanMoveToStart + tests (PlanFromVelocity survives - new AP-80 row); the RemoteMotion MoveTo capture fields. Registers: AD-8/AD-9/AP-8/AP-9 retired; AP-79 (TargetTracker adapter, retire R5) + AP-80 added. Full suite green: 3,948 passed. Live smoke launched - NPC chase/wander behavior pending user verification (recorded in the session handoff). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a144e87318
commit
7016b26ce7
10 changed files with 248 additions and 883 deletions
|
|
@ -60,7 +60,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Adaptation (AD) — 35 rows
|
## 2. Adaptation (AD) — 33 rows
|
||||||
|
|
||||||
| # | 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 |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
@ -70,8 +70,6 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
|
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
|
||||||
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
|
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
|
||||||
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects` → `recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
|
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects` → `recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
|
||||||
| AD-8 | MoveTo arrival gate `max(minDistance, distanceToObject)`; retail tests `dist <= min_distance` only | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:161` | ACE ships the threshold in `distance_to_object` with `min_distance == 0`; without the max, monsters never "arrive" and oscillate at melee range (user-reported 2026-04-28) | A server using both wire fields with retail semantics + large `distance_to_object` makes remotes stop short of the retail arrival point | `MoveToManager::HandleMoveToPosition` chase-arrival |
|
|
||||||
| AD-9 | 1.5 s stale-destination give-up timer on remote MoveTo (retail's MoveToManager runs until cancelled) | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:136` | Liveness guard sized to ACE's ~1 Hz re-emit cadence; prevents steering toward a stale destination after a missed cancel (the run-in-place symptom) | A server emitting MoveTo slower than ~1.5 s makes remotes freeze mid-chase and snap later instead of steering continuously | MoveToManager (no equivalent timeout) |
|
|
||||||
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
|
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
|
||||||
| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 |
|
| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 |
|
||||||
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
|
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
|
||||||
|
|
@ -113,8 +111,6 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
|
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
|
||||||
| AP-6 | Analytic swept-sphere cylinder collision (XY overlap + step-over + wall-slide) instead of retail CylSphere functions via the 6-path dispatcher; A6.P6 step-over branch ports `step_sphere_up`'s clearance check | `src/AcDream.Core/Physics/TransitionTypes.cs:2601` | Claimed to match retail for the exercised cases (trunks, NPC bodies, door foot-colliders); step-over and step_up_slide fallback retro-fitted from retail when the door phantom surfaced | Unported branches (push direction, interpenetration resolution) differ from retail against cylinder entities — the phantom-collision / sticky-NPC family | `CCylSphere::step_sphere_up` pc:324516-324538 |
|
| AP-6 | Analytic swept-sphere cylinder collision (XY overlap + step-over + wall-slide) instead of retail CylSphere functions via the 6-path dispatcher; A6.P6 step-over branch ports `step_sphere_up`'s clearance check | `src/AcDream.Core/Physics/TransitionTypes.cs:2601` | Claimed to match retail for the exercised cases (trunks, NPC bodies, door foot-colliders); step-over and step_up_slide fallback retro-fitted from retail when the door phantom surfaced | Unported branches (push direction, interpenetration resolution) differ from retail against cylinder entities — the phantom-collision / sticky-NPC family | `CCylSphere::step_sphere_up` pc:324516-324538 |
|
||||||
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
|
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
|
||||||
| AP-8 | Remote MoveTo driver is a minimum viable subset: no target re-tracking, no sticky/StickTo, no fail-distance detector, no sphere-cylinder distance variant | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:44` | All server-side concerns the local body needn't model; ACE re-emits MoveTo ~1 Hz with refreshed origins, substituting for re-tracking | If the re-emit cadence assumption breaks (or sticky-follow packets appear), chase/flee motion visibly diverges — orbiting, overshoot, giving up where retail tracks | `MoveToManager::HandleMoveToPosition` 0x00529d80 |
|
|
||||||
| AP-9 | Fixed π/2 rad/s in-motion turn rate; per-creature TurnSpeed unwired | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:77` | Matches ACE's monster TurnSpeed default; field hook documented for the future port | Creatures with non-default turn speeds rotate at the wrong rate — facing-correction mismatch vs retail observers | run_turn_factor 0x007c8914; `apply_run_to_command` 0x00527be0 |
|
|
||||||
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
|
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
|
||||||
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
|
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
|
||||||
| AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) |
|
| AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) |
|
||||||
|
|
@ -182,6 +178,8 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 |
|
| AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 |
|
||||||
| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) |
|
| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) |
|
||||||
| AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) |
|
| AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) |
|
||||||
|
| AP-79 | **P4 TargetTracker minimal adapter**: MoveToManager's setTarget/clearTarget seams store the tracked guid on RemoteMotion; GameWindow's per-tick block feeds `HandleUpdateTarget(Ok)` from the live entity table whenever the target moved beyond the set_target radius (and `ExitWorld` when the entity despawns) — retail's TargetManager is a full subscription system with per-target quantums and callback scheduling (R4-V4, 2026-07-03) | `src/AcDream.App/Rendering/GameWindow.cs` (the tracker block in TickAnimations + the setTarget seam in EnsureRemoteMotionBindings) | The manager's deferred-start/retarget lifecycle only needs position deltas at the tracked radius; the adapter delivers exactly that from data the client already holds | Update cadence is frame-quantized rather than quantum-scheduled; a target moving sub-radius never re-fires (retail same); multi-listener semantics absent until R5's TargetManager port | `TargetManager::ReceiveUpdate` chain (r4-moveto-decomp.md); retire in R5 |
|
||||||
|
| 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) |
|
||||||
|
|
||||||
## 4. Temporary stopgap (TS) — 36 rows (TS-37 is a retired-row historical note, not an active count)
|
## 4. Temporary stopgap (TS) — 36 rows (TS-37 is a retired-row historical note, not an active count)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,12 @@ public sealed class PlayerMovementController
|
||||||
private bool _prevRunHeld;
|
private bool _prevRunHeld;
|
||||||
private int _prevAutoWalkTurnDir;
|
private int _prevAutoWalkTurnDir;
|
||||||
|
|
||||||
|
// R4-V4: relocated from the DELETED RemoteMoveToDriver — B.6 auto-walk's
|
||||||
|
// last two dependencies on it. Both die with auto-walk in R4-V5.
|
||||||
|
private const float AutoWalkArrivalEpsilon = 0.05f;
|
||||||
|
private static float AutoWalkTurnRateFor(bool running)
|
||||||
|
=> running ? (MathF.PI / 2.0f) * 1.5f : (MathF.PI / 2.0f); // BaseTurnRate x RunTurnFactor
|
||||||
|
|
||||||
// Heartbeat timer.
|
// Heartbeat timer.
|
||||||
// Cadence is 1.0 sec to match holtburger's
|
// Cadence is 1.0 sec to match holtburger's
|
||||||
// AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace
|
// AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace
|
||||||
|
|
@ -533,10 +539,10 @@ public sealed class PlayerMovementController
|
||||||
/// for the rest of <see cref="Update"/> to consume.
|
/// for the rest of <see cref="Update"/> to consume.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Heading correction matches <see cref="RemoteMoveToDriver.Drive"/>
|
/// Heading correction matches the deleted RemoteMoveToDriver.Drive
|
||||||
/// — ±<see cref="RemoteMoveToDriver.HeadingSnapToleranceRad"/>
|
/// — ±its 20-degree HeadingSnapTolerance
|
||||||
/// snap-on-aligned, otherwise rotate at
|
/// snap-on-aligned, otherwise rotate at
|
||||||
/// <see cref="RemoteMoveToDriver.TurnRateRadPerSec"/>. Arrival
|
/// pi/2 rad/s. Arrival
|
||||||
/// predicate matches retail's
|
/// predicate matches retail's
|
||||||
/// <c>MoveToManager::HandleMoveToPosition</c>: chase arrives at
|
/// <c>MoveToManager::HandleMoveToPosition</c>: chase arrives at
|
||||||
/// <c>distanceToObject</c>; flee arrives at <c>minDistance</c>.
|
/// <c>distanceToObject</c>; flee arrives at <c>minDistance</c>.
|
||||||
|
|
@ -618,7 +624,7 @@ public sealed class PlayerMovementController
|
||||||
(_autoWalkMoveTowards
|
(_autoWalkMoveTowards
|
||||||
&& dist <= arrivalThreshold)
|
&& dist <= arrivalThreshold)
|
||||||
|| (!_autoWalkMoveTowards
|
|| (!_autoWalkMoveTowards
|
||||||
&& dist >= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon);
|
&& dist >= arrivalThreshold + AutoWalkArrivalEpsilon);
|
||||||
|
|
||||||
// Step Yaw toward target. Convention from Update line 364:
|
// Step Yaw toward target. Convention from Update line 364:
|
||||||
// _body.Orientation = Quaternion.CreateFromAxisAngle(Z, Yaw - π/2),
|
// _body.Orientation = Quaternion.CreateFromAxisAngle(Z, Yaw - π/2),
|
||||||
|
|
@ -655,7 +661,7 @@ public sealed class PlayerMovementController
|
||||||
// run/walk decision (one-shot at chain start) drives the
|
// run/walk decision (one-shot at chain start) drives the
|
||||||
// turn rate: running rotation is 50% faster per
|
// turn rate: running rotation is 50% faster per
|
||||||
// run_turn_factor at retail 0x007c8914.
|
// run_turn_factor at retail 0x007c8914.
|
||||||
float maxStep = RemoteMoveToDriver.TurnRateFor(_autoWalkInitiallyRunning) * dt;
|
float maxStep = AutoWalkTurnRateFor(_autoWalkInitiallyRunning) * dt;
|
||||||
float yawStep = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
float yawStep = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
||||||
Yaw += yawStep;
|
Yaw += yawStep;
|
||||||
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
|
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
|
||||||
|
|
@ -1011,7 +1017,7 @@ public sealed class PlayerMovementController
|
||||||
if (!autoWalkConsumedMotion
|
if (!autoWalkConsumedMotion
|
||||||
&& _motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
|
&& _motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
|
||||||
{
|
{
|
||||||
Yaw -= RemoteMoveToDriver.BaseTurnRateRadPerSec
|
Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver
|
||||||
* _motion.InterpretedState.TurnSpeed * dt;
|
* _motion.InterpretedState.TurnSpeed * dt;
|
||||||
}
|
}
|
||||||
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
|
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,21 @@ public sealed class GameWindow : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public System.Numerics.Vector3 ServerVelocity;
|
public System.Numerics.Vector3 ServerVelocity;
|
||||||
public bool HasServerVelocity;
|
public bool HasServerVelocity;
|
||||||
|
|
||||||
|
/// <summary>R4-V4: the entity's verbatim retail MoveToManager
|
||||||
|
/// (server-directed movement), constructed + seam-bound by
|
||||||
|
/// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds.</summary>
|
||||||
|
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo;
|
||||||
|
|
||||||
|
// R4-V4 (pin P4): the minimal TargetTracker adapter state — the
|
||||||
|
// manager's setTarget/clearTarget seams store the tracked guid here;
|
||||||
|
// the per-tick block feeds HandleUpdateTarget(Ok/ExitWorld) from the
|
||||||
|
// live entity table. Full TargetManager port is R5 (register row).
|
||||||
|
public uint TrackedTargetGuid;
|
||||||
|
public float TrackedTargetRadius;
|
||||||
|
public System.Numerics.Vector3 TrackedTargetLastFedPos;
|
||||||
|
public bool TrackedTargetFedOnce;
|
||||||
|
public double TargetQuantum;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True while a server MoveToObject/MoveToPosition packet is the
|
/// True while a server MoveToObject/MoveToPosition packet is the
|
||||||
/// active locomotion source. Retail runs these through MoveToManager
|
/// active locomotion source. Retail runs these through MoveToManager
|
||||||
|
|
@ -4191,7 +4206,7 @@ public sealed class GameWindow : IDisposable
|
||||||
/// the VectorUpdate path regardless of arrival order.
|
/// the VectorUpdate path regardless of arrival order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
|
private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
|
||||||
RemoteMotion rm, AnimatedEntity ae)
|
RemoteMotion rm, AnimatedEntity ae, uint serverGuid)
|
||||||
{
|
{
|
||||||
if (ae.Sequencer is null)
|
if (ae.Sequencer is null)
|
||||||
return rm.Sink;
|
return rm.Sink;
|
||||||
|
|
@ -4218,6 +4233,44 @@ public sealed class GameWindow : IDisposable
|
||||||
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
|
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
|
||||||
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
|
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
|
||||||
rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions;
|
rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions;
|
||||||
|
|
||||||
|
// R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness
|
||||||
|
// wiring (the conformance-tested reference). Positions are WORLD
|
||||||
|
// space on both sides (getPosition + the MovementStruct positions
|
||||||
|
// the UM router builds) — one consistent space, so the manager's
|
||||||
|
// distance/heading math matches the harness exactly.
|
||||||
|
var rmT = rm;
|
||||||
|
var mtBody = rm.Body;
|
||||||
|
rm.MoveTo = new AcDream.Core.Physics.Motion.MoveToManager(
|
||||||
|
rm.Motion,
|
||||||
|
stopCompletely: () => rmT.Motion.StopCompletely(),
|
||||||
|
getPosition: () => new AcDream.Core.Physics.Position(
|
||||||
|
rmT.CellId, mtBody.Position, mtBody.Orientation),
|
||||||
|
getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading(
|
||||||
|
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's TargetManager port
|
||||||
|
getOwnHeight: () => 0f,
|
||||||
|
contact: () => mtBody.OnWalkable,
|
||||||
|
isInterpolating: () => rmT.Interp.IsActive,
|
||||||
|
getVelocity: () => mtBody.Velocity,
|
||||||
|
getSelfId: () => serverGuid,
|
||||||
|
setTarget: (_, tlid, radius, _) =>
|
||||||
|
{
|
||||||
|
rmT.TrackedTargetGuid = tlid;
|
||||||
|
rmT.TrackedTargetRadius = radius;
|
||||||
|
rmT.TrackedTargetFedOnce = false;
|
||||||
|
},
|
||||||
|
clearTarget: () => rmT.TrackedTargetGuid = 0,
|
||||||
|
getTargetQuantum: () => rmT.TargetQuantum,
|
||||||
|
setTargetQuantum: q => rmT.TargetQuantum = q);
|
||||||
|
// TS-36 (remote side): the interp's interrupt seam is retail's
|
||||||
|
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
|
||||||
|
// chain (V2's reentrancy tests prove the wiring is inert-safe).
|
||||||
|
rm.Motion.InterruptCurrentMovement =
|
||||||
|
() => rmT.MoveTo?.CancelMoveTo(
|
||||||
|
AcDream.Core.Physics.WeenieError.ActionCancelled);
|
||||||
return rm.Sink;
|
return rm.Sink;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4400,24 +4453,13 @@ public sealed class GameWindow : IDisposable
|
||||||
// otherwise → resolve class byte and use full cmd
|
// otherwise → resolve class byte and use full cmd
|
||||||
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
|
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
|
||||||
uint fullMotion;
|
uint fullMotion;
|
||||||
if ((!command.HasValue || command.Value == 0)
|
// R4-V4: the PlanMoveToStart seed is DELETED — MoveTo UMs no
|
||||||
&& update.MotionState.IsServerControlledMoveTo)
|
// longer flow through the interpreted funnel at all (retail
|
||||||
{
|
// unpack_movement routes types 6-9 to MoveToManager; only type
|
||||||
// Retail MoveToManager::BeginMoveForward calls
|
// 0 does the interpreted-state copy). The manager's own
|
||||||
// MovementParameters::get_command (0x0052AA00), then
|
// BeginMoveForward -> get_command -> _DoMotion produces the
|
||||||
// _DoMotion -> adjust_motion. With CanRun and enough
|
// cycle through the same sink every other motion uses.
|
||||||
// distance, WalkForward + HoldKey_Run becomes RunForward,
|
if (!command.HasValue || command.Value == 0)
|
||||||
// and CMotionInterp::apply_run_to_command (0x00527BE0)
|
|
||||||
// multiplies speed by the packet's runRate.
|
|
||||||
var seed = AcDream.Core.Physics.ServerControlledLocomotion
|
|
||||||
.PlanMoveToStart(
|
|
||||||
update.MotionState.MoveToSpeed ?? 1f,
|
|
||||||
update.MotionState.MoveToRunRate ?? 1f,
|
|
||||||
update.MotionState.MoveToCanRun);
|
|
||||||
fullMotion = seed.Motion;
|
|
||||||
speedMod = seed.SpeedMod;
|
|
||||||
}
|
|
||||||
else if (!command.HasValue || command.Value == 0)
|
|
||||||
{
|
{
|
||||||
fullMotion = 0x41000003u;
|
fullMotion = 0x41000003u;
|
||||||
}
|
}
|
||||||
|
|
@ -4510,7 +4552,7 @@ public sealed class GameWindow : IDisposable
|
||||||
&& update.MotionState.MoveToPath is { } pathData)
|
&& update.MotionState.MoveToPath is { } pathData)
|
||||||
{
|
{
|
||||||
// Translate landblock-local origin → world space.
|
// Translate landblock-local origin → world space.
|
||||||
var destWorld = AcDream.Core.Physics.RemoteMoveToDriver
|
var destWorld = AcDream.Core.Physics.Motion.MoveToMath
|
||||||
.OriginToWorld(
|
.OriginToWorld(
|
||||||
pathData.OriginCellId,
|
pathData.OriginCellId,
|
||||||
pathData.OriginX,
|
pathData.OriginX,
|
||||||
|
|
@ -4563,13 +4605,97 @@ public sealed class GameWindow : IDisposable
|
||||||
// contact_allows_move gate, the retail mechanism behind the
|
// contact_allows_move gate, the retail mechanism behind the
|
||||||
// old K-fix17 guard).
|
// old K-fix17 guard).
|
||||||
//
|
//
|
||||||
// MoveTo packets (mt 6/7) reuse the PlanMoveToStart seed as
|
// R4-V4: retail unpack_movement dispatch (0x00524440) — the
|
||||||
// the funnel's forward command (fullMotion/speedMod computed
|
// head-interrupt fires for EVERY movement type, then types
|
||||||
// above) and additionally capture the path for the per-tick
|
// 6/7/8/9 route to MoveToManager.PerformMovement (which
|
||||||
// remote driver.
|
// cancels again itself — retail does both) while ONLY type 0
|
||||||
|
// flows through the interpreted-state funnel below.
|
||||||
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
|
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
|
||||||
{
|
{
|
||||||
remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
|
var sink = EnsureRemoteMotionBindings(remoteMot, ae, update.Guid);
|
||||||
|
|
||||||
|
// unpack_movement head (@300566): interrupt + unstick.
|
||||||
|
remoteMot.Motion.InterruptCurrentMovement?.Invoke();
|
||||||
|
remoteMot.Motion.UnstickFromObject?.Invoke();
|
||||||
|
|
||||||
|
if (update.MotionState.IsServerControlledMoveTo
|
||||||
|
&& update.MotionState.MoveToPath is { } path
|
||||||
|
&& remoteMot.MoveTo is { } moveMgr)
|
||||||
|
{
|
||||||
|
// my_run_rate write (unpack_movement @300603).
|
||||||
|
if (update.MotionState.MoveToRunRate is { } mtRunRate)
|
||||||
|
remoteMot.Motion.MyRunRate = mtRunRate;
|
||||||
|
|
||||||
|
var destWorld = AcDream.Core.Physics.Motion.MoveToMath
|
||||||
|
.OriginToWorld(
|
||||||
|
path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
|
||||||
|
_liveCenterX, _liveCenterY);
|
||||||
|
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
|
||||||
|
path.Bitfield,
|
||||||
|
path.DistanceToObject,
|
||||||
|
path.MinDistance,
|
||||||
|
path.FailDistance,
|
||||||
|
update.MotionState.MoveToSpeed ?? 1f,
|
||||||
|
path.WalkRunThreshold,
|
||||||
|
path.DesiredHeading);
|
||||||
|
|
||||||
|
var ms = new AcDream.Core.Physics.MovementStruct
|
||||||
|
{
|
||||||
|
Params = mp,
|
||||||
|
};
|
||||||
|
// mt 6 with a resolvable target → MoveToObject (the
|
||||||
|
// P4 tracker feeds position updates per tick); else
|
||||||
|
// degrade to MoveToPosition at the wire origin (§2f).
|
||||||
|
if (update.MotionState.MovementType == 6
|
||||||
|
&& path.TargetGuid is { } tgtGuid
|
||||||
|
&& _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt))
|
||||||
|
{
|
||||||
|
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
|
||||||
|
ms.ObjectId = tgtGuid;
|
||||||
|
ms.TopLevelId = tgtGuid;
|
||||||
|
ms.Pos = new AcDream.Core.Physics.Position(
|
||||||
|
remoteMot.CellId, tgtEnt.Position,
|
||||||
|
System.Numerics.Quaternion.Identity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
|
||||||
|
ms.Pos = new AcDream.Core.Physics.Position(
|
||||||
|
remoteMot.CellId, destWorld,
|
||||||
|
System.Numerics.Quaternion.Identity);
|
||||||
|
}
|
||||||
|
moveMgr.PerformMovement(ms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.MotionState.IsServerControlledTurnTo
|
||||||
|
&& update.MotionState.TurnToPath is { } turnPath
|
||||||
|
&& remoteMot.MoveTo is { } turnMgr)
|
||||||
|
{
|
||||||
|
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
|
||||||
|
turnPath.Bitfield,
|
||||||
|
turnPath.Speed,
|
||||||
|
turnPath.DesiredHeading);
|
||||||
|
|
||||||
|
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
|
||||||
|
if (update.MotionState.MovementType == 8
|
||||||
|
&& turnPath.TargetGuid is { } turnTgt
|
||||||
|
&& _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt))
|
||||||
|
{
|
||||||
|
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
|
||||||
|
ms.ObjectId = turnTgt;
|
||||||
|
ms.TopLevelId = turnTgt;
|
||||||
|
ms.Pos = new AcDream.Core.Physics.Position(
|
||||||
|
remoteMot.CellId, turnEnt.Position,
|
||||||
|
System.Numerics.Quaternion.Identity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
|
||||||
|
}
|
||||||
|
turnMgr.PerformMovement(ms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// [FWD_WIRE] + observed-velocity history invalidation on
|
// [FWD_WIRE] + observed-velocity history invalidation on
|
||||||
// a forward-command change (pre-S2 behavior, unchanged:
|
// a forward-command change (pre-S2 behavior, unchanged:
|
||||||
|
|
@ -4588,30 +4714,9 @@ public sealed class GameWindow : IDisposable
|
||||||
remoteMot.PrevServerPosTime = 0.0;
|
remoteMot.PrevServerPosTime = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update.MotionState.IsServerControlledMoveTo
|
|
||||||
&& update.MotionState.MoveToPath is { } path)
|
|
||||||
{
|
|
||||||
remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver
|
|
||||||
.OriginToWorld(
|
|
||||||
path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
|
|
||||||
_liveCenterX, _liveCenterY);
|
|
||||||
remoteMot.MoveToMinDistance = path.MinDistance;
|
|
||||||
remoteMot.MoveToDistanceToObject = path.DistanceToObject;
|
|
||||||
remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
|
|
||||||
remoteMot.HasMoveToDestination = true;
|
|
||||||
remoteMot.LastMoveToPacketTime =
|
|
||||||
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
|
||||||
}
|
|
||||||
else if (!update.MotionState.IsServerControlledMoveTo)
|
|
||||||
{
|
|
||||||
// Off MoveTo — clear stale destination so the
|
|
||||||
// per-tick driver doesn't keep steering.
|
|
||||||
remoteMot.HasMoveToDestination = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the funnel input. fullMotion/speedMod already
|
// Build the funnel input. fullMotion/speedMod already
|
||||||
// encode retail's absent-field forward defaults (null/0
|
// encode retail's absent-field forward defaults (null/0
|
||||||
// → Ready; MoveTo → PlanMoveToStart seed). Style:
|
// → Ready; mt 6-9 never reach here post-V4). Style:
|
||||||
// retail's InterpretedMotionState::UnPack (0x0051f400)
|
// retail's InterpretedMotionState::UnPack (0x0051f400)
|
||||||
// defaults an absent stance to NonCombat 0x8000003D —
|
// defaults an absent stance to NonCombat 0x8000003D —
|
||||||
// NOT keep-current (S0 trace: every empty UM applied
|
// NOT keep-current (S0 trace: every empty UM applied
|
||||||
|
|
@ -4670,11 +4775,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
// R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the
|
// R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the
|
||||||
// motion-table stack (GetObjectSequence + is_allowed
|
// motion-table stack (GetObjectSequence + is_allowed
|
||||||
// decide). The sink is PERSISTENT per remote and also
|
// decide) — mt-0 only post-V4 (types 6-9 returned above).
|
||||||
// bound as Motion.DefaultSink + the RemoveLink/InitTables
|
|
||||||
// seams (EnsureRemoteMotionBindings) so LeaveGround/
|
|
||||||
// HitGround dispatch through the same backend.
|
|
||||||
var sink = EnsureRemoteMotionBindings(remoteMot, ae);
|
|
||||||
remoteMot.Motion.MoveToInterpretedState(ims, sink);
|
remoteMot.Motion.MoveToInterpretedState(ims, sink);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4854,7 +4955,7 @@ public sealed class GameWindow : IDisposable
|
||||||
&& _animatedEntities.TryGetValue(ent.Id, out var ae)
|
&& _animatedEntities.TryGetValue(ent.Id, out var ae)
|
||||||
&& ae.Sequencer is not null)
|
&& ae.Sequencer is not null)
|
||||||
{
|
{
|
||||||
EnsureRemoteMotionBindings(rm, ae);
|
EnsureRemoteMotionBindings(rm, ae, update.Guid);
|
||||||
rm.Motion.LeaveGround();
|
rm.Motion.LeaveGround();
|
||||||
rm.Body.Velocity = update.Velocity;
|
rm.Body.Velocity = update.Velocity;
|
||||||
rm.Body.Omega = update.Omega;
|
rm.Body.Omega = update.Omega;
|
||||||
|
|
@ -4910,11 +5011,10 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
if (rm.Airborne) return;
|
if (rm.Airborne) return;
|
||||||
if (ae.Sequencer is null) return;
|
if (ae.Sequencer is null) return;
|
||||||
// MoveTo packets already seeded the retail speed/runRate cycle.
|
// R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's
|
||||||
// Keep UpdatePosition-derived velocity for render position only;
|
// get_command dispatch). Velocity-estimated cycle planning would
|
||||||
// using it to choose the cycle reverts fast chases back to slow
|
// fight it — same rationale as the pre-V4 ServerMoveToActive guard.
|
||||||
// velocity-estimated animation.
|
if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return;
|
||||||
if (rm.ServerMoveToActive) return;
|
|
||||||
|
|
||||||
if (IsPlayerGuid(serverGuid))
|
if (IsPlayerGuid(serverGuid))
|
||||||
{
|
{
|
||||||
|
|
@ -4960,7 +5060,7 @@ public sealed class GameWindow : IDisposable
|
||||||
+ $"|v|={velocity.Length():F2} "
|
+ $"|v|={velocity.Length():F2} "
|
||||||
+ $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} "
|
+ $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} "
|
||||||
+ $"prev=0x{currentMotion:X8} "
|
+ $"prev=0x{currentMotion:X8} "
|
||||||
+ $"airborne={rm.Airborne} moveTo={rm.ServerMoveToActive}");
|
+ $"airborne={rm.Airborne} moveTo={rm.MoveTo?.MovementTypeState ?? AcDream.Core.Physics.MovementType.Invalid}");
|
||||||
}
|
}
|
||||||
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
|
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
|
||||||
}
|
}
|
||||||
|
|
@ -5326,7 +5426,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// carries no stop information for our ACE.
|
// carries no stop information for our ACE.
|
||||||
if (svel.LengthSquared() < 0.04f)
|
if (svel.LengthSquared() < 0.04f)
|
||||||
{
|
{
|
||||||
rmState.ServerMoveToActive = false;
|
|
||||||
rmState.Motion.StopCompletely();
|
rmState.Motion.StopCompletely();
|
||||||
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
|
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
|
||||||
&& aeForStop.Sequencer is not null)
|
&& aeForStop.Sequencer is not null)
|
||||||
|
|
@ -9591,93 +9690,52 @@ public sealed class GameWindow : IDisposable
|
||||||
rm.Body.Velocity = rm.ServerVelocity;
|
rm.Body.Velocity = rm.ServerVelocity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive
|
else
|
||||||
&& rm.HasMoveToDestination)
|
|
||||||
{
|
{
|
||||||
// Phase L.1c port of retail MoveToManager per-tick
|
// R4-V4: the retail MoveToManager drives
|
||||||
// steering (HandleMoveToPosition @ 0x00529d80).
|
// server-directed movement per tick — the P4
|
||||||
// Steer body orientation toward the latest
|
// TargetTracker adapter feeds HandleUpdateTarget
|
||||||
// server-supplied destination, then let
|
// from the live entity table (full TargetManager
|
||||||
// apply_current_movement set Velocity from the
|
// port is R5; register row), then UseTime runs
|
||||||
// RunForward cycle through the now-correct heading.
|
// HandleMoveToPosition/HandleTurnToHeading
|
||||||
|
// (steering + arrival + fail-distance), and
|
||||||
// Stale-destination guard (2026-04-28): if no
|
// apply_current_movement recomputes velocity from
|
||||||
// MoveTo packet has refreshed the destination
|
// whatever the manager dispatched — the same
|
||||||
// recently, the entity has likely left our
|
// per-tick shape ordinary locomotion uses.
|
||||||
// streaming view or the server cancelled the
|
if (!IsPlayerGuid(serverGuid) && rm.MoveTo is { } mtm)
|
||||||
// move without us seeing the cancel UM. Continuing
|
|
||||||
// to steer toward a stale point produces the
|
|
||||||
// "monster runs in place after popping back into
|
|
||||||
// view" symptom. Clear and stand down.
|
|
||||||
double moveToAge = nowSec - rm.LastMoveToPacketTime;
|
|
||||||
if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds)
|
|
||||||
{
|
{
|
||||||
rm.HasMoveToDestination = false;
|
if (rm.TrackedTargetGuid != 0)
|
||||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
{
|
||||||
|
if (_entitiesByServerGuid.TryGetValue(rm.TrackedTargetGuid, out var trackedEnt))
|
||||||
|
{
|
||||||
|
var tpos = trackedEnt.Position;
|
||||||
|
if (!rm.TrackedTargetFedOnce
|
||||||
|
|| System.Numerics.Vector3.Distance(tpos, rm.TrackedTargetLastFedPos)
|
||||||
|
> rm.TrackedTargetRadius)
|
||||||
|
{
|
||||||
|
rm.TrackedTargetFedOnce = true;
|
||||||
|
rm.TrackedTargetLastFedPos = tpos;
|
||||||
|
var tp = new AcDream.Core.Physics.Position(
|
||||||
|
rm.CellId, tpos, System.Numerics.Quaternion.Identity);
|
||||||
|
mtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
|
||||||
|
rm.TrackedTargetGuid,
|
||||||
|
AcDream.Core.Physics.Motion.TargetStatus.Ok,
|
||||||
|
tp, tp));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var driveResult = AcDream.Core.Physics.RemoteMoveToDriver
|
var lp = new AcDream.Core.Physics.Position(
|
||||||
.Drive(
|
rm.CellId, rm.TrackedTargetLastFedPos,
|
||||||
rm.Body.Position,
|
System.Numerics.Quaternion.Identity);
|
||||||
rm.Body.Orientation,
|
mtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
|
||||||
rm.MoveToDestinationWorld,
|
rm.TrackedTargetGuid,
|
||||||
rm.MoveToMinDistance,
|
AcDream.Core.Physics.Motion.TargetStatus.ExitWorld,
|
||||||
rm.MoveToDistanceToObject,
|
lp, lp));
|
||||||
(float)dt,
|
|
||||||
rm.MoveToMoveTowards,
|
|
||||||
out var steeredOrientation);
|
|
||||||
rm.Body.Orientation = steeredOrientation;
|
|
||||||
|
|
||||||
if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver
|
|
||||||
.DriveResult.Arrived)
|
|
||||||
{
|
|
||||||
// Within arrival window — zero velocity until the
|
|
||||||
// next MoveTo packet refreshes the destination
|
|
||||||
// (or the server explicitly stops us with an
|
|
||||||
// interpreted-motion UM cmd=Ready).
|
|
||||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Steering active — apply_current_movement reads
|
|
||||||
// InterpretedState.ForwardCommand=RunForward (set
|
|
||||||
// when the MoveTo packet arrived) and emits
|
|
||||||
// velocity along +Y in body local space. Our
|
|
||||||
// updated orientation rotates that into the right
|
|
||||||
// world direction toward the target.
|
|
||||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
|
||||||
|
|
||||||
// Clamp horizontal velocity so we don't overshoot
|
|
||||||
// the arrival threshold during the final tick of
|
|
||||||
// approach. Without this, a 4 m/s body advances
|
|
||||||
// ~6 cm/tick and visibly runs slightly through
|
|
||||||
// the target before the swing UM lands.
|
|
||||||
float arrivalThreshold = rm.MoveToMoveTowards
|
|
||||||
? rm.MoveToDistanceToObject
|
|
||||||
: rm.MoveToMinDistance;
|
|
||||||
rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver
|
|
||||||
.ClampApproachVelocity(
|
|
||||||
rm.Body.Position,
|
|
||||||
rm.Body.Velocity,
|
|
||||||
rm.MoveToDestinationWorld,
|
|
||||||
arrivalThreshold,
|
|
||||||
(float)dt,
|
|
||||||
rm.MoveToMoveTowards);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
mtm.UseTime();
|
||||||
}
|
}
|
||||||
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
|
|
||||||
{
|
|
||||||
// MoveTo flag set but we haven't seen a path payload
|
|
||||||
// yet (e.g. truncated packet, or a brand-new entity
|
|
||||||
// whose first cycle UM is still in flight). Hold
|
|
||||||
// velocity at zero — same conservative stance as the
|
|
||||||
// 882a07c stabilizer for incomplete state.
|
|
||||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -338,7 +338,8 @@ public static class CreateObject
|
||||||
float MinDistance,
|
float MinDistance,
|
||||||
float FailDistance,
|
float FailDistance,
|
||||||
float WalkRunThreshold,
|
float WalkRunThreshold,
|
||||||
float DesiredHeading);
|
float DesiredHeading,
|
||||||
|
uint Bitfield = 0); // R4-V4: the raw UnPackNet flags dword, feeds MovementParameters.FromWire
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// R4-V3 (closes M7) — path-control payload of a server-controlled
|
/// R4-V3 (closes M7) — path-control payload of a server-controlled
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,8 @@ public static class UpdateMotion
|
||||||
minDistance,
|
minDistance,
|
||||||
failDistance,
|
failDistance,
|
||||||
walkRunThreshold,
|
walkRunThreshold,
|
||||||
desiredHeading);
|
desiredHeading,
|
||||||
|
movementParameters ?? 0u); // R4-V4: the raw flags dword -> FromWire
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,4 +198,26 @@ public static class MoveToMath
|
||||||
float edgeDist = centerDist - ownRadius - targetRadius;
|
float edgeDist = centerDist - ownRadius - targetRadius;
|
||||||
return edgeDist > 0f ? edgeDist : 0f;
|
return edgeDist > 0f ? edgeDist : 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Landblock-local wire origin → world space (verbatim relocation from
|
||||||
|
/// the deleted <c>RemoteMoveToDriver.OriginToWorld</c>, R4-V4): MoveTo /
|
||||||
|
/// TurnTo packets carry positions block-local; acdream's streaming world
|
||||||
|
/// re-centers on a live-center landblock grid.
|
||||||
|
/// </summary>
|
||||||
|
public static Vector3 OriginToWorld(
|
||||||
|
uint originCellId,
|
||||||
|
float originX,
|
||||||
|
float originY,
|
||||||
|
float originZ,
|
||||||
|
int liveCenterLandblockX,
|
||||||
|
int liveCenterLandblockY)
|
||||||
|
{
|
||||||
|
int lbX = (int)((originCellId >> 24) & 0xFFu);
|
||||||
|
int lbY = (int)((originCellId >> 16) & 0xFFu);
|
||||||
|
return new Vector3(
|
||||||
|
originX + (lbX - liveCenterLandblockX) * 192f,
|
||||||
|
originY + (lbY - liveCenterLandblockY) * 192f,
|
||||||
|
originZ);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,344 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Numerics;
|
|
||||||
|
|
||||||
namespace AcDream.Core.Physics;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Per-tick steering for server-controlled remote creatures while a
|
|
||||||
/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
|
|
||||||
/// is the active locomotion source.
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
|
|
||||||
/// stabilizer. With the full MoveTo path payload now captured on
|
|
||||||
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>,
|
|
||||||
/// the body solver has the destination + heading + thresholds it needs to
|
|
||||||
/// run the retail per-tick loop instead of waiting for sparse
|
|
||||||
/// UpdatePosition snap corrections.
|
|
||||||
/// </para>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// Retail references:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item><description>
|
|
||||||
/// <c>MoveToManager::HandleMoveToPosition</c> (<c>0x00529d80</c>) — the
|
|
||||||
/// per-tick driver. Computes heading-to-target, fires an aux
|
|
||||||
/// <c>TurnLeft</c>/<c>TurnRight</c> command when |delta| > 20°, snaps
|
|
||||||
/// orientation when within tolerance, and tests arrival via
|
|
||||||
/// <c>dist <= min_distance</c> (chase) or
|
|
||||||
/// <c>dist >= distance_to_object</c> (flee).
|
|
||||||
/// </description></item>
|
|
||||||
/// <item><description>
|
|
||||||
/// <c>MoveToManager::_DoMotion</c> / <c>_StopMotion</c> route turn
|
|
||||||
/// commands through <c>CMotionInterp::DoInterpretedMotion</c> — i.e.
|
|
||||||
/// MoveToManager itself does NOT touch the body. The body's actual
|
|
||||||
/// velocity comes from <c>CMotionInterp::apply_current_movement</c>
|
|
||||||
/// reading <c>InterpretedState.ForwardCommand = RunForward</c> and
|
|
||||||
/// emitting <c>velocity.Y = RunAnimSpeed × speedMod</c>, transformed by
|
|
||||||
/// the body's orientation.
|
|
||||||
/// </description></item>
|
|
||||||
/// </list>
|
|
||||||
/// </para>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// Acdream port scope: minimum viable subset. We skip target re-tracking
|
|
||||||
/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/
|
|
||||||
/// StickTo, fail-distance progress detector, and the sphere-cylinder
|
|
||||||
/// distance variant — all server-side concerns the local body doesn't need
|
|
||||||
/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance
|
|
||||||
/// (with ACE's <c>set_heading(true)</c> snap-on-aligned fudge), and
|
|
||||||
/// arrival detection via <c>min_distance</c>.
|
|
||||||
/// </para>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// Arrival predicates: retail and ACE AGREE — chase arrives at
|
|
||||||
/// <c>dist <= distance_to_object</c>, flee arrives at
|
|
||||||
/// <c>dist >= min_distance</c> (adjudicated in
|
|
||||||
/// <c>docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md</c> §1; see the
|
|
||||||
/// arrival comment in <see cref="Drive"/>). An earlier revision of this doc
|
|
||||||
/// claimed ACE swapped the two predicates — that was wrong; do not port
|
|
||||||
/// against it.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public static class RemoteMoveToDriver
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Heading tolerance below which we snap orientation directly to the
|
|
||||||
/// target heading (ACE's <c>set_heading(target, true)</c>
|
|
||||||
/// server-tic-rate fudge). Above tolerance we rotate at
|
|
||||||
/// <see cref="TurnRateRadPerSec"/>. Retail value (line 307251 of
|
|
||||||
/// <c>acclient_2013_pseudo_c.txt</c>) is 20°.
|
|
||||||
/// </summary>
|
|
||||||
public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Default angular rate for in-motion heading correction when delta
|
|
||||||
/// exceeds <see cref="HeadingSnapToleranceRad"/>. Picked to match
|
|
||||||
/// ACE's <c>TurnSpeed</c> default of <c>π/2</c> rad/s for monsters;
|
|
||||||
/// when the per-creature value differs, the future port can wire it
|
|
||||||
/// in via the <c>TurnSpeed</c> field on InterpretedMotionState.
|
|
||||||
/// </summary>
|
|
||||||
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retail base turn rate for the player Humanoid when turn_speed
|
|
||||||
/// scalar = 1.0. Convention default <c>omega.z = ±π/2 rad/s</c>
|
|
||||||
/// derived from <c>add_motion</c> at <c>0x005224b0</c> + the
|
|
||||||
/// HasOmega-cleared MotionData fallback documented in
|
|
||||||
/// <c>AnimationSequencer.cs:734-741</c>. ~90°/s.
|
|
||||||
/// </summary>
|
|
||||||
public const float BaseTurnRateRadPerSec = MathF.PI / 2.0f;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retail's <c>run_turn_factor</c> constant at <c>0x007c8914</c>
|
|
||||||
/// (PDB-named). <c>CMotionInterp::apply_run_to_command</c>
|
|
||||||
/// equivalent (decomp <c>0x00527be0</c>, line 305098 of
|
|
||||||
/// <c>acclient_2013_pseudo_c.txt</c>) multiplies <c>turn_speed</c>
|
|
||||||
/// by 1.5 when <c>HoldKey.Run</c> is active on a TurnRight/TurnLeft
|
|
||||||
/// command. Effect: running rotation is 50 % faster than walking.
|
|
||||||
/// </summary>
|
|
||||||
public const float RunTurnFactor = 1.5f;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retail-faithful local-player turn rate.
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item><b>Walking</b>: <c>BaseTurnRateRadPerSec</c> ≈ 90°/s.</item>
|
|
||||||
/// <item><b>Running</b>: <c>BaseTurnRateRadPerSec × RunTurnFactor</c>
|
|
||||||
/// ≈ 135°/s.</item>
|
|
||||||
/// </list>
|
|
||||||
/// Replaces the fixed <c>TurnRateRadPerSec</c> for paths that have
|
|
||||||
/// access to the player's run/walk state (keyboard A/D, auto-walk
|
|
||||||
/// overlay turn-first). NPC/monster remotes that lack the
|
|
||||||
/// information continue to use the constant which equals
|
|
||||||
/// <c>BaseTurnRateRadPerSec</c>.
|
|
||||||
/// </summary>
|
|
||||||
public static float TurnRateFor(bool running)
|
|
||||||
=> running ? BaseTurnRateRadPerSec * RunTurnFactor
|
|
||||||
: BaseTurnRateRadPerSec;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Float-comparison slack for the arrival predicate. With
|
|
||||||
/// <c>min_distance == 0</c> in a chase packet, exact equality is
|
|
||||||
/// unreachable due to integration wobble; this epsilon prevents the
|
|
||||||
/// driver from over-shooting by a sub-meter and snap-flipping back.
|
|
||||||
/// </summary>
|
|
||||||
public const float ArrivalEpsilon = 0.05f;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum staleness (seconds) of the most recent MoveTo packet
|
|
||||||
/// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz
|
|
||||||
/// during active chase; if no fresh packet arrives for this long,
|
|
||||||
/// the entity has likely either left our streaming view, switched
|
|
||||||
/// to a non-MoveTo motion the server's broadcast didn't reach us
|
|
||||||
/// for, or had its move cancelled server-side without our seeing
|
|
||||||
/// the cancel UM. In any of those cases, continuing to drive the
|
|
||||||
/// body toward a stale destination produces the "monster runs in
|
|
||||||
/// place after popping back into view" symptom (2026-04-28).
|
|
||||||
/// 1.5 s gives us comfortable margin over the ~1 s emit cadence
|
|
||||||
/// while still failing fast on real loss-of-state.
|
|
||||||
/// </summary>
|
|
||||||
public const double StaleDestinationSeconds = 1.5;
|
|
||||||
|
|
||||||
public enum DriveResult
|
|
||||||
{
|
|
||||||
/// <summary>Within arrival window — caller should zero velocity.</summary>
|
|
||||||
Arrived,
|
|
||||||
/// <summary>Steering active — caller should let
|
|
||||||
/// <c>apply_current_movement</c> set body velocity from the cycle.</summary>
|
|
||||||
Steering,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Steer body orientation toward <paramref name="destinationWorld"/>
|
|
||||||
/// and report whether the body has arrived or should keep running.
|
|
||||||
/// Pure function — emits the updated orientation via
|
|
||||||
/// <paramref name="newOrientation"/> (the input is not mutated; the
|
|
||||||
/// caller assigns the new value back to its body).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="minDistance">
|
|
||||||
/// <c>min_distance</c> from the wire's MovementParameters block —
|
|
||||||
/// retail's <c>HandleMoveToPosition</c> chase-arrival threshold.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="distanceToObject">
|
|
||||||
/// <c>distance_to_object</c> from the wire — ACE's chase-arrival
|
|
||||||
/// threshold (default 0.6 m, the melee range). The actual arrival
|
|
||||||
/// gate is <c>max(minDistance, distanceToObject)</c>: retail-faithful
|
|
||||||
/// when retail sends <c>min_distance</c> > 0, ACE-compatible when
|
|
||||||
/// ACE puts the value in <c>distance_to_object</c> with
|
|
||||||
/// <c>min_distance == 0</c>. Without this, ACE's <c>min_distance==0</c>
|
|
||||||
/// chase packets never arrive — the body keeps re-targeting around
|
|
||||||
/// the player at melee range and visibly oscillates between facings,
|
|
||||||
/// which is the user-reported "monster keeps running in different
|
|
||||||
/// directions when it should be attacking" symptom (2026-04-28).
|
|
||||||
/// </param>
|
|
||||||
public static DriveResult Drive(
|
|
||||||
Vector3 bodyPosition,
|
|
||||||
Quaternion bodyOrientation,
|
|
||||||
Vector3 destinationWorld,
|
|
||||||
float minDistance,
|
|
||||||
float distanceToObject,
|
|
||||||
float dt,
|
|
||||||
bool moveTowards,
|
|
||||||
out Quaternion newOrientation)
|
|
||||||
{
|
|
||||||
// Horizontal distance only — server owns Z, our body Z is
|
|
||||||
// hard-snapped to the latest UpdatePosition.
|
|
||||||
float dx = destinationWorld.X - bodyPosition.X;
|
|
||||||
float dy = destinationWorld.Y - bodyPosition.Y;
|
|
||||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
// Arrival predicate per retail MoveToManager::HandleMoveToPosition
|
|
||||||
// (acclient_2013_pseudo_c.txt:307289-307320) and ACE
|
|
||||||
// MoveToManager.cs:476:
|
|
||||||
//
|
|
||||||
// chase (MoveTowards): dist <= distance_to_object
|
|
||||||
// flee (MoveAway): dist >= min_distance
|
|
||||||
//
|
|
||||||
// (My earlier <c>max(MinDistance, DistanceToObject)</c> was a
|
|
||||||
// defensive guess; cross-checked with two independent research
|
|
||||||
// agents against the named retail decomp + ACE port + holtburger,
|
|
||||||
// the chase threshold is unambiguously DistanceToObject —
|
|
||||||
// MinDistance is the FLEE arrival threshold. ACE's wire defaults
|
|
||||||
// give MinDistance=0, DistanceToObject=0.6 — the body should stop
|
|
||||||
// at melee range, not run to zero.)
|
|
||||||
float arrivalThreshold = moveTowards ? distanceToObject : minDistance;
|
|
||||||
if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon)
|
|
||||||
{
|
|
||||||
newOrientation = bodyOrientation;
|
|
||||||
return DriveResult.Arrived;
|
|
||||||
}
|
|
||||||
if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon)
|
|
||||||
{
|
|
||||||
newOrientation = bodyOrientation;
|
|
||||||
return DriveResult.Arrived;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Degenerate — already on target horizontally; preserve heading.
|
|
||||||
if (dist < 1e-4f)
|
|
||||||
{
|
|
||||||
newOrientation = bodyOrientation;
|
|
||||||
return DriveResult.Steering;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Body's local-forward is +Y (see MotionInterpreter.get_state_velocity
|
|
||||||
// at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed).
|
|
||||||
// World forward = Transform((0,1,0), orientation). Yaw extracted
|
|
||||||
// via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity.
|
|
||||||
var localForward = new Vector3(0f, 1f, 0f);
|
|
||||||
var worldForward = Vector3.Transform(localForward, bodyOrientation);
|
|
||||||
float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y);
|
|
||||||
|
|
||||||
// Desired heading: face the target. (dx, dy) is the world-space
|
|
||||||
// offset to the target. With local-forward=+Y we want yaw such
|
|
||||||
// that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves
|
|
||||||
// to yaw = atan2(-dx, dy).
|
|
||||||
float desiredYaw = MathF.Atan2(-dx, dy);
|
|
||||||
float delta = WrapPi(desiredYaw - currentYaw);
|
|
||||||
|
|
||||||
if (MathF.Abs(delta) <= HeadingSnapToleranceRad)
|
|
||||||
{
|
|
||||||
// ACE's set_heading(target, true) — sync to server-tic-rate.
|
|
||||||
// We have the same sparse-UP problem ACE does, so the same
|
|
||||||
// fudge applies.
|
|
||||||
newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Retail BeginTurnToHeading / HandleMoveToPosition aux turn:
|
|
||||||
// rotate at TurnRate clamped to dt, in the shorter direction.
|
|
||||||
float maxStep = TurnRateRadPerSec * dt;
|
|
||||||
float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
|
||||||
// Apply incremental yaw around world +Z (preserving any
|
|
||||||
// server-supplied pitch/roll from the latest UpdatePosition).
|
|
||||||
var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step);
|
|
||||||
newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
return DriveResult.Steering;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Convert a landblock-local Origin from a MoveTo packet
|
|
||||||
/// (<see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>)
|
|
||||||
/// into acdream's render world space using the same arithmetic as
|
|
||||||
/// <c>OnLivePositionUpdated</c>: shift by the landblock-grid offset
|
|
||||||
/// from the live-mode center.
|
|
||||||
/// </summary>
|
|
||||||
public static Vector3 OriginToWorld(
|
|
||||||
uint originCellId,
|
|
||||||
float originX,
|
|
||||||
float originY,
|
|
||||||
float originZ,
|
|
||||||
int liveCenterLandblockX,
|
|
||||||
int liveCenterLandblockY)
|
|
||||||
{
|
|
||||||
int lbX = (int)((originCellId >> 24) & 0xFFu);
|
|
||||||
int lbY = (int)((originCellId >> 16) & 0xFFu);
|
|
||||||
return new Vector3(
|
|
||||||
originX + (lbX - liveCenterLandblockX) * 192f,
|
|
||||||
originY + (lbY - liveCenterLandblockY) * 192f,
|
|
||||||
originZ);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cap horizontal velocity so the body lands exactly at
|
|
||||||
/// <paramref name="arrivalThreshold"/> rather than overshooting past
|
|
||||||
/// it during the final tick of approach. Without this clamp, a body
|
|
||||||
/// running at <c>RunAnimSpeed × speedMod ≈ 4 m/s</c> can overshoot
|
|
||||||
/// the 0.6 m arrival window by up to one tick's advance (~6 cm at
|
|
||||||
/// 60 fps) — visible as the creature "running slightly through" the
|
|
||||||
/// player it's about to attack (user-reported 2026-04-28).
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// The clamp is a strict scale-down of the horizontal component
|
|
||||||
/// (X/Y); the vertical component (Z) is left to gravity / terrain
|
|
||||||
/// handling. <paramref name="moveTowards"/> false (flee branch) is a
|
|
||||||
/// no-op since fleeing has no overshoot risk — the body wants to
|
|
||||||
/// move AWAY from the destination.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public static Vector3 ClampApproachVelocity(
|
|
||||||
Vector3 bodyPosition,
|
|
||||||
Vector3 currentVelocity,
|
|
||||||
Vector3 destinationWorld,
|
|
||||||
float arrivalThreshold,
|
|
||||||
float dt,
|
|
||||||
bool moveTowards)
|
|
||||||
{
|
|
||||||
if (!moveTowards || dt <= 0f) return currentVelocity;
|
|
||||||
|
|
||||||
float dx = destinationWorld.X - bodyPosition.X;
|
|
||||||
float dy = destinationWorld.Y - bodyPosition.Y;
|
|
||||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
|
||||||
float remaining = MathF.Max(0f, dist - arrivalThreshold);
|
|
||||||
|
|
||||||
float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X
|
|
||||||
+ currentVelocity.Y * currentVelocity.Y);
|
|
||||||
if (vxy < 1e-3f) return currentVelocity;
|
|
||||||
|
|
||||||
float advance = vxy * dt;
|
|
||||||
if (advance <= remaining) return currentVelocity;
|
|
||||||
|
|
||||||
// Already inside or right at the threshold: zero horizontal
|
|
||||||
// velocity, keep Z. (The arrival predicate in Drive() should
|
|
||||||
// have fired this tick, but this is the belt-and-braces guard.)
|
|
||||||
if (remaining < 1e-3f)
|
|
||||||
return new Vector3(0f, 0f, currentVelocity.Z);
|
|
||||||
|
|
||||||
float scale = remaining / advance;
|
|
||||||
return new Vector3(
|
|
||||||
currentVelocity.X * scale,
|
|
||||||
currentVelocity.Y * scale,
|
|
||||||
currentVelocity.Z);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Wrap an angle in radians to [-π, π].</summary>
|
|
||||||
private static float WrapPi(float r)
|
|
||||||
{
|
|
||||||
const float TwoPi = MathF.PI * 2f;
|
|
||||||
r %= TwoPi;
|
|
||||||
if (r > MathF.PI) r -= TwoPi;
|
|
||||||
if (r < -MathF.PI) r += TwoPi;
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -32,24 +32,10 @@ public static class ServerControlledLocomotion
|
||||||
public const float MinSpeedMod = 0.25f;
|
public const float MinSpeedMod = 0.25f;
|
||||||
public const float MaxSpeedMod = 3.00f;
|
public const float MaxSpeedMod = 3.00f;
|
||||||
|
|
||||||
// Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command
|
// R4-V4: PlanMoveToStart DELETED - MoveTo UMs route to the verbatim
|
||||||
// (0x0052AA00) seeds forward motion before the next position update.
|
// MoveToManager (BeginMoveForward -> get_command -> _DoMotion produces
|
||||||
public static LocomotionCycle PlanMoveToStart(
|
// the cycle through the same sink every other motion uses).
|
||||||
float moveToSpeed = 1f,
|
// PlanFromVelocity below survives (M16 register row, retires in R6).
|
||||||
float runRate = 1f,
|
|
||||||
bool canRun = true)
|
|
||||||
{
|
|
||||||
moveToSpeed = SanitizePositive(moveToSpeed);
|
|
||||||
runRate = SanitizePositive(runRate);
|
|
||||||
|
|
||||||
if (!canRun)
|
|
||||||
return new LocomotionCycle(MotionCommand.WalkForward, moveToSpeed, true);
|
|
||||||
|
|
||||||
return new LocomotionCycle(
|
|
||||||
MotionCommand.RunForward,
|
|
||||||
moveToSpeed * runRate,
|
|
||||||
true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity)
|
public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,330 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Numerics;
|
|
||||||
using AcDream.Core.Physics;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.Physics;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Phase L.1c (2026-04-28). Covers <see cref="RemoteMoveToDriver"/> — the
|
|
||||||
/// per-tick steering port of retail
|
|
||||||
/// <c>MoveToManager::HandleMoveToPosition</c> for server-controlled remote
|
|
||||||
/// creatures.
|
|
||||||
/// </summary>
|
|
||||||
public class RemoteMoveToDriverTests
|
|
||||||
{
|
|
||||||
private const float Epsilon = 1e-3f;
|
|
||||||
|
|
||||||
private static float Yaw(Quaternion q)
|
|
||||||
{
|
|
||||||
var fwd = Vector3.Transform(new Vector3(0, 1, 0), q);
|
|
||||||
return MathF.Atan2(-fwd.X, fwd.Y);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Drive_AlreadyAtTarget_ReportsArrived()
|
|
||||||
{
|
|
||||||
var bodyPos = new Vector3(10f, 20f, 0f);
|
|
||||||
var bodyRot = Quaternion.Identity;
|
|
||||||
var dest = new Vector3(10f, 20.3f, 0f);
|
|
||||||
|
|
||||||
var result = RemoteMoveToDriver.Drive(
|
|
||||||
bodyPos, bodyRot, dest,
|
|
||||||
minDistance: 0.5f, distanceToObject: 0.6f,
|
|
||||||
dt: 0.016f, moveTowards: true,
|
|
||||||
out var newOrient);
|
|
||||||
|
|
||||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
|
||||||
Assert.Equal(bodyRot, newOrient); // orientation untouched
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Drive_AceMeleePacket_UsesDistanceToObjectAsArrival()
|
|
||||||
{
|
|
||||||
// ACE chase packet: MinDistance=0, DistanceToObject=0.6 (melee).
|
|
||||||
// Body at 0.5m from target should ARRIVE — not keep oscillating
|
|
||||||
// around the target the way it did pre-fix when only MinDistance
|
|
||||||
// was the gate. This is the "monster keeps running in different
|
|
||||||
// directions when it should be attacking" regression fix.
|
|
||||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
|
||||||
var bodyRot = Quaternion.Identity;
|
|
||||||
var dest = new Vector3(0f, 0.5f, 0f);
|
|
||||||
|
|
||||||
var result = RemoteMoveToDriver.Drive(
|
|
||||||
bodyPos, bodyRot, dest,
|
|
||||||
minDistance: 0f, distanceToObject: 0.6f,
|
|
||||||
dt: 0.016f, moveTowards: true,
|
|
||||||
out _);
|
|
||||||
|
|
||||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Drive_FleeArrival_UsesMinDistance()
|
|
||||||
{
|
|
||||||
// Flee branch (moveTowards=false): arrival when dist >= MinDistance.
|
|
||||||
// Retail / ACE both use MinDistance for the flee-arrival threshold.
|
|
||||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
|
||||||
var bodyRot = Quaternion.Identity;
|
|
||||||
var dest = new Vector3(0f, 6f, 0f);
|
|
||||||
|
|
||||||
var result = RemoteMoveToDriver.Drive(
|
|
||||||
bodyPos, bodyRot, dest,
|
|
||||||
minDistance: 5.0f, distanceToObject: 0.6f,
|
|
||||||
dt: 0.016f, moveTowards: false,
|
|
||||||
out _);
|
|
||||||
|
|
||||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Drive_ChaseDoesNotArriveAtMinDistanceFloor()
|
|
||||||
{
|
|
||||||
// Regression: my earlier max(MinDistance, DistanceToObject) port
|
|
||||||
// would have arrived here because dist (1.5) <= MinDistance (2.0).
|
|
||||||
// Retail uses DistanceToObject for chase arrival, so a chase at
|
|
||||||
// dist=1.5 with DistanceToObject=0.6 should still STEER, not arrive.
|
|
||||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
|
||||||
var bodyRot = Quaternion.Identity;
|
|
||||||
var dest = new Vector3(0f, 1.5f, 0f);
|
|
||||||
|
|
||||||
var result = RemoteMoveToDriver.Drive(
|
|
||||||
bodyPos, bodyRot, dest,
|
|
||||||
minDistance: 2.0f, distanceToObject: 0.6f,
|
|
||||||
dt: 0.016f, moveTowards: true,
|
|
||||||
out _);
|
|
||||||
|
|
||||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Drive_ChasingButNotInRange_ReportsSteering()
|
|
||||||
{
|
|
||||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
|
||||||
var bodyRot = Quaternion.Identity; // facing +Y
|
|
||||||
var dest = new Vector3(0f, 50f, 0f); // straight ahead
|
|
||||||
|
|
||||||
var result = RemoteMoveToDriver.Drive(
|
|
||||||
bodyPos, bodyRot, dest,
|
|
||||||
minDistance: 0f, distanceToObject: 0f,
|
|
||||||
dt: 0.016f, moveTowards: true,
|
|
||||||
out var newOrient);
|
|
||||||
|
|
||||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
|
||||||
// Already facing target → snap branch keeps yaw at 0.
|
|
||||||
Assert.InRange(Yaw(newOrient), -Epsilon, Epsilon);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Drive_TargetSlightlyOffAxis_SnapsWithinTolerance()
|
|
||||||
{
|
|
||||||
// Body facing +Y; target at (1, 10, 0) — that's a small angle
|
|
||||||
// (about 5.7°), well within the 20° snap tolerance.
|
|
||||||
var bodyPos = Vector3.Zero;
|
|
||||||
var bodyRot = Quaternion.Identity;
|
|
||||||
var dest = new Vector3(1f, 10f, 0f);
|
|
||||||
|
|
||||||
var result = RemoteMoveToDriver.Drive(
|
|
||||||
bodyPos, bodyRot, dest,
|
|
||||||
minDistance: 0f, distanceToObject: 0f,
|
|
||||||
dt: 0.016f, moveTowards: true,
|
|
||||||
out var newOrient);
|
|
||||||
|
|
||||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
|
||||||
// Snap should land us pointing at (1, 10): yaw = atan2(-1, 10) ≈ -0.0997 rad.
|
|
||||||
float expectedYaw = MathF.Atan2(-1f, 10f);
|
|
||||||
Assert.InRange(Yaw(newOrient), expectedYaw - Epsilon, expectedYaw + Epsilon);
|
|
||||||
|
|
||||||
// Verify orientation actually transforms +Y onto the (1,10) line.
|
|
||||||
var worldFwd = Vector3.Transform(new Vector3(0, 1, 0), newOrient);
|
|
||||||
Assert.InRange(worldFwd.X / worldFwd.Y, 0.1f - 1e-3f, 0.1f + 1e-3f);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Drive_TargetBeyondTolerance_RotatesByLimitedStep()
|
|
||||||
{
|
|
||||||
// Body facing +Y; target at (-10, 0) — that's 90° to the left
|
|
||||||
// (well beyond the 20° snap tolerance), so we turn by at most
|
|
||||||
// TurnRateRadPerSec * dt this tick rather than snapping.
|
|
||||||
var bodyPos = Vector3.Zero;
|
|
||||||
var bodyRot = Quaternion.Identity; // yaw = 0
|
|
||||||
var dest = new Vector3(-10f, 0f, 0f); // yaw = +π/2 (left)
|
|
||||||
const float dt = 0.1f;
|
|
||||||
|
|
||||||
var result = RemoteMoveToDriver.Drive(
|
|
||||||
bodyPos, bodyRot, dest,
|
|
||||||
minDistance: 0f, distanceToObject: 0f,
|
|
||||||
dt: dt, moveTowards: true,
|
|
||||||
out var newOrient);
|
|
||||||
|
|
||||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
|
||||||
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
|
|
||||||
// We should turn LEFT (positive yaw) toward the target.
|
|
||||||
Assert.InRange(Yaw(newOrient), expectedStep - Epsilon, expectedStep + Epsilon);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Drive_TargetBehind_TurnsRightOrLeftViaShortestPath()
|
|
||||||
{
|
|
||||||
// Body facing +Y; target directly behind at (0, -10, 0).
|
|
||||||
// |delta| = π, equally close either way; the implementation
|
|
||||||
// picks one (sign depends on float wobble) — just assert
|
|
||||||
// we made progress (yaw changed by exactly TurnRate * dt).
|
|
||||||
var bodyPos = Vector3.Zero;
|
|
||||||
var bodyRot = Quaternion.Identity;
|
|
||||||
var dest = new Vector3(0f, -10f, 0f);
|
|
||||||
const float dt = 0.1f;
|
|
||||||
|
|
||||||
var result = RemoteMoveToDriver.Drive(
|
|
||||||
bodyPos, bodyRot, dest,
|
|
||||||
minDistance: 0f, distanceToObject: 0f,
|
|
||||||
dt: dt, moveTowards: true,
|
|
||||||
out var newOrient);
|
|
||||||
|
|
||||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
|
||||||
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
|
|
||||||
Assert.InRange(MathF.Abs(Yaw(newOrient)), expectedStep - Epsilon, expectedStep + Epsilon);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Drive_PreservesOrientationAtArrival()
|
|
||||||
{
|
|
||||||
var bodyPos = new Vector3(5f, 5f, 0f);
|
|
||||||
var bodyRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.234f);
|
|
||||||
var dest = new Vector3(5.01f, 5.01f, 0f);
|
|
||||||
|
|
||||||
var result = RemoteMoveToDriver.Drive(
|
|
||||||
bodyPos, bodyRot, dest,
|
|
||||||
minDistance: 0.5f, distanceToObject: 0.6f,
|
|
||||||
dt: 0.016f, moveTowards: true,
|
|
||||||
out var newOrient);
|
|
||||||
|
|
||||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
|
||||||
// Caller would zero velocity; orientation should be untouched
|
|
||||||
// so the body settles facing whatever direction it was already.
|
|
||||||
Assert.Equal(bodyRot, newOrient);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ClampApproachVelocity_NoOverShoot_LandsExactlyAtThreshold()
|
|
||||||
{
|
|
||||||
// Body 1 m from destination, running at 4 m/s, dt = 0.1 s.
|
|
||||||
// Naive advance = 0.4 m → would end at 0.6 m from dest, exactly
|
|
||||||
// on the threshold. With threshold=0.6 and remaining=0.4, the
|
|
||||||
// clamp should let the full velocity through (advance == remaining).
|
|
||||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
|
||||||
var dest = new Vector3(0f, 1f, 0f);
|
|
||||||
var vel = new Vector3(0f, 4f, 0f);
|
|
||||||
|
|
||||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
|
||||||
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.1f, moveTowards: true);
|
|
||||||
|
|
||||||
// Within float-precision: 4 m/s × 0.1 s = 0.4 m, exactly the
|
|
||||||
// remaining distance. The clamp may apply a 0.99999×-style
|
|
||||||
// tiny scale due to FP rounding — accept anything ≥ 99.9% of
|
|
||||||
// the input as "no meaningful overshoot prevention applied."
|
|
||||||
Assert.InRange(clamped.Y, 4f * 0.999f, 4f);
|
|
||||||
Assert.Equal(0f, clamped.X);
|
|
||||||
Assert.Equal(0f, clamped.Z);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ClampApproachVelocity_WouldOverShoot_ScalesDownToExactLanding()
|
|
||||||
{
|
|
||||||
// Body 1 m from destination, running at 4 m/s, dt = 0.2 s.
|
|
||||||
// Naive advance = 0.8 m → would overshoot 0.6 m threshold by 0.4 m.
|
|
||||||
// remaining = 0.4 m, advance = 0.8 m → scale = 0.5.
|
|
||||||
// Velocity should be halved → 2 m/s.
|
|
||||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
|
||||||
var dest = new Vector3(0f, 1f, 0f);
|
|
||||||
var vel = new Vector3(0f, 4f, 0f);
|
|
||||||
|
|
||||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
|
||||||
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.2f, moveTowards: true);
|
|
||||||
|
|
||||||
Assert.InRange(clamped.Y, 2f - Epsilon, 2f + Epsilon);
|
|
||||||
Assert.Equal(0f, clamped.X);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ClampApproachVelocity_AlreadyAtThreshold_ZeroesHorizontal()
|
|
||||||
{
|
|
||||||
// Body exactly 0.6 m from dest with threshold 0.6 → remaining ≈ 0.
|
|
||||||
// Any horizontal velocity would overshoot; clamp must zero it.
|
|
||||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
|
||||||
var dest = new Vector3(0f, 0.6f, 0f);
|
|
||||||
var vel = new Vector3(0f, 4f, 0.5f); // some Z to confirm Z is preserved
|
|
||||||
|
|
||||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
|
||||||
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.016f, moveTowards: true);
|
|
||||||
|
|
||||||
Assert.Equal(0f, clamped.X);
|
|
||||||
Assert.Equal(0f, clamped.Y);
|
|
||||||
Assert.Equal(0.5f, clamped.Z); // gravity / Z handling unaffected
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ClampApproachVelocity_FleeBranch_NoOp()
|
|
||||||
{
|
|
||||||
// moveTowards=false (flee): no overshoot risk, return velocity unchanged.
|
|
||||||
var bodyPos = Vector3.Zero;
|
|
||||||
var dest = new Vector3(0f, 1f, 0f);
|
|
||||||
var vel = new Vector3(0f, -4f, 0f);
|
|
||||||
|
|
||||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
|
||||||
bodyPos, vel, dest, arrivalThreshold: 5f, dt: 0.5f, moveTowards: false);
|
|
||||||
|
|
||||||
Assert.Equal(vel, clamped);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void OriginToWorld_AppliesLandblockGridShift()
|
|
||||||
{
|
|
||||||
// Cell ID 0xA8B4000E → landblock x=0xA8, y=0xB4. With live center
|
|
||||||
// at (0xA9, 0xB4), that's one landblock west and zero north,
|
|
||||||
// so origin (10, 20, 0) inside that landblock should map to
|
|
||||||
// (10 - 192, 20 + 0, 0) = (-182, 20, 0) in render-world space.
|
|
||||||
var w = RemoteMoveToDriver.OriginToWorld(
|
|
||||||
originCellId: 0xA8B4000Eu,
|
|
||||||
originX: 10f, originY: 20f, originZ: 0f,
|
|
||||||
liveCenterLandblockX: 0xA9, liveCenterLandblockY: 0xB4);
|
|
||||||
|
|
||||||
Assert.Equal(-182f, w.X);
|
|
||||||
Assert.Equal(20f, w.Y);
|
|
||||||
Assert.Equal(0f, w.Z);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TurnRateFor_WalkingReturnsBaseRate()
|
|
||||||
{
|
|
||||||
// Retail: omega.z = ±π/2 × turn_speed (1.0) = π/2 rad/s ≈ 90°/s
|
|
||||||
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
|
|
||||||
// CMotionInterp::apply_run_to_command 0x00527be0 only
|
|
||||||
// multiplies under HoldKey.Run — walking is unscaled.
|
|
||||||
float rate = RemoteMoveToDriver.TurnRateFor(running: false);
|
|
||||||
Assert.Equal(MathF.PI / 2.0f, rate, precision: 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TurnRateFor_RunningAppliesRunTurnFactor()
|
|
||||||
{
|
|
||||||
// Retail: omega.z = ±π/2 × turn_speed × run_turn_factor
|
|
||||||
// run_turn_factor = 1.5f at 0x007c8914 (PDB-named).
|
|
||||||
// apply_run_to_command (acclient_2013_pseudo_c.txt:305098)
|
|
||||||
// multiplies turn_speed by 1.5f when input is TurnRight
|
|
||||||
// under HoldKey.Run.
|
|
||||||
float rate = RemoteMoveToDriver.TurnRateFor(running: true);
|
|
||||||
Assert.Equal(MathF.PI / 2.0f * 1.5f, rate, precision: 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TurnRateRadPerSec_BackCompatStillResolvesToWalkingRate()
|
|
||||||
{
|
|
||||||
// Existing call sites that haven't yet migrated to TurnRateFor
|
|
||||||
// (e.g., RemoteMoveToDriver.Drive's TurnSpeed=1.0 callers) still
|
|
||||||
// see the walking-rate constant. Same numerical value as
|
|
||||||
// BaseTurnRateRadPerSec.
|
|
||||||
Assert.Equal(RemoteMoveToDriver.BaseTurnRateRadPerSec,
|
|
||||||
RemoteMoveToDriver.TurnRateRadPerSec, precision: 5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,41 +6,8 @@ namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
public sealed class ServerControlledLocomotionTests
|
public sealed class ServerControlledLocomotionTests
|
||||||
{
|
{
|
||||||
[Fact]
|
|
||||||
public void PlanMoveToStart_SeedsImmediateRunCycle()
|
|
||||||
{
|
|
||||||
var plan = ServerControlledLocomotion.PlanMoveToStart();
|
|
||||||
|
|
||||||
Assert.True(plan.IsMoving);
|
|
||||||
Assert.Equal(MotionCommand.RunForward, plan.Motion);
|
|
||||||
Assert.Equal(1.0f, plan.SpeedMod);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PlanMoveToStart_AppliesRetailRunRate()
|
|
||||||
{
|
|
||||||
var plan = ServerControlledLocomotion.PlanMoveToStart(
|
|
||||||
moveToSpeed: 1.25f,
|
|
||||||
runRate: 1.5f,
|
|
||||||
canRun: true);
|
|
||||||
|
|
||||||
Assert.True(plan.IsMoving);
|
|
||||||
Assert.Equal(MotionCommand.RunForward, plan.Motion);
|
|
||||||
Assert.Equal(1.875f, plan.SpeedMod);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PlanMoveToStart_UsesWalkWhenRunDisallowed()
|
|
||||||
{
|
|
||||||
var plan = ServerControlledLocomotion.PlanMoveToStart(
|
|
||||||
moveToSpeed: 0.75f,
|
|
||||||
runRate: 2.0f,
|
|
||||||
canRun: false);
|
|
||||||
|
|
||||||
Assert.True(plan.IsMoving);
|
|
||||||
Assert.Equal(MotionCommand.WalkForward, plan.Motion);
|
|
||||||
Assert.Equal(0.75f, plan.SpeedMod);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PlanFromVelocity_StopsBelowRetailNoiseThreshold()
|
public void PlanFromVelocity_StopsBelowRetailNoiseThreshold()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue