Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/entity-guides/movement.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,3 +583,62 @@ Pass the real endpoint and the real size. If a value is genuinely unavailable, l
**Where this applies:** `Rs2Walker.Telemetry.*`, `WebWalkLog`, and any diagnostic that derives a field from an argument.

**Defensive check:** When a log line drives a diagnosis, confirm at the call site that each value is measured rather than hardcoded before trusting it.

## 27. Gate direct minimap clicks by Euclidean distance

`WorldPoint.distanceTo` / `distanceTo2D` use Chebyshev distance, but the usable minimap area is
approximately circular. A diagonally offset endpoint can therefore pass a short-walk distance check
while lying outside the minimap clip. Before trying a direct endpoint click, apply an explicit
Euclidean-radius check. When a raw route exists and the endpoint is outside that radius, select a
forward route tile immediately; this is normal continuation, not an exceptional fallback.

**Why this matters:** Near the end of a long route, the walker tried an endpoint that was diagonally
within 13 Chebyshev tiles, predictably failed the circular minimap clip, and then logged a forward
raw-route tile as a seemingly random fallback.

**Pattern to follow:**

```java
if (shouldAttemptDirectMinimapTarget(end, player, maxEuclidean) && walkMiniMap(end)) {
return true;
}
WorldPoint continuation = findFurthestVisibleKnownRawPathPoint(rawPath, player, maxEuclidean, anchor);
return continuation != null && walkMiniMap(continuation);
```

**Where this applies:** `Rs2Walker.tryDirectShortWalk`, route-backed final clicks, and any helper
that converts a Chebyshev proximity check into a minimap click.

**Defensive check:** Test a diagonal delta such as `(11, 11)` against a 12-tile minimap radius; it
must be rejected even though its Chebyshev distance is only 11.

## 28. Distinguish spatial proximity from route proximity

A route that detours around a mountain, fence, or building can pass close to itself. A future
smoothed waypoint may then be only a few world tiles from the player while remaining hundreds of raw
route steps ahead. Do not treat that waypoint as the immediate locally unreachable blocker and do
not anchor recovery to it. Bound local recovery candidates by forward raw-route distance; when the
candidate belongs to a distant route fold, continue from the stabilized current route frontier.

**Why this matters:** South of White Wolf Mountain, the route from Catherby toward Taverley first
travels around the mountain and later returns near `(2867,3442)`. Local recovery selected that
spatially close future branch from around `(2855,3440)`, causing every minimap click to fight the
mountain collision and route the player back west indefinitely.

**Pattern to follow:**

```java
if (!isLocalRecoveryCandidateOnForwardRoute(rawPath, smoothedToRaw,
routeStartIdx, candidateIdx, maxRawSteps)) {
issueRouteContinuationFromCurrentFrontier();
return;
}
recoverToward(candidate);
```

**Where this applies:** `Rs2Walker.processWalk` local-reachability recovery, route progress
stabilization, and any fallback that chooses a waypoint using world distance on a self-near route.

