From f7d9a8048ce65c19c60f0baed255c662b88a187d Mon Sep 17 00:00:00 2001 From: BruceChen Date: Wed, 29 Apr 2026 13:44:26 +0000 Subject: [PATCH] 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 --- .../Pathing/Execution/PathSegmentManager.cs | 87 ++++++++++++++++--- .../Execution/Templates/DescendTemplate.cs | 35 +++++++- 2 files changed, 108 insertions(+), 14 deletions(-) diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs index f36a0247..d64052d4 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -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(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=" : $"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(_diagnosticsTail.Count + 2) + { + $"[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}):"); + 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(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); + } + + /// + /// 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. + /// + private void DispatchDiagnosticsBatch(IReadOnlyList lines) + { + Action? 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); } } diff --git a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs index e481e361..bf44286c 100644 --- a/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs +++ b/MinecraftClient/Pathing/Execution/Templates/DescendTemplate.cs @@ -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;