pathing: stabilize 0-replan round-trip on ledge/descend runs

Fix a cluster of execution-layer issues that caused replans and void
falls when traversing narrow ledges and multi-block descents between
(251.5,141,210.5) and (252.5,138,220.5):

- WalkTemplate / GroundedSegmentController: suppress the pre-rotation
  bias toward the next segment's exit heading on stable-footing Turn
  exits where the next segment is not a jump.  The next template
  snaps yaw on its first tick anyway, and pre-rotating mid-stride on
  a 1-block walkway pushes sprint drift perpendicular to the path and
  walks the bot off the edge.  Turn exits into a jump still get the
  bias so the takeoff direction stays aligned.

- GroundedSegmentController.ShouldComplete: relax the headingReady
  gate for Turn exits with stable footing so the segment can complete
  once yaw is aligned with either the current or the next segment
  heading (within 25/15 deg).  Without this the removed bias would
  leave the bot stuck at the end of a walkway waiting for a rotation
  that never happens.

- DescendTemplate: restrict the airborne exit-heading bias so it only
  kicks in when the footprint is inside the landing block, or on
  single-step drops where the fall is too short for lateral drift to
  miss the landing column.  On 2+ block drops the bot now keeps yaw
  pointed at the landing center for the whole fall.

- DescendTemplate: add a multi-block overshoot guard on PrepareJump
  exits.  Once airborne and past the landing end-plane on a 2+ Y
  drop, release forward/sprint and press back briefly so air drag
  pulls the bot back into the 1x1 landing column instead of sailing
  one block past it into the neighbouring void.

Live round-trip between the two goal coordinates now completes with
zero replans in three consecutive runs in each direction.  Full unit
test suite is unchanged from the pre-existing baseline (22 failing
tests, all orthogonal to this change).

Made-with: Cursor
This commit is contained in:
BruceChen 2026-04-22 16:43:43 +00:00
parent d002930a6a
commit 5de169db64
19 changed files with 1418 additions and 171 deletions

View file

@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MinecraftClient.Mapping;
@ -33,6 +35,7 @@ namespace MinecraftClient.Pathing.Execution
private PathExecutor? _nextExecutor;
private IGoal? _goal;
private int _replanCount;
private bool _isInitialPlan;
private Task<PathResult>? _pendingReplan;
private CancellationTokenSource? _pendingReplanCts;
@ -40,6 +43,19 @@ namespace MinecraftClient.Pathing.Execution
private CancellationTokenSource? _pendingLookaheadCts;
private (int x, int y, int z)? _pendingLookaheadAnchor;
/// <summary>
/// Set to true to emit Info-level diagnostic traces (full path node dump,
/// failing-segment context, recent position tail) on every plan/replan
/// event. Users enable this via <c>/pathdiag on</c> when reporting pathing
/// bugs so the default log level stays quiet.
/// </summary>
public static bool DiagnosticsEnabled { get; set; }
private const int DiagnosticsTailSize = 64;
private readonly Queue<string> _diagnosticsTail = new(DiagnosticsTailSize + 1);
private PathResult? _lastPlan;
private int _lastObservedSegmentIndex = -1;
public bool IsNavigating =>
(_executor is not null && !_executor.IsComplete)
|| _nextExecutor is not null
@ -61,6 +77,7 @@ namespace MinecraftClient.Pathing.Execution
_nextExecutor = null;
_goal = goal;
_replanCount = 0;
_isInitialPlan = false;
if (result.Status == PathStatus.Failed || result.Path.Count < 2)
{
_infoLog?.Invoke("[PathMgr] Navigation rejected -- no path found.");
@ -71,9 +88,53 @@ namespace MinecraftClient.Pathing.Execution
var segments = PathSegmentBuilder.FromPath(result.Path);
_executor = new PathExecutor(segments, _debugLog, _observer);
_lastObservedSegmentIndex = -1;
_infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments");
}
/// <summary>
/// Kicks off the initial A* search on a background task and returns
/// immediately. The main tick loop drains the task via
/// <see cref="DrainPendingReplan"/>, installing the executor when the
/// plan completes. Use this from interactive entry points (e.g.
/// <c>/goto</c>) so the 20 TPS tick loop never blocks on a long A*
/// search -- a complex climb can take the full planning budget
/// (several seconds) and freezing the tick causes the player to
/// desync, stop sending keep-alives, and miss chunk updates.
/// </summary>
public void StartNavigationAsync(IGoal goal, Location startPos, World world, long timeoutMs)
{
ArgumentNullException.ThrowIfNull(goal);
ArgumentNullException.ThrowIfNull(world);
CancelPendingTasks();
_executor = null;
_nextExecutor = null;
_goal = goal;
_replanCount = 0;
_isInitialPlan = true;
int sx = (int)Math.Floor(startPos.X);
int sy = (int)Math.Floor(startPos.Y);
int sz = (int)Math.Floor(startPos.Z);
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz))
sy++;
_debugLog?.Invoke($"[PathMgr] Initial plan kicked off from ({sx},{sy},{sz}) to {goal}");
_pendingReplanCts = new CancellationTokenSource();
CancellationToken token = _pendingReplanCts.Token;
Action<string>? debugLog = _debugLog;
long budget = timeoutMs;
_pendingReplan = Task.Run(() =>
{
var finder = new AStarPathFinder { DebugLog = debugLog };
return finder.Calculate(ctx, sx, sy, sz, goal, token, budget);
}, token);
}
public void Tick(Location pos, PlayerPhysics physics, MovementInput input, World world)
{
DrainPendingReplan(pos, world);
@ -93,6 +154,9 @@ namespace MinecraftClient.Pathing.Execution
return;
}
if (DiagnosticsEnabled)
RecordDiagnosticsSample(pos, physics);
var state = _executor.Tick(pos, physics, input, world);
switch (state)
@ -102,6 +166,8 @@ namespace MinecraftClient.Pathing.Execution
break;
case PathExecutorState.Failed:
if (DiagnosticsEnabled)
EmitSegmentFailureDiagnostics(pos);
_infoLog?.Invoke("[PathMgr] Segment failed, replanning...");
// The prepared next path assumes we finished the current segment
// cleanly, so drop it when we fail.
@ -116,6 +182,64 @@ namespace MinecraftClient.Pathing.Execution
}
}
private void RecordDiagnosticsSample(Location pos, PlayerPhysics physics)
{
if (_executor is null)
return;
int segIdx = _executor.CurrentIndex;
PathSegment? seg = _executor.CurrentSegment;
string segStr = seg is null
? "none"
: $"seg{segIdx}/{_executor.TotalSegments} {seg.MoveType} ({seg.Start.X:F1},{seg.Start.Y:F1},{seg.Start.Z:F1})->({seg.End.X:F1},{seg.End.Y:F1},{seg.End.Z:F1})";
// Emit an Info-level transition event whenever the executor steps to a
// new segment so the caller can reconstruct the full execution timeline
// without relying on the bounded tail buffer. Resets on plan install.
if (segIdx != _lastObservedSegmentIndex)
{
_lastObservedSegmentIndex = segIdx;
_infoLog?.Invoke(
$"[PathDiag] seg->{segIdx}/{_executor.TotalSegments} pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2}) yaw={physics.Yaw:F1} vy={physics.DeltaMovement.Y:F3} og={physics.OnGround} " +
(seg is null ? "none" : $"{seg.MoveType} ({seg.Start.X:F1},{seg.Start.Y:F1},{seg.Start.Z:F1})->({seg.End.X:F1},{seg.End.Y:F1},{seg.End.Z:F1}) exit={seg.ExitTransition}"));
}
_diagnosticsTail.Enqueue(
$"pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2}) yaw={physics.Yaw:F1} vy={physics.DeltaMovement.Y:F3} vx={physics.DeltaMovement.X:F3} vz={physics.DeltaMovement.Z:F3} og={physics.OnGround} {segStr}");
while (_diagnosticsTail.Count > DiagnosticsTailSize)
_diagnosticsTail.Dequeue();
}
private void EmitSegmentFailureDiagnostics(Location pos)
{
if (_executor is null)
return;
PathSegment? seg = _executor.CurrentSegment;
int segIdx = _executor.CurrentIndex;
_infoLog?.Invoke($"[PathDiag] Failure context: pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2}) failingSeg={segIdx}/{_executor.TotalSegments} " +
(seg is null ? "seg=<none>" : $"seg={seg.MoveType} ({seg.Start.X:F1},{seg.Start.Y:F1},{seg.Start.Z:F1})->({seg.End.X:F1},{seg.End.Y:F1},{seg.End.Z:F1}) exit={seg.ExitTransition}"));
if (_diagnosticsTail.Count > 0)
{
_infoLog?.Invoke($"[PathDiag] Recent tick trace (last {_diagnosticsTail.Count}):");
int i = 0;
foreach (string line in _diagnosticsTail)
_infoLog?.Invoke($"[PathDiag] t-{_diagnosticsTail.Count - i++ - 1}: {line}");
}
}
private void EmitPathDumpDiagnostics(string label, PathResult result, int startIdx = 0)
{
if (!DiagnosticsEnabled)
return;
_infoLog?.Invoke($"[PathDiag] {label}: {result.Path.Count} waypoints, status={result.Status}, nodes={result.NodesExplored}, time={result.ElapsedMs}ms");
int count = result.Path.Count;
for (int i = 0; i < count; i++)
{
var node = result.Path[i];
string move = i == 0 ? "Start" : node.MoveUsed.ToString();
_infoLog?.Invoke($"[PathDiag] [{startIdx + i:D2}] {move,-22} ({node.X},{node.Y},{node.Z})");
}
}
public void Cancel()
{
if (_executor is not null || _pendingReplan is not null || _nextExecutor is not null)
@ -254,10 +378,16 @@ namespace MinecraftClient.Pathing.Execution
_pendingReplanCts = null;
cts?.Dispose();
bool isInitial = _isInitialPlan;
_isInitialPlan = false;
if (task.IsFaulted || task.IsCanceled)
{
_infoLog?.Invoke("[PathMgr] Replan task failed or was cancelled.");
_observer?.OnReplanFailed(_replanCount, pos);
_infoLog?.Invoke(isInitial
? "[PathMgr] Initial plan failed or was cancelled."
: "[PathMgr] Replan task failed or was cancelled.");
if (!isInitial)
_observer?.OnReplanFailed(_replanCount, pos);
_goal = null;
_executor = null;
_nextExecutor = null;
@ -283,8 +413,11 @@ namespace MinecraftClient.Pathing.Execution
if (result.Status == PathStatus.Failed || result.Path.Count < 2)
{
_observer?.OnReplanFailed(_replanCount, pos);
_infoLog?.Invoke("[PathMgr] Replan failed -- no path found.");
if (!isInitial)
_observer?.OnReplanFailed(_replanCount, pos);
_infoLog?.Invoke(isInitial
? $"[PathMgr] No path found (nodes={result.NodesExplored}, time={result.ElapsedMs}ms)."
: "[PathMgr] Replan failed -- no path found.");
_executor = null;
_nextExecutor = null;
_goal = null;
@ -292,10 +425,25 @@ namespace MinecraftClient.Pathing.Execution
}
var segments = PathSegmentBuilder.FromPath(result.Path);
_observer?.OnReplanSucceeded(_replanCount, segments);
_executor = new PathExecutor(segments, _debugLog, _observer);
_nextExecutor = null;
_infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})");
_lastPlan = result;
_diagnosticsTail.Clear();
_lastObservedSegmentIndex = -1;
if (isInitial)
{
_executor = new PathExecutor(segments, _debugLog, _observer);
_nextExecutor = null;
string partial = result.Status == PathStatus.Partial ? " (partial)" : "";
_infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments, nodes={result.NodesExplored}, time={result.ElapsedMs}ms{partial}");
EmitPathDumpDiagnostics("Initial plan", result);
}
else
{
_observer?.OnReplanSucceeded(_replanCount, segments);
_executor = new PathExecutor(segments, _debugLog, _observer);
_nextExecutor = null;
_infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})");
EmitPathDumpDiagnostics($"Replan #{_replanCount} plan", result);
}
}
private void MaybeStartLookahead(World world)

View file

@ -20,6 +20,20 @@ namespace MinecraftClient.Pathing.Execution.Templates
private const double EdgeCloseDistance = 1.2;
private const double LateralAlignmentTolerance = 0.2;
// Diagonal-ascend velocity alignment constants. Live-server
// regression: when A* routes through an "island" diagonal 1-block
// riser whose preceding segment delivered axis-aligned ground
// momentum (e.g. a cardinal Traverse along +Z landing at the foot of
// a -X+Z+Y riser), the 1-tick sprint-jump boost cannot redirect the
// perpendicular component onto the diagonal and the bot overshoots
// the target along the cardinal axis. Before firing Jump we hold
// Forward/Sprint off for up to a small window so ground friction can
// decay the perpendicular component; if we have not aligned within
// the window we take off anyway so the bot never stalls on the
// source block indefinitely.
private const double DiagonalTakeoffMaxPerpVelocity = 0.08;
private const int DiagonalTakeoffMaxBrakeTicks = 6;
public Location ExpectedStart { get; }
public Location ExpectedEnd { get; }
@ -29,6 +43,7 @@ namespace MinecraftClient.Pathing.Execution.Templates
private Location _lastPos;
private int _stuckTicks;
private bool _initiatedJump;
private int _diagonalBrakeTicks;
public AscendTemplate(PathSegment segment, PathSegment? nextSegment)
{
@ -57,7 +72,16 @@ namespace MinecraftClient.Pathing.Execution.Templates
float targetYaw = TemplateHelper.CalculateYaw(dx, dz);
float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz);
if (!groundedPrepareJumpHandoff)
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
{
// Snap yaw on the first tick so we don't drift sideways while
// rotating from a stale orientation (e.g. after a teleport or a
// sharp turn transition). The Ascend template also already gates
// forward input on headingReady below, but snapping removes one
// source of wasted ticks for narrow 1-block staircases.
physics.Yaw = _tickCount == 1
? targetYaw
: TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
}
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
float headingPenalty = YawDifference(physics.Yaw, targetYaw);
bool headingReady = headingPenalty <= 8.0;
@ -75,17 +99,69 @@ namespace MinecraftClient.Pathing.Execution.Templates
bool laterallyAligned = sideDist <= LateralAlignmentTolerance;
bool jumpReady;
if (HasHeadBonkClear(world))
if (diagonalAscend)
{
// Diagonal Ascend only reaches this path when the search
// layer has cleared the move (cardinal split not
// feasible). The source-center to target-center distance
// is ~sqrt(2) blocks, so the cardinal closeToEdge /
// sideDist gates below never fire and would stall the
// jump indefinitely; the bot must leap from the source
// block center as soon as its heading is aligned with
// the diagonal AND the horizontal velocity is close to
// the diagonal direction. If the bot arrives with strong
// cardinal momentum from a preceding Traverse (the
// common case for wall-shoulder islands), fire one or
// more ground ticks with Forward/Sprint released so
// friction can decay the perpendicular component before
// takeoff. Without this the preserved cardinal momentum
// leaks the landing footprint off the target block.
if (!headingReady)
{
jumpReady = false;
}
else
{
double diagLen = Math.Sqrt(
(double)_segment.HeadingX * _segment.HeadingX
+ (double)_segment.HeadingZ * _segment.HeadingZ);
double dirX = _segment.HeadingX / diagLen;
double dirZ = _segment.HeadingZ / diagLen;
double vx = physics.DeltaMovement.X;
double vz = physics.DeltaMovement.Z;
double perpMag = Math.Abs(vx * dirZ - vz * dirX);
if (perpMag > DiagonalTakeoffMaxPerpVelocity
&& _diagonalBrakeTicks < DiagonalTakeoffMaxBrakeTicks)
{
// Suppress this tick's acceleration so vanilla
// ground friction (~0.546/tick) alone decays the
// perpendicular component, and nudge the back
// input if the velocity is dominantly in the
// perpendicular direction - the back-input vector
// is along -yaw which is the reverse of the
// diagonal, cancelling the perpendicular faster
// than friction alone for high-speed entries.
input.Forward = false;
input.Sprint = false;
double along = vx * dirX + vz * dirZ;
if (perpMag > Math.Abs(along))
input.Back = true;
_diagonalBrakeTicks++;
jumpReady = false;
}
else
{
jumpReady = true;
}
}
}
else if (HasHeadBonkClear(world))
{
// Vertical head-room above the source block is clear, so starting the
// jump early is safe and actually makes the short hop more reliable
// (matches Baritone's "headBonkClear" shortcut).
jumpReady = headingReady;
}
else if (diagonalAscend)
{
jumpReady = headingReady;
}
else
{
// Mirror Baritone's gate: only jump when close to the riser and

View file

@ -62,13 +62,46 @@ namespace MinecraftClient.Pathing.Execution.Templates
float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
// Snap yaw on the first tick to avoid a few ticks of sideways drift
// when the bot enters this segment with a stale orientation (e.g.
// just after a teleport or after a turn). Ledge-adjacent descends
// cannot tolerate drift without falling off the wrong side.
if (_tickCount == 1)
physics.Yaw = targetYaw;
if (physics.OnGround && Math.Abs(dy) < (_hasFallen ? 1.0 : 0.6))
{
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world);
bool onOrPastTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd)
|| TemplateHelper.HasReachedSegmentEndPlane(pos, _segment);
// Fallback: after a diagonal descend landing the bot can end
// up on a support block that is not yet the target block
// (footprint still off the landing column). The braking
// planner reads "remaining <= coastStop + lead" and returns
// Coast, which zeroes every input - if the bot has already
// come to rest this means the segment hangs forever and the
// pathing manager replans. When we are stopped, not inside
// the target block, and not being asked to brake, walk
// toward the target instead of coasting so the landing
// resolves in one tick-window.
if (!decision.HoldBack
&& !TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd)
&& horizDistSq > 0.01
&& TemplateHelper.GetHorizontalSpeed(physics) < 0.03)
{
float walkYaw = TemplateHelper.CalculateYaw(dx, dz);
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, walkYaw);
input.Forward = true;
input.Sprint = _needsSprint;
if (GroundedSegmentController.ShouldComplete(_segment, pos, physics))
return TemplateState.Complete;
return TemplateState.InProgress;
}
if (horizDistSq > 0.01 && !decision.HoldBack)
{
bool onOrPastTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd)
|| TemplateHelper.HasReachedSegmentEndPlane(pos, _segment);
float groundedYaw = onOrPastTarget
? TemplateHelper.GetExitHeadingYaw(_segment)
: TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)
@ -96,8 +129,27 @@ namespace MinecraftClient.Pathing.Execution.Templates
{
bool onOrPastTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd)
|| TemplateHelper.HasReachedSegmentEndPlane(pos, _segment);
bool biasTowardExitInAir = onOrPastTarget
|| (_hasFallen && TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment, distanceThreshold: 1.5));
// Airborne bias toward the exit heading is only safe when
// the bot has effectively finished the current segment's
// horizontal travel: either the footprint is inside the
// landing block, or the vertical drop is small enough that
// lateral drift cannot miss the 1x1 landing column. For
// multi-block drops the bot is in the air for 8+ ticks;
// rotating yaw mid-fall (e.g. after crossing the end plane
// but still 1-2 blocks above landing) pushes sprint/walk
// momentum perpendicular to the segment and drifts the bot
// off the landing column into the void. Keep yaw pointed
// at the landing center through the whole fall on multi-Y
// descends; GroundedSegmentController rotates yaw once the
// bot is actually standing on the landing column.
double segmentYDrop = _segment.Start.Y - _segment.End.Y;
bool isSingleStepDescend = segmentYDrop <= 1.0;
bool footInsideTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd);
bool biasTowardExitInAir = footInsideTarget
|| (isSingleStepDescend
&& (onOrPastTarget
|| (_hasFallen
&& TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment, distanceThreshold: 1.5))));
float airborneYaw = biasTowardExitInAir
? TemplateHelper.GetExitHeadingYaw(_segment)
: targetYaw;
@ -117,7 +169,30 @@ namespace MinecraftClient.Pathing.Execution.Templates
else
{
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world);
if (_segment.ExitHints.AllowAirBrake)
// Multi-block descend overshoot guard: when the
// fall spans 2+ Y blocks, sprint momentum will
// carry the bot roughly one extra horizontal
// block past the planned landing. If the next
// segment prepares a jump (PrepareJump exit) the
// bot MUST land inside the planned 1x1 landing
// column so the jump takeoff has a valid footing;
// overshooting drops into the void or onto a
// block 1-2 tiers below, breaking the jump.
// Once airborne and past the landing end-plane,
// release forward input so sprint momentum decays
// via air drag over the final 1-2 ticks of fall,
// pulling the bot back into the landing column.
bool riskyOvershoot = _hasFallen
&& segmentYDrop >= 2.0
&& onOrPastTarget
&& _segment.ExitTransition == PathTransitionType.PrepareJump;
if (riskyOvershoot)
{
input.Forward = false;
input.Sprint = false;
input.Back = true;
}
else if (_segment.ExitHints.AllowAirBrake)
{
TemplateHelper.ApplyDecision(input, decision);
if (decision.HoldForward && _needsSprint)

View file

@ -25,13 +25,36 @@ namespace MinecraftClient.Pathing.Execution.Templates
return;
}
if (TemplateHelper.ShouldBiasTowardExitHeading(pos, segment))
TemplateHelper.FaceExitHeading(physics, segment);
// Compute the braking decision first so rotation and input stay
// consistent. Applying the exit-heading bias while we are still
// braking causes the Back input (which acts opposite to yaw) to
// push the bot perpendicular to the segment line, which on narrow
// 1-block walkways turns into a side-off-the-edge step. Stay on
// the segment heading for as long as we are braking and only let
// the bias rotate us once the brake has released.
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(segment, nextSegment, pos, physics, world);
TemplateHelper.ApplyDecision(input, decision);
if (decision.HoldBack)
{
TemplateHelper.FaceSegmentHeading(physics, segment);
return;
}
// On stable-footing Turn exits (the next segment is a walk-like
// move, not a jump) the next template snaps yaw instantly on
// its first tick, so pre-rotating here is unnecessary. On
// narrow 1-block walkways the bias combined with along-segment
// momentum pushes the bot perpendicular to the walkway and
// walks it off the edge (the bot sprint-drifts diagonally
// while yaw rotates ~45 deg mid-stride). For Turn exits into
// a jump (RequireJumpReady) we still need to align yaw before
// takeoff, so keep the bias there.
bool suppressBiasForSafeTurn = segment.ExitTransition == PathTransitionType.Turn
&& segment.ExitHints.RequireStableFooting
&& !segment.ExitHints.RequireJumpReady;
if (!suppressBiasForSafeTurn
&& TemplateHelper.ShouldBiasTowardExitHeading(pos, segment))
TemplateHelper.FaceExitHeading(physics, segment);
}
internal static bool ShouldComplete(PathSegment segment, Location pos, PlayerPhysics physics)
@ -61,8 +84,26 @@ namespace MinecraftClient.Pathing.Execution.Templates
}
double exitSpeed = TemplateHelper.ProjectHorizontalSpeedAlongHint(physics, segment);
bool headingReady = TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment)
<= (segment.ExitHints.RequireJumpReady ? 8.0 : 15.0);
// On Turn exits we deliberately do NOT pre-rotate yaw toward the
// next segment's heading (see Apply() above). The next segment's
// template snaps yaw on its first tick, so measuring heading
// readiness against the exit heading here would deadlock the
// handoff (bot is still facing segment heading, would never pass
// the 15 deg gate). Measure against segment heading for Turn
// exits with stable footing where yaw will be snapped anyway.
bool headingReady;
if (segment.ExitTransition == PathTransitionType.Turn
&& segment.ExitHints.RequireStableFooting
&& !segment.ExitHints.RequireJumpReady)
{
headingReady = TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment.HeadingX, segment.HeadingZ) <= 25.0
|| TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment) <= 15.0;
}
else
{
headingReady = TemplateHelper.HeadingPenaltyDegrees(physics.Yaw, segment)
<= (segment.ExitHints.RequireJumpReady ? 8.0 : 15.0);
}
if (!headingReady)
return false;

