Skip to content

[NOT READY] fix(walker): spirit tree filtering, transport-zone Dijkstra, cross-plane fixes#1786

Open
runsonmypc wants to merge 29 commits into
chsami:developmentfrom
runsonmypc:fix/walker
Open

[NOT READY] fix(walker): spirit tree filtering, transport-zone Dijkstra, cross-plane fixes#1786
runsonmypc wants to merge 29 commits into
chsami:developmentfrom
runsonmypc:fix/walker

Conversation

@runsonmypc
Copy link
Copy Markdown
Contributor

Summary

  • Spirit tree route filtering: isSpiritTreeDestinationEnabled only checked the destination side of a route, so routes FROM a disabled tree (e.g. Farming Guild with toggle off) to permanent trees (GE, Gnome Stronghold) still passed. Now checks both origin and destination against the toggle list.
  • Transport-zone Dijkstra: Walking nodes after a transport landing get heuristic=0 so A* freely explores transport chains instead of being locked to the beeline heuristic.
  • Cross-plane tile skip: Path tiles on a different plane than the player are skipped in processWalk, preventing 8+ second stalls on stairs/ladder walks.
  • Bounded BFS: getReachableTilesFromTile bounded to HANDLER_RANGE * 3 instead of unbounded scene flood.
  • Diagonal isTileReachable: Added 8-direction expansion to match getReachableTilesFromTile, fixing disagreement that caused oscillation.
  • Short-path click fix: nextWalkingDistance set to 0 for paths ≤ 5 tiles so the walker doesn't stall on short walks.
  • transportsPacked overwrite fix: refreshTeleports() no longer overwrites existing non-teleport transports at the player's position.
  • Reverted live collision snapshot: Architecturally unsound — removed entirely.
  • Defensive walker check: Spirit tree dispatch now verifies isUseSpiritTrees() before attempting interaction.
  • Removed dead refresh() assignments: Quest-gated transport toggles (fairy rings, gliders, quetzals, spirit trees) were set without quest checks in refresh() then immediately overwritten in refreshTransports().

Test plan

  • Walk to a destination that would previously route through a disabled spirit tree (e.g. Farming Guild with toggle off) — should not attempt spirit tree
  • Walk using a permanent spirit tree (GE, Gnome Stronghold) with master toggle on — should still work
  • Cross-plane walk (ground floor to upstairs) — should not stall
  • Short walk (< 5 tiles) — should click immediately without stalling
  • Long flat walk — no behavior change

runsonmypc added 25 commits May 27, 2026 11:19
…gate skip

Three fixes for Rs2Walker transport and gate handling:

1. Prevent infinite loops with network transports (spirit trees, fairy
   rings, quetzals, gliders). The current-tile transport handler's
   "closer to goal" heuristic allowed taking any network transport whose
   destination was nearer the goal, creating A→B→A loops. Now network
   transports require the destination to be on the forward path points.

2. Don't skip segment handlers when the next path tile is unreachable.
   The 15s post-transport optimization blanket-skipped all handlers
   (including door/gate resolution) when no nearby transport was planned.
   Adding a reachability check ensures gates are still opened.

3. Widen blocker scan radius from 2 to 4 tiles. Path smoothing can place
   path points 3+ tiles from a gate object (e.g. Al Kharid gate),
   causing the blocker scan to filter it out and leaving the walker stuck
   in recovery clicks that never reach the gate.
The post-click sleepUntil waited up to 2000ms for the player to reach
within 2-4 tiles of the click target, but had no check for the player
stopping. When the player hit a gate or obstacle mid-walk, the walker
sat idle for the remainder of the timeout before re-evaluating.

Add !Rs2Player.isMoving() to the wake condition (after the 600ms game
tick floor) so the walker reacts immediately when movement stops.
…breaks

PATH_VARIANCE_TOLERANCE_CHEBYSHEV raised from 3 to 6. The variance
check is the fallback when the BFS-based isNearPath() fails (e.g. a
nearby wall blocks BFS despite the player being 2-3 tiles from the
path). At 3, normal path-smoothing drift during walking triggered
off-path-but-moving breaks that killed the click chain — the walker
would watch the player walk without issuing new clicks, causing visible
pauses when the player eventually stopped.

At 6 tiles the walker keeps clicking through minor drift while still
recalculating when genuinely lost.
isTileReachable() dispatches an unbounded BFS to the client thread for
every path index in the inner walk loop. With 10+ indices per iteration,
each blocking on the client thread round-trip, this caused multi-second
delays before the first minimap click (9s observed on a 23-point path).

Compute reachable tiles once via getReachableTilesFromTile(radius=45)
before the inner loop and use HashMap lookups instead. One client-thread
BFS call replaces N sequential blocking calls.
Hoist the cache variables above the outer tail loop and only recompute
when the player's position changes. The outer loop can iterate up to 64
times (door settling, recovery clicks, interim yields) — previously
each iteration recomputed the full radius-45 BFS on the client thread
even when the player was standing still.
- Rewrite getReachableTilesFromTileInternal to use ArrayDeque BFS (O(n))
  instead of O(n*d) HashMap scanning with per-tile HashSet allocation
- Use raw tile-by-tile path for door/rockfall/transport detection instead
  of smoothed path, fixing gates missed between widely-spaced smooth points
- Add unbounded BFS fallback for reachability check to eliminate false
  unreachable hits from bounded cache
- Always recompute reachable tiles cache each iteration to pick up
  collision changes (gates opened by other players)
- Skip reachable raw edges in door/rockfall scanning for performance;
  transport scanning always checks all edges (teleports aren't blockages)
- Keep smoothed path for click targeting and spatial proximity checks
…outing

Transport nodes (fairy rings, spirit trees, etc.) were placed in a
separate pending queue ordered by g-cost, only processed when cheaper
than the boundary's g-cost. This caused the pathfinder to walk 988
tiles instead of using fairy ring CIR (~260 tiles) because transport
nodes at g=58 were starved behind walking nodes at g<58.

Now all nodes share the A* boundary queue with proper heuristic,
so a fairy ring destination at f=111 is explored immediately over
a walking path at f=2475.

Also: unbounded reachable tiles cache, remove isTileReachable fallback.
- refreshTransports: re-read config with = instead of &= so
  fairy rings / spirit trees / gliders recover after early startup
  calls when player data isn't loaded yet
- stallThresholdMs: replace Rs2Player.isInteracting() (can get stuck
  returning true) with isMoving()||isAnimating() for the interacting
  multiplier
- close bank before processWalk alongside existing closeWorldMap()
…nreachable

The BFS in getReachableTilesFromTileInternal only checked 4 cardinal
directions, but OSRS supports diagonal movement. Areas where the only
path requires diagonal movement (around corners, through diagonal gaps)
were falsely flagged unreachable, causing 4-second pauses while the
walker ran expensive door/blocker scans on tiles that were actually
walkable.

- Add NE/NW/SE/SW diagonal expansion to the BFS, matching the
  collision checks used by the pathfinder's CollisionMap
- Refresh the reachable cache before entering the heavy unreachable
  handler so transient blocks (stale cache) are caught cheaply
- Add Taverley wall blocked edges (gate at x=2936 excluded)
…overy

A* with Chebyshev heuristic can't discover multi-hop transport chains
where the intermediate leg (walking to a fairy ring) moves AWAY from
the target. The heuristic inflates f-cost on those nodes so the A*
settles on a worse single-hop teleport before ever exploring the chain.

Pre-seed the boundary queue with bridge nodes representing chained
routes: item teleport → walk → network transport (fairy ring, spirit
tree, gnome glider). Each bridge destination enters the queue with
combined g-cost and its own heuristic, so the A* naturally evaluates
the chain against direct routes.

Also close world map before opening bank in banked-transport flows.
The transport refresh snapshot was captured AFTER filterSimilarTransports
ran, baking target-specific filtering into the cached data. This forced
targetPacked into the cache key, meaning every pathfind to a new
destination triggered a full 450ms refresh of 11,738 transports.

Move snapshot capture BEFORE filterSimilarTransports so the cached data
is target-independent. filterSimilarTransports still runs after every
cache restore (which it already did), so target-specific filtering is
always applied fresh. Remove targetPacked from the cache key.

Sequential pathfinds (birdhouse runs, farming runs) now hit the cache
on the 2nd+ destination instead of paying 450ms each time.
walkWithBankedTransportsAndState ran compareRoutes on every walk,
which internally calls getWalkPath 3 times + 2 explicit config.refresh
calls — 5 full transport refreshes (450ms each) even for a 20-tile
path. For short walks (<=200 Chebyshev), skip the comparison entirely
and walk directly. Banking detours only make sense for long-distance
routes where a teleport item from the bank could save significant tiles.
…ansport

The current-tile transport handler oscillated on stairs: after a planned
transport UP (segment handler), the current-tile handler saw the stairs
at the new tile, the plane filter allowed it (playerPlane != targetPlane),
and it took them back DOWN — creating an infinite up/down loop.

Block transports whose destination matches lastTransportHandledAtLocation
(where the player was before the last transport). This prevents the
handler from undoing a transport that just completed.
…e paths

The chain bridge injection could pick a teleport→network chain over
nearby stairs for cross-plane walks because the heuristic ignores plane
changes. A games necklace chain with f=200 beats a stair path whose
true cost is 50 but explores 2M nodes to discover.

Gate injection behind MIN_CHAIN_INJECT_DISTANCE (500 Chebyshev). Short
walks use the normal A* which finds stairs through local expansion.
Chains are only beneficial for long-distance routes where walking to a
fairy ring after a teleport saves hundreds of tiles.
The chain injection approach was fundamentally wrong — it created
phantom parent chains that don't correspond to real transports,
could teleport players out of buildings for in-building walks, and
required arbitrary distance guards to contain the damage. Reverted.
When start and target are within 200 Chebyshev tiles, skip
refreshTeleports entirely so the A* only considers walking and
local transports (stairs, doors). Prevents the pathfinder from
choosing absurd teleport routes (games necklace to Corp) for
in-building cross-plane walks where the stairs are nearby but the
collision map makes them expensive to discover.
Tile (1621, 3822, plane=1) is fully blocked in collision-map.zip
(N/E/S/W all false) despite being walkable in-game. This made the
target unreachable, causing the A* to explore 2M nodes across the
entire map and ultimately choose a teleport-to-Corp route for an
in-building walk.
The pathfinder used a static collision-map.zip that can have incorrect
data (tiles marked blocked when they're walkable in-game). This caused
the A* to explore millions of nodes and choose absurd teleport routes
for simple in-building walks.

On each refresh, snapshot the game engine's live collision flags for
all planes of the loaded scene (104x104). CollisionMap.n/e/s/w now
check the live snapshot first for tiles within the scene, falling back
to the static map only for tiles outside. The live data is always
correct — it reflects actual walls, doors, and terrain as the game
engine sees them.

Removes the ignoreCollisionPacked bandaid for the Arceuus Library tile
since the live data handles it properly.
CollisionMap is stored in a ThreadLocal — each thread gets its own
instance. The snapshot was captured on the refresh thread's instance
but the pathfinder runs on a different thread with a different
CollisionMap that never received the snapshot. Making the snapshot
fields and setter static ensures all threads see the same live data.
Two bugs found by review:

1. isBlocked() didn't check BLOCK_MOVEMENT_FULL from live data. A tile
   occupied by an object (rock, tree) with no directional flags set
   would appear walkable — the pathfinder could route through walls.
   Now checks BLOCK_MOVEMENT_FULL first.

2. Three separate volatile fields (liveFlags, liveBaseX, liveBaseY)
   could be read inconsistently during a scene change. Bundle into a
   single immutable LiveSnapshot object with one volatile reference.
   Also consolidate the repeated live-flag lookup into liveFlag().
The initial refreshTeleports guard was bypassed by the wilderness level
initialization. wildernessLevel starts at 31, so on the FIRST node
expansion the A* detected 'not in wilderness' and called
refreshTeleports(node, 0) — adding all teleports including games
necklace to Corp. This caused the pathfinder to teleport out of
buildings for in-building cross-plane walks.

For short-distance paths: set wildernessLevel=0 upfront (skipping the
initial refresh), and guard the in-loop wilderness refresh with the
shortDistance flag so teleports are never injected mid-search.
The live snapshot was REPLACING static data for scene tiles, which
introduced a regression: closed doors in live data blocked paths that
the static map showed as open, preventing the pathfinder from finding
stair routes and forcing teleport detours.

Now: if the static map says walkable, trust it. Only consult live data
when the static map says blocked — the live data can reveal the tile is
actually walkable (fixing stale static data) but can never make a
walkable tile appear blocked.
…ort-zone Dijkstra

- Revert live collision snapshot (CollisionMap + PathfinderConfig): the
  "unblock only" overlay had structural bugs (missing BLOCK_MOVEMENT_FULL,
  s()/w() not checking destination tile) causing paths through furniture.
  Walker already handles dynamic obstacles via door/transport handlers.

- Restore upstream Pathfinder with pending queue (transports sorted by
  g-cost, walking nodes by f-cost). Our prior merge into boundary with
  heuristic made transport chain discovery worse, not better.

- Transport-zone Dijkstra: walking nodes after a transport landing get
  heuristic=0 (Dijkstra) so A* freely explores to find the next transport
  in a chain. Pure walking retains full Chebyshev heuristic for performance.
  This fixes multi-hop routing (e.g. Ardougne cloak -> fairy ring).

- Fix transportsPacked overwrite in refreshTeleports: the packed map was
  unconditionally overwritten with teleports-only, losing existing transports
  (stairs/doors) at the player's position. Now uses the merged set.
… short-path clicks

- Skip path tiles on a different plane than the player in processWalk loop.
  These tiles trigger unreachable handlers, BFS refreshes, and door probes
  that can never succeed from the wrong plane. Eliminates 8-second stalls
  on cross-plane walks.

- Bound getReachableTilesFromTile to HANDLER_RANGE*3 (39 tiles) instead of
  Integer.MAX_VALUE. Reduces BFS from ~10,816 tiles to ~6,000 max while
  covering routes around walls.

- Add diagonal movement to isTileReachableInternal to match
  getReachableTilesFromTileInternal. Fixes disagreement between the two
  methods that caused oscillation on diagonally-reachable tiles.

- For paths <= 5 tiles, set nextWalkingDistance to 0 so the path loop can
  click nearby tiles instead of spinning without issuing movement.

- Lower banking skip threshold from 200 to 100 Chebyshev.

- Recalculate path instead of giving up on pathfinder-still-null when
  target is still set.
…re off

isSpiritTreeDestinationEnabled only checked the destination, so routes
FROM a disabled tree (e.g. Farming Guild) to permanent trees (GE, Gnome
Stronghold) still passed. Renamed to isSpiritTreeRouteEnabled and now
rejects any route where either endpoint touches a toggled-off tree.

Also removes dead pre-quest-gate assignments in refresh() that were
immediately overwritten by refreshTransports(), and adds a defensive
useSpiritTrees check in the walker's spirit tree dispatch.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 27, 2026

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8f467d6a-226a-45c1-987e-fa1eca43dcd3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This PR refines pathfinding heuristics and walker path execution across four modules. Pathfinder adds a post-transport heuristic propagation mechanism to guide frontier ordering after transport nodes or heuristic-zero states. PathfinderConfig introduces a dedicated spirit-tree toggle, refactors spirit-tree route gating to check proximity and destinations, and updates quest-dependent transport refresh logic with adjusted caching behavior. Rs2Tile replaces its reachable-tiles computation with a collision-flag-aware BFS that handles instanced regions. Rs2Walker undergoes substantial refactoring to use raw-path semantics for obstacle handling, introduces per-loop reachable-tile caching, updates door/rockfall/transport handlers, adds spirit-tree config gating, and includes navigation refinements such as minimap distance adjustments, world map closure, and a fast path for close banked-transport targets.

Possibly related PRs

  • chsami/Microbot#1775: Refactors Rs2Walker transport edge/object detection via a catalog and suppresses door-like probing during transport segments, overlapping the transport interaction logic.
  • chsami/Microbot#1496: Modifies Rs2Walker's transport-aware walking flow around banking (including closeWorldMap and banked-transport logic).
  • chsami/Microbot#1769: Introduces shortest-path transport graph and eligibility changes directly tied to this PR's PathfinderConfig transport refresh and spirit-tree gating updates.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed Description provides detailed explanations of all major changes and includes a comprehensive test plan, directly addressing the modifications in the four modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately reflects the main objectives: spirit tree filtering, transport-zone Dijkstra improvements, and cross-plane fixes are all documented in the PR objectives and represented in the code changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java (1)

207-218: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't use heuristic == 0 as the post-transport signal.

Lines 244-245 and 260-261 already allow plain walking nodes to get 0 from the wrapped underground heuristic. Once one of those nodes is popped, this change propagates 0 to the surrounding walking region even though no transport was taken, which can blow up the frontier and push long surface↔underground searches into the cutoff path. Carry an explicit afterTransport bit on the node instead of inferring it from the heuristic value.

Also applies to: 312-323, 339-350

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java`
around lines 207 - 218, The code is incorrectly using neighbor.heuristic == 0 as
a marker for "after taking a transport"; add an explicit boolean flag (e.g.,
Node.afterTransport) to Node (and set it on TransportNode instances or when you
enqueue a neighbor that was reached via a transport) and replace all uses of
heuristic==0 in Pathfinder (including the neighbor handling around
pending.add(...), the heuristic assignment sites, and any later checks that
infer post-transport state) to check that boolean instead; ensure when you pop a
transport node or enqueue a neighbor via pending you set neighbor.afterTransport
= true, and when computing heuristic for normal walking neighbors set
afterTransport = false so the wrapped underground heuristic 0 doesn't get
misinterpreted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java`:
- Around line 353-453: The BFS still uses collision flags even when
ignoreCollision is true; update getReachableTilesFromTileInternal to
short-circuit collision logic: skip the BLOCK_MOVEMENT_FULL removal check and
the flag-derived canE/canW/canN/canS calculations when ignoreCollision is true,
and instead enqueue cardinal neighbors by bounds-only checks (use
isWithinBounds(sx±1, sy) / isWithinBounds(sx, sy±1)) and diagonals by
bounds-only checks (isWithinBounds on the diagonal target) without consulting
flags or CollisionDataFlag; keep using tileDistances.putIfAbsent(...) and
queue.add(...) for all enqueues so existing dedup logic remains.

In
`@runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java`:
- Around line 3268-3275: The code is comparing candidate transport destinations
against lastTransportHandledAtLocation (the previous post-landing tile) which is
the last destination, not the last origin, so reverse transports (B -> A after A
-> B) still pass; declare and maintain a separate field (e.g.,
lastTransportOrigin) in Rs2Walker, set it to the player's tile at transport
start (or capture it in the method that begins handling a Transport) and
update/clear it alongside finishHandledTransport(); then change the stream
filter to compare t.getDestination() against this lastTransportOrigin (not
lastTransportHandledAtLocation) to correctly suppress immediate reverse
transport candidates.

---

Outside diff comments:
In
`@runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java`:
- Around line 207-218: The code is incorrectly using neighbor.heuristic == 0 as
a marker for "after taking a transport"; add an explicit boolean flag (e.g.,
Node.afterTransport) to Node (and set it on TransportNode instances or when you
enqueue a neighbor that was reached via a transport) and replace all uses of
heuristic==0 in Pathfinder (including the neighbor handling around
pending.add(...), the heuristic assignment sites, and any later checks that
infer post-transport state) to check that boolean instead; ensure when you pop a
transport node or enqueue a neighbor via pending you set neighbor.afterTransport
= true, and when computing heuristic for normal walking neighbors set
afterTransport = false so the wrapped underground heuristic 0 doesn't get
misinterpreted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: aa32d05a-69b2-45f7-80ef-b73bc0dcfed7

📥 Commits

Reviewing files that changed from the base of the PR and between b56cfe0 and 141ffb4.

⛔ Files ignored due to path filters (1)
  • runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/blocked_edges.tsv is excluded by !**/*.tsv
📒 Files selected for processing (4)
  • runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java
  • runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java
  • runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java
  • runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java

Comment on lines 353 to +453
private static HashMap<WorldPoint, Integer> getReachableTilesFromTileInternal(WorldPoint tile, int distance, boolean ignoreCollision) {
final HashMap<WorldPoint, Integer> tileDistances = new HashMap<>();
if (tile == null) return tileDistances;

final int[][] flags = getFlagsInternal();
if (flags == null) return tileDistances;

final WorldView wv = Microbot.getClient().getTopLevelWorldView();
final boolean isInstance = wv.getScene().isInstance();

final ArrayDeque<WorldPoint> queue = new ArrayDeque<>();
tileDistances.put(tile, 0);
queue.add(tile);

for (int i = 0; i < distance + 1; i++) {
int dist = i;
for (var kvp : tileDistances.entrySet().stream().filter(x -> x.getValue() == dist).collect(Collectors.toList())) {
var point = kvp.getKey();
LocalPoint localPoint;
if (Microbot.getClient().getTopLevelWorldView().isInstance()) {
WorldPoint worldPoint = WorldPoint.toLocalInstance(Microbot.getClient().getTopLevelWorldView(), point).stream().findFirst().orElse(null);
if (worldPoint == null) break;
localPoint = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), worldPoint);
} else
localPoint = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), point);

CollisionData[] collisionMap = Microbot.getClient().getTopLevelWorldView().getCollisionMaps();
if (collisionMap != null && localPoint != null) {
CollisionData collisionData = collisionMap[Microbot.getClient().getTopLevelWorldView().getPlane()];
int[][] flags = collisionData.getFlags();
int data = flags[localPoint.getSceneX()][localPoint.getSceneY()];

Set<MovementFlag> movementFlags = MovementFlag.getSetFlags(data);

if (!ignoreCollision && !tile.equals(point)) {
if (movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_FULL)
|| movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_FLOOR)) {
tileDistances.remove(point);
continue;
}
}
while (!queue.isEmpty()) {
final WorldPoint point = queue.poll();
final int dist = tileDistances.get(point);

final LocalPoint lp;
if (isInstance) {
WorldPoint instancePoint = WorldPoint.toLocalInstance(wv, point).stream().findFirst().orElse(null);
if (instancePoint == null) continue;
lp = LocalPoint.fromWorld(wv, instancePoint);
} else {
lp = LocalPoint.fromWorld(wv, point);
}
if (lp == null) continue;

final int sx = lp.getSceneX();
final int sy = lp.getSceneY();
if (!isWithinBounds(sx, sy)) continue;

if (kvp.getValue() >= distance)
continue;

if (!movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_EAST))
tileDistances.putIfAbsent(point.dx(1), dist + 1);
if (!movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_WEST))
tileDistances.putIfAbsent(point.dx(-1), dist + 1);
if (!movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_NORTH))
tileDistances.putIfAbsent(point.dy(1), dist + 1);
if (!movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_SOUTH))
tileDistances.putIfAbsent(point.dy(-1), dist + 1);
final int data = flags[sx][sy];

if (!ignoreCollision && !tile.equals(point)) {
if ((data & CollisionDataFlag.BLOCK_MOVEMENT_FULL) != 0) {
tileDistances.remove(point);
continue;
}
}

if (dist >= distance) continue;

final boolean canE = (data & CollisionDataFlag.BLOCK_MOVEMENT_EAST) == 0;
final boolean canW = (data & CollisionDataFlag.BLOCK_MOVEMENT_WEST) == 0;
final boolean canN = (data & CollisionDataFlag.BLOCK_MOVEMENT_NORTH) == 0;
final boolean canS = (data & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH) == 0;

if (canE) {
WorldPoint neighbor = point.dx(1);
if (tileDistances.putIfAbsent(neighbor, dist + 1) == null)
queue.add(neighbor);
}
if (canW) {
WorldPoint neighbor = point.dx(-1);
if (tileDistances.putIfAbsent(neighbor, dist + 1) == null)
queue.add(neighbor);
}
if (canN) {
WorldPoint neighbor = point.dy(1);
if (tileDistances.putIfAbsent(neighbor, dist + 1) == null)
queue.add(neighbor);
}
if (canS) {
WorldPoint neighbor = point.dy(-1);
if (tileDistances.putIfAbsent(neighbor, dist + 1) == null)
queue.add(neighbor);
}

if (canN && canE && isWithinBounds(sx + 1, sy) && isWithinBounds(sx, sy + 1) && isWithinBounds(sx + 1, sy + 1)
&& (flags[sx + 1][sy] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | CollisionDataFlag.BLOCK_MOVEMENT_NORTH)) == 0
&& (flags[sx][sy + 1] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | CollisionDataFlag.BLOCK_MOVEMENT_EAST)) == 0
&& (flags[sx + 1][sy + 1] & CollisionDataFlag.BLOCK_MOVEMENT_FULL) == 0) {
WorldPoint neighbor = new WorldPoint(point.getX() + 1, point.getY() + 1, point.getPlane());
if (tileDistances.putIfAbsent(neighbor, dist + 1) == null)
queue.add(neighbor);
}
if (canN && canW && isWithinBounds(sx - 1, sy) && isWithinBounds(sx, sy + 1) && isWithinBounds(sx - 1, sy + 1)
&& (flags[sx - 1][sy] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | CollisionDataFlag.BLOCK_MOVEMENT_NORTH)) == 0
&& (flags[sx][sy + 1] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | CollisionDataFlag.BLOCK_MOVEMENT_WEST)) == 0
&& (flags[sx - 1][sy + 1] & CollisionDataFlag.BLOCK_MOVEMENT_FULL) == 0) {
WorldPoint neighbor = new WorldPoint(point.getX() - 1, point.getY() + 1, point.getPlane());
if (tileDistances.putIfAbsent(neighbor, dist + 1) == null)
queue.add(neighbor);
}
if (canS && canE && isWithinBounds(sx + 1, sy) && isWithinBounds(sx, sy - 1) && isWithinBounds(sx + 1, sy - 1)
&& (flags[sx + 1][sy] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | CollisionDataFlag.BLOCK_MOVEMENT_SOUTH)) == 0
&& (flags[sx][sy - 1] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | CollisionDataFlag.BLOCK_MOVEMENT_EAST)) == 0
&& (flags[sx + 1][sy - 1] & CollisionDataFlag.BLOCK_MOVEMENT_FULL) == 0) {
WorldPoint neighbor = new WorldPoint(point.getX() + 1, point.getY() - 1, point.getPlane());
if (tileDistances.putIfAbsent(neighbor, dist + 1) == null)
queue.add(neighbor);
}
if (canS && canW && isWithinBounds(sx - 1, sy) && isWithinBounds(sx, sy - 1) && isWithinBounds(sx - 1, sy - 1)
&& (flags[sx - 1][sy] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | CollisionDataFlag.BLOCK_MOVEMENT_SOUTH)) == 0
&& (flags[sx][sy - 1] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | CollisionDataFlag.BLOCK_MOVEMENT_WEST)) == 0
&& (flags[sx - 1][sy - 1] & CollisionDataFlag.BLOCK_MOVEMENT_FULL) == 0) {
WorldPoint neighbor = new WorldPoint(point.getX() - 1, point.getY() - 1, point.getPlane());
if (tileDistances.putIfAbsent(neighbor, dist + 1) == null)
queue.add(neighbor);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

ignoreCollision still obeys collision flags.

When ignoreCollision is true, this BFS still derives canN/canS/canE/canW and all diagonal eligibility from CollisionDataFlag, so getReachableTilesFromTileIgnoreCollision(...) no longer ignores blocked edges. That breaks the method contract and any caller using it as a pure radius/reachability sample. Split the expansion path so the ignoreCollision branch enqueues in-bounds neighbors without consulting movement flags.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java`
around lines 353 - 453, The BFS still uses collision flags even when
ignoreCollision is true; update getReachableTilesFromTileInternal to
short-circuit collision logic: skip the BLOCK_MOVEMENT_FULL removal check and
the flag-derived canE/canW/canN/canS calculations when ignoreCollision is true,
and instead enqueue cardinal neighbors by bounds-only checks (use
isWithinBounds(sx±1, sy) / isWithinBounds(sx, sy±1)) and diagonals by
bounds-only checks (isWithinBounds on the diagonal target) without consulting
flags or CollisionDataFlag; keep using tileDistances.putIfAbsent(...) and
queue.add(...) for all enqueues so existing dedup logic remains.

Comment on lines +3268 to +3275
WorldPoint lastTransportOrigin = lastTransportHandledAtLocation;
List<Transport> candidates = transports.stream()
.filter(t -> t.getDestination() != null)
// Local adjacent same-plane edges (doors/gates) are handled by segment door/object
// logic; current-tile transport probing can bounce on these and create loops.
.filter(t -> !isAdjacentSamePlaneTransport(t))
.filter(t -> lastTransportOrigin == null
|| !t.getDestination().equals(lastTransportOrigin))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Track the previous transport origin separately here.

lastTransportHandledAtLocation is the post-landing tile (finishHandledTransport() writes the player's location after the transport completes), so this filter is comparing against the last destination, not the last origin. After A -> B, the reverse candidate B -> A still passes, which reopens the bounce loop this branch is trying to prevent. Store the last origin explicitly and compare against that instead.

Suggested fix
+ private static volatile WorldPoint lastTransportHandledOrigin = null;
...
- WorldPoint lastTransportOrigin = lastTransportHandledAtLocation;
+ WorldPoint lastTransportOrigin = lastTransportHandledOrigin;
     private static boolean finishHandledTransport(Transport transport) {
         long handoffStartedAt = System.currentTimeMillis();
         lastTransportHandledAtMs = handoffStartedAt;
+        lastTransportHandledOrigin = transport != null ? transport.getOrigin() : null;
         lastTransportHandledAtLocation = Rs2Player.getWorldLocation();
         WorldPoint goal = currentTarget;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java`
around lines 3268 - 3275, The code is comparing candidate transport destinations
against lastTransportHandledAtLocation (the previous post-landing tile) which is
the last destination, not the last origin, so reverse transports (B -> A after A
-> B) still pass; declare and maintain a separate field (e.g.,
lastTransportOrigin) in Rs2Walker, set it to the player's tile at transport
start (or capture it in the method that begins handling a Transport) and
update/clear it alongside finishHandledTransport(); then change the stream
filter to compare t.getDestination() against this lastTransportOrigin (not
lastTransportHandledAtLocation) to correctly suppress immediate reverse
transport candidates.

Pre-existing Rs2Walker/Rs2Tile violations detected by the scanner after
rebase onto upstream/development. No new violations introduced.
@runsonmypc runsonmypc changed the title fix(walker): spirit tree filtering, transport-zone Dijkstra, cross-plane fixes [NOT READY] fix(walker): spirit tree filtering, transport-zone Dijkstra, cross-plane fixes May 27, 2026
…heuristic cascade

Three fixes:

- Rs2Walker: track transport origin separately from landing tile so the
  reverse-transport filter correctly blocks A→B→A loops (cave entrance
  ping-pong). New lastTransportOriginLocation field, cleared on walk
  session start.

- Rs2Tile: getReachableTilesFromTileInternal now fully honours
  ignoreCollision — cardinal direction flags and diagonal corridor
  flags are short-circuited, leaving only bounds checks.

- Pathfinder: remove `|| node.heuristic == 0` from afterTransport
  checks in all three addNeighbors variants. The zero-heuristic no
  longer cascades past the first ring of walking nodes after a
  transport, restoring A* guidance for post-transport paths.
… scripted-walk pathfinder guard

- Pathfinder: network-transport-aware admissible heuristic so teleport->
  fairy/spirit-tree/glider/quetzal chains are discovered. Each enabled
  network hub becomes an A* landmark: h = min(directWalk, dist(node->origin)
  + min(dest->goal)). Stays admissible AND consistent (landmark set fixed
  per pathfind) so A* optimality holds, while the search is pulled toward
  useful hubs instead of ignoring them. Fixes the cloak->fairy->CIR route to
  the Farming Guild being passed over for a worse single teleport. Adds no
  graph edges (cannot teleport out of buildings) and never zeroes the
  heuristic (cannot collapse to whole-map Dijkstra), unlike the prior
  reverted bridge-injection and cascade attempts.

- Rs2Walker: current-tile transport handler no longer fires off-path
  region-crossing transports (boat/ship/charter/canoe) by straight-line
  distance. A boat destination can be straight-line "closer" to the goal
  while sitting on another landmass, which made the walker click the Fossil
  Island rowboat (~17s of landing-timeout retries) before falling through to
  the planned teleport. These transports are now only used when explicitly
  on the planned path.

- ShortestPathPlugin: skip onGameTick arrival-detection (which nulls the
  pathfinder) while a scripted Rs2Walker target is active, fixing
  intermittent pathfinder-still-null stalls.
The recovery-click rewrite in Rs2Walker added two lambda expressions,
shifting every subsequent compiler-assigned lambda ordinal by +2. The
guardrail baseline keys on those synthetic lambda names, so 26 entries
renamed (same callers, same inferred-client-thread API calls) — no new
real violations. Regenerated to match.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant