diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 300919e..1633f4a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2424,6 +2424,82 @@ public sealed class GameWindow : IDisposable // current cyclic tail and currState.Substate remains Ready. // Treating 0x10000051/52/53 as SetCycle commands made the // immediate follow-up Ready packet abort the swing. + // Phase L.1c followup (2026-04-28): the next two state-update + // blocks are LIFTED out of the substate-only `else` branch so + // they run for BOTH overlay (Action/Modifier/ChatEmote) and + // substate (Walk/Run/Ready/etc) packets. Two separate research + // agents converged on the same root cause for the user- + // observed "creature just runs instead of attacking" symptom: + // + // 1. Attack swings arrive as mt=0 with + // ForwardCommand=AttackHigh1 (Action class). Retail's + // CMotionInterp::move_to_interpreted_state + // (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies + // forward_command from the wire into the body's + // InterpretedState UNCONDITIONALLY. With + // forward_command=AttackHigh1, get_state_velocity + // returns 0 because its gate is RunForward||WalkForward + // — body stops moving forward. + // + // 2. The acdream overlay branch was routing through + // PlayAction (animation overlay) but skipping ALL of: + // - ServerMoveToActive flag update + // - MoveToPath capture + // - InterpretedState.ForwardCommand assignment + // So during a swing UM, the body's InterpretedState + // stayed at RunForward from the prior MoveTo packet, + // ServerMoveToActive stayed true, and the per-tick + // remote driver kept steering + applying RunForward + // velocity through every frame. + // + // Note: we bypass DoInterpretedMotion / ApplyMotionToInterpretedState + // here because the latter is a heuristic that ONLY handles + // WalkForward / RunForward / WalkBackward / SideStep / Turn + // / Ready (MotionInterpreter.cs:941-970). For an Action + // command (e.g. AttackHigh1 = 0x10000062) the switch falls + // through and InterpretedState is silently NOT updated — + // exactly the bug we are fixing. Direct field assignment + // matches retail's copy_movement_from bulk-copy + // (acclient_2013_pseudo_c.txt:293301-293311). + if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) + { + remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; + + 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; + + // Bulk-copy the wire's resolved ForwardCommand + speed + // into InterpretedState. For Action commands this + // makes apply_current_movement return zero velocity + // on the next tick (gate fails). For substate + // commands (Run/Walk/Ready), this is identical to + // what DoInterpretedMotion would have written. + remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion; + remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod <= 0f ? 1f : speedMod; + } + } + if (forwardIsOverlay) { if (!remoteIsAirborne) @@ -2499,47 +2575,17 @@ public sealed class GameWindow : IDisposable // FUN_00528f70 DoInterpretedMotion // FUN_00528960 get_state_velocity // FUN_00529210 apply_current_movement - if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) + // ServerMoveToActive flag, MoveToPath capture, and the + // InterpretedState.ForwardCommand bulk-copy are already + // handled by the LIFTED block above (so overlay-class swings + // also clear stale MoveTo state and update the body's + // forward command). This branch only handles sidestep / + // turn axes plus the ObservedOmega seed — none of which + // appear on overlay packets, so the existing logic is + // correct unchanged. (`remoteMot` is the same dictionary + // entry obtained at the top of the lifted block.) + if (remoteMot is not null) { - remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; - - // Phase L.1c (2026-04-28): capture the full MoveTo path - // payload so the per-tick remote driver can steer the - // body toward Origin instead of holding velocity at zero - // between sparse UpdatePosition snaps. Retail - // MoveToManager::MoveToPosition stores the same fields - // (acclient_2013_pseudo_c.txt:307521-307593). - 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) - { - // Cycle changed off MoveTo — clear stale destination - // so the per-tick driver doesn't keep steering after - // the server has switched us back to interpreted - // motion. - remoteMot.HasMoveToDestination = false; - } - - // Forward axis (Ready / WalkForward / RunForward / WalkBackward). - remoteMot.Motion.DoInterpretedMotion( - fullMotion, speedMod, modifyInterpretedState: true); - // Sidestep axis. if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0) { diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs index 54152b0..0981666 100644 --- a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs +++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs @@ -147,19 +147,31 @@ public static class RemoteMoveToDriver float dy = destinationWorld.Y - bodyPosition.Y; float dist = MathF.Sqrt(dx * dx + dy * dy); - // Arrival predicate. Retail (named decomp): dist ≤ min_distance. - // ACE port: dist ≤ DistanceToObject. ACE's wire put the melee - // threshold in DistanceToObject (default 0.6) and left - // MinDistance=0; retail server config presumably set MinDistance. - // Defensive port: take the larger so we honor whichever field is - // populated. Flee branch is unused here but we honor the - // moveTowards flag for symmetry. - float arrivalThreshold = MathF.Max(minDistance, distanceToObject); + // 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) diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index da042f0..09f9eb9 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -268,6 +268,41 @@ public class UpdateMotionTests Assert.Equal(90.0f, path.DesiredHeading); } + [Fact] + public void ParsesAttackHigh1_AsActionForwardCommand() + { + // Phase L.1c followup (2026-04-28): regression that verifies the + // wire-format ACE uses for melee swings — mt=0 with + // ForwardCommand=AttackHigh1 (0x0062 in low 16 bits) and + // ForwardSpeed (typically the animSpeed). The receiver in + // GameWindow.OnLiveMotionUpdated relies on this layout to bulk-copy + // ForwardCommand into the body's InterpretedState so that + // get_state_velocity returns 0 (gate is RunForward||WalkForward). + var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 4]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x800003B5u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 6; // header padding + + body[p++] = 0; // mt = Invalid (interpreted) + body[p++] = 0; // motion_flags + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003C); p += 2; // stance: HandCombat + + // InterpretedMotionState: flags = ForwardCommand (0x02) | ForwardSpeed (0x04) + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x06u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0062); p += 2; // AttackHigh1 low bits + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // animSpeed + + var result = UpdateMotion.TryParse(body); + + Assert.NotNull(result); + Assert.Equal((byte)0, result!.Value.MotionState.MovementType); + Assert.False(result.Value.MotionState.IsServerControlledMoveTo); + Assert.Equal((ushort)0x0062, result.Value.MotionState.ForwardCommand); + Assert.Equal(1.25f, result.Value.MotionState.ForwardSpeed); + } + [Fact] public void ParsesMoveToObjectTargetGuidAndOrigin() { diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs index 3274702..ece3f9b 100644 --- a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs @@ -60,12 +60,30 @@ public class RemoteMoveToDriverTests } [Fact] - public void Drive_RetailMinDistanceWins_WhenLargerThanDistanceToObject() + public void Drive_FleeArrival_UsesMinDistance() { - // Hypothetical retail packet: MinDistance=2.0 (set explicitly), - // DistanceToObject=0.6 (default). Arrival should fire at 2 m - // because retail's algorithm uses MinDistance and it's the larger - // of the two. + // 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); @@ -76,7 +94,7 @@ public class RemoteMoveToDriverTests dt: 0.016f, moveTowards: true, out _); - Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); + Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); } [Fact]