View file

@ -18,6 +18,7 @@ namespace MinecraftClient.Pathing.Execution.Templates
private int _tickCount;
private Location _lastPos;
private int _stuckTicks;
private int _airborneTicks;
public WalkTemplate(PathSegment segment, PathSegment? nextSegment)
{
@ -35,11 +36,42 @@ namespace MinecraftClient.Pathing.Execution.Templates
double dx = ExpectedEnd.X - pos.X;
double dz = ExpectedEnd.Z - pos.Z;
double dy = ExpectedEnd.Y - pos.Y;
float targetYaw = TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)
? TemplateHelper.GetExitHeadingYaw(_segment)
: TemplateHelper.CalculateYaw(dx, dz);
// While approaching the end, steer via pos->end so lateral drift self-
// corrects. Once the center has entered the target block the pos->end
// vector becomes tiny/negative and flips yaw by ~180 degrees, which
// fights GroundedSegmentController's exit-heading rotation and locks
// yaw at a local equilibrium (e.g. 333 deg on a 1,1 diagonal) where
// HeadingPenalty never drops below the 8 deg ShouldComplete gate.
// Fall back to the stable quantized segment heading once inside the
// target block so the completion check and exit rotation converge.
// Skip the exit-heading bias on stable-footing Turn exits: it
// rotates yaw mid-segment while the bot still has along-segment
// momentum, which on a 1-block walkway drifts the bot
// perpendicular and walks it off the edge. The next segment's
// template snaps yaw on its first tick, so nothing is lost by
// deferring the rotation. Keep the bias when the next segment
// is a jump (RequireJumpReady): we need yaw aligned before
// takeoff or the jump direction will be off.
bool suppressBiasForSafeTurn = _segment.ExitTransition == PathTransitionType.Turn
&& _segment.ExitHints.RequireStableFooting
&& !_segment.ExitHints.RequireJumpReady;
float targetYaw;
if (!suppressBiasForSafeTurn
&& TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment))
targetYaw = TemplateHelper.GetExitHeadingYaw(_segment);
else if (TemplateFootingHelper.IsCenterInsideTargetBlock(pos, _segment.End))
targetYaw = TemplateHelper.CalculateYaw(_segment.HeadingX, _segment.HeadingZ);
else
targetYaw = TemplateHelper.CalculateYaw(dx, dz);
float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz);
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
// Snap yaw on the first tick so we don't push forward input while the
// bot is still rotating from whatever yaw it had before this segment
// started (e.g. a random post-teleport orientation). Baritone-style:
// the server accepts instant yaw updates and the narrow 1-block lanes
// in parkour courses don't tolerate 3 ticks of sideways drift.
physics.Yaw = _tickCount == 1
? targetYaw
: TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world);
@ -51,6 +83,15 @@ namespace MinecraftClient.Pathing.Execution.Templates
_stuckTicks = movedSq < 0.0005 ? _stuckTicks + 1 : 0;
_lastPos = pos;
// Walk/Diagonal is a grounded move: if the bot is airborne for more
// than a handful of ticks the platform is gone beneath us (e.g. we
// rotated toward an exit heading on a narrow 1-block walkway and
// stepped off the edge). Fail fast so the replanner can recover
// before gravity carries the bot 10+ blocks out of position.
_airborneTicks = physics.OnGround ? 0 : _airborneTicks + 1;
if (_airborneTicks > 8)
return TemplateState.Failed;
int maxTicks = _segment.ExitTransition switch
{
PathTransitionType.ContinueStraight => 100,