pathing: fix long-descend water drift and offload PathDiag to background

Two related fixes for the (255,117,220) -> (237,97,172) route reported
where the bot fell out of a 22-block water descent and landed on the
rim, and where /pathdiag noticeably froze and warped the bot.

DescendTemplate: long-fall water-column overshoot
=================================================
On segment 7 of the route, a 22-block descend Descend (254.5,113,224.5)
-> (253.5,91,224.5), the bot's footprint enters the target column at
the very first airborne tick (X=253.6 footprint inside [253,254], Z=224.7
footprint inside [224,225]). The previous code immediately set
biasTowardExitInAir = true, rotating yaw to the next segment's exit
heading (-Z) for the entire 20+ tick fall. Forward held during the
fall pushed -Z momentum each tick (~0.05 m/tick equilibrium), so by
landing time the bot had drifted ~1 m past the water column and landed
on the dry rim (Z=223.42 vs target Z=224.5). On a real server this
plunge from 22 blocks onto a non-water block would kill the bot.

Two changes in DescendTemplate.cs:

1. Gate the footprint-inside-target bias for non-single-step descends
   behind a "near landing" check (remainingFallY <= 1.5 m, ~3 ticks of
   free-fall). The bias still applies on single-step descends (where
   the fall is too short for drift to matter) and on the final approach
   ticks of multi-block falls.

2. Extend the existing riskyOvershoot Back-input brake to fire on any
   multi-block descend whose footprint is already inside the target,
   not just the PrepareJump exit case. Once the bot is in the column,
   the segment's horizontal travel is done; releasing Forward and
   pressing Back kills any residual horizontal velocity so the bot
   falls straight into the water/landing block instead of accumulating
   air-control momentum from holding Forward for 20+ ticks.

Verified on 1.21.11 with /pathdiag on: segment 7 now lands at
(253.59,91,224.42), 0.09 m off target X and 0.08 m off target Z, well
inside the target water column.

PathSegmentManager: PathDiag main-thread offload
=================================================
Diagnostic dumps were emitted line-by-line through _infoLog?.Invoke(),
which calls Log.Info -> ConsoleIO.WriteLogLine -> file logger on the
main 20 TPS tick. A slow-segment dump or failure trace produces
25-200 synchronous log calls; on the affected route a single batch
took 200-500 ms on the tick thread, freezing the position-packet
stream long enough for the server to lose track and then snap the
bot forward when the tick resumed. Symptom on the user side: every
time /pathdiag is on, the bot freezes for ~half a second, then
"teleports" through the queued segments, then freezes again.

Each diagnostic emission point now snapshots its lines into a List
and dispatches them through DispatchDiagnosticsBatch, which appends
to a single chained Task running on TaskScheduler.Default. The chain
preserves emission order across batches so concurrent slow-segment
and seg-> headers do not interleave. The tick path now does O(1)
work per dump (build list, ContinueWith) instead of O(N) console
writes.

Verified by running the same /goto twice (with and without pathdiag):
both runs produced identical trajectories and identical replan
counts, confirming the diagnostics path no longer perturbs movement.

Pathing test suite: 23 failures, identical to baseline. Live regression
on prior 4 zero-replan routes ((237<->252, 237<->244)) all still
complete cleanly.

Made-with: Cursor
This commit is contained in:
BruceChen 2026-04-29 13:44:26 +00:00
parent 3a82914ea2
commit f7d9a8048c
2 changed files with 108 additions and 14 deletions

View file

