diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index d7451a1b..d294e6c6 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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 | |---|---|---|---|---|---| @@ -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-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-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-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 | @@ -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-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-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-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) | @@ -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-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-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) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 880d2bbf..be458af6 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -200,6 +200,12 @@ public sealed class PlayerMovementController private bool _prevRunHeld; 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. // Cadence is 1.0 sec to match holtburger's // AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace @@ -533,10 +539,10 @@ public sealed class PlayerMovementController /// for the rest of to consume. /// /// - /// Heading correction matches - /// — ± + /// Heading correction matches the deleted RemoteMoveToDriver.Drive + /// — ±its 20-degree HeadingSnapTolerance /// snap-on-aligned, otherwise rotate at - /// . Arrival + /// pi/2 rad/s. Arrival /// predicate matches retail's /// MoveToManager::HandleMoveToPosition: chase arrives at /// distanceToObject; flee arrives at minDistance. @@ -618,7 +624,7 @@ public sealed class PlayerMovementController (_autoWalkMoveTowards && dist <= arrivalThreshold) || (!_autoWalkMoveTowards - && dist >= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon); + && dist >= arrivalThreshold + AutoWalkArrivalEpsilon); // Step Yaw toward target. Convention from Update line 364: // _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 // turn rate: running rotation is 50% faster per // 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); Yaw += yawStep; while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI; @@ -1011,7 +1017,7 @@ public sealed class PlayerMovementController if (!autoWalkConsumedMotion && _motion.InterpretedState.TurnCommand == MotionCommand.TurnRight) { - Yaw -= RemoteMoveToDriver.BaseTurnRateRadPerSec + Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver * _motion.InterpretedState.TurnSpeed * dt; } Yaw -= input.MouseDeltaX * MouseTurnSensitivity; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4c1c114f..5c187026 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -429,6 +429,21 @@ public sealed class GameWindow : IDisposable /// public System.Numerics.Vector3 ServerVelocity; public bool HasServerVelocity; + + /// R4-V4: the entity's verbatim retail MoveToManager + /// (server-directed movement), constructed + seam-bound by + /// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. + 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; /// /// True while a server MoveToObject/MoveToPosition packet is the /// active locomotion source. Retail runs these through MoveToManager @@ -4191,7 +4206,7 @@ public sealed class GameWindow : IDisposable /// the VectorUpdate path regardless of arrival order. /// private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings( - RemoteMotion rm, AnimatedEntity ae) + RemoteMotion rm, AnimatedEntity ae, uint serverGuid) { if (ae.Sequencer is null) return rm.Sink; @@ -4218,6 +4233,44 @@ public sealed class GameWindow : IDisposable // R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions // 0x0050fe30 -> MotionTableManager.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; } @@ -4400,24 +4453,13 @@ public sealed class GameWindow : IDisposable // otherwise → resolve class byte and use full cmd float speedMod = update.MotionState.ForwardSpeed ?? 1f; uint fullMotion; - if ((!command.HasValue || command.Value == 0) - && update.MotionState.IsServerControlledMoveTo) - { - // Retail MoveToManager::BeginMoveForward calls - // MovementParameters::get_command (0x0052AA00), then - // _DoMotion -> adjust_motion. With CanRun and enough - // distance, WalkForward + HoldKey_Run becomes RunForward, - // 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) + // R4-V4: the PlanMoveToStart seed is DELETED — MoveTo UMs no + // longer flow through the interpreted funnel at all (retail + // unpack_movement routes types 6-9 to MoveToManager; only type + // 0 does the interpreted-state copy). The manager's own + // BeginMoveForward -> get_command -> _DoMotion produces the + // cycle through the same sink every other motion uses. + if (!command.HasValue || command.Value == 0) { fullMotion = 0x41000003u; } @@ -4510,7 +4552,7 @@ public sealed class GameWindow : IDisposable && update.MotionState.MoveToPath is { } pathData) { // Translate landblock-local origin → world space. - var destWorld = AcDream.Core.Physics.RemoteMoveToDriver + var destWorld = AcDream.Core.Physics.Motion.MoveToMath .OriginToWorld( pathData.OriginCellId, pathData.OriginX, @@ -4563,13 +4605,97 @@ public sealed class GameWindow : IDisposable // contact_allows_move gate, the retail mechanism behind the // old K-fix17 guard). // - // MoveTo packets (mt 6/7) reuse the PlanMoveToStart seed as - // the funnel's forward command (fullMotion/speedMod computed - // above) and additionally capture the path for the per-tick - // remote driver. + // R4-V4: retail unpack_movement dispatch (0x00524440) — the + // head-interrupt fires for EVERY movement type, then types + // 6/7/8/9 route to MoveToManager.PerformMovement (which + // 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)) { - 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 // a forward-command change (pre-S2 behavior, unchanged: @@ -4588,30 +4714,9 @@ public sealed class GameWindow : IDisposable 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 // 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) // defaults an absent stance to NonCombat 0x8000003D — // 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 // motion-table stack (GetObjectSequence + is_allowed - // decide). The sink is PERSISTENT per remote and also - // bound as Motion.DefaultSink + the RemoveLink/InitTables - // seams (EnsureRemoteMotionBindings) so LeaveGround/ - // HitGround dispatch through the same backend. - var sink = EnsureRemoteMotionBindings(remoteMot, ae); + // decide) — mt-0 only post-V4 (types 6-9 returned above). remoteMot.Motion.MoveToInterpretedState(ims, sink); } } @@ -4854,7 +4955,7 @@ public sealed class GameWindow : IDisposable && _animatedEntities.TryGetValue(ent.Id, out var ae) && ae.Sequencer is not null) { - EnsureRemoteMotionBindings(rm, ae); + EnsureRemoteMotionBindings(rm, ae, update.Guid); rm.Motion.LeaveGround(); rm.Body.Velocity = update.Velocity; rm.Body.Omega = update.Omega; @@ -4910,11 +5011,10 @@ public sealed class GameWindow : IDisposable { if (rm.Airborne) return; if (ae.Sequencer is null) return; - // MoveTo packets already seeded the retail speed/runRate cycle. - // Keep UpdatePosition-derived velocity for render position only; - // using it to choose the cycle reverts fast chases back to slow - // velocity-estimated animation. - if (rm.ServerMoveToActive) return; + // R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's + // get_command dispatch). Velocity-estimated cycle planning would + // fight it — same rationale as the pre-V4 ServerMoveToActive guard. + if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return; if (IsPlayerGuid(serverGuid)) { @@ -4960,7 +5060,7 @@ public sealed class GameWindow : IDisposable + $"|v|={velocity.Length():F2} " + $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} " + $"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); } @@ -5326,7 +5426,6 @@ public sealed class GameWindow : IDisposable // carries no stop information for our ACE. if (svel.LengthSquared() < 0.04f) { - rmState.ServerMoveToActive = false; rmState.Motion.StopCompletely(); if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop) && aeForStop.Sequencer is not null) @@ -9591,93 +9690,52 @@ public sealed class GameWindow : IDisposable rm.Body.Velocity = rm.ServerVelocity; } } - else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive - && rm.HasMoveToDestination) - { - // Phase L.1c port of retail MoveToManager per-tick - // steering (HandleMoveToPosition @ 0x00529d80). - // Steer body orientation toward the latest - // server-supplied destination, then let - // apply_current_movement set Velocity from the - // RunForward cycle through the now-correct heading. - - // Stale-destination guard (2026-04-28): if no - // MoveTo packet has refreshed the destination - // recently, the entity has likely left our - // streaming view or the server cancelled the - // 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; - rm.Body.Velocity = System.Numerics.Vector3.Zero; - } - else - { - var driveResult = AcDream.Core.Physics.RemoteMoveToDriver - .Drive( - rm.Body.Position, - rm.Body.Orientation, - rm.MoveToDestinationWorld, - rm.MoveToMinDistance, - rm.MoveToDistanceToObject, - (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); - } - } - } - 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 { + // R4-V4: the retail MoveToManager drives + // server-directed movement per tick — the P4 + // TargetTracker adapter feeds HandleUpdateTarget + // from the live entity table (full TargetManager + // port is R5; register row), then UseTime runs + // HandleMoveToPosition/HandleTurnToHeading + // (steering + arrival + fail-distance), and + // apply_current_movement recomputes velocity from + // whatever the manager dispatched — the same + // per-tick shape ordinary locomotion uses. + if (!IsPlayerGuid(serverGuid) && rm.MoveTo is { } mtm) + { + if (rm.TrackedTargetGuid != 0) + { + 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 + { + var lp = new AcDream.Core.Physics.Position( + rm.CellId, rm.TrackedTargetLastFedPos, + System.Numerics.Quaternion.Identity); + mtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo( + rm.TrackedTargetGuid, + AcDream.Core.Physics.Motion.TargetStatus.ExitWorld, + lp, lp)); + } + } + mtm.UseTime(); + } rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); } } diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 3f4caacc..24e7bdb3 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -338,7 +338,8 @@ public static class CreateObject float MinDistance, float FailDistance, float WalkRunThreshold, - float DesiredHeading); + float DesiredHeading, + uint Bitfield = 0); // R4-V4: the raw UnPackNet flags dword, feeds MovementParameters.FromWire /// /// R4-V3 (closes M7) — path-control payload of a server-controlled diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index e061b4da..2040484e 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -406,7 +406,8 @@ public static class UpdateMotion minDistance, failDistance, walkRunThreshold, - desiredHeading); + desiredHeading, + movementParameters ?? 0u); // R4-V4: the raw flags dword -> FromWire return true; } diff --git a/src/AcDream.Core/Physics/Motion/MoveToMath.cs b/src/AcDream.Core/Physics/Motion/MoveToMath.cs index ab30efaa..9dac5942 100644 --- a/src/AcDream.Core/Physics/Motion/MoveToMath.cs +++ b/src/AcDream.Core/Physics/Motion/MoveToMath.cs @@ -198,4 +198,26 @@ public static class MoveToMath float edgeDist = centerDist - ownRadius - targetRadius; return edgeDist > 0f ? edgeDist : 0f; } + + /// + /// Landblock-local wire origin → world space (verbatim relocation from + /// the deleted RemoteMoveToDriver.OriginToWorld, R4-V4): MoveTo / + /// TurnTo packets carry positions block-local; acdream's streaming world + /// re-centers on a live-center landblock grid. + /// + 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); + } } diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs deleted file mode 100644 index 68f337ae..00000000 --- a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs +++ /dev/null @@ -1,344 +0,0 @@ -using System; -using System.Numerics; - -namespace AcDream.Core.Physics; - -/// -/// Per-tick steering for server-controlled remote creatures while a -/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet -/// is the active locomotion source. -/// -/// -/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo" -/// stabilizer. With the full MoveTo path payload now captured on -/// , -/// 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. -/// -/// -/// -/// Retail references: -/// -/// -/// MoveToManager::HandleMoveToPosition (0x00529d80) — the -/// per-tick driver. Computes heading-to-target, fires an aux -/// TurnLeft/TurnRight command when |delta| > 20°, snaps -/// orientation when within tolerance, and tests arrival via -/// dist <= min_distance (chase) or -/// dist >= distance_to_object (flee). -/// -/// -/// MoveToManager::_DoMotion / _StopMotion route turn -/// commands through CMotionInterp::DoInterpretedMotion — i.e. -/// MoveToManager itself does NOT touch the body. The body's actual -/// velocity comes from CMotionInterp::apply_current_movement -/// reading InterpretedState.ForwardCommand = RunForward and -/// emitting velocity.Y = RunAnimSpeed × speedMod, transformed by -/// the body's orientation. -/// -/// -/// -/// -/// -/// 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 set_heading(true) snap-on-aligned fudge), and -/// arrival detection via min_distance. -/// -/// -/// -/// Arrival predicates: retail and ACE AGREE — chase arrives at -/// dist <= distance_to_object, flee arrives at -/// dist >= min_distance (adjudicated in -/// docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md §1; see the -/// arrival comment in ). An earlier revision of this doc -/// claimed ACE swapped the two predicates — that was wrong; do not port -/// against it. -/// -/// -public static class RemoteMoveToDriver -{ - /// - /// Heading tolerance below which we snap orientation directly to the - /// target heading (ACE's set_heading(target, true) - /// server-tic-rate fudge). Above tolerance we rotate at - /// . Retail value (line 307251 of - /// acclient_2013_pseudo_c.txt) is 20°. - /// - public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f; - - /// - /// Default angular rate for in-motion heading correction when delta - /// exceeds . Picked to match - /// ACE's TurnSpeed default of π/2 rad/s for monsters; - /// when the per-creature value differs, the future port can wire it - /// in via the TurnSpeed field on InterpretedMotionState. - /// - public const float TurnRateRadPerSec = MathF.PI / 2.0f; - - /// - /// Retail base turn rate for the player Humanoid when turn_speed - /// scalar = 1.0. Convention default omega.z = ±π/2 rad/s - /// derived from add_motion at 0x005224b0 + the - /// HasOmega-cleared MotionData fallback documented in - /// AnimationSequencer.cs:734-741. ~90°/s. - /// - public const float BaseTurnRateRadPerSec = MathF.PI / 2.0f; - - /// - /// Retail's run_turn_factor constant at 0x007c8914 - /// (PDB-named). CMotionInterp::apply_run_to_command - /// equivalent (decomp 0x00527be0, line 305098 of - /// acclient_2013_pseudo_c.txt) multiplies turn_speed - /// by 1.5 when HoldKey.Run is active on a TurnRight/TurnLeft - /// command. Effect: running rotation is 50 % faster than walking. - /// - public const float RunTurnFactor = 1.5f; - - /// - /// Retail-faithful local-player turn rate. - /// - /// Walking: BaseTurnRateRadPerSec ≈ 90°/s. - /// Running: BaseTurnRateRadPerSec × RunTurnFactor - /// ≈ 135°/s. - /// - /// Replaces the fixed TurnRateRadPerSec 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 - /// BaseTurnRateRadPerSec. - /// - public static float TurnRateFor(bool running) - => running ? BaseTurnRateRadPerSec * RunTurnFactor - : BaseTurnRateRadPerSec; - - /// - /// Float-comparison slack for the arrival predicate. With - /// min_distance == 0 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. - /// - public const float ArrivalEpsilon = 0.05f; - - /// - /// 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. - /// - public const double StaleDestinationSeconds = 1.5; - - public enum DriveResult - { - /// Within arrival window — caller should zero velocity. - Arrived, - /// Steering active — caller should let - /// apply_current_movement set body velocity from the cycle. - Steering, - } - - /// - /// Steer body orientation toward - /// and report whether the body has arrived or should keep running. - /// Pure function — emits the updated orientation via - /// (the input is not mutated; the - /// caller assigns the new value back to its body). - /// - /// - /// min_distance from the wire's MovementParameters block — - /// retail's HandleMoveToPosition chase-arrival threshold. - /// - /// - /// distance_to_object from the wire — ACE's chase-arrival - /// threshold (default 0.6 m, the melee range). The actual arrival - /// gate is max(minDistance, distanceToObject): retail-faithful - /// when retail sends min_distance > 0, ACE-compatible when - /// ACE puts the value in distance_to_object with - /// min_distance == 0. Without this, ACE's min_distance==0 - /// 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). - /// - 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 max(MinDistance, DistanceToObject) 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; - } - - /// - /// Convert a landblock-local Origin from a MoveTo packet - /// () - /// into acdream's render world space using the same arithmetic as - /// OnLivePositionUpdated: shift by the landblock-grid offset - /// from the live-mode center. - /// - 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); - } - - /// - /// Cap horizontal velocity so the body lands exactly at - /// rather than overshooting past - /// it during the final tick of approach. Without this clamp, a body - /// running at RunAnimSpeed × speedMod ≈ 4 m/s 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). - /// - /// - /// The clamp is a strict scale-down of the horizontal component - /// (X/Y); the vertical component (Z) is left to gravity / terrain - /// handling. false (flee branch) is a - /// no-op since fleeing has no overshoot risk — the body wants to - /// move AWAY from the destination. - /// - /// - 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); - } - - /// Wrap an angle in radians to [-π, π]. - 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; - } -} diff --git a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs index af4d14dd..6444b3da 100644 --- a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs +++ b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs @@ -32,24 +32,10 @@ public static class ServerControlledLocomotion public const float MinSpeedMod = 0.25f; public const float MaxSpeedMod = 3.00f; - // Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command - // (0x0052AA00) seeds forward motion before the next position update. - public static LocomotionCycle PlanMoveToStart( - float moveToSpeed = 1f, - 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); - } + // R4-V4: PlanMoveToStart DELETED - MoveTo UMs route to the verbatim + // MoveToManager (BeginMoveForward -> get_command -> _DoMotion produces + // the cycle through the same sink every other motion uses). + // PlanFromVelocity below survives (M16 register row, retires in R6). public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity) { diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs deleted file mode 100644 index 659452a6..00000000 --- a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs +++ /dev/null @@ -1,330 +0,0 @@ -using System; -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// Phase L.1c (2026-04-28). Covers — the -/// per-tick steering port of retail -/// MoveToManager::HandleMoveToPosition for server-controlled remote -/// creatures. -/// -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); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs index 65cc50da..9ea2ca04 100644 --- a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs @@ -6,41 +6,8 @@ namespace AcDream.Core.Tests.Physics; 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] public void PlanFromVelocity_StopsBelowRetailNoiseThreshold()