diff --git a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs
index 0f147df0..cabc5d8e 100644
--- a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs
+++ b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs
@@ -798,4 +798,92 @@ public sealed class GroundedTemplateConvergenceTests
Assert.True(input.Back, $"decision={decision} input(F={input.Forward},B={input.Back},S={input.Sprint})");
Assert.False(input.Forward, $"decision={decision} input(F={input.Forward},B={input.Back},S={input.Sprint})");
}
+
+ ///
+ /// Bug 2.1 regression: an island diagonal Ascend (heading (-X,+Z,+Y)) is
+ /// reached after a preceding cardinal Traverse has built up pure +Z ground
+ /// momentum. Without the execution-layer brake the perpendicular momentum
+ /// survives takeoff, collides with the +Z shoulder wall of the target
+ /// block, and the bot lands outside the target footprint. The template
+ /// must release Forward/Sprint (and engage Back when the perpendicular
+ /// dominates) for a few ground ticks so friction can decay the misaligned
+ /// component before the jump fires, and the final landing must be inside
+ /// the target block.
+ ///
+ [Fact]
+ public void AscendTemplate_IslandDiagonalFromCardinalMomentum_BrakesPerpBeforeJumpAndLandsInsideTarget()
+ {
+ // Build a small island layout at y=79 floor:
+ // source block (0,79,0) stands on (0,78,0)
+ // target block (-1,80,1) stands on (-1,79,1); approach is diagonal (-X,+Z)
+ // the +Z shoulder relative to the target (-1,80,2) is solid at head
+ // height so any over-travel along +Z bonks a wall (matches the live
+ // case where perpendicular momentum pushed past the target)
+ World world = FlatWorldTestBuilder.CreateStoneFloor(min: -4, max: 4);
+ FlatWorldTestBuilder.ClearBox(world, -4, 79, -4, 4, 84, 4);
+ FlatWorldTestBuilder.SetSolid(world, 0, 79, 0);
+ FlatWorldTestBuilder.SetSolid(world, -1, 80, 1);
+ FlatWorldTestBuilder.SetSolid(world, -1, 81, 2);
+ FlatWorldTestBuilder.SetSolid(world, -1, 80, 2);
+
+ var ascend = new PathSegment
+ {
+ Start = new Location(0.5, 80, 0.5),
+ End = new Location(-0.5, 81, 1.5),
+ MoveType = MoveType.Ascend,
+ ExitTransition = PathTransitionType.FinalStop,
+ PreserveSprint = true
+ };
+
+ var template = new AscendTemplate(ascend, null);
+
+ // Seed pure +Z cardinal momentum at the source block center: this is
+ // the perpendicular axis relative to the diagonal (-X,+Z) / sqrt(2)
+ // heading; without the brake gate the bot would take off carrying it.
+ var physics = new PlayerPhysics
+ {
+ Position = new Vec3d(0.5, 80, 0.5),
+ DeltaMovement = new Vec3d(0.0, 0.0, 0.22),
+ OnGround = true,
+ Sprinting = true,
+ MovementSpeed = 0.1f,
+ Yaw = 0f,
+ Pitch = 0f
+ };
+
+ var input = new MovementInput();
+ TemplateState state = TemplateState.InProgress;
+ Location finalPos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
+ bool sawBrakeTick = false;
+ var trace = new List();
+ for (int tick = 0; tick < 120; tick++)
+ {
+ input.Reset();
+ Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
+ state = template.Tick(pos, physics, input, world);
+ if (physics.OnGround && !input.Forward && !input.Jump)
+ sawBrakeTick = true;
+ if (tick < 24 || state != TemplateState.InProgress)
+ {
+ trace.Add(
+ $"tick={tick} state={state} pos={pos} yaw={physics.Yaw:F1} vel={physics.DeltaMovement} " +
+ $"onGround={physics.OnGround} input(F={input.Forward},B={input.Back},J={input.Jump},S={input.Sprint})");
+ }
+ if (state != TemplateState.InProgress)
+ {
+ finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
+ break;
+ }
+
+ physics.ApplyInput(input);
+ physics.Tick(world);
+ finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
+ }
+
+ Assert.True(sawBrakeTick, "expected at least one ground tick where Forward was released to decay perpendicular momentum");
+ Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
+ Assert.True(
+ TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, ascend.End),
+ $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
+ }
}
diff --git a/MinecraftClient.Tests/Pathing/Moves/MoveDescendTests.cs b/MinecraftClient.Tests/Pathing/Moves/MoveDescendTests.cs
new file mode 100644
index 00000000..732df4bf
--- /dev/null
+++ b/MinecraftClient.Tests/Pathing/Moves/MoveDescendTests.cs
@@ -0,0 +1,104 @@
+using MinecraftClient.Mapping;
+using MinecraftClient.Pathing.Core;
+using MinecraftClient.Pathing.Moves;
+using MinecraftClient.Pathing.Moves.Impl;
+using MinecraftClient.Tests.Pathing.Execution;
+using Xunit;
+
+namespace MinecraftClient.Tests.Pathing.Moves;
+
+public sealed class MoveDescendTests
+{
+ private const int FloorY = 79;
+
+ private static CalculationContext BuildContext(World world)
+ => new(world, allowParkour: true, allowParkourAscend: true);
+
+ [Fact]
+ public void Accepts1BlockStepDown()
+ {
+ var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
+ // Raise the source column by one so a +X step descends 1 block.
+ FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 1, 0);
+
+ var ctx = BuildContext(world);
+ var move = new MoveDescend(1, 0);
+ var result = default(MoveResult);
+
+ // Source feet block is FloorY+2, destination feet block is FloorY+1.
+ move.Calculate(ctx, 0, FloorY + 2, 0, ref result);
+
+ Assert.False(result.IsImpossible);
+ Assert.Equal(1, result.DestX);
+ Assert.Equal(FloorY + 1, result.DestY);
+ }
+
+ ///
+ /// Regression: when the landing column is itself solid at y-1 (e.g. a
+ /// 2-block-thick platform top), MoveDescend must reject the move.
+ /// Previously the simple 1-block branch only checked the y-2 floor and the
+ /// y / y+1 body-clearance at the destination, so A* emitted a Descend that
+ /// the bot could never execute (it just walked onto the solid y-1 block at
+ /// the same feet level), producing an infinite replan loop in live play.
+ ///
+ [Fact]
+ public void Rejects1BlockDescendIntoSolidLandingColumn()
+ {
+ var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
+ // Bot stands on source pillar (0, FloorY+1), feet at FloorY+2.
+ FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 1, 0);
+ // Destination column is ALSO solid at the feet-landing level (y-1 of source).
+ // Concretely: (1, FloorY+1) is stone, (1, FloorY) is stone, and the flat
+ // floor under that is still there too. There is no valid 1-block drop.
+ FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 1, 0);
+
+ var ctx = BuildContext(world);
+ var move = new MoveDescend(1, 0);
+ var result = default(MoveResult);
+
+ move.Calculate(ctx, 0, FloorY + 2, 0, ref result);
+
+ Assert.True(result.IsImpossible);
+ }
+
+ [Fact]
+ public void Accepts2BlockDrop()
+ {
+ // Two-tier setup: source pillar at y=FloorY+2, destination floor at y=FloorY.
+ var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
+ FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 1, 0);
+ FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 2, 0);
+
+ var ctx = BuildContext(world);
+ var move = new MoveDescend(1, 0);
+ var result = default(MoveResult);
+
+ // Source feet block is FloorY+3, destination column drops to FloorY+1 floor.
+ move.Calculate(ctx, 0, FloorY + 3, 0, ref result);
+
+ Assert.False(result.IsImpossible);
+ Assert.Equal(1, result.DestX);
+ Assert.Equal(FloorY + 1, result.DestY);
+ }
+
+ [Fact]
+ public void RejectsMultiBlockDropWhenFlightColumnIsBlocked()
+ {
+ // Source pillar at y=FloorY+2, but destination column has a solid
+ // block at y-1 that blocks the fall path entirely. The bot cannot
+ // enter the destination column at all.
+ var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
+ FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 1, 0);
+ FlatWorldTestBuilder.SetSolid(world, 0, FloorY + 2, 0);
+ // Blocker: (1, FloorY+2) is solid -- this is the y-1 of the source feet.
+ FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 2, 0);
+
+ var ctx = BuildContext(world);
+ var move = new MoveDescend(1, 0);
+ var result = default(MoveResult);
+
+ move.Calculate(ctx, 0, FloorY + 3, 0, ref result);
+
+ Assert.True(result.IsImpossible);
+ }
+}
diff --git a/MinecraftClient.Tests/Pathing/Moves/MoveJumpDiagonalAscendTests.cs b/MinecraftClient.Tests/Pathing/Moves/MoveJumpDiagonalAscendTests.cs
new file mode 100644
index 00000000..b3520d7e
--- /dev/null
+++ b/MinecraftClient.Tests/Pathing/Moves/MoveJumpDiagonalAscendTests.cs
@@ -0,0 +1,109 @@
+using MinecraftClient.Mapping;
+using MinecraftClient.Pathing.Core;
+using MinecraftClient.Pathing.Moves;
+using MinecraftClient.Pathing.Moves.Impl;
+using MinecraftClient.Tests.Pathing.Execution;
+using Xunit;
+
+namespace MinecraftClient.Tests.Pathing.Moves;
+
+///
+/// Regression tests for the Baritone-parity cardinal-split gate in
+/// 's diagonal Ascend branch. When a cardinal
+/// fallback (cardinal Walk into the dx or dz shoulder + a cardinal Ascend
+/// from there) exists, the diagonal Ascend must be rejected: it has no
+/// physical way to redirect the preceding segment's axis-aligned ground
+/// momentum into the diagonal in 2 handoff ticks, so executing it
+/// overshoots the target and loops on replan.
+///
+public sealed class MoveJumpDiagonalAscendTests
+{
+ private const int FloorY = 79;
+
+ private static CalculationContext BuildContext(World world)
+ => new(world, allowParkour: true, allowParkourAscend: true);
+
+ [Fact]
+ public void RejectsDiagonalAscendWhenCardinalSplitIsWalkable()
+ {
+ // Flat floor at FloorY, so the cardinal shoulders at (1, FloorY, 0)
+ // and (0, FloorY, 1) both have solid ground. The ascend target is a
+ // 1-block riser on the diagonal corner at (1, FloorY+1, 1). Either
+ // "walk +X first, then cardinal Ascend +Z+Y" or "walk +Z first, then
+ // cardinal Ascend +X+Y" produces a stable 2-step plan, so the direct
+ // diagonal Ascend must be rejected.
+ var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
+ FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 1, 1);
+
+ var ctx = BuildContext(world);
+ var move = MoveJump.DiagonalAscend(1, 1);
+ var result = default(MoveResult);
+
+ move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
+
+ Assert.True(result.IsImpossible);
+ }
+
+ [Fact]
+ public void AcceptsDiagonalAscendWhenBothCardinalShouldersLackFloor()
+ {
+ // Island configuration: the source pillar and the diagonal ascend
+ // riser are the only walk-on surfaces near the bot. The cardinal
+ // shoulders are open air, so no cardinal Walk + cardinal Ascend
+ // split exists and the diagonal Ascend is the genuine only option.
+ var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
+ world.SetBlock(new Location(1, FloorY, 0), Block.Air);
+ world.SetBlock(new Location(0, FloorY, 1), Block.Air);
+ FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 1, 1);
+
+ var ctx = BuildContext(world);
+ var move = MoveJump.DiagonalAscend(1, 1);
+ var result = default(MoveResult);
+
+ move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
+
+ Assert.False(result.IsImpossible);
+ Assert.Equal(1, result.DestX);
+ Assert.Equal(FloorY + 2, result.DestY);
+ Assert.Equal(1, result.DestZ);
+ }
+
+ [Fact]
+ public void RejectsDiagonalAscendWhenOnlyOneCardinalShoulderHasFloor()
+ {
+ // Only the +X shoulder has floor support; the +Z shoulder is open
+ // air. Even a single viable cardinal split is enough for Baritone's
+ // gate to forbid the diagonal Ascend, because A* can simply take
+ // "walk +X then cardinal Ascend +Z+Y" instead.
+ var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
+ world.SetBlock(new Location(0, FloorY, 1), Block.Air);
+ FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 1, 1);
+
+ var ctx = BuildContext(world);
+ var move = MoveJump.DiagonalAscend(1, 1);
+ var result = default(MoveResult);
+
+ move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
+
+ Assert.True(result.IsImpossible);
+ }
+
+ [Fact]
+ public void CardinalAscendStillAcceptedOnFlatFloor()
+ {
+ // Sanity: the gate must not touch cardinal Ascend. A plain +X Ascend
+ // onto a 1-block riser on flat floor should still plan as before.
+ var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
+ FlatWorldTestBuilder.SetSolid(world, 1, FloorY + 1, 0);
+
+ var ctx = BuildContext(world);
+ var move = MoveJump.Ascend(1, 0);
+ var result = default(MoveResult);
+
+ move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
+
+ Assert.False(result.IsImpossible);
+ Assert.Equal(1, result.DestX);
+ Assert.Equal(FloorY + 2, result.DestY);
+ }
+}
diff --git a/MinecraftClient/Commands/Goto.cs b/MinecraftClient/Commands/Goto.cs
index 18cf7d0f..33680535 100644
--- a/MinecraftClient/Commands/Goto.cs
+++ b/MinecraftClient/Commands/Goto.cs
@@ -42,7 +42,10 @@ namespace MinecraftClient.Commands
Location current = handler.GetCurrentLocation();
goal.ToAbsolute(current);
- var (success, message) = handler.MoveToAStar(goal);
+ // The A* search runs on a background task (see NavigateToGoal), so
+ // a generous budget no longer blocks the 20 TPS tick loop. Matches
+ // Baritone's multi-second default budget for interactive goto.
+ var (success, message) = handler.MoveToAStar(goal, timeoutMs: 15000);
return r.SetAndReturn(success ? Status.Done : Status.Fail, message);
}
diff --git a/MinecraftClient/Commands/PathDiag.cs b/MinecraftClient/Commands/PathDiag.cs
new file mode 100644
index 00000000..e324a5f0
--- /dev/null
+++ b/MinecraftClient/Commands/PathDiag.cs
@@ -0,0 +1,57 @@
+using Brigadier.NET;
+using Brigadier.NET.Builder;
+using MinecraftClient.CommandHandler;
+using MinecraftClient.Pathing.Execution;
+using static MinecraftClient.CommandHandler.CmdResult;
+
+namespace MinecraftClient.Commands
+{
+ ///
+ /// Toggles Info-level pathing diagnostics. When enabled,
+ /// emits the full waypoint dump of every planned/replanned path, the recent per-tick
+ /// trace at segment-failure time, and the failing segment's target. Used for
+ /// reporting pathing bugs without permanently changing the debug log level.
+ ///
+ public class PathDiag : Command
+ {
+ public override string CmdName => "pathdiag";
+ public override string CmdUsage => "pathdiag [on|off]";
+ public override string CmdDesc => Translations.cmd_pathdiag_desc;
+
+ public override void RegisterCommand(CommandDispatcher dispatcher)
+ {
+ dispatcher.Register(l => l.Literal("help")
+ .Then(l => l.Literal(CmdName)
+ .Executes(r => GetUsage(r.Source)))
+ );
+
+ dispatcher.Register(l => l.Literal(CmdName)
+ .Executes(r => Toggle(r.Source))
+ .Then(l => l.Literal("on")
+ .Executes(r => SetDiagnostics(r.Source, true)))
+ .Then(l => l.Literal("off")
+ .Executes(r => SetDiagnostics(r.Source, false)))
+ .Then(l => l.Literal("_help")
+ .Executes(r => GetUsage(r.Source))
+ .Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))
+ );
+ }
+
+ private int GetUsage(CmdResult r)
+ {
+ return r.SetAndReturn(GetCmdDescTranslated());
+ }
+
+ private static int Toggle(CmdResult r)
+ {
+ return SetDiagnostics(r, !PathSegmentManager.DiagnosticsEnabled);
+ }
+
+ private static int SetDiagnostics(CmdResult r, bool enabled)
+ {
+ PathSegmentManager.DiagnosticsEnabled = enabled;
+ return r.SetAndReturn(Status.Done,
+ enabled ? Translations.cmd_pathdiag_enabled : Translations.cmd_pathdiag_disabled);
+ }
+ }
+}
diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs
index 1cba394a..6d32351e 100644
--- a/MinecraftClient/McClient.cs
+++ b/MinecraftClient/McClient.cs
@@ -1724,58 +1724,33 @@ namespace MinecraftClient
///
/// Navigate to a goal using the new A* pathfinder and template-based execution.
- /// Accepts any IGoal for flexible target specification.
- /// Returns a description of the result for UI feedback.
+ /// The A* search runs on a background task so the 20 TPS tick loop is
+ /// never blocked; the caller sees a "planning started" acknowledgement
+ /// and the real result is logged when
+ /// installs the plan
+ /// on a subsequent tick.
///
public (bool success, string message) NavigateToGoal(Pathing.Goals.IGoal goal, long timeoutMs = 5000)
{
+ Location startPos;
lock (locationLock)
{
- var ctx = new Pathing.Core.CalculationContext(world,
- allowParkour: true, allowParkourAscend: true);
- var finder = new Pathing.Core.AStarPathFinder();
- finder.DebugLog = msg => Log.Debug(msg);
-
- int sx = (int)Math.Floor(location.X);
- int sy = (int)Math.Floor(location.Y);
- int sz = (int)Math.Floor(location.Z);
-
- if (!ctx.CanWalkThrough(sx, sy, sz) && ctx.CanWalkThrough(sx, sy + 1, sz))
- sy++;
-
- Log.Info($"[Navigate] A* search from ({sx},{sy},{sz}) to {goal}");
-
- using var cts = new CancellationTokenSource();
- var result = finder.Calculate(ctx, sx, sy, sz, goal, cts.Token, timeoutMs);
-
- Log.Info($"[Navigate] A* result: {result.Status}, nodes={result.NodesExplored}, " +
- $"time={result.ElapsedMs}ms, path length={result.Path.Count}");
-
- if (result.Status == Pathing.Core.PathStatus.Failed || result.Path.Count < 2)
- {
- return (false, string.Format(Translations.cmd_goto_failed,
- result.NodesExplored, result.ElapsedMs));
- }
-
- for (int i = 1; i < result.Path.Count; i++)
- {
- var node = result.Path[i];
- Log.Debug($"[Navigate] seg[{i - 1}] = {node.MoveUsed}: ({node.X},{node.Y},{node.Z})");
- }
-
- pathTarget = null;
- path = null;
-
- pathSegmentManager = new Pathing.Execution.PathSegmentManager(
- debugLog: msg => Log.Debug(msg),
- infoLog: msg => Log.Info(msg),
- observer: new Pathing.Execution.Telemetry.PathExecutionLogObserver(msg => Log.Debug(msg)));
- pathSegmentManager.StartNavigation(goal, result);
-
- string statusStr = result.Status == Pathing.Core.PathStatus.Partial ? " (partial)" : "";
- return (true, string.Format(Translations.cmd_goto_success,
- result.Path.Count - 1, result.NodesExplored, result.ElapsedMs, statusStr));
+ startPos = location;
}
+
+ Log.Info($"[Navigate] A* search from ({(int)Math.Floor(startPos.X)},{(int)Math.Floor(startPos.Y)},{(int)Math.Floor(startPos.Z)}) to {goal}");
+
+ pathTarget = null;
+ path = null;
+
+ pathSegmentManager?.Cancel();
+ pathSegmentManager = new Pathing.Execution.PathSegmentManager(
+ debugLog: msg => Log.Debug(msg),
+ infoLog: msg => Log.Info(msg),
+ observer: new Pathing.Execution.Telemetry.PathExecutionLogObserver(msg => Log.Debug(msg)));
+ pathSegmentManager.StartNavigationAsync(goal, startPos, world, timeoutMs);
+
+ return (true, string.Format(Translations.cmd_goto_planning, timeoutMs));
}
///
diff --git a/MinecraftClient/Pathing/Core/AStarPathFinder.cs b/MinecraftClient/Pathing/Core/AStarPathFinder.cs
index c6f6e832..520ab8f9 100644
--- a/MinecraftClient/Pathing/Core/AStarPathFinder.cs
+++ b/MinecraftClient/Pathing/Core/AStarPathFinder.cs
@@ -429,34 +429,49 @@ namespace MinecraftClient.Pathing.Core
int stepX = destX - current.X;
int stepZ = destZ - current.Z;
- ReadOnlySpan descriptors = JumpExpander.Descriptors;
- for (int i = 0; i < descriptors.Length; i++)
+ // Sidewall candidates are generated dynamically by JumpExpander's
+ // cardinal probe now, so we probe each cardinal forward direction
+ // directly instead of scanning a descriptor table. The only shape
+ // TryGetRequiredStaticEntryRunupSteps flags as needing a static
+ // runup today is (major=5, minor=1, yDelta=-1) -- we use that
+ // canonical shape as the query (lateral=+1 is arbitrary; the
+ // helper only looks at yDelta and major).
+ ReadOnlySpan<(int fx, int fz)> forwards =
+ [
+ (1, 0),
+ (-1, 0),
+ (0, 1),
+ (0, -1),
+ ];
+
+ for (int i = 0; i < forwards.Length; i++)
{
- JumpDescriptor candidate = descriptors[i];
- if (candidate.Flavor != JumpFlavor.Sidewall)
+ (int forwardX, int forwardZ) = forwards[i];
+ if (stepX != -forwardX || stepZ != -forwardZ)
continue;
+ int xOffset, zOffset;
+ if (forwardX != 0)
+ {
+ xOffset = forwardX * 5;
+ zOffset = 1;
+ }
+ else
+ {
+ xOffset = 1;
+ zOffset = forwardZ * 5;
+ }
+
if (!ParkourFeasibility.TryGetRequiredStaticEntryRunupSteps(
current.MoveUsed,
- candidate.XOffset,
- candidate.ZOffset,
- candidate.YDelta,
+ xOffset,
+ zOffset,
+ yDelta: -1,
out int requiredSteps))
{
continue;
}
- ParkourFeasibility.GetSidewallAxes(
- candidate.XOffset,
- candidate.ZOffset,
- out int forwardX,
- out int forwardZ,
- out _,
- out _);
-
- if (stepX != -forwardX || stepZ != -forwardZ)
- continue;
-
state = new EntryPreparationState(
EntryPreparationKind.SidewallRunup,
current.X,
diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs
index ad9d5131..7a4dc031 100644
--- a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs
+++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs
@@ -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? _pendingReplan;
private CancellationTokenSource? _pendingReplanCts;
@@ -40,6 +43,19 @@ namespace MinecraftClient.Pathing.Execution
private CancellationTokenSource? _pendingLookaheadCts;
private (int x, int y, int z)? _pendingLookaheadAnchor;
+ ///
+ /// 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 /pathdiag on when reporting pathing
+ /// bugs so the default log level stays quiet.
+ ///
+ public static bool DiagnosticsEnabled { get; set; }
+
+ private const int DiagnosticsTailSize = 64;
+ private readonly Queue _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");
}
+ ///
+ /// Kicks off the initial A* search on a background task and returns
+ /// immediately. The main tick loop drains the task via
+ /// , installing the executor when the
+ /// plan completes. Use this from interactive entry points (e.g.
+ /// /goto) 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.
+ ///
+ 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? 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=" : $"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)
diff --git a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs
index 777b4d96..dedb7818 100644
--- a/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs
+++ b/MinecraftClient/Pathing/Execution/Templates/AscendTemplate.cs
@@ -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
diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs
index 5eadda33..3cee5861 100644
--- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs
+++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs
@@ -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)
diff --git a/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs b/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs
index 516a5686..13e67da4 100644
--- a/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs
+++ b/MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs
@@ -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;
diff --git a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs
index 1483ca00..1bb1e7a2 100644
--- a/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs
+++ b/MinecraftClient/Pathing/Execution/Templates/WalkTemplate.cs
@@ -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,
diff --git a/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs b/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs
index 8f0036e5..f1141f17 100644
--- a/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs
+++ b/MinecraftClient/Pathing/Moves/Impl/MoveDescend.cs
@@ -35,6 +35,19 @@ namespace MinecraftClient.Pathing.Moves.Impl
return;
}
+ // The landing feet column must also be passable. Without this, a descend
+ // into a column whose y-1 block is solid (e.g. a 2-block thick platform top
+ // where (destX,y-1) is stone and (destX,y-2) is also stone) would be
+ // accepted: the solid y-2 floor satisfies CanWalkOn, the y/y+1 body space
+ // satisfies the step, but physically the bot just walks onto the solid
+ // y-1 block at the same feet level and the Descend template waits forever
+ // for a drop that can never happen -- producing an infinite replan loop.
+ if (!ctx.CanWalkThrough(destX, y - 1, destZ))
+ {
+ result.SetImpossible();
+ return;
+ }
+
// Don't descend from ladder/vine (unreliable)
Material fromDown = ctx.GetMaterial(x, y - 1, z);
if (fromDown.CanBeClimbedOn())
diff --git a/MinecraftClient/Pathing/Moves/JumpExpander.cs b/MinecraftClient/Pathing/Moves/JumpExpander.cs
index 3c5858c6..b07abdb7 100644
--- a/MinecraftClient/Pathing/Moves/JumpExpander.cs
+++ b/MinecraftClient/Pathing/Moves/JumpExpander.cs
@@ -7,34 +7,49 @@ namespace MinecraftClient.Pathing.Moves;
///
/// Dynamic expander for every move in the jump family (Walk, Step,
-/// SprintJump, Sidewall). Iterates a declarative descriptor table and calls
-/// for each entry without allocating
-/// an IMove object per direction.
+/// SprintJump, Sidewall). Walk, Step, diagonal SprintJump and Sidewall are
+/// still driven by a declarative descriptor table that calls
+/// one entry at a time. Cardinal
+/// SprintJumps are produced by , a Baritone-style
+/// near-to-far scan that emits at most one candidate per direction -- letting
+/// A* re-probe from each landing instead of enumerating every (distance,
+/// yDelta) combination.
///
/// The hot path hoists per-node guards (AllowParkour, head clearance,
/// takeoff material, adjacent-wall presence) and precomputes an 8-direction
/// "first-step has no floor" table so entire descriptor groups can be
/// rejected in O(1) before touching . Ordinary
-/// ground-walking nodes skip all ~170 jump descriptors this way; nodes
-/// without any adjacent wall skip all 112 sidewall descriptors.
+/// ground-walking nodes skip every jump descriptor this way; nodes without
+/// any adjacent wall skip all sidewall descriptors.
///
public sealed class JumpExpander : IMoveExpander
{
+ ///
+ /// Extra slots in the neighbor buffer for the 4 cardinal probes. Each
+ /// probe may emit up to one sprint-jump candidate per yDelta (4 total)
+ /// plus up to one sidewall candidate per (lateral sign, yDelta) (8
+ /// total), so 4 directions * (4 + 8) = 48 slots. The probe almost never
+ /// fills every slot; this is a generous upper bound that keeps the
+ /// neighbor buffer stack-allocated.
+ ///
+ private const int CardinalProbeSlots = 48;
+
private static readonly JumpDescriptor[] _descriptors = BuildDescriptors();
- public int MaxNeighbors => _descriptors.Length;
+ public int MaxNeighbors => _descriptors.Length + CardinalProbeSlots;
public int Expand(CalculationContext ctx, int x, int y, int z, Span buffer)
{
int count = 0;
MoveResult result = default;
- // ---- Per-node preconditions (shared by every SprintJump + Sidewall descriptor) ----
- // These are the first checks JumpFeasibility.Evaluate* would make. Hoisting
- // them once turns ~170 method calls per node into one branch in the hot path.
+ // ---- Per-node preconditions shared by every jump-family move ----
+ // These are the first checks JumpFeasibility.Evaluate* would make.
+ // Hoisting them once turns many method calls per node into one
+ // branch in the hot path. "canSprintTakeoff" gates both the
+ // descriptor loop's SprintJump entries and all four cardinal probes.
bool jumpFamilyAllowed = ctx.AllowParkour && ctx.CanSprint;
bool canSprintTakeoff = false;
- bool hasAdjacentWall = false;
if (jumpFamilyAllowed)
{
Material standingOn = ctx.GetMaterial(x, y - 1, z);
@@ -43,9 +58,6 @@ public sealed class JumpExpander : IMoveExpander
!standingOn.CanBeClimbedOn()
&& !atFeet.IsLiquid()
&& ctx.CanWalkThrough(x, y + 2, z);
-
- if (canSprintTakeoff)
- hasAdjacentWall = HasAnyAdjacentWall(ctx, x, y, z);
}
// ---- Per-direction gap table (SprintJump only) ----
@@ -88,9 +100,8 @@ public sealed class JumpExpander : IMoveExpander
}
break;
case JumpFlavor.Sidewall:
- if (!canSprintTakeoff || !hasAdjacentWall)
- continue;
- break;
+ // Sidewall candidates are produced by ProbeCardinal now.
+ continue;
default:
break;
}
@@ -104,23 +115,254 @@ public sealed class JumpExpander : IMoveExpander
if (count < buffer.Length)
buffer[count++] = new MoveNeighbor(result, type);
}
+
+ // ---- Cardinal SprintJump probes (Baritone-style near-to-far scan) ----
+ // Each cardinal direction probes distance 2..5 and emits at most one
+ // candidate (the closest feasible landing). A* re-probes from that
+ // landing to discover longer variants, which keeps the frontier small
+ // while preserving reachability.
+ if (canSprintTakeoff)
+ {
+ ProbeCardinal(ctx, x, y, z, +1, 0, directionGapOpen, buffer, ref count, ref result);
+ ProbeCardinal(ctx, x, y, z, -1, 0, directionGapOpen, buffer, ref count, ref result);
+ ProbeCardinal(ctx, x, y, z, 0, +1, directionGapOpen, buffer, ref count, ref result);
+ ProbeCardinal(ctx, x, y, z, 0, -1, directionGapOpen, buffer, ref count, ref result);
+ }
+
return count;
}
///
- /// Conservative O(1) short-circuit for the Sidewall family. Every sidewall
- /// descriptor needs a solid block one lateral step from the takeoff at
- /// y or y+1, i.e. at a cardinal neighbor. If all four
- /// cardinal neighbors at both heights are walk-through, there is no wall
- /// to cling to and all 112 sidewall descriptors can be skipped without
- /// calling .
+ /// Scans a single cardinal direction (fx, fz) for both sprint-jump
+ /// and sidewall landings. The forward air corridor is swept once
+ /// (Baritone-style monotonic scan with early break on obstruction) and
+ /// every feasible landing shape shares that sweep. Per-(lateral, yDelta)
+ /// sidewall candidates use the same i iteration to locate their
+ /// landing on the lateral column, so a single O(5) scan replaces the
+ /// ~8 + 16 static descriptor entries this direction used to need.
+ ///
+ /// Instead of emitting the closest valid landing (Baritone's choice),
+ /// the probe records the farthest valid landing per shape bucket and
+ /// emits one candidate each. Preferring the longer jump keeps A*'s path
+ /// cost low and avoids chains of short d=2 parkour jumps that MCC's
+ /// template can overshoot when sprint momentum is carried over.
///
- private static bool HasAnyAdjacentWall(CalculationContext ctx, int x, int y, int z)
+ private static void ProbeCardinal(
+ CalculationContext ctx,
+ int x, int y, int z,
+ int fx, int fz,
+ ReadOnlySpan directionGapOpen,
+ Span buffer,
+ ref int count,
+ ref MoveResult result)
{
- return !ctx.CanWalkThrough(x + 1, y, z) || !ctx.CanWalkThrough(x + 1, y + 1, z)
- || !ctx.CanWalkThrough(x - 1, y, z) || !ctx.CanWalkThrough(x - 1, y + 1, z)
- || !ctx.CanWalkThrough(x, y, z + 1) || !ctx.CanWalkThrough(x, y + 1, z + 1)
- || !ctx.CanWalkThrough(x, y, z - 1) || !ctx.CanWalkThrough(x, y + 1, z - 1);
+ // If the first step has a floor, a cheaper Walk move covers this
+ // direction already (Baritone: "don't parkour if we could just
+ // traverse"). Use the precomputed gap table.
+ int firstStepIdx = ((fx + 1) * 3) + (fz + 1);
+ if (!directionGapOpen[firstStepIdx])
+ return;
+
+ // The first step's column (y, y+1) must be passable; without it the
+ // player hits a wall before leaving the takeoff block. (y+2 over the
+ // takeoff itself is guaranteed by canSprintTakeoff.)
+ int sx1 = x + fx;
+ int sz1 = z + fz;
+ if (!ctx.CanWalkThrough(sx1, y, sz1) || !ctx.CanWalkThrough(sx1, y + 1, sz1))
+ return;
+
+ // Lateral unit vectors perpendicular to (fx, fz). Positive and
+ // negative sides are tracked independently so the wall presence
+ // short-circuit applies per side.
+ int lxP, lzP, lxN, lzN;
+ if (fx != 0)
+ {
+ lxP = 0; lzP = +1;
+ lxN = 0; lzN = -1;
+ }
+ else
+ {
+ lxP = +1; lzP = 0;
+ lxN = -1; lzN = 0;
+ }
+
+ // Sidewall needs a solid block immediately lateral to the takeoff
+ // (step=0 in HasSidewallArcClearance). If that cell is walk-through
+ // at both y and y+1, no sidewall candidate from this takeoff can
+ // succeed along that lateral sign.
+ bool wallP = !ctx.CanWalkThrough(x + lxP, y, z + lzP)
+ || !ctx.CanWalkThrough(x + lxP, y + 1, z + lzP);
+ bool wallN = !ctx.CanWalkThrough(x + lxN, y, z + lzN)
+ || !ctx.CanWalkThrough(x + lxN, y + 1, z + lzN);
+
+ // Farthest valid i for each sprint-jump shape.
+ int bestAscend = 0;
+ int bestFlat = 0;
+ int bestDescend1 = 0;
+ int bestDescend2 = 0;
+
+ // Farthest valid i per (lateral sign, yDelta) for sidewall.
+ // yDelta indices: 0=+1, 1=0, 2=-1, 3=-2.
+ int bestSwP0 = 0, bestSwP1 = 0, bestSwP2 = 0, bestSwP3 = 0;
+ int bestSwN0 = 0, bestSwN1 = 0, bestSwN2 = 0, bestSwN3 = 0;
+
+ const int MaxJumpDistance = 5;
+ for (int i = 2; i <= MaxJumpDistance; i++)
+ {
+ int dx = x + fx * i;
+ int dz = z + fz * i;
+
+ // Shared head-height air corridor. If blocked the whole arc is
+ // interrupted; every larger i is also unreachable for both
+ // sprint jump and sidewall.
+ if (!ctx.CanWalkThrough(dx, y + 1, dz) || !ctx.CanWalkThrough(dx, y + 2, dz))
+ break;
+
+ if (!ctx.CanWalkThrough(dx, y, dz))
+ {
+ // Foot-height is blocked. Only sprint-jump ascend is
+ // potentially viable here, and only for i <= 3. Sidewall's
+ // HasSidewallArcClearance requires a clear forward column
+ // at every step, so no sidewall candidate survives past
+ // this obstruction either.
+ if (i <= 3 && ctx.CanWalkOn(dx, y, dz))
+ bestAscend = i;
+ break;
+ }
+
+ // Foot-height is clear; record the best forward-axis landing.
+ if (ctx.CanWalkOn(dx, y - 1, dz))
+ bestFlat = i;
+ else if (ctx.CanWalkOn(dx, y - 2, dz))
+ bestDescend1 = i;
+ else if (ctx.CanWalkOn(dx, y - 3, dz))
+ bestDescend2 = i;
+
+ // Sidewall candidates land on the lateral column. The forward
+ // corridor has already been validated above; HasSidewallArc-
+ // Clearance's wall-depth and outside-lateral checks are deferred
+ // to EvaluateSidewall.
+ if (wallP)
+ TrackSidewallCandidates(ctx, dx, y, dz, lxP, lzP, i,
+ ref bestSwP0, ref bestSwP1, ref bestSwP2, ref bestSwP3);
+ if (wallN)
+ TrackSidewallCandidates(ctx, dx, y, dz, lxN, lzN, i,
+ ref bestSwN0, ref bestSwN1, ref bestSwN2, ref bestSwN3);
+ }
+
+ // Emit sprint-jump bests (MoveType.Parkour).
+ if (bestAscend > 0)
+ TryEmitSprintJump(ctx, x, y, z, fx * bestAscend, fz * bestAscend, +1, buffer, ref count, ref result);
+ if (bestFlat > 0)
+ TryEmitSprintJump(ctx, x, y, z, fx * bestFlat, fz * bestFlat, 0, buffer, ref count, ref result);
+ if (bestDescend1 > 0)
+ TryEmitSprintJump(ctx, x, y, z, fx * bestDescend1, fz * bestDescend1, -1, buffer, ref count, ref result);
+ if (bestDescend2 > 0)
+ TryEmitSprintJump(ctx, x, y, z, fx * bestDescend2, fz * bestDescend2, -2, buffer, ref count, ref result);
+
+ // Emit sidewall bests, one candidate per (lateral sign, yDelta).
+ EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxP, lzP, +1, bestSwP0, buffer, ref count, ref result);
+ EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxP, lzP, 0, bestSwP1, buffer, ref count, ref result);
+ EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxP, lzP, -1, bestSwP2, buffer, ref count, ref result);
+ EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxP, lzP, -2, bestSwP3, buffer, ref count, ref result);
+
+ EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxN, lzN, +1, bestSwN0, buffer, ref count, ref result);
+ EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxN, lzN, 0, bestSwN1, buffer, ref count, ref result);
+ EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxN, lzN, -1, bestSwN2, buffer, ref count, ref result);
+ EmitSidewallIfAny(ctx, x, y, z, fx, fz, lxN, lzN, -2, bestSwN3, buffer, ref count, ref result);
+ }
+
+ ///
+ /// Cheap per-i pre-check for sidewall candidates. Updates the
+ /// per-yDelta "farthest valid i" buckets whenever the lateral landing
+ /// column matches the y offset. The expensive full feasibility check
+ /// ( etc.) is
+ /// still performed by
+ /// on emission; this pre-check just filters out trivially-impossible
+ /// iterations so Evaluate runs at most 8 times per direction.
+ ///
+ private static void TrackSidewallCandidates(
+ CalculationContext ctx,
+ int dx, int y, int dz,
+ int lateralX, int lateralZ,
+ int i,
+ ref int bestPlus1,
+ ref int bestFlat,
+ ref int bestMinus1,
+ ref int bestMinus2)
+ {
+ int lx = dx + lateralX;
+ int lz = dz + lateralZ;
+
+ // yDelta = +1 (ascend). Only meaningful for i <= 3.
+ if (i <= 3
+ && ctx.CanWalkOn(lx, y, lz)
+ && ctx.CanWalkThrough(lx, y + 1, lz)
+ && ctx.CanWalkThrough(lx, y + 2, lz))
+ {
+ bestPlus1 = i;
+ }
+
+ // Destination column body clearance at flat/descend heights.
+ if (!ctx.CanWalkThrough(lx, y, lz) || !ctx.CanWalkThrough(lx, y + 1, lz))
+ return;
+
+ if (ctx.CanWalkOn(lx, y - 1, lz))
+ bestFlat = i;
+ else if (ctx.CanWalkOn(lx, y - 2, lz))
+ bestMinus1 = i;
+ else if (ctx.CanWalkOn(lx, y - 3, lz))
+ bestMinus2 = i;
+ }
+
+ private static void EmitSidewallIfAny(
+ CalculationContext ctx,
+ int x, int y, int z,
+ int fx, int fz,
+ int lateralX, int lateralZ,
+ int yDelta,
+ int bestI,
+ Span buffer,
+ ref int count,
+ ref MoveResult result)
+ {
+ if (bestI <= 0)
+ return;
+
+ int xOffset = fx * bestI + lateralX;
+ int zOffset = fz * bestI + lateralZ;
+ JumpDescriptor desc = new(xOffset, zOffset, yDelta, JumpFlavor.Sidewall);
+ result.Cost = 0;
+ JumpFeasibility.Evaluate(ctx, x, y, z, desc, ref result);
+ if (result.IsImpossible)
+ return;
+
+ if (count < buffer.Length)
+ buffer[count++] = new MoveNeighbor(result, MoveType.Parkour);
+ }
+
+ ///
+ /// Builds a cardinal descriptor for
+ /// the probed shape and delegates to .
+ /// The descriptor table and this probe share a single source of truth for
+ /// run-up, flight path, overshoot, cost, and entry preparation.
+ ///
+ private static void TryEmitSprintJump(
+ CalculationContext ctx,
+ int x, int y, int z,
+ int xOffset, int zOffset, int yDelta,
+ Span buffer,
+ ref int count,
+ ref MoveResult result)
+ {
+ JumpDescriptor desc = new(xOffset, zOffset, yDelta, JumpFlavor.SprintJump);
+ result.Cost = 0;
+ JumpFeasibility.Evaluate(ctx, x, y, z, desc, ref result);
+ if (result.IsImpossible)
+ return;
+
+ if (count < buffer.Length)
+ buffer[count++] = new MoveNeighbor(result, MoveType.Parkour);
}
private static MoveType DeriveMoveType(JumpDescriptor d) => d.Flavor switch
@@ -160,29 +402,8 @@ public sealed class JumpExpander : IMoveExpander
}
}
- // Cardinal parkour (flat / +1 / -1 / -2)
- foreach (int dx in offsets)
- {
- for (int d = 2; d <= 5; d++)
- list.Add(new JumpDescriptor(dx * d, 0, 0, JumpFlavor.SprintJump));
- for (int d = 2; d <= 3; d++)
- list.Add(new JumpDescriptor(dx * d, 0, 1, JumpFlavor.SprintJump));
- for (int d = 2; d <= 5; d++)
- list.Add(new JumpDescriptor(dx * d, 0, -1, JumpFlavor.SprintJump));
- for (int d = 2; d <= 5; d++)
- list.Add(new JumpDescriptor(dx * d, 0, -2, JumpFlavor.SprintJump));
- }
- foreach (int dz in offsets)
- {
- for (int d = 2; d <= 5; d++)
- list.Add(new JumpDescriptor(0, dz * d, 0, JumpFlavor.SprintJump));
- for (int d = 2; d <= 3; d++)
- list.Add(new JumpDescriptor(0, dz * d, 1, JumpFlavor.SprintJump));
- for (int d = 2; d <= 5; d++)
- list.Add(new JumpDescriptor(0, dz * d, -1, JumpFlavor.SprintJump));
- for (int d = 2; d <= 5; d++)
- list.Add(new JumpDescriptor(0, dz * d, -2, JumpFlavor.SprintJump));
- }
+ // Cardinal parkour is handled dynamically by ProbeCardinal; only the
+ // diagonal SprintJump variants remain as static descriptors.
// Diagonal parkour
foreach (int dx in offsets)
@@ -205,37 +426,19 @@ public sealed class JumpExpander : IMoveExpander
}
}
- // Sidewall parkour
- foreach (int dx in offsets)
- {
- foreach (int dz in offsets)
- {
- foreach (int distance in new[] { 2, 3, 4, 5 })
- {
- list.Add(new JumpDescriptor(dx, dz * distance, 0, JumpFlavor.Sidewall));
- list.Add(new JumpDescriptor(dx * distance, dz, 0, JumpFlavor.Sidewall));
-
- if (distance <= 3)
- {
- list.Add(new JumpDescriptor(dx, dz * distance, 1, JumpFlavor.Sidewall));
- list.Add(new JumpDescriptor(dx * distance, dz, 1, JumpFlavor.Sidewall));
- }
-
- list.Add(new JumpDescriptor(dx, dz * distance, -1, JumpFlavor.Sidewall));
- list.Add(new JumpDescriptor(dx * distance, dz, -1, JumpFlavor.Sidewall));
- list.Add(new JumpDescriptor(dx, dz * distance, -2, JumpFlavor.Sidewall));
- list.Add(new JumpDescriptor(dx * distance, dz, -2, JumpFlavor.Sidewall));
- }
- }
- }
+ // Sidewall parkour is produced by ProbeCardinal alongside cardinal
+ // sprint jumps -- the probe shares a single forward-corridor scan
+ // with the sprint-jump candidates and emits a sidewall candidate
+ // whenever a lateral wall supports it.
return list.ToArray();
}
///
- /// Read-only snapshot of the descriptor table used by this expander. Exposed
- /// for callers that need to enumerate the jump family directly (e.g. A*'s
- /// sidewall-runup preparation logic).
+ /// Read-only snapshot of the descriptor table used by this expander.
+ /// Contains only moves that are enumerated statically (Walk, Step,
+ /// diagonal SprintJump); cardinal SprintJump and Sidewall are produced
+ /// dynamically by .
///
public static ReadOnlySpan Descriptors => _descriptors;
}
diff --git a/MinecraftClient/Pathing/Moves/JumpFeasibility.cs b/MinecraftClient/Pathing/Moves/JumpFeasibility.cs
index afd783ef..86793c45 100644
--- a/MinecraftClient/Pathing/Moves/JumpFeasibility.cs
+++ b/MinecraftClient/Pathing/Moves/JumpFeasibility.cs
@@ -165,6 +165,27 @@ internal static class JumpFeasibility
return;
}
+ // Baritone-parity gate (MovementDiagonal.cost @197-200): when either
+ // cardinal shoulder also has solid ground below (i.e. the bot could
+ // walk that way first and then do a plain cardinal Ascend), refuse
+ // the diagonal Ascend. Executing a diagonal Ascend requires the
+ // bot's ground-speed momentum to already point along the diagonal at
+ // the moment of takeoff; when the preceding segment is a cardinal
+ // Walk the momentum is axis-aligned and the 2-tick yaw/input rotation
+ // during the handoff cannot redirect enough horizontal motion, so the
+ // bot consistently overshoots the target block. Forcing A* to spend
+ // the extra ~0.4 cost of a cardinal Walk + cardinal Ascend pair
+ // eliminates that execution failure while still leaving true "only
+ // reachable diagonally" setups (no cardinal floor support) on the
+ // table for scenarios that explicitly test the diagonal Step graph.
+ bool cardinalWalkableViaX = pathViaX && ctx.CanWalkOn(x + dx, y - 1, z);
+ bool cardinalWalkableViaZ = pathViaZ && ctx.CanWalkOn(x, y - 1, z + dz);
+ if (cardinalWalkableViaX || cardinalWalkableViaZ)
+ {
+ result.SetImpossible();
+ return;
+ }
+
double diagCost = ctx.SprintCost * ActionCosts.DiagonalMultiplier + ctx.JumpPenalty;
result.Set(destX, destY, destZ, diagCost);
}
diff --git a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs
index 765b25d7..a1ef655f 100644
--- a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs
+++ b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs
@@ -284,7 +284,16 @@ internal static class ParkourFeasibility
int major = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset));
int insideWallDepth = 0;
- for (int step = 0; step < 2; step++)
+ // Probe up to MaxProbeDepth cells along the forward axis at the lateral
+ // column to measure how thick the inner wall is. A 1- or 2-thick wall
+ // was the original supported case; thicker walls (3) still let the
+ // sidewall arc play out because the wall only provides lateral support
+ // during the sprint-jump — the player brushes the wall longer but the
+ // forward reach is unchanged. Walls thicker than MaxProbeDepth are
+ // rejected because they either bury the landing column or leave no
+ // open air for the arc to complete.
+ const int MaxProbeDepth = 3;
+ for (int step = 0; step < MaxProbeDepth; step++)
{
int wx = x + lateralX + (forwardX * step);
int wz = z + lateralZ + (forwardZ * step);
@@ -293,7 +302,7 @@ internal static class ParkourFeasibility
insideWallDepth++;
}
- if (insideWallDepth is < 1 or > 2)
+ if (insideWallDepth is < 1 or > MaxProbeDepth)
return false;
for (int step = 1; step <= major; step++)
diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs
index e3c200a0..11a851ae 100644
--- a/MinecraftClient/Resources/Translations/Translations.Designer.cs
+++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs
@@ -3528,6 +3528,42 @@ namespace MinecraftClient {
}
}
+ ///
+ /// Looks up a localized string similar to Planning path in background (budget: {0}ms)... result will appear in the log.
+ ///
+ internal static string cmd_goto_planning {
+ get {
+ return ResourceManager.GetString("cmd.goto.planning", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to toggle verbose pathing diagnostics (full plan dump, failure trace).
+ ///
+ internal static string cmd_pathdiag_desc {
+ get {
+ return ResourceManager.GetString("cmd.pathdiag.desc", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Pathing diagnostics enabled. Run /pathdiag off to disable.
+ ///
+ internal static string cmd_pathdiag_enabled {
+ get {
+ return ResourceManager.GetString("cmd.pathdiag.enabled", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Pathing diagnostics disabled.
+ ///
+ internal static string cmd_pathdiag_disabled {
+ get {
+ return ResourceManager.GetString("cmd.pathdiag.disabled", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to [PathMetric] routeStart segments={0}.
///
diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx
index 542e62f7..e3b34fc6 100644
--- a/MinecraftClient/Resources/Translations/Translations.resx
+++ b/MinecraftClient/Resources/Translations/Translations.resx
@@ -1252,6 +1252,18 @@ Change EnableEmoji=false in the settings if the display is confusing.
No path found ({0} nodes explored in {1}ms)
+
+ Planning path in background (budget: {0}ms)... result will appear in the log.
+
+
+ toggle verbose pathing diagnostics (full plan dump, failure trace).
+
+
+ Pathing diagnostics enabled. Run /pathdiag off to disable.
+
+
+ Pathing diagnostics disabled.
+
[PathMetric] routeStart segments={0}
diff --git a/tools/pathing_data/momentum-capabilities.json b/tools/pathing_data/momentum-capabilities.json
index 5e028d27..c922f44c 100644
--- a/tools/pathing_data/momentum-capabilities.json
+++ b/tools/pathing_data/momentum-capabilities.json
@@ -428,6 +428,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "ascend",
+ "movement_mode": "sprint",
+ "capability_metric": "gap_blocks",
+ "min_mm": 0,
+ "max_mm": 0,
+ "max_reach": 2,
+ "delta_y": 1.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "ascend",
@@ -441,6 +454,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "ascend",
+ "movement_mode": "sprint",
+ "capability_metric": "gap_blocks",
+ "min_mm": 1,
+ "max_mm": 12,
+ "max_reach": 3,
+ "delta_y": 1.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "ascend",
@@ -480,6 +506,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "ascend",
+ "movement_mode": "walk",
+ "capability_metric": "gap_blocks",
+ "min_mm": 0,
+ "max_mm": 0,
+ "max_reach": 1,
+ "delta_y": 1.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "ascend",
@@ -493,6 +532,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "ascend",
+ "movement_mode": "walk",
+ "capability_metric": "gap_blocks",
+ "min_mm": 1,
+ "max_mm": 12,
+ "max_reach": 2,
+ "delta_y": 1.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "descend",
@@ -532,6 +584,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "descend",
+ "movement_mode": "sprint",
+ "capability_metric": "gap_blocks",
+ "min_mm": 0,
+ "max_mm": 0,
+ "max_reach": 4,
+ "delta_y": -2.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "descend",
@@ -545,6 +610,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "descend",
+ "movement_mode": "sprint",
+ "capability_metric": "gap_blocks",
+ "min_mm": 1,
+ "max_mm": 12,
+ "max_reach": 5,
+ "delta_y": -2.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "descend",
@@ -584,6 +662,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "descend",
+ "movement_mode": "sprint",
+ "capability_metric": "gap_blocks",
+ "min_mm": 0,
+ "max_mm": 1,
+ "max_reach": 4,
+ "delta_y": -1.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "descend",
@@ -597,6 +688,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "descend",
+ "movement_mode": "sprint",
+ "capability_metric": "gap_blocks",
+ "min_mm": 2,
+ "max_mm": 12,
+ "max_reach": 5,
+ "delta_y": -1.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "descend",
@@ -649,6 +753,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "descend",
+ "movement_mode": "walk",
+ "capability_metric": "gap_blocks",
+ "min_mm": 0,
+ "max_mm": 0,
+ "max_reach": 2,
+ "delta_y": -2.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "descend",
@@ -662,6 +779,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "descend",
+ "movement_mode": "walk",
+ "capability_metric": "gap_blocks",
+ "min_mm": 1,
+ "max_mm": 2,
+ "max_reach": 3,
+ "delta_y": -2.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "descend",
@@ -675,6 +805,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "descend",
+ "movement_mode": "walk",
+ "capability_metric": "gap_blocks",
+ "min_mm": 3,
+ "max_mm": 12,
+ "max_reach": 4,
+ "delta_y": -2.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "descend",
@@ -714,6 +857,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "descend",
+ "movement_mode": "walk",
+ "capability_metric": "gap_blocks",
+ "min_mm": 0,
+ "max_mm": 0,
+ "max_reach": 2,
+ "delta_y": -1.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "descend",
@@ -727,6 +883,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "descend",
+ "movement_mode": "walk",
+ "capability_metric": "gap_blocks",
+ "min_mm": 1,
+ "max_mm": 12,
+ "max_reach": 3,
+ "delta_y": -1.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "flat",
@@ -766,6 +935,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "flat",
+ "movement_mode": "sprint",
+ "capability_metric": "gap_blocks",
+ "min_mm": 0,
+ "max_mm": 0,
+ "max_reach": 3,
+ "delta_y": 0.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "flat",
@@ -779,6 +961,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "flat",
+ "movement_mode": "sprint",
+ "capability_metric": "gap_blocks",
+ "min_mm": 1,
+ "max_mm": 12,
+ "max_reach": 4,
+ "delta_y": 0.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "flat",
@@ -818,6 +1013,19 @@
"wall_offset": 1,
"notes": ""
},
+ {
+ "family": "sidewall",
+ "subfamily": "flat",
+ "movement_mode": "walk",
+ "capability_metric": "gap_blocks",
+ "min_mm": 0,
+ "max_mm": 1,
+ "max_reach": 2,
+ "delta_y": 0.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
+ },
{
"family": "sidewall",
"subfamily": "flat",
@@ -830,5 +1038,18 @@
"ceiling_height": null,
"wall_offset": 1,
"notes": ""
+ },
+ {
+ "family": "sidewall",
+ "subfamily": "flat",
+ "movement_mode": "walk",
+ "capability_metric": "gap_blocks",
+ "min_mm": 2,
+ "max_mm": 12,
+ "max_reach": 3,
+ "delta_y": 0.0,
+ "ceiling_height": null,
+ "wall_offset": 2,
+ "notes": ""
}
]