**Defensive check:** Construct a raw route whose final branch returns near its beginning. A
spatially close waypoint beyond the raw-step budget must be rejected while an ordinary nearby
forward waypoint remains eligible.
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ private List<AgentHandler> buildHandlers(int maxResults) {
new QuestHelperHandler(gson),
new StateMachineDebugHandler(gson),
new ProfileHandler(gson),
new DynamicScriptDeployHandler(gson)
new DynamicScriptDeployHandler(gson),
new LiveCollisionHandler(gson)
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package net.runelite.client.plugins.microbot.agentserver.handler;

import com.google.gson.Gson;
import com.sun.net.httpserver.HttpExchange;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.coords.WorldPoint;
import net.runelite.client.plugins.microbot.Microbot;
import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig;
import net.runelite.client.plugins.microbot.util.player.Rs2Player;

import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;

/**
* Diagnostics for the hybrid live-collision overlay: {@code GET /live-collision[?x=&y=&plane=]}.
* <p>
* Reports whether the overlay is enabled and captured, the snapshot's scene base, and — for a tile
* (the player's by default, or one given by query params) — the overlay's own edge readings
* ({@code overlayRaw}: {@code true}/{@code false}/{@code null} = defer to static), the resolved edges
* the pathfinder uses, and the static-only edges. Where {@code resolved} differs from {@code static},
* live collision is changing routing. Exists to verify Stage 1/2 against a running client, since the
* overlay and pathfinder are otherwise not observable over HTTP.
*/
@Slf4j
public class LiveCollisionHandler extends AgentHandler {

public LiveCollisionHandler(Gson gson) {
super(gson);
}

@Override
public String getPath() {
return "/live-collision";
}

@Override
protected void handleRequest(HttpExchange exchange) throws IOException {
try {
requireGet(exchange);
} catch (HttpMethodException e) {
sendJson(exchange, 405, errorResponse(e.getMessage()));
return;
}

final PathfinderConfig config = ShortestPathPlugin.getPathfinderConfig();
if (config == null) {
sendJson(exchange, 503, errorResponse("Pathfinder not initialised yet"));
return;
}

final Map<String, String> params = parseQuery(exchange.getRequestURI());
final WorldPoint tile = resolveTile(params);
if (tile == null) {
sendJson(exchange, 400, errorResponse("No tile: pass x, y (and optional plane) or log in"));
return;
}

final Map<String, Object> response = new LinkedHashMap<>(
config.liveCollisionDiagnostics(tile.getX(), tile.getY(), tile.getPlane()));
sendJson(exchange, 200, response);
}

private WorldPoint resolveTile(Map<String, String> params) {
if (params.get("x") != null && params.get("y") != null) {
try {
final int x = Integer.parseInt(params.get("x"));
final int y = Integer.parseInt(params.get("y"));
final int plane = getIntParam(params, "plane", 0);
return new WorldPoint(x, y, plane);
} catch (NumberFormatException e) {
return null;
}
}
if (!Microbot.isLoggedIn()) {
return null;
}
return Rs2Player.getWorldLocation();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -867,4 +867,15 @@ default boolean spiritTreeFarmingGuild() {
default boolean reloadTransportDefinitions() {
return false;
}

@ConfigItem(
keyName = "useLiveCollision",
name = "Live collision (experimental)",
description = "Overlay RuneLite's live scene collision on the static map inside the loaded scene, so pathfinding reflects opened/closed doors, gates and temporary objects. Out-of-scene routing still uses the static map. Experimental — leave OFF unless testing.",
position = 1,
section = sectionDeveloper
)
default boolean useLiveCollision() {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionCapture;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveRouteValidator;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap;
import net.runelite.client.plugins.microbot.util.player.Rs2Player;
import net.runelite.client.plugins.microbot.util.tile.Rs2Tile;
Expand Down Expand Up @@ -563,9 +567,159 @@ void handlePendingLoginRefresh() {
}
}

// Scene base of the last live-collision capture, so the snapshot is only rebuilt when the scene
// actually reloads rather than every tick (avoids the per-tick allocation the design warns against).
private int lastLiveCaptureBaseX = Integer.MIN_VALUE;
private int lastLiveCaptureBaseY = Integer.MIN_VALUE;
// Set by object spawn/despawn events so a mid-scene change (door, temp object) triggers one recapture
// on the next tick. Debounced to at most one rebuild per tick regardless of how many objects changed.
private volatile boolean liveCollisionDirty = false;
// Kept set until the active route has actually been checked against the newest successful capture.
// This is intentionally separate from capture dirtiness: cooldown/pathfinder gates defer validation
// without losing it.
private boolean liveRouteValidationPending = false;
// Cooldown so a run of live changes cannot spam route recalculation.
private long lastLiveRecalcMs = 0L;
private static final long LIVE_RECALC_COOLDOWN_MS = 3000L;
// Only the next few tiles of the route matter — far-ahead changes are re-checked as the player nears
// them, and may clear before then.
private static final int LIVE_RECALC_LOOKAHEAD = 15;

/** Flags the live-collision snapshot for rebuild. Cheap (a volatile write); fired from object events. */
private void markLiveCollisionDirty() {
liveCollisionDirty = true;
}

@Subscribe
public void onGameObjectSpawned(net.runelite.api.events.GameObjectSpawned event) {
markLiveCollisionDirty();
}

@Subscribe
public void onGameObjectDespawned(net.runelite.api.events.GameObjectDespawned event) {
markLiveCollisionDirty();
}

@Subscribe
public void onWallObjectSpawned(net.runelite.api.events.WallObjectSpawned event) {
markLiveCollisionDirty();
}

@Subscribe
public void onWallObjectDespawned(net.runelite.api.events.WallObjectDespawned event) {
markLiveCollisionDirty();
}

/**
* Keeps the shared live-collision overlay in step with the config flag and the loaded scene, and
* proactively recalculates the walker's route when a mid-scene change has blocked the road ahead.
* Runs on the client thread from {@link #onGameTick}. Rebuilds the immutable snapshot only when the
* scene base changes (a reload) or an object changed since the last rebuild.
*/
void refreshLiveCollision() {
if (pathfinderConfig == null) {
return;
}
final LiveCollisionOverlay overlay = pathfinderConfig.getLiveCollisionOverlay();
final boolean enabled = config.useLiveCollision();
if (enabled != overlay.isEnabled()) {
overlay.setEnabled(enabled);
lastLiveCaptureBaseX = Integer.MIN_VALUE; // force a capture on enable, drop snapshot on disable
liveCollisionDirty = enabled;
liveRouteValidationPending = false;
}
if (!enabled) {
liveCollisionDirty = false;
liveRouteValidationPending = false;
return;
}

final WorldView wv = client.getTopLevelWorldView();
if (wv == null) {
return;
}
final int baseX = wv.getBaseX();
final int baseY = wv.getBaseY();
final boolean baseChanged = baseX != lastLiveCaptureBaseX || baseY != lastLiveCaptureBaseY;
final boolean captureNeeded = baseChanged || liveCollisionDirty
|| (overlay.current() == null && !wv.isInstance());
if (captureNeeded) {
final LiveCollisionSnapshot snapshot = LiveCollisionCapture.captureOnClientThread();
if (snapshot == null) {
overlay.clear();
if (wv.isInstance()) {
// Instances intentionally use static collision. Latch this scene so we do not retry
// every tick; leaving it changes the base or produces a non-instance capture request.
lastLiveCaptureBaseX = baseX;
lastLiveCaptureBaseY = baseY;
liveCollisionDirty = false;
liveRouteValidationPending = false;
} else {
// Collision data can be briefly unavailable during loading. Keep the capture pending
// and do not claim this scene base was successfully captured.
liveCollisionDirty = true;
}
return;
}

overlay.set(snapshot);
lastLiveCaptureBaseX = baseX;
lastLiveCaptureBaseY = baseY;
liveCollisionDirty = false;
// The newly trusted interior can invalidate a route which was calculated while those tiles
// were outside the old scene, so recenter captures need the same validation as object changes.
liveRouteValidationPending = true;
}

if (liveRouteValidationPending && validateRouteAgainstLiveCollision(overlay)) {
liveRouteValidationPending = false;
}
}

/**
* If the walker is mid-route and live collision now blocks a walking step within the look-ahead,
* restart pathfinding so it routes around before stalling into the block. Cooldown-gated. Openable
* doors and transports are skipped by {@link LiveRouteValidator} (door edges are unknown in the
* overlay, transport jumps are non-adjacent), so this does not fight the runtime door handler.
*/
private boolean validateRouteAgainstLiveCollision(LiveCollisionOverlay overlay) {
if (overlay.current() == null || Rs2Walker.getCurrentTarget() == null) {
return true;
}
final long now = System.currentTimeMillis();
if (now - lastLiveRecalcMs < LIVE_RECALC_COOLDOWN_MS) {
return false;
}
final Pathfinder pf = ShortestPathPlugin.pathfinder;
if (pf == null || !pf.isDone()) {
return false;
}
final List<WorldPoint> path = pf.getPath();
if (path == null || path.size() < 2) {
return true;
}
final WorldPoint me = Rs2Player.getWorldLocation();
if (me == null) {
return false;
}

final CollisionMap map = pathfinderConfig.getMap();
map.beginSearch(); // pin the freshly captured snapshot for this validation
final int from = LiveRouteValidator.nearestIndex(path, me);
final int blocked = LiveRouteValidator.firstBlockedStep(path, from, LIVE_RECALC_LOOKAHEAD, map);
if (blocked >= 0) {
lastLiveRecalcMs = now;
log.debug("[LiveCollision] route step {} -> {} now blocked; recalculating",
path.get(blocked), path.get(blocked + 1));
Rs2Walker.recalculatePath();
}
return true;
}

@Subscribe
public void onGameTick(GameTick tick) {
handlePendingLoginRefresh();
refreshLiveCollision();

if (Rs2Walker.getCurrentTarget() != null) {
return;
Expand Down
Loading
Loading