@ -58,6 +58,22 @@ namespace MinecraftClient.Pathing.Execution
private int _lastObservedSegmentIndex = -1;
private int _ticksSinceSegmentStart;
// Diagnostic emission runs on the thread pool to keep the 20 TPS tick
// unblocked. Each batch is appended to a single chained Task so that
// line ordering is preserved across batches, even when the same tick
// produces both a slow-segment dump and the next seg-> header. Without
// the chain, multiple Task.Run calls could interleave and mangle the
// log output.
//
// Without this offload, a 200-line failure dump issued synchronously
// through ConsoleIO.WriteLogLine -> file logger took 200-500 ms on
// the main tick. The stalled tick stops position packets so the
// server view freezes, then snaps forward when the tick resumes -
// exactly the "freeze, jump, freeze" the user reported when /pathdiag
// was on.
private Task _diagFlushTail = Task.CompletedTask;
private readonly object _diagFlushLock = new();
public bool IsNavigating =>
(_executor is not null && !_executor.IsComplete)
|| _nextExecutor is not null
@ -203,24 +219,29 @@ namespace MinecraftClient.Pathing.Execution
if (_lastObservedSegmentIndex >= 0
&& _ticksSinceSegmentStart >= SlowSegmentDumpTickThreshold)
{
_infoLog?.Invoke(
$"[PathDiag] Slow segment {_lastObservedSegmentIndex}/{_executor.TotalSegments} took {_ticksSinceSegmentStart} ticks, dumping last {Math.Min(_diagnosticsTail.Count, _ticksSinceSegmentStart)} ticks:");
int toDump = Math.Min(_diagnosticsTail.Count, _ticksSinceSegmentStart);
int skipCount = _diagnosticsTail.Count - toDump;
var batch = new List<string>(toDump + 1)
{
$"[PathDiag] Slow segment {_lastObservedSegmentIndex}/{_executor.TotalSegments} took {_ticksSinceSegmentStart} ticks, dumping last {toDump} ticks:"
};
int i = 0;
foreach (string line in _diagnosticsTail)
{
if (i++ < skipCount)
continue;
_infoLog?.Invoke($"[PathDiag] t-{toDump - (i - skipCount)}: {line}");
batch.Add($"[PathDiag] t-{toDump - (i - skipCount)}: {line}");
}
DispatchDiagnosticsBatch(batch);
}
_lastObservedSegmentIndex = segIdx;
_ticksSinceSegmentStart = 0;
_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}"));
DispatchDiagnosticsBatch(new[]
{
$"[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}")
});
}
else
{
@ -239,28 +260,70 @@ namespace MinecraftClient.Pathing.Execution
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}"));
var batch = new List<string>(_diagnosticsTail.Count + 2)
{
$"[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}):");
batch.Add($"[PathDiag] Recent tick trace (last {_diagnosticsTail.Count}):");
int i = 0;
int total = _diagnosticsTail.Count;
foreach (string line in _diagnosticsTail)
_infoLog?.Invoke($"[PathDiag] t-{_diagnosticsTail.Count - i++ - 1}: {line}");
batch.Add($"[PathDiag] t-{total - i++ - 1}: {line}");
}
DispatchDiagnosticsBatch(batch);
}
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;
var batch = new List<string>(count + 1)
{
$"[PathDiag] {label}: {count} waypoints, status={result.Status}, nodes={result.NodesExplored}, time={result.ElapsedMs}ms"
};
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})");
batch.Add($"[PathDiag] [{startIdx + i:D2}] {move,-22} ({node.X},{node.Y},{node.Z})");
}
DispatchDiagnosticsBatch(batch);
}
/// <summary>
/// Schedule a diagnostics line batch for emission on a background task,
/// chained behind any prior batch so output order is preserved. The
/// caller's snapshot is captured by reference; the input list MUST not
/// be mutated after dispatch.
/// </summary>
private void DispatchDiagnosticsBatch(IReadOnlyList<string> lines)
{
Action<string>? infoLog = _infoLog;
if (infoLog is null || lines.Count == 0)
return;
lock (_diagFlushLock)
{
_diagFlushTail = _diagFlushTail.ContinueWith(_ =>
{
for (int i = 0; i < lines.Count; i++)
{
try
{
infoLog(lines[i]);
}
catch
{
// Swallow logger faults so a downstream sink failure
// never tears down the chain (which would silently
// drop every subsequent diagnostics batch).
}
}
}, TaskScheduler.Default);
}
}

View file

@ -145,7 +145,24 @@ namespace MinecraftClient.Pathing.Execution.Templates
double segmentYDrop = _segment.Start.Y - _segment.End.Y;
bool isSingleStepDescend = segmentYDrop <= 1.0;
bool footInsideTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd);
bool biasTowardExitInAir = footInsideTarget
// Long-descend lateral drift guard. When the bot's footprint
// enters the landing block at the very start of a multi-block
// fall (e.g. a 22-block water drop where target X/Z column
// matches the launch column), `biasTowardExitInAir` would
// immediately rotate yaw to the next segment's heading. With
// Forward held during the entire fall, the perpendicular air
// drift accumulates ~0.05 m/tick and over 20+ airborne ticks
// walks the bot a full block out of the landing column, so it
// misses the water/landing target and dies on the rim. Only
// permit exit-heading bias for non-single-step descends once
// the bot is within ~1.5 m of the landing Y (~3 ticks of
// free-fall), so any exit-heading drift cannot displace the
// landing footprint by more than a fraction of a block.
double remainingFallY = pos.Y - _segment.End.Y;
bool nearLanding = remainingFallY <= 1.5;
bool biasTowardExitInAir = (footInsideTarget && (isSingleStepDescend || nearLanding))
|| (isSingleStepDescend
&& (onOrPastTarget
|| (_hasFallen
@ -204,11 +221,25 @@ namespace MinecraftClient.Pathing.Execution.Templates
// 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.
//
// The same guard applies to long water/landing
// drops with any non-PrepareJump exit. A 22-block
// fall lasts 20+ airborne ticks; at ~0.2 m/tick
// peak air-control velocity, holding Forward for
// the entire fall accumulates 4+ m of horizontal
// drift past the start ledge and the bot lands
// outside the 1x1 water column. Once the
// footprint is inside the target column, brake
// horizontal velocity so the bot falls straight
// down into the water/landing block.
bool riskyOvershoot = _hasFallen
&& segmentYDrop >= 2.0
&& onOrPastTarget
&& _segment.ExitTransition == PathTransitionType.PrepareJump;
if (riskyOvershoot)
bool longFallFootprintLanding = _hasFallen
&& segmentYDrop >= 2.0
&& footInsideTarget;
if (riskyOvershoot || longFallFootprintLanding)
{
input.Forward = false;
input.Sprint = false;