From d35b299f1acd43d3114b1248df521f8c04b4aba5 Mon Sep 17 00:00:00 2001 From: Hazeybyte Date: Sun, 11 Oct 2026 11:00:16 +0100 Subject: [PATCH 01/26] feat(ui): remember the open console and sidebar tab across restarts The toggleable console's open state and the selected sidebar tab are persisted (clientConsoleOpen, and clientSidebarTab keyed by the nav button's tooltip) and restored on startup - the console in show(), the tab in addNavigation as plugins register their panels. Inserting the first sidebar tab makes JTabbedPane select index 0, which looks like the user picking a tab and unset the tab just restored, so saving is suppressed while navigation is being inserted or removed. That is what the old removingNavigation flag already did for removal, hence the rename to updatingNavigation. Co-Authored-By: Claude Opus 5 --- .../java/net/runelite/client/ui/ClientUI.java | 81 ++++++++++++++++--- 1 file changed, 69 insertions(+), 12 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java index 1572437f144..276716b99a1 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java @@ -103,6 +103,8 @@ public class ClientUI private static final String CONFIG_CLIENT_BOUNDS = "clientBounds"; private static final String CONFIG_CLIENT_MAXIMIZED = "clientMaximized"; private static final String CONFIG_CLIENT_SIDEBAR_CLOSED = "clientSidebarClosed"; + private static final String CONFIG_CLIENT_SIDEBAR_TAB = "clientSidebarTab"; + private static final String CONFIG_CLIENT_CONSOLE_OPEN = "clientConsoleOpen"; public static final BufferedImage ICON_128 = ImageUtil.loadImageResource(Microbot.class, "microbot_logo.png"); public static final BufferedImage ICON_16 = ImageUtil.loadImageResource(Microbot.class, "microbot_logo.png"); public static String proxyMessage = ""; @@ -164,6 +166,8 @@ public class ClientUI private BufferedImage consoleIconOpen; private BufferedImage consoleIconClosed; private boolean consoleVisible; + private boolean sidebarTabRestored; + private boolean updatingNavigation; @RequiredArgsConstructor private static class HistoryEntry @@ -202,7 +206,9 @@ private void onConfigChanged(ConfigChanged event) { if (!event.getGroup().equals(CONFIG_GROUP) || event.getKey().equals(CONFIG_CLIENT_MAXIMIZED) || - event.getKey().equals(CONFIG_CLIENT_BOUNDS)) + event.getKey().equals(CONFIG_CLIENT_BOUNDS) || + event.getKey().equals(CONFIG_CLIENT_SIDEBAR_TAB) || + event.getKey().equals(CONFIG_CLIENT_CONSOLE_OPEN)) { return; } @@ -226,12 +232,28 @@ void addNavigation(NavigationButton navBtn) final int TAB_SIZE = 16; Icon icon = new ImageIcon(ImageUtil.resizeImage(navBtn.getIcon(), TAB_SIZE, TAB_SIZE)); - sidebar.insertTab(null, icon, navBtn.getPanel().getWrappedPanel(), navBtn.getTooltip(), - sidebarEntries.headSet(navBtn).size()); - // insertTab changes the selected index when the first tab is inserted, avoid this - if (sidebar.getTabCount() == 1) + updatingNavigation = true; + try { - sidebar.setSelectedIndex(-1); + sidebar.insertTab(null, icon, navBtn.getPanel().getWrappedPanel(), navBtn.getTooltip(), + sidebarEntries.headSet(navBtn).size()); + // insertTab changes the selected index when the first tab is inserted, avoid this + if (sidebar.getTabCount() == 1) + { + sidebar.setSelectedIndex(-1); + } + + if (!sidebarTabRestored + && sidebar.getSelectedIndex() < 0 + && navBtn.getTooltip().equals(configManager.getConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB))) + { + sidebarTabRestored = true; + sidebar.setSelectedIndex(sidebarEntries.headSet(navBtn).size()); + } + } + finally + { + updatingNavigation = false; } } @@ -245,14 +267,22 @@ void removeNavigation(NavigationButton navBtn) { boolean closingOpenTab = !selectedTabHistory.isEmpty() && selectedTabHistory.getLast().navBtn == navBtn; selectedTabHistory.removeIf(it -> it.navBtn == navBtn); - sidebar.remove(navBtn.getPanel().getWrappedPanel()); - if (closingOpenTab) + updatingNavigation = true; + try { - HistoryEntry entry = selectedTabHistory.isEmpty() - ? new HistoryEntry(true, null) - : selectedTabHistory.removeLast(); + sidebar.remove(navBtn.getPanel().getWrappedPanel()); + if (closingOpenTab) + { + HistoryEntry entry = selectedTabHistory.isEmpty() + ? new HistoryEntry(true, null) + : selectedTabHistory.removeLast(); - openPanel(entry.navBtn, entry.sidebarOpen); + openPanel(entry.navBtn, entry.sidebarOpen); + } + } + finally + { + updatingNavigation = false; } } @@ -420,6 +450,19 @@ public void componentMoved(ComponentEvent e) selectedTab = newSelectedTab; + if (!updatingNavigation) + { + sidebarTabRestored = true; + if (newSelectedTab == null) + { + configManager.unsetConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB); + } + else + { + configManager.setConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB, newSelectedTab.getTooltip()); + } + } + if (sidebar.isVisible()) { pushHistory(); @@ -630,6 +673,12 @@ else if (OSType.getOSType() == OSType.MacOS && SystemInfo.isMacFullWindowContent // Update config updateFrameConfig(false); + // Reopen the console if it was open when the client was last closed + if (configManager.getConfiguration(CONFIG_GROUP, CONFIG_CLIENT_CONSOLE_OPEN, Boolean.class) == Boolean.TRUE) + { + setConsoleVisible(true); + } + // Close sidebar if the config closed state is set if (configManager.getConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_CLOSED, Boolean.class) == Boolean.TRUE) { @@ -1187,6 +1236,14 @@ private void setConsoleVisible(boolean visible) } consoleVisible = visible; + if (visible) + { + configManager.setConfiguration(CONFIG_GROUP, CONFIG_CLIENT_CONSOLE_OPEN, true); + } + else + { + configManager.unsetConfiguration(CONFIG_GROUP, CONFIG_CLIENT_CONSOLE_OPEN); + } clientPanel.setConsoleVisible(visible); updateConsoleToggleButton(); From 6e3684705110dc85c0c7d0223c5a8cfab842a819 Mon Sep 17 00:00:00 2001 From: Hazeybyte Date: Sun, 11 Oct 2026 11:00:16 +0100 Subject: [PATCH 02/26] feat(ui): auto-scroll the console to the latest logs The console opens on the latest log line and follows new ones as they arrive. Scrolling up stops it, so output can be read while a script keeps logging, and scrolling back to the bottom resumes following. Whether to follow is decided by what the user does, never by measuring the scroll position: under a flood the view trails the document, and reading that as "the user scrolled up" leaves the console parked while lines keep arriving. Appends are buffered into one insert per event queue pass, since a line per pass schedules a layout and repaint each, and a busy script logs faster than the queue can run them. Co-Authored-By: Claude Opus 5 --- .../runelite/client/ui/LogConsolePanel.java | 207 +++++++++++++++++- 1 file changed, 196 insertions(+), 11 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java b/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java index f6fe67040d3..0f191175d78 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java @@ -5,12 +5,19 @@ package net.runelite.client.ui; import java.awt.*; +import java.awt.event.HierarchyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionAdapter; +import java.awt.geom.Rectangle2D; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import javax.swing.JPanel; +import javax.swing.JScrollBar; import javax.swing.JScrollPane; +import javax.swing.JViewport; import javax.swing.JTextArea; import javax.swing.SwingUtilities; import javax.swing.border.MatteBorder; @@ -22,9 +29,22 @@ final class LogConsolePanel extends JPanel { private static final int MAX_CHARACTERS = 100_000; + // Trimming shifts the view, so it is deferred while the user is scrolled up - but not forever. + private static final int MAX_CHARACTERS_SCROLLED_UP = 400_000; private static final int PREFERRED_HEIGHT = 160; + private static final int BOTTOM_TOLERANCE_PX = 4; + // enough passes for a starved layout to catch up, few enough to give up on an off-screen console + private static final int MAX_SNAP_ATTEMPTS = 20; private final JTextArea textArea = new JTextArea(); + private final JScrollPane scrollPane = new JScrollPane(textArea); + + private final StringBuilder pending = new StringBuilder(); + private boolean flushScheduled; + + private boolean followTail = true; + private boolean snapQueued; + private int snapAttempts; LogConsolePanel() { @@ -42,13 +62,117 @@ final class LogConsolePanel extends JPanel textArea.setBorder(null); DefaultCaret caret = (DefaultCaret) textArea.getCaret(); - caret.setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE); + caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE); - JScrollPane scrollPane = new JScrollPane(textArea); scrollPane.setBorder(null); add(scrollPane, BorderLayout.CENTER); + + // Following is decided by what the user does, never by measuring the view: under a flood the + // scroll position trails the document, which reads as "the user scrolled up". + scrollPane.addMouseWheelListener(ev -> reconsiderFollowingTail()); + + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + vertical.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent ev) + { + followTail = false; + } + + @Override + public void mouseReleased(MouseEvent ev) + { + reconsiderFollowingTail(); + } + }); + vertical.addMouseMotionListener(new MouseMotionAdapter() + { + @Override + public void mouseDragged(MouseEvent ev) + { + followTail = false; + } + }); + + // opening the console lands on the newest line, wherever the view was left + addHierarchyListener(ev -> + { + if ((ev.getChangeFlags() & HierarchyEvent.SHOWING_CHANGED) != 0 && isShowing()) + { + scrollToBottom(); + } + }); + } + + void scrollToBottom() + { + followTail = true; + snapAttempts = 0; + queueSnapToBottom(); + } + + // Snapping waits for the next pass: until the insert's revalidation has run the viewport still + // reports the pre-insert view size and would snap a line short of the newest one. + private void queueSnapToBottom() + { + if (snapQueued) + { + return; + } + + snapQueued = true; + SwingUtilities.invokeLater(() -> + { + snapQueued = false; + if (followTail) + { + snapToBottom(); + } + }); + } + + private void snapToBottom() + { + int end = textArea.getDocument().getLength(); + textArea.setCaretPosition(end); + + Rectangle2D newestLine; + try + { + // the viewport's laid-out size trails the document, so ask the text view, which reads the + // newest line's position from the document itself + newestLine = textArea.modelToView2D(end); + } + catch (BadLocationException ex) + { + return; + } + + if (newestLine == null) + { + return; + } + + textArea.scrollRectToVisible(newestLine.getBounds()); + + JViewport viewport = scrollPane.getViewport(); + + // The scroll is clamped to the size the text area was last laid out at, so under load it can + // land short, and nothing else would correct that - keep trying while the layout catches up. + boolean reachedNewestLine = newestLine.getMaxY() + <= viewport.getViewPosition().y + viewport.getExtentSize().height + 1; + if (reachedNewestLine || ++snapAttempts > MAX_SNAP_ATTEMPTS) + { + snapAttempts = 0; + return; + } + + queueSnapToBottom(); } + // One batch per event queue pass. A line per pass is what breaks the console under load: each + // insert schedules its own layout, and a busy script logs faster than the queue can run them. void append(String text) { if (text == null || text.isEmpty()) @@ -56,19 +180,52 @@ void append(String text) return; } - String sanitized = text.replace("\r", ""); - if (SwingUtilities.isEventDispatchThread()) + synchronized (pending) { - appendOnEdt(sanitized); + pending.append(text, 0, text.length()); + + int excess = pending.length() - MAX_CHARACTERS_SCROLLED_UP; + if (excess > 0) + { + // the oldest buffered lines would be trimmed out of the document on arrival anyway + pending.delete(0, excess); + } + + if (flushScheduled) + { + return; + } + flushScheduled = true; } - else + + SwingUtilities.invokeLater(this::flushPending); + } + + private void flushPending() + { + String text; + synchronized (pending) { - SwingUtilities.invokeLater(() -> appendOnEdt(sanitized)); + flushScheduled = false; + if (pending.length() == 0) + { + return; + } + + text = pending.toString(); + pending.setLength(0); } + + appendOnEdt(text.replace("\r", "")); } void clear() { + synchronized (pending) + { + pending.setLength(0); + } + if (SwingUtilities.isEventDispatchThread()) { textArea.setText(""); @@ -89,10 +246,18 @@ private void appendOnEdt(String text) Document document = textArea.getDocument(); try { + boolean atBottom = followTail; int length = document.getLength(); document.insertString(length, text, null); - trimIfNecessary(); - textArea.setCaretPosition(document.getLength()); + trimIfNecessary(atBottom); + if (atBottom) + { + textArea.setCaretPosition(document.getLength()); + // NEVER_UPDATE stops the caret dragging the view, so move it ourselves; the attempt + // budget is per batch, or a flood that outruns the layout would exhaust it for good. + snapAttempts = 0; + queueSnapToBottom(); + } } catch (BadLocationException ex) { @@ -100,10 +265,30 @@ private void appendOnEdt(String text) } } - private void trimIfNecessary() + // Deferred so the user's gesture has reached the scrollbar before it is read. + private void reconsiderFollowingTail() + { + SwingUtilities.invokeLater(() -> + { + followTail = isScrolledToBottom(); + if (followTail) + { + snapAttempts = 0; + queueSnapToBottom(); + } + }); + } + + private boolean isScrolledToBottom() + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + return vertical.getValue() + vertical.getVisibleAmount() >= vertical.getMaximum() - BOTTOM_TOLERANCE_PX; + } + + private void trimIfNecessary(boolean atBottom) { Document document = textArea.getDocument(); - int excess = document.getLength() - MAX_CHARACTERS; + int excess = document.getLength() - (atBottom ? MAX_CHARACTERS : MAX_CHARACTERS_SCROLLED_UP); if (excess <= 0) { return; From 658abbfefc7f4a99124642eef0e8f6a238e45d50 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:05:35 +0200 Subject: [PATCH 03/26] feat(world-hop): pace hops against an estimated per-IP hop allowance Jagex limits hops and logins per IP; going over it returns "Too many login attempts" and LoginLockout then pauses all logins for 5-30 min. Nothing stopped scripts from draining that allowance in the first place. HopBudget models the allowance as a token bucket (default 390 tokens, 93 regained per hour, 20 kept in reserve for logins). Every HOPPING and LOGGING_IN transition spends a token, including manual hops. Microbot.hopToWorld waits for the next token once the allowance reaches the reserve, so hop-heavy scripts slow down instead of getting locked out. The client thread and the Swing thread get false instead of blocking. State is kept in ~/.runelite/microbot-hop-budget.properties, file-locked and keyed by SOCKS proxy host, so it survives restarts and is shared by every client on the same IP. A real lockout sets it to zero so the estimate corrects itself. The numbers are configurable under "World hop limit" in the Microbot settings, and /login on the agent server reports hopsAvailable and nextHopInMs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/AGENT_SERVER.md | 4 + docs/ARCHITECTURE.md | 1 + .../client/plugins/microbot/Microbot.java | 33 +++ .../plugins/microbot/MicrobotConfig.java | 65 +++++ .../plugins/microbot/MicrobotPlugin.java | 39 ++- .../agentserver/handler/LoginHandler.java | 6 + .../microbot/util/security/HopBudget.java | 232 ++++++++++++++++++ .../microbot/util/security/LoginManager.java | 1 + .../plugins/microbot/MicrobotConfigTest.java | 13 + .../microbot/MicrobotPluginHopCountTest.java | 29 +++ .../microbot/util/security/HopBudgetTest.java | 164 +++++++++++++ 11 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java diff --git a/docs/AGENT_SERVER.md b/docs/AGENT_SERVER.md index 65598af260b..a435707f8eb 100644 --- a/docs/AGENT_SERVER.md +++ b/docs/AGENT_SERVER.md @@ -492,6 +492,8 @@ When the client is on the login screen, the response includes `loginIndex` and ` "loggedIn": true, "gameState": "LOGGED_IN", "loginAttemptActive": false, + "hopsAvailable": 372, + "nextHopInMs": 0, "loginDurationMs": 120000, "activeProfile": { "name": "player@email.com", @@ -514,6 +516,8 @@ When the client is on the login screen, the response includes `loginIndex` and ` `loginIndex` and `loginError` are only present when `gameState` is `LOGIN_SCREEN`. When logged in, these fields are omitted. +`hopsAvailable` and `nextHopInMs` come from the world hop limiter (`HopBudget`). They are present whenever "Pace world hops" is enabled in the Microbot settings. `nextHopInMs` is how long `Microbot.hopToWorld` would wait right now, and is 0 while the allowance is above the login reserve. + #### POST /login Triggers a login using the active profile. **Blocks by default** until the login succeeds or fails, returning a definitive result. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 90a58f51440..204451aee4c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,6 +31,7 @@ - `BlockingEventManager` uses exponential backoff when no events validate to reduce overhead; events are re-queued if execution fails. - Default Gradle `test` tasks are disabled in `runelite-client/build.gradle.kts`; use explicit tasks such as `:client:runUnitTests`, `:client:runTests`, `:client:runDebugTests`, `:client:runIntegrationTest`, or `:client:runClientThreadScanner`. - World hopping short-circuits if the player is interacting or already hopping; confirmation widget auto-click handled in `Microbot.hopToWorld`. +- `Microbot.hopToWorld` paces hops against `HopBudget`, an estimate of Jagex's per-IP hop/login allowance (default 390 tokens, 93 regained per hour, 20 kept in reserve for logins). Every `HOPPING` and `LOGGING_IN` transition spends a token, including manual hops. When the allowance is down to the reserve, script threads wait for the next token; the client thread and Swing thread get `false` instead. The state lives in `~/.runelite/microbot-hop-budget.properties`, keyed by SOCKS proxy host, so it survives restarts and is shared by every client on the same IP. A "Too many login attempts" lockout (login index 33) sets it to zero. ## References - Queryable API guide: `../runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/QUERYABLE_API.md` diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java index d8b98fdfee2..2ff220dcae8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java @@ -55,6 +55,7 @@ import net.runelite.client.plugins.microbot.api.playerstate.Rs2PlayerStateCache; import net.runelite.client.plugins.microbot.api.tileitem.Rs2TileItemCache; import net.runelite.client.plugins.microbot.api.tileobject.Rs2TileObjectCache; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import net.runelite.client.plugins.microbot.util.security.LoginManager; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import net.runelite.client.ui.overlay.infobox.InfoBoxManager; @@ -376,6 +377,9 @@ public static boolean hopToWorld(int worldNumber) { log.error("Can't hop world, already trying to hop"); return false; } + if (Microbot.getClient().getWorld() != worldNumber && !awaitHopBudget(worldNumber)) { + return false; + } final net.runelite.api.World[] target = new net.runelite.api.World[1]; boolean prepared = Microbot.getClientThread().runOnClientThreadOptional(() -> { if (Rs2Combat.inCombat()) { @@ -464,6 +468,35 @@ public static boolean hopToWorld(int worldNumber) { return success; } + static boolean awaitHopBudget(int worldNumber) { + HopBudget budget = HopBudget.getInstance(); + long waitMs = budget.millisUntilHopAllowed(); + if (waitMs <= 0) { + return true; + } + if (Microbot.getClient().isClientThread() || SwingUtilities.isEventDispatchThread()) { + log.warn("Hop limit reached - not hopping to world {}, next hop allowed in {}s", + worldNumber, TimeUnit.MILLISECONDS.toSeconds(waitMs)); + return false; + } + log.info("Hop limit reached - waiting {}s before hopping to world {}", + TimeUnit.MILLISECONDS.toSeconds(waitMs), worldNumber); + String previousStatus = status; + try { + while (waitMs > 0) { + status = "Hop limit - next hop in " + TimeUnit.MILLISECONDS.toSeconds(waitMs + 999) + "s"; + Thread.sleep(Math.min(waitMs, 1000L)); + waitMs = budget.millisUntilHopAllowed(); + } + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } finally { + status = previousStatus; + } + } + static boolean hopViaWorldSwitcher(net.runelite.api.World rsWorld, int loadTimeoutMs) { if (!isWorldSwitcherLoaded() && !sleepUntil(Microbot::isWorldSwitcherLoaded, Microbot::openWorldSwitcher, loadTimeoutMs, WORLD_SWITCHER_REOPEN_INTERVAL_MS)) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java index dbf20321206..1070bb0023f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java @@ -7,6 +7,8 @@ import net.runelite.client.config.ConfigGroup; import net.runelite.client.config.ConfigItem; import net.runelite.client.config.ConfigSection; +import net.runelite.client.config.Range; +import net.runelite.client.plugins.microbot.util.security.HopBudget; @ConfigGroup(MicrobotConfig.configGroup) public interface MicrobotConfig extends Config @@ -272,4 +274,67 @@ default int inputIdleResumeMs() { return 1800; } + + @ConfigSection( + name = "World hop limit", + description = "Pace world hops so the per-IP login limit (\"Too many login attempts\") is never reached", + position = 4 + ) + String hopLimitSection = "hopLimitSection"; + + String keyEnableHopLimit = "enableHopLimit"; + @ConfigItem( + keyName = keyEnableHopLimit, + name = "Pace world hops", + description = "Wait before a scripted world hop when the estimated hop allowance runs low, instead of getting locked out. " + + "Shared by every client on the same IP or proxy.", + position = 0, + section = hopLimitSection + ) + default boolean enableHopLimit() + { + return true; + } + + String keyHopLimitCapacity = "hopLimitCapacity"; + @Range(min = 1, max = 1000) + @ConfigItem( + keyName = keyHopLimitCapacity, + name = "Hop allowance", + description = "Estimated number of hops and logins Jagex allows before locking the IP out", + position = 1, + section = hopLimitSection + ) + default int hopLimitCapacity() + { + return HopBudget.DEFAULT_CAPACITY; + } + + String keyHopLimitRefillPerHour = "hopLimitRefillPerHour"; + @Range(min = 1, max = 1000) + @ConfigItem( + keyName = keyHopLimitRefillPerHour, + name = "Hops regained per hour", + description = "Estimated number of hops the allowance regains every hour", + position = 2, + section = hopLimitSection + ) + default int hopLimitRefillPerHour() + { + return HopBudget.DEFAULT_REFILL_PER_HOUR; + } + + String keyHopLimitReserve = "hopLimitReserve"; + @Range(min = 0, max = 200) + @ConfigItem( + keyName = keyHopLimitReserve, + name = "Reserve for logins", + description = "Hops never spent on world hopping, kept for logging back in after breaks or disconnects", + position = 3, + section = hopLimitSection + ) + default int hopLimitReserve() + { + return HopBudget.DEFAULT_RESERVE; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index 1ff435b816e..fc2e3aeb48e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -6,6 +6,7 @@ import net.runelite.api.*; import net.runelite.api.events.*; import net.runelite.api.gameval.InventoryID; +import net.runelite.client.RuneLite; import net.runelite.client.RuneLiteProperties; import net.runelite.client.config.ConfigManager; import net.runelite.client.eventbus.EventBus; @@ -40,6 +41,7 @@ import net.runelite.client.plugins.microbot.util.shop.Rs2Shop; import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import net.runelite.client.plugins.microbot.util.security.LoginManager; import net.runelite.client.ui.ClientToolbar; import net.runelite.client.ui.NavigationButton; @@ -58,6 +60,7 @@ import java.awt.AWTException; import java.awt.Rectangle; import java.awt.image.BufferedImage; +import java.io.File; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -66,6 +69,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; @PluginDescriptor( name = PluginDescriptor.Default + "Microbot", description = "Microbot", @@ -101,6 +105,9 @@ public class MicrobotPlugin extends Plugin @Inject private MicrobotConfig microbotConfig; + @Inject + private ScheduledExecutorService executor; + private MicrobotTopLevelConfigPanel topLevelConfigPanel; private NavigationButton navButton; @@ -177,6 +184,8 @@ protected void startUp() throws AWTException InputArbiter.setDisabled(microbotConfig.disableInputYielding()); InputArbiter.setMotionThresholdPx(microbotConfig.inputMotionThresholdPx()); InputArbiter.setIdleResumeMs(microbotConfig.inputIdleResumeMs()); + HopBudget.getInstance().persistTo(new File(RuneLite.RUNELITE_DIR, HopBudget.FILE_NAME).toPath(), HopBudget.networkKey()); + applyHopBudgetConfig(); CanvasInputListener.attach(); Microbot.enableAutoRunOn = microbotConfig.enableAutoRunOn(); Microbot.useStaminaPotsIfNeeded = microbotConfig.useStaminaPotsIfNeeded(); @@ -323,7 +332,10 @@ private int[] getShopContainerIds() @Subscribe public void onGameStateChanged(GameStateChanged gameStateChanged) { - + if (isLoginServerRequest(LoginManager.getLastKnownGameState(), gameStateChanged.getGameState())) + { + executor.execute(() -> HopBudget.getInstance().recordHop()); + } if (gameStateChanged.getGameState() == GameState.LOGGED_IN) { // Region-based login detection logic @@ -359,6 +371,25 @@ public void onGameStateChanged(GameStateChanged gameStateChanged) LoginManager.setLastKnownGameState(gameStateChanged.getGameState()); } + static boolean isLoginServerRequest(GameState previous, GameState next) + { + if (next == GameState.HOPPING) + { + return previous != GameState.HOPPING; + } + return next == GameState.LOGGING_IN + && (previous == GameState.LOGIN_SCREEN || previous == GameState.LOGIN_SCREEN_AUTHENTICATOR); + } + + private void applyHopBudgetConfig() + { + HopBudget.getInstance().configure( + microbotConfig.enableHopLimit(), + microbotConfig.hopLimitCapacity(), + microbotConfig.hopLimitRefillPerHour(), + microbotConfig.hopLimitReserve()); + } + private static EnumSet normalizeWorldTypesForProfileComparison(EnumSet rawTypes) { EnumSet normalized = rawTypes == null @@ -518,6 +549,12 @@ public void onConfigChanged(ConfigChanged ev) case MicrobotConfig.keyInputIdleResumeMs: InputArbiter.setIdleResumeMs(microbotConfig.inputIdleResumeMs()); break; + case MicrobotConfig.keyEnableHopLimit: + case MicrobotConfig.keyHopLimitCapacity: + case MicrobotConfig.keyHopLimitRefillPerHour: + case MicrobotConfig.keyHopLimitReserve: + applyHopBudgetConfig(); + break; case MicrobotConfig.keyEnableGameChatLogging: case MicrobotConfig.keyGameChatLogPattern: case MicrobotConfig.keyGameChatLogLevel: diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java index eeab31b4a88..252b1c10cec 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java @@ -7,6 +7,7 @@ import net.runelite.api.GameState; import net.runelite.client.config.ConfigProfile; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import net.runelite.client.plugins.microbot.util.security.LoginLockout; import net.runelite.client.plugins.microbot.util.security.LoginManager; @@ -79,6 +80,11 @@ private Map buildStatusMap() { if (loginCooldownMs > 0) { result.put("loginCooldownMs", loginCooldownMs); } + HopBudget hopBudget = HopBudget.getInstance(); + if (hopBudget.isEnabled()) { + result.put("hopsAvailable", hopBudget.available()); + result.put("nextHopInMs", hopBudget.millisUntilHopAllowed()); + } if (Microbot.isLoggedIn()) { long durationMs = LoginManager.getLoginDuration().toMillis(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java new file mode 100644 index 00000000000..10d2776a5ef --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java @@ -0,0 +1,232 @@ +package net.runelite.client.plugins.microbot.util.security; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; +import java.util.function.UnaryOperator; + +@Slf4j +public final class HopBudget { + public static final int DEFAULT_CAPACITY = 390; + public static final int DEFAULT_REFILL_PER_HOUR = 93; + public static final int DEFAULT_RESERVE = 20; + public static final String FILE_NAME = "microbot-hop-budget.properties"; + + private static final HopBudget INSTANCE = new HopBudget(System::currentTimeMillis, new MemoryStore()); + + private final LongSupplier clock; + private volatile Store store; + private volatile boolean enabled = true; + private volatile int capacity = DEFAULT_CAPACITY; + private volatile int refillPerHour = DEFAULT_REFILL_PER_HOUR; + private volatile int reserve = DEFAULT_RESERVE; + + HopBudget(LongSupplier clock, Store store) { + this.clock = clock; + this.store = store; + } + + public static HopBudget getInstance() { + return INSTANCE; + } + + public static String networkKey() { + String proxyHost = System.getProperty("socksProxyHost"); + if (proxyHost == null || proxyHost.isBlank()) { + return "direct"; + } + return "proxy." + proxyHost.trim().replaceAll("[^A-Za-z0-9._-]", "_"); + } + + public void persistTo(Path file, String networkKey) { + store = new FileStore(file, networkKey, store); + } + + public void configure(boolean enabled, int capacity, int refillPerHour, int reserve) { + this.capacity = Math.max(1, capacity); + this.refillPerHour = Math.max(0, refillPerHour); + this.reserve = Math.min(Math.max(0, reserve), this.capacity - 1); + this.enabled = enabled; + } + + public boolean isEnabled() { + return enabled; + } + + public int available() { + return (int) Math.floor(current().tokens); + } + + public long millisUntilHopAllowed() { + if (!enabled) { + return 0L; + } + double missing = reserve + 1 - current().tokens; + if (missing <= 0) { + return 0L; + } + double perMilli = refillPerMilli(); + if (perMilli <= 0) { + return Long.MAX_VALUE; + } + return Math.max(1L, (long) Math.ceil(missing / perMilli)); + } + + public void recordHop() { + long now = clock.getAsLong(); + State next = store.update(state -> { + State refilled = refill(state, now); + return new State(Math.max(0, refilled.tokens - 1), now); + }); + if (enabled && next.tokens < reserve + 1) { + log.info("Hop limit nearly reached - {} hops left, keeping {} in reserve for logins", + (int) Math.floor(next.tokens), reserve); + } + } + + public void recordLockout() { + long now = clock.getAsLong(); + store.update(state -> new State(0, now)); + } + + private State current() { + return refill(store.update(UnaryOperator.identity()), clock.getAsLong()); + } + + private State refill(State state, long now) { + if (state == null) { + return new State(capacity, now); + } + long elapsed = Math.max(0L, now - state.updatedAt); + double tokens = Math.min(capacity, state.tokens + elapsed * refillPerMilli()); + return new State(tokens, now); + } + + private double refillPerMilli() { + return refillPerHour / (double) TimeUnit.HOURS.toMillis(1); + } + + static final class State { + final double tokens; + final long updatedAt; + + State(double tokens, long updatedAt) { + this.tokens = tokens; + this.updatedAt = updatedAt; + } + } + + interface Store { + State update(UnaryOperator change); + } + + static final class MemoryStore implements Store { + private State state; + + MemoryStore() { + } + + MemoryStore(State state) { + this.state = state; + } + + @Override + public synchronized State update(UnaryOperator change) { + state = change.apply(state); + return state; + } + } + + static final class FileStore implements Store { + private final Path file; + private final String tokensKey; + private final String updatedAtKey; + private final MemoryStore fallback; + private boolean warned; + + FileStore(Path file, String networkKey, Store seed) { + this.file = file; + this.tokensKey = networkKey + ".tokens"; + this.updatedAtKey = networkKey + ".updatedAt"; + this.fallback = new MemoryStore(seed.update(UnaryOperator.identity())); + } + + @Override + public synchronized State update(UnaryOperator change) { + boolean interrupted = Thread.interrupted(); + try { + Files.createDirectories(file.toAbsolutePath().getParent()); + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.CREATE, + StandardOpenOption.READ, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + Properties properties = read(channel); + State current = parse(properties); + State next = change.apply(current); + if (next != current && next != null) { + properties.setProperty(tokensKey, Double.toString(next.tokens)); + properties.setProperty(updatedAtKey, Long.toString(next.updatedAt)); + write(channel, properties); + } + fallback.update(state -> next); + return next; + } + } catch (IOException | RuntimeException e) { + if (!warned) { + warned = true; + log.warn("Hop budget file {} unavailable, tracking hops for this client only", file, e); + } + return fallback.update(change); + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + private State parse(Properties properties) { + String tokens = properties.getProperty(tokensKey); + String updatedAt = properties.getProperty(updatedAtKey); + if (tokens == null || updatedAt == null) { + return null; + } + try { + return new State(Double.parseDouble(tokens), Long.parseLong(updatedAt)); + } catch (NumberFormatException e) { + return null; + } + } + + private static Properties read(FileChannel channel) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate((int) channel.size()); + while (buffer.hasRemaining() && channel.read(buffer, buffer.position()) > 0) { + } + Properties properties = new Properties(); + properties.load(new StringReader(new String(buffer.array(), 0, buffer.position(), StandardCharsets.UTF_8))); + return properties; + } + + private static void write(FileChannel channel, Properties properties) throws IOException { + StringWriter writer = new StringWriter(); + properties.store(writer, null); + ByteBuffer buffer = ByteBuffer.wrap(writer.toString().getBytes(StandardCharsets.UTF_8)); + channel.truncate(0); + long position = 0; + while (buffer.hasRemaining()) { + position += channel.write(buffer, position); + } + channel.force(false); + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java index da9f6b9ac83..12d048a073a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java @@ -243,6 +243,7 @@ public static boolean checkLoginLockout() { return lockout.isActive(); } if (lockout.recordLockout()) { + HopBudget.getInstance().recordLockout(); log.warn("Too many login attempts - pausing all logins for {}s (lockout #{})", TimeUnit.MILLISECONDS.toSeconds(lockout.remainingMillis()), lockout.getConsecutiveLockouts()); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java index 930770f6712..1d04928de5c 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java @@ -1,7 +1,9 @@ package net.runelite.client.plugins.microbot; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class MicrobotConfigTest @@ -13,4 +15,15 @@ public void inputYieldingIsDisabledByDefault() assertTrue(config.disableInputYielding()); } + + @Test + public void hopLimitIsOnByDefaultWithTheEstimatedAllowance() + { + MicrobotConfig config = new MicrobotConfig() { }; + + assertTrue(config.enableHopLimit()); + assertEquals(HopBudget.DEFAULT_CAPACITY, config.hopLimitCapacity()); + assertEquals(HopBudget.DEFAULT_REFILL_PER_HOUR, config.hopLimitRefillPerHour()); + assertEquals(HopBudget.DEFAULT_RESERVE, config.hopLimitReserve()); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java new file mode 100644 index 00000000000..d7de1e4696b --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java @@ -0,0 +1,29 @@ +package net.runelite.client.plugins.microbot; + +import net.runelite.api.GameState; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class MicrobotPluginHopCountTest +{ + @Test + public void countsEachHopAndEachLoginAttemptOnce() + { + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGGED_IN, GameState.HOPPING)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGIN_SCREEN, GameState.LOGGING_IN)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGIN_SCREEN_AUTHENTICATOR, GameState.LOGGING_IN)); + } + + @Test + public void ignoresTransitionsThatDoNotReachTheLoginServer() + { + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.HOPPING, GameState.HOPPING)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.HOPPING, GameState.LOGGING_IN)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.LOGGING_IN, GameState.LOGGING_IN)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.HOPPING, GameState.LOADING)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.LOADING, GameState.LOGGED_IN)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.LOGGED_IN, GameState.LOGIN_SCREEN)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java new file mode 100644 index 00000000000..88f4fe095c0 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java @@ -0,0 +1,164 @@ +package net.runelite.client.plugins.microbot.util.security; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.*; + +public class HopBudgetTest { + private static final long ONE_TOKEN_MILLIS = (long) Math.ceil(TimeUnit.HOURS.toMillis(1) / 93.0); + + @Rule public TemporaryFolder folder = new TemporaryFolder(); + + private final AtomicLong clock = new AtomicLong(1_000_000L); + + private HopBudget budget(int capacity, int reserve) { + HopBudget budget = new HopBudget(clock::get, new HopBudget.MemoryStore()); + budget.configure(true, capacity, 93, reserve); + return budget; + } + + private static void hop(HopBudget budget, int times) { + for (int i = 0; i < times; i++) { + budget.recordHop(); + } + } + + @Test public void freshBudgetStartsFullAndAllowsHops() { + HopBudget budget = budget(390, 20); + assertEquals(390, budget.available()); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void hopsStopAtTheReserveAndResumeAfterOneTokenRefills() { + HopBudget budget = budget(30, 5); + hop(budget, 24); + assertEquals(6, budget.available()); + assertEquals(0L, budget.millisUntilHopAllowed()); + hop(budget, 1); + assertEquals(5, budget.available()); + long wait = budget.millisUntilHopAllowed(); + assertTrue(Math.abs(wait - ONE_TOKEN_MILLIS) <= 1); + clock.addAndGet(wait - 2); + assertTrue(budget.millisUntilHopAllowed() > 0); + clock.addAndGet(2); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void refillNeverExceedsCapacity() { + HopBudget budget = budget(30, 5); + hop(budget, 10); + clock.addAndGet(TimeUnit.HOURS.toMillis(5)); + assertEquals(30, budget.available()); + } + + @Test public void lockoutEmptiesTheBudget() { + HopBudget budget = budget(390, 20); + budget.recordLockout(); + assertEquals(0, budget.available()); + assertTrue(budget.millisUntilHopAllowed() >= 20 * ONE_TOKEN_MILLIS); + } + + @Test public void disabledBudgetNeverWaitsButStillCounts() { + HopBudget budget = budget(10, 2); + budget.configure(false, 10, 93, 2); + hop(budget, 10); + assertEquals(0, budget.available()); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void reserveIsClampedBelowCapacity() { + HopBudget budget = budget(5, 50); + assertEquals(0L, budget.millisUntilHopAllowed()); + hop(budget, 1); + assertTrue(budget.millisUntilHopAllowed() > 0); + } + + @Test public void clockGoingBackwardsDoesNotDrainOrRefill() { + HopBudget budget = budget(30, 5); + hop(budget, 10); + clock.addAndGet(-TimeUnit.HOURS.toMillis(1)); + assertEquals(20, budget.available()); + } + + @Test public void fileStoreIsSharedBetweenClientsOnTheSameNetwork() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget first = budget(30, 5); + first.persistTo(file, "direct"); + HopBudget second = budget(30, 5); + second.persistTo(file, "direct"); + HopBudget proxied = budget(30, 5); + proxied.persistTo(file, "proxy.10.0.0.1"); + + hop(first, 4); + hop(second, 3); + assertEquals(23, first.available()); + assertEquals(23, second.available()); + assertEquals(30, proxied.available()); + + HopBudget restarted = budget(30, 5); + restarted.persistTo(file, "direct"); + assertEquals(23, restarted.available()); + } + + @Test public void interruptedThreadStillUsesTheSharedFileAndKeepsItsInterrupt() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget first = budget(30, 5); + first.persistTo(file, "direct"); + HopBudget second = budget(30, 5); + second.persistTo(file, "direct"); + + Thread.currentThread().interrupt(); + try { + first.recordHop(); + assertEquals(29, first.available()); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + assertEquals(29, second.available()); + } + + @Test public void corruptFileIsTreatedAsAFullBudget() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + Files.write(file, "direct.tokens=abc\ndirect.updatedAt=\n".getBytes(StandardCharsets.UTF_8)); + HopBudget budget = budget(30, 5); + budget.persistTo(file, "direct"); + assertEquals(30, budget.available()); + budget.recordHop(); + assertEquals(29, budget.available()); + } + + @Test public void unwritableFileFallsBackToThisClientOnly() throws Exception { + Path directory = folder.newFolder("blocked").toPath(); + HopBudget budget = budget(30, 5); + hop(budget, 2); + budget.persistTo(directory, "direct"); + assertEquals(28, budget.available()); + budget.recordHop(); + assertEquals(27, budget.available()); + } + + @Test public void networkKeyFollowsTheSocksProxy() { + String previous = System.getProperty("socksProxyHost"); + try { + System.clearProperty("socksProxyHost"); + assertEquals("direct", HopBudget.networkKey()); + System.setProperty("socksProxyHost", "proxy.example.com"); + assertEquals("proxy.proxy.example.com", HopBudget.networkKey()); + System.setProperty("socksProxyHost", "fe80::1"); + assertEquals("proxy.fe80__1", HopBudget.networkKey()); + } finally { + if (previous == null) { + System.clearProperty("socksProxyHost"); + } else { + System.setProperty("socksProxyHost", previous); + } + } + } +} From 798168f66c9c2592f36a6ce07ad2a40519efb589 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:21:06 +0200 Subject: [PATCH 04/26] fix(world-hop): close the F2P membership prompts that block the world switcher F2P accounts holding a bond get a chatbox prompt "Want your members-only features back?" (F2PBondRedeem). While it is open the world switcher never loads, so every Microbot.hopToWorld call failed with "World switcher did not load in time" until the prompt was closed by hand. MembershipPromptEvent is a BlockingEvent that clicks Close on F2PBondRedeem and on its sibling upsell prompts BondPrompt and MembershipBenefitsPromptHop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/BlockingEventManager.java | 1 + .../util/events/MembershipPromptEvent.java | 56 +++++++ .../events/MembershipPromptEventTest.java | 154 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEvent.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEventTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java index 8a738ccaee2..d7e19d3e1ad 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java @@ -59,6 +59,7 @@ public BlockingEventManager() blockingEvents.add(new BankJagexPopupEvent()); blockingEvents.add(new PluginPauseEvent()); blockingEvents.add(new EnjoyRSChatboxEvent()); + blockingEvents.add(new MembershipPromptEvent()); blockingEvents.add(new DisableWorldSwitcherConfirmationEvent()); blockingEvents.add(new HideRoofsEvent()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEvent.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEvent.java new file mode 100644 index 00000000000..b3be87e3ca5 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEvent.java @@ -0,0 +1,56 @@ +package net.runelite.client.plugins.microbot.util.events; + +import net.runelite.api.Client; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.BlockingEvent; +import net.runelite.client.plugins.microbot.BlockingEventPriority; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.Global; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; + +public class MembershipPromptEvent implements BlockingEvent { + + static final int[][] PROMPTS = { + {InterfaceID.F2PBondRedeem.UNIVERSE, InterfaceID.F2PBondRedeem.CLOSE}, + {InterfaceID.BondPrompt.UNIVERSE, InterfaceID.BondPrompt.CLOSE}, + {InterfaceID.MembershipBenefitsPromptHop.UNIVERSE, InterfaceID.MembershipBenefitsPromptHop.CLOSE_BUTTON}, + }; + + @Override + public boolean validate() { + return findCloseButton() != null; + } + + @Override + public boolean execute() { + Widget closeButton = findCloseButton(); + if (closeButton == null) return true; + + Rs2Widget.clickWidget(closeButton); + return Global.sleepUntil(() -> findCloseButton() == null, 10000); + } + + @Override + public BlockingEventPriority priority() { + return BlockingEventPriority.NORMAL; + } + + private static Widget findCloseButton() { + if (!Microbot.isLoggedIn()) return null; + return Microbot.getClientThread() + .runOnClientThreadOptional(() -> findCloseButton(Microbot.getClient())) + .orElse(null); + } + + static Widget findCloseButton(Client client) { + if (client == null) return null; + for (int[] prompt : PROMPTS) { + Widget root = client.getWidget(prompt[0]); + if (root == null || root.isHidden()) continue; + Widget close = client.getWidget(prompt[1]); + if (close != null && !close.isHidden()) return close; + } + return null; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEventTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEventTest.java new file mode 100644 index 00000000000..a982e875b2c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEventTest.java @@ -0,0 +1,154 @@ +package net.runelite.client.plugins.microbot.util.events; + +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.concurrent.Callable; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class MembershipPromptEventTest +{ + private Client client; + private ClientThread clientThread; + private Object previousClient; + private Object previousClientThread; + + @Before + public void before() throws Exception + { + client = mock(Client.class); + clientThread = mock(ClientThread.class); + previousClient = swapStatic("client", client); + previousClientThread = swapStatic("clientThread", clientThread); + } + + @After + public void after() throws Exception + { + swapStatic("client", previousClient); + swapStatic("clientThread", previousClientThread); + } + + @Test + public void doesNotTouchClientThreadWhenNotLoggedIn() + { + for (GameState state : new GameState[]{GameState.LOGIN_SCREEN, GameState.LOGGING_IN, GameState.LOADING, GameState.HOPPING}) + { + when(client.getGameState()).thenReturn(state); + assertFalse(new MembershipPromptEvent().validate()); + } + verifyNoInteractions(clientThread); + } + + @Test + public void clientThreadTimeoutIsNotVisible() + { + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + when(clientThread.runOnClientThreadOptional(any())).thenReturn(Optional.empty()); + + assertFalse(new MembershipPromptEvent().validate()); + } + + @Test + public void validatesWhenBondRedeemPromptIsOpen() throws Exception + { + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + runClientThreadInline(); + visible(InterfaceID.F2PBondRedeem.UNIVERSE); + visible(InterfaceID.F2PBondRedeem.CLOSE); + + assertTrue(new MembershipPromptEvent().validate()); + } + + @Test + public void findsTheCloseButtonOfEachPrompt() + { + for (int[] prompt : MembershipPromptEvent.PROMPTS) + { + Client promptClient = mock(Client.class); + Widget root = widget(false); + Widget close = widget(false); + when(promptClient.getWidget(prompt[0])).thenReturn(root); + when(promptClient.getWidget(prompt[1])).thenReturn(close); + + assertSame(close, MembershipPromptEvent.findCloseButton(promptClient)); + } + } + + @Test + public void ignoresMissingOrHiddenPrompts() + { + assertNull(MembershipPromptEvent.findCloseButton(null)); + assertNull(MembershipPromptEvent.findCloseButton(client)); + + Widget hiddenRoot = widget(true); + Widget visibleRoot = widget(false); + Widget hiddenClose = widget(true); + Widget visibleClose = widget(false); + + when(client.getWidget(InterfaceID.F2PBondRedeem.UNIVERSE)).thenReturn(hiddenRoot); + when(client.getWidget(InterfaceID.F2PBondRedeem.CLOSE)).thenReturn(visibleClose); + assertNull(MembershipPromptEvent.findCloseButton(client)); + + when(client.getWidget(InterfaceID.F2PBondRedeem.UNIVERSE)).thenReturn(visibleRoot); + when(client.getWidget(InterfaceID.F2PBondRedeem.CLOSE)).thenReturn(hiddenClose); + assertNull(MembershipPromptEvent.findCloseButton(client)); + } + + @Test + public void executeWithoutPromptCompletesWithoutWaiting() + { + when(client.getGameState()).thenReturn(GameState.LOGIN_SCREEN); + + assertTrue(new MembershipPromptEvent().execute()); + verify(client, never()).getWidget(anyInt()); + } + + @SuppressWarnings("unchecked") + private void runClientThreadInline() throws Exception + { + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> + Optional.ofNullable(((Callable) invocation.getArgument(0)).call())); + } + + private void visible(int componentId) + { + Widget widget = widget(false); + when(client.getWidget(componentId)).thenReturn(widget); + } + + private static Widget widget(boolean hidden) + { + Widget widget = mock(Widget.class); + when(widget.isHidden()).thenReturn(hidden); + return widget; + } + + private static Object swapStatic(String name, Object value) throws Exception + { + Field field = Microbot.class.getDeclaredField(name); + field.setAccessible(true); + Object previous = field.get(null); + field.set(null, value); + return previous; + } +} From de3e08b1f02799cf1dbbda14030e573df5c78512 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:21:07 +0200 Subject: [PATCH 05/26] fix(world-hop): count the first login after a client launch The tracked previous game state starts as UNKNOWN because MicrobotPlugin starts after the client already reached the login screen, so the first login after a launch went UNKNOWN -> LOGGING_IN and was not counted. Count every LOGGING_IN except one coming straight from HOPPING or LOGGING_IN, which also counts reconnects after a lost connection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../net/runelite/client/plugins/microbot/MicrobotPlugin.java | 3 ++- .../client/plugins/microbot/MicrobotPluginHopCountTest.java | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index fc2e3aeb48e..e14b5f104e6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -378,7 +378,8 @@ static boolean isLoginServerRequest(GameState previous, GameState next) return previous != GameState.HOPPING; } return next == GameState.LOGGING_IN - && (previous == GameState.LOGIN_SCREEN || previous == GameState.LOGIN_SCREEN_AUTHENTICATOR); + && previous != GameState.HOPPING + && previous != GameState.LOGGING_IN; } private void applyHopBudgetConfig() diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java index d7de1e4696b..a7fa7c4b5a5 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java @@ -14,6 +14,8 @@ public void countsEachHopAndEachLoginAttemptOnce() assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGGED_IN, GameState.HOPPING)); assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGIN_SCREEN, GameState.LOGGING_IN)); assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGIN_SCREEN_AUTHENTICATOR, GameState.LOGGING_IN)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.UNKNOWN, GameState.LOGGING_IN)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.CONNECTION_LOST, GameState.LOGGING_IN)); } @Test From e263c4f6d878fb2c3087165fca8fe6a0b61e7aea Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:53:41 +0200 Subject: [PATCH 06/26] fix(world-hop): harden hop budget keying, file writes and long waits - Key the shared budget by SOCKS host, port and a hash of the proxy username, so rotating-port and session-user gateways get one budget per exit IP instead of sharing one. - Repair a stored timestamp from the future so refill resumes after the clock goes backwards. - Treat non-finite or negative stored tokens as corrupt instead of waiting forever. - Clamp the refill rate to at least 1 per hour. - Write the budget to a temporary file and move it into place under a separate lock file, so a crash mid-write can't leave a short number. - Check the calling thread before reading the file; the client and Swing threads use this client's last known state. - While waiting for a hop, stop if the player is logged out and press backspace before the idle logout, as Rs2Reflection already does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/ARCHITECTURE.md | 2 +- .../client/plugins/microbot/Microbot.java | 26 +++- .../microbot/util/security/HopBudget.java | 113 +++++++++++++----- .../client/proxy/ProxyConfiguration.java | 7 ++ .../microbot/util/security/HopBudgetTest.java | 94 +++++++++++++-- 5 files changed, 198 insertions(+), 44 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 204451aee4c..052b89a0177 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,7 +31,7 @@ - `BlockingEventManager` uses exponential backoff when no events validate to reduce overhead; events are re-queued if execution fails. - Default Gradle `test` tasks are disabled in `runelite-client/build.gradle.kts`; use explicit tasks such as `:client:runUnitTests`, `:client:runTests`, `:client:runDebugTests`, `:client:runIntegrationTest`, or `:client:runClientThreadScanner`. - World hopping short-circuits if the player is interacting or already hopping; confirmation widget auto-click handled in `Microbot.hopToWorld`. -- `Microbot.hopToWorld` paces hops against `HopBudget`, an estimate of Jagex's per-IP hop/login allowance (default 390 tokens, 93 regained per hour, 20 kept in reserve for logins). Every `HOPPING` and `LOGGING_IN` transition spends a token, including manual hops. When the allowance is down to the reserve, script threads wait for the next token; the client thread and Swing thread get `false` instead. The state lives in `~/.runelite/microbot-hop-budget.properties`, keyed by SOCKS proxy host, so it survives restarts and is shared by every client on the same IP. A "Too many login attempts" lockout (login index 33) sets it to zero. +- `Microbot.hopToWorld` paces hops against `HopBudget`, an estimate of Jagex's per-IP hop/login allowance (default 390 tokens, 93 regained per hour, 20 kept in reserve for logins). Every `HOPPING` and `LOGGING_IN` transition spends a token, including manual hops. When the allowance is down to the reserve, script threads wait for the next token, stop waiting if the player is logged out, and press a harmless key so a long wait doesn't trigger the idle logout. The client thread and Swing thread get `false` instead and only check this client's last known state, without touching the file. The state lives in `~/.runelite/microbot-hop-budget.properties`, keyed by SOCKS proxy host, port and a hash of the proxy username, so it survives restarts and is shared by every client on the same exit IP. Writes go to a temporary file that replaces the old one, with `microbot-hop-budget.properties.lock` held while reading and writing. A "Too many login attempts" lockout (login index 33) sets it to zero. ## References - Queryable API guide: `../runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/QUERYABLE_API.md` diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java index 2ff220dcae8..b5983b5ff88 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java @@ -42,6 +42,7 @@ import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; import net.runelite.client.plugins.microbot.util.shop.Rs2Shop; import net.runelite.client.plugins.microbot.util.item.Rs2ItemManager; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; import net.runelite.client.plugins.microbot.util.math.Rs2Random; import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; @@ -70,6 +71,7 @@ import javax.swing.Timer; import javax.swing.*; import java.awt.*; +import java.awt.event.KeyEvent; import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; @@ -174,6 +176,8 @@ public class Microbot { private static net.runelite.api.World quickHopTargetWorld; private static final int WORLD_SWITCHER_LOAD_TIMEOUT_MS = 5000; private static final int WORLD_SWITCHER_REOPEN_INTERVAL_MS = 600; + private static final int HOP_WAIT_MIN_IDLE_TICKS = 5000; + private static final int HOP_WAIT_MAX_IDLE_TICKS = 10000; /** * PouchScript is injected in the main MicrobotPlugin as it's being used in multiple scripts */ @@ -470,20 +474,34 @@ public static boolean hopToWorld(int worldNumber) { static boolean awaitHopBudget(int worldNumber) { HopBudget budget = HopBudget.getInstance(); - long waitMs = budget.millisUntilHopAllowed(); - if (waitMs <= 0) { - return true; - } if (Microbot.getClient().isClientThread() || SwingUtilities.isEventDispatchThread()) { + long waitMs = budget.cachedMillisUntilHopAllowed(); + if (waitMs <= 0) { + return true; + } log.warn("Hop limit reached - not hopping to world {}, next hop allowed in {}s", worldNumber, TimeUnit.MILLISECONDS.toSeconds(waitMs)); return false; } + long waitMs = budget.millisUntilHopAllowed(); + if (waitMs <= 0) { + return true; + } log.info("Hop limit reached - waiting {}s before hopping to world {}", TimeUnit.MILLISECONDS.toSeconds(waitMs), worldNumber); String previousStatus = status; + int idleLimit = Rs2Random.between(HOP_WAIT_MIN_IDLE_TICKS, HOP_WAIT_MAX_IDLE_TICKS); try { while (waitMs > 0) { + if (!isLoggedIn()) { + log.info("Logged out while waiting for the hop limit - not hopping to world {}", worldNumber); + return false; + } + Client client = Microbot.getClient(); + if (Math.min(client.getKeyboardIdleTicks(), client.getMouseIdleTicks()) > idleLimit) { + Rs2Keyboard.keyPress(KeyEvent.VK_BACK_SPACE); + idleLimit = Rs2Random.between(HOP_WAIT_MIN_IDLE_TICKS, HOP_WAIT_MAX_IDLE_TICKS); + } status = "Hop limit - next hop in " + TimeUnit.MILLISECONDS.toSeconds(waitMs + 999) + "s"; Thread.sleep(Math.min(waitMs, 1000L)); waitMs = budget.millisUntilHopAllowed(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java index 10d2776a5ef..fb93abb75b1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java @@ -1,6 +1,7 @@ package net.runelite.client.plugins.microbot.util.security; import lombok.extern.slf4j.Slf4j; +import net.runelite.client.proxy.ProxyConfiguration; import java.io.IOException; import java.io.StringReader; @@ -9,8 +10,11 @@ import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.util.Properties; import java.util.concurrent.TimeUnit; @@ -43,11 +47,26 @@ public static HopBudget getInstance() { } public static String networkKey() { - String proxyHost = System.getProperty("socksProxyHost"); - if (proxyHost == null || proxyHost.isBlank()) { + return networkKey(System.getProperty("socksProxyHost"), System.getProperty("socksProxyPort"), + ProxyConfiguration.getUsername()); + } + + static String networkKey(String host, String port, String username) { + if (host == null || host.isBlank()) { return "direct"; } - return "proxy." + proxyHost.trim().replaceAll("[^A-Za-z0-9._-]", "_"); + StringBuilder key = new StringBuilder("proxy.").append(sanitize(host)); + if (port != null && !port.isBlank()) { + key.append('_').append(sanitize(port)); + } + if (username != null && !username.isEmpty()) { + key.append('.').append(Integer.toHexString(username.hashCode())); + } + return key.toString(); + } + + private static String sanitize(String value) { + return value.trim().replaceAll("[^A-Za-z0-9._-]", "_"); } public void persistTo(Path file, String networkKey) { @@ -56,7 +75,7 @@ public void persistTo(Path file, String networkKey) { public void configure(boolean enabled, int capacity, int refillPerHour, int reserve) { this.capacity = Math.max(1, capacity); - this.refillPerHour = Math.max(0, refillPerHour); + this.refillPerHour = Math.max(1, refillPerHour); this.reserve = Math.min(Math.max(0, reserve), this.capacity - 1); this.enabled = enabled; } @@ -70,18 +89,19 @@ public int available() { } public long millisUntilHopAllowed() { - if (!enabled) { - return 0L; - } - double missing = reserve + 1 - current().tokens; + return enabled ? millisUntil(current()) : 0L; + } + + public long cachedMillisUntilHopAllowed() { + return enabled ? millisUntil(refill(store.peek(), clock.getAsLong())) : 0L; + } + + private long millisUntil(State state) { + double missing = reserve + 1 - state.tokens; if (missing <= 0) { return 0L; } - double perMilli = refillPerMilli(); - if (perMilli <= 0) { - return Long.MAX_VALUE; - } - return Math.max(1L, (long) Math.ceil(missing / perMilli)); + return Math.max(1L, (long) Math.ceil(missing / refillPerMilli())); } public void recordHop() { @@ -102,7 +122,9 @@ public void recordLockout() { } private State current() { - return refill(store.update(UnaryOperator.identity()), clock.getAsLong()); + long now = clock.getAsLong(); + return refill(store.update(state -> state != null && state.updatedAt > now + ? new State(state.tokens, now) : state), now); } private State refill(State state, long now) { @@ -130,6 +152,8 @@ static final class State { interface Store { State update(UnaryOperator change); + + State peek(); } static final class MemoryStore implements Store { @@ -147,10 +171,16 @@ public synchronized State update(UnaryOperator change) { state = change.apply(state); return state; } + + @Override + public synchronized State peek() { + return state; + } } static final class FileStore implements Store { private final Path file; + private final Path lockFile; private final String tokensKey; private final String updatedAtKey; private final MemoryStore fallback; @@ -158,6 +188,7 @@ static final class FileStore implements Store { FileStore(Path file, String networkKey, Store seed) { this.file = file; + this.lockFile = file.resolveSibling(file.getFileName() + ".lock"); this.tokensKey = networkKey + ".tokens"; this.updatedAtKey = networkKey + ".updatedAt"; this.fallback = new MemoryStore(seed.update(UnaryOperator.identity())); @@ -168,16 +199,16 @@ public synchronized State update(UnaryOperator change) { boolean interrupted = Thread.interrupted(); try { Files.createDirectories(file.toAbsolutePath().getParent()); - try (FileChannel channel = FileChannel.open(file, StandardOpenOption.CREATE, - StandardOpenOption.READ, StandardOpenOption.WRITE); + try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, + StandardOpenOption.WRITE); FileLock ignored = channel.lock()) { - Properties properties = read(channel); + Properties properties = read(); State current = parse(properties); State next = change.apply(current); if (next != current && next != null) { properties.setProperty(tokensKey, Double.toString(next.tokens)); properties.setProperty(updatedAtKey, Long.toString(next.updatedAt)); - write(channel, properties); + write(properties); } fallback.update(state -> next); return next; @@ -195,6 +226,11 @@ public synchronized State update(UnaryOperator change) { } } + @Override + public State peek() { + return fallback.peek(); + } + private State parse(Properties properties) { String tokens = properties.getProperty(tokensKey); String updatedAt = properties.getProperty(updatedAtKey); @@ -202,31 +238,48 @@ private State parse(Properties properties) { return null; } try { - return new State(Double.parseDouble(tokens), Long.parseLong(updatedAt)); + double parsedTokens = Double.parseDouble(tokens); + if (!Double.isFinite(parsedTokens) || parsedTokens < 0) { + return null; + } + return new State(parsedTokens, Long.parseLong(updatedAt)); } catch (NumberFormatException e) { return null; } } - private static Properties read(FileChannel channel) throws IOException { - ByteBuffer buffer = ByteBuffer.allocate((int) channel.size()); - while (buffer.hasRemaining() && channel.read(buffer, buffer.position()) > 0) { - } + private Properties read() throws IOException { Properties properties = new Properties(); - properties.load(new StringReader(new String(buffer.array(), 0, buffer.position(), StandardCharsets.UTF_8))); + byte[] bytes; + try { + bytes = Files.readAllBytes(file); + } catch (NoSuchFileException e) { + return properties; + } + properties.load(new StringReader(new String(bytes, StandardCharsets.UTF_8))); return properties; } - private static void write(FileChannel channel, Properties properties) throws IOException { + private void write(Properties properties) throws IOException { StringWriter writer = new StringWriter(); properties.store(writer, null); ByteBuffer buffer = ByteBuffer.wrap(writer.toString().getBytes(StandardCharsets.UTF_8)); - channel.truncate(0); - long position = 0; - while (buffer.hasRemaining()) { - position += channel.write(buffer, position); + Path temp = Files.createTempFile(file.toAbsolutePath().getParent(), file.getFileName().toString(), ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(false); + } + try { + Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temp); } - channel.force(false); } } } diff --git a/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java b/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java index a2c97757e4d..bb39a3d964e 100644 --- a/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java +++ b/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java @@ -15,6 +15,12 @@ */ public class ProxyConfiguration { + private static volatile String username; + + public static String getUsername() { + return username; + } + /** * Sets up the proxy configuration based on the provided options. * @param options @@ -46,6 +52,7 @@ public static void setupProxy(OptionSet options, ArgumentAcceptingOptionSpec files = Files.list(folder.getRoot().toPath())) { + assertEquals(java.util.Set.of(HopBudget.FILE_NAME, HopBudget.FILE_NAME + ".lock"), + files.map(path -> path.getFileName().toString()).collect(java.util.stream.Collectors.toSet())); + } + assertTrue(new String(Files.readAllBytes(file), StandardCharsets.UTF_8).contains("direct.tokens=27")); + } + + @Test public void storedTimestampInTheFutureIsRepairedSoRefillResumes() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget budget = budget(30, 5); + budget.persistTo(file, "direct"); + hop(budget, 25); + clock.addAndGet(-TimeUnit.HOURS.toMillis(1)); + assertTrue(budget.millisUntilHopAllowed() > 0); + clock.addAndGet(ONE_TOKEN_MILLIS); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void zeroRefillRateIsClampedSoWaitsStayFinite() { + HopBudget budget = new HopBudget(clock::get, new HopBudget.MemoryStore()); + budget.configure(true, 30, 0, 5); + hop(budget, 25); + assertTrue(Math.abs(budget.millisUntilHopAllowed() - TimeUnit.HOURS.toMillis(1)) <= 1); + } + + @Test public void cachedWaitUsesThisClientsLastKnownState() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget first = budget(30, 5); + first.persistTo(file, "direct"); + HopBudget second = budget(30, 5); + second.persistTo(file, "direct"); + assertEquals(0L, first.cachedMillisUntilHopAllowed()); + hop(second, 25); + assertEquals(0L, first.cachedMillisUntilHopAllowed()); + assertTrue(first.millisUntilHopAllowed() > 0); + assertTrue(first.cachedMillisUntilHopAllowed() > 0); + first.configure(false, 30, 93, 5); + assertEquals(0L, first.cachedMillisUntilHopAllowed()); + } + @Test public void unwritableFileFallsBackToThisClientOnly() throws Exception { Path directory = folder.newFolder("blocked").toPath(); HopBudget budget = budget(30, 5); @@ -145,20 +202,39 @@ private static void hop(HopBudget budget, int times) { } @Test public void networkKeyFollowsTheSocksProxy() { - String previous = System.getProperty("socksProxyHost"); + String previousHost = System.getProperty("socksProxyHost"); + String previousPort = System.getProperty("socksProxyPort"); try { System.clearProperty("socksProxyHost"); + System.clearProperty("socksProxyPort"); assertEquals("direct", HopBudget.networkKey()); System.setProperty("socksProxyHost", "proxy.example.com"); - assertEquals("proxy.proxy.example.com", HopBudget.networkKey()); - System.setProperty("socksProxyHost", "fe80::1"); - assertEquals("proxy.fe80__1", HopBudget.networkKey()); + System.setProperty("socksProxyPort", "10001"); + assertEquals("proxy.proxy.example.com_10001", HopBudget.networkKey()); } finally { - if (previous == null) { - System.clearProperty("socksProxyHost"); - } else { - System.setProperty("socksProxyHost", previous); - } + restore("socksProxyHost", previousHost); + restore("socksProxyPort", previousPort); + } + } + + @Test public void networkKeySeparatesProxyPortsAndUsersWithoutStoringTheUsername() { + assertEquals("direct", HopBudget.networkKey(null, "1080", "user")); + assertEquals("direct", HopBudget.networkKey(" ", "1080", "user")); + assertEquals("proxy.fe80__1_1080", HopBudget.networkKey("fe80::1", "1080", null)); + assertNotEquals(HopBudget.networkKey("gate.example.com", "10001", null), + HopBudget.networkKey("gate.example.com", "10002", null)); + String first = HopBudget.networkKey("gate.example.com", "7000", "customer-session-a"); + String second = HopBudget.networkKey("gate.example.com", "7000", "customer-session-b"); + assertNotEquals(first, second); + assertFalse(first.contains("customer")); + assertTrue(first.matches("[A-Za-z0-9._-]+")); + } + + private static void restore(String property, String value) { + if (value == null) { + System.clearProperty(property); + } else { + System.setProperty(property, value); } } } From 7abcc563b121c9fd01b42fe02a48152468262f5a Mon Sep 17 00:00:00 2001 From: droplugins <50-combat-pit@icloud.com> Date: Mon, 12 Oct 2026 14:04:04 -0700 Subject: [PATCH 07/26] fix(supplies): let owning scripts confirm terminal logout before stopping --- docs/entity-guides/items.md | 5 +++ .../client/plugins/microbot/Microbot.java | 11 ++++++ .../microbot/util/Rs2InventorySetup.java | 11 ++++++ .../microbot/util/player/Rs2Player.java | 7 ++++ .../util/player/TerminalLogoutPrompt.java | 35 +++++++++++++++++++ .../util/InventorySetupTerminalOwnerTest.java | 24 +++++++++++++ .../util/player/TerminalLogoutPromptTest.java | 33 +++++++++++++++++ 7 files changed, 126 insertions(+) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index b882157b179..01059dfc0cd 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -272,3 +272,8 @@ An ordinary logout can be a break or world hop, so it must keep normal AutoLogin **Pattern to follow:** The helper arms suppression before closing the bank and reports whether logout was observed or only requested. Uncompleted requests expire after 15 seconds using a monotonic clock, including when no game-state event occurs; observed terminal logouts do not expire. Ordinary `logout()` is unchanged; scripts that use it for terminal failure must explicitly adopt the new API. **Defensive check:** Run `AutoLoginSuppressionTest`, `AutoLoginPluginSupplyStopTest`, `ScriptStartedTest`, `TerminalSupplyLogoutTest`, and `InventorySetupSupplyLogoutTest`. Verify live that a shortage stays logged out and restarting a gameplay script permits login. + + +## Terminal supply stops need observed logout + +An owning script can set `withMissingSupplyHandler` on its Inventory Setup loader to keep its scheduled loop alive while `TerminalLogoutPrompt` closes the bank and requests logout. Do not show a blocking shortage notification or cancel that loop until `LOGGED_OUT` is observed. The default loader policy stays unchanged when no handler is supplied; ordinary breaks and world hops do not arm terminal Auto Login suppression. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java index 2ff220dcae8..e7c9906dd4b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java @@ -523,6 +523,17 @@ private static boolean isWorldSwitcherLoaded() { () -> Microbot.getClient().getWidget(InterfaceID.Worldswitcher.BUTTONS) != null).orElse(false); } + public static String supplyMessage(String supplies) { + String item = supplies == null ? "required supplies" : supplies.trim(); + item = item.replaceFirst("(?i)^ran out of supplies:\\s*", "") + .replaceFirst("(?i)^(?:out of|missing|no|not enough|insufficient)\\s+", "") + .replaceFirst("(?i)\\s+(?:not found|found|missing)(?: (?:in|from) (?:the )?bank)?[.!]?$", "") + .replaceFirst("(?i)\\s+(?:remaining|in bank)[.!]?$", "") + .replaceFirst("[.!]+$", ""); + if (item.isEmpty()) item = "required supplies"; + return "Ran out of supplies: " + item; + } + public static void showMessage(String message) { try { Runnable messageRunnable = () -> diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java index b0a98b7c4a2..3d0fb00d995 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java @@ -202,6 +202,13 @@ private static void addRuneLikePouchRows(List out, List missingSupplyHandler; + + /** Retains the owning loop until its terminal logout is observed. */ + public Rs2InventorySetup withMissingSupplyHandler(java.util.function.Consumer handler) { + missingSupplyHandler = Objects.requireNonNull(handler, "handler"); + return this; + } /** * Selects behavior for confirmed required-supply shortages on this loader only. @@ -921,6 +928,10 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) { } private void stopForMissingRequiredSupply(String itemName) { + if (missingSupplyHandler != null) { + missingSupplyHandler.accept(itemName); + return; + } if (missingSupplyPolicy == MissingSupplyPolicy.PAUSE_ALL_SCRIPTS) { Microbot.pauseAllScripts.compareAndSet(false, true); return; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java index 3caa11536b8..126a2dadb00 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java @@ -476,6 +476,13 @@ public static boolean isRunEnabled() { public enum TerminalLogoutState { BANK_CLOSING, LOGOUT_REQUESTED, LOGGED_OUT, INPUT_BLOCKED } + /** One off-client-thread terminal logout step with AutoLogin suppression. */ + public static TerminalLogoutState logoutWithoutAutoLoginStep() { + if (Microbot.getClient().isClientThread()) return TerminalLogoutState.INPUT_BLOCKED; + Microbot.getEventBus().post(new AutoLoginSuppressionRequest()); + return terminalLogoutInputStep(); + } + private static TerminalLogoutState terminalLogoutInputStep() { if (!Microbot.isLoggedIn()) return TerminalLogoutState.LOGGED_OUT; if (Rs2Bank.isOpen()) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java new file mode 100644 index 00000000000..ca0d2a07d59 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java @@ -0,0 +1,35 @@ +package net.runelite.client.plugins.microbot.util.player; + +import java.util.function.Consumer; +import java.util.function.Supplier; +import net.runelite.client.plugins.microbot.Microbot; + +/** Keeps terminal logout separate from notification; the host pauses gameplay until completed. */ +public final class TerminalLogoutPrompt { + private String message; + private long nextAttemptAt; + + public void begin(String text) { + if (message == null) message = text; + } + + public boolean active() { return message != null; } + public String message() { return message; } + public void reset() { message = null; nextAttemptAt = 0L; } + + public boolean tick() { + long now = System.currentTimeMillis(); + if (now < nextAttemptAt) return false; + nextAttemptAt = now + 600L; + return tick(Rs2Player::logoutWithoutAutoLoginStep, Microbot::showMessage); + } + + public boolean tick(Supplier logout, Consumer notify) { + if (message == null) return false; + if (logout.get() != Rs2Player.TerminalLogoutState.LOGGED_OUT) return false; + String completedMessage = message; + message = null; + notify.accept(completedMessage); + return true; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java new file mode 100644 index 00000000000..b330ebe60cf --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java @@ -0,0 +1,24 @@ +package net.runelite.client.plugins.microbot.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ScheduledFuture; +import net.runelite.client.plugins.microbot.inventorysetups.InventorySetup; +import org.junit.Test; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class InventorySetupTerminalOwnerTest { + @Test public void missingSupplyRetainsHostSchedulerForObservedLogout() throws Exception { + ScheduledFuture scheduler = mock(ScheduledFuture.class); + Rs2InventorySetup loader = new Rs2InventorySetup(mock(InventorySetup.class), scheduler); + List missing = new ArrayList<>(); + loader.withMissingSupplyPolicy(Rs2InventorySetup.MissingSupplyPolicy.LOGOUT_AND_STOP) + .withMissingSupplyHandler(missing::add); + var method = Rs2InventorySetup.class.getDeclaredMethod("stopForMissingRequiredSupply", String.class); + method.setAccessible(true); + method.invoke(loader, "Super restore(4)"); + assertEquals(List.of("Super restore(4)"), missing); + verifyNoInteractions(scheduler); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java new file mode 100644 index 00000000000..f60b81cd8ee --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java @@ -0,0 +1,33 @@ +package net.runelite.client.plugins.microbot.util.player; + +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class TerminalLogoutPromptTest { + @Test public void waitsThroughBankClosureAndBlockedLogoutBeforeOneNotification() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + List messages = new ArrayList<>(); + prompt.begin("Ran out of supplies: box traps"); + for (Rs2Player.TerminalLogoutState state : new Rs2Player.TerminalLogoutState[]{ + Rs2Player.TerminalLogoutState.BANK_CLOSING, Rs2Player.TerminalLogoutState.INPUT_BLOCKED, + Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED}) { + assertFalse(prompt.tick(() -> state, messages::add)); + assertTrue(prompt.active()); + assertTrue(messages.isEmpty()); + } + assertTrue(prompt.tick(() -> Rs2Player.TerminalLogoutState.LOGGED_OUT, messages::add)); + assertFalse(prompt.active()); + assertEquals(List.of("Ran out of supplies: box traps"), messages); + assertFalse(prompt.tick(() -> { throw new AssertionError("completed stop retried"); }, messages::add)); + } + + @Test public void explicitRestartCancelsOldLogoutAndMessage() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + prompt.begin("Old stop"); + prompt.reset(); + assertFalse(prompt.tick(() -> { throw new AssertionError("old stop continued"); }, + text -> { throw new AssertionError("old popup appeared"); })); + } +} From 511b7d497bf2a02fcbe400d2cf71c92911a23c38 Mon Sep 17 00:00:00 2001 From: droplugins <50-combat-pit@icloud.com> Date: Mon, 12 Oct 2026 14:14:58 -0700 Subject: [PATCH 08/26] fix(logout): use ClientThread predicate for terminal input guard --- .../runelite/client/plugins/microbot/util/player/Rs2Player.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java index 126a2dadb00..e327cc319f1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java @@ -478,7 +478,7 @@ public enum TerminalLogoutState { BANK_CLOSING, LOGOUT_REQUESTED, LOGGED_OUT, IN /** One off-client-thread terminal logout step with AutoLogin suppression. */ public static TerminalLogoutState logoutWithoutAutoLoginStep() { - if (Microbot.getClient().isClientThread()) return TerminalLogoutState.INPUT_BLOCKED; + if (Microbot.getClientThread().isClientThread()) return TerminalLogoutState.INPUT_BLOCKED; Microbot.getEventBus().post(new AutoLoginSuppressionRequest()); return terminalLogoutInputStep(); } From da0a6236db3d5c1d6479a7900d3f81be2f0ff32e Mon Sep 17 00:00:00 2001 From: droplugins <50-combat-pit@icloud.com> Date: Mon, 12 Oct 2026 14:37:21 -0700 Subject: [PATCH 09/26] fix(logout): confirm login screen and arm suppression once per stop --- .../microbot/util/player/Rs2Player.java | 28 +++++++--- .../util/player/TerminalLogoutPrompt.java | 27 ++++++++-- .../util/InventorySetupTerminalOwnerTest.java | 1 + .../util/player/TerminalLogoutPromptTest.java | 27 ++++++++++ .../util/player/TerminalLogoutStepTest.java | 51 +++++++++++++++++++ 5 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java index e327cc319f1..4c9f7ed6f99 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java @@ -476,13 +476,6 @@ public static boolean isRunEnabled() { public enum TerminalLogoutState { BANK_CLOSING, LOGOUT_REQUESTED, LOGGED_OUT, INPUT_BLOCKED } - /** One off-client-thread terminal logout step with AutoLogin suppression. */ - public static TerminalLogoutState logoutWithoutAutoLoginStep() { - if (Microbot.getClientThread().isClientThread()) return TerminalLogoutState.INPUT_BLOCKED; - Microbot.getEventBus().post(new AutoLoginSuppressionRequest()); - return terminalLogoutInputStep(); - } - private static TerminalLogoutState terminalLogoutInputStep() { if (!Microbot.isLoggedIn()) return TerminalLogoutState.LOGGED_OUT; if (Rs2Bank.isOpen()) { @@ -1970,4 +1963,25 @@ public static Actor getInteracting() { public static boolean hasCompletedTutorialIsland() { return Microbot.getVarbitPlayerValue(281) >= 1000; } + + /** One off-client-thread terminal logout step with AutoLogin suppression. */ + public static TerminalLogoutState logoutWithoutAutoLoginStep() { + return logoutWithoutAutoLoginStep(() -> Microbot.getEventBus().post(new AutoLoginSuppressionRequest())); + } + + static TerminalLogoutState logoutWithoutAutoLoginStep(Runnable requestSuppression) { + if (Microbot.getClientThread().isClientThread()) return TerminalLogoutState.INPUT_BLOCKED; + GameState state = Microbot.getClientThread().runOnClientThreadOptional( + () -> Microbot.getClient().getGameState()).orElse(GameState.UNKNOWN); + if (state != GameState.LOGGED_IN && state != GameState.LOGIN_SCREEN) return TerminalLogoutState.INPUT_BLOCKED; + requestSuppression.run(); + if (state == GameState.LOGIN_SCREEN) return TerminalLogoutState.LOGGED_OUT; + if (Rs2Bank.isOpen()) { + return Rs2Widget.clickChildWidget(786434, 11) + ? TerminalLogoutState.BANK_CLOSING : TerminalLogoutState.INPUT_BLOCKED; + } + logout(); + return TerminalLogoutState.LOGOUT_REQUESTED; + } + } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java index ca0d2a07d59..ee166bfdb96 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java @@ -1,32 +1,51 @@ +/* Copyright (c) 2026, DRO (droplugins). SPDX-License-Identifier: BSD-2-Clause */ package net.runelite.client.plugins.microbot.util.player; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Supplier; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.accountselector.AutoLoginSuppressionRequest; /** Keeps terminal logout separate from notification; the host pauses gameplay until completed. */ public final class TerminalLogoutPrompt { private String message; private long nextAttemptAt; + private boolean suppressionRequested; public void begin(String text) { - if (message == null) message = text; + if (message == null) { + message = text; + suppressionRequested = false; + } } public boolean active() { return message != null; } public String message() { return message; } - public void reset() { message = null; nextAttemptAt = 0L; } + public void reset() { message = null; nextAttemptAt = 0L; suppressionRequested = false; } public boolean tick() { long now = System.currentTimeMillis(); if (now < nextAttemptAt) return false; nextAttemptAt = now + 600L; - return tick(Rs2Player::logoutWithoutAutoLoginStep, Microbot::showMessage); + return tick(Rs2Player::logoutWithoutAutoLoginStep, Microbot::showMessage, + () -> Microbot.getEventBus().post(new AutoLoginSuppressionRequest())); } public boolean tick(Supplier logout, Consumer notify) { + return tick(ignored -> logout.get(), notify, () -> {}); + } + + boolean tick(Function logout, Consumer notify, + Runnable requestSuppression) { if (message == null) return false; - if (logout.get() != Rs2Player.TerminalLogoutState.LOGGED_OUT) return false; + Rs2Player.TerminalLogoutState state = logout.apply(() -> { + if (!suppressionRequested) { + requestSuppression.run(); + suppressionRequested = true; + } + }); + if (state != Rs2Player.TerminalLogoutState.LOGGED_OUT) return false; String completedMessage = message; message = null; notify.accept(completedMessage); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java index b330ebe60cf..87fbae3f37c 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java @@ -1,3 +1,4 @@ +/* Copyright (c) 2026, DRO (droplugins). SPDX-License-Identifier: BSD-2-Clause */ package net.runelite.client.plugins.microbot.util; import java.util.ArrayList; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java index f60b81cd8ee..24e0bfb4d51 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java @@ -1,3 +1,4 @@ +/* Copyright (c) 2026, DRO (droplugins). SPDX-License-Identifier: BSD-2-Clause */ package net.runelite.client.plugins.microbot.util.player; import java.util.ArrayList; @@ -30,4 +31,30 @@ public class TerminalLogoutPromptTest { assertFalse(prompt.tick(() -> { throw new AssertionError("old stop continued"); }, text -> { throw new AssertionError("old popup appeared"); })); } + + @Test public void suppressionIsRequestedOnceAcrossRetriesAndAgainForANewStop() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + int[] requests = {0}; + List messages = new ArrayList<>(); + prompt.begin("First stop"); + for (int i = 0; i < 3; i++) { + assertFalse(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.BANK_CLOSING; + }, messages::add, () -> requests[0]++)); + } + assertEquals(1, requests[0]); + assertTrue(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.LOGGED_OUT; + }, messages::add, () -> requests[0]++)); + assertEquals(1, requests[0]); + prompt.begin("Second stop"); + assertTrue(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.LOGGED_OUT; + }, messages::add, () -> requests[0]++)); + assertEquals(2, requests[0]); + assertEquals(List.of("First stop", "Second stop"), messages); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java new file mode 100644 index 00000000000..0c0a3fd7659 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java @@ -0,0 +1,51 @@ +/* Copyright (c) 2026, DRO (droplugins). SPDX-License-Identifier: BSD-2-Clause */ +package net.runelite.client.plugins.microbot.util.player; + +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.concurrent.Callable; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import org.junit.Test; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class TerminalLogoutStepTest { + private void check(GameState state, boolean onClientThread, Rs2Player.TerminalLogoutState expected, + int expectedRequests) throws Exception { + Client client=mock(Client.class); + ClientThread thread=mock(ClientThread.class); + when(client.getGameState()).thenReturn(state); + when(thread.isClientThread()).thenReturn(onClientThread); + when(thread.runOnClientThreadOptional(any(Callable.class))).thenAnswer(invocation -> + Optional.ofNullable(((Callable)invocation.getArgument(0)).call())); + Field cf=Microbot.class.getDeclaredField("client"),tf=Microbot.class.getDeclaredField("clientThread"); + cf.setAccessible(true);tf.setAccessible(true); + Object oldClient=cf.get(null),oldThread=tf.get(null); + try { + cf.set(null,client);tf.set(null,thread); + int[] requests={0}; + assertEquals(expected,Rs2Player.logoutWithoutAutoLoginStep(() -> requests[0]++)); + assertEquals(expectedRequests,requests[0]); + } finally { + cf.set(null,oldClient);tf.set(null,oldThread); + } + } + + @Test public void worldAndLoginTransitionsAreNotConfirmedLogout() throws Exception { + for(GameState state:new GameState[]{GameState.HOPPING,GameState.LOADING,GameState.CONNECTION_LOST, + GameState.LOGGING_IN,GameState.LOGIN_SCREEN_AUTHENTICATOR,GameState.UNKNOWN}) { + check(state,false,Rs2Player.TerminalLogoutState.INPUT_BLOCKED,0); + } + } + + @Test public void loginScreenConfirmsLogoutWithSuppression() throws Exception { + check(GameState.LOGIN_SCREEN,false,Rs2Player.TerminalLogoutState.LOGGED_OUT,1); + } + + @Test public void clientThreadCannotDispatchOrRequestSuppression() throws Exception { + check(GameState.LOGGED_IN,true,Rs2Player.TerminalLogoutState.INPUT_BLOCKED,0); + } +} From 7cdf412cda68a81d4573c06695ac3045e03e3f58 Mon Sep 17 00:00:00 2001 From: droplugins <50-combat-pit@icloud.com> Date: Mon, 12 Oct 2026 14:42:16 -0700 Subject: [PATCH 10/26] docs(logout): clarify confirmed state and single suppression request --- docs/entity-guides/items.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index 01059dfc0cd..a68163fd997 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -277,3 +277,5 @@ An ordinary logout can be a break or world hop, so it must keep normal AutoLogin ## Terminal supply stops need observed logout An owning script can set `withMissingSupplyHandler` on its Inventory Setup loader to keep its scheduled loop alive while `TerminalLogoutPrompt` closes the bank and requests logout. Do not show a blocking shortage notification or cancel that loop until `LOGGED_OUT` is observed. The default loader policy stays unchanged when no handler is supplied; ordinary breaks and world hops do not arm terminal Auto Login suppression. + +The observed terminal state is `LOGIN_SCREEN`; loading, hopping, connection loss and login transitions are not successful logout. A terminal prompt requests Auto Login suppression once across its retries so an explicit script/Auto Login restart is not repeatedly re-blocked by the old prompt. From b586c6febe3a737d95259e53c2ceeaa54625ee0f Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:38:45 +0200 Subject: [PATCH 11/26] fix(world-hop): address review of the hop budget follow-up - Read idle counters and chat input on the client thread, and reuse Rs2Player.checkIdleLogout so the keep-alive follows the client's configured idle timeout; checkIdleLogout now reads on the client thread too, which drops its guardrail baseline entry. - Skip the keep-alive backspace while the chatbox or an input dialog holds typed text. - Hash the proxy username with SHA-256 instead of String.hashCode. - Read the shared file once in persistTo so the client- and Swing-thread check never mistakes an unread file for a full budget. - Always set the proxy username, clearing it for proxies without one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../client/plugins/microbot/Microbot.java | 26 ++++++++++++++----- .../microbot/util/player/Rs2Player.java | 13 +++++----- .../microbot/util/security/HopBudget.java | 18 ++++++++++++- .../client/proxy/ProxyConfiguration.java | 2 +- .../microbot/util/security/HopBudgetTest.java | 16 ++++++++++++ .../client-thread-guardrail-baseline.txt | 1 - 6 files changed, 61 insertions(+), 15 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java index b5983b5ff88..6a045d8971e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java @@ -14,6 +14,7 @@ import net.runelite.api.annotations.Varp; import net.runelite.api.events.ItemContainerChanged; import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.VarClientID; import net.runelite.api.widgets.Widget; import net.runelite.api.widgets.WidgetModalMode; import net.runelite.client.Notifier; @@ -47,6 +48,7 @@ import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; import net.runelite.client.plugins.microbot.util.mouse.Mouse; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.mouse.VirtualMouse; import net.runelite.client.plugins.microbot.util.mouse.naturalmouse.NaturalMouse; import net.runelite.client.plugins.microbot.api.boat.Rs2BoatCache; @@ -176,8 +178,8 @@ public class Microbot { private static net.runelite.api.World quickHopTargetWorld; private static final int WORLD_SWITCHER_LOAD_TIMEOUT_MS = 5000; private static final int WORLD_SWITCHER_REOPEN_INTERVAL_MS = 600; - private static final int HOP_WAIT_MIN_IDLE_TICKS = 5000; - private static final int HOP_WAIT_MAX_IDLE_TICKS = 10000; + private static final int HOP_WAIT_MIN_IDLE_MARGIN_TICKS = 3000; + private static final int HOP_WAIT_MAX_IDLE_MARGIN_TICKS = 10000; /** * PouchScript is injected in the main MicrobotPlugin as it's being used in multiple scripts */ @@ -490,17 +492,16 @@ static boolean awaitHopBudget(int worldNumber) { log.info("Hop limit reached - waiting {}s before hopping to world {}", TimeUnit.MILLISECONDS.toSeconds(waitMs), worldNumber); String previousStatus = status; - int idleLimit = Rs2Random.between(HOP_WAIT_MIN_IDLE_TICKS, HOP_WAIT_MAX_IDLE_TICKS); + int idleMargin = Rs2Random.between(HOP_WAIT_MIN_IDLE_MARGIN_TICKS, HOP_WAIT_MAX_IDLE_MARGIN_TICKS); try { while (waitMs > 0) { if (!isLoggedIn()) { log.info("Logged out while waiting for the hop limit - not hopping to world {}", worldNumber); return false; } - Client client = Microbot.getClient(); - if (Math.min(client.getKeyboardIdleTicks(), client.getMouseIdleTicks()) > idleLimit) { + if (shouldPreventIdleLogout(idleMargin)) { Rs2Keyboard.keyPress(KeyEvent.VK_BACK_SPACE); - idleLimit = Rs2Random.between(HOP_WAIT_MIN_IDLE_TICKS, HOP_WAIT_MAX_IDLE_TICKS); + idleMargin = Rs2Random.between(HOP_WAIT_MIN_IDLE_MARGIN_TICKS, HOP_WAIT_MAX_IDLE_MARGIN_TICKS); } status = "Hop limit - next hop in " + TimeUnit.MILLISECONDS.toSeconds(waitMs + 999) + "s"; Thread.sleep(Math.min(waitMs, 1000L)); @@ -515,6 +516,19 @@ static boolean awaitHopBudget(int worldNumber) { } } + private static boolean shouldPreventIdleLogout(int idleMargin) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Client client = Microbot.getClient(); + return Rs2Player.checkIdleLogout(idleMargin) + && isNullOrEmpty(client.getVarcStrValue(VarClientID.CHATINPUT)) + && isNullOrEmpty(client.getVarcStrValue(VarClientID.MESLAYERINPUT)); + }).orElse(false); + } + + private static boolean isNullOrEmpty(String value) { + return value == null || value.isEmpty(); + } + static boolean hopViaWorldSwitcher(net.runelite.api.World rsWorld, int loadTimeoutMs) { if (!isWorldSwitcherLoaded() && !sleepUntil(Microbot::isWorldSwitcherLoaded, Microbot::openWorldSwitcher, loadTimeoutMs, WORLD_SWITCHER_REOPEN_INTERVAL_MS)) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java index 3caa11536b8..fab2528ae4f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java @@ -1705,12 +1705,13 @@ public static int distanceTo(WorldPoint endpoint) { * @return {@code true} if the player's idle time exceeds or equals the adjusted timeout, {@code false} otherwise. */ public static boolean checkIdleLogout(long randomDelay) { - long idleClientTicks = Long.min( - Microbot.getClient().getMouseIdleTicks(), - Microbot.getClient().getKeyboardIdleTicks() - ); - - return idleClientTicks >= Microbot.getClient().getIdleTimeout() - randomDelay; + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + long idleClientTicks = Long.min( + Microbot.getClient().getMouseIdleTicks(), + Microbot.getClient().getKeyboardIdleTicks() + ); + return idleClientTicks >= Microbot.getClient().getIdleTimeout() - randomDelay; + }).orElse(false); } /** diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java index fb93abb75b1..eb881a317a4 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java @@ -16,6 +16,8 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Properties; import java.util.concurrent.TimeUnit; import java.util.function.LongSupplier; @@ -60,17 +62,31 @@ static String networkKey(String host, String port, String username) { key.append('_').append(sanitize(port)); } if (username != null && !username.isEmpty()) { - key.append('.').append(Integer.toHexString(username.hashCode())); + key.append('.').append(digest(username)); } return key.toString(); } + private static String digest(String value) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (int i = 0; i < 8; i++) { + hex.append(String.format("%02x", hash[i])); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + private static String sanitize(String value) { return value.trim().replaceAll("[^A-Za-z0-9._-]", "_"); } public void persistTo(Path file, String networkKey) { store = new FileStore(file, networkKey, store); + store.update(UnaryOperator.identity()); } public void configure(boolean enabled, int capacity, int refillPerHour, int reserve) { diff --git a/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java b/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java index bb39a3d964e..6aa909a2b3b 100644 --- a/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java +++ b/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java @@ -50,9 +50,9 @@ public static void setupProxy(OptionSet options, ArgumentAcceptingOptionSpec 0); + } + private static void restore(String property, String value) { if (value == null) { System.clearProperty(property); 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 fac8ab0fc88..6f4c2dd57e1 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 @@ -544,7 +544,6 @@ net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel#getTransformedComposit net.runelite.client.plugins.microbot.util.player.Rs2Player#IsInInstance(): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.player.Rs2Player#IsInInstance(): boolean -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.player.Rs2Player#IsInInstance(): boolean -> net.runelite.api.WorldView#getScene(): Scene -net.runelite.client.plugins.microbot.util.player.Rs2Player#checkIdleLogout(long): boolean -> net.runelite.api.Client#getKeyboardIdleTicks(): int net.runelite.client.plugins.microbot.util.player.Rs2Player#distanceTo(WorldPoint): int -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.player.Rs2Player#distanceTo(WorldPoint): int -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.player.Rs2Player#distanceTo(WorldPoint): int -> net.runelite.api.WorldView#getScene(): Scene From c0a3a914d30b816e945700e51ee5869822fa7b99 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:31:20 +0200 Subject: [PATCH 12/26] fix(world-hop): refuse blocked hops before waiting and load the budget off the EDT - Check combat, bank, shop and dialogue before waiting for the hop limit, so a blocked hop is refused at once instead of after the wait. - Load the shared budget file on the plugin executor instead of the Swing thread during startUp, and read it once instead of twice. - Apply the future-timestamp repair when loading, so the client- and Swing-thread check refills after the clock goes backwards. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../client/plugins/microbot/Microbot.java | 30 ++++++++++++++----- .../plugins/microbot/MicrobotPlugin.java | 3 +- .../microbot/util/security/HopBudget.java | 4 +-- .../microbot/MicrobotWorldHopTest.java | 23 ++++++++++++++ .../microbot/util/security/HopBudgetTest.java | 11 +++++++ 5 files changed, 60 insertions(+), 11 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java index 6a045d8971e..d95a5de4089 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java @@ -383,17 +383,21 @@ public static boolean hopToWorld(int worldNumber) { log.error("Can't hop world, already trying to hop"); return false; } - if (Microbot.getClient().getWorld() != worldNumber && !awaitHopBudget(worldNumber)) { - return false; + if (Microbot.getClient().getWorld() != worldNumber) { + String blocker = Microbot.getClientThread().runOnClientThreadOptional(Microbot::hopBlocker).orElse(null); + if (blocker != null) { + log.error(blocker); + return false; + } + if (!awaitHopBudget(worldNumber)) { + return false; + } } final net.runelite.api.World[] target = new net.runelite.api.World[1]; boolean prepared = Microbot.getClientThread().runOnClientThreadOptional(() -> { - if (Rs2Combat.inCombat()) { - log.error("Player is in combat, cannot hop worlds"); - return false; - } - if (Rs2Bank.isOpen() || Rs2Shop.isOpen() || Rs2Dialogue.isInDialogue()) { - log.error("Blocking widget open (bank/shop/dialogue), cannot hop worlds"); + String blocker = hopBlocker(); + if (blocker != null) { + log.error(blocker); return false; } if (quickHopTargetWorld != null || Microbot.getClient().getGameState() != GameState.LOGGED_IN) { @@ -474,6 +478,16 @@ public static boolean hopToWorld(int worldNumber) { return success; } + private static String hopBlocker() { + if (Rs2Combat.inCombat()) { + return "Player is in combat, cannot hop worlds"; + } + if (Rs2Bank.isOpen() || Rs2Shop.isOpen() || Rs2Dialogue.isInDialogue()) { + return "Blocking widget open (bank/shop/dialogue), cannot hop worlds"; + } + return null; + } + static boolean awaitHopBudget(int worldNumber) { HopBudget budget = HopBudget.getInstance(); if (Microbot.getClient().isClientThread() || SwingUtilities.isEventDispatchThread()) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index e14b5f104e6..0645c7b49e8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -184,8 +184,9 @@ protected void startUp() throws AWTException InputArbiter.setDisabled(microbotConfig.disableInputYielding()); InputArbiter.setMotionThresholdPx(microbotConfig.inputMotionThresholdPx()); InputArbiter.setIdleResumeMs(microbotConfig.inputIdleResumeMs()); - HopBudget.getInstance().persistTo(new File(RuneLite.RUNELITE_DIR, HopBudget.FILE_NAME).toPath(), HopBudget.networkKey()); applyHopBudgetConfig(); + executor.execute(() -> HopBudget.getInstance().persistTo( + new File(RuneLite.RUNELITE_DIR, HopBudget.FILE_NAME).toPath(), HopBudget.networkKey())); CanvasInputListener.attach(); Microbot.enableAutoRunOn = microbotConfig.enableAutoRunOn(); Microbot.useStaminaPotsIfNeeded = microbotConfig.useStaminaPotsIfNeeded(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java index eb881a317a4..5eff0e7e990 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java @@ -86,7 +86,7 @@ private static String sanitize(String value) { public void persistTo(Path file, String networkKey) { store = new FileStore(file, networkKey, store); - store.update(UnaryOperator.identity()); + current(); } public void configure(boolean enabled, int capacity, int refillPerHour, int reserve) { @@ -207,7 +207,7 @@ static final class FileStore implements Store { this.lockFile = file.resolveSibling(file.getFileName() + ".lock"); this.tokensKey = networkKey + ".tokens"; this.updatedAtKey = networkKey + ".updatedAt"; - this.fallback = new MemoryStore(seed.update(UnaryOperator.identity())); + this.fallback = new MemoryStore(seed.peek()); } @Override diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java index 924e6d5976d..bec3f0839f7 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java @@ -5,6 +5,9 @@ import net.runelite.api.gameval.InterfaceID; import net.runelite.api.widgets.Widget; import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.combat.Rs2Combat; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -103,6 +106,26 @@ public void hopsImmediatelyWhenSwitcherAlreadyOpen() { verify(client).hopToWorld(target); } + @Test + public void blockedHopIsRefusedBeforeWaitingForTheHopLimit() { + HopBudget budget = mock(HopBudget.class); + when(budget.millisUntilHopAllowed()).thenThrow(new AssertionError("waited for the hop limit")); + when(budget.cachedMillisUntilHopAllowed()).thenThrow(new AssertionError("waited for the hop limit")); + microbot.when(Microbot::isLoggedIn).thenReturn(true); + when(client.getWorld()).thenReturn(301); + try (MockedStatic hopBudget = Mockito.mockStatic(HopBudget.class); + MockedStatic combat = Mockito.mockStatic(Rs2Combat.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class)) { + hopBudget.when(HopBudget::getInstance).thenReturn(budget); + combat.when(Rs2Combat::inCombat).thenReturn(false); + bank.when(Rs2Bank::isOpen).thenReturn(true); + + assertFalse(Microbot.hopToWorld(302)); + } + + verify(client, never()).hopToWorld(any()); + } + @Test public void doesNotHopWhenSwitcherNeverLoads() { assertFalse(Microbot.hopViaWorldSwitcher(target, 300)); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java index 3f20e10c6a9..c6246246ae7 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java @@ -169,6 +169,17 @@ private static void hop(HopBudget budget, int times) { assertEquals(0L, budget.millisUntilHopAllowed()); } + @Test public void persistToRepairsAFutureTimestampForTheCachedCheck() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + Files.write(file, ("direct.tokens=5.0\ndirect.updatedAt=" + (clock.get() + TimeUnit.HOURS.toMillis(1)) + "\n") + .getBytes(StandardCharsets.UTF_8)); + HopBudget budget = budget(30, 5); + budget.persistTo(file, "direct"); + assertTrue(budget.cachedMillisUntilHopAllowed() > 0); + clock.addAndGet(ONE_TOKEN_MILLIS); + assertEquals(0L, budget.cachedMillisUntilHopAllowed()); + } + @Test public void zeroRefillRateIsClampedSoWaitsStayFinite() { HopBudget budget = new HopBudget(clock::get, new HopBudget.MemoryStore()); budget.configure(true, 30, 0, 5); From 58bd4c0dbecc5ca1668ac3265a8ed8db5dd15e6d Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:27:33 +0200 Subject: [PATCH 13/26] fix(world-hop): call hopToWorld once per attempt in Rs2WorldUtil.hopWorld hopWorld wrapped Microbot.hopToWorld in sleepUntil(..., 6000), so a hop that failed fast (combat, dialogue, an invalid world) was retried on every poll for six seconds, logging an error each time. The isHopping and world checks that follow already handle a failed hop and drive the backoff retries, so call it once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/world/Rs2WorldUtil.java | 2 +- .../util/world/Rs2WorldUtilHopWorldTest.java | 45 +++++++++++++++++++ .../client-thread-guardrail-baseline.txt | 2 +- 3 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtilHopWorldTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java index e076effb721..05fe4f813f1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java @@ -389,7 +389,7 @@ public static boolean hopWorld(CompletableFuture scheduledFuture, int world, log.info("Attempting world hop {} to world {}", attemptsPerWorld, world); ; - sleepUntil(() -> Microbot.hopToWorld(world), 6000); + Microbot.hopToWorld(world); sleepUntil(() -> !Microbot.isHopping(), 6000); if (Microbot.isHopping()) { Microbot.status = "Failed to hop to world " + world + " (attempt " + attemptsPerWorld + ")"; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtilHopWorldTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtilHopWorldTest.java new file mode 100644 index 00000000000..3c5e5609104 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtilHopWorldTest.java @@ -0,0 +1,45 @@ +package net.runelite.client.plugins.microbot.util.world; + +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.shop.Rs2Shop; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +public class Rs2WorldUtilHopWorldTest { + + @Test + public void failedHopIsAttemptedOncePerAttempt() { + Client client = mock(Client.class); + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + WorldHoppingConfig config = WorldHoppingConfig.builder() + .maxAttemptsPerWorld(2) + .baseHopDelay(10) + .maxHopDelay(10) + .build(); + + try (MockedStatic microbot = Mockito.mockStatic(Microbot.class, Mockito.CALLS_REAL_METHODS); + MockedStatic shop = Mockito.mockStatic(Rs2Shop.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class); + MockedStatic player = Mockito.mockStatic(Rs2Player.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(() -> Microbot.hopToWorld(anyInt())).thenReturn(false); + microbot.when(Microbot::isHopping).thenReturn(false); + player.when(Rs2Player::getWorld).thenReturn(301); + + assertFalse(Rs2WorldUtil.hopWorld(null, 302, 0, config)); + + microbot.verify(() -> Microbot.hopToWorld(302), times(2)); + } + } +} 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 6f4c2dd57e1..8a1ec12e80d 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 @@ -989,4 +989,4 @@ net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#getRandomAccessible net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#getRandomAccessibleWorldFromRegion(WorldRegion, boolean, boolean, boolean): int -> net.runelite.api.Client#getWorldType(): EnumSet net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#getWorldStatistics(): Map -> net.runelite.api.Client#getWorldType(): EnumSet net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#isMemberAccount(): boolean -> net.runelite.api.Client#getLocalPlayer(): Player -net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#lambda$hopWorld$8(): boolean -> net.runelite.api.Client#getGameState(): GameState +net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#lambda$hopWorld$7(): boolean -> net.runelite.api.Client#getGameState(): GameState From ba2ea702fafbd1c162e1090bcea2bbceff1d33cd Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:34:24 +0200 Subject: [PATCH 14/26] fix(world-hop): wait for login before the single hop attempt in hopWorld Calling hopToWorld once lost the old polling's retry when the hop was refused only because the game was briefly LOADING. Wait up to six seconds for the player to be logged in before the attempt, and drop a stray empty statement. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/world/Rs2WorldUtil.java | 2 +- .../util/world/Rs2WorldUtilHopWorldTest.java | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java index 05fe4f813f1..8137bf22497 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java @@ -388,7 +388,7 @@ public static boolean hopWorld(CompletableFuture scheduledFuture, int world, log.info("Attempting world hop {} to world {}", attemptsPerWorld, world); - ; + sleepUntil(Microbot::isLoggedIn, 6000); Microbot.hopToWorld(world); sleepUntil(() -> !Microbot.isHopping(), 6000); if (Microbot.isHopping()) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtilHopWorldTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtilHopWorldTest.java index 3c5e5609104..a66bd13ef18 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtilHopWorldTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtilHopWorldTest.java @@ -10,7 +10,10 @@ import org.mockito.MockedStatic; import org.mockito.Mockito; +import java.util.concurrent.atomic.AtomicInteger; + import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -33,6 +36,7 @@ public void failedHopIsAttemptedOncePerAttempt() { MockedStatic bank = Mockito.mockStatic(Rs2Bank.class); MockedStatic player = Mockito.mockStatic(Rs2Player.class)) { microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::isLoggedIn).thenReturn(true); microbot.when(() -> Microbot.hopToWorld(anyInt())).thenReturn(false); microbot.when(Microbot::isHopping).thenReturn(false); player.when(Rs2Player::getWorld).thenReturn(301); @@ -42,4 +46,37 @@ public void failedHopIsAttemptedOncePerAttempt() { microbot.verify(() -> Microbot.hopToWorld(302), times(2)); } } + + @Test + public void waitsForLoginBeforeHopping() { + Client client = mock(Client.class); + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + WorldHoppingConfig config = WorldHoppingConfig.builder() + .maxAttemptsPerWorld(1) + .baseHopDelay(10) + .maxHopDelay(10) + .build(); + AtomicInteger world = new AtomicInteger(301); + + try (MockedStatic microbot = Mockito.mockStatic(Microbot.class, Mockito.CALLS_REAL_METHODS); + MockedStatic shop = Mockito.mockStatic(Rs2Shop.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class); + MockedStatic player = Mockito.mockStatic(Rs2Player.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::isLoggedIn).thenReturn(false, false, true); + microbot.when(() -> Microbot.hopToWorld(anyInt())).thenAnswer(invocation -> { + if (!Microbot.isLoggedIn()) { + return false; + } + world.set(invocation.getArgument(0)); + return true; + }); + microbot.when(Microbot::isHopping).thenReturn(false); + player.when(Rs2Player::getWorld).thenAnswer(invocation -> world.get()); + + assertTrue(Rs2WorldUtil.hopWorld(null, 302, 0, config)); + + microbot.verify(() -> Microbot.hopToWorld(302), times(1)); + } + } } From b3df414855d8e0c93ab1ed9d9caa420ab827d106 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:02:35 +0200 Subject: [PATCH 15/26] fix(hop): scope cantHopWorld to the members-area tile it was refused on The "can't log into a non-members world" message set Microbot.cantHopWorld and nothing ever cleared it, so every later hopToWorld call returned false for the rest of the session. The refusal is now recorded with the player's tile and only blocks hops to non-members worlds while the player is still on that tile; it clears itself as soon as the player has moved, and hops to members worlds are no longer blocked by it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../client/plugins/microbot/Microbot.java | 33 ++++++- .../plugins/microbot/MicrobotPlugin.java | 2 +- .../microbot/MicrobotWorldHopTest.java | 99 +++++++++++++++++++ 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java index d95a5de4089..629df1c05a6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java @@ -12,6 +12,7 @@ import net.runelite.api.annotations.Component; import net.runelite.api.annotations.Varbit; import net.runelite.api.annotations.Varp; +import net.runelite.api.coords.WorldPoint; import net.runelite.api.events.ItemContainerChanged; import net.runelite.api.gameval.InterfaceID; import net.runelite.api.gameval.VarClientID; @@ -201,7 +202,8 @@ public class Microbot { private static GameState lastGameState = null; public static boolean cantReachTarget = false; - public static boolean cantHopWorld = false; + public static volatile boolean cantHopWorld = false; + private static volatile WorldPoint cantHopWorldLocation = null; public static int cantReachTargetRetries = 0; @@ -379,11 +381,11 @@ public static boolean hopToWorld(int worldNumber) { log.error("Already hopping world"); return true; } - if (Microbot.cantHopWorld) { - log.error("Can't hop world, already trying to hop"); - return false; - } if (Microbot.getClient().getWorld() != worldNumber) { + if (isNonMembersHopRefused(worldNumber)) { + log.error("Can't hop to non-members world {} from a members-only area", worldNumber); + return false; + } String blocker = Microbot.getClientThread().runOnClientThreadOptional(Microbot::hopBlocker).orElse(null); if (blocker != null) { log.error(blocker); @@ -478,6 +480,27 @@ public static boolean hopToWorld(int worldNumber) { return success; } + public static void markNonMembersHopRefused(WorldPoint location) { + cantHopWorldLocation = location; + cantHopWorld = true; + } + + static boolean isNonMembersHopRefused(int worldNumber) { + if (!cantHopWorld) { + return false; + } + WorldPoint refusedAt = cantHopWorldLocation; + if (refusedAt == null || !refusedAt.equals(Rs2Player.getWorldLocation())) { + cantHopWorld = false; + cantHopWorldLocation = null; + return false; + } + WorldService service = getWorldService(); + net.runelite.http.api.worlds.WorldResult worlds = service == null ? null : service.getWorlds(); + World world = worlds == null ? null : worlds.findWorld(worldNumber); + return world != null && !world.getTypes().contains(net.runelite.http.api.worlds.WorldType.MEMBERS); + } + private static String hopBlocker() { if (Rs2Combat.inCombat()) { return "Player is in combat, cannot hop worlds"; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index 0645c7b49e8..d27143ddea5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -496,7 +496,7 @@ private void onChatMessage(ChatMessage event) String msg = event.getMessage(); if (msg != null && containsIgnoreCase(msg, "you can't log into a non-members")) { - Microbot.cantHopWorld = true; + Microbot.markNonMembersHopRefused(Rs2Player.getWorldLocation()); } // Leagues: "haven't unlocked access to X area" -> blacklist last transport dest. diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java index bec3f0839f7..d997e7e6160 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java @@ -2,12 +2,17 @@ import net.runelite.api.Client; import net.runelite.api.World; +import net.runelite.api.coords.WorldPoint; import net.runelite.api.gameval.InterfaceID; import net.runelite.api.widgets.Widget; import net.runelite.client.callback.ClientThread; +import net.runelite.client.game.WorldService; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.combat.Rs2Combat; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.security.HopBudget; +import net.runelite.http.api.worlds.WorldResult; +import net.runelite.http.api.worlds.WorldType; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -15,6 +20,7 @@ import org.mockito.MockedStatic; import org.mockito.Mockito; +import java.util.EnumSet; import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicBoolean; @@ -71,9 +77,102 @@ public void setUp() { @After public void tearDown() { + Microbot.cantHopWorld = false; microbot.close(); } + private static final WorldPoint REFUSED_AT = new WorldPoint(3221, 3218, 0); + + private void stubWorlds() { + net.runelite.http.api.worlds.World f2p = mock(net.runelite.http.api.worlds.World.class); + when(f2p.getTypes()).thenReturn(EnumSet.noneOf(WorldType.class)); + net.runelite.http.api.worlds.World members = mock(net.runelite.http.api.worlds.World.class); + when(members.getTypes()).thenReturn(EnumSet.of(WorldType.MEMBERS)); + WorldResult result = mock(WorldResult.class); + when(result.findWorld(308)).thenReturn(f2p); + when(result.findWorld(302)).thenReturn(members); + WorldService worldService = mock(WorldService.class); + when(worldService.getWorlds()).thenReturn(result); + microbot.when(Microbot::getWorldService).thenReturn(worldService); + } + + @Test + public void membersAreaRefusalBlocksNonMembersHopFromTheSameTile() { + stubWorlds(); + microbot.when(Microbot::isLoggedIn).thenReturn(true); + when(client.getWorld()).thenReturn(301); + Microbot.markNonMembersHopRefused(REFUSED_AT); + try (MockedStatic player = Mockito.mockStatic(Rs2Player.class); + MockedStatic hopBudget = Mockito.mockStatic(HopBudget.class)) { + player.when(Rs2Player::getWorldLocation).thenReturn(REFUSED_AT); + + assertFalse(Microbot.hopToWorld(308)); + + hopBudget.verify(HopBudget::getInstance, never()); + } + assertTrue(Microbot.cantHopWorld); + verify(client, never()).hopToWorld(any()); + } + + @Test + public void membersAreaRefusalDoesNotBlockMembersWorlds() { + stubWorlds(); + Microbot.markNonMembersHopRefused(REFUSED_AT); + try (MockedStatic player = Mockito.mockStatic(Rs2Player.class)) { + player.when(Rs2Player::getWorldLocation).thenReturn(REFUSED_AT); + + assertFalse(Microbot.isNonMembersHopRefused(302)); + assertTrue(Microbot.isNonMembersHopRefused(308)); + } + } + + @Test + public void membersAreaRefusalClearsOnceThePlayerHasMoved() { + stubWorlds(); + Microbot.markNonMembersHopRefused(REFUSED_AT); + try (MockedStatic player = Mockito.mockStatic(Rs2Player.class)) { + player.when(Rs2Player::getWorldLocation).thenReturn(REFUSED_AT.dx(1)); + + assertFalse(Microbot.isNonMembersHopRefused(308)); + assertFalse(Microbot.cantHopWorld); + + player.when(Rs2Player::getWorldLocation).thenReturn(REFUSED_AT); + assertFalse(Microbot.isNonMembersHopRefused(308)); + } + } + + @Test + public void flagWithoutARefusalLocationDoesNotBlockHops() { + stubWorlds(); + Microbot.markNonMembersHopRefused(null); + try (MockedStatic player = Mockito.mockStatic(Rs2Player.class)) { + player.when(Rs2Player::getWorldLocation).thenReturn(REFUSED_AT); + + assertFalse(Microbot.isNonMembersHopRefused(308)); + assertFalse(Microbot.cantHopWorld); + } + } + + @Test + public void staleRefusalDoesNotStopLaterHopAttempts() { + stubWorlds(); + microbot.when(Microbot::isLoggedIn).thenReturn(true); + when(client.getWorld()).thenReturn(301); + Microbot.markNonMembersHopRefused(REFUSED_AT); + try (MockedStatic player = Mockito.mockStatic(Rs2Player.class); + MockedStatic combat = Mockito.mockStatic(Rs2Combat.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class)) { + player.when(Rs2Player::getWorldLocation).thenReturn(new WorldPoint(3165, 3487, 0)); + combat.when(Rs2Combat::inCombat).thenReturn(false); + bank.when(Rs2Bank::isOpen).thenReturn(true); + + assertFalse(Microbot.hopToWorld(308)); + + combat.verify(Rs2Combat::inCombat, atLeastOnce()); + } + assertFalse(Microbot.cantHopWorld); + } + @Test public void opensSwitcherThenHopsOnceListHasLoaded() { doAnswer(inv -> { From 6209ab969b9e2aca4e3711b02fe2699d5192a8a3 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:16:27 +0200 Subject: [PATCH 16/26] fix(supplies): stop equipment load once the missing-supply handler takes over loadEquipment only returned after a missing gear row when the policy was LOGOUT_AND_STOP, so a handler with the default policy kept equipping the remaining gear and re-invoked the handler for every other missing row while the owning script ran its terminal logout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/Rs2InventorySetup.java | 5 +- .../util/InventorySetupTerminalOwnerTest.java | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java index 3d0fb00d995..e053d4c8c89 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java @@ -886,12 +886,13 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) { if (!inInventory && !inBank) { int bankGear = bankQtyForPresetRow(item, lowerCaseName, isFuzzy); int invGear = isFuzzy ? Rs2Inventory.itemQuantity((String) identifier) : Rs2Inventory.itemQuantity((int) identifier); + boolean stopLoad = missingSupplyHandler != null || missingSupplyPolicy == MissingSupplyPolicy.LOGOUT_AND_STOP; logSetup(Level.WARN, "missing gear %s | bank=%d | inv=%d | need=%d — %s", item.getName(), bankGear, invGear, item.getQuantity(), - missingSupplyPolicy == MissingSupplyPolicy.LOGOUT_AND_STOP ? "stopping" : "pausing"); + stopLoad ? "stopping" : "pausing"); stopForMissingRequiredSupply(item.getName()); - if (missingSupplyPolicy == MissingSupplyPolicy.LOGOUT_AND_STOP) return false; + if (stopLoad) return false; continue; } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java index 87fbae3f37c..0aab1b913c4 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java @@ -3,8 +3,20 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.ScheduledFuture; +import java.util.function.Predicate; +import java.util.stream.Stream; +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.inventorysetups.InventorySetup; +import net.runelite.client.plugins.microbot.inventorysetups.InventorySetupsItem; +import net.runelite.client.plugins.microbot.inventorysetups.InventorySetupsStackCompareID; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; import org.junit.Test; import static org.junit.Assert.*; import static org.mockito.Mockito.*; @@ -22,4 +34,42 @@ public class InventorySetupTerminalOwnerTest { assertEquals(List.of("Super restore(4)"), missing); verifyNoInteractions(scheduler); } + + @Test public void handlerWithDefaultPolicyStopsEquipmentLoadAtFirstMissingGear() { + boolean pausedBefore = Microbot.pauseAllScripts.getAndSet(false); + InventorySetup setup = mock(InventorySetup.class); + InventorySetupsItem missingGear = new InventorySetupsItem(100, "Missing helm", 1, false, + InventorySetupsStackCompareID.None, false, -1); + InventorySetupsItem bankedGear = new InventorySetupsItem(200, "Banked body", 1, false, + InventorySetupsStackCompareID.None, false, -1); + when(setup.getInventory()).thenReturn(List.of()); + when(setup.getEquipment()).thenReturn(List.of(missingGear, bankedGear)); + ClientThread thread = mock(ClientThread.class); + when(thread.runOnClientThreadOptional(any())).thenReturn(Optional.empty()); + try (var microbot = mockStatic(Microbot.class); + var bank = mockStatic(Rs2Bank.class); + var inventory = mockStatic(Rs2Inventory.class); + var equipment = mockStatic(Rs2Equipment.class); + var player = mockStatic(Rs2Player.class)) { + microbot.when(Microbot::getClient).thenReturn(mock(Client.class)); + microbot.when(Microbot::getClientThread).thenReturn(thread); + bank.when(Rs2Bank::isOpen).thenReturn(true); + bank.when(() -> Rs2Bank.verifyBankMirrorAfterOpen(true, 0)).thenReturn(true); + bank.when(() -> Rs2Bank.hasItem(200)).thenReturn(true); + inventory.when(Rs2Inventory::items).thenAnswer(call -> Stream.empty()); + inventory.when(() -> Rs2Inventory.items(any(Predicate.class))).thenAnswer(call -> Stream.empty()); + equipment.when(Rs2Equipment::all).thenAnswer(call -> Stream.empty()); + ScheduledFuture scheduler = mock(ScheduledFuture.class); + List missing = new ArrayList<>(); + Rs2InventorySetup loader = new Rs2InventorySetup(setup, scheduler).withMissingSupplyHandler(missing::add); + assertFalse(loader.loadEquipment(false)); + assertEquals(List.of("Missing helm"), missing); + bank.verify(() -> Rs2Bank.withdrawAndEquip(anyInt()), never()); + player.verify(Rs2Player::logoutWithoutAutoLogin, never()); + verify(scheduler, never()).cancel(anyBoolean()); + assertFalse(Microbot.pauseAllScripts.get()); + } finally { + Microbot.pauseAllScripts.set(pausedBefore); + } + } } From 63a1ca569d70f3bb281418ee50ad5753c5912917 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:00:26 +0200 Subject: [PATCH 17/26] fix(walker): keep banked direct walks alive on MOVING and honour finish distance walkWithBankedTransportsAndStateLocked treated every non-ARRIVED result of the direct walk as a failure, so a transient MOVING (null player location, route still calculating, Leagues teleport in flight) cleared the target and cancelled the active route. Only terminal states now clear it. ShortestPathScript used a hardcoded reached distance of 10 for banked manual walks while the plain branch already used the Finish distance setting; the banked branch now uses config.reachedDistance() as well. Ideas from dylanreniers in #1837. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../shortestpath/ShortestPathPlugin.java | 2 +- .../shortestpath/ShortestPathScript.java | 2 +- .../microbot/util/walker/Rs2Walker.java | 4 +- ...ShortestPathScriptReachedDistanceTest.java | 69 +++++++++++ .../Rs2WalkerBankedDirectWalkStateTest.java | 108 ++++++++++++++++++ 5 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScriptReachedDistanceTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerBankedDirectWalkStateTest.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 3f07542fef3..212ba48a116 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 @@ -92,7 +92,7 @@ description = "Draws the shortest path to a chosen destination on the map (right click a spot on the world map to use)", tags = {"pathfinder", "map", "waypoint", "navigation", "microbot"}, enabledByDefault = false, - version = "1.0.2", + version = "1.0.3", alwaysOn = true ) public class ShortestPathPlugin extends Plugin { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java index 39e484dd363..9045d7d34e7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java @@ -209,7 +209,7 @@ boolean loggedIn() { WalkerState executeWalk(WorldPoint target) { return config.walkWithBankedTransports() - ? Rs2Walker.walkWithBankedTransportsAndState(target, 10, false) + ? Rs2Walker.walkWithBankedTransportsAndState(target, config.reachedDistance(), false) : Rs2Walker.walkWithState(target); } 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 0d50ea21e69..dae623eafd1 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 @@ -7684,11 +7684,9 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar WalkerState state = walkWithStateInternal(target, distance); if (state == WalkerState.ARRIVED) { WebWalkLog.bankWalkDebug("arrived goal={}", target); - } else { + } else if (state != WalkerState.MOVING) { WebWalkLog.bankWalkFailed(target, state); setTarget(null, "rs2walker:walkWithBankedTransports:direct-walk-failed"); - return state; - } return state; } else { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScriptReachedDistanceTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScriptReachedDistanceTest.java new file mode 100644 index 00000000000..30ce3b49601 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScriptReachedDistanceTest.java @@ -0,0 +1,69 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.walker.WalkerState; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ShortestPathScriptReachedDistanceTest +{ + private static final WorldPoint TARGET = new WorldPoint(3210, 3424, 0); + + @Test + public void bankedManualWalkUsesConfiguredFinishDistance() throws Exception + { + ShortestPathConfig config = mock(ShortestPathConfig.class); + when(config.walkWithBankedTransports()).thenReturn(true); + when(config.reachedDistance()).thenReturn(3); + ShortestPathScript script = scriptWith(config); + + try (MockedStatic walker = Mockito.mockStatic(Rs2Walker.class)) + { + walker.when(() -> Rs2Walker.walkWithBankedTransportsAndState(eq(TARGET), anyInt(), anyBoolean())) + .thenReturn(WalkerState.ARRIVED); + + assertEquals(WalkerState.ARRIVED, script.executeWalk(TARGET)); + + walker.verify(() -> Rs2Walker.walkWithBankedTransportsAndState(TARGET, 3, false)); + } + } + + @Test + public void plainManualWalkStillUsesTheWalkerDefaultDistance() throws Exception + { + ShortestPathConfig config = mock(ShortestPathConfig.class); + when(config.walkWithBankedTransports()).thenReturn(false); + ShortestPathScript script = scriptWith(config); + + try (MockedStatic walker = Mockito.mockStatic(Rs2Walker.class)) + { + walker.when(() -> Rs2Walker.walkWithState(TARGET)).thenReturn(WalkerState.MOVING); + + assertEquals(WalkerState.MOVING, script.executeWalk(TARGET)); + + walker.verify(() -> Rs2Walker.walkWithState(TARGET)); + walker.verify(() -> Rs2Walker.walkWithBankedTransportsAndState(Mockito.any(), anyInt(), anyBoolean()), + Mockito.never()); + } + } + + private static ShortestPathScript scriptWith(ShortestPathConfig config) throws Exception + { + ShortestPathScript script = new ShortestPathScript(ignored -> {}); + Field field = ShortestPathScript.class.getDeclaredField("config"); + field.setAccessible(true); + field.set(script, config); + return script; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerBankedDirectWalkStateTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerBankedDirectWalkStateTest.java new file mode 100644 index 00000000000..3c1215ad2e4 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerBankedDirectWalkStateTest.java @@ -0,0 +1,108 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.Client; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.shortestpath.ShortestPathConfig; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; +import net.runelite.client.plugins.microbot.util.walker.banking.Rs2WalkerBankingPlanner; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.HashMap; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2WalkerBankedDirectWalkStateTest +{ + private static final WorldPoint PLAYER = new WorldPoint(3222, 3218, 0); + private static final WorldPoint TARGET = new WorldPoint(3400, 3400, 0); + private static final String DIRECT_WALK_FAILED = "rs2walker:walkWithBankedTransports:direct-walk-failed"; + + private MockedStatic microbot; + private MockedStatic player; + private MockedStatic tile; + private MockedStatic pathApi; + private MockedStatic planner; + private ShortestPathConfig originalConfig; + private Client client; + + @Before + public void setUp() + { + originalConfig = Rs2Walker.config; + Rs2Walker.config = mock(ShortestPathConfig.class); + Rs2Walker.routeStateForTesting().lastRouteClearReason = ""; + + client = mock(Client.class); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + + player = Mockito.mockStatic(Rs2Player.class); + tile = Mockito.mockStatic(Rs2Tile.class); + tile.when(() -> Rs2Tile.getReachableTilesFromTile(any(WorldPoint.class), anyInt())).thenReturn(new HashMap<>()); + + Rs2ActiveRouteStatus status = mock(Rs2ActiveRouteStatus.class); + pathApi = Mockito.mockStatic(Rs2PathApi.class); + pathApi.when(Rs2PathApi::getActiveRouteStatus).thenReturn(status); + + TransportRouteAnalysis comparison = mock(TransportRouteAnalysis.class); + when(comparison.getDirectDistance()).thenReturn(250); + when(comparison.getTransportEdgesToBank()).thenReturn(List.of()); + planner = Mockito.mockStatic(Rs2WalkerBankingPlanner.class); + planner.when(() -> Rs2WalkerBankingPlanner.compareRoutes(isNull(), eq(TARGET), isNull())).thenReturn(comparison); + planner.when(() -> Rs2WalkerBankingPlanner.getRequiredTransportEdgesFromBank(any())).thenReturn(List.of()); + planner.when(() -> Rs2WalkerBankingPlanner.getMissingTransportEdges(anyList())).thenReturn(List.of()); + planner.when(() -> Rs2WalkerBankingPlanner.getMissingTransportEdgeLoadout(anyList(), anyList())) + .thenReturn(Rs2TransportLoadout.empty()); + } + + @After + public void tearDown() + { + planner.close(); + pathApi.close(); + tile.close(); + player.close(); + microbot.close(); + Rs2Walker.config = originalConfig; + } + + @Test + public void transientMovingFromDirectWalkKeepsTheRoute() + { + player.when(Rs2Player::getWorldLocation).thenReturn(PLAYER, (WorldPoint) null); + + WalkerState state = Rs2Walker.walkWithBankedTransportsAndState(TARGET, 5, false); + + assertEquals(WalkerState.MOVING, state); + assertNotEquals(DIRECT_WALK_FAILED, Rs2Walker.getLastRouteClearReason()); + pathApi.verify(Rs2PathApi::cancelAndClearActiveRoute, Mockito.never()); + } + + @Test + public void terminalExitFromDirectWalkClearsTheRoute() + { + player.when(Rs2Player::getWorldLocation).thenReturn(PLAYER); + when(client.isClientThread()).thenReturn(false, true); + + WalkerState state = Rs2Walker.walkWithBankedTransportsAndState(TARGET, 5, false); + + assertEquals(WalkerState.EXIT, state); + assertEquals(DIRECT_WALK_FAILED, Rs2Walker.getLastRouteClearReason()); + pathApi.verify(Rs2PathApi::cancelAndClearActiveRoute); + } +} From 7bb8f71172295f9d464eb6718f7d71731aa37f6f Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:52:41 +0200 Subject: [PATCH 18/26] fix(camera): wait for the yaw to settle after a camera turn Rs2Camera.holdUntil released the arrow key and returned while the camera was still easing, so callers that computed a canvas point right after turnTo (doors, transports) could click a stale position. After the key release, wait until yaw and pitch read the same on two client ticks, capped at 600 ms, sampling on the client thread. getYaw and getPitch now read on the client thread too. Idea from dylanreniers in #1837. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/README.md | 2 + docs/entity-guides/movement.md | 18 +++ .../microbot/util/camera/Rs2Camera.java | 29 +++- .../util/camera/Rs2CameraSettleTest.java | 153 ++++++++++++++++++ 4 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraSettleTest.java diff --git a/docs/entity-guides/README.md b/docs/entity-guides/README.md index cd9b345700b..cbf145245c0 100644 --- a/docs/entity-guides/README.md +++ b/docs/entity-guides/README.md @@ -21,6 +21,8 @@ Scene walk-click render validity and destination confirmation: [movement gotcha Route camera turns only for unreachable targets: [movement gotcha 23](movement.md#23-route-camera-turns-are-for-targets-the-scene-cannot-reach). +Camera turns wait for the yaw to settle before returning: [movement gotcha 24](movement.md#24-a-camera-turn-is-finished-only-when-the-yaw-stops-changing). + ## Format Each entity guide is a numbered list of gotchas. Each entry follows this structure: diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index 08e5061e65f..ab10e989ba0 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -451,3 +451,21 @@ fallback click toward a nearer visible tile, the walker aligns toward the reques **Defensive check:** `RouteCameraPolicyTest` covers visible, hidden, aligned, recently failed, near, and throttled cases. + +## 24. A camera turn is finished only when the yaw stops changing + +Releasing the arrow key does not stop the camera. The yaw keeps easing for several client +ticks, and any canvas point computed in that window belongs to a view that no longer exists. + +**Why this matters:** In a live probe at the Grand Exchange, the yaw kept changing for +115–126 ms (7 client ticks) after `setAngle` released the key. It drifted another 12.6–17.6 +degrees, and the target object's canvas point moved 56–121 px. Door and transport +interactions click right after `turnTo`, so they could land on a stale position. + +**Where this applies:** `Rs2Camera.setAngle`, `turnTo` and `adjustPitch` all go through +`holdUntil`. After releasing the key, it waits with `sleepUntil` (600 ms cap) until yaw and +pitch read the same on two samples taken on different client ticks. Each sample is read on +the client thread. Callers should not add their own sleeps after `turnTo`. + +**Defensive check:** `Rs2CameraSettleTest` simulates a camera that coasts after release. It +also covers the timeout bound and the client-thread no-op. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java index 11be71ee6a4..917e54db2d5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java @@ -165,10 +165,35 @@ private static void holdUntil(int keyCode, java.util.function.BooleanSupplier re } finally { Rs2Keyboard.keyRelease(keyCode); } + if (!awaitCameraSettled()) { + log.debug("Camera did not settle within {}ms after releasing key {}", SETTLE_TIMEOUT_MS, keyCode); + } + } + + static final int SETTLE_TIMEOUT_MS = 600; + + static boolean awaitCameraSettled() { + int[][] previous = new int[1][]; + return Global.sleepUntil(() -> { + int[] sample = sampleCamera(); + if (sample == null) { + return false; + } + int[] last = previous[0]; + previous[0] = sample; + return last != null && sample[0] != last[0] && sample[1] == last[1] && sample[2] == last[2]; + }, SETTLE_TIMEOUT_MS); + } + + private static int[] sampleCamera() { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Client client = Microbot.getClient(); + return new int[]{client.getGameCycle(), client.getCameraYaw(), client.getCameraPitch()}; + }).orElse(null); } public static int getPitch() { - return fromClientAngleUnits(Microbot.getClient().getCameraPitch()); + return fromClientAngleUnits(Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getCameraPitch()).orElse(0)); } // set camera pitch @@ -283,7 +308,7 @@ public static void setZoom(int zoom) { } // Get camera/compass facing public static int getYaw() { - return fromClientAngleUnits(Microbot.getClient().getCameraYaw()); + return fromClientAngleUnits(Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getCameraYaw()).orElse(0)); } // Set camera/compass facing diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraSettleTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraSettleTest.java new file mode 100644 index 00000000000..4591115d6c1 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraSettleTest.java @@ -0,0 +1,153 @@ +package net.runelite.client.plugins.microbot.util.camera; + +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.awt.event.KeyEvent; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class Rs2CameraSettleTest { + + private static final int FULL_TURN = 16384; + private static final int TICK_MS = 20; + private static final int HELD_SPEED = 160; + private static final int COAST_DECAY = 20; + + private MockedStatic microbot; + private MockedStatic keyboard; + private ScheduledExecutorService ticker; + private Client client; + private ClientThread clientThread; + + private final AtomicInteger cycle = new AtomicInteger(); + private final AtomicInteger yaw = new AtomicInteger(); + private final AtomicInteger pitch = new AtomicInteger(2048); + private final AtomicInteger velocity = new AtomicInteger(); + private final AtomicInteger heldKey = new AtomicInteger(-1); + private final AtomicBoolean spinForever = new AtomicBoolean(); + private final AtomicBoolean onClientThread = new AtomicBoolean(); + private final AtomicBoolean isClientThread = new AtomicBoolean(); + + @Before + public void setUp() { + client = mock(Client.class); + when(client.isClientThread()).thenAnswer(inv -> isClientThread.get()); + when(client.getCameraYaw()).thenAnswer(inv -> onClientThread(yaw)); + when(client.getCameraPitch()).thenAnswer(inv -> onClientThread(pitch)); + when(client.getGameCycle()).thenAnswer(inv -> onClientThread(cycle)); + + clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + Callable callable = inv.getArgument(0); + onClientThread.set(true); + try { + return Optional.ofNullable(callable.call()); + } finally { + onClientThread.set(false); + } + }); + + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + + keyboard = Mockito.mockStatic(Rs2Keyboard.class); + keyboard.when(() -> Rs2Keyboard.keyHold(anyInt())).thenAnswer(inv -> { + heldKey.set(inv.getArgument(0)); + return null; + }); + keyboard.when(() -> Rs2Keyboard.keyRelease(anyInt())).thenAnswer(inv -> { + heldKey.compareAndSet(inv.getArgument(0), -1); + return null; + }); + + ticker = Executors.newSingleThreadScheduledExecutor(); + ticker.scheduleAtFixedRate(this::tick, TICK_MS, TICK_MS, TimeUnit.MILLISECONDS); + } + + @After + public void tearDown() { + ticker.shutdownNow(); + keyboard.close(); + microbot.close(); + } + + private int onClientThread(AtomicInteger value) { + if (!onClientThread.get()) { + throw new IllegalStateException("camera state must be read on the client thread"); + } + return value.get(); + } + + private void tick() { + int key = heldKey.get(); + int v; + if (spinForever.get()) { + v = HELD_SPEED; + } else if (key == KeyEvent.VK_LEFT) { + v = -HELD_SPEED; + } else if (key == KeyEvent.VK_RIGHT) { + v = HELD_SPEED; + } else { + int current = velocity.get(); + v = current > 0 ? Math.max(0, current - COAST_DECAY) : Math.min(0, current + COAST_DECAY); + } + velocity.set(v); + yaw.set(Math.floorMod(yaw.get() + v, FULL_TURN)); + cycle.incrementAndGet(); + } + + @Test + public void setAngleReturnsOnlyAfterYawStopsCoasting() throws Exception { + Rs2Camera.setAngle(90, 10); + + int yawAtReturn = yaw.get(); + assertEquals("camera must be at rest when setAngle returns", 0, velocity.get()); + Thread.sleep(200); + assertEquals("yaw kept changing after setAngle returned", yawAtReturn, yaw.get()); + assertTrue("turn did not reach the target window", Rs2Camera.isAngleGood(90, 40)); + } + + @Test + public void settleWaitIsBoundedWhenCameraNeverStops() { + spinForever.set(true); + + long start = System.nanoTime(); + boolean settled = Rs2Camera.awaitCameraSettled(); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + + assertFalse(settled); + assertTrue("settle wait ran " + elapsedMs + "ms", elapsedMs < Rs2Camera.SETTLE_TIMEOUT_MS + 400); + } + + @Test + public void settleWaitNeverRunsOnTheClientThread() { + isClientThread.set(true); + + assertFalse(Rs2Camera.awaitCameraSettled()); + verify(clientThread, never()).runOnClientThreadOptional(any()); + } +} From bb6db469c9d9043e6cd247a8b404a452152c9f45 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:14:11 +0200 Subject: [PATCH 19/26] fix(logout): keep Auto Login suppressed while a terminal logout is delayed TerminalLogoutPrompt requested suppression once and kept retrying the logout, but a pending request lapses after 15 s. A logout delayed past that window reached the login screen unsuppressed and Auto Login logged straight back in. The prompt now renews the pending request every 5 s; a renewal only extends a request that is still pending, so it never re-blocks Auto Login after a script start, restart or manual login cleared it. logoutWithoutAutoLoginStep no longer requests suppression when it is already on the login screen, where the request could never complete. TerminalLogoutPrompt throttles on a monotonic clock, guards its state for begin/reset from other threads, and ignores a logout result or suppression callback from a stop that was reset mid-tick. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../accountselector/AutoLoginPlugin.java | 6 +- .../accountselector/AutoLoginScript.java | 4 + .../accountselector/AutoLoginSuppression.java | 9 +- .../AutoLoginSuppressionRequest.java | 22 ++++ .../microbot/util/player/Rs2Player.java | 2 +- .../util/player/TerminalLogoutPrompt.java | 111 ++++++++++++++---- .../AutoLoginPluginSupplyStopTest.java | 3 + .../AutoLoginSuppressionTest.java | 40 +++++++ .../util/player/TerminalLogoutPromptTest.java | 87 +++++++++++++- .../util/player/TerminalLogoutStepTest.java | 4 +- 10 files changed, 257 insertions(+), 31 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java index 830b0c83d73..682913e5f84 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java @@ -51,7 +51,11 @@ protected void shutDown() { @Subscribe public void onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest event) { - accountSelectorScript.suppressAutoLogin(); + if (event.isRenewal()) { + accountSelectorScript.renewAutoLoginSuppression(); + } else { + accountSelectorScript.suppressAutoLogin(); + } } @Subscribe diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java index cb4ef480efc..d7033af1d04 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java @@ -92,6 +92,10 @@ void suppressAutoLogin() { suppression.request(); } + void renewAutoLoginSuppression() { + suppression.renew(); + } + void resumeAutoLogin() { suppression.clear(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java index 255776e2797..3854b97d27c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java @@ -8,7 +8,7 @@ final class AutoLoginSuppression { private enum State { ALLOWED, AWAITING_LOGOUT, LOGGED_OUT } - static final long AWAIT_LOGOUT_MILLIS = 15_000L; + static final long AWAIT_LOGOUT_MILLIS = AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS; private final LongSupplier clock; private State state = State.ALLOWED; private long requestedAt; @@ -29,6 +29,13 @@ synchronized void request() { } } + synchronized void renew() { + expirePendingRequest(); + if (state == State.AWAITING_LOGOUT) { + requestedAt = clock.getAsLong(); + } + } + synchronized void observe(GameState gameState) { expirePendingRequest(); if (state == State.AWAITING_LOGOUT && gameState == GameState.LOGIN_SCREEN) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java index d157c74db32..b122abedf0b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java @@ -3,7 +3,29 @@ /** * Scripts post this event before a terminal logout to prevent automatic re-login. * AutoLogin resumes when another script starts, after a manual login, or on AutoLogin restart. + * A request that does not reach the login screen within {@link #PENDING_LOGOUT_MILLIS} expires. + * A renewal restarts that deadline only while the earlier request is still pending, so it never + * re-blocks AutoLogin after a restart or a manual login cleared it. * Ordinary logouts must not post this event. */ public final class AutoLoginSuppressionRequest { + public static final long PENDING_LOGOUT_MILLIS = 15_000L; + + private final boolean renewal; + + public AutoLoginSuppressionRequest() { + this(false); + } + + private AutoLoginSuppressionRequest(boolean renewal) { + this.renewal = renewal; + } + + public static AutoLoginSuppressionRequest renewal() { + return new AutoLoginSuppressionRequest(true); + } + + public boolean isRenewal() { + return renewal; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java index b1f5e649e6d..19360a72493 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java @@ -1975,8 +1975,8 @@ static TerminalLogoutState logoutWithoutAutoLoginStep(Runnable requestSuppressio GameState state = Microbot.getClientThread().runOnClientThreadOptional( () -> Microbot.getClient().getGameState()).orElse(GameState.UNKNOWN); if (state != GameState.LOGGED_IN && state != GameState.LOGIN_SCREEN) return TerminalLogoutState.INPUT_BLOCKED; - requestSuppression.run(); if (state == GameState.LOGIN_SCREEN) return TerminalLogoutState.LOGGED_OUT; + requestSuppression.run(); if (Rs2Bank.isOpen()) { return Rs2Widget.clickChildWidget(786434, 11) ? TerminalLogoutState.BANK_CLOSING : TerminalLogoutState.INPUT_BLOCKED; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java index ee166bfdb96..68ae3858811 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java @@ -1,54 +1,119 @@ /* Copyright (c) 2026, DRO (droplugins). SPDX-License-Identifier: BSD-2-Clause */ package net.runelite.client.plugins.microbot.util.player; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.LongSupplier; import java.util.function.Supplier; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.accountselector.AutoLoginSuppressionRequest; -/** Keeps terminal logout separate from notification; the host pauses gameplay until completed. */ +/** + * Keeps terminal logout separate from notification; the host pauses gameplay until completed. + * Safe to begin or reset from one thread while another thread ticks. + */ public final class TerminalLogoutPrompt { + static final long RETRY_MILLIS = 600L; + static final long RENEW_SUPPRESSION_MILLIS = AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS / 3; + + private final LongSupplier clock; private String message; - private long nextAttemptAt; + private long stop; + private boolean attempted; + private long lastAttemptAt; private boolean suppressionRequested; + private long suppressionRequestedAt; + + public TerminalLogoutPrompt() { + this(() -> TimeUnit.NANOSECONDS.toMillis(System.nanoTime())); + } - public void begin(String text) { + TerminalLogoutPrompt(LongSupplier clock) { + this.clock = clock; + } + + public synchronized void begin(String text) { if (message == null) { message = text; - suppressionRequested = false; + startNewStop(); } } - public boolean active() { return message != null; } - public String message() { return message; } - public void reset() { message = null; nextAttemptAt = 0L; suppressionRequested = false; } + public synchronized boolean active() { return message != null; } + public synchronized String message() { return message; } + + public synchronized void reset() { + message = null; + startNewStop(); + } public boolean tick() { - long now = System.currentTimeMillis(); - if (now < nextAttemptAt) return false; - nextAttemptAt = now + 600L; - return tick(Rs2Player::logoutWithoutAutoLoginStep, Microbot::showMessage, - () -> Microbot.getEventBus().post(new AutoLoginSuppressionRequest())); + return throttledTick(Rs2Player::logoutWithoutAutoLoginStep, Microbot::showMessage, + () -> Microbot.getEventBus().post(new AutoLoginSuppressionRequest()), + () -> Microbot.getEventBus().post(AutoLoginSuppressionRequest.renewal())); } public boolean tick(Supplier logout, Consumer notify) { - return tick(ignored -> logout.get(), notify, () -> {}); + return tick(ignored -> logout.get(), notify, () -> {}, () -> {}); + } + + boolean throttledTick(Function logout, Consumer notify, + Runnable requestSuppression, Runnable renewSuppression) { + synchronized (this) { + if (message == null) return false; + long now = clock.getAsLong(); + if (attempted && now - lastAttemptAt < RETRY_MILLIS) return false; + attempted = true; + lastAttemptAt = now; + } + return tick(logout, notify, requestSuppression, renewSuppression); } boolean tick(Function logout, Consumer notify, - Runnable requestSuppression) { - if (message == null) return false; - Rs2Player.TerminalLogoutState state = logout.apply(() -> { - if (!suppressionRequested) { - requestSuppression.run(); - suppressionRequested = true; - } - }); + Runnable requestSuppression, Runnable renewSuppression) { + long tickStop; + synchronized (this) { + if (message == null) return false; + tickStop = stop; + } + Rs2Player.TerminalLogoutState state = logout.apply( + () -> suppress(tickStop, requestSuppression, renewSuppression)); if (state != Rs2Player.TerminalLogoutState.LOGGED_OUT) return false; - String completedMessage = message; - message = null; + String completedMessage; + synchronized (this) { + if (stop != tickStop || message == null) return false; + completedMessage = message; + message = null; + startNewStop(); + } notify.accept(completedMessage); return true; } + + private void suppress(long tickStop, Runnable requestSuppression, Runnable renewSuppression) { + Runnable action; + synchronized (this) { + if (stop != tickStop) return; + if (!suppressionRequested) { + action = requestSuppression; + } else if (clock.getAsLong() - suppressionRequestedAt >= RENEW_SUPPRESSION_MILLIS) { + action = renewSuppression; + } else { + return; + } + } + action.run(); + synchronized (this) { + if (stop != tickStop) return; + suppressionRequested = true; + suppressionRequestedAt = clock.getAsLong(); + } + } + + private void startNewStop() { + stop++; + attempted = false; + suppressionRequested = false; + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java index be62f005fa4..22c4a66521e 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java @@ -14,6 +14,9 @@ public class AutoLoginPluginSupplyStopTest { plugin.accountSelectorScript = mock(AutoLoginScript.class); plugin.onAutoLoginSuppressionRequest(new AutoLoginSuppressionRequest()); verify(plugin.accountSelectorScript).suppressAutoLogin(); + plugin.onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest.renewal()); + verify(plugin.accountSelectorScript).renewAutoLoginSuppression(); + verify(plugin.accountSelectorScript).suppressAutoLogin(); GameStateChanged event = new GameStateChanged(); event.setGameState(GameState.LOGIN_SCREEN); plugin.onGameStateChanged(event); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java index 2a92f48e678..e3558994469 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java @@ -86,4 +86,44 @@ public class AutoLoginSuppressionTest { clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 2); assertFalse(suppression.isSuppressed()); } + + @Test public void renewalRestartsThePendingDeadline() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS - 1); + suppression.renew(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 2 - 2); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGIN_SCREEN); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 10); + assertTrue(suppression.isSuppressed()); + } + + @Test public void renewalNeverReblocksAClearedOrExpiredRequest() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.renew(); + assertFalse(suppression.isSuppressed()); + suppression.request(); + suppression.clear(); + suppression.renew(); + assertFalse(suppression.isSuppressed()); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS); + suppression.renew(); + assertFalse(suppression.isSuppressed()); + } + + @Test public void renewalAfterLogoutKeepsWaitingForManualLogin() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + suppression.observe(GameState.LOGIN_SCREEN); + suppression.renew(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 10); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGGED_IN); + assertFalse(suppression.isSuppressed()); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java index 24e0bfb4d51..6a6eb5d4a4f 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import net.runelite.client.plugins.microbot.accountselector.AutoLoginSuppressionRequest; import org.junit.Test; import static org.junit.Assert.*; @@ -41,20 +43,99 @@ public class TerminalLogoutPromptTest { assertFalse(prompt.tick(request -> { request.run(); return Rs2Player.TerminalLogoutState.BANK_CLOSING; - }, messages::add, () -> requests[0]++)); + }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed without delay"); })); } assertEquals(1, requests[0]); assertTrue(prompt.tick(request -> { request.run(); return Rs2Player.TerminalLogoutState.LOGGED_OUT; - }, messages::add, () -> requests[0]++)); + }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed without delay"); })); assertEquals(1, requests[0]); prompt.begin("Second stop"); assertTrue(prompt.tick(request -> { request.run(); return Rs2Player.TerminalLogoutState.LOGGED_OUT; - }, messages::add, () -> requests[0]++)); + }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed without delay"); })); assertEquals(2, requests[0]); assertEquals(List.of("First stop", "Second stop"), messages); } + + @Test public void delayedLogoutKeepsRenewingSuppressionUntilTheLoginScreen() { + AtomicLong clock = new AtomicLong(); + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(clock::get); + int[] requests = {0}; + long[] lastSuppressionAt = {Long.MIN_VALUE}; + List messages = new ArrayList<>(); + prompt.begin("Ran out of supplies"); + long logoutAt = AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS * 3; + for (; clock.get() < logoutAt; clock.addAndGet(TerminalLogoutPrompt.RETRY_MILLIS)) { + assertFalse(prompt.throttledTick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED; + }, messages::add, () -> { + requests[0]++; + lastSuppressionAt[0] = clock.get(); + }, () -> lastSuppressionAt[0] = clock.get())); + assertTrue(clock.get() - lastSuppressionAt[0] < AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS); + } + assertEquals(1, requests[0]); + assertTrue(prompt.throttledTick(request -> Rs2Player.TerminalLogoutState.LOGGED_OUT, messages::add, + () -> requests[0]++, () -> { throw new AssertionError("renewed after logout"); })); + assertTrue(clock.get() - lastSuppressionAt[0] < AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS); + assertEquals(List.of("Ran out of supplies"), messages); + } + + @Test public void throttleUsesTheInjectedClockEvenWhenItStartsNegative() { + AtomicLong clock = new AtomicLong(-1_000_000L); + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(clock::get); + int[] attempts = {0}; + prompt.begin("Stop"); + Runnable tick = () -> prompt.throttledTick(request -> { + attempts[0]++; + return Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED; + }, text -> {}, () -> {}, () -> {}); + tick.run(); + assertEquals(1, attempts[0]); + clock.addAndGet(TerminalLogoutPrompt.RETRY_MILLIS - 1); + tick.run(); + assertEquals(1, attempts[0]); + clock.incrementAndGet(); + tick.run(); + assertEquals(2, attempts[0]); + prompt.reset(); + prompt.begin("Next stop"); + tick.run(); + assertEquals(3, attempts[0]); + } + + @Test public void restartFromAnotherThreadDuringLogoutIsNotCompletedOrSuppressedByTheOldTick() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + int[] requests = {0}; + List messages = new ArrayList<>(); + prompt.begin("Old stop"); + assertFalse(prompt.tick(request -> { + Thread restart = new Thread(() -> { + prompt.reset(); + prompt.begin("New stop"); + }); + restart.start(); + try { + restart.join(5_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + assertFalse(restart.isAlive()); + request.run(); + return Rs2Player.TerminalLogoutState.LOGGED_OUT; + }, messages::add, () -> requests[0]++, () -> requests[0]++)); + assertEquals(0, requests[0]); + assertTrue(messages.isEmpty()); + assertEquals("New stop", prompt.message()); + assertTrue(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.LOGGED_OUT; + }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed a new stop"); })); + assertEquals(1, requests[0]); + assertEquals(List.of("New stop"), messages); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java index 0c0a3fd7659..075fcea47e3 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java @@ -41,8 +41,8 @@ private void check(GameState state, boolean onClientThread, Rs2Player.TerminalLo } } - @Test public void loginScreenConfirmsLogoutWithSuppression() throws Exception { - check(GameState.LOGIN_SCREEN,false,Rs2Player.TerminalLogoutState.LOGGED_OUT,1); + @Test public void loginScreenConfirmsLogoutWithoutAnUncompletableSuppression() throws Exception { + check(GameState.LOGIN_SCREEN,false,Rs2Player.TerminalLogoutState.LOGGED_OUT,0); } @Test public void clientThreadCannotDispatchOrRequestSuppression() throws Exception { From ab646231fbbe12679e59602b682c90c196e52b3c Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:47:19 +0200 Subject: [PATCH 20/26] fix(console): show each log line once and stop following on user input Logback's STDOUT ConsoleAppender resolves System.out on every write, so after ClientUI swapped System.out for the console tee every log line reached the panel twice: once through the tee and once through ConsoleLogAppender. Bind System.out console appenders to the original stdout when the console is installed; plain System.out prints still go through the tee. Wheel-up, key presses and mouse presses/drags in the text area now stop following immediately; following resumes only when the view is back at the bottom with no selection. The caret moves made while following no longer scroll the view from their deferred visibility update, which could undo a wheel-up. The pending buffer is compacted only once it doubles and a flush inserts at most what the document keeps (100k chars while following). The saved sidebar tab is restored only during startup, and removing the open tab's plugin stores the tab that is shown instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../java/net/runelite/client/ui/ClientUI.java | 26 +- .../client/ui/ConsoleLogAppender.java | 24 ++ .../runelite/client/ui/LogConsolePanel.java | 77 ++++- .../client/ui/ConsoleLogAppenderTest.java | 103 ++++++ .../client/ui/LogConsolePanelTest.java | 307 ++++++++++++++++++ 5 files changed, 519 insertions(+), 18 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/ui/LogConsolePanelTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java index 276716b99a1..c4af522cd24 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java @@ -278,6 +278,7 @@ void removeNavigation(NavigationButton navBtn) : selectedTabHistory.removeLast(); openPanel(entry.navBtn, entry.sidebarOpen); + saveSidebarTab(); } } finally @@ -452,15 +453,7 @@ public void componentMoved(ComponentEvent e) if (!updatingNavigation) { - sidebarTabRestored = true; - if (newSelectedTab == null) - { - configManager.unsetConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB); - } - else - { - configManager.setConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB, newSelectedTab.getTooltip()); - } + saveSidebarTab(); } if (sidebar.isVisible()) @@ -764,6 +757,7 @@ public void show() frame.revalidateMinimumSize(); // this must run after the native window border is installed on the window frame.updateContainsInScreen(); + sidebarTabRestored = true; }); // Show out of date dialog if needed @@ -1125,6 +1119,19 @@ public GraphicsConfiguration getGraphicsConfiguration() return frame.getGraphicsConfiguration(); } + private void saveSidebarTab() + { + sidebarTabRestored = true; + if (selectedTab == null) + { + configManager.unsetConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB); + } + else + { + configManager.setConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB, selectedTab.getTooltip()); + } + } + void openPanel(NavigationButton navBtn, boolean showSidebar) { if (navBtn != null && !sidebarEntries.contains(navBtn)) @@ -1294,6 +1301,7 @@ private void initializeConsoleLogging() System.setOut(consolePrintStream); LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + ConsoleLogAppender.bindStdoutAppenders(loggerContext, originalOut); consoleLogAppender = new ConsoleLogAppender(consolePanel::append); consoleLogAppender.setContext(loggerContext); consoleLogAppender.start(); diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java b/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java index 95be2c60434..38f4b84ba01 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java @@ -4,9 +4,16 @@ */ package net.runelite.client.ui; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.PatternLayout; import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.Appender; import ch.qos.logback.core.AppenderBase; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.joran.spi.ConsoleTarget; +import java.io.OutputStream; +import java.util.Iterator; import java.util.function.Consumer; final class ConsoleLogAppender extends AppenderBase @@ -19,6 +26,23 @@ final class ConsoleLogAppender extends AppenderBase this.logConsumer = logConsumer; } + static void bindStdoutAppenders(LoggerContext context, OutputStream stdout) + { + for (Logger logger : context.getLoggerList()) + { + Iterator> appenders = logger.iteratorForAppenders(); + while (appenders.hasNext()) + { + Appender appender = appenders.next(); + if (appender instanceof ConsoleAppender + && ConsoleTarget.SystemOut.getName().equals(((ConsoleAppender) appender).getTarget())) + { + ((ConsoleAppender) appender).setOutputStream(stdout); + } + } + } + } + @Override public void start() { diff --git a/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java b/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java index 0f191175d78..c9519eb6c8d 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java @@ -6,6 +6,8 @@ import java.awt.*; import java.awt.event.HierarchyEvent; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionAdapter; @@ -45,6 +47,7 @@ final class LogConsolePanel extends JPanel private boolean followTail = true; private boolean snapQueued; private int snapAttempts; + private int tailCaretDot = -1; LogConsolePanel() { @@ -61,15 +64,65 @@ final class LogConsolePanel extends JPanel textArea.setForeground(new Color(0, 255, 70)); textArea.setBorder(null); - DefaultCaret caret = (DefaultCaret) textArea.getCaret(); + DefaultCaret caret = new DefaultCaret() + { + @Override + protected void adjustVisibility(Rectangle nloc) + { + if (getDot() != tailCaretDot || getMark() != tailCaretDot) + { + super.adjustVisibility(nloc); + } + } + }; caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE); + textArea.setCaret(caret); scrollPane.setBorder(null); add(scrollPane, BorderLayout.CENTER); // Following is decided by what the user does, never by measuring the view: under a flood the // scroll position trails the document, which reads as "the user scrolled up". - scrollPane.addMouseWheelListener(ev -> reconsiderFollowingTail()); + scrollPane.addMouseWheelListener(ev -> + { + if (ev.getPreciseWheelRotation() < 0) + { + followTail = false; + } + reconsiderFollowingTail(); + }); + + textArea.addKeyListener(new KeyAdapter() + { + @Override + public void keyPressed(KeyEvent ev) + { + followTail = false; + SwingUtilities.invokeLater(LogConsolePanel.this::reconsiderFollowingTail); + } + }); + textArea.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent ev) + { + followTail = false; + } + + @Override + public void mouseReleased(MouseEvent ev) + { + reconsiderFollowingTail(); + } + }); + textArea.addMouseMotionListener(new MouseMotionAdapter() + { + @Override + public void mouseDragged(MouseEvent ev) + { + followTail = false; + } + }); JScrollBar vertical = scrollPane.getVerticalScrollBar(); vertical.addMouseListener(new MouseAdapter() @@ -135,7 +188,7 @@ private void queueSnapToBottom() private void snapToBottom() { int end = textArea.getDocument().getLength(); - textArea.setCaretPosition(end); + moveCaretToTail(); Rectangle2D newestLine; try @@ -184,11 +237,10 @@ void append(String text) { pending.append(text, 0, text.length()); - int excess = pending.length() - MAX_CHARACTERS_SCROLLED_UP; - if (excess > 0) + if (pending.length() > 2 * MAX_CHARACTERS_SCROLLED_UP) { // the oldest buffered lines would be trimmed out of the document on arrival anyway - pending.delete(0, excess); + pending.delete(0, pending.length() - MAX_CHARACTERS_SCROLLED_UP); } if (flushScheduled) @@ -212,7 +264,8 @@ private void flushPending() return; } - text = pending.toString(); + int cap = followTail ? MAX_CHARACTERS : MAX_CHARACTERS_SCROLLED_UP; + text = pending.substring(Math.max(0, pending.length() - cap)); pending.setLength(0); } @@ -252,7 +305,7 @@ private void appendOnEdt(String text) trimIfNecessary(atBottom); if (atBottom) { - textArea.setCaretPosition(document.getLength()); + moveCaretToTail(); // NEVER_UPDATE stops the caret dragging the view, so move it ourselves; the attempt // budget is per batch, or a flood that outruns the layout would exhaust it for good. snapAttempts = 0; @@ -265,12 +318,18 @@ private void appendOnEdt(String text) } } + private void moveCaretToTail() + { + tailCaretDot = textArea.getDocument().getLength(); + textArea.setCaretPosition(tailCaretDot); + } + // Deferred so the user's gesture has reached the scrollbar before it is read. private void reconsiderFollowingTail() { SwingUtilities.invokeLater(() -> { - followTail = isScrolledToBottom(); + followTail = isScrolledToBottom() && textArea.getSelectionStart() == textArea.getSelectionEnd(); if (followTail) { snapAttempts = 0; diff --git a/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java new file mode 100644 index 00000000000..07d470ca283 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java @@ -0,0 +1,103 @@ +package net.runelite.client.ui; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.ConsoleAppender; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class ConsoleLogAppenderTest +{ + private PrintStream originalOut; + private LoggerContext context; + + @Before + public void setUp() + { + originalOut = System.out; + context = new LoggerContext(); + } + + @After + public void tearDown() + { + System.setOut(originalOut); + context.stop(); + } + + @Test + public void logLineReachesTheConsoleOnceAndStdoutOnce() + { + ByteArrayOutputStream terminal = new ByteArrayOutputStream(); + ByteArrayOutputStream consolePanel = new ByteArrayOutputStream(); + StringBuilder console = new StringBuilder(); + + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setContext(context); + encoder.setPattern("%msg%n"); + encoder.start(); + ConsoleAppender stdout = new ConsoleAppender<>(); + stdout.setContext(context); + stdout.setEncoder(encoder); + stdout.start(); + root.addAppender(stdout); + + System.setOut(new PrintStream(new Tee(consolePanel, terminal), true, StandardCharsets.UTF_8)); + + ConsoleLogAppender.bindStdoutAppenders(context, terminal); + ConsoleLogAppender appender = new ConsoleLogAppender(console::append); + appender.setContext(context); + appender.start(); + root.addAppender(appender); + + root.info("flood line"); + System.out.println("plain print"); + + String panel = console + consolePanel.toString(StandardCharsets.UTF_8); + assertEquals(1, occurrences(panel, "flood line")); + assertEquals(1, occurrences(panel, "plain print")); + + String out = terminal.toString(StandardCharsets.UTF_8); + assertEquals(1, occurrences(out, "flood line")); + assertEquals(1, occurrences(out, "plain print")); + } + + private static int occurrences(String text, String needle) + { + int count = 0; + for (int i = text.indexOf(needle); i >= 0; i = text.indexOf(needle, i + 1)) + { + count++; + } + return count; + } + + private static final class Tee extends OutputStream + { + private final OutputStream first; + private final OutputStream second; + + private Tee(OutputStream first, OutputStream second) + { + this.first = first; + this.second = second; + } + + @Override + public void write(int b) throws IOException + { + first.write(b); + second.write(b); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/ui/LogConsolePanelTest.java b/runelite-client/src/test/java/net/runelite/client/ui/LogConsolePanelTest.java new file mode 100644 index 00000000000..ed4ed2eb62a --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/ui/LogConsolePanelTest.java @@ -0,0 +1,307 @@ +package net.runelite.client.ui; + +import java.awt.Point; +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import javax.swing.JScrollBar; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.BadLocationException; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class LogConsolePanelTest +{ + private static final int DOCUMENT_CAP = 100_000; + + private LogConsolePanel panel; + private JScrollPane scrollPane; + private JTextArea textArea; + + @Before + public void setUp() throws Exception + { + onEdt(() -> + { + panel = new LogConsolePanel(); + scrollPane = (JScrollPane) panel.getComponent(0); + textArea = (JTextArea) scrollPane.getViewport().getView(); + panel.setSize(400, 160); + layOut(); + }); + panel.append(lines(0, 200)); + settle(); + assertTrue(isAtBottom()); + } + + @Test + public void wheelUpWinsOverAnAlreadyQueuedSnap() throws Exception + { + onEdt(() -> + { + panel.append(lines(200, 50)); + SwingUtilities.invokeLater(() -> + { + layOut(); + MouseWheelEvent wheelUp = new MouseWheelEvent(scrollPane, MouseEvent.MOUSE_WHEEL, System.currentTimeMillis(), + 0, 10, 10, 0, false, MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -1); + for (MouseWheelListener listener : scrollPane.getMouseWheelListeners()) + { + listener.mouseWheelMoved(wheelUp); + } + }); + }); + settle(); + assertFalse(isAtBottom()); + + panel.append(lines(250, 50)); + settle(); + assertFalse(isAtBottom()); + } + + @Test + public void pageUpStopsFollowing() throws Exception + { + press(KeyEvent.VK_PAGE_UP, 0); + settle(); + assertFalse(isAtBottom()); + + panel.append(lines(200, 50)); + settle(); + assertFalse(isAtBottom()); + } + + @Test + public void ctrlEndAfterPageUpResumesFollowing() throws Exception + { + press(KeyEvent.VK_PAGE_UP, 0); + settle(); + panel.append(lines(200, 50)); + settle(); + assertFalse(isAtBottom()); + + press(KeyEvent.VK_END, InputEvent.CTRL_DOWN_MASK); + settle(); + panel.append(lines(250, 50)); + settle(); + assertTrue(isAtBottom()); + } + + @Test + public void dragSelectionSurvivesTheNextBatch() throws Exception + { + AtomicReference start = new AtomicReference<>(); + onEdt(() -> + { + Point view = scrollPane.getViewport().getViewPosition(); + start.set(new Point(5, view.y + 20)); + textArea.dispatchEvent(mouse(MouseEvent.MOUSE_PRESSED, start.get().x, start.get().y)); + textArea.dispatchEvent(mouse(MouseEvent.MOUSE_DRAGGED, start.get().x + 60, start.get().y + 20)); + textArea.dispatchEvent(mouse(MouseEvent.MOUSE_RELEASED, start.get().x + 60, start.get().y + 20)); + }); + settle(); + String selected = onEdtGet(textArea::getSelectedText); + assertTrue(selected != null && !selected.isEmpty()); + + panel.append(lines(200, 50)); + settle(); + assertEquals(selected, onEdtGet(textArea::getSelectedText)); + } + + @Test + public void wheelDownToTheBottomResumesFollowing() throws Exception + { + onEdt(() -> + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + vertical.setValue(vertical.getMaximum() / 2); + }); + wheel(-3); + settle(); + assertFalse(isAtBottom()); + + onEdt(() -> + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + vertical.setValue(vertical.getMaximum()); + }); + wheel(3); + settle(); + panel.append(lines(200, 50)); + settle(); + assertTrue(isAtBottom()); + } + + @Test + public void starvedEdtInsertsOnlyWhatAFollowerKeeps() throws Exception + { + List inserts = new ArrayList<>(); + onEdt(() -> textArea.getDocument().addDocumentListener(new DocumentListener() + { + @Override + public void insertUpdate(DocumentEvent ev) + { + inserts.add(ev.getLength()); + } + + @Override + public void removeUpdate(DocumentEvent ev) + { + } + + @Override + public void changedUpdate(DocumentEvent ev) + { + } + })); + + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean released = new AtomicBoolean(); + SwingUtilities.invokeLater(() -> + { + blocked.countDown(); + try + { + released.set(release.await(30, TimeUnit.SECONDS)); + } + catch (InterruptedException ex) + { + Thread.currentThread().interrupt(); + } + }); + assertTrue(blocked.await(30, TimeUnit.SECONDS)); + + int next = 1_000; + String last = null; + while (next < 1_000 + 80_000) + { + panel.append(lines(next, 1_000)); + next += 1_000; + last = "line " + (next - 1) + "\n"; + } + release.countDown(); + settle(); + assertTrue(released.get()); + + int largest = inserts.stream().mapToInt(Integer::intValue).max().orElse(0); + assertTrue("largest insert " + largest, largest <= DOCUMENT_CAP); + String text = onEdtGet(() -> + { + try + { + return textArea.getDocument().getText(0, textArea.getDocument().getLength()); + } + catch (BadLocationException ex) + { + throw new IllegalStateException(ex); + } + }); + assertTrue(text.length() <= DOCUMENT_CAP); + assertTrue(text.endsWith(last)); + assertNotEquals(-1, text.indexOf("line " + (next - 2) + "\n")); + } + + private void press(int keyCode, int modifiers) throws Exception + { + onEdt(() -> + { + KeyEvent key = new KeyEvent(textArea, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), + modifiers, keyCode, KeyEvent.CHAR_UNDEFINED); + for (KeyListener listener : textArea.getKeyListeners()) + { + listener.keyPressed(key); + } + Object binding = textArea.getInputMap().get(KeyStroke.getKeyStrokeForEvent(key)); + textArea.getActionMap().get(binding).actionPerformed(new ActionEvent(textArea, ActionEvent.ACTION_PERFORMED, null)); + }); + } + + private void wheel(int rotation) throws Exception + { + onEdt(() -> + { + MouseWheelEvent event = new MouseWheelEvent(scrollPane, MouseEvent.MOUSE_WHEEL, System.currentTimeMillis(), + 0, 10, 10, 0, false, MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, rotation); + for (MouseWheelListener listener : scrollPane.getMouseWheelListeners()) + { + listener.mouseWheelMoved(event); + } + }); + } + + private MouseEvent mouse(int id, int x, int y) + { + int modifiers = id == MouseEvent.MOUSE_RELEASED ? 0 : InputEvent.BUTTON1_DOWN_MASK; + return new MouseEvent(textArea, id, System.currentTimeMillis(), modifiers, x, y, 1, false, MouseEvent.BUTTON1); + } + + private void layOut() + { + for (int i = 0; i < 2; i++) + { + panel.doLayout(); + scrollPane.doLayout(); + scrollPane.getViewport().doLayout(); + } + } + + private void settle() throws Exception + { + for (int i = 0; i < 40; i++) + { + onEdt(this::layOut); + } + } + + private boolean isAtBottom() throws Exception + { + return onEdtGet(() -> + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + return vertical.getValue() + vertical.getVisibleAmount() >= vertical.getMaximum() - 4; + }); + } + + private static String lines(int from, int count) + { + StringBuilder text = new StringBuilder(); + for (int i = from; i < from + count; i++) + { + text.append("line ").append(i).append('\n'); + } + return text.toString(); + } + + private static void onEdt(Runnable runnable) throws Exception + { + SwingUtilities.invokeAndWait(runnable); + } + + private static T onEdtGet(Supplier supplier) throws Exception + { + AtomicReference result = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> result.set(supplier.get())); + return result.get(); + } +} From 9d6527055b16faede46696bbe254d92e89b7f2f1 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:33:53 +0200 Subject: [PATCH 21/26] fix(camera): fall back to a direct camera read instead of a fake 0 getYaw and getPitch returned 0 when the client-thread read failed (interrupted thread or client-thread timeout). A yaw of 0 is north and a pitch of 0 made adjustPitch hold UP, so the failure looked like a real angle. Fall back to reading the camera directly instead. adjustPitch no longer presses DOWN and waits for a settle poll when the pitch is already at the target. Widen the settle-timeout test bound to the cap plus the longest poll plus scheduling slack, and document in movement.md that the settle wait runs to its cap while something else moves the camera. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/movement.md | 12 ++++++++++ .../microbot/util/camera/Rs2Camera.java | 14 ++++++++--- .../util/camera/Rs2CameraSettleTest.java | 23 ++++++++++++++++++- .../client-thread-guardrail-baseline.txt | 2 ++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index ab10e989ba0..1c2cf04ed37 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -467,5 +467,17 @@ interactions click right after `turnTo`, so they could land on a stale position. pitch read the same on two samples taken on different client ticks. Each sample is read on the client thread. Callers should not add their own sleeps after `turnTo`. +The settle check cannot tell our own coasting apart from anything else that moves the camera. +While `NpcTracker` is steering the yaw with `setCameraYawTarget`, the QoL plugin's smooth camera +tracking is on, or the user is dragging the camera, the yaw never holds still for two ticks +and every turn waits the full 600 ms cap before returning. That is the bound working, not a +hang; the view is still moving when the call returns, so a canvas point taken right after it +can still be stale. + +If the client-thread read fails (thread interrupted or the client-thread timeout), `getYaw` and +`getPitch` fall back to reading the camera directly instead of returning 0. A yaw of 0 is +north and a pitch of 0 sends `adjustPitch` holding UP, so a fake 0 looked like a real angle. +`adjustPitch` returns without pressing a key when the pitch is already at the target. + **Defensive check:** `Rs2CameraSettleTest` simulates a camera that coasts after release. It also covers the timeout bound and the client-thread no-op. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java index 917e54db2d5..103f72d4385 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java @@ -148,7 +148,7 @@ public static void adjustPitch(float percentage) { if (currentPitchPercentage < percentage) { holdUntil(KeyEvent.VK_UP, () -> cameraPitchPercentage() >= percentage); - } else { + } else if (currentPitchPercentage > percentage) { holdUntil(KeyEvent.VK_DOWN, () -> cameraPitchPercentage() <= percentage); } } @@ -193,7 +193,11 @@ private static int[] sampleCamera() { } public static int getPitch() { - return fromClientAngleUnits(Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getCameraPitch()).orElse(0)); + return fromClientAngleUnits(Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getCameraPitch()).orElseGet(Rs2Camera::readPitchDirectly)); + } + + private static int readPitchDirectly() { + return Microbot.getClient().getCameraPitch(); } // set camera pitch @@ -308,7 +312,11 @@ public static void setZoom(int zoom) { } // Get camera/compass facing public static int getYaw() { - return fromClientAngleUnits(Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getCameraYaw()).orElse(0)); + return fromClientAngleUnits(Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getCameraYaw()).orElseGet(Rs2Camera::readYawDirectly)); + } + + private static int readYawDirectly() { + return Microbot.getClient().getCameraYaw(); } // Set camera/compass facing diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraSettleTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraSettleTest.java index 4591115d6c1..9732b41646a 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraSettleTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraSettleTest.java @@ -24,6 +24,7 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -35,6 +36,8 @@ public class Rs2CameraSettleTest { private static final int TICK_MS = 20; private static final int HELD_SPEED = 160; private static final int COAST_DECAY = 20; + private static final int MAX_POLL_MS = 320; + private static final int SCHEDULING_SLACK_MS = 1000; private MockedStatic microbot; private MockedStatic keyboard; @@ -140,7 +143,25 @@ public void settleWaitIsBoundedWhenCameraNeverStops() { long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); assertFalse(settled); - assertTrue("settle wait ran " + elapsedMs + "ms", elapsedMs < Rs2Camera.SETTLE_TIMEOUT_MS + 400); + assertTrue("settle wait ran " + elapsedMs + "ms", elapsedMs < Rs2Camera.SETTLE_TIMEOUT_MS + MAX_POLL_MS + SCHEDULING_SLACK_MS); + } + + @Test + public void failedClientThreadReadFallsBackToTheLiveCameraAngles() { + doReturn(Optional.empty()).when(clientThread).runOnClientThreadOptional(any()); + doReturn(Rs2Camera.toClientAngleUnits(1536)).when(client).getCameraYaw(); + doReturn(Rs2Camera.toClientAngleUnits(383)).when(client).getCameraPitch(); + + assertEquals(1536, Rs2Camera.getYaw()); + assertEquals(383, Rs2Camera.getPitch()); + } + + @Test + public void adjustPitchLeavesTheKeysAloneWhenAlreadyAtTarget() { + Rs2Camera.adjustPitch(Rs2Camera.cameraPitchPercentage()); + + keyboard.verify(() -> Rs2Keyboard.keyHold(anyInt()), never()); + keyboard.verify(() -> Rs2Keyboard.keyRelease(anyInt()), never()); } @Test 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 8a1ec12e80d..3054b91a799 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 @@ -117,6 +117,8 @@ net.runelite.client.plugins.microbot.util.bank.Rs2Bank#withdrawLootItems(String, net.runelite.client.plugins.microbot.util.bank.Rs2Bank#withdrawLootItems(String, List): boolean -> net.runelite.api.ItemComposition#isTradeable(): boolean net.runelite.client.plugins.microbot.util.camera.Rs2Camera#getZoom(): int -> net.runelite.api.Client#getVarcIntValue(int): int net.runelite.client.plugins.microbot.util.camera.Rs2Camera#isTileOnScreen(LocalPoint): boolean -> net.runelite.api.Client#getPlane(): int +net.runelite.client.plugins.microbot.util.camera.Rs2Camera#readPitchDirectly(): int -> net.runelite.api.Client#getCameraPitch(): int +net.runelite.client.plugins.microbot.util.camera.Rs2Camera#readYawDirectly(): int -> net.runelite.api.Client#getCameraYaw(): int net.runelite.client.plugins.microbot.util.combat.Rs2Combat#getWeaponAttackStyle(): String -> net.runelite.api.EnumComposition#getIntValue(int): int net.runelite.client.plugins.microbot.util.combat.Rs2Combat#inCombat(): boolean -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.combat.Rs2Combat#setAttackStyle(WidgetInfo): boolean -> net.runelite.api.Client#getWidget(WidgetInfo): Widget From 8b6ae624fcafda77ae03ade059ea2d232466388e Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:52:48 +0200 Subject: [PATCH 22/26] fix(logout): keep terminal-stop Auto Login suppression alive in every state TerminalLogoutPrompt renewed its suppression request only from inside logoutWithoutAutoLoginStep, which reaches the callback only at LOGGED_IN. A client stuck in CONNECTION_LOST or LOADING for more than 15 s let the request lapse, and Auto Login logged back in once the client dropped to the login screen. The prompt now renews from its own tick on every attempt that has not yet seen the login screen. The prompt's first suppression is now a request followed by a renewal, so a request another source already left pending (for example Rs2Player.logoutWithoutAutoLogin from Inventory Setup) gets a fresh deadline instead of lapsing before the prompt's first renewal. A terminal stop whose prompt first sees the login screen without ever having requested suppression (disconnect or break-handler logout) now posts AutoLoginSuppressionRequest.loggedOut(). AutoLogin moves straight to its suppressed logged-out state if it is still on the login screen, and resumes on a manual login, a script start or an AutoLogin restart. The plain logoutWithoutAutoLoginStep still requests nothing at the login screen, and a stop that was reset or replaced mid-tick never holds. Trim the Javadoc lines added in #1950 and bump AutoLogin to 1.0.7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../accountselector/AutoLoginPlugin.java | 4 +- .../accountselector/AutoLoginScript.java | 4 + .../accountselector/AutoLoginSuppression.java | 6 + .../AutoLoginSuppressionRequest.java | 25 ++- .../util/player/TerminalLogoutPrompt.java | 61 ++++--- .../AutoLoginPluginSupplyStopTest.java | 4 + .../AutoLoginSuppressionTest.java | 36 ++++ .../util/player/TerminalLogoutPromptTest.java | 156 +++++++++++++++--- 8 files changed, 238 insertions(+), 58 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java index 682913e5f84..50b4c984d79 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java @@ -23,7 +23,7 @@ @PluginDescriptor( name = PluginDescriptor.Mocrosoft + "AutoLogin", description = "Microbot autologin plugin", - version = "1.0.6", + version = "1.0.7", tags = {"account", "microbot", "login"}, enabledByDefault = false ) @@ -53,6 +53,8 @@ protected void shutDown() { public void onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest event) { if (event.isRenewal()) { accountSelectorScript.renewAutoLoginSuppression(); + } else if (event.isLoggedOut()) { + accountSelectorScript.holdAutoLoginLoggedOut(); } else { accountSelectorScript.suppressAutoLogin(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java index d7033af1d04..97c53740b27 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java @@ -96,6 +96,10 @@ void renewAutoLoginSuppression() { suppression.renew(); } + void holdAutoLoginLoggedOut() { + suppression.holdLoggedOut(LoginManager.getGameState()); + } + void resumeAutoLogin() { suppression.clear(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java index 3854b97d27c..52d0095cea7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java @@ -36,6 +36,12 @@ synchronized void renew() { } } + synchronized void holdLoggedOut(GameState gameState) { + if (gameState == GameState.LOGIN_SCREEN) { + state = State.LOGGED_OUT; + } + } + synchronized void observe(GameState gameState) { expirePendingRequest(); if (state == State.AWAITING_LOGOUT && gameState == GameState.LOGIN_SCREEN) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java index b122abedf0b..3e6dacbcaf0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java @@ -3,29 +3,36 @@ /** * Scripts post this event before a terminal logout to prevent automatic re-login. * AutoLogin resumes when another script starts, after a manual login, or on AutoLogin restart. - * A request that does not reach the login screen within {@link #PENDING_LOGOUT_MILLIS} expires. - * A renewal restarts that deadline only while the earlier request is still pending, so it never - * re-blocks AutoLogin after a restart or a manual login cleared it. * Ordinary logouts must not post this event. */ public final class AutoLoginSuppressionRequest { public static final long PENDING_LOGOUT_MILLIS = 15_000L; - private final boolean renewal; + private enum Kind { REQUEST, RENEWAL, LOGGED_OUT } + + private final Kind kind; public AutoLoginSuppressionRequest() { - this(false); + this(Kind.REQUEST); } - private AutoLoginSuppressionRequest(boolean renewal) { - this.renewal = renewal; + private AutoLoginSuppressionRequest(Kind kind) { + this.kind = kind; } public static AutoLoginSuppressionRequest renewal() { - return new AutoLoginSuppressionRequest(true); + return new AutoLoginSuppressionRequest(Kind.RENEWAL); + } + + public static AutoLoginSuppressionRequest loggedOut() { + return new AutoLoginSuppressionRequest(Kind.LOGGED_OUT); } public boolean isRenewal() { - return renewal; + return kind == Kind.RENEWAL; + } + + public boolean isLoggedOut() { + return kind == Kind.LOGGED_OUT; } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java index 68ae3858811..b9c8eaeec06 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java @@ -9,10 +9,7 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.accountselector.AutoLoginSuppressionRequest; -/** - * Keeps terminal logout separate from notification; the host pauses gameplay until completed. - * Safe to begin or reset from one thread while another thread ticks. - */ +/** Keeps terminal logout separate from notification; the host pauses gameplay until completed. */ public final class TerminalLogoutPrompt { static final long RETRY_MILLIS = 600L; static final long RENEW_SUPPRESSION_MILLIS = AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS / 3; @@ -51,15 +48,16 @@ public synchronized void reset() { public boolean tick() { return throttledTick(Rs2Player::logoutWithoutAutoLoginStep, Microbot::showMessage, () -> Microbot.getEventBus().post(new AutoLoginSuppressionRequest()), - () -> Microbot.getEventBus().post(AutoLoginSuppressionRequest.renewal())); + () -> Microbot.getEventBus().post(AutoLoginSuppressionRequest.renewal()), + () -> Microbot.getEventBus().post(AutoLoginSuppressionRequest.loggedOut())); } public boolean tick(Supplier logout, Consumer notify) { - return tick(ignored -> logout.get(), notify, () -> {}, () -> {}); + return tick(ignored -> logout.get(), notify, () -> {}, () -> {}, () -> {}); } boolean throttledTick(Function logout, Consumer notify, - Runnable requestSuppression, Runnable renewSuppression) { + Runnable requestSuppression, Runnable renewSuppression, Runnable holdLoggedOut) { synchronized (this) { if (message == null) return false; long now = clock.getAsLong(); @@ -67,19 +65,28 @@ boolean throttledTick(Function logout, attempted = true; lastAttemptAt = now; } - return tick(logout, notify, requestSuppression, renewSuppression); + return tick(logout, notify, requestSuppression, renewSuppression, holdLoggedOut); } boolean tick(Function logout, Consumer notify, - Runnable requestSuppression, Runnable renewSuppression) { + Runnable requestSuppression, Runnable renewSuppression, Runnable holdLoggedOut) { long tickStop; synchronized (this) { if (message == null) return false; tickStop = stop; } Rs2Player.TerminalLogoutState state = logout.apply( - () -> suppress(tickStop, requestSuppression, renewSuppression)); - if (state != Rs2Player.TerminalLogoutState.LOGGED_OUT) return false; + () -> requestSuppressionOnce(tickStop, requestSuppression, renewSuppression)); + if (state != Rs2Player.TerminalLogoutState.LOGGED_OUT) { + renewSuppressionIfDue(tickStop, renewSuppression); + return false; + } + boolean neverSuppressed; + synchronized (this) { + if (stop != tickStop || message == null) return false; + neverSuppressed = !suppressionRequested; + } + if (neverSuppressed) holdLoggedOut.run(); String completedMessage; synchronized (this) { if (stop != tickStop || message == null) return false; @@ -91,24 +98,28 @@ boolean tick(Function logout, Consumer< return true; } - private void suppress(long tickStop, Runnable requestSuppression, Runnable renewSuppression) { - Runnable action; + private void requestSuppressionOnce(long tickStop, Runnable requestSuppression, Runnable renewSuppression) { synchronized (this) { - if (stop != tickStop) return; - if (!suppressionRequested) { - action = requestSuppression; - } else if (clock.getAsLong() - suppressionRequestedAt >= RENEW_SUPPRESSION_MILLIS) { - action = renewSuppression; - } else { - return; - } + if (stop != tickStop || suppressionRequested) return; } - action.run(); + requestSuppression.run(); + renewSuppression.run(); + markSuppressed(tickStop); + } + + private void renewSuppressionIfDue(long tickStop, Runnable renewSuppression) { synchronized (this) { - if (stop != tickStop) return; - suppressionRequested = true; - suppressionRequestedAt = clock.getAsLong(); + if (stop != tickStop || !suppressionRequested + || clock.getAsLong() - suppressionRequestedAt < RENEW_SUPPRESSION_MILLIS) return; } + renewSuppression.run(); + markSuppressed(tickStop); + } + + private synchronized void markSuppressed(long tickStop) { + if (stop != tickStop) return; + suppressionRequested = true; + suppressionRequestedAt = clock.getAsLong(); } private void startNewStop() { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java index 22c4a66521e..410e1c38d69 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java @@ -17,6 +17,10 @@ public class AutoLoginPluginSupplyStopTest { plugin.onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest.renewal()); verify(plugin.accountSelectorScript).renewAutoLoginSuppression(); verify(plugin.accountSelectorScript).suppressAutoLogin(); + plugin.onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest.loggedOut()); + verify(plugin.accountSelectorScript).holdAutoLoginLoggedOut(); + verify(plugin.accountSelectorScript).suppressAutoLogin(); + verify(plugin.accountSelectorScript).renewAutoLoginSuppression(); GameStateChanged event = new GameStateChanged(); event.setGameState(GameState.LOGIN_SCREEN); plugin.onGameStateChanged(event); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java index e3558994469..eea76cb8430 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java @@ -126,4 +126,40 @@ public class AutoLoginSuppressionTest { suppression.observe(GameState.LOGGED_IN); assertFalse(suppression.isSuppressed()); } + + @Test public void holdAtTheLoginScreenStaysSuppressedUntilManualLogin() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.holdLoggedOut(GameState.LOGIN_SCREEN); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 100); + suppression.observe(GameState.LOGGING_IN); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGGED_IN); + assertFalse(suppression.isSuppressed()); + } + + @Test public void holdIsClearedByARestart() { + AutoLoginSuppression suppression = new AutoLoginSuppression(); + suppression.holdLoggedOut(GameState.LOGIN_SCREEN); + suppression.clear(); + assertFalse(suppression.isSuppressed()); + } + + @Test public void holdOutsideTheLoginScreenDoesNothing() { + for (GameState state : new GameState[] {GameState.LOGGED_IN, GameState.LOADING, GameState.HOPPING, + GameState.CONNECTION_LOST, GameState.LOGGING_IN, GameState.UNKNOWN}) { + AutoLoginSuppression suppression = new AutoLoginSuppression(); + suppression.holdLoggedOut(state); + assertFalse(state.name(), suppression.isSuppressed()); + } + } + + @Test public void holdCompletesAPendingRequestFromAnotherSource() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + suppression.holdLoggedOut(GameState.LOGIN_SCREEN); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 10); + assertTrue(suppression.isSuppressed()); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java index 6a6eb5d4a4f..2c0bc338fa1 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java @@ -34,57 +34,165 @@ public class TerminalLogoutPromptTest { text -> { throw new AssertionError("old popup appeared"); })); } + private static final Runnable NO_HOLD = () -> { throw new AssertionError("held a stop that requested suppression"); }; + + private static final class FakeSuppression { + private final AtomicLong clock; + private long deadline = Long.MIN_VALUE; + private int requests; + private int renewals; + private int holds; + + FakeSuppression(AtomicLong clock) { + this.clock = clock; + } + + void request() { + requests++; + if (!pending()) deadline = clock.get() + AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS; + } + + void renew() { + renewals++; + if (pending()) deadline = clock.get() + AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS; + } + + void hold() { + holds++; + } + + boolean pending() { + return clock.get() < deadline; + } + } + + private static boolean tick(TerminalLogoutPrompt prompt, FakeSuppression suppression, boolean stepRequests, + Rs2Player.TerminalLogoutState state, List messages) { + return prompt.throttledTick(request -> { + if (stepRequests) request.run(); + return state; + }, messages::add, suppression::request, suppression::renew, suppression::hold); + } + @Test public void suppressionIsRequestedOnceAcrossRetriesAndAgainForANewStop() { TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); int[] requests = {0}; + int[] renewals = {0}; List messages = new ArrayList<>(); prompt.begin("First stop"); for (int i = 0; i < 3; i++) { assertFalse(prompt.tick(request -> { request.run(); return Rs2Player.TerminalLogoutState.BANK_CLOSING; - }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed without delay"); })); + }, messages::add, () -> requests[0]++, () -> renewals[0]++, NO_HOLD)); } assertEquals(1, requests[0]); + assertEquals(1, renewals[0]); assertTrue(prompt.tick(request -> { request.run(); return Rs2Player.TerminalLogoutState.LOGGED_OUT; - }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed without delay"); })); + }, messages::add, () -> requests[0]++, () -> renewals[0]++, NO_HOLD)); assertEquals(1, requests[0]); prompt.begin("Second stop"); assertTrue(prompt.tick(request -> { request.run(); return Rs2Player.TerminalLogoutState.LOGGED_OUT; - }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed without delay"); })); + }, messages::add, () -> requests[0]++, () -> renewals[0]++, NO_HOLD)); assertEquals(2, requests[0]); + assertEquals(2, renewals[0]); assertEquals(List.of("First stop", "Second stop"), messages); } @Test public void delayedLogoutKeepsRenewingSuppressionUntilTheLoginScreen() { AtomicLong clock = new AtomicLong(); TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(clock::get); - int[] requests = {0}; - long[] lastSuppressionAt = {Long.MIN_VALUE}; + FakeSuppression suppression = new FakeSuppression(clock); List messages = new ArrayList<>(); prompt.begin("Ran out of supplies"); long logoutAt = AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS * 3; for (; clock.get() < logoutAt; clock.addAndGet(TerminalLogoutPrompt.RETRY_MILLIS)) { - assertFalse(prompt.throttledTick(request -> { - request.run(); - return Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED; - }, messages::add, () -> { - requests[0]++; - lastSuppressionAt[0] = clock.get(); - }, () -> lastSuppressionAt[0] = clock.get())); - assertTrue(clock.get() - lastSuppressionAt[0] < AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS); + assertFalse(tick(prompt, suppression, true, Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED, messages)); + assertTrue(suppression.pending()); } - assertEquals(1, requests[0]); - assertTrue(prompt.throttledTick(request -> Rs2Player.TerminalLogoutState.LOGGED_OUT, messages::add, - () -> requests[0]++, () -> { throw new AssertionError("renewed after logout"); })); - assertTrue(clock.get() - lastSuppressionAt[0] < AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS); + assertEquals(1, suppression.requests); + int renewals = suppression.renewals; + assertTrue(tick(prompt, suppression, false, Rs2Player.TerminalLogoutState.LOGGED_OUT, messages)); + assertTrue(suppression.pending()); + assertEquals(renewals, suppression.renewals); + assertEquals(0, suppression.holds); + assertEquals(List.of("Ran out of supplies"), messages); + } + + @Test public void suppressionIsRenewedWhileATransientStateBlocksTheLogoutStep() { + AtomicLong clock = new AtomicLong(); + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(clock::get); + FakeSuppression suppression = new FakeSuppression(clock); + List messages = new ArrayList<>(); + prompt.begin("Ran out of supplies"); + assertFalse(tick(prompt, suppression, true, Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED, messages)); + long reconnectedAt = AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS * 3; + for (clock.addAndGet(TerminalLogoutPrompt.RETRY_MILLIS); clock.get() < reconnectedAt; + clock.addAndGet(TerminalLogoutPrompt.RETRY_MILLIS)) { + assertFalse(tick(prompt, suppression, false, Rs2Player.TerminalLogoutState.INPUT_BLOCKED, messages)); + assertTrue("suppression lapsed at " + clock.get() + " ms", suppression.pending()); + } + assertEquals(1, suppression.requests); + assertTrue(tick(prompt, suppression, false, Rs2Player.TerminalLogoutState.LOGGED_OUT, messages)); + assertTrue(suppression.pending()); + assertEquals(0, suppression.holds); + assertEquals(List.of("Ran out of supplies"), messages); + } + + @Test public void firstSuppressionExtendsARequestAnotherSourceAlreadyMade() { + AtomicLong clock = new AtomicLong(); + FakeSuppression suppression = new FakeSuppression(clock); + suppression.request(); + clock.set(AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS - 4_000L); + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(clock::get); + List messages = new ArrayList<>(); + prompt.begin("Ran out of supplies"); + long logoutAt = AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS * 3; + for (; clock.get() < logoutAt; clock.addAndGet(TerminalLogoutPrompt.RETRY_MILLIS)) { + assertFalse(tick(prompt, suppression, true, Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED, messages)); + assertTrue("suppression lapsed at " + clock.get() + " ms", suppression.pending()); + } + assertTrue(tick(prompt, suppression, false, Rs2Player.TerminalLogoutState.LOGGED_OUT, messages)); assertEquals(List.of("Ran out of supplies"), messages); } + @Test public void stopFirstSeenAtTheLoginScreenHoldsAutoLoginBeforeNotifying() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + List events = new ArrayList<>(); + prompt.begin("Ran out of supplies"); + assertTrue(prompt.tick(request -> Rs2Player.TerminalLogoutState.LOGGED_OUT, text -> events.add("notify"), + () -> events.add("request"), () -> events.add("renew"), () -> events.add("hold"))); + assertEquals(List.of("hold", "notify"), events); + assertFalse(prompt.tick(request -> Rs2Player.TerminalLogoutState.LOGGED_OUT, text -> events.add("notify"), + () -> events.add("request"), () -> events.add("renew"), () -> events.add("hold"))); + assertEquals(List.of("hold", "notify"), events); + } + + @Test public void stopWithAPendingRequestDoesNotHoldAtTheLoginScreen() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + List events = new ArrayList<>(); + prompt.begin("Ran out of supplies"); + assertFalse(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.INPUT_BLOCKED; + }, text -> events.add("notify"), () -> events.add("request"), () -> events.add("renew"), NO_HOLD)); + assertTrue(prompt.tick(request -> Rs2Player.TerminalLogoutState.LOGGED_OUT, text -> events.add("notify"), + () -> events.add("request"), () -> events.add("renew"), NO_HOLD)); + assertEquals(List.of("request", "renew", "notify"), events); + } + + @Test public void supplierTickNeverSuppresses() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + List messages = new ArrayList<>(); + prompt.begin("Stop"); + assertTrue(prompt.tick(() -> Rs2Player.TerminalLogoutState.LOGGED_OUT, messages::add)); + assertEquals(List.of("Stop"), messages); + } + @Test public void throttleUsesTheInjectedClockEvenWhenItStartsNegative() { AtomicLong clock = new AtomicLong(-1_000_000L); TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(clock::get); @@ -93,7 +201,7 @@ public class TerminalLogoutPromptTest { Runnable tick = () -> prompt.throttledTick(request -> { attempts[0]++; return Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED; - }, text -> {}, () -> {}, () -> {}); + }, text -> {}, () -> {}, () -> {}, () -> {}); tick.run(); assertEquals(1, attempts[0]); clock.addAndGet(TerminalLogoutPrompt.RETRY_MILLIS - 1); @@ -110,7 +218,7 @@ public class TerminalLogoutPromptTest { @Test public void restartFromAnotherThreadDuringLogoutIsNotCompletedOrSuppressedByTheOldTick() { TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); - int[] requests = {0}; + List suppressions = new ArrayList<>(); List messages = new ArrayList<>(); prompt.begin("Old stop"); assertFalse(prompt.tick(request -> { @@ -127,15 +235,17 @@ public class TerminalLogoutPromptTest { assertFalse(restart.isAlive()); request.run(); return Rs2Player.TerminalLogoutState.LOGGED_OUT; - }, messages::add, () -> requests[0]++, () -> requests[0]++)); - assertEquals(0, requests[0]); + }, messages::add, () -> suppressions.add("request"), () -> suppressions.add("renew"), + () -> suppressions.add("hold"))); + assertTrue(suppressions.isEmpty()); assertTrue(messages.isEmpty()); assertEquals("New stop", prompt.message()); assertTrue(prompt.tick(request -> { request.run(); return Rs2Player.TerminalLogoutState.LOGGED_OUT; - }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed a new stop"); })); - assertEquals(1, requests[0]); + }, messages::add, () -> suppressions.add("request"), () -> suppressions.add("renew"), + () -> suppressions.add("hold"))); + assertEquals(List.of("request", "renew"), suppressions); assertEquals(List.of("New stop"), messages); } } From f534f8341979dc27873b943641cd5f41bfb7be02 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:22:51 +0200 Subject: [PATCH 23/26] fix(ui): keep the saved sidebar tab and stdout intact while closing When the client closes, MicrobotPluginManager stops the Hub plugins and each removeNavigation runs from invokeLater after ConfigManager's final sendConfig. Removing the open tab then stored the fallback tab, so the in-memory value flipped and any later flush in the exit window persisted the wrong tab. ClientUI now marks itself closing on ClientShutdown and removeNavigation no longer stores the tab while closing. bindStdoutAppenders handed the real System.out to logback, and OutputStreamAppender.stop closes its stream, so a stopped or reset LoggerContext closed fd 1. The stream is now wrapped in a close shield. ClientUITest covers both tab paths and that console initialization binds the stdout appenders; ConsoleLogAppenderTest covers the close shield. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../java/net/runelite/client/ui/ClientUI.java | 12 +- .../client/ui/ConsoleLogAppender.java | 25 +- .../net/runelite/client/ui/ClientUITest.java | 250 ++++++++++++++++++ .../client/ui/ConsoleLogAppenderTest.java | 36 +++ 4 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/ui/ClientUITest.java diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java index c4af522cd24..54d64977db0 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java @@ -168,6 +168,7 @@ public class ClientUI private boolean consoleVisible; private boolean sidebarTabRestored; private boolean updatingNavigation; + private boolean closing; @RequiredArgsConstructor private static class HistoryEntry @@ -278,7 +279,10 @@ void removeNavigation(NavigationButton navBtn) : selectedTabHistory.removeLast(); openPanel(entry.navBtn, entry.sidebarOpen); - saveSidebarTab(); + if (!closing) + { + saveSidebarTab(); + } } } finally @@ -290,6 +294,12 @@ void removeNavigation(NavigationButton navBtn) sidebarEntries.remove(navBtn); } + @Subscribe(priority = 100) + private void onClientShutdown(ClientShutdown event) + { + closing = true; + } + @Subscribe private void onGameStateChanged(final GameStateChanged event) { diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java b/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java index 38f4b84ba01..8cc70aadd7b 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java @@ -12,6 +12,8 @@ import ch.qos.logback.core.AppenderBase; import ch.qos.logback.core.ConsoleAppender; import ch.qos.logback.core.joran.spi.ConsoleTarget; +import java.io.FilterOutputStream; +import java.io.IOException; import java.io.OutputStream; import java.util.Iterator; import java.util.function.Consumer; @@ -28,6 +30,7 @@ final class ConsoleLogAppender extends AppenderBase static void bindStdoutAppenders(LoggerContext context, OutputStream stdout) { + OutputStream shielded = new CloseShieldOutputStream(stdout); for (Logger logger : context.getLoggerList()) { Iterator> appenders = logger.iteratorForAppenders(); @@ -37,7 +40,7 @@ static void bindStdoutAppenders(LoggerContext context, OutputStream stdout) if (appender instanceof ConsoleAppender && ConsoleTarget.SystemOut.getName().equals(((ConsoleAppender) appender).getTarget())) { - ((ConsoleAppender) appender).setOutputStream(stdout); + ((ConsoleAppender) appender).setOutputStream(shielded); } } } @@ -63,4 +66,24 @@ protected void append(ILoggingEvent eventObject) String formatted = layout.doLayout(eventObject); logConsumer.accept(formatted); } + + private static final class CloseShieldOutputStream extends FilterOutputStream + { + private CloseShieldOutputStream(OutputStream out) + { + super(out); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException + { + out.write(b, off, len); + } + + @Override + public void close() throws IOException + { + flush(); + } + } } diff --git a/runelite-client/src/test/java/net/runelite/client/ui/ClientUITest.java b/runelite-client/src/test/java/net/runelite/client/ui/ClientUITest.java new file mode 100644 index 00000000000..601081f83b6 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/ui/ClientUITest.java @@ -0,0 +1,250 @@ +package net.runelite.client.ui; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.joran.spi.ConsoleTarget; +import com.google.common.collect.Iterables; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.concurrent.atomic.AtomicReference; +import javax.inject.Provider; +import javax.swing.JScrollPane; +import javax.swing.JTabbedPane; +import javax.swing.JTextArea; +import javax.swing.SwingUtilities; +import javax.swing.text.Document; +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.config.RuneLiteConfig; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.events.ClientShutdown; +import net.runelite.client.input.MouseManager; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.LoggerFactory; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +public class ClientUITest +{ + private static final String CONFIG_GROUP = "runelite"; + private static final String SIDEBAR_TAB = "clientSidebarTab"; + + private ConfigManager configManager; + private EventBus eventBus; + private ClientUI clientUI; + private PrintStream originalOut; + private ConsoleAppender stdoutAppender; + + @Before + public void setUp() throws Exception + { + originalOut = System.out; + configManager = mock(ConfigManager.class); + eventBus = new EventBus(); + Constructor constructor = ClientUI.class.getDeclaredConstructor(RuneLiteConfig.class, MouseManager.class, + Client.class, ConfigManager.class, Provider.class, EventBus.class, boolean.class, String.class); + constructor.setAccessible(true); + clientUI = constructor.newInstance(mock(RuneLiteConfig.class), null, null, configManager, + (Provider) () -> null, eventBus, false, "Microbot"); + eventBus.register(clientUI); + } + + @After + public void tearDown() throws Exception + { + System.setOut(originalOut); + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + ConsoleLogAppender consoleLogAppender = (ConsoleLogAppender) get("consoleLogAppender"); + if (consoleLogAppender != null) + { + root.detachAppender(consoleLogAppender); + consoleLogAppender.stop(); + } + if (stdoutAppender != null) + { + root.detachAppender(stdoutAppender); + stdoutAppender.stop(); + ConsoleLogAppender.bindStdoutAppenders(context, ConsoleTarget.SystemOut.getStream()); + } + } + + @Test + public void removingTheOpenTabStoresTheTabShownInstead() throws Exception + { + NavigationButton first = button("First", 1); + NavigationButton second = button("Second", 2); + onEdt(() -> + { + installSidebar(); + clientUI.addNavigation(first); + clientUI.addNavigation(second); + clientUI.openPanel(first, true); + clientUI.openPanel(second, true); + clientUI.removeNavigation(second); + }); + + verify(configManager).setConfiguration(CONFIG_GROUP, SIDEBAR_TAB, "First"); + } + + @Test + public void removingTheOpenTabWhileClosingKeepsTheSavedTab() throws Exception + { + NavigationButton first = button("First", 1); + NavigationButton second = button("Second", 2); + onEdt(() -> + { + installSidebar(); + clientUI.addNavigation(first); + clientUI.addNavigation(second); + clientUI.openPanel(first, true); + clientUI.openPanel(second, true); + }); + + eventBus.post(new ClientShutdown()); + onEdt(() -> clientUI.removeNavigation(second)); + + verify(configManager, never()).setConfiguration(eq(CONFIG_GROUP), eq(SIDEBAR_TAB), any(String.class)); + verify(configManager, never()).unsetConfiguration(CONFIG_GROUP, SIDEBAR_TAB); + } + + @Test + public void consoleInitializationKeepsStdoutLogLinesOutOfThePanel() throws Exception + { + ByteArrayOutputStream terminal = new ByteArrayOutputStream(); + System.setOut(new PrintStream(terminal, true, StandardCharsets.UTF_8)); + + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setContext(context); + encoder.setPattern("%msg%n"); + encoder.start(); + stdoutAppender = new ConsoleAppender<>(); + stdoutAppender.setContext(context); + stdoutAppender.setEncoder(encoder); + stdoutAppender.start(); + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + root.addAppender(stdoutAppender); + + AtomicReference panel = new AtomicReference<>(); + onEdt(() -> panel.set(new LogConsolePanel())); + set("consolePanel", panel.get()); + Method initialize = ClientUI.class.getDeclaredMethod("initializeConsoleLogging"); + initialize.setAccessible(true); + initialize.invoke(clientUI); + + String line = "console init probe " + System.nanoTime(); + root.warn(line); + onEdt(() -> { }); + onEdt(() -> { }); + + JTextArea textArea = (JTextArea) ((JScrollPane) panel.get().getComponent(0)).getViewport().getView(); + AtomicReference text = new AtomicReference<>(); + onEdt(() -> + { + Document document = textArea.getDocument(); + text.set(document.getText(0, document.getLength())); + }); + + assertEquals(1, occurrences(text.get(), line)); + assertTrue(terminal.toString(StandardCharsets.UTF_8).contains(line)); + } + + private void installSidebar() throws Exception + { + JTabbedPane sidebar = new JTabbedPane(JTabbedPane.RIGHT); + sidebar.setSelectedIndex(-1); + Collection entries = (Collection) get("sidebarEntries"); + sidebar.addChangeListener(ev -> + { + int index = sidebar.getSelectedIndex(); + try + { + set("selectedTab", index < 0 ? null : Iterables.get(entries, index)); + } + catch (ReflectiveOperationException ex) + { + throw new IllegalStateException(ex); + } + }); + set("sidebar", sidebar); + } + + private static NavigationButton button(String tooltip, int priority) + { + return NavigationButton.builder() + .tooltip(tooltip) + .priority(priority) + .icon(new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB)) + .panel(new PluginPanel() + { + }) + .build(); + } + + private Object get(String name) throws ReflectiveOperationException + { + Field field = ClientUI.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(clientUI); + } + + private void set(String name, Object value) throws ReflectiveOperationException + { + Field field = ClientUI.class.getDeclaredField(name); + field.setAccessible(true); + field.set(clientUI, value); + } + + private static int occurrences(String text, String needle) + { + int count = 0; + for (int i = text.indexOf(needle); i >= 0; i = text.indexOf(needle, i + 1)) + { + count++; + } + return count; + } + + private static void onEdt(ThrowingRunnable runnable) throws Exception + { + AtomicReference failure = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> + { + try + { + runnable.run(); + } + catch (Exception ex) + { + failure.set(ex); + } + }); + if (failure.get() != null) + { + throw failure.get(); + } + } + + private interface ThrowingRunnable + { + void run() throws Exception; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java index 07d470ca283..780cc8e6eab 100644 --- a/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java +++ b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java @@ -14,6 +14,7 @@ import org.junit.Before; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; public class ConsoleLogAppenderTest { @@ -72,6 +73,30 @@ public void logLineReachesTheConsoleOnceAndStdoutOnce() assertEquals(1, occurrences(out, "plain print")); } + @Test + public void stoppingTheLoggerContextLeavesStdoutOpen() + { + CloseTrackingStream terminal = new CloseTrackingStream(); + + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setContext(context); + encoder.setPattern("%msg%n"); + encoder.start(); + ConsoleAppender stdout = new ConsoleAppender<>(); + stdout.setContext(context); + stdout.setEncoder(encoder); + stdout.start(); + root.addAppender(stdout); + + ConsoleLogAppender.bindStdoutAppenders(context, terminal); + root.info("before stop"); + context.stop(); + + assertFalse(terminal.closed); + assertEquals(1, occurrences(terminal.toString(StandardCharsets.UTF_8), "before stop")); + } + private static int occurrences(String text, String needle) { int count = 0; @@ -82,6 +107,17 @@ private static int occurrences(String text, String needle) return count; } + private static final class CloseTrackingStream extends ByteArrayOutputStream + { + private boolean closed; + + @Override + public void close() + { + closed = true; + } + } + private static final class Tee extends OutputStream { private final OutputStream first; From ea05a88de375eee917c08b54fe7a6fb0a07f6d4d Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:50:45 +0200 Subject: [PATCH 24/26] fix(ui): keep sidebar state and console logging across close and logback resets While the client closes, removing the open Hub tab fell back to an older history entry and opened or closed the sidebar to match it, which wrote runelite.clientSidebarClosed during the exit window. removeNavigation now keeps the current sidebar visibility while closing, so toggleSidebar is a no-op and nothing is written. A logback reset (scan=true reconfiguration) detached and stopped the console appender, and the recreated STDOUT ConsoleAppender wrote through the System.out tee again. ConsoleLogAppender is now a reset-resistant LoggerContextListener: on reset it reattaches itself and rebinds the STDOUT appenders to the real stdout on the first event after the new configuration attaches them. On context stop it detaches itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../java/net/runelite/client/ui/ClientUI.java | 11 +-- .../client/ui/ConsoleLogAppender.java | 72 ++++++++++++++++++- .../net/runelite/client/ui/ClientUITest.java | 51 ++++++++++++- .../client/ui/ConsoleLogAppenderTest.java | 71 ++++++++++++++++++ 4 files changed, 191 insertions(+), 14 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java index 54d64977db0..1befeff5824 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java @@ -30,7 +30,6 @@ import com.google.common.base.Strings; import com.google.common.collect.Iterables; import com.google.inject.Inject; -import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -278,7 +277,7 @@ void removeNavigation(NavigationButton navBtn) ? new HistoryEntry(true, null) : selectedTabHistory.removeLast(); - openPanel(entry.navBtn, entry.sidebarOpen); + openPanel(entry.navBtn, closing ? sidebar.isVisible() : entry.sidebarOpen); if (!closing) { saveSidebarTab(); @@ -1311,13 +1310,7 @@ private void initializeConsoleLogging() System.setOut(consolePrintStream); LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - ConsoleLogAppender.bindStdoutAppenders(loggerContext, originalOut); - consoleLogAppender = new ConsoleLogAppender(consolePanel::append); - consoleLogAppender.setContext(loggerContext); - consoleLogAppender.start(); - - Logger rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME); - rootLogger.addAppender(consoleLogAppender); + consoleLogAppender = ConsoleLogAppender.install(loggerContext, originalOut, consolePanel::append); } private BufferedImage createConsoleIcon(boolean active) diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java b/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java index 8cc70aadd7b..47e7c328a59 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java @@ -4,10 +4,12 @@ */ package net.runelite.client.ui; +import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.PatternLayout; import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggerContextListener; import ch.qos.logback.core.Appender; import ch.qos.logback.core.AppenderBase; import ch.qos.logback.core.ConsoleAppender; @@ -18,19 +20,47 @@ import java.util.Iterator; import java.util.function.Consumer; -final class ConsoleLogAppender extends AppenderBase +final class ConsoleLogAppender extends AppenderBase implements LoggerContextListener { private final Consumer logConsumer; + private final OutputStream stdout; private PatternLayout layout; + private volatile boolean rebindPending; ConsoleLogAppender(Consumer logConsumer) + { + this(logConsumer, null); + } + + private ConsoleLogAppender(Consumer logConsumer, OutputStream stdout) { this.logConsumer = logConsumer; + this.stdout = stdout; + } + + static ConsoleLogAppender install(LoggerContext context, OutputStream stdout, Consumer logConsumer) + { + ConsoleLogAppender appender = new ConsoleLogAppender(logConsumer, stdout); + appender.setContext(context); + bindStdoutAppenders(context, stdout); + appender.start(); + context.addListener(appender); + context.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(appender); + return appender; + } + + void uninstall() + { + LoggerContext context = (LoggerContext) getContext(); + context.removeListener(this); + context.getLogger(Logger.ROOT_LOGGER_NAME).detachAppender(this); + stop(); } - static void bindStdoutAppenders(LoggerContext context, OutputStream stdout) + static boolean bindStdoutAppenders(LoggerContext context, OutputStream stdout) { OutputStream shielded = new CloseShieldOutputStream(stdout); + boolean bound = false; for (Logger logger : context.getLoggerList()) { Iterator> appenders = logger.iteratorForAppenders(); @@ -41,9 +71,43 @@ static void bindStdoutAppenders(LoggerContext context, OutputStream stdout) && ConsoleTarget.SystemOut.getName().equals(((ConsoleAppender) appender).getTarget())) { ((ConsoleAppender) appender).setOutputStream(shielded); + bound = true; } } } + return bound; + } + + @Override + public boolean isResetResistant() + { + return true; + } + + @Override + public void onStart(LoggerContext context) + { + } + + @Override + public void onReset(LoggerContext context) + { + rebindPending = stdout != null; + start(); + context.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(this); + } + + @Override + public void onStop(LoggerContext context) + { + rebindPending = false; + context.getLogger(Logger.ROOT_LOGGER_NAME).detachAppender(this); + stop(); + } + + @Override + public void onLevelChange(Logger logger, Level level) + { } @Override @@ -63,6 +127,10 @@ protected void append(ILoggingEvent eventObject) { return; } + if (rebindPending && bindStdoutAppenders((LoggerContext) getContext(), stdout)) + { + rebindPending = false; + } String formatted = layout.doLayout(eventObject); logConsumer.accept(formatted); } diff --git a/runelite-client/src/test/java/net/runelite/client/ui/ClientUITest.java b/runelite-client/src/test/java/net/runelite/client/ui/ClientUITest.java index 601081f83b6..4347804b12b 100644 --- a/runelite-client/src/test/java/net/runelite/client/ui/ClientUITest.java +++ b/runelite-client/src/test/java/net/runelite/client/ui/ClientUITest.java @@ -17,6 +17,7 @@ import java.util.Collection; import java.util.concurrent.atomic.AtomicReference; import javax.inject.Provider; +import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTabbedPane; import javax.swing.JTextArea; @@ -34,6 +35,7 @@ import org.junit.Test; import org.slf4j.LoggerFactory; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -45,6 +47,7 @@ public class ClientUITest { private static final String CONFIG_GROUP = "runelite"; private static final String SIDEBAR_TAB = "clientSidebarTab"; + private static final String SIDEBAR_CLOSED = "clientSidebarClosed"; private ConfigManager configManager; private EventBus eventBus; @@ -75,8 +78,7 @@ public void tearDown() throws Exception ConsoleLogAppender consoleLogAppender = (ConsoleLogAppender) get("consoleLogAppender"); if (consoleLogAppender != null) { - root.detachAppender(consoleLogAppender); - consoleLogAppender.stop(); + consoleLogAppender.uninstall(); } if (stdoutAppender != null) { @@ -125,6 +127,32 @@ public void removingTheOpenTabWhileClosingKeepsTheSavedTab() throws Exception verify(configManager, never()).unsetConfiguration(CONFIG_GROUP, SIDEBAR_TAB); } + @Test + public void removingTheOpenTabWhileClosingDoesNotReopenAClosedSidebar() throws Exception + { + NavigationButton first = button("First", 1); + NavigationButton second = button("Second", 2); + AtomicReference sidebar = new AtomicReference<>(); + onEdt(() -> + { + installSidebar(); + sidebar.set((JTabbedPane) get("sidebar")); + clientUI.addNavigation(first); + clientUI.addNavigation(second); + clientUI.openPanel(first, true); + clientUI.openPanel(second, true); + sidebar.get().setVisible(false); + pushHistory(); + }); + + eventBus.post(new ClientShutdown()); + onEdt(() -> clientUI.removeNavigation(second)); + + verify(configManager, never()).setConfiguration(eq(CONFIG_GROUP), eq(SIDEBAR_CLOSED), any(Boolean.class)); + verify(configManager, never()).unsetConfiguration(CONFIG_GROUP, SIDEBAR_CLOSED); + assertFalse(sidebar.get().isVisible()); + } + @Test public void consoleInitializationKeepsStdoutLogLinesOutOfThePanel() throws Exception { @@ -177,7 +205,16 @@ private void installSidebar() throws Exception int index = sidebar.getSelectedIndex(); try { - set("selectedTab", index < 0 ? null : Iterables.get(entries, index)); + NavigationButton selected = index < 0 ? null : Iterables.get(entries, index); + if (selected == get("selectedTab")) + { + return; + } + set("selectedTab", selected); + if (sidebar.isVisible()) + { + pushHistory(); + } } catch (ReflectiveOperationException ex) { @@ -185,6 +222,14 @@ private void installSidebar() throws Exception } }); set("sidebar", sidebar); + set("content", new JPanel()); + } + + private void pushHistory() throws ReflectiveOperationException + { + Method pushHistory = ClientUI.class.getDeclaredMethod("pushHistory"); + pushHistory.setAccessible(true); + pushHistory.invoke(clientUI); } private static NavigationButton button(String tooltip, int priority) diff --git a/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java index 780cc8e6eab..1393e9d1f05 100644 --- a/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java +++ b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java @@ -3,8 +3,11 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.joran.JoranConfigurator; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.joran.spi.JoranException; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; @@ -18,6 +21,13 @@ public class ConsoleLogAppenderTest { + private static final String CONFIG = "" + + "" + + "%msg%n" + + "" + + "" + + ""; + private PrintStream originalOut; private LoggerContext context; @@ -97,6 +107,67 @@ public void stoppingTheLoggerContextLeavesStdoutOpen() assertEquals(1, occurrences(terminal.toString(StandardCharsets.UTF_8), "before stop")); } + @Test + public void reconfigurationKeepsTheConsoleAppenderAndStdoutBinding() throws JoranException + { + ByteArrayOutputStream terminal = new ByteArrayOutputStream(); + ByteArrayOutputStream consolePanel = new ByteArrayOutputStream(); + StringBuilder console = new StringBuilder(); + + configure(); + System.setOut(new PrintStream(new Tee(consolePanel, terminal), true, StandardCharsets.UTF_8)); + ConsoleLogAppender.install(context, terminal, console::append); + + context.reset(); + configure(); + context.getLogger(Logger.ROOT_LOGGER_NAME).info("after reset"); + + assertEquals(1, occurrences(console.toString(), "after reset")); + assertEquals(0, occurrences(consolePanel.toString(StandardCharsets.UTF_8), "after reset")); + assertEquals(1, occurrences(terminal.toString(StandardCharsets.UTF_8), "after reset")); + } + + @Test + public void lineLoggedBeforeReconfigurationEndsDoesNotLeaveStdoutUnbound() throws JoranException + { + ByteArrayOutputStream terminal = new ByteArrayOutputStream(); + ByteArrayOutputStream consolePanel = new ByteArrayOutputStream(); + StringBuilder console = new StringBuilder(); + + configure(); + System.setOut(new PrintStream(new Tee(consolePanel, terminal), true, StandardCharsets.UTF_8)); + ConsoleLogAppender.install(context, terminal, console::append); + + context.reset(); + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + root.info("during reset"); + configure(); + root.info("after reset"); + + assertEquals(1, occurrences(console.toString(), "during reset")); + assertEquals(1, occurrences(console.toString(), "after reset")); + assertEquals(0, occurrences(consolePanel.toString(StandardCharsets.UTF_8), "after reset")); + assertEquals(1, occurrences(terminal.toString(StandardCharsets.UTF_8), "after reset")); + } + + @Test + public void stoppingTheLoggerContextDetachesTheConsoleAppender() + { + ConsoleLogAppender appender = ConsoleLogAppender.install(context, new ByteArrayOutputStream(), line -> { }); + + context.stop(); + + assertFalse(appender.isStarted()); + assertFalse(context.getLogger(Logger.ROOT_LOGGER_NAME).isAttached(appender)); + } + + private void configure() throws JoranException + { + JoranConfigurator configurator = new JoranConfigurator(); + configurator.setContext(context); + configurator.doConfigure(new ByteArrayInputStream(CONFIG.getBytes(StandardCharsets.UTF_8))); + } + private static int occurrences(String text, String needle) { int count = 0; From 00aea206eabfe3152722afb910499e5cbe526270 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:02:23 +0200 Subject: [PATCH 25/26] fix(logging): reattach game chat and error telemetry appenders after logback resets MicrobotPlugin attached GameChatAppender and ScriptErrorReporter to the root logger directly. A LoggerContext reset (logback.xml scan=true reconfiguration) detaches and stops every root appender, so game-chat logging and error telemetry silently stopped for the rest of the session. Plugin shutdown also left the stopped GameChatAppender attached. ResetResistantAttachment is a reset-resistant LoggerContextListener that attaches an appender to the root logger, restarts and reattaches it after every reset (exactly once, addAppender is idempotent), detaches it when the context stops, and on remove() drops the listener, detaches and stops the appender. ConsoleLogAppender, GameChatAppender and ScriptErrorReporter all install through it; MicrobotPlugin removes both attachments on shutdown. GameChatAppender only restarts when game chat logging is enabled, and ScriptErrorReporter.start() is idempotent so the flush task is never scheduled twice. ConsoleLogAppender now gives up rebinding STDOUT after 1000 log events following a reset, instead of scanning the logger list on every line forever when the new configuration has no STDOUT appender. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../plugins/microbot/GameChatAppender.java | 12 ++ .../plugins/microbot/MicrobotPlugin.java | 22 +-- .../diagnostics/ScriptErrorReporter.java | 13 ++ .../client/ui/ConsoleLogAppender.java | 51 ++--- .../client/util/ResetResistantAttachment.java | 91 +++++++++ .../microbot/GameChatAppenderTest.java | 38 ++++ .../diagnostics/ScriptErrorReporterTest.java | 49 +++++ .../client/ui/ConsoleLogAppenderTest.java | 45 ++++- .../util/ResetResistantAttachmentTest.java | 178 ++++++++++++++++++ 9 files changed, 446 insertions(+), 53 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/util/ResetResistantAttachment.java create mode 100644 runelite-client/src/test/java/net/runelite/client/util/ResetResistantAttachmentTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/GameChatAppender.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/GameChatAppender.java index 89e0cb7e8fd..f018f6071f0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/GameChatAppender.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/GameChatAppender.java @@ -1,13 +1,16 @@ package net.runelite.client.plugins.microbot; import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.PatternLayout; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.AppenderBase; import ch.qos.logback.core.Context; import ch.qos.logback.core.filter.Filter; import ch.qos.logback.core.spi.FilterReply; +import java.util.function.BooleanSupplier; import net.runelite.api.ChatMessageType; +import net.runelite.client.util.ResetResistantAttachment; public class GameChatAppender extends AppenderBase { private final PatternLayout layout = new PatternLayout(); @@ -28,6 +31,15 @@ public GameChatAppender() { this("[%d{HH:mm:ss}] %msg%ex{0}%n"); // Default simple pattern } + public ResetResistantAttachment install(LoggerContext context, BooleanSupplier enabled) { + setContext(context); + Runnable startIfEnabled = () -> { + if (enabled.getAsBoolean()) start(); + }; + startIfEnabled.run(); + return ResetResistantAttachment.attach(context, this, startIfEnabled); + } + @Override public void setContext(Context context) { super.setContext(context); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index d27143ddea5..9f25c41883e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -49,8 +49,8 @@ import net.runelite.client.ui.overlay.OverlayManager; import net.runelite.client.ui.overlay.OverlayMenuEntry; import net.runelite.client.util.ImageUtil; +import net.runelite.client.util.ResetResistantAttachment; -import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.inject.Inject; @@ -130,6 +130,8 @@ MicrobotConfig provideConfig(ConfigManager configManager) @Inject private EventBus eventBus; private GameChatAppender gameChatAppender; + private ResetResistantAttachment gameChatAttachment; + private ResetResistantAttachment scriptErrorAttachment; @Inject private MicrobotVersionChecker microbotVersionChecker; @@ -161,18 +163,9 @@ protected void startUp() throws AWTException gameChatAppender.setPattern(pattern); final LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); - gameChatAppender.setContext(context); - context.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(gameChatAppender); + gameChatAttachment = gameChatAppender.install(context, microbotConfig::enableGameChatLogging); + scriptErrorAttachment = scriptErrorReporter.install(context); - scriptErrorReporter.setContext(context); - scriptErrorReporter.start(); - context.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(scriptErrorReporter); - - // Start appender if logging is enabled - if (microbotConfig.enableGameChatLogging()) { - gameChatAppender.start(); - } - // Initialize the cached configuration in GameChatAppender GameChatAppender.updateConfiguration( microbotConfig.enableGameChatLogging(), @@ -235,9 +228,8 @@ protected void shutDown() overlayManager.remove(gembagOverlay); overlayManager.remove(pouchOverlay); clientToolbar.removeNavigation(navButton); - if (gameChatAppender.isStarted()) gameChatAppender.stop(); - ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(Logger.ROOT_LOGGER_NAME).detachAppender(scriptErrorReporter); - scriptErrorReporter.stop(); + gameChatAttachment.remove(); + scriptErrorAttachment.remove(); microbotVersionChecker.shutdown(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java index b328274e18f..09997c7786e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java @@ -1,6 +1,7 @@ package net.runelite.client.plugins.microbot.diagnostics; import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.classic.spi.IThrowableProxy; import ch.qos.logback.classic.spi.StackTraceElementProxy; @@ -28,6 +29,7 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.MicrobotApi; import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; +import net.runelite.client.util.ResetResistantAttachment; @Slf4j @Singleton @@ -59,9 +61,20 @@ public class ScriptErrorReporter extends UnsynchronizedAppenderBase implements LoggerContextListener +final class ConsoleLogAppender extends AppenderBase { + static final int MAX_REBIND_ATTEMPTS = 1000; + private final Consumer logConsumer; private final OutputStream stdout; private PatternLayout layout; - private volatile boolean rebindPending; + private ResetResistantAttachment attachment; + private int rebindAttemptsLeft; ConsoleLogAppender(Consumer logConsumer) { @@ -44,17 +46,13 @@ static ConsoleLogAppender install(LoggerContext context, OutputStream stdout, Co appender.setContext(context); bindStdoutAppenders(context, stdout); appender.start(); - context.addListener(appender); - context.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(appender); + appender.attachment = ResetResistantAttachment.attach(context, appender, appender::restartAfterReset); return appender; } void uninstall() { - LoggerContext context = (LoggerContext) getContext(); - context.removeListener(this); - context.getLogger(Logger.ROOT_LOGGER_NAME).detachAppender(this); - stop(); + attachment.remove(); } static boolean bindStdoutAppenders(LoggerContext context, OutputStream stdout) @@ -78,36 +76,15 @@ static boolean bindStdoutAppenders(LoggerContext context, OutputStream stdout) return bound; } - @Override - public boolean isResetResistant() - { - return true; - } - - @Override - public void onStart(LoggerContext context) + private synchronized void restartAfterReset() { - } - - @Override - public void onReset(LoggerContext context) - { - rebindPending = stdout != null; + rebindAttemptsLeft = stdout != null ? MAX_REBIND_ATTEMPTS : 0; start(); - context.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(this); } - @Override - public void onStop(LoggerContext context) - { - rebindPending = false; - context.getLogger(Logger.ROOT_LOGGER_NAME).detachAppender(this); - stop(); - } - - @Override - public void onLevelChange(Logger logger, Level level) + synchronized boolean isRebindPending() { + return rebindAttemptsLeft > 0; } @Override @@ -127,9 +104,9 @@ protected void append(ILoggingEvent eventObject) { return; } - if (rebindPending && bindStdoutAppenders((LoggerContext) getContext(), stdout)) + if (rebindAttemptsLeft > 0) { - rebindPending = false; + rebindAttemptsLeft = bindStdoutAppenders((LoggerContext) getContext(), stdout) ? 0 : rebindAttemptsLeft - 1; } String formatted = layout.doLayout(eventObject); logConsumer.accept(formatted); diff --git a/runelite-client/src/main/java/net/runelite/client/util/ResetResistantAttachment.java b/runelite-client/src/main/java/net/runelite/client/util/ResetResistantAttachment.java new file mode 100644 index 00000000000..7bcff0e9717 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/util/ResetResistantAttachment.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024 Microbot Contributors + * All rights reserved. + */ +package net.runelite.client.util; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggerContextListener; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.status.ErrorStatus; + +public final class ResetResistantAttachment implements LoggerContextListener +{ + private final LoggerContext context; + private final Appender appender; + private final Runnable onReset; + private volatile boolean removed; + + private ResetResistantAttachment(LoggerContext context, Appender appender, Runnable onReset) + { + this.context = context; + this.appender = appender; + this.onReset = onReset; + } + + public static ResetResistantAttachment attach(LoggerContext context, Appender appender, Runnable onReset) + { + ResetResistantAttachment attachment = new ResetResistantAttachment(context, appender, onReset); + context.addListener(attachment); + root(context).addAppender(appender); + return attachment; + } + + public void remove() + { + removed = true; + context.removeListener(this); + root(context).detachAppender(appender); + appender.stop(); + } + + @Override + public boolean isResetResistant() + { + return true; + } + + @Override + public void onStart(LoggerContext context) + { + } + + @Override + public void onReset(LoggerContext context) + { + if (removed) + { + return; + } + try + { + onReset.run(); + } + catch (RuntimeException e) + { + context.getStatusManager().add(new ErrorStatus("Failed to restart appender " + appender.getName() + " after reset", this, e)); + } + root(context).addAppender(appender); + } + + @Override + public void onStop(LoggerContext context) + { + removed = true; + root(context).detachAppender(appender); + appender.stop(); + } + + @Override + public void onLevelChange(Logger logger, Level level) + { + } + + private static Logger root(LoggerContext context) + { + return context.getLogger(Logger.ROOT_LOGGER_NAME); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/GameChatAppenderTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/GameChatAppenderTest.java index 9e6f49b6ed3..2dc9262d50f 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/GameChatAppenderTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/GameChatAppenderTest.java @@ -10,10 +10,12 @@ import net.runelite.api.Client; import net.runelite.api.GameState; import net.runelite.client.callback.ClientThread; +import net.runelite.client.util.ResetResistantAttachment; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -21,6 +23,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -100,6 +103,41 @@ public void filteredEventDoesNotTouchClientThread() verify(client, never()).addChatMessage(any(), anyString(), anyString(), anyString(), anyBoolean()); } + @Test + public void installedAppenderKeepsMirroringToChatAfterLogbackReset() + { + LoggerContext context = new LoggerContext(); + GameChatAppender installed = new GameChatAppender(); + ResetResistantAttachment attachment = installed.install(context, () -> true); + + context.reset(); + context.getLogger("net.runelite.client.plugins.microbot.walker").warn("walker stalled"); + + assertTrue(installed.isStarted()); + verify(clientThread, times(1)).invoke(any(Runnable.class)); + + attachment.remove(); + context.getLogger("net.runelite.client.plugins.microbot.walker").warn("after remove"); + + assertFalse(installed.isStarted()); + verify(clientThread, times(1)).invoke(any(Runnable.class)); + } + + @Test + public void disabledAppenderStaysStoppedAfterLogbackReset() + { + LoggerContext context = new LoggerContext(); + GameChatAppender installed = new GameChatAppender(); + installed.install(context, () -> false); + + context.reset(); + context.getLogger("net.runelite.client.plugins.microbot.walker").warn("walker stalled"); + + assertFalse(installed.isStarted()); + verify(clientThread, never()).invoke(any(Runnable.class)); + context.stop(); + } + private static Object swapStatic(String name, Object value) throws Exception { Field field = Microbot.class.getDeclaredField(name); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java index 4b3cf86854e..032d9fc9bb1 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java @@ -13,15 +13,19 @@ import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.plugins.microbot.MicrobotApi; import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; +import net.runelite.client.util.ResetResistantAttachment; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -249,4 +253,49 @@ public void sendsNothingWithDisableTelemetryFlag() assertEquals(0, reporter.pendingCount()); verify(api, never()).submitErrors(any(), any()); } + + @Test + public void installedReporterKeepsCollectingErrorsAfterLogbackReset() + { + ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + ScriptErrorReporter installed = new ScriptErrorReporter(api, mock(MicrobotPluginManager.class), executor, sessions, false); + ResetResistantAttachment attachment = installed.install(context); + + context.reset(); + context.getLogger("some.Script").error("loop failed", boom()); + + assertTrue(installed.isStarted()); + assertEquals(1, installed.pendingCount()); + verify(executor, times(2)).scheduleWithFixedDelay(any(), anyLong(), anyLong(), any()); + attachment.remove(); + } + + @Test + public void reinstalledReporterCountsEachErrorOnceAfterLogbackReset() + { + ScriptErrorReporter installed = new ScriptErrorReporter(api, mock(MicrobotPluginManager.class), mock(ScheduledExecutorService.class), sessions, false); + installed.install(context).remove(); + installed.install(context); + + context.reset(); + context.getLogger("some.Script").error("loop failed", boom()); + installed.flush(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JsonObject.class); + verify(api).submitErrors(captor.capture(), any()); + assertEquals(1, captor.getValue().getAsJsonArray("errors").get(0).getAsJsonObject().get("count").getAsInt()); + } + + @Test + public void removedReporterIgnoresErrorsAfterLogbackReset() + { + ScriptErrorReporter installed = new ScriptErrorReporter(api, mock(MicrobotPluginManager.class), mock(ScheduledExecutorService.class), sessions, false); + installed.install(context).remove(); + + context.reset(); + context.getLogger("some.Script").error("loop failed", boom()); + + assertFalse(installed.isStarted()); + assertEquals(0, installed.pendingCount()); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java index 1393e9d1f05..5339de5e8e4 100644 --- a/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java +++ b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java @@ -18,6 +18,7 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; public class ConsoleLogAppenderTest { @@ -28,6 +29,8 @@ public class ConsoleLogAppenderTest + "" + ""; + private static final String NO_STDOUT_CONFIG = ""; + private PrintStream originalOut; private LoggerContext context; @@ -161,11 +164,51 @@ public void stoppingTheLoggerContextDetachesTheConsoleAppender() assertFalse(context.getLogger(Logger.ROOT_LOGGER_NAME).isAttached(appender)); } + @Test + public void reconfigurationWithoutStdoutStopsSearchingAfterBoundedLines() throws JoranException + { + configure(); + ConsoleLogAppender appender = ConsoleLogAppender.install(context, new ByteArrayOutputStream(), line -> { }); + + context.reset(); + configure(NO_STDOUT_CONFIG); + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + for (int i = 0; i < ConsoleLogAppender.MAX_REBIND_ATTEMPTS - 1; i++) + { + root.info("line {}", i); + } + assertTrue(appender.isRebindPending()); + + root.info("last line"); + + assertFalse(appender.isRebindPending()); + } + + @Test + public void uninstallRemovesTheResetListener() throws JoranException + { + StringBuilder console = new StringBuilder(); + configure(); + ConsoleLogAppender.install(context, new ByteArrayOutputStream(), console::append).uninstall(); + + context.reset(); + configure(); + context.getLogger(Logger.ROOT_LOGGER_NAME).info("after uninstall"); + + assertTrue(context.getCopyOfListenerList().isEmpty()); + assertEquals(0, occurrences(console.toString(), "after uninstall")); + } + private void configure() throws JoranException + { + configure(CONFIG); + } + + private void configure(String config) throws JoranException { JoranConfigurator configurator = new JoranConfigurator(); configurator.setContext(context); - configurator.doConfigure(new ByteArrayInputStream(CONFIG.getBytes(StandardCharsets.UTF_8))); + configurator.doConfigure(new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8))); } private static int occurrences(String text, String needle) diff --git a/runelite-client/src/test/java/net/runelite/client/util/ResetResistantAttachmentTest.java b/runelite-client/src/test/java/net/runelite/client/util/ResetResistantAttachmentTest.java new file mode 100644 index 00000000000..5954d2dfdd0 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/util/ResetResistantAttachmentTest.java @@ -0,0 +1,178 @@ +package net.runelite.client.util; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggerContextListener; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.read.ListAppender; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ResetResistantAttachmentTest +{ + private static final String CONFIG = "" + + "" + + "" + + ""; + + private LoggerContext context; + private ListAppender appender; + + @Before + public void setUp() throws JoranException + { + context = new LoggerContext(); + configure(); + appender = new ListAppender<>(); + appender.setContext(context); + appender.start(); + } + + @After + public void tearDown() + { + context.stop(); + } + + @Test + public void appenderIsReattachedOnceAfterReconfiguration() throws JoranException + { + ResetResistantAttachment.attach(context, appender, appender::start); + + context.reset(); + configure(); + root().info("after reset"); + + assertTrue(appender.isStarted()); + assertEquals(1, attachedCount()); + assertEquals(1, appender.list.size()); + } + + @Test + public void appenderIsReattachedAfterEveryReset() throws JoranException + { + ResetResistantAttachment.attach(context, appender, appender::start); + + for (int i = 0; i < 3; i++) + { + context.reset(); + configure(); + } + root().info("after resets"); + + assertEquals(1, attachedCount()); + assertEquals(1, appender.list.size()); + } + + @Test + public void removedAttachmentLeavesNoListenerAndIsNotReattached() throws JoranException + { + ResetResistantAttachment attachment = ResetResistantAttachment.attach(context, appender, appender::start); + + attachment.remove(); + context.reset(); + configure(); + root().info("after reset"); + + assertFalse(context.getCopyOfListenerList().contains(attachment)); + assertFalse(appender.isStarted()); + assertEquals(0, attachedCount()); + assertEquals(0, appender.list.size()); + } + + @Test + public void reattachingAfterRemoveKeepsOneListenerAndOneAttachment() throws JoranException + { + ResetResistantAttachment.attach(context, appender, appender::start).remove(); + appender.start(); + ResetResistantAttachment.attach(context, appender, appender::start); + + context.reset(); + configure(); + root().info("after restart and reset"); + + assertEquals(1, attachmentListeners()); + assertEquals(1, attachedCount()); + assertEquals(1, appender.list.size()); + } + + @Test + public void failingRestartStillReattachesAndLetsOtherListenersRun() throws JoranException + { + ResetResistantAttachment.attach(context, appender, () -> + { + throw new IllegalStateException("executor rejected"); + }); + ListAppender other = new ListAppender<>(); + other.setContext(context); + other.start(); + ResetResistantAttachment.attach(context, other, other::start); + + context.reset(); + configure(); + root().info("after reset"); + + assertEquals(1, attachedCount()); + assertEquals(1, other.list.size()); + } + + @Test + public void stoppingTheContextDetachesAndStopsTheAppender() + { + ResetResistantAttachment.attach(context, appender, appender::start); + + context.stop(); + + assertFalse(appender.isStarted()); + assertEquals(0, attachedCount()); + } + + private int attachedCount() + { + int count = 0; + Iterator> appenders = root().iteratorForAppenders(); + while (appenders.hasNext()) + { + if (appenders.next() == appender) + { + count++; + } + } + return count; + } + + private int attachmentListeners() + { + int count = 0; + for (LoggerContextListener listener : context.getCopyOfListenerList()) + { + if (listener instanceof ResetResistantAttachment) + { + count++; + } + } + return count; + } + + private Logger root() + { + return context.getLogger(Logger.ROOT_LOGGER_NAME); + } + + private void configure() throws JoranException + { + JoranConfigurator configurator = new JoranConfigurator(); + configurator.setContext(context); + configurator.doConfigure(new ByteArrayInputStream(CONFIG.getBytes(StandardCharsets.UTF_8))); + } +} From 834248fefb5fd6e0a9352faa160ff892505972bb Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:08:42 +0200 Subject: [PATCH 26/26] chore(release): bump microbot.version to 2.6.32 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 3a10760e10a..472b0f88d62 100644 --- a/gradle.properties +++ b/gradle.properties @@ -32,7 +32,7 @@ project.build.version=1.13.1 runelite.injected-client.version=1.13.1 glslang.path= -microbot.version=2.6.31 +microbot.version=2.6.32 microbot.commit.sha=nogit microbot.repo.url=http://138.201.81.246:8081/repository/microbot-snapshot/ microbot.repo.username=