From da79e67aa13d5775f8f67a41c69f325dbf63350c Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 23 Jul 2026 10:20:26 +0100 Subject: [PATCH 1/7] feat(shortestpath): hybrid live collision overlay (stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an immutable live-collision overlay on top of the static SplitFlagMap so pathfinding and smoothing can reflect the loaded scene, gated by a config flag that defaults OFF. Out-of-scene routing is unchanged — the static map still owns global routes. New pathfinder/live package: - LiveCollisionSnapshot: immutable, world-addressed, known/value per edge so the scene rim reads as unknown and falls back to the static map. - LiveCollisionOverlay: shared, atomically-swapped volatile holder read lock-free by every CollisionMap. - LiveCollisionCapture: client-thread builder mirroring Rs2Tile's CollisionDataFlag translation; instances return null (static fallback) pending dedicated chunk mapping. CollisionMap.get() consults a per-search pinned snapshot then the static map, so both neighbour expansion and PathSmoother pick it up from one place. PathfinderConfig owns the shared overlay behind its ThreadLocal; Pathfinder.run() pins the snapshot per search so a mid-search swap cannot mix two scenes. ShortestPathPlugin rebuilds the snapshot on scene-base change from the onGameTick client thread (no per-tick allocation). With the flag off a CollisionMap reads byte-for-byte as the static-only map, so the existing shortestpath suite is unchanged. New LiveCollisionTest covers the flag translation (walls, symmetry, full-block closing all four edges), rim/out-of-scene fallback, overlay-wins-in-scene, and no-overlay determinism. Guardrail stays green. Stage 2 (door/openable-edge preservation) and Stage 3 (debounced scene-change recalc) follow. Co-Authored-By: Claude Opus 4.8 --- .../shortestpath/ShortestPathConfig.java | 11 ++ .../shortestpath/ShortestPathPlugin.java | 43 ++++ .../shortestpath/pathfinder/CollisionMap.java | 37 ++++ .../shortestpath/pathfinder/Pathfinder.java | 3 + .../pathfinder/PathfinderConfig.java | 10 +- .../pathfinder/live/LiveCollisionCapture.java | 131 +++++++++++++ .../pathfinder/live/LiveCollisionOverlay.java | 50 +++++ .../live/LiveCollisionSnapshot.java | 83 ++++++++ .../shortestpath/LiveCollisionTest.java | 184 ++++++++++++++++++ 9 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionOverlay.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionSnapshot.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java index f239ab1530..35649c9806 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java @@ -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; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java index 3f4c1437d1..967857a620 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java @@ -51,6 +51,8 @@ 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.SplitFlagMap; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; @@ -563,9 +565,50 @@ 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; + + /** + * Keeps the shared live-collision overlay in step with the config flag and the loaded scene. Runs on + * the client thread from {@link #onGameTick}. Rebuilds the immutable snapshot only when the scene + * base changes (a scene reload) or when the flag was just enabled. Finer-grained refresh on + * mid-scene object changes, and the recalc that acts on it, are later stages. + */ + 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 + } + if (!enabled) { + return; + } + + final WorldView wv = client.getTopLevelWorldView(); + if (wv == null) { + return; + } + final int baseX = wv.getBaseX(); + final int baseY = wv.getBaseY(); + if (baseX == lastLiveCaptureBaseX && baseY == lastLiveCaptureBaseY) { + return; + } + + overlay.set(LiveCollisionCapture.captureOnClientThread()); + lastLiveCaptureBaseX = baseX; + lastLiveCaptureBaseY = baseY; + } + @Subscribe public void onGameTick(GameTick tick) { handlePendingLoginRefresh(); + refreshLiveCollision(); if (Rs2Walker.getCurrentTarget() != null) { return; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java index f5365853f5..f737df1964 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java @@ -5,6 +5,8 @@ import net.runelite.api.coords.WorldPoint; import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportType; +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.WorldPointUtil; import net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint; import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; @@ -19,15 +21,50 @@ public class CollisionMap { private final SplitFlagMap collisionData; + /** + * Shared live-collision overlay. When enabled and covering {@code (x, y, z)}, its edges win over the + * static map; otherwise every read falls through to {@code collisionData}. Defaults to a disabled + * holder, so a {@link #CollisionMap(SplitFlagMap)} behaves exactly as the static-only map — the whole + * existing test suite is unaffected. + */ + private final LiveCollisionOverlay overlay; + + /** + * Snapshot pinned for the duration of one search, so a mid-search swap on the client thread cannot + * mix two scenes into a single path. Refreshed via {@link #beginSearch()}. + */ + private LiveCollisionSnapshot pinnedSnapshot; + public byte[] getPlanes() { return collisionData.getRegionMapPlaneCounts(); } public CollisionMap(SplitFlagMap collisionData) { + this(collisionData, new LiveCollisionOverlay()); + } + + public CollisionMap(SplitFlagMap collisionData, LiveCollisionOverlay overlay) { this.collisionData = collisionData; + this.overlay = overlay; + } + + /** + * Pins the overlay's current snapshot for the upcoming search. Called once at the start of a + * pathfind so every {@link #get} within that search sees one immutable view; between searches the + * client thread is free to swap in a newer snapshot. + */ + public void beginSearch() { + pinnedSnapshot = overlay.current(); } private boolean get(int x, int y, int z, int flag) { + final LiveCollisionSnapshot live = pinnedSnapshot; + if (live != null) { + final Boolean liveEdge = live.edge(x, y, z, flag); + if (liveEdge != null) { + return liveEdge; + } + } return collisionData.get(x, y, z, flag); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java index 47d76b138b..c7b10f56d9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java @@ -735,6 +735,9 @@ public void run() { WebWalkLog.pf("run_start src={} dst={} cutoffMs={}", WorldPointUtil.toString(start), WorldPointUtil.toString(targets), config.getCalculationCutoffMillis()); joinedPath = null; + // Pin the live-collision snapshot for this whole search so a mid-search swap on the client + // thread cannot mix two scenes into one path. No-op when live collision is disabled. + map.beginSearch(); try { stats.start(); computeNetworkLandmarks(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java index 9bb584e261..3c9b1d625c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java @@ -11,6 +11,7 @@ import net.runelite.client.plugins.itemcharges.ItemChargeConfig; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.shortestpath.*; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.policy.TransportRequirementPolicy; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; @@ -74,6 +75,13 @@ public class PathfinderConfig { private final SplitFlagMap mapData; private final ThreadLocal map; + /** + * One shared overlay behind every per-thread {@link CollisionMap}, so the client thread can swap in a + * fresh live snapshot that all pathfinding threads pick up. Disabled until the + * {@code useLiveCollision} config flag turns it on. + */ + @Getter + private final LiveCollisionOverlay liveCollisionOverlay = new LiveCollisionOverlay(); /** * All transports by origin {@link WorldPoint}. The null key is used for transports centered on the player. */ @@ -190,7 +198,7 @@ public PathfinderConfig(SplitFlagMap mapData, Map> tr List restrictions, Client client, ShortestPathConfig config) { this.mapData = mapData; - this.map = ThreadLocal.withInitial(() -> new CollisionMap(this.mapData)); + this.map = ThreadLocal.withInitial(() -> new CollisionMap(this.mapData, this.liveCollisionOverlay)); this.allTransports = Collections.synchronizedMap(new HashMap<>()); replaceAllTransports(transports); this.usableTeleports = ConcurrentHashMap.newKeySet(allTransports.size() / 20); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java new file mode 100644 index 0000000000..7804101007 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java @@ -0,0 +1,131 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder.live; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.CollisionData; +import net.runelite.api.CollisionDataFlag; +import net.runelite.api.WorldView; +import net.runelite.client.plugins.microbot.Microbot; + +import java.util.BitSet; + +import static net.runelite.api.Constants.SCENE_SIZE; + +/** + * Builds a {@link LiveCollisionSnapshot} from the live RuneLite scene. + *

+ * {@link #capture()} must run on the client thread — it reads {@code WorldView.getCollisionMaps()} — and + * hands the result to an immutable snapshot the off-thread pathfinder can then read lock-free. The + * translation from RuneLite's per-tile {@link CollisionDataFlag} bitmask to the can-north / can-east edge + * model mirrors the canonical logic in {@code Rs2Tile.getReachableTilesFromTileInternal}: a tile is + * standable unless {@link CollisionDataFlag#BLOCK_MOVEMENT_FULL} is set, and a directional wall bit blocks + * the corresponding edge. An edge is only recorded when both tiles it connects were captured, so the + * north/east rim of the scene is left unknown and falls back to the static map. + *

+ * Instances are deliberately not captured in this stage: the scene→world mapping for rotated and repeated + * template chunks needs its own handling, and an approximate mapping would fabricate phantom walls. Inside + * an instance {@link #capture()} returns {@code null}, so the overlay covers nothing and the pathfinder + * uses the static map — the same behaviour as today. + */ +@Slf4j +public final class LiveCollisionCapture { + + private LiveCollisionCapture() { + } + + /** + * Reads the live scene on the client thread and returns an immutable snapshot, or {@code null} when + * there is nothing to capture (no world view, no collision data, or an instanced scene). + */ + public static LiveCollisionSnapshot capture() { + return Microbot.getClientThread().runOnClientThreadOptional(LiveCollisionCapture::captureOnClientThread) + .orElse(null); + } + + /** + * Same as {@link #capture()} but for callers that are already on the client thread (e.g. a + * {@code GameTick} subscriber), avoiding a redundant thread hop. + */ + public static LiveCollisionSnapshot captureOnClientThread() { + final WorldView wv = Microbot.getClient().getTopLevelWorldView(); + if (wv == null) { + return null; + } + // See class javadoc: instances need dedicated scene->world chunk mapping; skip for now. + if (wv.isInstance()) { + return null; + } + + final CollisionData[] collisionMaps = wv.getCollisionMaps(); + if (collisionMaps == null || collisionMaps.length == 0) { + return null; + } + + final int planeCount = collisionMaps.length; + final int[][][] flagsByPlane = new int[planeCount][][]; + for (int z = 0; z < planeCount; z++) { + final CollisionData plane = collisionMaps[z]; + flagsByPlane[z] = plane != null ? plane.getFlags() : null; + } + + return build(wv.getBaseX(), wv.getBaseY(), planeCount, flagsByPlane); + } + + /** + * Pure translation, split out so it can be unit-tested without a client. {@code flagsByPlane[z]} is + * the {@code int[SCENE_SIZE][SCENE_SIZE]} returned by {@code CollisionData.getFlags()}, indexed + * {@code [sceneX][sceneY]}, or {@code null} for a plane with no data. + */ + public static LiveCollisionSnapshot build(int baseX, int baseY, int planeCount, int[][][] flagsByPlane) { + final int cells = planeCount * SCENE_SIZE * SCENE_SIZE; + final BitSet northKnown = new BitSet(cells); + final BitSet northValue = new BitSet(cells); + final BitSet eastKnown = new BitSet(cells); + final BitSet eastValue = new BitSet(cells); + + for (int z = 0; z < planeCount; z++) { + final int[][] flags = flagsByPlane[z]; + if (flags == null) { + continue; + } + for (int sx = 0; sx < SCENE_SIZE; sx++) { + for (int sy = 0; sy < SCENE_SIZE; sy++) { + final int index = (z * SCENE_SIZE + sy) * SCENE_SIZE + sx; + final int data = flags[sx][sy]; + final boolean walkableHere = standable(data); + + // North edge: known only when the north neighbour is inside the scene. + if (sy + 1 < SCENE_SIZE) { + northKnown.set(index); + final int north = flags[sx][sy + 1]; + final boolean open = walkableHere + && standable(north) + && (data & CollisionDataFlag.BLOCK_MOVEMENT_NORTH) == 0 + && (north & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH) == 0; + if (open) { + northValue.set(index); + } + } + + // East edge: known only when the east neighbour is inside the scene. + if (sx + 1 < SCENE_SIZE) { + eastKnown.set(index); + final int east = flags[sx + 1][sy]; + final boolean open = walkableHere + && standable(east) + && (data & CollisionDataFlag.BLOCK_MOVEMENT_EAST) == 0 + && (east & CollisionDataFlag.BLOCK_MOVEMENT_WEST) == 0; + if (open) { + eastValue.set(index); + } + } + } + } + } + + return new LiveCollisionSnapshot(baseX, baseY, planeCount, northKnown, northValue, eastKnown, eastValue); + } + + private static boolean standable(int data) { + return (data & CollisionDataFlag.BLOCK_MOVEMENT_FULL) == 0; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionOverlay.java new file mode 100644 index 0000000000..39e9338bff --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionOverlay.java @@ -0,0 +1,50 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder.live; + +/** + * Shared, atomically-swappable holder for the current {@link LiveCollisionSnapshot}. + *

+ * One instance is referenced by every {@code CollisionMap} — which is a {@code ThreadLocal} over a shared + * immutable {@code SplitFlagMap}, so the live overlay must live outside those per-thread instances. The + * client thread swaps the snapshot via {@link #set}; the off-thread pathfinder reads it as a single + * volatile load through {@link #current()}. Because a snapshot is immutable, a reader that pins the + * reference for the duration of one search sees a consistent view even if the client thread swaps in a + * newer one mid-search. + *

+ * When {@link #isEnabled()} is {@code false} — the default, and whenever the config flag is off — there is + * no snapshot and callers behave exactly as the static-only map does today. + */ +public final class LiveCollisionOverlay { + private volatile boolean enabled; + private volatile LiveCollisionSnapshot current; + + /** + * @return the current snapshot, or {@code null} when disabled or nothing has been captured yet. + * Callers doing more than one edge read should pin this into a local so the whole search sees one + * immutable view. + */ + public LiveCollisionSnapshot current() { + return enabled ? current : null; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + if (!enabled) { + current = null; + } + } + + /** Swaps in a freshly captured snapshot. No effect while disabled. */ + public void set(LiveCollisionSnapshot snapshot) { + if (enabled) { + current = snapshot; + } + } + + public void clear() { + current = null; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionSnapshot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionSnapshot.java new file mode 100644 index 0000000000..c7f51c049d --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionSnapshot.java @@ -0,0 +1,83 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder.live; + +import java.util.BitSet; + +import static net.runelite.api.Constants.SCENE_SIZE; + +/** + * Immutable snapshot of the live collision of a single loaded scene, addressed in world + * coordinates and expressed in the same edge model as + * {@link net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap}: two flags per tile, + * flag {@code 0} = can move north, flag {@code 1} = can move east. South and west are read from the + * neighbour's north/east, exactly as {@code CollisionMap} does, so an overlay built from this stays + * consistent with the static map it sits on top of. + *

+ * Each edge carries a validity bit as well as a value: an edge is only known when the capture + * could see both tiles it connects. Edges on the north/east rim of the scene, whose neighbour lies + * outside the captured 104×104, are left unknown so the caller falls back to the static map + * there rather than fabricating a wall at the scene boundary. + *

+ * Built once on the client thread by {@link LiveCollisionCapture} and never mutated, so the off-thread + * pathfinder can read it lock-free. + */ +public final class LiveCollisionSnapshot { + /** North edge, flag 0 in the shared edge model. */ + public static final int FLAG_NORTH = 0; + /** East edge, flag 1 in the shared edge model. */ + public static final int FLAG_EAST = 1; + + private static final int PLANE_STRIDE = SCENE_SIZE * SCENE_SIZE; + + private final int baseX; + private final int baseY; + private final int planeCount; + private final BitSet northKnown; + private final BitSet northValue; + private final BitSet eastKnown; + private final BitSet eastValue; + + LiveCollisionSnapshot(int baseX, int baseY, int planeCount, + BitSet northKnown, BitSet northValue, + BitSet eastKnown, BitSet eastValue) { + this.baseX = baseX; + this.baseY = baseY; + this.planeCount = planeCount; + this.northKnown = northKnown; + this.northValue = northValue; + this.eastKnown = eastKnown; + this.eastValue = eastValue; + } + + /** + * @return {@link Boolean#TRUE}/{@link Boolean#FALSE} when {@code (x, y, z)} is inside this snapshot + * and the edge is known, or {@code null} when the tile is outside the scene or the edge sits on the + * unknown rim — the caller falls back to the static map. Returns the cached {@code Boolean} + * constants, so this allocates nothing on the pathfinder hot path. + */ + public Boolean edge(int x, int y, int z, int flag) { + final int lx = x - baseX; + final int ly = y - baseY; + if (lx < 0 || lx >= SCENE_SIZE || ly < 0 || ly >= SCENE_SIZE || z < 0 || z >= planeCount) { + return null; + } + final int index = z * PLANE_STRIDE + ly * SCENE_SIZE + lx; + final BitSet known = flag == FLAG_EAST ? eastKnown : northKnown; + if (!known.get(index)) { + return null; + } + final BitSet value = flag == FLAG_EAST ? eastValue : northValue; + return value.get(index) ? Boolean.TRUE : Boolean.FALSE; + } + + public int getBaseX() { + return baseX; + } + + public int getBaseY() { + return baseY; + } + + public int getPlaneCount() { + return planeCount; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java new file mode 100644 index 0000000000..43a52e1ce1 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java @@ -0,0 +1,184 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.CollisionDataFlag; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; +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 org.junit.BeforeClass; +import org.junit.Test; + +import static net.runelite.api.Constants.SCENE_SIZE; +import static net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot.FLAG_EAST; +import static net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot.FLAG_NORTH; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Stage 1 hybrid live-collision coverage: the pure flag translation in {@link LiveCollisionCapture#build}, + * the snapshot's edge/validity semantics, and that {@link CollisionMap} lets an enabled overlay win inside + * the captured scene while falling back to the static map everywhere else. With no overlay, a + * {@code CollisionMap} must read byte-for-byte as the static-only map — the guard that keeps every other + * shortestpath test deterministic. + */ +public class LiveCollisionTest { + + private static final int BASE_X = 3200; + private static final int BASE_Y = 3200; + + private static SplitFlagMap staticFlags; + + @BeforeClass + public static void loadCollisionMap() { + staticFlags = SplitFlagMap.fromResources(); + } + + // ---- pure translation (no client, no static map) ---- + + private static int[][][] openScene() { + // One plane, all tiles standable, no walls -> every interior edge open. + return new int[][][]{new int[SCENE_SIZE][SCENE_SIZE]}; + } + + @Test + public void openScene_interiorEdgesOpen_rimUnknown() { + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, openScene()); + + // interior tile: both edges known and open + assertEquals(Boolean.TRUE, snap.edge(BASE_X + 10, BASE_Y + 10, 0, FLAG_NORTH)); + assertEquals(Boolean.TRUE, snap.edge(BASE_X + 10, BASE_Y + 10, 0, FLAG_EAST)); + + // north rim: north neighbour is outside the scene -> unknown -> null (static fallback) + assertNull(snap.edge(BASE_X + 10, BASE_Y + SCENE_SIZE - 1, 0, FLAG_NORTH)); + // east rim: east neighbour outside -> unknown + assertNull(snap.edge(BASE_X + SCENE_SIZE - 1, BASE_Y + 10, 0, FLAG_EAST)); + + // entirely outside the scene -> null + assertNull(snap.edge(BASE_X - 5, BASE_Y + 10, 0, FLAG_NORTH)); + assertNull(snap.edge(BASE_X + 10, BASE_Y + 10, 1, FLAG_NORTH)); // plane 1 not captured + } + + @Test + public void wallBitBlocksTheEdge() { + int[][][] flags = openScene(); + flags[0][10][10] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + flags[0][20][20] |= CollisionDataFlag.BLOCK_MOVEMENT_EAST; + + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags); + + assertEquals(Boolean.FALSE, snap.edge(BASE_X + 10, BASE_Y + 10, 0, FLAG_NORTH)); + assertEquals(Boolean.FALSE, snap.edge(BASE_X + 20, BASE_Y + 20, 0, FLAG_EAST)); + // the untouched edge of the same tile is still open + assertEquals(Boolean.TRUE, snap.edge(BASE_X + 10, BASE_Y + 10, 0, FLAG_EAST)); + } + + @Test + public void wallIsSymmetric_neighbourSouthBitAlsoBlocks() { + // A south wall on the north tile must block the same edge, since s(x,y)=n(x,y-1). + int[][][] flags = openScene(); + flags[0][30][31] |= CollisionDataFlag.BLOCK_MOVEMENT_SOUTH; // south wall on the northern tile + + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags); + + // north edge of (30,30) connects (30,30)<->(30,31); the south wall on (30,31) closes it + assertEquals(Boolean.FALSE, snap.edge(BASE_X + 30, BASE_Y + 30, 0, FLAG_NORTH)); + } + + @Test + public void fullBlockClosesAllFourEdgesAroundTheTile() { + int[][][] flags = openScene(); + final int fx = 40, fy = 40; + flags[0][fx][fy] |= CollisionDataFlag.BLOCK_MOVEMENT_FULL; + + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags); + + // the full tile's own outgoing north/east edges are closed + assertEquals(Boolean.FALSE, snap.edge(BASE_X + fx, BASE_Y + fy, 0, FLAG_NORTH)); + assertEquals(Boolean.FALSE, snap.edge(BASE_X + fx, BASE_Y + fy, 0, FLAG_EAST)); + // and the incoming edges: south neighbour's north, west neighbour's east + assertEquals(Boolean.FALSE, snap.edge(BASE_X + fx, BASE_Y + fy - 1, 0, FLAG_NORTH)); + assertEquals(Boolean.FALSE, snap.edge(BASE_X + fx - 1, BASE_Y + fy, 0, FLAG_EAST)); + } + + // ---- CollisionMap integration against the real static map ---- + + @Test + public void noOverlay_readsIdenticalToStaticMap() { + CollisionMap plain = new CollisionMap(staticFlags); + CollisionMap withEmptyOverlay = new CollisionMap(staticFlags, new LiveCollisionOverlay()); + withEmptyOverlay.beginSearch(); // overlay disabled -> pins null + + // sweep a mapped area and confirm every edge matches + for (int x = 3200; x < 3260; x++) { + for (int y = 3200; y < 3260; y++) { + assertEquals(plain.n(x, y, 0), withEmptyOverlay.n(x, y, 0)); + assertEquals(plain.e(x, y, 0), withEmptyOverlay.e(x, y, 0)); + assertEquals(plain.isBlocked(x, y, 0), withEmptyOverlay.isBlocked(x, y, 0)); + } + } + } + + @Test + public void overlayBlocksAnOpenStaticEdge_andFallsBackOutsideScene() { + CollisionMap staticMap = new CollisionMap(staticFlags); + + // find an interior mapped tile with an open north edge in Lumbridge + int tx = -1, ty = -1; + for (int x = 3210; x < 3250 && tx < 0; x++) { + for (int y = 3210; y < 3250; y++) { + if (staticFlags.hasRegion(x, y) && staticMap.n(x, y, 0)) { + tx = x; + ty = y; + break; + } + } + } + assertTrue("expected an open north edge in Lumbridge", tx > 0); + + // snapshot centred so the target tile is interior (not on the unknown rim) + final int baseX = tx - 52; + final int baseY = ty - 52; + int[][][] flags = openScene(); + flags[0][tx - baseX][ty - baseY] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + + LiveCollisionOverlay overlay = new LiveCollisionOverlay(); + overlay.setEnabled(true); + overlay.set(LiveCollisionCapture.build(baseX, baseY, 1, flags)); + + CollisionMap live = new CollisionMap(staticFlags, overlay); + live.beginSearch(); + + // overlay wins inside the scene + assertTrue("precondition: static edge open", staticMap.n(tx, ty, 0)); + assertFalse("overlay must block the edge", live.n(tx, ty, 0)); + + // a tile far outside the snapshot falls back to the static map + int farX = baseX + 5000; + int farY = baseY + 5000; + assertEquals(staticMap.n(farX, farY, 0), live.n(farX, farY, 0)); + assertEquals(staticMap.e(farX, farY, 0), live.e(farX, farY, 0)); + } + + @Test + public void disabledOverlaySnapshot_isIgnored() { + int[][][] flags = openScene(); + LiveCollisionOverlay overlay = new LiveCollisionOverlay(); + // set() while disabled must not take effect + overlay.set(LiveCollisionCapture.build(3148, 3148, 1, flags)); + assertNull(overlay.current()); + + CollisionMap staticMap = new CollisionMap(staticFlags); + CollisionMap live = new CollisionMap(staticFlags, overlay); + live.beginSearch(); + for (int x = 3200; x < 3210; x++) { + for (int y = 3200; y < 3210; y++) { + assertEquals(staticMap.n(x, y, 0), live.n(x, y, 0)); + assertEquals(staticMap.e(x, y, 0), live.e(x, y, 0)); + } + } + } +} From d93be1c3a8c8473b9b9c868ab93266a9f9efb4d3 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 23 Jul 2026 10:34:26 +0100 Subject: [PATCH 2/7] feat(shortestpath): preserve openable doors under live collision (stage 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A closed door blocks its doorway in RuneLite's live collision, so with the overlay on the pathfinder would route around an openable door instead of walking up and opening it — the static map treats the doorway as passable and the runtime door subsystem (Rs2Walker.handleDoors) opens the physical door as the path crosses it. LiveCollisionCapture now scans the loaded scene on the client thread for wall objects with a door action (open / go-through / walk-through / pass / pick-lock / pay-toll, incl. multiloc impostors) and leaves every edge touching such a tile UNKNOWN in the snapshot. Unknown edges already fall back to the static map in CollisionMap.get, so the existing door pipeline keeps owning doors unchanged — no CollisionMap change needed. Transports need no handling: getNeighbors adds transport edges independently of the collision traversability check, so a live-blocked walking edge never removes a transport edge. That, plus the door exemption, covers the false-"unreachable" cases without a blanket fallback that would defeat live blocking of genuine new obstacles. Tests: door-tile edges stay unknown even when live flags block all four sides; null door mask matches the no-door path. Full suite and guardrail green. Co-Authored-By: Claude Opus 4.8 --- .../pathfinder/live/LiveCollisionCapture.java | 116 +++++++++++++++++- .../shortestpath/LiveCollisionTest.java | 40 ++++++ 2 files changed, 151 insertions(+), 5 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java index 7804101007..0120f0cfca 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java @@ -3,10 +3,17 @@ import lombok.extern.slf4j.Slf4j; import net.runelite.api.CollisionData; import net.runelite.api.CollisionDataFlag; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Tile; +import net.runelite.api.WallObject; import net.runelite.api.WorldView; import net.runelite.client.plugins.microbot.Microbot; +import java.util.Arrays; import java.util.BitSet; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; import static net.runelite.api.Constants.SCENE_SIZE; @@ -29,6 +36,16 @@ @Slf4j public final class LiveCollisionCapture { + /** + * Actions that mark a wall object as a door the walker opens at runtime. Mirrors the door-action set + * in {@code Rs2Walker.handleDoors}. Any edge touching such a door is left unknown in the + * snapshot so it falls back to the static map (which treats the doorway as passable) and the existing + * runtime door subsystem keeps owning it — otherwise a closed door's live-blocked edge would make the + * pathfinder route around an openable door. + */ + private static final Set DOOR_ACTIONS = new HashSet<>(Arrays.asList( + "open", "go-through", "walk-through", "pass", "pick-lock", "pay-toll")); + private LiveCollisionCapture() { } @@ -67,7 +84,78 @@ public static LiveCollisionSnapshot captureOnClientThread() { flagsByPlane[z] = plane != null ? plane.getFlags() : null; } - return build(wv.getBaseX(), wv.getBaseY(), planeCount, flagsByPlane); + return build(wv.getBaseX(), wv.getBaseY(), planeCount, flagsByPlane, findDoorTiles(wv, planeCount)); + } + + /** + * Scans the loaded scene for openable-door wall objects. Runs on the client thread as part of + * {@link #captureOnClientThread()}. {@code doorTile[z][sx][sy]} is set where a wall object with a + * door action sits, so {@link #build} can exempt every edge touching it. + */ + private static boolean[][][] findDoorTiles(WorldView wv, int planeCount) { + final Tile[][][] tiles = wv.getScene().getTiles(); + if (tiles == null) { + return null; + } + final boolean[][][] doorTile = new boolean[planeCount][][]; + final int planes = Math.min(planeCount, tiles.length); + for (int z = 0; z < planes; z++) { + final Tile[][] plane = tiles[z]; + if (plane == null) { + continue; + } + boolean[][] doors = null; + for (int sx = 0; sx < SCENE_SIZE && sx < plane.length; sx++) { + final Tile[] col = plane[sx]; + if (col == null) { + continue; + } + for (int sy = 0; sy < SCENE_SIZE && sy < col.length; sy++) { + final Tile tile = col[sy]; + if (tile == null) { + continue; + } + final WallObject wall = tile.getWallObject(); + if (wall != null && isOpenableDoor(wall.getId())) { + if (doors == null) { + doors = new boolean[SCENE_SIZE][SCENE_SIZE]; + } + doors[sx][sy] = true; + } + } + } + doorTile[z] = doors; + } + return doorTile; + } + + private static boolean isOpenableDoor(int objectId) { + final ObjectComposition comp = Microbot.getClient().getObjectDefinition(objectId); + if (comp == null) { + return false; + } + if (hasDoorAction(comp)) { + return true; + } + // A closed door is often a multiloc whose openable form is an impostor. + if (comp.getImpostorIds() != null) { + final ObjectComposition impostor = comp.getImpostor(); + return impostor != null && hasDoorAction(impostor); + } + return false; + } + + private static boolean hasDoorAction(ObjectComposition comp) { + final String[] actions = comp.getActions(); + if (actions == null) { + return false; + } + for (String action : actions) { + if (action != null && DOOR_ACTIONS.contains(action.toLowerCase(Locale.ENGLISH))) { + return true; + } + } + return false; } /** @@ -76,6 +164,17 @@ public static LiveCollisionSnapshot captureOnClientThread() { * {@code [sceneX][sceneY]}, or {@code null} for a plane with no data. */ public static LiveCollisionSnapshot build(int baseX, int baseY, int planeCount, int[][][] flagsByPlane) { + return build(baseX, baseY, planeCount, flagsByPlane, null); + } + + /** + * As {@link #build(int, int, int, int[][][])} but exempting openable-door edges: any edge touching a + * tile flagged in {@code doorTile} is left unknown so the pathfinder falls back to the static map and + * the runtime door handler opens it. {@code doorTile} may be {@code null} (no doors), as may any plane + * within it. + */ + public static LiveCollisionSnapshot build(int baseX, int baseY, int planeCount, int[][][] flagsByPlane, + boolean[][][] doorTile) { final int cells = planeCount * SCENE_SIZE * SCENE_SIZE; final BitSet northKnown = new BitSet(cells); final BitSet northValue = new BitSet(cells); @@ -87,14 +186,16 @@ public static LiveCollisionSnapshot build(int baseX, int baseY, int planeCount, if (flags == null) { continue; } + final boolean[][] doors = doorTile != null ? doorTile[z] : null; for (int sx = 0; sx < SCENE_SIZE; sx++) { for (int sy = 0; sy < SCENE_SIZE; sy++) { final int index = (z * SCENE_SIZE + sy) * SCENE_SIZE + sx; final int data = flags[sx][sy]; final boolean walkableHere = standable(data); - // North edge: known only when the north neighbour is inside the scene. - if (sy + 1 < SCENE_SIZE) { + // North edge: known only when the north neighbour is inside the scene and neither + // endpoint is a door tile (doors defer to the static map + runtime handler). + if (sy + 1 < SCENE_SIZE && !isDoor(doors, sx, sy) && !isDoor(doors, sx, sy + 1)) { northKnown.set(index); final int north = flags[sx][sy + 1]; final boolean open = walkableHere @@ -106,8 +207,9 @@ && standable(north) } } - // East edge: known only when the east neighbour is inside the scene. - if (sx + 1 < SCENE_SIZE) { + // East edge: known only when the east neighbour is inside the scene and neither + // endpoint is a door tile. + if (sx + 1 < SCENE_SIZE && !isDoor(doors, sx, sy) && !isDoor(doors, sx + 1, sy)) { eastKnown.set(index); final int east = flags[sx + 1][sy]; final boolean open = walkableHere @@ -125,6 +227,10 @@ && standable(east) return new LiveCollisionSnapshot(baseX, baseY, planeCount, northKnown, northValue, eastKnown, eastValue); } + private static boolean isDoor(boolean[][] doors, int sx, int sy) { + return doors != null && doors[sx][sy]; + } + private static boolean standable(int data) { return (data & CollisionDataFlag.BLOCK_MOVEMENT_FULL) == 0; } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java index 43a52e1ce1..3094743d76 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java @@ -88,6 +88,46 @@ public void wallIsSymmetric_neighbourSouthBitAlsoBlocks() { assertEquals(Boolean.FALSE, snap.edge(BASE_X + 30, BASE_Y + 30, 0, FLAG_NORTH)); } + @Test + public void doorTileEdgesAreExemptEvenWhenLiveFlagsBlockThem() { + // A closed door blocks its doorway in the live flags; the door mask must leave those edges + // unknown so they fall back to the static map (passable) and the runtime door handler opens it. + int[][][] flags = openScene(); + final int dx = 50, dy = 50; + // simulate a closed door: walls on all four sides of the door tile + flags[0][dx][dy] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH + | CollisionDataFlag.BLOCK_MOVEMENT_EAST + | CollisionDataFlag.BLOCK_MOVEMENT_SOUTH + | CollisionDataFlag.BLOCK_MOVEMENT_WEST; + + boolean[][][] doorTile = new boolean[1][SCENE_SIZE][SCENE_SIZE]; + doorTile[0][dx][dy] = true; + + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags, doorTile); + + // every edge touching the door tile is unknown -> static fallback, not a fabricated wall + assertNull(snap.edge(BASE_X + dx, BASE_Y + dy, 0, FLAG_NORTH)); + assertNull(snap.edge(BASE_X + dx, BASE_Y + dy, 0, FLAG_EAST)); + assertNull(snap.edge(BASE_X + dx, BASE_Y + dy - 1, 0, FLAG_NORTH)); // south edge (neighbour's north) + assertNull(snap.edge(BASE_X + dx - 1, BASE_Y + dy, 0, FLAG_EAST)); // west edge (neighbour's east) + + // a tile two away from the door is unaffected and still overlaid normally + assertEquals(Boolean.TRUE, snap.edge(BASE_X + dx + 2, BASE_Y + dy + 2, 0, FLAG_NORTH)); + } + + @Test + public void nullDoorMask_behavesLikeNoDoors() { + int[][][] flags = openScene(); + flags[0][60][60] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + + LiveCollisionSnapshot withNull = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags, null); + LiveCollisionSnapshot fourArg = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags); + + assertEquals(Boolean.FALSE, withNull.edge(BASE_X + 60, BASE_Y + 60, 0, FLAG_NORTH)); + assertEquals(fourArg.edge(BASE_X + 60, BASE_Y + 60, 0, FLAG_NORTH), + withNull.edge(BASE_X + 60, BASE_Y + 60, 0, FLAG_NORTH)); + } + @Test public void fullBlockClosesAllFourEdgesAroundTheTile() { int[][][] flags = openScene(); From 327c6637ac9482d0385863d2aa535a6f4822ad45 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 23 Jul 2026 11:13:32 +0100 Subject: [PATCH 3/7] feat(agentserver): add /live-collision diagnostics endpoint GET /live-collision[?x=&y=&plane=] reports whether the live-collision overlay is enabled and captured, the snapshot's scene base, and per-tile the overlay's own edge readings (overlayRaw: true/false/null=defer-to-static), the resolved edges the pathfinder uses, and the static-only edges. Where resolved differs from static, live collision is changing routing. The overlay and pathfinder are otherwise not observable over HTTP; this exists to verify the hybrid live-collision work against a running client. Logic lives in PathfinderConfig.liveCollisionDiagnostics (immutable reads, any thread); the handler is thin. Co-Authored-By: Claude Opus 4.8 --- .../agentserver/AgentServerPlugin.java | 3 +- .../handler/LiveCollisionHandler.java | 81 +++++++++++++++++++ .../pathfinder/PathfinderConfig.java | 52 ++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LiveCollisionHandler.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java index 607bc2c990..4e2bd42f5b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java @@ -152,7 +152,8 @@ private List buildHandlers(int maxResults) { new QuestHelperHandler(gson), new StateMachineDebugHandler(gson), new ProfileHandler(gson), - new DynamicScriptDeployHandler(gson) + new DynamicScriptDeployHandler(gson), + new LiveCollisionHandler(gson) ); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LiveCollisionHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LiveCollisionHandler.java new file mode 100644 index 0000000000..b024f0560e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LiveCollisionHandler.java @@ -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=]}. + *

+ * 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 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 response = new LinkedHashMap<>( + config.liveCollisionDiagnostics(tile.getX(), tile.getY(), tile.getPlane())); + sendJson(exchange, 200, response); + } + + private WorldPoint resolveTile(Map 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(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java index 3c9b1d625c..d7b7b7aa98 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java @@ -12,6 +12,7 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.shortestpath.*; 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.policy.TransportRequirementPolicy; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; @@ -220,6 +221,57 @@ public CollisionMap getMap() { return map.get(); } + /** + * Diagnostics for the live-collision overlay at one tile, for the agent server's + * {@code /live-collision} endpoint. Reads only immutable data (the static map and the pinned + * snapshot), so it is safe to call from any thread. + *

+ * {@code overlayRaw} is the snapshot's own answer ({@code true}/{@code false}/{@code null} where + * {@code null} means "unknown, defer to static") — proving capture and the flag translation. + * {@code resolved} is what the pathfinder actually uses (overlay where known, else static), and + * {@code static} is the static-only reading. Where they differ, the overlay is changing routing. + */ + public Map liveCollisionDiagnostics(int x, int y, int z) { + final Map out = new LinkedHashMap<>(); + out.put("enabled", liveCollisionOverlay.isEnabled()); + + final LiveCollisionSnapshot snapshot = liveCollisionOverlay.current(); + out.put("snapshotPresent", snapshot != null); + if (snapshot != null) { + out.put("baseX", snapshot.getBaseX()); + out.put("baseY", snapshot.getBaseY()); + out.put("planeCount", snapshot.getPlaneCount()); + } + out.put("tile", Map.of("x", x, "y", y, "plane", z)); + + final CollisionMap staticMap = new CollisionMap(mapData); + final CollisionMap resolvedMap = new CollisionMap(mapData, liveCollisionOverlay); + resolvedMap.beginSearch(); + out.put("static", edgeReadout(staticMap, x, y, z)); + out.put("resolved", edgeReadout(resolvedMap, x, y, z)); + + if (snapshot != null) { + final Map raw = new LinkedHashMap<>(); + raw.put("n", snapshot.edge(x, y, z, LiveCollisionSnapshot.FLAG_NORTH)); + raw.put("e", snapshot.edge(x, y, z, LiveCollisionSnapshot.FLAG_EAST)); + raw.put("s", snapshot.edge(x, y - 1, z, LiveCollisionSnapshot.FLAG_NORTH)); + raw.put("w", snapshot.edge(x - 1, y, z, LiveCollisionSnapshot.FLAG_EAST)); + out.put("overlayRaw", raw); + } + return out; + } + + private static Map edgeReadout(CollisionMap m, int x, int y, int z) { + final Map e = new LinkedHashMap<>(); + e.put("n", m.n(x, y, z)); + e.put("e", m.e(x, y, z)); + e.put("s", m.s(x, y, z)); + e.put("w", m.w(x, y, z)); + e.put("blocked", m.isBlocked(x, y, z)); + e.put("hasRegion", m.hasRegion(x, y)); + return e; + } + public void refresh(WorldPoint target) { calculationCutoffMillis = (long) config.calculationCutoff() * Constants.GAME_TICK_LENGTH; avoidWilderness = ShortestPathPlugin.override("avoidWilderness", config.avoidWilderness()); From 259f0e17e19b17b43a09cf1e9fbf5fe796ca9651 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 23 Jul 2026 11:13:46 +0100 Subject: [PATCH 4/7] fix(shortestpath): defer scene-border live collision to static RuneLite's collision for the outer ring of the loaded scene is incomplete while adjacent chunks are only partially loaded. Verified against a running client: the border produced a uniform band of ~1260 false walls out to about five tiles deep, while the deep interior matched reality. Stage 1's rim handling only exempted the outermost neighbour-out-of-scene edges, so those false walls leaked into the overlay and would fabricate obstacles near every scene boundary. LiveCollisionCapture now treats any tile within SCENE_BORDER_MARGIN (8) of the scene edge as unknown, deferring to the static map there. The scene always loads centred on the player (~tile 52 of 104), so the trusted ~88x88 interior still covers everything near the player, and the border recentres before the player reaches it. Eight gives headroom over the ~5 tiles of artifact observed (real live-vs-static differences began at ~24 tiles in, a clean gap). Re-verified live after the fix: border diffs 1260 -> 0, while the genuine interior corrections (incl. 3 stale-static unblocks) are preserved. Co-Authored-By: Claude Opus 4.8 --- .../pathfinder/live/LiveCollisionCapture.java | 31 +++++++++++++++---- .../shortestpath/LiveCollisionTest.java | 21 +++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java index 0120f0cfca..eb4ec6357f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java @@ -46,6 +46,17 @@ public final class LiveCollisionCapture { private static final Set DOOR_ACTIONS = new HashSet<>(Arrays.asList( "open", "go-through", "walk-through", "pass", "pick-lock", "pay-toll")); + /** + * Tiles this close to the edge of the loaded scene are left unknown (deferred to the static map), + * because RuneLite's collision for the scene's outer ring is incomplete while adjacent chunks are + * only partially loaded — verified live, where the border produced a uniform band of false walls out + * to about five tiles while the deep interior matched reality. The scene always loads centred on the + * player (~tile 52 of 104), so the trusted interior still covers everything near the player, and by + * the time the player nears the border the scene has recentred. Eight gives headroom over the ~5 + * tiles of artifact observed. + */ + private static final int SCENE_BORDER_MARGIN = 8; + private LiveCollisionCapture() { } @@ -193,9 +204,11 @@ public static LiveCollisionSnapshot build(int baseX, int baseY, int planeCount, final int data = flags[sx][sy]; final boolean walkableHere = standable(data); - // North edge: known only when the north neighbour is inside the scene and neither - // endpoint is a door tile (doors defer to the static map + runtime handler). - if (sy + 1 < SCENE_SIZE && !isDoor(doors, sx, sy) && !isDoor(doors, sx, sy + 1)) { + // North edge: known only when both endpoints are trusted interior tiles (away from + // the unreliable scene border) and neither is a door tile (doors defer to the static + // map + runtime handler). + if (interior(sx, sy) && interior(sx, sy + 1) + && !isDoor(doors, sx, sy) && !isDoor(doors, sx, sy + 1)) { northKnown.set(index); final int north = flags[sx][sy + 1]; final boolean open = walkableHere @@ -207,9 +220,9 @@ && standable(north) } } - // East edge: known only when the east neighbour is inside the scene and neither - // endpoint is a door tile. - if (sx + 1 < SCENE_SIZE && !isDoor(doors, sx, sy) && !isDoor(doors, sx + 1, sy)) { + // East edge: same, for the east neighbour. + if (interior(sx, sy) && interior(sx + 1, sy) + && !isDoor(doors, sx, sy) && !isDoor(doors, sx + 1, sy)) { eastKnown.set(index); final int east = flags[sx + 1][sy]; final boolean open = walkableHere @@ -231,6 +244,12 @@ private static boolean isDoor(boolean[][] doors, int sx, int sy) { return doors != null && doors[sx][sy]; } + /** A scene tile is trusted only when it is at least {@link #SCENE_BORDER_MARGIN} tiles from the edge. */ + private static boolean interior(int sx, int sy) { + return sx >= SCENE_BORDER_MARGIN && sx < SCENE_SIZE - SCENE_BORDER_MARGIN + && sy >= SCENE_BORDER_MARGIN && sy < SCENE_SIZE - SCENE_BORDER_MARGIN; + } + private static boolean standable(int data) { return (data & CollisionDataFlag.BLOCK_MOVEMENT_FULL) == 0; } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java index 3094743d76..94bd82297c 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java @@ -88,6 +88,27 @@ public void wallIsSymmetric_neighbourSouthBitAlsoBlocks() { assertEquals(Boolean.FALSE, snap.edge(BASE_X + 30, BASE_Y + 30, 0, FLAG_NORTH)); } + @Test + public void sceneBorderIsUnknown_soBoundaryArtifactsFallBackToStatic() { + // RuneLite's collision for the scene's outer ring is unreliable (verified live: a uniform band + // of false walls ~5 tiles deep). The capture leaves that border unknown -> static fallback. + int[][][] flags = openScene(); + // put a "wall" both deep in the interior and out at the border + flags[0][50][50] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; // interior + flags[0][2][50] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; // border (sx=2, within margin 8) + + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags); + + // interior wall is captured + assertEquals(Boolean.FALSE, snap.edge(BASE_X + 50, BASE_Y + 50, 0, FLAG_NORTH)); + // border tile is unknown regardless of its flags -> defer to static + assertNull(snap.edge(BASE_X + 2, BASE_Y + 50, 0, FLAG_NORTH)); + // a tile just inside the margin is known again + assertEquals(Boolean.TRUE, snap.edge(BASE_X + 8, BASE_Y + 50, 0, FLAG_NORTH)); + // a tile just outside the margin is unknown + assertNull(snap.edge(BASE_X + 7, BASE_Y + 50, 0, FLAG_NORTH)); + } + @Test public void doorTileEdgesAreExemptEvenWhenLiveFlagsBlockThem() { // A closed door blocks its doorway in the live flags; the door mask must leave those edges From 78d03b7cfa55f3a184bf3c2832ad62469ca35fbf Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 23 Jul 2026 11:42:38 +0100 Subject: [PATCH 5/7] feat(shortestpath): debounced live-collision recalc on scene change (stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 refreshed the overlay only on scene-base change, so a door closing or a temporary object appearing mid-scene was not picked up until the next reload, and the walker would walk into it and stall. Now object spawn/despawn (game + wall) mark the snapshot dirty, and refreshLiveCollision rebuilds it at most once per tick. After a mid-scene rebuild it validates the walker's remaining route against the fresh overlay via LiveRouteValidator and, if a walking step within a 15-tile look-ahead is now blocked, calls the walker's public recalculatePath() so it routes around before stalling. Cooldown-gated (3s) so churn cannot spam recalcs. Driven entirely from the plugin's onGameTick (client thread) using public walker API — the walker's internal loop is untouched. Openable doors and transports are skipped by LiveRouteValidator (door edges are unknown in the overlay, transport jumps are non-adjacent), so this never fights the runtime door handler. Scene reloads do not trigger a proactive recalc (they usually just recenter); the walker's own machinery picks up the fresh snapshot there. LiveRouteValidator is a pure helper with unit tests: nearest-index, detects a live-blocked walking step, skips transport/cross-plane jumps, respects the look-ahead bound. The four object subscribers only set a volatile flag (no client reads), so the guardrail is unaffected. Full suite green. Co-Authored-By: Claude Opus 4.8 --- .../shortestpath/ShortestPathPlugin.java | 93 ++++++++++++++++++- .../pathfinder/live/LiveRouteValidator.java | 77 +++++++++++++++ .../shortestpath/LiveCollisionTest.java | 87 +++++++++++++++++ 3 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveRouteValidator.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java index 967857a620..80929dd4c9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java @@ -53,6 +53,7 @@ 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.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; @@ -569,12 +570,46 @@ void handlePendingLoginRefresh() { // 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; + // 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. Runs on - * the client thread from {@link #onGameTick}. Rebuilds the immutable snapshot only when the scene - * base changes (a scene reload) or when the flag was just enabled. Finer-grained refresh on - * mid-scene object changes, and the recalc that acts on it, are later stages. + * 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) { @@ -587,6 +622,7 @@ void refreshLiveCollision() { lastLiveCaptureBaseX = Integer.MIN_VALUE; // force a capture on enable, drop snapshot on disable } if (!enabled) { + liveCollisionDirty = false; return; } @@ -596,13 +632,60 @@ void refreshLiveCollision() { } final int baseX = wv.getBaseX(); final int baseY = wv.getBaseY(); - if (baseX == lastLiveCaptureBaseX && baseY == lastLiveCaptureBaseY) { + final boolean baseChanged = baseX != lastLiveCaptureBaseX || baseY != lastLiveCaptureBaseY; + if (!baseChanged && !liveCollisionDirty) { return; } overlay.set(LiveCollisionCapture.captureOnClientThread()); lastLiveCaptureBaseX = baseX; lastLiveCaptureBaseY = baseY; + liveCollisionDirty = false; + + // A scene reload usually just recenters the same collision, so only the mid-scene object case + // is worth a proactive recalc. Reloads let the walker's own machinery pick up the fresh snapshot. + if (!baseChanged) { + recalcIfRouteBlockedByLiveCollision(overlay); + } + } + + /** + * 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 void recalcIfRouteBlockedByLiveCollision(LiveCollisionOverlay overlay) { + if (overlay.current() == null || Rs2Walker.getCurrentTarget() == null) { + return; + } + final long now = System.currentTimeMillis(); + if (now - lastLiveRecalcMs < LIVE_RECALC_COOLDOWN_MS) { + return; + } + final Pathfinder pf = ShortestPathPlugin.pathfinder; + if (pf == null || !pf.isDone()) { + return; + } + final List path = pf.getPath(); + if (path == null || path.size() < 2) { + return; + } + final WorldPoint me = Rs2Player.getWorldLocation(); + if (me == null) { + return; + } + + 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(); + } } @Subscribe diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveRouteValidator.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveRouteValidator.java new file mode 100644 index 0000000000..88341e6174 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveRouteValidator.java @@ -0,0 +1,77 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder.live; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; + +import java.util.List; + +/** + * Validates the walking steps of an in-progress route against a {@link CollisionMap}, so the walker can + * proactively recalculate when live collision has blocked the road ahead (a door closed, a temporary + * object appeared) rather than walking into it and stalling. + *

+ * Only walking edges between adjacent same-plane tiles are checked. Transport jumps (non-adjacent, or + * cross-plane — stairs, ladders, the wilderness ditch "Cross") are skipped: those are graph edges the + * pathfinder adds independently of collision, and openable doors are already left unknown in the overlay + * so their steps read as passable and the runtime door handler owns them. + */ +public final class LiveRouteValidator { + + private LiveRouteValidator() { + } + + /** + * @return the index of the path tile nearest the player (same plane), or 0 when the path is empty. + * Used as the start of the look-ahead so a player slightly off the exact path tile is handled. + */ + public static int nearestIndex(List path, WorldPoint player) { + if (path == null || path.isEmpty() || player == null) { + return 0; + } + int best = 0; + int bestDist = Integer.MAX_VALUE; + for (int i = 0; i < path.size(); i++) { + final WorldPoint p = path.get(i); + if (p.getPlane() != player.getPlane()) { + continue; + } + final int d = Math.abs(p.getX() - player.getX()) + Math.abs(p.getY() - player.getY()); + if (d < bestDist) { + bestDist = d; + best = i; + } + } + return best; + } + + /** + * @return the first path index at or after {@code fromIndex}, within {@code lookahead} steps, whose + * walking step to the next tile is no longer traversable per {@code map}; or {@code -1} when the near + * route is clear. Caller must have pinned the map's snapshot ({@link CollisionMap#beginSearch()}). + */ + public static int firstBlockedStep(List path, int fromIndex, int lookahead, CollisionMap map) { + if (path == null || map == null) { + return -1; + } + int checked = 0; + for (int i = Math.max(0, fromIndex); i + 1 < path.size() && checked < lookahead; i++, checked++) { + final WorldPoint a = path.get(i); + final WorldPoint b = path.get(i + 1); + if (a.getPlane() != b.getPlane()) { + continue; // transport (stairs/ladder/teleport) + } + final int dx = b.getX() - a.getX(); + final int dy = b.getY() - a.getY(); + if (dx == 0 && dy == 0) { + continue; + } + if (Math.abs(dx) > 1 || Math.abs(dy) > 1) { + continue; // non-adjacent: a transport jump, not a walking step + } + if (!map.canStep(a.getX(), a.getY(), a.getPlane(), dx, dy)) { + return i; + } + } + return -1; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java index 94bd82297c..8065a772af 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java @@ -1,14 +1,19 @@ package net.runelite.client.plugins.microbot.shortestpath; import net.runelite.api.CollisionDataFlag; +import net.runelite.api.coords.WorldPoint; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; 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 org.junit.BeforeClass; import org.junit.Test; +import java.util.Arrays; +import java.util.List; + import static net.runelite.api.Constants.SCENE_SIZE; import static net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot.FLAG_EAST; import static net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot.FLAG_NORTH; @@ -224,6 +229,88 @@ public void overlayBlocksAnOpenStaticEdge_andFallsBackOutsideScene() { assertEquals(staticMap.e(farX, farY, 0), live.e(farX, farY, 0)); } + // ---- Stage 3: route validation (LiveRouteValidator) ---- + + /** Overlay-blocks the north edge of {@code (tx,ty)} and returns a CollisionMap with it pinned. */ + private CollisionMap mapBlockingNorthEdge(int tx, int ty) { + final int baseX = tx - 52, baseY = ty - 52; + int[][][] flags = openScene(); + flags[0][tx - baseX][ty - baseY] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + LiveCollisionOverlay overlay = new LiveCollisionOverlay(); + overlay.setEnabled(true); + overlay.set(LiveCollisionCapture.build(baseX, baseY, 1, flags)); + CollisionMap map = new CollisionMap(staticFlags, overlay); + map.beginSearch(); + return map; + } + + /** A tile with a run of open north edges from y-2..y+1, so multi-step north paths are clear in static. */ + private int[] findOpenNorthEdgeTile() { + CollisionMap staticMap = new CollisionMap(staticFlags); + for (int x = 3200; x < 3260; x++) { + for (int y = 3210; y < 3250; y++) { + if (staticFlags.hasRegion(x, y) + && staticMap.n(x, y - 2, 0) && staticMap.n(x, y - 1, 0) + && staticMap.n(x, y, 0) && staticMap.n(x, y + 1, 0)) { + return new int[]{x, y}; + } + } + } + throw new AssertionError("no run of open north edges found in Lumbridge"); + } + + @Test + public void routeValidator_nearestIndex() { + List path = Arrays.asList( + new WorldPoint(3200, 3200, 0), new WorldPoint(3200, 3201, 0), new WorldPoint(3200, 3202, 0)); + assertEquals(1, LiveRouteValidator.nearestIndex(path, new WorldPoint(3200, 3201, 0))); + assertEquals(2, LiveRouteValidator.nearestIndex(path, new WorldPoint(3204, 3202, 0))); + assertEquals(0, LiveRouteValidator.nearestIndex(path, new WorldPoint(3200, 3201, 1))); // other plane ignored + } + + @Test + public void routeValidator_detectsLiveBlockedWalkingStep() { + int[] t = findOpenNorthEdgeTile(); + int tx = t[0], ty = t[1]; + List path = Arrays.asList( + new WorldPoint(tx, ty, 0), new WorldPoint(tx, ty + 1, 0), new WorldPoint(tx, ty + 2, 0)); + + CollisionMap clear = new CollisionMap(staticFlags); + clear.beginSearch(); + assertEquals("clear route", -1, LiveRouteValidator.firstBlockedStep(path, 0, 15, clear)); + + CollisionMap blocked = mapBlockingNorthEdge(tx, ty); + assertEquals("first step now blocked", 0, LiveRouteValidator.firstBlockedStep(path, 0, 15, blocked)); + } + + @Test + public void routeValidator_skipsTransportJumps() { + CollisionMap clear = new CollisionMap(staticFlags); + clear.beginSearch(); + // a non-adjacent jump (walk transport) then a cross-plane jump (stairs) are not walking steps + List path = Arrays.asList( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3260, 3260, 0), + new WorldPoint(3260, 3260, 1)); + assertEquals(-1, LiveRouteValidator.firstBlockedStep(path, 0, 15, clear)); + } + + @Test + public void routeValidator_respectsLookahead() { + int[] t = findOpenNorthEdgeTile(); + int tx = t[0], ty = t[1]; + // blocked edge is the step from index 2 -> 3 + List path = Arrays.asList( + new WorldPoint(tx, ty - 2, 0), new WorldPoint(tx, ty - 1, 0), + new WorldPoint(tx, ty, 0), new WorldPoint(tx, ty + 1, 0)); + CollisionMap blocked = mapBlockingNorthEdge(tx, ty); + + assertEquals("lookahead too short to see the block", -1, + LiveRouteValidator.firstBlockedStep(path, 0, 2, blocked)); + assertEquals("longer lookahead finds it at index 2", 2, + LiveRouteValidator.firstBlockedStep(path, 0, 3, blocked)); + } + @Test public void disabledOverlaySnapshot_isIgnored() { int[][][] flags = openScene(); From c42d63decc518e3a87084e26332b4e29f0ad79c2 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 23 Jul 2026 17:22:52 +0100 Subject: [PATCH 6/7] Improve walker recovery and live collision handling --- docs/entity-guides/movement.md | 59 +++ .../shortestpath/ShortestPathPlugin.java | 62 ++- .../pathfinder/live/LiveCollisionCapture.java | 93 +++-- .../live/LiveCollisionDoorMask.java | 122 ++++++ .../microbot/util/walker/Rs2Walker.java | 363 ++++++++++++++++-- .../shortestpath/LiveCollisionTest.java | 62 ++- .../util/walker/Rs2WalkerUnitTest.java | 193 +++++++++- 7 files changed, 848 insertions(+), 106 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionDoorMask.java diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index fef8aa3983..8f8ce8e49f 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -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. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java index 80929dd4c9..65fce6f2c9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java @@ -53,6 +53,7 @@ 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; @@ -573,6 +574,10 @@ void handlePendingLoginRefresh() { // 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; @@ -620,9 +625,12 @@ void refreshLiveCollision() { 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; } @@ -633,19 +641,38 @@ void refreshLiveCollision() { final int baseX = wv.getBaseX(); final int baseY = wv.getBaseY(); final boolean baseChanged = baseX != lastLiveCaptureBaseX || baseY != lastLiveCaptureBaseY; - if (!baseChanged && !liveCollisionDirty) { - return; - } + 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(LiveCollisionCapture.captureOnClientThread()); - lastLiveCaptureBaseX = baseX; - lastLiveCaptureBaseY = baseY; - liveCollisionDirty = false; + 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; + } - // A scene reload usually just recenters the same collision, so only the mid-scene object case - // is worth a proactive recalc. Reloads let the walker's own machinery pick up the fresh snapshot. - if (!baseChanged) { - recalcIfRouteBlockedByLiveCollision(overlay); + if (liveRouteValidationPending && validateRouteAgainstLiveCollision(overlay)) { + liveRouteValidationPending = false; } } @@ -655,25 +682,25 @@ void refreshLiveCollision() { * 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 void recalcIfRouteBlockedByLiveCollision(LiveCollisionOverlay overlay) { + private boolean validateRouteAgainstLiveCollision(LiveCollisionOverlay overlay) { if (overlay.current() == null || Rs2Walker.getCurrentTarget() == null) { - return; + return true; } final long now = System.currentTimeMillis(); if (now - lastLiveRecalcMs < LIVE_RECALC_COOLDOWN_MS) { - return; + return false; } final Pathfinder pf = ShortestPathPlugin.pathfinder; if (pf == null || !pf.isDone()) { - return; + return false; } final List path = pf.getPath(); if (path == null || path.size() < 2) { - return; + return true; } final WorldPoint me = Rs2Player.getWorldLocation(); if (me == null) { - return; + return false; } final CollisionMap map = pathfinderConfig.getMap(); @@ -686,6 +713,7 @@ private void recalcIfRouteBlockedByLiveCollision(LiveCollisionOverlay overlay) { path.get(blocked), path.get(blocked + 1)); Rs2Walker.recalculatePath(); } + return true; } @Subscribe diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java index eb4ec6357f..2143cc9091 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java @@ -3,7 +3,9 @@ import lombok.extern.slf4j.Slf4j; import net.runelite.api.CollisionData; import net.runelite.api.CollisionDataFlag; +import net.runelite.api.GameObject; import net.runelite.api.ObjectComposition; +import net.runelite.api.Point; import net.runelite.api.Tile; import net.runelite.api.WallObject; import net.runelite.api.WorldView; @@ -11,8 +13,10 @@ import java.util.Arrays; import java.util.BitSet; +import java.util.HashMap; import java.util.HashSet; import java.util.Locale; +import java.util.Map; import java.util.Set; import static net.runelite.api.Constants.SCENE_SIZE; @@ -95,27 +99,30 @@ public static LiveCollisionSnapshot captureOnClientThread() { flagsByPlane[z] = plane != null ? plane.getFlags() : null; } - return build(wv.getBaseX(), wv.getBaseY(), planeCount, flagsByPlane, findDoorTiles(wv, planeCount)); + return build(wv.getBaseX(), wv.getBaseY(), planeCount, flagsByPlane, findDoorEdges(wv, planeCount)); } /** - * Scans the loaded scene for openable-door wall objects. Runs on the client thread as part of - * {@link #captureOnClientThread()}. {@code doorTile[z][sx][sy]} is set where a wall object with a - * door action sits, so {@link #build} can exempt every edge touching it. + * Scans the loaded scene for objects owned by the runtime door handler. Wall objects contribute + * only their oriented door edge; game-object doors contribute the edges touching their footprint + * because they do not expose a wall orientation. */ - private static boolean[][][] findDoorTiles(WorldView wv, int planeCount) { + private static LiveCollisionDoorMask findDoorEdges(WorldView wv, int planeCount) { final Tile[][][] tiles = wv.getScene().getTiles(); if (tiles == null) { return null; } - final boolean[][][] doorTile = new boolean[planeCount][][]; + final LiveCollisionDoorMask doorEdges = new LiveCollisionDoorMask(planeCount); + // A scene can reference the same object id many times (and multi-tile game objects can appear + // on more than one Tile). Resolve each definition at most once per capture. + final Map wallDoorIds = new HashMap<>(); + final Map gameObjectDoorIds = new HashMap<>(); final int planes = Math.min(planeCount, tiles.length); for (int z = 0; z < planes; z++) { final Tile[][] plane = tiles[z]; if (plane == null) { continue; } - boolean[][] doors = null; for (int sx = 0; sx < SCENE_SIZE && sx < plane.length; sx++) { final Tile[] col = plane[sx]; if (col == null) { @@ -127,33 +134,54 @@ private static boolean[][][] findDoorTiles(WorldView wv, int planeCount) { continue; } final WallObject wall = tile.getWallObject(); - if (wall != null && isOpenableDoor(wall.getId())) { - if (doors == null) { - doors = new boolean[SCENE_SIZE][SCENE_SIZE]; + if (wall != null && wallDoorIds.computeIfAbsent( + wall.getId(), LiveCollisionCapture::isOpenableDoor)) { + doorEdges.markWall(z, sx, sy, wall.getOrientationA(), wall.getOrientationB()); + } + final GameObject[] gameObjects = tile.getGameObjects(); + if (gameObjects == null) { + continue; + } + for (GameObject gameObject : gameObjects) { + if (gameObject == null) { + continue; + } + final Point min = gameObject.getSceneMinLocation(); + final Point max = gameObject.getSceneMaxLocation(); + if (min == null || max == null || min.getX() != sx || min.getY() != sy) { + continue; // process a multi-tile object once, at its scene-min tile } - doors[sx][sy] = true; + if (!gameObjectDoorIds.computeIfAbsent( + gameObject.getId(), LiveCollisionCapture::isOpenableGameObjectDoor)) { + continue; + } + doorEdges.markGameObject(z, min.getX(), min.getY(), max.getX(), max.getY()); } } } - doorTile[z] = doors; } - return doorTile; + return doorEdges; } private static boolean isOpenableDoor(int objectId) { - final ObjectComposition comp = Microbot.getClient().getObjectDefinition(objectId); + final ObjectComposition comp = resolvedComposition(objectId); if (comp == null) { return false; } - if (hasDoorAction(comp)) { - return true; - } - // A closed door is often a multiloc whose openable form is an impostor. - if (comp.getImpostorIds() != null) { - final ObjectComposition impostor = comp.getImpostor(); - return impostor != null && hasDoorAction(impostor); - } - return false; + return hasDoorAction(comp); + } + + private static boolean isOpenableGameObjectDoor(int objectId) { + final ObjectComposition comp = resolvedComposition(objectId); + return comp != null + && comp.getName() != null + && comp.getName().toLowerCase(Locale.ENGLISH).contains("door") + && hasDoorAction(comp); + } + + private static ObjectComposition resolvedComposition(int objectId) { + final ObjectComposition comp = Microbot.getClient().getObjectDefinition(objectId); + return comp != null && comp.getImpostorIds() != null ? comp.getImpostor() : comp; } private static boolean hasDoorAction(ObjectComposition comp) { @@ -179,13 +207,10 @@ public static LiveCollisionSnapshot build(int baseX, int baseY, int planeCount, } /** - * As {@link #build(int, int, int, int[][][])} but exempting openable-door edges: any edge touching a - * tile flagged in {@code doorTile} is left unknown so the pathfinder falls back to the static map and - * the runtime door handler opens it. {@code doorTile} may be {@code null} (no doors), as may any plane - * within it. + * As {@link #build(int, int, int, int[][][])} but exempting edges owned by the runtime door handler. */ public static LiveCollisionSnapshot build(int baseX, int baseY, int planeCount, int[][][] flagsByPlane, - boolean[][][] doorTile) { + LiveCollisionDoorMask doorEdges) { final int cells = planeCount * SCENE_SIZE * SCENE_SIZE; final BitSet northKnown = new BitSet(cells); final BitSet northValue = new BitSet(cells); @@ -197,7 +222,6 @@ public static LiveCollisionSnapshot build(int baseX, int baseY, int planeCount, if (flags == null) { continue; } - final boolean[][] doors = doorTile != null ? doorTile[z] : null; for (int sx = 0; sx < SCENE_SIZE; sx++) { for (int sy = 0; sy < SCENE_SIZE; sy++) { final int index = (z * SCENE_SIZE + sy) * SCENE_SIZE + sx; @@ -205,10 +229,9 @@ public static LiveCollisionSnapshot build(int baseX, int baseY, int planeCount, final boolean walkableHere = standable(data); // North edge: known only when both endpoints are trusted interior tiles (away from - // the unreliable scene border) and neither is a door tile (doors defer to the static - // map + runtime handler). + // the unreliable scene border) and the runtime door handler does not own this edge. if (interior(sx, sy) && interior(sx, sy + 1) - && !isDoor(doors, sx, sy) && !isDoor(doors, sx, sy + 1)) { + && (doorEdges == null || !doorEdges.exemptsNorth(z, sx, sy))) { northKnown.set(index); final int north = flags[sx][sy + 1]; final boolean open = walkableHere @@ -222,7 +245,7 @@ && standable(north) // East edge: same, for the east neighbour. if (interior(sx, sy) && interior(sx + 1, sy) - && !isDoor(doors, sx, sy) && !isDoor(doors, sx + 1, sy)) { + && (doorEdges == null || !doorEdges.exemptsEast(z, sx, sy))) { eastKnown.set(index); final int east = flags[sx + 1][sy]; final boolean open = walkableHere @@ -240,10 +263,6 @@ && standable(east) return new LiveCollisionSnapshot(baseX, baseY, planeCount, northKnown, northValue, eastKnown, eastValue); } - private static boolean isDoor(boolean[][] doors, int sx, int sy) { - return doors != null && doors[sx][sy]; - } - /** A scene tile is trusted only when it is at least {@link #SCENE_BORDER_MARGIN} tiles from the edge. */ private static boolean interior(int sx, int sy) { return sx >= SCENE_BORDER_MARGIN && sx < SCENE_SIZE - SCENE_BORDER_MARGIN diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionDoorMask.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionDoorMask.java new file mode 100644 index 0000000000..637861d46f --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionDoorMask.java @@ -0,0 +1,122 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder.live; + +import java.util.BitSet; + +import static net.runelite.api.Constants.SCENE_SIZE; + +/** + * Edges owned by the walker's runtime door handler. Marked edges are left unknown in the live snapshot, + * so they fall back to the static map and remain available to the door interaction pipeline. + */ +public final class LiveCollisionDoorMask { + private static final int PLANE_STRIDE = SCENE_SIZE * SCENE_SIZE; + + private final int planeCount; + private final BitSet north; + private final BitSet east; + + public LiveCollisionDoorMask(int planeCount) { + this.planeCount = Math.max(0, planeCount); + final int cells = this.planeCount * PLANE_STRIDE; + this.north = new BitSet(cells); + this.east = new BitSet(cells); + } + + /** + * Marks the precise wall edge(s) represented by RuneLite wall orientations. Diagonal wall + * orientations map to the two cardinal edges which bound that corner, matching the pathfinder's + * cardinal-edge representation. + */ + public void markWall(int plane, int sceneX, int sceneY, int orientationA, int orientationB) { + markOrientation(plane, sceneX, sceneY, orientationA); + markOrientation(plane, sceneX, sceneY, orientationB); + } + + /** + * Game-object doors have no wall-edge orientation. Conservatively exempt every edge touching their + * footprint, while keeping that broader fallback limited to the game object itself. + */ + public void markGameObject(int plane, int minSceneX, int minSceneY, int maxSceneX, int maxSceneY) { + for (int x = minSceneX; x <= maxSceneX; x++) { + for (int y = minSceneY; y <= maxSceneY; y++) { + markCardinal(plane, x, y, -1, 0); + markCardinal(plane, x, y, 1, 0); + markCardinal(plane, x, y, 0, -1); + markCardinal(plane, x, y, 0, 1); + } + } + } + + public boolean exemptsNorth(int plane, int sceneX, int sceneY) { + return valid(plane, sceneX, sceneY) && north.get(index(plane, sceneX, sceneY)); + } + + public boolean exemptsEast(int plane, int sceneX, int sceneY) { + return valid(plane, sceneX, sceneY) && east.get(index(plane, sceneX, sceneY)); + } + + private void markOrientation(int plane, int sceneX, int sceneY, int orientation) { + switch (orientation) { + case 1: // west + markCardinal(plane, sceneX, sceneY, -1, 0); + break; + case 2: // north + markCardinal(plane, sceneX, sceneY, 0, 1); + break; + case 4: // east + markCardinal(plane, sceneX, sceneY, 1, 0); + break; + case 8: // south + markCardinal(plane, sceneX, sceneY, 0, -1); + break; + case 16: // north-west + markCardinal(plane, sceneX, sceneY, -1, 0); + markCardinal(plane, sceneX, sceneY, 0, 1); + break; + case 32: // north-east + markCardinal(plane, sceneX, sceneY, 0, 1); + markCardinal(plane, sceneX, sceneY, 1, 0); + break; + case 64: // south-east + markCardinal(plane, sceneX, sceneY, 1, 0); + markCardinal(plane, sceneX, sceneY, 0, -1); + break; + case 128: // south-west + markCardinal(plane, sceneX, sceneY, 0, -1); + markCardinal(plane, sceneX, sceneY, -1, 0); + break; + default: + break; + } + } + + private void markCardinal(int plane, int sceneX, int sceneY, int dx, int dy) { + int edgeX = sceneX; + int edgeY = sceneY; + final BitSet edges; + if (dx != 0) { + if (dx < 0) { + edgeX--; + } + edges = east; + } else { + if (dy < 0) { + edgeY--; + } + edges = north; + } + if (valid(plane, edgeX, edgeY)) { + edges.set(index(plane, edgeX, edgeY)); + } + } + + private boolean valid(int plane, int sceneX, int sceneY) { + return plane >= 0 && plane < planeCount + && sceneX >= 0 && sceneX < SCENE_SIZE + && sceneY >= 0 && sceneY < SCENE_SIZE; + } + + private static int index(int plane, int sceneX, int sceneY) { + return plane * PLANE_STRIDE + sceneY * SCENE_SIZE + sceneX; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 0851d6b491..08cfeeccb1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -169,6 +169,10 @@ public static WorldPoint getCurrentTarget() { private static final int POST_DOOR_EDGE_NUDGE_MAX_FROM_PLAYER = 3; private static final int POST_DOOR_EDGE_NUDGE_WAIT_MS = 1200; private static final int HANDLER_RANGE = 13; + // Raw/smoothed segments can span several walkable tiles before their transport edge. + // Do not let the transport handler turn that future edge into a long movement command: + // normal route clicks own the approach, then the handler takes over beside the origin. + private static final int RAW_TRANSPORT_DISPATCH_MAX_DISTANCE = 2; private static final int QUETZAL_MAP_VISIBLE_WAIT_MS = 7_000; private static final int QUETZAL_ICON_READY_WAIT_MS = 3_000; private static final int FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV = 1; @@ -178,6 +182,13 @@ public static WorldPoint getCurrentTarget() { private static final int UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES = 2; private static final int UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES = 10; private static final int STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN = 10; + /** + * A spatially-near smoothed waypoint can be hundreds of raw route steps ahead when a route + * doubles back around a mountain or fence. Do not treat that future branch as the immediate + * local blocker. The bounded reachability sample is roughly 39 tiles, so 48 preserves ordinary + * false-negative recovery while rejecting distant route folds. + */ + private static final int LOCAL_RECOVERY_RAW_ROUTE_LOOKAHEAD_STEPS = 48; private static final int NORMAL_MINIMAP_REACH_EUCLIDEAN = 11; private static final int UNREACHABLE_RECOVERY_FORWARD_SCAN_TILES = 24; /** @@ -326,6 +337,13 @@ private static void markWalkSessionStart(WorldPoint target) { WebWalkLog.tmark("walk_start", 0, target, Rs2Player.getWorldLocation(), "target_set"); } + private static void clearRecentTransportContext() { + lastTransportHandledAtMs = 0L; + lastTransportHandledAtLocation = null; + lastTransportOriginLocation = null; + lastTransportDestinationLocation = null; + } + private static void markFirstMovementClick(String phase, WorldPoint target, WorldPoint at, String detail) { if (firstMovementClickMarked) { return; @@ -726,6 +744,7 @@ static boolean isRouteProgressExit(String exitReason) { case "path-blocker-handled": case "interim-in-flight": case "recovery-move-in-flight": + case "route-fold-continuation-click": return true; default: return false; @@ -898,10 +917,7 @@ static void clearWalkerDedupeForTesting() SEASONAL_HANDLER_MISS_LOGGED_COUNT.set(0); WORLD_MAP_REMOVE_NULL_LOGGED.set(false); recentCurrentTileTransportByEdge.clear(); - lastTransportHandledAtMs = 0L; - lastTransportHandledAtLocation = null; - lastTransportOriginLocation = null; - lastTransportDestinationLocation = null; + clearRecentTransportContext(); resetRouteProgress(); } @@ -1535,7 +1551,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part WebWalkLog.spDebug("stall_recovery_suppressed | reason=immediate-route-transport idx={}", earlyRouteStartIdx); } else if (!Rs2Player.isMoving() && !Rs2Player.isAnimating() && !Rs2Player.isInteracting()) { setTarget(target); - tryIssueRouteRecoveryClick(rawPath, path, target, "stall recovery click"); + tryIssueRouteRecoveryClick(rawPath, path, target, distance, "stall recovery click"); continue; } else { setTarget(target); @@ -1543,7 +1559,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } } if (shouldRunActiveRouteIdleNudge(shouldIssueActiveRouteIdleNudge, immediateRouteTransportPending)) { - if (tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) { + if (tryIssueRouteRecoveryClick(rawPath, path, target, distance, "active route idle nudge")) { lastAttemptedMinimapClick = null; lastAttemptedMinimapClickOk = false; lastAttemptedMinimapClickAtMs = 0L; @@ -1601,7 +1617,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part && !Rs2Player.isAnimating() && !isDoorInteractionSettling() && !isTransportInteractionSettling() - && tryIssueRouteContinuationClick(rawPath, path, target)) { + && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { walkerDiag("tail exempt exitReason=interim-close-route-click tailBefore=%d", processWalkTail); processWalkTail--; continue; @@ -1921,6 +1937,23 @@ && shouldSkipStartupPreclickSegmentHandlers( if (playerLoc != null) { int unreachableDist = currentWorldPoint.distanceTo2D(playerLoc); if (unreachableDist <= HANDLER_RANGE + 2) { + boolean candidateOnCurrentRouteFrontier = isLocalRecoveryCandidateOnForwardRoute( + rawPath, + smoothedToRaw, + indexOfStartPoint, + i, + LOCAL_RECOVERY_RAW_ROUTE_LOOKAHEAD_STEPS); + if (!candidateOnCurrentRouteFrontier) { + log.info("[Walker] spatially-near future route branch ignored for local recovery: " + + "tile={} idx={}/{} routeStart={} player={}", + currentWorldPoint, i, path.size(), indexOfStartPoint, playerLoc); + if (tryIssueRouteContinuationClick(rawPath, path, target, distance)) { + exitReason = "route-fold-continuation-click"; + } else { + exitReason = "route-fold-continuation-pending"; + } + break; + } log.info("[Walker] local reachability miss near player; checking blockers/recovery: tile={} idx={}/{} player={} target={}", currentWorldPoint, i, path.size(), playerLoc, target); @@ -2164,9 +2197,15 @@ && walkFastCanvas(recoverTarget)) { } nextWalkingDistance = path.size() <= 5 ? 0 : Rs2Random.between(9, 12); int dist2d = currentWorldPoint.distanceTo2D(Rs2Player.getWorldLocation()); - if (dist2d > nextWalkingDistance) { + boolean approachPlannedTransportOrigin = shouldApproachPlannedTransportOrigin( + hasExplicitTransportStep(path, i), + currentWorldPoint, + Rs2Player.getWorldLocation(), + RAW_TRANSPORT_DISPATCH_MAX_DISTANCE); + if (dist2d > nextWalkingDistance || approachPlannedTransportOrigin) { tmarkPostTransport("post_transport_click_eligibility", target, - "i=" + i + " dist2d=" + dist2d + " threshold=" + nextWalkingDistance); + "i=" + i + " dist2d=" + dist2d + " threshold=" + nextWalkingDistance + + (approachPlannedTransportOrigin ? " reason=transport_approach" : "")); // Minimap clickable area is a circle, so reach is a Euclidean radius — // cardinal tiles reach ~13, diagonals ~9. Empirically 14 was too // optimistic (clicks at 13.5–13.9 Euclidean missed the clip). @@ -2549,6 +2588,14 @@ && walkFastCanvas(recoverTarget)) { } } } + // A route scan can legitimately find no new click while the server is still completing + // the previous movement command. Charging those passes as failures can exhaust the + // bounded tail loop before the player reaches a nearby transport origin. + if (!doorOrTransportResult + && "end-of-path".equals(exitReason) + && Rs2Player.isMoving()) { + exitReason = "route-move-in-flight"; + } WorldPoint pathLastForFinish = path.get(path.size() - 1); int finishThreshold = tightFinishThreshold(target, pathLastForFinish, distance); int finalDist = Rs2Player.getWorldLocation().distanceTo(target); @@ -2650,6 +2697,8 @@ && walkFastCanvas(recoverTarget)) { // long minimap interim waits cannot exhaust MAX_PROCESS_WALK_TAIL_ITERATIONS and EXIT. if ("interim-in-flight".equals(exitReason) || "recovery-move-in-flight".equals(exitReason) + || "route-move-in-flight".equals(exitReason) + || "route-fold-continuation-click".equals(exitReason) || isOffPathRecalcDeferredExit(exitReason)) { walkerDiag("tail exempt exitReason=%s tailBefore=%d", exitReason, processWalkTail); processWalkTail--; @@ -3324,6 +3373,35 @@ private static int rawPathStepDistance(WorldPoint previous, WorldPoint current) return Math.max(1, previous.distanceTo2D(current)); } + static boolean isLocalRecoveryCandidateOnForwardRoute(List rawPath, + int[] smoothedToRaw, + int routeStartIdx, + int candidateIdx, + int maxRawRouteSteps) { + if (rawPath == null || rawPath.isEmpty() || smoothedToRaw == null + || routeStartIdx < 0 || candidateIdx < routeStartIdx + || routeStartIdx >= smoothedToRaw.length || candidateIdx >= smoothedToRaw.length + || maxRawRouteSteps < 0) { + return false; + } + int rawStart = smoothedToRaw[routeStartIdx]; + int rawCandidate = smoothedToRaw[candidateIdx]; + if (rawStart < 0 || rawCandidate < rawStart + || rawStart >= rawPath.size() || rawCandidate >= rawPath.size()) { + return false; + } + + int routeSteps = 0; + for (int i = rawStart; i < rawCandidate; i++) { + int step = rawPathStepDistance(rawPath.get(i), rawPath.get(i + 1)); + if (step > maxRawRouteSteps - routeSteps) { + return false; + } + routeSteps += step; + } + return true; + } + static int rawPathForwardAnchorIndex(List rawPath, WorldPoint playerLoc, int rawAnchorIndex) { if (rawPath == null || rawPath.isEmpty() || playerLoc == null) { return -1; @@ -3382,14 +3460,16 @@ private static boolean shouldIssueActiveRouteIdleNudge() { private static boolean tryIssueRouteRecoveryClick(List rawPath, List path, WorldPoint target, + int configuredDistance, String logLabel) { - return tryIssueRouteMovementClick(rawPath, path, target, logLabel, + return tryIssueRouteMovementClick(rawPath, path, target, configuredDistance, logLabel, STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN, true); } private static boolean tryIssueRouteContinuationClick(List rawPath, List path, - WorldPoint target) { + WorldPoint target, + int configuredDistance) { WorldPoint playerLoc = Rs2Player.getWorldLocation(); if (playerLoc == null || path == null || path.isEmpty()) { return false; @@ -3411,13 +3491,14 @@ private static boolean tryIssueRouteContinuationClick(List rawPath, POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST)) { return false; } - return tryIssueRouteMovementClick(rawPath, path, target, "interim close route click", + return tryIssueRouteMovementClick(rawPath, path, target, configuredDistance, "interim close route click", NORMAL_MINIMAP_REACH_EUCLIDEAN, false); } private static boolean tryIssueRouteMovementClick(List rawPath, List path, WorldPoint target, + int configuredDistance, String logLabel, int maxEuclidean, boolean markRecoveryCooldown) { @@ -3425,12 +3506,15 @@ private static boolean tryIssueRouteMovementClick(List rawPath, if (playerLoc == null || path == null || path.isEmpty()) { return false; } + if (routeArrivalSatisfied(playerLoc, target, path, configuredDistance)) { + return false; + } int targetIdx = stabilizeRouteProgressIndex(path, getClosestTileIndex(path, playerLoc), target, playerLoc); int startIdx = Math.max(0, targetIdx); int[] routeSmoothedToRaw = mapSmoothedToRaw(path, rawPath); int rawAnchorIndex = rawIndexForSmoothedIndex(targetIdx, routeSmoothedToRaw, rawPath); - // Use the SAME line-of-sight-first selector as the main route loop. Recovery/idle-nudge and + // Use the same route-backed selector as the main route loop. Recovery/idle-nudge and // interim continuation clicks previously had their own walkable-only selection, so which // policy applied depended on which path happened to fire (timing-dependent, e.g. a slow // pathfinder makes the idle nudge issue the first click instead of the main loop). @@ -3504,6 +3588,19 @@ private static boolean tryIssueRouteMovementClick(List rawPath, return true; } + static boolean routeArrivalSatisfied(WorldPoint playerLoc, + WorldPoint target, + List path, + int configuredDistance) { + if (playerLoc == null || target == null || path == null || path.isEmpty() + || playerLoc.getPlane() != target.getPlane()) { + return false; + } + WorldPoint pathEnd = path.get(path.size() - 1); + int finishThreshold = tightFinishThreshold(target, pathEnd, configuredDistance); + return playerLoc.distanceTo2D(target) <= finishThreshold; + } + static String routeMovementClickPhase(String logLabel) { if ("stall recovery click".equals(logLabel)) { return "stall_recovery_click"; @@ -4277,14 +4374,43 @@ private static boolean clickRouteBackedShortWalk(List rawPath, WorldPoint playerLoc, int maxEuclidean, int rawAnchorIndex) { - WorldPoint clickedTarget = clickMiniMapOrFallback(rawPath, end, playerLoc, - maxEuclidean, false, rawAnchorIndex); - if (clickedTarget != null) { + boolean directTargetInRange = shouldAttemptDirectMinimapTarget(end, playerLoc, maxEuclidean); + if (directTargetInRange && walkMiniMap(end)) { + return true; + } + + // distanceTo() is Chebyshev distance, while the minimap clip is effectively circular. + // A diagonal endpoint can therefore pass the short-walk gate while being well outside the + // clip. In that case select a normal forward raw-route point immediately instead of first + // issuing a predictably rejected endpoint click and reporting the continuation as a fallback. + WorldPoint routeTarget = findFurthestVisibleKnownRawPathPoint( + rawPath, playerLoc, maxEuclidean, rawAnchorIndex); + if (routeTarget != null + && !routeTarget.equals(playerLoc) + && !routeTarget.equals(end) + && walkMiniMap(routeTarget)) { + if (directTargetInRange) { + log.debug("[Walker] Direct short-walk target {} was outside the minimap clip; continuing via route {}", + end, routeTarget); + } return true; } return walkFastCanvasOnScreenOnly(end, true); } + static boolean shouldAttemptDirectMinimapTarget(WorldPoint target, + WorldPoint playerLoc, + int maxEuclidean) { + if (target == null || playerLoc == null || maxEuclidean < 0 + || target.getPlane() != playerLoc.getPlane()) { + return false; + } + long dx = (long) target.getX() - playerLoc.getX(); + long dy = (long) target.getY() - playerLoc.getY(); + long radius = maxEuclidean; + return dx * dx + dy * dy <= radius * radius; + } + private static boolean hasPendingExplicitTransportStepBeforeArrival(List path, WorldPoint target, int distance) { @@ -4605,6 +4731,7 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, // single log line shows which handler dominates instead of needing another repro round. final long scanStartMs = System.currentTimeMillis(); int scannedIdx = 0; + long snapshotMs = 0L; long transportMs = 0L; long doorMs = 0L; long doorCandidateMs = 0L; @@ -4614,8 +4741,13 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, // handlerRange) and only match objects within one tile of a probe, so a handlerRange+2 // radius is a superset of what the per-probe queries could have found. final int snapshotRadius = handlerRange + 2; + long snapshotStartedAt = System.currentTimeMillis(); rawScanWallSnapshot = Rs2GameObject.getWallObjects(o -> true, playerLoc, snapshotRadius); rawScanGameObjectSnapshot = Rs2GameObject.getGameObjects(o -> true, playerLoc, snapshotRadius); + snapshotMs = System.currentTimeMillis() - snapshotStartedAt; + rawScanDoorCompositionCache = new HashMap<>(); + rawScanDoorSegmentCache = new HashMap<>(); + rawScanDoorInteractionWaitMs = 0L; try { for (int i = start; i < endExclusive; i++) { WorldPoint currentWorldPoint = rawPath.get(i); @@ -4626,7 +4758,10 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, } scannedIdx++; - if (allowTransportHandlers && hasExplicitTransportStep(rawPath, i)) { + if (allowTransportHandlers + && hasExplicitTransportStep(rawPath, i) + && isRawTransportOriginNearPlayer( + rawPath, i, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE)) { WorldPoint before = Rs2Player.getWorldLocation(); WorldPoint expectedDestination = i + 1 < rawPath.size() ? rawPath.get(i + 1) : null; long t = System.currentTimeMillis(); @@ -4675,12 +4810,17 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, } finally { rawScanWallSnapshot = null; rawScanGameObjectSnapshot = null; + rawScanDoorCompositionCache = null; + rawScanDoorSegmentCache = null; long totalMs = System.currentTimeMillis() - scanStartMs; if (totalMs >= SLOW_RAW_SCENE_SCAN_LOG_MS) { - log.info("[Walker] slow raw scene scan: total={}ms idx={} doors={}ms doorCand={}ms rockfall={}ms transports={}ms resolved={} allowTransports={}", - totalMs, scannedIdx, doorMs, doorCandidateMs, rockfallMs, transportMs, + long doorWaitMs = rawScanDoorInteractionWaitMs; + long doorProbeMs = Math.max(0L, doorMs - doorWaitMs); + log.info("[Walker] slow raw scene scan: total={}ms idx={} snapshot={}ms doorProbe={}ms doorWait={}ms doorCand={}ms rockfall={}ms transports={}ms resolved={} allowTransports={}", + totalMs, scannedIdx, snapshotMs, doorProbeMs, doorWaitMs, doorCandidateMs, rockfallMs, transportMs, resolved, allowTransportHandlers); } + rawScanDoorInteractionWaitMs = 0L; } } @@ -4700,6 +4840,11 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, */ private static volatile List rawScanWallSnapshot = null; private static volatile List rawScanGameObjectSnapshot = null; + /** Object definitions and segment matches are stable for one immutable scene snapshot. */ + private static Map> rawScanDoorCompositionCache = null; + private static Map> rawScanDoorSegmentCache = null; + /** Interaction/edge-resolution wait contained inside {@link #handleDoors}; excluded from probe cost. */ + private static volatile long rawScanDoorInteractionWaitMs = 0L; /** * Exact-tile match first, then a one-tile adjacency fallback — the same preference order the @@ -5341,6 +5486,21 @@ private static boolean handleDoors(List path, int index, boolean all return false; } + // A broad raw scan already owns immutable wall/game-object snapshots. Resolve the + // segment directly from them instead of running the probe loop, which repeatedly + // requested the same object definitions on the client thread for adjacent raw edges. + if (allowSegmentProbe + && (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null)) { + TileObject snapshotDoor = findDoorNearSegment(fromWp, toWp, doorActions); + if (snapshotDoor == null) { + return false; + } + if (snapshotDoor instanceof WallObject) { + return tryHandleDoorObject(snapshotDoor, snapshotDoor.getWorldLocation(), + fromWp, toWp, doorActions, true); + } + } + for (int offset = 0; offset <= 1; offset++) { int doorIdx = index + offset; if (doorIdx >= path.size()) continue; @@ -5525,23 +5685,77 @@ private static TileObject findDoorNearSegment(WorldPoint fromWp, WorldPoint toWp } final int searchDistance = 10; + List wallSnapshot = rawScanWallSnapshot; + List gameObjectSnapshot = rawScanGameObjectSnapshot; + if (wallSnapshot != null || gameObjectSnapshot != null) { + Map> segmentCache = rawScanDoorSegmentCache; + String segmentKey = fromWp.getX() + "," + fromWp.getY() + "," + fromWp.getPlane() + + ">" + toWp.getX() + "," + toWp.getY() + "," + toWp.getPlane(); + if (segmentCache != null && segmentCache.containsKey(segmentKey)) { + return segmentCache.get(segmentKey).orElse(null); + } + List candidates = new ArrayList<>( + (wallSnapshot != null ? wallSnapshot.size() : 0) + + (gameObjectSnapshot != null ? gameObjectSnapshot.size() : 0)); + if (wallSnapshot != null) { + candidates.addAll(wallSnapshot); + } + if (gameObjectSnapshot != null) { + candidates.addAll(gameObjectSnapshot); + } + TileObject match = candidates.stream() + .filter(o -> isDoorCandidateOnSegment(o, playerLoc, fromWp, toWp, + doorActions, searchDistance)) + .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo2D(playerLoc))) + .orElse(null); + if (segmentCache != null) { + segmentCache.put(segmentKey, Optional.ofNullable(match)); + } + return match; + } return Rs2GameObject.getAll(o -> { - if (o == null || o.getWorldLocation() == null) return false; - WorldPoint loc = o.getWorldLocation(); - if (loc.getPlane() != playerLoc.getPlane()) return false; - if (loc.distanceTo2D(playerLoc) > searchDistance) return false; - if (sessionBlacklistedDoors.contains(loc)) return false; - if (!(o instanceof WallObject) && !(o instanceof GameObject)) return false; - if (isCatalogTransportObject(o) && !isDoorLikeSceneObject(o)) return false; - if (!isDoorOnSegment(o, fromWp, toWp)) return false; - ObjectComposition comp = Rs2GameObject.convertToObjectComposition(o); - if (!isDoorComposition(comp, doorActions)) return false; - return true; + return isDoorCandidateOnSegment(o, playerLoc, fromWp, toWp, + doorActions, searchDistance); }, playerLoc, searchDistance).stream() .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo2D(playerLoc))) .orElse(null); } + private static boolean isDoorCandidateOnSegment(TileObject object, + WorldPoint playerLoc, + WorldPoint fromWp, + WorldPoint toWp, + List doorActions, + int searchDistance) { + if (object == null || object.getWorldLocation() == null) { + return false; + } + WorldPoint loc = object.getWorldLocation(); + if (loc.getPlane() != playerLoc.getPlane() + || loc.distanceTo2D(playerLoc) > searchDistance + || sessionBlacklistedDoors.contains(loc) + || (!(object instanceof WallObject) && !(object instanceof GameObject)) + || (isCatalogTransportObject(object) && !isDoorLikeSceneObject(object)) + || !isDoorOnSegment(object, fromWp, toWp)) { + return false; + } + ObjectComposition comp = resolveRawScanDoorComposition(object); + return isDoorComposition(comp, doorActions); + } + + private static ObjectComposition resolveRawScanDoorComposition(TileObject object) { + if (object == null) { + return null; + } + Map> compositionCache = rawScanDoorCompositionCache; + if (compositionCache == null) { + return Rs2GameObject.convertToObjectComposition(object); + } + return compositionCache.computeIfAbsent(object.getId(), + ignored -> Optional.ofNullable(Rs2GameObject.convertToObjectComposition(object))) + .orElse(null); + } + private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, List doorActions, boolean allowSegmentProbe) { if (object == null || probe == null) return false; @@ -5553,7 +5767,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return false; } - ObjectComposition comp = Rs2GameObject.convertToObjectComposition(object); + ObjectComposition comp = resolveRawScanDoorComposition(object); if (!isDoorComposition(comp, doorActions)) return false; String action = getDoorAction(comp, doorActions); @@ -6274,11 +6488,20 @@ private static boolean hasImmediatePlannedTransportStep(List path, return false; } WorldPoint origin = path.get(routeStartIdx); - if (origin == null || origin.getPlane() != playerLoc.getPlane() - || origin.distanceTo2D(playerLoc) > HANDLER_RANGE) { - return false; - } - return hasExplicitTransportStep(path, routeStartIdx); + return hasExplicitTransportStep(path, routeStartIdx) + && isTransportOriginNearPlayer( + origin, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE); + } + + static boolean shouldApproachPlannedTransportOrigin(boolean explicitTransportStep, + WorldPoint routeOrigin, + WorldPoint playerLoc, + int dispatchMaxDistance) { + return explicitTransportStep + && routeOrigin != null + && playerLoc != null + && routeOrigin.getPlane() == playerLoc.getPlane() + && routeOrigin.distanceTo2D(playerLoc) > Math.max(0, dispatchMaxDistance); } /** @@ -6463,8 +6686,15 @@ private static boolean isCatalogTransportObject(TileObject object) { } private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp) { + long startedAt = System.currentTimeMillis(); AwaitTicket ticket = Rs2WalkerAwaits.beginTicket(); - Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp); + try { + Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp); + } finally { + if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { + rawScanDoorInteractionWaitMs += System.currentTimeMillis() - startedAt; + } + } } private static boolean waitForDoorEdgeResolution(WorldPoint fromWp, WorldPoint toWp, int timeoutMs) { @@ -8108,6 +8338,10 @@ public static void setTarget(WorldPoint target, String clearReasonWhenNull) { currentTarget = target; if (target == null) { + // A completed/cancelled route owns its transport handoff context. Keeping the + // timestamp alive made an unrelated walk started within 15 seconds inherit + // post-transport handler suppression and misleading elapsed-time markers. + clearRecentTransportContext(); resetRouteProgress(); logRouteClear(clearReasonWhenNull); synchronized (Rs2PathApi.getPathfinderMutex()) { @@ -8726,8 +8960,27 @@ static boolean isSettledNearAdjacentSamePlaneLanding(Transport transport, return false; } int destinationDistance = playerLoc.distanceTo2D(destWait); - return destinationDistance <= Math.max(1, maxInclusive) - && playerLoc.distanceTo2D(origin) > 0; + if (destinationDistance <= Math.max(1, maxInclusive) + && playerLoc.distanceTo2D(origin) > 0) { + return true; + } + if (transport.getType() != TransportType.AGILITY_SHORTCUT) { + return false; + } + + // Some adjacent shortcut catalogues describe a multi-object animation as one-tile + // hops. The Falador stepping stones, for example, can carry 3154 -> 3149 while the + // selected edge says 3154 -> 3153. Accept only a tightly bounded forward, collinear + // overshoot; sideways movement, reverse movement, and arbitrary teleports still fail. + int edgeX = destWait.getX() - origin.getX(); + int edgeY = destWait.getY() - origin.getY(); + int movedX = playerLoc.getX() - origin.getX(); + int movedY = playerLoc.getY() - origin.getY(); + int forwardProgress = movedX * edgeX + movedY * edgeY; + int lateralOffset = Math.abs(movedX * edgeY - movedY * edgeX); + return forwardProgress > 0 + && forwardProgress <= 6 + && lateralOffset <= 1; } /** @@ -8810,7 +9063,11 @@ private static boolean handleObject(Transport transport, TileObject tileObject, if (tdObj.getPlane() == plObj.getPlane()) { if (transport.getType() == TransportType.AGILITY_SHORTCUT) { Rs2Player.waitForAnimation(); - sleepUntil(() -> isPlayerWithinChebyshevInclusive(tdObj, 2), 10000); + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return isPlayerWithinChebyshevInclusive(tdObj, 2) + || isSettledNearAdjacentSamePlaneLanding(transport, now, tdObj, 0); + }, 10000); } else if (transport.getType() == TransportType.MINECART) { if (interactWithAdventureLog(transport)) { sleepTickJitter(2); // wait extra 2 game ticks before moving @@ -8950,6 +9207,11 @@ private static boolean handleRockfallInRawSegment(List rawPath, int private static boolean handleTransportsInRawSegment(List rawPath, int rawFrom, int rawTo) { for (int ri = rawFrom; ri < rawTo && ri < rawPath.size() - 1; ri++) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (!isRawTransportOriginNearPlayer( + rawPath, ri, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE)) { + continue; + } if (handleTransports(rawPath, ri)) { return true; } @@ -8957,6 +9219,27 @@ private static boolean handleTransportsInRawSegment(List rawPath, in return false; } + static boolean isRawTransportOriginNearPlayer(List rawPath, + int transportIndex, + WorldPoint playerLoc, + int maxDistance) { + if (rawPath == null || playerLoc == null + || transportIndex < 0 || transportIndex >= rawPath.size() - 1) { + return false; + } + WorldPoint routeOrigin = rawPath.get(transportIndex); + return isTransportOriginNearPlayer(routeOrigin, playerLoc, maxDistance); + } + + private static boolean isTransportOriginNearPlayer(WorldPoint routeOrigin, + WorldPoint playerLoc, + int maxDistance) { + return routeOrigin != null + && playerLoc != null + && routeOrigin.getPlane() == playerLoc.getPlane() + && routeOrigin.distanceTo2D(playerLoc) <= Math.max(0, maxDistance); + } + private static boolean finishHandledTransport(Transport transport) { long handoffStartedAt = System.currentTimeMillis(); lastTransportHandledAtMs = handoffStartedAt; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java index 8065a772af..af32d75c80 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java @@ -5,6 +5,7 @@ import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionCapture; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionDoorMask; 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; @@ -115,32 +116,71 @@ public void sceneBorderIsUnknown_soBoundaryArtifactsFallBackToStatic() { } @Test - public void doorTileEdgesAreExemptEvenWhenLiveFlagsBlockThem() { - // A closed door blocks its doorway in the live flags; the door mask must leave those edges - // unknown so they fall back to the static map (passable) and the runtime door handler opens it. + public void wallDoorExemptsOnlyItsOrientedEdge() { + // A closed north-facing wall door owns only the north edge. Other live-blocked edges touching + // the same tile must remain authoritative instead of being erased by a tile-wide exemption. int[][][] flags = openScene(); final int dx = 50, dy = 50; - // simulate a closed door: walls on all four sides of the door tile flags[0][dx][dy] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH | CollisionDataFlag.BLOCK_MOVEMENT_EAST | CollisionDataFlag.BLOCK_MOVEMENT_SOUTH | CollisionDataFlag.BLOCK_MOVEMENT_WEST; - boolean[][][] doorTile = new boolean[1][SCENE_SIZE][SCENE_SIZE]; - doorTile[0][dx][dy] = true; + LiveCollisionDoorMask doorEdges = new LiveCollisionDoorMask(1); + doorEdges.markWall(0, dx, dy, 2, 0); // orientation 2 = north - LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags, doorTile); + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags, doorEdges); - // every edge touching the door tile is unknown -> static fallback, not a fabricated wall + // The physical doorway falls back to static so the runtime door handler can open it. assertNull(snap.edge(BASE_X + dx, BASE_Y + dy, 0, FLAG_NORTH)); - assertNull(snap.edge(BASE_X + dx, BASE_Y + dy, 0, FLAG_EAST)); - assertNull(snap.edge(BASE_X + dx, BASE_Y + dy - 1, 0, FLAG_NORTH)); // south edge (neighbour's north) - assertNull(snap.edge(BASE_X + dx - 1, BASE_Y + dy, 0, FLAG_EAST)); // west edge (neighbour's east) + // Unrelated edges around the same tile retain their live walls. + assertEquals(Boolean.FALSE, snap.edge(BASE_X + dx, BASE_Y + dy, 0, FLAG_EAST)); + assertEquals(Boolean.FALSE, snap.edge(BASE_X + dx, BASE_Y + dy - 1, 0, FLAG_NORTH)); + assertEquals(Boolean.FALSE, snap.edge(BASE_X + dx - 1, BASE_Y + dy, 0, FLAG_EAST)); // a tile two away from the door is unaffected and still overlaid normally assertEquals(Boolean.TRUE, snap.edge(BASE_X + dx + 2, BASE_Y + dy + 2, 0, FLAG_NORTH)); } + @Test + public void gameObjectDoorExemptsEdgesTouchingItsFootprint() { + int[][][] flags = openScene(); + final int dx = 50, dy = 50; + flags[0][dx][dy] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH + | CollisionDataFlag.BLOCK_MOVEMENT_EAST + | CollisionDataFlag.BLOCK_MOVEMENT_SOUTH + | CollisionDataFlag.BLOCK_MOVEMENT_WEST; + + LiveCollisionDoorMask doorEdges = new LiveCollisionDoorMask(1); + doorEdges.markGameObject(0, dx, dy, dx, dy); + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags, doorEdges); + + assertNull(snap.edge(BASE_X + dx, BASE_Y + dy, 0, FLAG_NORTH)); + assertNull(snap.edge(BASE_X + dx, BASE_Y + dy, 0, FLAG_EAST)); + assertNull(snap.edge(BASE_X + dx, BASE_Y + dy - 1, 0, FLAG_NORTH)); + assertNull(snap.edge(BASE_X + dx - 1, BASE_Y + dy, 0, FLAG_EAST)); + } + + @Test + public void wallDoorCardinalOrientationsMapToSharedEdgeCoordinates() { + final int x = 50, y = 50; + LiveCollisionDoorMask edges = new LiveCollisionDoorMask(1); + + edges.markWall(0, x, y, 1, 0); // west is neighbour's east edge + assertTrue(edges.exemptsEast(0, x - 1, y)); + assertFalse(edges.exemptsEast(0, x, y)); + + edges = new LiveCollisionDoorMask(1); + edges.markWall(0, x, y, 8, 0); // south is neighbour's north edge + assertTrue(edges.exemptsNorth(0, x, y - 1)); + assertFalse(edges.exemptsNorth(0, x, y)); + + edges = new LiveCollisionDoorMask(1); + edges.markWall(0, x, y, 4, 2); // east + north, including orientationB + assertTrue(edges.exemptsEast(0, x, y)); + assertTrue(edges.exemptsNorth(0, x, y)); + } + @Test public void nullDoorMask_behavesLikeNoDoors() { int[][][] flags = openScene(); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java index 69f2f8db07..406c566909 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java @@ -130,6 +130,66 @@ public void shouldRecalculatePathAfterTransport_includesOriginlessTeleport() { assertTrue(Rs2Walker.shouldRecalculatePathAfterTransport(varrockTeleport)); } + @Test + public void rawTransportDispatch_allowsImmediateOriginlessTeleportEdge() { + List rawPath = Arrays.asList( + new WorldPoint(2610, 3100, 0), + new WorldPoint(3213, 3424, 0), + new WorldPoint(3214, 3424, 0)); + + assertTrue(Rs2Walker.isRawTransportOriginNearPlayer( + rawPath, 0, new WorldPoint(2610, 3100, 0), 2)); + } + + @Test + public void rawTransportDispatch_defersFutureTransportUntilApproach() { + List rawPath = Arrays.asList( + new WorldPoint(2610, 3100, 0), + new WorldPoint(2611, 3100, 0), + new WorldPoint(2623, 3093, 0), + new WorldPoint(3303, 3333, 0)); + + assertFalse("A future edge inside the broad handler window must not start a long run", + Rs2Walker.isRawTransportOriginNearPlayer( + rawPath, 2, new WorldPoint(2610, 3100, 0), 2)); + assertTrue("The same edge becomes dispatchable once the route has approached it", + Rs2Walker.isRawTransportOriginNearPlayer( + rawPath, 2, new WorldPoint(2622, 3093, 0), 2)); + } + + @Test + public void rawTransportDispatch_rejectsOtherPlane() { + List rawPath = Arrays.asList( + new WorldPoint(2775, 3234, 1), + new WorldPoint(2772, 3234, 0)); + + assertFalse(Rs2Walker.isRawTransportOriginNearPlayer( + rawPath, 0, new WorldPoint(2775, 3234, 0), 2)); + } + + @Test + public void plannedTransportApproach_clicksUntilDispatchRange() { + WorldPoint player = new WorldPoint(2760, 3229, 0); + WorldPoint charterOrigin = new WorldPoint(2760, 3238, 0); + + assertTrue("A planned transport nine tiles ahead still needs a route approach click", + Rs2Walker.shouldApproachPlannedTransportOrigin( + true, charterOrigin, player, 2)); + assertFalse("Once beside the transport, its handler owns the next action", + Rs2Walker.shouldApproachPlannedTransportOrigin( + true, charterOrigin, new WorldPoint(2760, 3236, 0), 2)); + } + + @Test + public void plannedTransportApproach_rejectsOrdinaryAndOtherPlaneSteps() { + WorldPoint player = new WorldPoint(2760, 3229, 0); + + assertFalse(Rs2Walker.shouldApproachPlannedTransportOrigin( + false, new WorldPoint(2760, 3238, 0), player, 2)); + assertFalse(Rs2Walker.shouldApproachPlannedTransportOrigin( + true, new WorldPoint(2760, 3238, 1), player, 2)); + } + @Test public void shouldRecalculatePathAfterTransport_skipsAdjacentSamePlaneTransport() { Transport door = new Transport( @@ -202,6 +262,54 @@ public void isSettledNearAdjacentSamePlaneLanding_rejectsTilesTooFarFromDestinat 0)); } + @Test + public void isSettledNearAdjacentSamePlaneLanding_acceptsBoundedForwardAgilityOvershoot() { + Transport steppingStone = new Transport( + new WorldPoint(3154, 3363, 0), + new WorldPoint(3153, 3363, 0), + "Stepping stone", + TransportType.AGILITY_SHORTCUT, + false, + "Jump-onto", + "Stepping stone", + 16533); + + assertTrue(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + steppingStone, + new WorldPoint(3149, 3363, 0), + steppingStone.getDestination(), + 0)); + } + + @Test + public void isSettledNearAdjacentSamePlaneLanding_rejectsReverseOrUnboundedAgilityMovement() { + Transport steppingStone = new Transport( + new WorldPoint(3154, 3363, 0), + new WorldPoint(3153, 3363, 0), + "Stepping stone", + TransportType.AGILITY_SHORTCUT, + false, + "Jump-onto", + "Stepping stone", + 16533); + + assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + steppingStone, + new WorldPoint(3155, 3363, 0), + steppingStone.getDestination(), + 0)); + assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + steppingStone, + new WorldPoint(3147, 3363, 0), + steppingStone.getDestination(), + 0)); + assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + steppingStone, + new WorldPoint(3149, 3365, 0), + steppingStone.getDestination(), + 0)); + } + @Test public void shouldRecalculatePathAfterTransport_includesLongDistanceTransport() { Transport ship = new Transport( @@ -590,7 +698,8 @@ public void routeProgressExits_areNotChargedAgainstThePartialRetryBudget() { "rockfall-handled", "path-blocker-handled", "interim-in-flight", - "recovery-move-in-flight"}) { + "recovery-move-in-flight", + "route-fold-continuation-click"}) { assertTrue("'" + progress + "' means the walker advanced the route, so it must not spend " + "a partial retry", Rs2Walker.isRouteProgressExit(progress)); } @@ -990,6 +1099,63 @@ public void clampToEuclideanRadius_keepsInRangeTarget() { assertEquals(target, Rs2Walker.clampToEuclideanRadius(player, target, 10)); } + @Test + public void directMinimapTarget_usesEuclideanRatherThanChebyshevRange() { + WorldPoint player = new WorldPoint(3289, 3476, 0); + + assertFalse("A diagonal endpoint can be Chebyshev-close but outside the circular minimap reach", + Rs2Walker.shouldAttemptDirectMinimapTarget( + new WorldPoint(3300, 3487, 0), player, 12)); + assertTrue("A cardinal endpoint on the Euclidean boundary remains eligible", + Rs2Walker.shouldAttemptDirectMinimapTarget( + new WorldPoint(3301, 3476, 0), player, 12)); + assertFalse("A different plane is never a direct minimap target", + Rs2Walker.shouldAttemptDirectMinimapTarget( + new WorldPoint(3289, 3476, 1), player, 12)); + } + + @Test + public void localRecoveryCandidate_rejectsSpatiallyNearFutureRouteFold() { + List raw = new java.util.ArrayList<>(); + int x = 2855; + int y = 3440; + raw.add(new WorldPoint(x, y, 0)); + for (int i = 0; i < 15; i++) { + raw.add(new WorldPoint(--x, y, 0)); + } + for (int i = 0; i < 15; i++) { + raw.add(new WorldPoint(x, ++y, 0)); + } + for (int i = 0; i < 15; i++) { + raw.add(new WorldPoint(++x, y, 0)); + } + for (int i = 0; i < 15; i++) { + raw.add(new WorldPoint(x, --y, 0)); + } + // The future branch returns spatially close to the player, but remains 60 raw route steps + // ahead. Its proximity must not override the immediate route frontier. + int[] smoothedToRaw = {0, 6, 60}; + + assertTrue(Rs2Walker.isLocalRecoveryCandidateOnForwardRoute( + raw, smoothedToRaw, 0, 1, 48)); + assertFalse(Rs2Walker.isLocalRecoveryCandidateOnForwardRoute( + raw, smoothedToRaw, 0, 2, 48)); + } + + @Test + public void localRecoveryCandidate_rejectsTransportJumpAndBackwardCandidate() { + List raw = Arrays.asList( + new WorldPoint(2808, 3436, 0), + new WorldPoint(2809, 3436, 0), + new WorldPoint(2900, 3400, 0)); + int[] smoothedToRaw = {0, 1, 2}; + + assertFalse(Rs2Walker.isLocalRecoveryCandidateOnForwardRoute( + raw, smoothedToRaw, 0, 2, 48)); + assertFalse(Rs2Walker.isLocalRecoveryCandidateOnForwardRoute( + raw, smoothedToRaw, 1, 0, 48)); + } + // --------------------------------------------------------------------------- // Raw-path wall-door segment probing // --------------------------------------------------------------------------- @@ -1214,6 +1380,31 @@ public void routeMovementClickPhase_labelsContinuationSeparatelyFromRecovery() { assertEquals("route_movement_click", Rs2Walker.routeMovementClickPhase("other")); } + @Test + public void routeArrivalSatisfied_preventsRecoveryClickAtGoal() { + WorldPoint goal = new WorldPoint(3304, 3336, 0); + List path = Arrays.asList( + new WorldPoint(3300, 3333, 0), + goal); + + assertTrue(Rs2Walker.routeArrivalSatisfied(goal, goal, path, 10)); + } + + @Test + public void routeArrivalSatisfied_usesTightFinalApproachThreshold() { + WorldPoint goal = new WorldPoint(3304, 3336, 0); + List path = Arrays.asList( + new WorldPoint(3300, 3333, 0), + goal); + + assertTrue(Rs2Walker.routeArrivalSatisfied( + new WorldPoint(3303, 3336, 0), goal, path, 10)); + assertFalse(Rs2Walker.routeArrivalSatisfied( + new WorldPoint(3302, 3336, 0), goal, path, 10)); + assertFalse(Rs2Walker.routeArrivalSatisfied( + new WorldPoint(3304, 3336, 1), goal, path, 10)); + } + @Test public void shouldRunActiveRouteIdleNudge_waitsForImmediateTransport() { assertFalse(Rs2Walker.shouldRunActiveRouteIdleNudge(true, true)); From 8a0c9f98f4c8849b570a960b150e77b8f0734d7e Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 23 Jul 2026 19:04:33 +0100 Subject: [PATCH 7/7] GUARDRAIL i forgot --- .../microbot/util/walker/Rs2Walker.java | 49 +++++++++++++++---- .../client-thread-guardrail-baseline.txt | 42 ++++++++-------- 2 files changed, 61 insertions(+), 30 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 08cfeeccb1..d98e8be50b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -4744,8 +4744,10 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, long snapshotStartedAt = System.currentTimeMillis(); rawScanWallSnapshot = Rs2GameObject.getWallObjects(o -> true, playerLoc, snapshotRadius); rawScanGameObjectSnapshot = Rs2GameObject.getGameObjects(o -> true, playerLoc, snapshotRadius); + rawScanDoorLocationSnapshot = Microbot.getClientThread() + .invoke(Rs2Walker::captureRawScanDoorLocationsOnClientThread); snapshotMs = System.currentTimeMillis() - snapshotStartedAt; - rawScanDoorCompositionCache = new HashMap<>(); + rawScanDoorCompositionCache = new IdentityHashMap<>(); rawScanDoorSegmentCache = new HashMap<>(); rawScanDoorInteractionWaitMs = 0L; try { @@ -4810,6 +4812,7 @@ && isRawTransportOriginNearPlayer( } finally { rawScanWallSnapshot = null; rawScanGameObjectSnapshot = null; + rawScanDoorLocationSnapshot = null; rawScanDoorCompositionCache = null; rawScanDoorSegmentCache = null; long totalMs = System.currentTimeMillis() - scanStartMs; @@ -4840,12 +4843,33 @@ && isRawTransportOriginNearPlayer( */ private static volatile List rawScanWallSnapshot = null; private static volatile List rawScanGameObjectSnapshot = null; + /** Immutable locations copied on the client thread for off-thread snapshot filtering. */ + private static Map rawScanDoorLocationSnapshot = null; /** Object definitions and segment matches are stable for one immutable scene snapshot. */ - private static Map> rawScanDoorCompositionCache = null; + private static Map> rawScanDoorCompositionCache = null; private static Map> rawScanDoorSegmentCache = null; /** Interaction/edge-resolution wait contained inside {@link #handleDoors}; excluded from probe cost. */ private static volatile long rawScanDoorInteractionWaitMs = 0L; + private static Map captureRawScanDoorLocationsOnClientThread() { + Map locations = new IdentityHashMap<>(); + if (rawScanWallSnapshot != null) { + for (WallObject wall : rawScanWallSnapshot) { + if (wall != null) { + locations.put(wall, ((TileObject) wall).getWorldLocation()); + } + } + } + if (rawScanGameObjectSnapshot != null) { + for (GameObject object : rawScanGameObjectSnapshot) { + if (object != null) { + locations.put(object, ((TileObject) object).getWorldLocation()); + } + } + } + return locations; + } + /** * Exact-tile match first, then a one-tile adjacency fallback — the same preference order the * previous pair of bounded queries produced. @@ -5688,6 +5712,10 @@ private static TileObject findDoorNearSegment(WorldPoint fromWp, WorldPoint toWp List wallSnapshot = rawScanWallSnapshot; List gameObjectSnapshot = rawScanGameObjectSnapshot; if (wallSnapshot != null || gameObjectSnapshot != null) { + Map locations = rawScanDoorLocationSnapshot; + if (locations == null) { + return null; + } Map> segmentCache = rawScanDoorSegmentCache; String segmentKey = fromWp.getX() + "," + fromWp.getY() + "," + fromWp.getPlane() + ">" + toWp.getX() + "," + toWp.getY() + "," + toWp.getPlane(); @@ -5704,9 +5732,10 @@ private static TileObject findDoorNearSegment(WorldPoint fromWp, WorldPoint toWp candidates.addAll(gameObjectSnapshot); } TileObject match = candidates.stream() - .filter(o -> isDoorCandidateOnSegment(o, playerLoc, fromWp, toWp, + .filter(o -> isDoorCandidateOnSegment(o, locations.get(o), + playerLoc, fromWp, toWp, doorActions, searchDistance)) - .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo2D(playerLoc))) + .min(Comparator.comparingInt(o -> locations.get(o).distanceTo2D(playerLoc))) .orElse(null); if (segmentCache != null) { segmentCache.put(segmentKey, Optional.ofNullable(match)); @@ -5714,7 +5743,8 @@ private static TileObject findDoorNearSegment(WorldPoint fromWp, WorldPoint toWp return match; } return Rs2GameObject.getAll(o -> { - return isDoorCandidateOnSegment(o, playerLoc, fromWp, toWp, + return isDoorCandidateOnSegment(o, o.getWorldLocation(), + playerLoc, fromWp, toWp, doorActions, searchDistance); }, playerLoc, searchDistance).stream() .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo2D(playerLoc))) @@ -5722,15 +5752,16 @@ private static TileObject findDoorNearSegment(WorldPoint fromWp, WorldPoint toWp } private static boolean isDoorCandidateOnSegment(TileObject object, + WorldPoint objectLocation, WorldPoint playerLoc, WorldPoint fromWp, WorldPoint toWp, List doorActions, int searchDistance) { - if (object == null || object.getWorldLocation() == null) { + if (object == null || objectLocation == null) { return false; } - WorldPoint loc = object.getWorldLocation(); + WorldPoint loc = objectLocation; if (loc.getPlane() != playerLoc.getPlane() || loc.distanceTo2D(playerLoc) > searchDistance || sessionBlacklistedDoors.contains(loc) @@ -5747,11 +5778,11 @@ private static ObjectComposition resolveRawScanDoorComposition(TileObject object if (object == null) { return null; } - Map> compositionCache = rawScanDoorCompositionCache; + Map> compositionCache = rawScanDoorCompositionCache; if (compositionCache == null) { return Rs2GameObject.convertToObjectComposition(object); } - return compositionCache.computeIfAbsent(object.getId(), + return compositionCache.computeIfAbsent(object, ignored -> Optional.ofNullable(Rs2GameObject.convertToObjectComposition(object))) .orElse(null); } diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index 2a5b2499b6..7c4c55b8c7 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -798,34 +798,34 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isPendingRouteDoorObj net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isPendingRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$45(WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$46(WorldPoint, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$47(WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$48(WorldPoint, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$13(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$14(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$191(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$160(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$162(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$138(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$144(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$144(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$145(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$145(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$194(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$163(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$165(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$141(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$147(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$147(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$148(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$148(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleRockfall$24(WorldPoint, Tile): boolean -> net.runelite.api.Tile#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$117(int, boolean, boolean, TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$117(int, boolean, boolean, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$119(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$120(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$121(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$122(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$124(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$125(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$152(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$153(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$120(int, boolean, boolean, TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$120(int, boolean, boolean, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$122(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$123(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$124(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$125(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$127(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$128(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$155(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$156(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$2(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$3(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$34(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$35(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$73(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$76(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint