tiles = new HashSet<>();
+ final String prefixComment = "#";
+ final String delimColumn = "\t";
+
+ try {
+ String s = new String(Util.readAllBytes(
+ ShortestPathPlugin.class.getResourceAsStream("dangerous_tiles.tsv")), StandardCharsets.UTF_8);
+ Scanner scanner = new Scanner(s);
+ while (scanner.hasNextLine()) {
+ String line = scanner.nextLine();
+ if (line.startsWith(prefixComment) || line.isBlank()) {
+ continue;
+ }
+ WorldPoint hazard = parseBlockedEdgePoint(line.split(delimColumn)[0]);
+ // Penalize the hazard tile and all 8 neighbours (the melee-aggro ring) so the
+ // path keeps >=2 tiles away. Hazard tiles themselves are usually blocked anyway.
+ for (int dx = -1; dx <= 1; dx++) {
+ for (int dy = -1; dy <= 1; dy++) {
+ tiles.add(WorldPointUtil.packWorldPoint(
+ hazard.getX() + dx, hazard.getY() + dy, hazard.getPlane()));
+ }
+ }
+ }
+ scanner.close();
+ } catch (IOException e) {
+ throw new RuntimeException("Unable to load shortest-path dangerous tiles", e);
+ }
+
+ return Collections.unmodifiableSet(tiles);
+ }
+
private static boolean blocksWalkingEdgeWhenUnavailable(Transport transport) {
if (transport == null || transport.getOrigin() == null || transport.getDestination() == null) {
return false;
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/reflection/Rs2Reflection.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/reflection/Rs2Reflection.java
index 494a3e2d35a..4509a0ef5ae 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/reflection/Rs2Reflection.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/reflection/Rs2Reflection.java
@@ -135,7 +135,7 @@ static String[] getGroundItemActionsFromObject(Object item) {
cachedOuterField = outerField;
cachedListField = listField;
cachedStringField = null;
- return toStringArray(list);
+ return groundItemActionsOrDefault(toStringArray(list));
}
Field stringField = null;
@@ -150,12 +150,12 @@ static String[] getGroundItemActionsFromObject(Object item) {
cachedOuterField = outerField;
cachedListField = listField;
cachedStringField = stringField;
- return extractFromBeans(list, stringField);
+ return groundItemActionsOrDefault(extractFromBeans(list, stringField));
}
}
}
- return new String[]{};
+ return defaultGroundItemActions();
}
static void resetGroundItemActionCache() {
@@ -169,16 +169,28 @@ private static String[] extractWithCache(Object item) throws Exception {
cachedOuterField.setAccessible(true);
Object outer = cachedOuterField.get(item);
cachedOuterField.setAccessible(false);
- if (outer == null) return new String[]{};
+ if (outer == null) return defaultGroundItemActions();
cachedListField.setAccessible(true);
Object listObj = cachedListField.get(outer);
cachedListField.setAccessible(false);
- if (!(listObj instanceof List)) return new String[]{};
+ if (!(listObj instanceof List)) return defaultGroundItemActions();
List> list = (List>) listObj;
- if (cachedStringField == null) return toStringArray(list);
- return extractFromBeans(list, cachedStringField);
+ if (cachedStringField == null) return groundItemActionsOrDefault(toStringArray(list));
+ return groundItemActionsOrDefault(extractFromBeans(list, cachedStringField));
+ }
+
+ private static String[] groundItemActionsOrDefault(String[] actions) {
+ if (actions == null || actions.length == 0) return defaultGroundItemActions();
+ for (String action : actions) {
+ if (action != null && !action.isBlank()) return actions;
+ }
+ return defaultGroundItemActions();
+ }
+
+ private static String[] defaultGroundItemActions() {
+ return new String[]{null, null, "Take", null, null};
}
private static String[] toStringArray(List> list) {
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 c90ff191d9d..f58762a4570 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
@@ -62,6 +62,7 @@
import net.runelite.client.plugins.microbot.util.walker.door.model.DoorResolution;
import net.runelite.client.plugins.microbot.util.walker.banking.Rs2WalkerBankingPlanner;
import net.runelite.client.plugins.microbot.util.walker.awaits.Rs2WalkerRuntimeAwaits;
+import net.runelite.client.plugins.microbot.util.walker.puzzles.DraynorBasementSolver;
import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy;
import net.runelite.client.plugins.microbot.util.walker.transport.Rs2WalkerTransportAwaits;
import net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime;
@@ -1053,6 +1054,18 @@ public static WalkerState walkWithState(WorldPoint target) {
* @param distance
*/
private static WalkerState processWalk(WorldPoint target, int distance) {
+ // Solve the Draynor basement lever puzzle first if walking to a basement tile, so the
+ // door-transports are unlocked before pathfinding. No-op outside the basement. The
+ // solver's internal walkTo calls clear currentTarget, so restore it before the real walk.
+ if (DraynorBasementSolver.isBasementTarget(target)) {
+ DraynorBasementSolver.solveIfNeeded(target);
+ // The solver's nested walkTo calls clear currentTarget; restore it so the real walk
+ // runs — but not if this walk was interrupted/cancelled while the (blocking) solver
+ // ran (the solver itself never interrupts, so an interrupt here is an external cancel).
+ if (!Thread.currentThread().isInterrupted()) {
+ setTarget(target, "rs2walker:basement-solve-restore");
+ }
+ }
return processWalk(target, distance, 0);
}
@@ -1645,6 +1658,21 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part
path.get(i), RECOVERY_MINIMAP_REACH_EUCLIDEAN - 1,
wp -> inInstance || isKnownWalkableOrUnloaded(wp));
}
+ // Don't let recovery park the player on a tile next to an aggressive NPC
+ // (e.g. an undead tree). The planner avoids those via avoidDangerousNpcs,
+ // but this runtime fallback would otherwise strand us in melee. Step the
+ // target back along the path to the nearest non-hazard tile.
+ PathfinderConfig dangerCfg = ShortestPathPlugin.pathfinderConfig;
+ if (dangerCfg != null && dangerCfg.isAvoidDangerousNpcs() && recoverTarget != null
+ && dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(recoverTarget))) {
+ int safeIdx = recoverIdx;
+ while (safeIdx > indexOfStartPoint
+ && dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(path.get(safeIdx)))) {
+ safeIdx--;
+ }
+ recoverIdx = safeIdx;
+ recoverTarget = path.get(safeIdx);
+ }
boolean clicked = recoverTarget != null
&& !recoverTarget.equals(playerLoc)
&& Rs2Walker.walkMiniMap(recoverTarget);
@@ -3736,24 +3764,12 @@ && isNullOrPlaceholderObjectName(comp.getName())) {
final String name = comp.getName();
if (object instanceof WallObject) {
- WallObject wallObj = (WallObject) object;
- int orientationA = wallObj.getOrientationA();
- int orientationB = wallObj.getOrientationB();
- boolean pathTouchesBothEnds = probe.distanceTo(fromWp) <= 1 && probe.distanceTo(toWp) <= 1
- && fromWp.distanceTo(toWp) >= 1 && fromWp.distanceTo(toWp) <= 2;
- boolean orientOk = false;
- if (orientationA != 0) {
- orientOk = searchNeighborPoint(orientationA, probe, fromWp)
- || searchNeighborPoint(orientationA, probe, toWp);
- }
- if (!orientOk && orientationB != 0) {
- orientOk = searchNeighborPoint(orientationB, probe, fromWp)
- || searchNeighborPoint(orientationB, probe, toWp);
- }
- if (!orientOk && pathTouchesBothEnds) {
- orientOk = true;
- }
- if (orientOk) {
+ // Validate the door's ACTUAL blocked edge against the segment, not the probe
+ // tile. The probe can sit a tile off the wall (adjacency fallback above), and the
+ // old probe-orientation check plus the pathTouchesBothEnds shortcut opened doors
+ // merely beside the path. isDoorOnSegment walks the segment against the wall's
+ // real edge, matching the GameObject branch and findDoorNearSegment.
+ if (isDoorOnSegment(object, fromWp, toWp)) {
log.info("Found WallObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp);
found = true;
} else {
@@ -4447,8 +4463,11 @@ static boolean wallDoorTouchesSegment(WallObject wall, WorldPoint fromWp, WorldP
if (wall.getWorldLocation().getPlane() != fromWp.getPlane() || fromWp.getPlane() != toWp.getPlane()) return false;
WorldPoint doorTile = wall.getWorldLocation();
- WorldPoint blockedNeighbor = getWallDoorNeighborPoint(wall.getOrientationA(), doorTile);
- if (blockedNeighbor == null) return false;
+ // A door panel can advertise a blocked edge on either orientation; check both so a
+ // legitimately-on-path door is never missed.
+ WorldPoint blockedNeighborA = getWallDoorNeighborPoint(wall.getOrientationA(), doorTile);
+ WorldPoint blockedNeighborB = getWallDoorNeighborPoint(wall.getOrientationB(), doorTile);
+ if (blockedNeighborA == null && blockedNeighborB == null) return false;
int x = fromWp.getX();
int y = fromWp.getY();
@@ -4461,7 +4480,8 @@ static boolean wallDoorTouchesSegment(WallObject wall, WorldPoint fromWp, WorldP
x += Integer.signum(toWp.getX() - x);
y += Integer.signum(toWp.getY() - y);
WorldPoint next = new WorldPoint(x, y, fromWp.getPlane());
- if (isDoorEdgeTransition(previous, next, doorTile, blockedNeighbor)) {
+ if ((blockedNeighborA != null && isDoorEdgeTransition(previous, next, doorTile, blockedNeighborA))
+ || (blockedNeighborB != null && isDoorEdgeTransition(previous, next, doorTile, blockedNeighborB))) {
return true;
}
previous = next;
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/puzzles/DraynorBasementSolver.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/puzzles/DraynorBasementSolver.java
new file mode 100644
index 00000000000..103fde2bac5
--- /dev/null
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/puzzles/DraynorBasementSolver.java
@@ -0,0 +1,291 @@
+/*
+ * Copyright (c) 2025, Microbot
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package net.runelite.client.plugins.microbot.util.walker.puzzles;
+
+import lombok.extern.slf4j.Slf4j;
+import net.runelite.api.coords.WorldPoint;
+import net.runelite.api.gameval.ObjectID;
+import net.runelite.api.gameval.VarbitID;
+import net.runelite.client.plugins.microbot.Microbot;
+import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject;
+import net.runelite.client.plugins.microbot.util.player.Rs2Player;
+import net.runelite.client.plugins.microbot.util.walker.Rs2Walker;
+
+import java.util.ArrayDeque;
+
+import static net.runelite.client.plugins.microbot.util.Global.sleepUntil;
+
+/**
+ * Solves the Draynor Manor (Ernest the Chicken) basement lever puzzle during a web-walk so a
+ * plain {@code Rs2Walker.walkTo(basementTile)} / "Start path" routes to any basement room.
+ *
+ * The 9 puzzle doors are registered as varbit-conditional transports (shortestpath
+ * transports.tsv), so once the levers unlock a door the stock walker crosses + opens it. This
+ * solver supplies the missing half: it sets the levers. Given the target tile's room, it does a
+ * BFS over (lever-combo x room) — actions are "pull a lever in the current room" and "cross an
+ * unlocked door" — to find the lever-pull sequence, then walks to each lever and pulls it. The
+ * walker (via the transports) handles the door crossings.
+ *
+ *
Hook: call {@link #solveIfNeeded(WorldPoint)} at the top of the walk loop. It is a no-op
+ * unless the target and the player are both inside the basement.
+ */
+@Slf4j
+public final class DraynorBasementSolver
+{
+ private DraynorBasementSolver() {}
+
+ private static volatile boolean active = false;
+
+ // Room indices.
+ private static final int ENTRANCE = 0, CD = 1, SS = 2, NS = 3, SW = 4, EF = 5, OIL = 6;
+
+ // Room bounding boxes: {x1, y1, x2, y2} (plane 0), indexed by room.
+ private static final int[][] ROOM_BOX = {
+ {3100, 9745, 3118, 9757}, // ENTRANCE
+ {3105, 9758, 3112, 9767}, // C/D
+ {3100, 9758, 3104, 9762}, // S-small
+ {3100, 9763, 3104, 9767}, // N-small
+ {3096, 9758, 3099, 9762}, // SW-small
+ {3096, 9763, 3099, 9767}, // E/F
+ {3090, 9753, 3099, 9757}, // Oil
+ };
+
+ // Levers A..F (bit index = position): varbit, object id, room, world tile.
+ private static final int[] LEVER_VARBIT = {
+ VarbitID.ERNESTLEVER_A, VarbitID.ERNESTLEVER_B, VarbitID.ERNESTLEVER_C,
+ VarbitID.ERNESTLEVER_D, VarbitID.ERNESTLEVER_E, VarbitID.ERNESTLEVER_F};
+ private static final int[] LEVER_OBJ = {
+ ObjectID.LEVERA, ObjectID.LEVERB, ObjectID.LEVERC,
+ ObjectID.LEVERD, ObjectID.LEVERE, ObjectID.LEVERF};
+ private static final int[] LEVER_ROOM = {ENTRANCE, ENTRANCE, CD, CD, EF, EF};
+ private static final WorldPoint[] LEVER_TILE = {
+ new WorldPoint(3108, 9745, 0), new WorldPoint(3118, 9752, 0), new WorldPoint(3112, 9760, 0),
+ new WorldPoint(3108, 9767, 0), new WorldPoint(3097, 9767, 0), new WorldPoint(3096, 9765, 0)};
+
+ // A walkable interior tile per room (for executing a door crossing), indexed by room.
+ private static final WorldPoint[] ROOM_TILE = {
+ new WorldPoint(3115, 9752, 0), new WorldPoint(3110, 9762, 0), new WorldPoint(3102, 9760, 0),
+ new WorldPoint(3102, 9765, 0), new WorldPoint(3098, 9760, 0), new WorldPoint(3097, 9766, 0),
+ new WorldPoint(3093, 9755, 0)};
+
+ // Doors: {roomA, roomB, mask, requiredValue}. unlocked == (combo & mask) == requiredValue.
+ // A combo bit is set when that lever is DOWN (varbit == 1). Bits: A=0 B=1 C=2 D=3 E=4 F=5.
+ private static final int[][] DOORS = {
+ {ENTRANCE, CD, 0b100111, 0b000011}, // d1 (obj 144): A=D,B=D,C=U,F=U
+ {CD, SS, 0b110101, 0b000001}, // d2 (139): A=D,C=U,E=U,F=U
+ {ENTRANCE, SS, 0b011000, 0b001000}, // d3 (145): D=D,E=U
+ {SS, SW, 0b011000, 0b001000}, // d4 (140): D=D,E=U
+ {EF, SW, 0b101010, 0b001000}, // d5 (143): B=U,D=D,F=U
+ {EF, NS, 0b101010, 0b101000}, // d6 (138): B=U,D=D,F=D
+ {CD, NS, 0b110001, 0b110000}, // d7 (137): A=U,E=D,F=D
+ {SS, NS, 0b110000, 0b100000}, // d8 (142): E=U,F=D
+ {ENTRANCE, OIL, 0b101100, 0b101100}, // d9 (141): C=D,D=D,F=D
+ };
+
+ private static final int LEVER_PULL_TIMEOUT_MS = 4000;
+
+ public static boolean isBasementTarget(WorldPoint p)
+ {
+ return p != null && p.getPlane() == 0
+ && p.getX() >= 3088 && p.getX() <= 3120
+ && p.getY() >= 9744 && p.getY() <= 9768;
+ }
+
+ private static int roomOf(WorldPoint p)
+ {
+ if (p == null)
+ {
+ return -1;
+ }
+ for (int r = 0; r < ROOM_BOX.length; r++)
+ {
+ int[] b = ROOM_BOX[r];
+ if (p.getX() >= b[0] && p.getX() <= b[2] && p.getY() >= b[1] && p.getY() <= b[3])
+ {
+ return r;
+ }
+ }
+ return -1;
+ }
+
+ private static int readCombo()
+ {
+ int combo = 0;
+ for (int i = 0; i < 6; i++)
+ {
+ if (Microbot.getVarbitValue(LEVER_VARBIT[i]) == 1)
+ {
+ combo |= (1 << i);
+ }
+ }
+ return combo;
+ }
+
+ /**
+ * BFS over (combo, room); returns the FIRST action on a shortest path to targetRoom, as
+ * {@code {0, leverIndex}} (pull that lever — we're in its room) or {@code {1, roomIndex}}
+ * (cross into that adjacent room). Null if already there / unreachable. Re-running this after
+ * every executed step (rather than committing to a full plan) is what keeps it correct when a
+ * lever pull re-locks doors.
+ */
+ private static int[] firstAction(int startCombo, int startRoom, int targetRoom)
+ {
+ if (startRoom < 0 || targetRoom < 0 || startRoom == targetRoom)
+ {
+ return null;
+ }
+ final int N = 64 * 7;
+ int[][] first = new int[N][];
+ boolean[] seen = new boolean[N];
+ int start = startCombo * 7 + startRoom;
+ seen[start] = true;
+ ArrayDeque q = new ArrayDeque<>();
+ q.add(start);
+ while (!q.isEmpty())
+ {
+ int s = q.poll();
+ int combo = s / 7, room = s % 7;
+ if (room == targetRoom)
+ {
+ return first[s];
+ }
+ for (int li = 0; li < 6; li++)
+ {
+ if (LEVER_ROOM[li] == room)
+ {
+ int ns = (combo ^ (1 << li)) * 7 + room;
+ if (!seen[ns])
+ {
+ seen[ns] = true;
+ first[ns] = (s == start) ? new int[]{0, li} : first[s];
+ q.add(ns);
+ }
+ }
+ }
+ for (int[] d : DOORS)
+ {
+ if ((room == d[0] || room == d[1]) && (combo & d[2]) == d[3])
+ {
+ int other = room == d[0] ? d[1] : d[0];
+ int ns = combo * 7 + other;
+ if (!seen[ns])
+ {
+ seen[ns] = true;
+ first[ns] = (s == start) ? new int[]{1, other} : first[s];
+ q.add(ns);
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * If {@code target} is inside the Draynor basement and the player is too, set the levers so the
+ * target's room is reachable. Blocking; safe to call every walk iteration (re-entrant guard).
+ */
+ public static void solveIfNeeded(WorldPoint target)
+ {
+ if (active || !isBasementTarget(target) || !Microbot.isLoggedIn())
+ {
+ return;
+ }
+ WorldPoint me = Rs2Player.getWorldLocation();
+ if (!isBasementTarget(me))
+ {
+ return; // not in the basement yet; let the walk descend first
+ }
+ int targetRoom = roomOf(target);
+ if (targetRoom < 0 || roomOf(me) == targetRoom)
+ {
+ return;
+ }
+ active = true;
+ try
+ {
+ log.info("[DraynorBasement] solving toward room {}", targetRoom);
+ // Re-plan after every step: read the current room + lever combo, take the first action
+ // on a shortest path, execute it. A fixed up-front plan strands the player because a
+ // lever pull can re-lock the doors around it; re-planning each step stays correct.
+ for (int step = 0; step < 80; step++)
+ {
+ int room = roomOf(Rs2Player.getWorldLocation());
+ if (room < 0 || room == targetRoom)
+ {
+ return;
+ }
+ int[] act = firstAction(readCombo(), room, targetRoom);
+ if (act == null)
+ {
+ log.warn("[DraynorBasement] no path from room {} to room {}", room, targetRoom);
+ return;
+ }
+ if (act[0] == 0)
+ {
+ // pull a lever in the current room; bail if the walk, click, or toggle fails so
+ // we don't re-plan the same (unchanged) action until the step cap.
+ int li = act[1];
+ if (!Rs2Walker.walkTo(LEVER_TILE[li], 1))
+ {
+ log.warn("[DraynorBasement] could not reach lever {}", (char) ('A' + li));
+ return;
+ }
+ final int vb = LEVER_VARBIT[li];
+ final int before = Microbot.getVarbitValue(vb);
+ if (!Rs2GameObject.interact(LEVER_OBJ[li], "Pull")
+ || !sleepUntil(() -> Microbot.getVarbitValue(vb) != before, LEVER_PULL_TIMEOUT_MS))
+ {
+ log.warn("[DraynorBasement] lever {} pull did not register", (char) ('A' + li));
+ return;
+ }
+ }
+ else
+ {
+ // cross into the next room; the unlocked door's transport opens + crosses it.
+ // Retry: the route can momentarily read UNREACHABLE before the transport set
+ // re-evaluates the varbits from the previous pull.
+ final int next = act[1];
+ boolean crossed = false;
+ for (int attempt = 0; attempt < 3 && !crossed; attempt++)
+ {
+ Rs2Walker.walkTo(ROOM_TILE[next], 1);
+ crossed = roomOf(Rs2Player.getWorldLocation()) == next
+ || sleepUntil(() -> roomOf(Rs2Player.getWorldLocation()) == next, 700);
+ }
+ if (!crossed)
+ {
+ log.warn("[DraynorBasement] could not cross into room {}; deferring to walker", next);
+ return;
+ }
+ }
+ sleepUntil(() -> !Rs2Player.isMoving(), 400); // let movement + transport state settle
+ }
+ }
+ finally
+ {
+ active = false;
+ }
+ }
+}
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/tileindicators/TileIndicatorsOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/tileindicators/TileIndicatorsOverlay.java
index 04acf99e3fa..3f567a449d4 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/tileindicators/TileIndicatorsOverlay.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/tileindicators/TileIndicatorsOverlay.java
@@ -36,6 +36,7 @@
import net.runelite.api.WorldView;
import net.runelite.api.coords.LocalPoint;
import net.runelite.api.coords.WorldPoint;
+import net.runelite.api.gameval.VarbitID;
import net.runelite.client.ui.overlay.Overlay;
import net.runelite.client.ui.overlay.OverlayLayer;
import net.runelite.client.ui.overlay.OverlayPosition;
@@ -59,6 +60,11 @@ private TileIndicatorsOverlay(Client client, TileIndicatorsConfig config)
@Override
public Dimension render(Graphics2D graphics)
{
+ if (client.getVarbitValue(VarbitID.CUTSCENE_STATUS) == 1)
+ {
+ return null;
+ }
+
if (config.highlightHoveredTile())
{
WorldView wv = client.getLocalPlayer().getWorldView();
diff --git a/runelite-client/src/main/java/net/runelite/client/ui/overlay/OverlayRenderer.java b/runelite-client/src/main/java/net/runelite/client/ui/overlay/OverlayRenderer.java
index 0a940377258..84a56afc439 100644
--- a/runelite-client/src/main/java/net/runelite/client/ui/overlay/OverlayRenderer.java
+++ b/runelite-client/src/main/java/net/runelite/client/ui/overlay/OverlayRenderer.java
@@ -52,6 +52,7 @@
import net.runelite.api.KeyCode;
import net.runelite.api.events.BeforeRender;
import net.runelite.api.events.FocusChanged;
+import net.runelite.api.events.MenuOpened;
import net.runelite.api.events.PostMenuSort;
import net.runelite.api.gameval.InterfaceID;
import net.runelite.api.gameval.VarbitID;
@@ -173,6 +174,15 @@ public void onFocusChanged(FocusChanged event)
}
}
+ @Subscribe
+ private void onMenuOpened(MenuOpened event)
+ {
+ if (client.isKeyPressed(KeyCode.KC_SHIFT) && curHoveredOverlay != null)
+ {
+ overlayManager.addOriginMenu(curHoveredOverlay);
+ }
+ }
+
@Subscribe
protected void onPostMenuSort(PostMenuSort event)
{
@@ -190,8 +200,6 @@ protected void onPostMenuSort(PostMenuSort event)
return;
}
- overlayManager.addOriginMenu(overlay);
-
List menuEntries = overlay.getMenuEntries();
if (menuEntries.isEmpty())
{
diff --git a/runelite-client/src/main/resources/item_variations.json b/runelite-client/src/main/resources/item_variations.json
index a0d1e0bea23..7ca35830253 100644
--- a/runelite-client/src/main/resources/item_variations.json
+++ b/runelite-client/src/main/resources/item_variations.json
@@ -191,14 +191,16 @@
],
"vial of water": [
227,
- 9086
+ 9086,
+ 33774
],
"vial": [
229,
2389,
2390,
23839,
- 23879
+ 23879,
+ 33699
],
"pestle and mortar": [
233,
@@ -1095,11 +1097,13 @@
],
"tooth half of key": [
985,
- 30105
+ 30105,
+ 33782
],
"loop half of key": [
987,
- 30107
+ 30107,
+ 33781
],
"white apron": [
1005,
@@ -5751,6 +5755,10 @@
7914,
7916
],
+ "pure essence": [
+ 7936,
+ 33776
+ ],
"fresh monkfish": [
7942,
7943
@@ -6677,6 +6685,10 @@
30803,
30805
],
+ "daeyalt ore": [
+ 9632,
+ 33777
+ ],
"castle sketch": [
9646,
9647,
@@ -6691,7 +6703,8 @@
9657,
9658,
22415,
- 24690
+ 24690,
+ 33715
],
"useless key": [
9662,
@@ -7313,6 +7326,10 @@
11057,
11059
],
+ "mace": [
+ 11058,
+ 33760
+ ],
"gold bracelet": [
11068,
11069
@@ -7384,6 +7401,10 @@
11173,
11174
],
+ "ancient symbol": [
+ 11181,
+ 33737
+ ],
"digsite pendant": [
11190,
11191,
@@ -9239,6 +9260,10 @@
20382,
20385
],
+ "spear": [
+ 20397,
+ 33758
+ ],
"elder chaos top": [
20517,
27115,
@@ -9954,7 +9979,8 @@
],
"vial of blood": [
22405,
- 22446
+ 22446,
+ 33775
],
"chain": [
22414,
@@ -10692,6 +10718,10 @@
24682,
25829
],
+ "blisterwood sickle": [
+ 24697,
+ 33713
+ ],
"ring of endurance": [
24735,
24736,
@@ -11716,6 +11746,10 @@
27281,
27509
],
+ "supplies": [
+ 27314,
+ 33630
+ ],
"nectar": [
27315,
27317,
@@ -12012,6 +12046,10 @@
28399,
28400
],
+ "grid note": [
+ 28424,
+ 33763
+ ],
"damp tablet": [
28439,
28440
@@ -12619,6 +12657,10 @@
30064,
30066
],
+ "moon key": [
+ 30109,
+ 33728
+ ],
"nasty token": [
30113,
30114,
@@ -13954,5 +13996,54 @@
33451,
33454,
33457
+ ],
+ "letvek": [
+ 33625,
+ 33825
+ ],
+ "maggot egg": [
+ 33665,
+ 33667,
+ 33669,
+ 33671,
+ 33673,
+ 33675
+ ],
+ "tarnished necklace": [
+ 33689,
+ 33691
+ ],
+ "drakan emblem": [
+ 33731,
+ 33732,
+ 33733
+ ],
+ "fancy gem": [
+ 33765,
+ 33766
+ ],
+ "putrid sticky potion": [
+ 33803,
+ 33804,
+ 33805,
+ 33806
+ ],
+ "foul chunky potion": [
+ 33807,
+ 33808,
+ 33809,
+ 33810
+ ],
+ "rancid slimy potion": [
+ 33811,
+ 33812,
+ 33813,
+ 33814
+ ],
+ "rank frothy potion": [
+ 33815,
+ 33816,
+ 33817,
+ 33818
]
}
\ No newline at end of file
diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/dangerous_tiles.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/dangerous_tiles.tsv
new file mode 100644
index 00000000000..2149505b463
--- /dev/null
+++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/dangerous_tiles.tsv
@@ -0,0 +1,14 @@
+# Tile Display info
+# Tiles occupied by aggressive NPCs that attack you while you path next to them.
+# When the "Avoid dangerous NPCs" pathfinder option is on, a high step penalty is applied
+# to every tile within 1 of these (the melee-aggro ring), so paths keep >=2 tiles away
+# when a reasonable detour exists. It is a penalty, not a block: at a true chokepoint the
+# path still goes through. Each entry is "x y plane".
+3103 3347 0 Draynor Manor undead tree
+3107 3342 0 Draynor Manor undead tree
+3107 3344 0 Draynor Manor undead tree
+3108 3346 0 Draynor Manor undead tree
+3111 3339 0 Draynor Manor undead tree
+3111 3348 0 Draynor Manor undead tree
+3115 3344 0 Draynor Manor undead tree
+3120 3344 0 Draynor Manor undead tree
diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv
index c84ca618c36..28fc5837254 100644
--- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv
+++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv
@@ -5264,4 +5264,23 @@
1575 3483 0 1574 3483 1 Climb-up;Rope ladder;28857
#Grimstone Dungeon
2902 10454 0 2902 10456 0 Climb;Uneven stone ledges;60120
-2902 10456 0 2902 10454 0 Climb;Uneven stone ledges;60120
\ No newline at end of file
+2902 10456 0 2902 10454 0 Climb;Uneven stone ledges;60120
+# Draynor Manor basement (Ernest the Chicken) lever-puzzle doors — gated by lever varbits 1788-1793
+3108 9757 0 3108 9759 0 Open;Door;144 1788=1;1789=1;1790=0;1793=0 1 Draynor basement puzzle door
+3108 9759 0 3108 9757 0 Open;Door;144 1788=1;1789=1;1790=0;1793=0 1 Draynor basement puzzle door
+3104 9760 0 3106 9760 0 Open;Door;139 1788=1;1790=0;1792=0;1793=0 1 Draynor basement puzzle door
+3106 9760 0 3104 9760 0 Open;Door;139 1788=1;1790=0;1792=0;1793=0 1 Draynor basement puzzle door
+3102 9757 0 3102 9759 0 Open;Door;145 1791=1;1792=0 1 Draynor basement puzzle door
+3102 9759 0 3102 9757 0 Open;Door;145 1791=1;1792=0 1 Draynor basement puzzle door
+3099 9760 0 3101 9760 0 Open;Door;140 1791=1;1792=0 1 Draynor basement puzzle door
+3101 9760 0 3099 9760 0 Open;Door;140 1791=1;1792=0 1 Draynor basement puzzle door
+3097 9762 0 3097 9764 0 Open;Door;143 1789=0;1791=1;1793=0 1 Draynor basement puzzle door
+3097 9764 0 3097 9762 0 Open;Door;143 1789=0;1791=1;1793=0 1 Draynor basement puzzle door
+3099 9765 0 3101 9765 0 Open;Door;138 1789=0;1791=1;1793=1 1 Draynor basement puzzle door
+3101 9765 0 3099 9765 0 Open;Door;138 1789=0;1791=1;1793=1 1 Draynor basement puzzle door
+3104 9765 0 3106 9765 0 Open;Door;137 1788=0;1792=1;1793=1 1 Draynor basement puzzle door
+3106 9765 0 3104 9765 0 Open;Door;137 1788=0;1792=1;1793=1 1 Draynor basement puzzle door
+3102 9762 0 3102 9764 0 Open;Door;142 1792=0;1793=1 1 Draynor basement puzzle door
+3102 9764 0 3102 9762 0 Open;Door;142 1792=0;1793=1 1 Draynor basement puzzle door
+3099 9755 0 3101 9755 0 Open;Door;141 1790=1;1791=1;1793=1 1 Draynor basement puzzle door
+3101 9755 0 3099 9755 0 Open;Door;141 1790=1;1791=1;1793=1 1 Draynor basement puzzle door
diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/util/reflection/menu-action-info.properties b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/util/reflection/menu-action-info.properties
index 1cc5f3cd9ee..c60091b7e87 100644
--- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/util/reflection/menu-action-info.properties
+++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/util/reflection/menu-action-info.properties
@@ -1,5 +1,5 @@
#Build-time pre-seed for MenuActionInfoCache (do not edit by hand; regenerate via :client:seedMenuActionInfo)
-#Wed Jun 24 20:37:30 CEST 2026
+#Wed Jul 01 06:39:11 CEST 2026
menuAction.garbageValue=-1612500198
menuAction.descriptor=(IIIIIILjava/lang/String;Ljava/lang/String;III)V
menuAction.methodName=fu
diff --git a/runelite-client/src/test/java/groundactionfixture/GroundItemActionFixture.java b/runelite-client/src/test/java/groundactionfixture/GroundItemActionFixture.java
index 8f778807a99..872773f05bb 100644
--- a/runelite-client/src/test/java/groundactionfixture/GroundItemActionFixture.java
+++ b/runelite-client/src/test/java/groundactionfixture/GroundItemActionFixture.java
@@ -17,12 +17,48 @@ public static Object createWithListInterface(String... actions) {
return new FakeListItem(actions);
}
+ public static Object createWithoutGroundActions() {
+ return new FakeItemWithoutGroundActions();
+ }
+
+ public static Object createWithNullGroundOps() {
+ return new FakeItem(null);
+ }
+
+ public static Object createWithFlexibleActions(String... actions) {
+ return new FakeFlexibleItem(new ArrayList<>(Arrays.asList(actions)));
+ }
+
+ public static Object createWithInvalidFlexibleActions() {
+ return new FakeFlexibleItem("not a list");
+ }
+
+ private static final class FakeItemWithoutGroundActions {
+ private final String name = "Amethyst";
+ }
+
private static final class FakeItem {
private static final MisleadingOuter STATIC_OUTER = new MisleadingOuter();
private final GroundOps groundOps;
private FakeItem(String[] actions) {
- groundOps = new GroundOps(actions);
+ groundOps = actions == null ? null : new GroundOps(actions);
+ }
+ }
+
+ private static final class FakeFlexibleItem {
+ private final FlexibleGroundOps groundOps;
+
+ private FakeFlexibleItem(Object actions) {
+ groundOps = new FlexibleGroundOps(actions);
+ }
+ }
+
+ private static final class FlexibleGroundOps {
+ private final Object actions;
+
+ private FlexibleGroundOps(Object actions) {
+ this.actions = actions;
}
}
diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/reflection/Rs2ReflectionGroundItemActionsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/reflection/Rs2ReflectionGroundItemActionsTest.java
index d3d7d439778..6aa09155c5c 100644
--- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/reflection/Rs2ReflectionGroundItemActionsTest.java
+++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/reflection/Rs2ReflectionGroundItemActionsTest.java
@@ -43,4 +43,49 @@ public void discoverySupportsListInterfaceAndNonArrayListImplementation() {
assertArrayEquals(new String[]{"Take", "Destroy"}, actions);
}
+
+ @Test
+ public void fallbackReturnsTakeWhenNoGroundActionStructureExists() {
+ String[] actions = Rs2Reflection.getGroundItemActionsFromObject(
+ GroundItemActionFixture.createWithoutGroundActions());
+
+ assertArrayEquals(new String[]{null, null, "Take", null, null}, actions);
+ }
+
+ @Test
+ public void fallbackReturnsTakeWhenGroundActionsAreEmpty() {
+ String[] actions = Rs2Reflection.getGroundItemActionsFromObject(
+ GroundItemActionFixture.create());
+
+ assertArrayEquals(new String[]{null, null, "Take", null, null}, actions);
+ }
+
+ @Test
+ public void fallbackReturnsTakeWhenGroundActionsAreNullOrBlank() {
+ String[] actions = Rs2Reflection.getGroundItemActionsFromObject(
+ GroundItemActionFixture.create(null, "", " "));
+
+ assertArrayEquals(new String[]{null, null, "Take", null, null}, actions);
+ }
+
+ @Test
+ public void cachedPathFallsBackWhenOuterFieldBecomesNull() {
+ Rs2Reflection.getGroundItemActionsFromObject(GroundItemActionFixture.create("Take"));
+
+ String[] actions = Rs2Reflection.getGroundItemActionsFromObject(
+ GroundItemActionFixture.createWithNullGroundOps());
+
+ assertArrayEquals(new String[]{null, null, "Take", null, null}, actions);
+ }
+
+ @Test
+ public void cachedPathFallsBackWhenCachedActionFieldIsNotAList() {
+ Rs2Reflection.getGroundItemActionsFromObject(
+ GroundItemActionFixture.createWithFlexibleActions("Take"));
+
+ String[] actions = Rs2Reflection.getGroundItemActionsFromObject(
+ GroundItemActionFixture.createWithInvalidFlexibleActions());
+
+ assertArrayEquals(new String[]{null, null, "Take", null, null}, actions);
+ }
}