From 3a05d43bc4d77cfa9ab17e5ccde48e9cf435c8cc Mon Sep 17 00:00:00 2001 From: ProductOfAmerica <14712114+ProductOfAmerica@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:13:37 -0500 Subject: [PATCH 1/2] feat: yield input to the user, and clean up what the bot emits Scripts now emit through an input layer that gives way to real input. Moving the mouse or pressing a key on the game canvas pauses scripts, aborts the gesture in flight, releases anything held down, and resumes after an idle window. Threshold, delay and an off switch are configurable. What the bot emits now matches the shape of real input: the click triad without the spurious ENTERED/EXITED pair, position read from actual events instead of a bot-only field that human motion could never update, and canvas boundary crossings in both directions, so leaving and returning looks like a pointer that exists. Crossing outward also fixes the moveMouseOffScreen antiban setting, which never left the canvas. MouseMotion clamps its destination to the canvas, so the cursor walked to the edge and stopped there while the client went on believing a pointer was over it. Fixes found along the way: - a takeover mid-typeString sprayed the rest of the string into the widget - a key held when the window lost focus stayed down forever - the cursor overlay drew beneath the right-click menu --- .../devtools/MicrobotMouseOverlay.java | 15 +- .../client/plugins/microbot/AGENTS.md | 6 + .../plugins/microbot/MicrobotConfig.java | 42 ++ .../plugins/microbot/MicrobotOverlay.java | 28 +- .../plugins/microbot/MicrobotPlugin.java | 19 + .../client/plugins/microbot/Script.java | 14 +- .../client/plugins/microbot/util/Global.java | 95 +++- .../microbot/util/camera/Rs2Camera.java | 30 +- .../microbot/util/input/AwtEmitter.java | 303 ++++++++++++ .../util/input/CanvasInputListener.java | 234 +++++++++ .../microbot/util/input/InputArbiter.java | 217 +++++++++ .../microbot/util/input/InputDiagnostics.java | 99 ++++ .../microbot/util/input/InputLoop.java | 191 ++++++++ .../microbot/util/input/PointerState.java | 135 ++++++ .../microbot/util/input/StretchMapper.java | 87 ++++ .../microbot/util/keyboard/Rs2Keyboard.java | 166 ++++--- .../plugins/microbot/util/mouse/Mouse.java | 6 +- .../microbot/util/mouse/VirtualMouse.java | 242 ++++------ .../util/mouse/naturalmouse/NaturalMouse.java | 33 +- .../microbot/util/walker/Rs2Walker.java | 11 + .../util/input/CanvasBoundaryTest.java | 451 ++++++++++++++++++ .../input/FocusLossReleasesHeldInputTest.java | 169 +++++++ .../microbot/util/input/GestureAbortTest.java | 378 +++++++++++++++ .../microbot/util/input/InputArbiterTest.java | 311 ++++++++++++ .../util/input/InputDiagnosticsTest.java | 89 ++++ .../util/input/InputEmissionTest.java | 269 +++++++++++ .../microbot/util/input/YieldOnHumanTest.java | 225 +++++++++ .../keyboard/Rs2KeyboardHeldKeysTest.java | 255 ++++++++++ 28 files changed, 3854 insertions(+), 266 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/AwtEmitter.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/CanvasInputListener.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputArbiter.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputDiagnostics.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputLoop.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/PointerState.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/StretchMapper.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/CanvasBoundaryTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/FocusLossReleasesHeldInputTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/GestureAbortTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputArbiterTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputDiagnosticsTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputEmissionTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/YieldOnHumanTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2KeyboardHeldKeysTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/devtools/MicrobotMouseOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/devtools/MicrobotMouseOverlay.java index fd2fc660a83..7f95f88360e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/devtools/MicrobotMouseOverlay.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/devtools/MicrobotMouseOverlay.java @@ -5,6 +5,7 @@ import net.runelite.api.Client; import net.runelite.api.Point; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.input.PointerState; import net.runelite.client.ui.overlay.Overlay; import net.runelite.client.ui.overlay.OverlayLayer; import net.runelite.client.ui.overlay.OverlayPosition; @@ -26,7 +27,9 @@ public class MicrobotMouseOverlay extends Overlay { this.client = client; this.plugin = plugin; setPosition(OverlayPosition.DYNAMIC); - setLayer(OverlayLayer.ABOVE_WIDGETS); + // Not ABOVE_WIDGETS: that layer is defined as "render under the right-click menu", so the + // crosshair vanished behind an open context menu. A real cursor draws above it. + setLayer(OverlayLayer.ALWAYS_ON_TOP); setPriority(Overlay.PRIORITY_LOW); setNaughty(); // Increase the angle @@ -51,7 +54,10 @@ public class MicrobotMouseOverlay extends Overlay { @Override public Dimension render(Graphics2D g) { - if (plugin.getMouseMovement().isActive()) { + // Nothing to draw while the pointer is off the canvas: the client believes none is there, + // so a crosshair where the user left it is a phantom. Falls through to the branch that + // clears the trail, so returning rebuilds it from wherever the pointer comes back. + if (plugin.getMouseMovement().isActive() && !PointerState.isOutside()) { if (!Microbot.getMouse().getTimer().isRunning()) { Microbot.getMouse().getPoints().clear(); Microbot.getMouse().getTimer().start(); @@ -125,8 +131,9 @@ public Dimension render(Graphics2D g) { g2d.dispose(); // Mouse position - int x = Microbot.getMouse().getLastMove().getX(); - int y = Microbot.getMouse().getLastMove().getY(); + Point cursor = PointerState.get(); + int x = cursor.getX(); + int y = cursor.getY(); // Draw the crosshair centered diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/AGENTS.md b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/AGENTS.md index 0cf61444e73..b3d592c3191 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/AGENTS.md +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/AGENTS.md @@ -21,6 +21,12 @@ Config UI uses the custom `MicrobotConfigPanel` (`plugins/microbot/ui`), not Run Check `docs/entity-guides/README.md` before modifying anything under `util/` — each entity type has documented footguns. +## Waits and multi-action helpers +- **A wait that returned is not a wait that succeeded.** Every `sleepUntil*` ends early on a timeout, an interrupt, or a human taking over. Discarding the result means acting as though the previous game action landed when nothing observed that. +- **Never report success you did not observe.** A helper returning `true` unconditionally after a wait leaves its caller no way to find out. +- **A helper spanning more than one game action exposes a step.** The game is a remote asynchronous server, so no sequence of actions can be made atomic; the only option is re-deriving from observed state, which the 600ms `Script.run()` tick already does. A helper blocking for seconds is reimplementing that loop inside one iteration of it. +- Copy `Rs2Walker`: `walkStep` issues one click and returns a `WalkerState`, with the blocking `walkTo` built on top. The enum matters as much as the split, since `if (walkStep(...))` does not compile while `if (someBooleanHelper(...))` silently discards the answer. + ## Scripts - Extend `Script` (or `StateMachineScript` for 3+ phase scripts — see `statemachine/AGENTS.md`). - Implement `run(TConfig)`; return `true` to signal successful start. 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 a10ee059952..245a5ab6f6e 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 @@ -230,4 +230,46 @@ default boolean showCacheInfo() { default boolean disableTelemetry() { return false; } + + String keyDisableInputYielding = "disableInputYielding"; + @ConfigItem( + keyName = keyDisableInputYielding, + name = "Disable input yielding", + description = "Stop scripts pausing when you use the real mouse or keyboard on the game canvas. " + + "Leave this off unless yielding misfires; with it on, your input and the bot's compete for the cursor.", + position = 7, + section = generalSection + ) + default boolean disableInputYielding() + { + return false; + } + + String keyInputMotionThresholdPx = "inputMotionThresholdPx"; + @ConfigItem( + keyName = keyInputMotionThresholdPx, + name = "Input yield: motion threshold", + description = "How far the real mouse must move, in pixels from where the bot last put it, before scripts yield. " + + "Lower reacts sooner and risks yielding on an accidental nudge.", + position = 8, + section = generalSection + ) + default int inputMotionThresholdPx() + { + return 10; + } + + String keyInputIdleResumeMs = "inputIdleResumeMs"; + @ConfigItem( + keyName = keyInputIdleResumeMs, + name = "Input yield: resume delay (ms)", + description = "How long the real mouse and keyboard must stay quiet before scripts resume. " + + "Never resumes while you are holding a button or key, whatever this is set to.", + position = 9, + section = generalSection + ) + default int inputIdleResumeMs() + { + return 1800; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotOverlay.java index 8a3be400409..48e9d8b7813 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotOverlay.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotOverlay.java @@ -5,11 +5,15 @@ import net.runelite.api.Point; import net.runelite.api.coords.LocalPoint; import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.input.InputArbiter; +import net.runelite.client.plugins.microbot.util.input.InputDiagnostics; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; import net.runelite.client.ui.overlay.OverlayPanel; import net.runelite.client.ui.overlay.OverlayPosition; import net.runelite.client.ui.overlay.OverlayUtil; +import net.runelite.client.ui.overlay.components.LineComponent; +import net.runelite.client.ui.overlay.components.TitleComponent; import javax.annotation.Nullable; import javax.inject.Inject; @@ -30,7 +34,29 @@ public class MicrobotOverlay extends OverlayPanel { @Override public Dimension render(Graphics2D graphics) { - panelComponent.setPreferredSize(new Dimension(200, 300)); + panelComponent.setPreferredSize(new Dimension(InputDiagnostics.isEnabled() ? 260 : 200, 300)); + panelComponent.getChildren().clear(); + + // A silent pause is indistinguishable from a crash. Empty, and so invisible, otherwise. + if (InputArbiter.isHuman()) { + panelComponent.getChildren().add(TitleComponent.builder() + .text("Paused for your input") + .color(Color.YELLOW) + .build()); + } + + if (InputDiagnostics.isEnabled()) { + panelComponent.getChildren().add(TitleComponent.builder() + .text("input") + .color(Color.WHITE) + .build()); + for (Map.Entry row : InputDiagnostics.readout().entrySet()) { + panelComponent.getChildren().add(LineComponent.builder() + .left(row.getKey()) + .right(row.getValue()) + .build()); + } + } for (Map.Entry dangerousTile : Rs2Tile.getDangerousGraphicsObjectTiles().entrySet()) { drawTile(graphics, dangerousTile.getKey(), Color.RED, dangerousTile.getValue().toString()); 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 ffafb969af5..80457486f78 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 @@ -26,6 +26,8 @@ import net.runelite.client.plugins.microbot.util.huntkit.Rs2HuntKit; import net.runelite.client.plugins.microbot.util.inventory.Rs2Gembag; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.input.CanvasInputListener; +import net.runelite.client.plugins.microbot.util.input.InputArbiter; import net.runelite.client.plugins.microbot.util.inventory.Rs2RunePouch; import net.runelite.client.plugins.microbot.util.overlay.GembagOverlay; import net.runelite.client.plugins.microbot.util.player.Rs2Player; @@ -164,6 +166,10 @@ protected void startUp() throws AWTException ); Microbot.pauseAllScripts.set(false); + InputArbiter.setDisabled(microbotConfig.disableInputYielding()); + InputArbiter.setMotionThresholdPx(microbotConfig.inputMotionThresholdPx()); + InputArbiter.setIdleResumeMs(microbotConfig.inputIdleResumeMs()); + CanvasInputListener.attach(); Microbot.enableAutoRunOn = microbotConfig.enableAutoRunOn(); Microbot.useStaminaPotsIfNeeded = microbotConfig.useStaminaPotsIfNeeded(); Microbot.getBlockingEventManager().start(); @@ -206,6 +212,7 @@ protected void startUp() throws AWTException protected void shutDown() { + CanvasInputListener.detach(); overlayManager.remove(microbotOverlay); overlayManager.remove(gembagOverlay); overlayManager.remove(pouchOverlay); @@ -490,6 +497,15 @@ public void onConfigChanged(ConfigChanged ev) case MicrobotConfig.keyUseStaminaPotsIfNeeded: Microbot.useStaminaPotsIfNeeded = microbotConfig.useStaminaPotsIfNeeded(); break; + case MicrobotConfig.keyDisableInputYielding: + InputArbiter.setDisabled(microbotConfig.disableInputYielding()); + break; + case MicrobotConfig.keyInputMotionThresholdPx: + InputArbiter.setMotionThresholdPx(microbotConfig.inputMotionThresholdPx()); + break; + case MicrobotConfig.keyInputIdleResumeMs: + InputArbiter.setIdleResumeMs(microbotConfig.inputIdleResumeMs()); + break; case MicrobotConfig.keyEnableGameChatLogging: case MicrobotConfig.keyGameChatLogPattern: case MicrobotConfig.keyGameChatLogLevel: @@ -607,6 +623,9 @@ public void onOverlayMenuClicked(OverlayMenuClicked overlayMenuClicked) @Subscribe public void onGameTick(GameTick event) { + // Cheap identity check: a stale registration leaves the arbiter deaf with no other symptom. + CanvasInputListener.attach(); + // Start Leagues teleport calibration ASAP after login (non-blocking; prompts for consent once). Rs2LeaguesTransport.tickLeaguesCalibration(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java index 9994f5eb40f..a98ac6a03d0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java @@ -7,7 +7,9 @@ import net.runelite.client.plugins.microbot.util.Global; import net.runelite.client.plugins.microbot.agentserver.handler.ScriptHeartbeatRegistry; import net.runelite.client.plugins.microbot.util.antiban.SessionFatigue; +import net.runelite.client.plugins.microbot.util.input.InputArbiter; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import org.jetbrains.annotations.NotNull; @@ -60,6 +62,9 @@ public void shutdown() { if (Microbot.getClientThread().scheduledFuture != null) Microbot.getClientThread().scheduledFuture.cancel(true); initialPlayerLocation = null; + // Backstop for a script stopped between a hold and its release. Fires inconsistently: + // most scripts catch-and-continue without reaching here. + Rs2Keyboard.releaseHeldKeys(); Microbot.pauseAllScripts.set(false); Rs2Walker.disableTeleports = false; Microbot.getSpecialAttackConfigs().reset(); @@ -88,7 +93,14 @@ public boolean run() { // A blocking event was found & is executing return false; } - if (Microbot.pauseAllScripts.get()) + // The arbiter keeps its own flag, so a takeover idles every script through the gate that + // already exists, cancelling nothing. + boolean humanOwnsInput = InputArbiter.isHuman(); + if (humanOwnsInput) { + // A held key is not gesture-scoped, so InputLoop cannot unwind it. + Rs2Keyboard.releaseHeldKeys(); + } + if (Microbot.pauseAllScripts.get() || humanOwnsInput) return false; if (Thread.currentThread().isInterrupted()) return false; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Global.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Global.java index 1baaa658d5d..fd2cb18b97b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Global.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Global.java @@ -3,14 +3,29 @@ import lombok.SneakyThrows; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.util.antiban.SessionFatigue; +import net.runelite.client.plugins.microbot.util.input.InputArbiter; import net.runelite.client.plugins.microbot.util.math.Rs2Random; import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; public class Global { static ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(10); - static ScheduledFuture scheduledFuture; + + /** + * Every wait here treats a human takeover as terminal. + * + *

Deliberately not terminal on {@code pauseAllScripts}. That flag doubles as a transient + * guard a script raises around its own action sequence and then keeps waiting inside; see + * {@code Rs2GroundItem.runWhilePaused}. Making these waits no-ops there would break it. + */ + private static boolean humanOwnsInput() { + return InputArbiter.isHuman(); + } + + private static final int SLEEP_SLICE_MS = 50; private static final int POLL_MIN_MS = 40; private static final int POLL_MAX_MS = 320; @@ -25,23 +40,53 @@ static int nextPollIntervalMs() { return (int) sample; } + /** + * Polls a condition off-thread and runs the callback once it holds. Stops without running the + * callback if the human takes over. + * + *

The future is held per call, not in a static field: two concurrent callers raced on that + * and could cancel each other. Cancellation is non-interrupting because the task cancels + * itself, and {@code cancel(true)} would leave an interrupt flag on a pooled thread. + */ public static ScheduledFuture awaitExecutionUntil(Runnable callback, BooleanSupplier awaitedCondition, int time) { - scheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { - if (awaitedCondition.getAsBoolean()) { - scheduledFuture.cancel(true); - scheduledFuture = null; - callback.run(); - } - }, 0, time, TimeUnit.MILLISECONDS); - return scheduledFuture; + final AtomicReference> holder = new AtomicReference<>(); + final AtomicBoolean finished = new AtomicBoolean(); + + Runnable poll = () -> { + if (finished.get()) return; + boolean human = humanOwnsInput(); + if (!human && !awaitedCondition.getAsBoolean()) return; + if (!finished.compareAndSet(false, true)) return; + cancelQuietly(holder); + if (!human) callback.run(); + }; + + ScheduledFuture future = scheduledExecutorService.scheduleWithFixedDelay(poll, 0, time, TimeUnit.MILLISECONDS); + holder.set(future); + // The first poll runs with zero initial delay, so it can finish before the line above. + if (finished.get()) cancelQuietly(holder); + return future; + } + + private static void cancelQuietly(AtomicReference> holder) { + ScheduledFuture future = holder.get(); + if (future != null) future.cancel(false); } + /** Sliced so a takeover cuts the remainder short. Every fixed-sleep wrapper funnels here. */ public static void sleep(int start) { if (Microbot.getClient().isClientThread()) return; - try { - Thread.sleep(start); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); + long remaining = start; + while (remaining > 0) { + if (humanOwnsInput()) return; + long slice = Math.min(remaining, SLEEP_SLICE_MS); + try { + Thread.sleep(slice); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return; + } + remaining -= slice; } } @@ -96,7 +141,7 @@ public static T sleepUntilNotNull(Callable method, int timeoutMillis, int T methodResponse; final long endTime = System.currentTimeMillis()+timeoutMillis; do { - if (Thread.currentThread().isInterrupted()) { + if (Thread.currentThread().isInterrupted() || humanOwnsInput()) { return null; } methodResponse = method.call(); @@ -127,6 +172,7 @@ public static boolean sleepUntil(BooleanSupplier awaitedCondition, int time) { long startTime = System.currentTimeMillis(); try { while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() - startTime < time) { + if (humanOwnsInput()) return false; if (awaitedCondition.getAsBoolean()) return true; sleep(nextPollIntervalMs()); } @@ -142,6 +188,7 @@ public static boolean sleepUntil(BooleanSupplier awaitedCondition, Runnable acti long timeoutNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMillis); try { while (!Thread.currentThread().isInterrupted() && System.nanoTime() - startTime < timeoutNanos) { + if (humanOwnsInput()) return false; if (awaitedCondition.getAsBoolean()) { return true; } @@ -159,7 +206,7 @@ public static boolean sleepUntilTrue(BooleanSupplier awaitedCondition) { long startTime = System.currentTimeMillis(); try { do { - if (Thread.currentThread().isInterrupted()) { + if (Thread.currentThread().isInterrupted() || humanOwnsInput()) { return false; } if (awaitedCondition.getAsBoolean()) { @@ -178,7 +225,7 @@ public static boolean sleepUntilTrue(BooleanSupplier awaitedCondition, int time, long startTime = System.currentTimeMillis(); try { do { - if (Thread.currentThread().isInterrupted()) { + if (Thread.currentThread().isInterrupted() || humanOwnsInput()) { return false; } if (awaitedCondition.getAsBoolean()) { @@ -197,7 +244,7 @@ public static boolean sleepUntilTrue(BooleanSupplier awaitedCondition, BooleanSu long startTime = System.currentTimeMillis(); try { do { - if (Thread.currentThread().isInterrupted()) { + if (Thread.currentThread().isInterrupted() || humanOwnsInput()) { return false; } if (resetCondition.getAsBoolean()) { @@ -227,7 +274,8 @@ public static void sleepUntilOnClientThread(BooleanSupplier awaitedCondition, in long startTime = System.currentTimeMillis(); try { do { - if (Thread.currentThread().isInterrupted()) { + // Never calls sleep(): it spins on the client-thread round trip. + if (Thread.currentThread().isInterrupted() || humanOwnsInput()) { return; } done = Microbot.getClientThread().runOnClientThreadOptional(awaitedCondition::getAsBoolean).orElse(false); @@ -242,12 +290,18 @@ public boolean sleepUntilTick(int ticksToWait) { return sleepTicks(ticksToWait); } + /** + * The one wait that cannot be immediate: it blocks on a {@code CountDownLatch} released by the + * GameTick event, so a takeover only surfaces at the next tick or the latch timeout. Checked + * either side of the await. + */ public static boolean sleepUntilNextTick() { if (Microbot.getClient().isClientThread()) return false; + if (humanOwnsInput()) return false; GameTickBroadcaster broadcaster = Microbot.getGameTickBroadcaster(); if (broadcaster == null) return false; try { - return broadcaster.awaitNextTick(); + return broadcaster.awaitNextTick() && !humanOwnsInput(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; @@ -256,10 +310,11 @@ public static boolean sleepUntilNextTick() { public static boolean sleepUntilNextTick(long timeoutMs) { if (Microbot.getClient().isClientThread()) return false; + if (humanOwnsInput()) return false; GameTickBroadcaster broadcaster = Microbot.getGameTickBroadcaster(); if (broadcaster == null) return false; try { - return broadcaster.awaitNextTick(timeoutMs); + return broadcaster.awaitNextTick(timeoutMs) && !humanOwnsInput(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; 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 e0ea763e691..11be71ee6a4 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 @@ -136,13 +136,9 @@ public static void setAngle(int targetDegrees, int maxAngle) { Microbot.getClient().setCameraSpeed(3f); if (getAngleTo(targetDegrees) > maxAngle) { - Rs2Keyboard.keyHold(KeyEvent.VK_LEFT); - Global.sleepUntilTrue(() -> Math.abs(getAngleTo(targetDegrees)) <= maxAngle, 50, 5000); - Rs2Keyboard.keyRelease(KeyEvent.VK_LEFT); + holdUntil(KeyEvent.VK_LEFT, () -> Math.abs(getAngleTo(targetDegrees)) <= maxAngle); } else if (getAngleTo(targetDegrees) < -maxAngle) { - Rs2Keyboard.keyHold(KeyEvent.VK_RIGHT); - Global.sleepUntilTrue(() -> Math.abs(getAngleTo(targetDegrees)) <= maxAngle, 50, 5000); - Rs2Keyboard.keyRelease(KeyEvent.VK_RIGHT); + holdUntil(KeyEvent.VK_RIGHT, () -> Math.abs(getAngleTo(targetDegrees)) <= maxAngle); } Microbot.getClient().setCameraSpeed((float) defaultCameraSpeed); } @@ -151,13 +147,23 @@ public static void adjustPitch(float percentage) { float currentPitchPercentage = cameraPitchPercentage(); if (currentPitchPercentage < percentage) { - Rs2Keyboard.keyHold(KeyEvent.VK_UP); - Global.sleepUntilTrue(() -> cameraPitchPercentage() >= percentage, 50, 5000); - Rs2Keyboard.keyRelease(KeyEvent.VK_UP); + holdUntil(KeyEvent.VK_UP, () -> cameraPitchPercentage() >= percentage); } else { - Rs2Keyboard.keyHold(KeyEvent.VK_DOWN); - Global.sleepUntilTrue(() -> cameraPitchPercentage() <= percentage, 50, 5000); - Rs2Keyboard.keyRelease(KeyEvent.VK_DOWN); + holdUntil(KeyEvent.VK_DOWN, () -> cameraPitchPercentage() <= percentage); + } + } + + /** + * Holds an arrow key until the camera arrives, then always releases it. The {@code finally} + * matters: if the condition throws in between, the key stays down at the client with nothing + * to clear it, since releaseHeldKeys only runs on a takeover. + */ + private static void holdUntil(int keyCode, java.util.function.BooleanSupplier reached) { + Rs2Keyboard.keyHold(keyCode); + try { + Global.sleepUntilTrue(reached, 50, 5000); + } finally { + Rs2Keyboard.keyRelease(keyCode); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/AwtEmitter.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/AwtEmitter.java new file mode 100644 index 00000000000..b7137ef3c0e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/AwtEmitter.java @@ -0,0 +1,303 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Point; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.mouse.BotEventGuard; + +import java.awt.AWTEvent; +import java.awt.Canvas; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Dispatches synthetic AWT mouse events on the game canvas, taking canvas coordinates. + * + *

Does not decide whether an emit is allowed; that is {@link InputArbiter}'s job, upstream. + */ +public final class AwtEmitter +{ + /** Exit coordinates can land a pixel outside the component. */ + private static final int EDGE_SLOP = 2; + + // Re-entry spread. Guesses, not a model of a person; the point is only that entry stops being + // a function of exit. Wider after a covered exit, where nothing anchors the pointer. + private static final int EDGE_SIGMA_PX = 35; + private static final int COVERED_SIGMA_PX = 120; + private static final double COVERED_EDGE_RETURN_CHANCE = 0.35; + + private AwtEmitter() + { + } + + public static void moved(int canvasX, int canvasY) + { + // Here, not at the callers: every motion funnels through this method. + if (exitIfOutside(canvasX, canvasY)) + { + return; + } + Canvas canvas = canvas(); + if (canvas == null) + { + return; + } + enterIfOutside(canvas, canvasX, canvasY); + Point component = StretchMapper.toComponent(canvasX, canvasY); + recordPosition(canvasX, canvasY); + dispatch(canvas, new MouseEvent(canvas, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, + component.getX(), component.getY(), 0, false)); + } + + public static void pressed(int canvasX, int canvasY, int button) + { + button(MouseEvent.MOUSE_PRESSED, canvasX, canvasY, button); + } + + public static void released(int canvasX, int canvasY, int button) + { + button(MouseEvent.MOUSE_RELEASED, canvasX, canvasY, button); + } + + public static void clicked(int canvasX, int canvasY, int button) + { + button(MouseEvent.MOUSE_CLICKED, canvasX, canvasY, button); + } + + public static void wheel(int canvasX, int canvasY, int wheelRotation, int unitsToScroll) + { + Canvas canvas = canvas(); + if (canvas == null) + { + return; + } + enterIfOutside(canvas, canvasX, canvasY); + Point component = StretchMapper.toComponent(canvasX, canvasY); + recordPosition(canvasX, canvasY); + dispatch(canvas, new MouseWheelEvent(canvas, MouseEvent.MOUSE_WHEEL, System.currentTimeMillis(), 0, + component.getX(), component.getY(), 0, false, 0, unitsToScroll, wheelRotation)); + } + + private static void button(int id, int canvasX, int canvasY, int button) + { + Canvas canvas = canvas(); + if (canvas == null) + { + return; + } + enterIfOutside(canvas, canvasX, canvasY); + Point component = StretchMapper.toComponent(canvasX, canvasY); + recordPosition(canvasX, canvasY); + dispatch(canvas, new MouseEvent(canvas, id, System.currentTimeMillis(), 0, + component.getX(), component.getY(), 1, false, button)); + } + + /** + * Announces the pointer's return before any other event, when the client believes none is over + * the canvas. A real MOUSE_EXITED leaves it tracking (-1,-1), so a bare MOVED or PRESSED after + * that delivers motion for a pointer it does not think exists. + */ + private static void enterIfOutside(Canvas canvas, int fallbackX, int fallbackY) + { + if (!PointerState.isOutside()) + { + return; + } + // Cleared first: the ENTERED below must not re-enter this method. + PointerState.markInside(); + + Point at = PointerState.get(); + int exitX = at.getX() < 0 ? fallbackX : at.getX(); + int exitY = at.getY() < 0 ? fallbackY : at.getY(); + + Point entry = reentryPoint(exitX, exitY, canvasWidth(), canvasHeight(), ThreadLocalRandom.current()); + Point component = StretchMapper.toComponent(entry.getX(), entry.getY()); + // Recorded too, or the next click presses where the pointer never travelled. + recordPosition(entry.getX(), entry.getY()); + dispatch(canvas, new MouseEvent(canvas, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, + component.getX(), component.getY(), 0, false)); + } + + /** + * Mirror of {@link #enterIfOutside}. Off-canvas is a real destination: antiban parks the + * cursor there. + * + * @return true when the point is off the canvas, so there is no motion left to send + */ + private static boolean exitIfOutside(int canvasX, int canvasY) + { + int width = canvasWidth(); + int height = canvasHeight(); + // Unknown size: call everything inside, rather than silencing all motion. + if (width <= 0 || height <= 0) + { + return false; + } + if (canvasX >= 0 && canvasX < width && canvasY >= 0 && canvasY < height) + { + return false; + } + if (PointerState.isOutside()) + { + return true; + } + // Real events win, same rule recordPosition applies: a synthetic exit must not move the + // human's pointer off the canvas underneath them. + if (InputArbiter.isHuman()) + { + return true; + } + + Canvas canvas = canvas(); + if (canvas == null) + { + return false; + } + // Through recordPosition, so the bot reference moves out with the pointer. Left behind, the + // motion threshold would be measured from a point the bot has already left. + recordPosition(canvasX, canvasY); + PointerState.markOutside(); + Point component = StretchMapper.toComponent(canvasX, canvasY); + dispatch(canvas, new MouseEvent(canvas, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, + component.getX(), component.getY(), 0, false)); + return true; + } + + /** + * Where the pointer comes back in, given where it went out. An edge exit returns along that + * edge; a mid-canvas exit means a window covered the client and the position is unknown, so + * the return is drawn from inside or across an edge. The exact exit point stays reachable: + * that is the user who never touched the mouse. + * + *

Randomness is a parameter so tests can exercise the distribution. + */ + static Point reentryPoint(int exitX, int exitY, int width, int height, Random random) + { + if (width <= 0 || height <= 0) + { + return new Point(exitX, exitY); + } + + boolean onVerticalEdge = exitX <= EDGE_SLOP || exitX >= width - 1 - EDGE_SLOP; + boolean onHorizontalEdge = exitY <= EDGE_SLOP || exitY >= height - 1 - EDGE_SLOP; + + if (onVerticalEdge) + { + return new Point(clamp(exitX, width), clamp(gaussian(random, exitY, EDGE_SIGMA_PX), height)); + } + if (onHorizontalEdge) + { + return new Point(clamp(gaussian(random, exitX, EDGE_SIGMA_PX), width), clamp(exitY, height)); + } + + if (random.nextDouble() < COVERED_EDGE_RETURN_CHANCE) + { + return randomEdgePoint(width, height, random); + } + return new Point(clamp(gaussian(random, exitX, COVERED_SIGMA_PX), width), + clamp(gaussian(random, exitY, COVERED_SIGMA_PX), height)); + } + + private static Point randomEdgePoint(int width, int height, Random random) + { + switch (random.nextInt(4)) + { + case 0: + return new Point(0, random.nextInt(height)); + case 1: + return new Point(width - 1, random.nextInt(height)); + case 2: + return new Point(random.nextInt(width), 0); + default: + return new Point(random.nextInt(width), height - 1); + } + } + + private static int gaussian(Random random, int mean, int sigma) + { + return (int) Math.round(mean + random.nextGaussian() * sigma); + } + + private static int clamp(int value, int size) + { + return Math.max(0, Math.min(size - 1, value)); + } + + private static int canvasWidth() + { + try + { + return Microbot.getClient() == null ? 0 : Microbot.getClient().getCanvasWidth(); + } + catch (Exception ex) + { + return 0; + } + } + + private static int canvasHeight() + { + try + { + return Microbot.getClient() == null ? 0 : Microbot.getClient().getCanvasHeight(); + } + catch (Exception ex) + { + return 0; + } + } + + /** + * Real events win: while the human owns input a synthetic emit must not move the recorded + * position. The abort path still emits its RELEASED, it just does not drag the position along. + */ + private static void recordPosition(int canvasX, int canvasY) + { + if (InputArbiter.isHuman()) + { + return; + } + PointerState.setFromBot(canvasX, canvasY); + } + + private static Canvas canvas() + { + try + { + return Microbot.getClient() == null ? null : Microbot.getClient().getCanvas(); + } + catch (Exception ex) + { + return null; + } + } + + // Jagex's MOUSE_PRESSED listener calls canvas.requestFocus(), stealing OS focus from whatever + // the user is typing in. Non-focusable for the dispatch neuters it; mouse delivery ignores + // focusable state. Skipped when the canvas already owns focus, where setFocusable(false) would + // hand focus to the parent instead, which is the thing being prevented. + private static void dispatch(Canvas canvas, AWTEvent event) + { + boolean canvasIsFocused = canvas.isFocusOwner(); + boolean wasFocusable = canvas.isFocusable(); + boolean shouldGuard = wasFocusable && !canvasIsFocused; + if (shouldGuard) + { + canvas.setFocusable(false); + } + BotEventGuard.begin(); + try + { + canvas.dispatchEvent(event); + } + finally + { + BotEventGuard.end(); + if (shouldGuard) + { + canvas.setFocusable(true); + } + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/CanvasInputListener.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/CanvasInputListener.java new file mode 100644 index 00000000000..c1e1247ed67 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/CanvasInputListener.java @@ -0,0 +1,234 @@ +package net.runelite.client.plugins.microbot.util.input; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.Point; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.mouse.BotEventGuard; + +import java.awt.Canvas; +import java.awt.event.FocusEvent; +import java.awt.event.FocusListener; +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; +import java.awt.event.MouseEvent; +import java.awt.event.MouseListener; +import java.awt.event.MouseMotionListener; + +/** + * Observe-only listeners on the game canvas: they read real input and never consume, transform or + * dispatch anything. + * + *

Synthetic events are filtered by {@link BotEventGuard}, which works because + * {@code Canvas.dispatchEvent} runs listeners synchronously on the dispatching thread. + * {@code Rs2Keyboard} hand-delivers to {@code canvas.getKeyListeners()} instead, so it has to + * raise the same guard or the bot reads its own keystrokes as a takeover. + */ +@Slf4j +public final class CanvasInputListener implements MouseListener, MouseMotionListener, KeyListener, FocusListener +{ + private static final CanvasInputListener INSTANCE = new CanvasInputListener(); + + private static volatile Canvas attachedCanvas; + + private CanvasInputListener() + { + } + + /** + * Idempotent. Re-attaches if the canvas instance ever changes, since a stale registration + * leaves the arbiter silently deaf with no other symptom. + * + *

Measured: a fixed/resizable switch does not replace the canvas, so this is currently + * untriggered. Kept because it is a per-tick identity comparison and a renderer swap is a + * plausible trigger nobody has tested. + */ + public static synchronized void attach() + { + Canvas canvas = canvas(); + if (canvas == null || canvas == attachedCanvas) + { + return; + } + detach(); + canvas.addMouseListener(INSTANCE); + canvas.addMouseMotionListener(INSTANCE); + canvas.addKeyListener(INSTANCE); + canvas.addFocusListener(INSTANCE); + attachedCanvas = canvas; + // Info, not debug: fires once per canvas, and the alternative is a silent no-op at plugin + // start followed by a silent recovery on the first game tick. + log.info("Input arbiter listening on canvas {}", System.identityHashCode(canvas)); + } + + public static synchronized void detach() + { + Canvas previous = attachedCanvas; + if (previous == null) + { + return; + } + previous.removeMouseListener(INSTANCE); + previous.removeMouseMotionListener(INSTANCE); + previous.removeKeyListener(INSTANCE); + previous.removeFocusListener(INSTANCE); + attachedCanvas = null; + } + + static boolean isAttachedTo(Canvas canvas) + { + return attachedCanvas == canvas; + } + + /** False if the registration was left behind on a replaced canvas. */ + public static boolean isAttachedToLiveCanvas() + { + Canvas canvas = canvas(); + return canvas != null && attachedCanvas == canvas; + } + + /** + * Real key events only arrive while the canvas owns focus, which is why keys typed in another + * window never yield. + */ + public static boolean isCanvasFocused() + { + Canvas canvas = canvas(); + return canvas != null && canvas.isFocusOwner(); + } + + @Override + public void mouseMoved(MouseEvent event) + { + position(event); + } + + @Override + public void mouseDragged(MouseEvent event) + { + position(event); + } + + @Override + public void mousePressed(MouseEvent event) + { + if (BotEventGuard.isSynthetic()) + { + return; + } + position(event); + InputArbiter.onRealButtonPressed(event.getButton()); + } + + @Override + public void mouseReleased(MouseEvent event) + { + if (BotEventGuard.isSynthetic()) + { + return; + } + position(event); + InputArbiter.onRealButtonReleased(event.getButton()); + } + + @Override + public void keyPressed(KeyEvent event) + { + if (BotEventGuard.isSynthetic()) + { + return; + } + InputArbiter.onRealKeyPressed(event.getKeyCode()); + } + + @Override + public void keyReleased(KeyEvent event) + { + if (BotEventGuard.isSynthetic()) + { + return; + } + InputArbiter.onRealKeyReleased(event.getKeyCode()); + } + + @Override + public void keyTyped(KeyEvent event) + { + // No key code, and always follows a KEY_PRESSED. + } + + /** + * Anything held when focus leaves never delivers its release, and the stale entry would + * suppress idle resume forever. + * + *

Not filtered on {@link FocusEvent#isTemporary()}: window deactivation reports a temporary + * loss, which is precisely the case this exists for. + */ + @Override + public void focusLost(FocusEvent event) + { + InputArbiter.onFocusLost(); + } + + @Override + public void focusGained(FocusEvent event) + { + // Nothing to restore: a key still physically down announces itself on its next press. + } + + @Override + public void mouseClicked(MouseEvent event) + { + // PRESSED and RELEASED already cover the button. + } + + /** + * Recorded, but does not flip HUMAN: crossing the boundary is not an intent to take over, and + * the motion either side of it already speaks for itself. + */ + @Override + public void mouseEntered(MouseEvent event) + { + if (BotEventGuard.isSynthetic()) + { + return; + } + Point canvasPoint = StretchMapper.toCanvas(event.getX(), event.getY()); + PointerState.setInside(canvasPoint.getX(), canvasPoint.getY()); + } + + @Override + public void mouseExited(MouseEvent event) + { + if (BotEventGuard.isSynthetic()) + { + return; + } + Point canvasPoint = StretchMapper.toCanvas(event.getX(), event.getY()); + PointerState.setOutside(canvasPoint.getX(), canvasPoint.getY()); + } + + private void position(MouseEvent event) + { + if (BotEventGuard.isSynthetic()) + { + return; + } + Point canvasPoint = StretchMapper.toCanvas(event.getX(), event.getY()); + PointerState.setFromReal(canvasPoint.getX(), canvasPoint.getY()); + InputArbiter.onRealMove(canvasPoint.getX(), canvasPoint.getY()); + } + + private static Canvas canvas() + { + try + { + Client client = Microbot.getClient(); + return client == null ? null : client.getCanvas(); + } + catch (Exception ex) + { + return null; + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputArbiter.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputArbiter.java new file mode 100644 index 00000000000..7387cd8b764 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputArbiter.java @@ -0,0 +1,217 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Point; + +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; + +/** + * Decides whether the bot or the human currently owns input. + * + *

No timer thread: {@link #isHuman()} evaluates the idle window on every read, so a gesture + * holding a lock cannot delay the return to BOT. + * + *

Deliberately does not write {@code pauseAllScripts}. That flag has many writers and + * {@code Script.shutdown()} clears it unconditionally, so an unrelated script finishing would + * un-pause the bot mid-takeover, and the idle resume would cancel a Break Handler break. + */ +public final class InputArbiter +{ + private static final int DEFAULT_MOTION_THRESHOLD_PX = 10; + private static final long DEFAULT_IDLE_RESUME_MS = 1800L; + + /** Not 0: {@link System#nanoTime()} has an arbitrary origin and may return it. */ + private static final long NEVER = Long.MIN_VALUE; + + private static final Set REAL_BUTTONS_DOWN = ConcurrentHashMap.newKeySet(); + private static final Set REAL_KEYS_DOWN = ConcurrentHashMap.newKeySet(); + private static final AtomicLong LAST_REAL_ACTIVITY_NANOS = new AtomicLong(NEVER); + + private static volatile int motionThresholdPx = DEFAULT_MOTION_THRESHOLD_PX; + private static volatile long idleResumeMs = DEFAULT_IDLE_RESUME_MS; + private static volatile boolean disabled; + + /** + * Monotonic nanos, not a wall clock. A wall clock steps backwards on NTP correction or a VM + * resuming, which makes the elapsed comparison negative, reads as "inside the idle window", + * and pins HUMAN until real time catches up. + */ + private static volatile LongSupplier clock = System::nanoTime; + + private InputArbiter() + { + } + + public static boolean isHuman() + { + if (disabled) + { + return false; + } + // Holding a button generates no further events, so the idle window alone would resume the + // bot underneath the user's hand. + if (!REAL_BUTTONS_DOWN.isEmpty() || !REAL_KEYS_DOWN.isEmpty()) + { + return true; + } + long last = LAST_REAL_ACTIVITY_NANOS.get(); + if (last == NEVER) + { + return false; + } + long elapsed = clock.getAsLong() - last; + // Negative should be impossible, but fail towards resuming: an early resume is visible and + // recoverable, a permanent HUMAN is neither. + return elapsed >= 0 && elapsed < idleResumeMs * 1_000_000L; + } + + /** + * Measured from the last position the bot wrote, not the previous real event: per-event deltas + * never accumulate, so twenty 3px moves would be 60px of travel and never cross the threshold. + * + *

Before the first bot emit there is no reference and nothing to abort, so motion alone does + * not flip HUMAN. Buttons and keys still do. + */ + public static void onRealMove(int canvasX, int canvasY) + { + if (!PointerState.hasBotPoint()) + { + return; + } + Point reference = PointerState.lastBotPoint(); + int dx = canvasX - reference.getX(); + int dy = canvasY - reference.getY(); + if ((long) dx * dx + (long) dy * dy >= (long) motionThresholdPx * motionThresholdPx) + { + markActivity(); + } + } + + public static void onRealButtonPressed(int button) + { + REAL_BUTTONS_DOWN.add(button); + markActivity(); + } + + // Activity first, then the set. The other order leaves a window where a reader sees nothing + // held and the stale timestamp, and resumes mid-click. + public static void onRealButtonReleased(int button) + { + markActivity(); + REAL_BUTTONS_DOWN.remove(button); + } + + public static void onRealKeyPressed(int keyCode) + { + REAL_KEYS_DOWN.add(keyCode); + markActivity(); + } + + public static void onRealKeyReleased(int keyCode) + { + markActivity(); + REAL_KEYS_DOWN.remove(keyCode); + } + + public static boolean isRealButtonOrKeyDown() + { + return !REAL_BUTTONS_DOWN.isEmpty() || !REAL_KEYS_DOWN.isEmpty(); + } + + /** + * Drops everything held: a key down when the window deactivates never delivers its + * KEY_RELEASED, and since a held key suppresses idle resume the stale entry would pin HUMAN + * forever. Ctrl held for a screenshot is the usual way in. + * + *

Marks activity rather than clearing it, so the idle window runs from the focus loss. + */ + public static void onFocusLost() + { + if (REAL_BUTTONS_DOWN.isEmpty() && REAL_KEYS_DOWN.isEmpty()) + { + return; + } + markActivity(); + REAL_BUTTONS_DOWN.clear(); + REAL_KEYS_DOWN.clear(); + } + + public static Set realButtonsDown() + { + return new TreeSet<>(REAL_BUTTONS_DOWN); + } + + public static Set realKeysDown() + { + return new TreeSet<>(REAL_KEYS_DOWN); + } + + /** Milliseconds since the last real input, or -1 if there has not been any. */ + public static long millisSinceRealActivity() + { + long last = LAST_REAL_ACTIVITY_NANOS.get(); + return last == NEVER ? -1L : (clock.getAsLong() - last) / 1_000_000L; + } + + public static int motionThresholdPx() + { + return motionThresholdPx; + } + + public static long idleResumeMs() + { + return idleResumeMs; + } + + /** Forces BOT. Without it, one false positive stops every script with no way to recover. */ + public static void setDisabled(boolean value) + { + disabled = value; + } + + public static boolean isDisabled() + { + return disabled; + } + + // Clamped: the config fields behind these have no range on them, and a negative idle window + // makes isHuman() false immediately after activity, switching the yield off with no sign of it. + public static void setMotionThresholdPx(int value) + { + motionThresholdPx = Math.max(0, value); + } + + public static void setIdleResumeMs(long value) + { + idleResumeMs = Math.max(0L, value); + } + + /** + * Test fixture reset, public only because tests in other packages need it. Never call it from + * production: it discards the user's configured threshold and idle window. + */ + public static void resetForTest() + { + REAL_BUTTONS_DOWN.clear(); + REAL_KEYS_DOWN.clear(); + LAST_REAL_ACTIVITY_NANOS.set(NEVER); + motionThresholdPx = DEFAULT_MOTION_THRESHOLD_PX; + idleResumeMs = DEFAULT_IDLE_RESUME_MS; + disabled = false; + clock = System::nanoTime; + } + + /** Supplies nanos. */ + static void setClockForTest(LongSupplier value) + { + clock = value; + } + + private static void markActivity() + { + LAST_REAL_ACTIVITY_NANOS.set(clock.getAsLong()); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputDiagnostics.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputDiagnostics.java new file mode 100644 index 00000000000..75330020c08 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputDiagnostics.java @@ -0,0 +1,99 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Point; + +import java.awt.event.KeyEvent; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.StringJoiner; + +/** + * Live arbiter state, for verifying the yield against a running client. Off unless + * {@code -Dmicrobot.inputDebug=true}. + * + *

A yield fault has one visible symptom and three causes: the listener never attached, the + * threshold never tripped, or the waits never observed the flag. This separates them. + */ +public final class InputDiagnostics +{ + private static final boolean ENABLED = Boolean.getBoolean("microbot.inputDebug"); + + private InputDiagnostics() + { + } + + public static boolean isEnabled() + { + return ENABLED; + } + + /** Label to value, in display order. Safe before anything has happened. */ + public static Map readout() + { + Map out = new LinkedHashMap<>(); + out.put("owner", owner()); + out.put("listener", CanvasInputListener.isAttachedToLiveCanvas() ? "attached" : "DETACHED"); + // A held key with focus lost is a different situation from a held key while playing, and + // they are indistinguishable without this. + out.put("focus", CanvasInputListener.isCanvasFocused() ? "canvas" : "elsewhere"); + + Point pointer = PointerState.get(); + out.put("pointer", pointer.getX() + "," + pointer.getY()); + + if (PointerState.hasBotPoint()) + { + Point botPoint = PointerState.lastBotPoint(); + out.put("bot point", botPoint.getX() + "," + botPoint.getY()); + out.put("drift", distance(pointer, botPoint) + " / " + InputArbiter.motionThresholdPx() + "px"); + } + else + { + // No reference point yet, so motion alone cannot flip HUMAN. Stated rather than + // printing a distance from (-1,-1) that would read as a bug. + out.put("bot point", "none yet"); + out.put("drift", "n/a until first emit"); + } + + long since = InputArbiter.millisSinceRealActivity(); + out.put("last real", since < 0 ? "never" : since + " / " + InputArbiter.idleResumeMs() + "ms"); + out.put("real held", held()); + return out; + } + + private static String owner() + { + if (InputArbiter.isDisabled()) + { + return "BOT (yielding off)"; + } + return InputArbiter.isHuman() ? "HUMAN" : "BOT"; + } + + private static String held() + { + Set buttons = InputArbiter.realButtonsDown(); + Set keys = InputArbiter.realKeysDown(); + if (buttons.isEmpty() && keys.isEmpty()) + { + return "none"; + } + StringJoiner joiner = new StringJoiner(" "); + for (Integer button : buttons) + { + joiner.add("btn" + button); + } + for (Integer key : keys) + { + joiner.add(KeyEvent.getKeyText(key)); + } + return joiner.toString(); + } + + private static long distance(Point a, Point b) + { + long dx = a.getX() - b.getX(); + long dy = a.getY() - b.getY(); + return Math.round(Math.sqrt((double) dx * dx + (double) dy * dy)); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputLoop.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputLoop.java new file mode 100644 index 00000000000..fbf10212e3b --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/InputLoop.java @@ -0,0 +1,191 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Point; +import net.runelite.client.plugins.microbot.Microbot; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Runs one input gesture at a time, since there is one cursor, and unwinds it if the human takes + * over partway through. + * + *

A lock, not a thread and queue: script threads should block, and the client thread already + * defers through an executor. A dedicated thread would stall every script whenever one gesture + * waited on a busy client thread. + * + *

The held-button set exists for drag. A triad is three events in microseconds and a + * trajectory has nothing to unwind. Keys are not gesture-scoped and live in {@code Rs2Keyboard}. + */ +public final class InputLoop +{ + private static final ReentrantLock LOCK = new ReentrantLock(); + private static final ThreadLocal IN_GESTURE = ThreadLocal.withInitial(() -> Boolean.FALSE); + + /** Generous: a healthy gesture holds the lock for milliseconds, so this only catches a wedge. */ + private static volatile long lockTimeoutMs = 5_000L; + + /** Shrunk by tests so exercising the timeout is not a five second wait. */ + static void setLockTimeoutForTest(long millis) + { + lockTimeoutMs = millis; + } + + public enum Result + { + COMPLETED, + ABORTED + } + + @FunctionalInterface + public interface Gesture + { + void run(Emit emit); + } + + private InputLoop() + { + } + + public static Result run(Gesture gesture) + { + // An inner run would get its own Emit, so an inner abort would clear targetMenu and release + // its buttons while the outer gesture carried on believing it held them. + if (IN_GESTURE.get()) + { + throw new IllegalStateException("InputLoop.run is already running a gesture on this thread"); + } + if (InputArbiter.isHuman()) + { + return Result.ABORTED; + } + try + { + // Bounded: a gesture can block on the client thread, and unbounded one wedged gesture + // would hold every other script's input for as long as it stayed wedged. + if (!LOCK.tryLock(lockTimeoutMs, TimeUnit.MILLISECONDS)) + { + return Result.ABORTED; + } + } + catch (InterruptedException interrupted) + { + Thread.currentThread().interrupt(); + return Result.ABORTED; + } + IN_GESTURE.set(Boolean.TRUE); + try + { + // The human may have taken over while this thread waited, and a deferred client-thread + // item may have been queued long before it ran. + if (InputArbiter.isHuman()) + { + return Result.ABORTED; + } + Emit emit = new Emit(); + try + { + gesture.run(emit); + return Result.COMPLETED; + } + catch (Aborted aborted) + { + // The menu-aware click path arms targetMenu immediately before dispatching, so an + // abort in between leaves the bot's entry loaded for the human's next click. + Microbot.targetMenu = null; + return Result.ABORTED; + } + finally + { + releaseHeldButtons(emit); + } + } + finally + { + IN_GESTURE.remove(); + LOCK.unlock(); + } + } + + /** + * Emits the matching RELEASED for anything still held, at the current point rather than where + * the gesture was heading. Also runs on normal completion and on an unexpected throw. + */ + private static void releaseHeldButtons(Emit emit) + { + if (emit.heldButtons.isEmpty()) + { + return; + } + Point at = PointerState.get(); + for (Integer button : new ArrayList<>(emit.heldButtons)) + { + AwtEmitter.released(at.getX(), at.getY(), button); + } + emit.heldButtons.clear(); + } + + /** The only way to emit inside a gesture; every method checks first. */ + public static final class Emit + { + private final Set heldButtons = new LinkedHashSet<>(); + + private Emit() + { + } + + public void move(int canvasX, int canvasY) + { + checkpoint(); + AwtEmitter.moved(canvasX, canvasY); + } + + public void press(int canvasX, int canvasY, int button) + { + checkpoint(); + AwtEmitter.pressed(canvasX, canvasY, button); + heldButtons.add(button); + } + + public void release(int canvasX, int canvasY, int button) + { + checkpoint(); + AwtEmitter.released(canvasX, canvasY, button); + heldButtons.remove(button); + } + + public void click(int canvasX, int canvasY, int button) + { + checkpoint(); + AwtEmitter.clicked(canvasX, canvasY, button); + } + + public void wheel(int canvasX, int canvasY, int wheelRotation, int unitsToScroll) + { + checkpoint(); + AwtEmitter.wheel(canvasX, canvasY, wheelRotation, unitsToScroll); + } + + public void checkpoint() + { + if (InputArbiter.isHuman()) + { + throw ABORTED; + } + } + } + + private static final Aborted ABORTED = new Aborted(); + + /** Control flow, not an error. Shared and stack-traceless: aborting is an ordinary event. */ + private static final class Aborted extends RuntimeException + { + private Aborted() + { + super(null, null, false, false); + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/PointerState.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/PointerState.java new file mode 100644 index 00000000000..fd9bcb0b1c2 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/PointerState.java @@ -0,0 +1,135 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Point; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * Live pointer position, in canvas space, written by both real AWT events and synthetic + * emits. + * + *

Position only. Held buttons and keys live in {@link InputArbiter}, which needs them split + * into real and synthetic; a union of the two answers no useful question. + * + *

One {@link AtomicLong} rather than two volatile ints, because x and y are only meaningful as + * a pair. + */ +public final class PointerState +{ + private static final long UNSET = pack(-1, -1); + + private static final AtomicLong POSITION = new AtomicLong(UNSET); + private static final AtomicLong LAST_BOT_POSITION = new AtomicLong(UNSET); + + /** + * Whether the client believes a pointer is over the canvas. Alt-tab away and it receives a real + * MOUSE_EXITED, after which its own tracked position is (-1,-1). + */ + private static volatile boolean outside; + + private PointerState() + { + } + + public static int getX() + { + return unpackX(POSITION.get()); + } + + public static int getY() + { + return unpackY(POSITION.get()); + } + + public static Point get() + { + long packed = POSITION.get(); + return new Point(unpackX(packed), unpackY(packed)); + } + + public static boolean isAt(int canvasX, int canvasY) + { + return POSITION.get() == pack(canvasX, canvasY); + } + + public static void setFromReal(int canvasX, int canvasY) + { + POSITION.set(pack(canvasX, canvasY)); + } + + /** + * Records a synthetic emit, and the bot-written reference point the arbiter measures its + * motion threshold against. + */ + public static void setFromBot(int canvasX, int canvasY) + { + long packed = pack(canvasX, canvasY); + POSITION.set(packed); + LAST_BOT_POSITION.set(packed); + } + + public static Point lastBotPoint() + { + long packed = LAST_BOT_POSITION.get(); + return new Point(unpackX(packed), unpackY(packed)); + } + + public static boolean hasBotPoint() + { + return LAST_BOT_POSITION.get() != UNSET; + } + + public static boolean isOutside() + { + return outside; + } + + /** + * Records a boundary crossing. Coordinates are kept rather than blanked: mirroring the client's + * (-1,-1) would feed nonsense to NaturalMouse and to every overlay reading a cursor position. + */ + public static void setOutside(int canvasX, int canvasY) + { + POSITION.set(pack(canvasX, canvasY)); + outside = true; + } + + public static void setInside(int canvasX, int canvasY) + { + POSITION.set(pack(canvasX, canvasY)); + outside = false; + } + + static void markInside() + { + outside = false; + } + + /** Flag only. The bot's own exit records its position through {@link #setFromBot}. */ + static void markOutside() + { + outside = true; + } + + public static void reset() + { + POSITION.set(UNSET); + LAST_BOT_POSITION.set(UNSET); + outside = false; + } + + static long pack(int x, int y) + { + return ((long) x << 32) | (y & 0xFFFFFFFFL); + } + + static int unpackX(long packed) + { + return (int) (packed >> 32); + } + + static int unpackY(long packed) + { + return (int) packed; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/StretchMapper.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/StretchMapper.java new file mode 100644 index 00000000000..a848d20262c --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/input/StretchMapper.java @@ -0,0 +1,87 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Client; +import net.runelite.api.Point; +import net.runelite.client.plugins.microbot.Microbot; + +import java.awt.Dimension; + +/** + * Converts between canvas space, which scripts and {@link PointerState} use, and the component + * space an AWT event on the game canvas carries. Identity when stretched mode is off. + */ +public final class StretchMapper +{ + private StretchMapper() + { + } + + public static Point toComponent(int canvasX, int canvasY) + { + Dims dims = dims(); + if (dims == null) + { + return new Point(canvasX, canvasY); + } + return new Point( + (int) ((long) canvasX * dims.stretchedWidth / dims.realWidth), + (int) ((long) canvasY * dims.stretchedHeight / dims.realHeight)); + } + + public static Point toCanvas(int componentX, int componentY) + { + Dims dims = dims(); + if (dims == null) + { + return new Point(componentX, componentY); + } + return new Point( + (int) ((long) componentX * dims.realWidth / dims.stretchedWidth), + (int) ((long) componentY * dims.realHeight / dims.stretchedHeight)); + } + + /** Null means identity. Both pairs are checked for zero: each direction divides by one of them. */ + private static Dims dims() + { + Client client; + try + { + client = Microbot.getClient(); + } + catch (Exception ex) + { + return null; + } + if (client == null || !client.isStretchedEnabled()) + { + return null; + } + Dimension stretched = client.getStretchedDimensions(); + Dimension real = client.getRealDimensions(); + if (stretched == null || real == null) + { + return null; + } + if (stretched.width == 0 || stretched.height == 0 || real.width == 0 || real.height == 0) + { + return null; + } + return new Dims(stretched.width, stretched.height, real.width, real.height); + } + + private static final class Dims + { + private final int stretchedWidth; + private final int stretchedHeight; + private final int realWidth; + private final int realHeight; + + private Dims(int stretchedWidth, int stretchedHeight, int realWidth, int realHeight) + { + this.stretchedWidth = stretchedWidth; + this.stretchedHeight = stretchedHeight; + this.realWidth = realWidth; + this.realHeight = realHeight; + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2Keyboard.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2Keyboard.java index fb6bbe016ab..3097686336f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2Keyboard.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2Keyboard.java @@ -2,11 +2,16 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.util.Global; +import net.runelite.client.plugins.microbot.util.input.InputArbiter; import net.runelite.client.plugins.microbot.util.math.Rs2Random; +import net.runelite.client.plugins.microbot.util.mouse.BotEventGuard; import java.awt.*; import java.awt.event.KeyEvent; import java.awt.event.KeyListener; +import java.util.ArrayList; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import static java.awt.event.KeyEvent.CHAR_UNDEFINED; @@ -15,6 +20,8 @@ */ public class Rs2Keyboard { + /** Keys the bot currently holds down. See {@link #releaseHeldKeys()}. */ + private static final Set HELD_KEYS = ConcurrentHashMap.newKeySet(); /** * Gets the current game canvas. @@ -26,19 +33,6 @@ private static Canvas getCanvas() return Microbot.getClient().getCanvas(); } - /** - * Kept as a no-op wrapper so existing call sites still compile / read naturally. - * The previous implementation toggled {@code Canvas.setFocusable(true)} around - * dispatch; on many window managers that call nudges the OS to grant focus to the - * game window, stealing it from whatever app the user was actually typing in. - * Direct-listener dispatch (see {@link #dispatchKeyEvent}) makes the toggle - * unnecessary, so this wrapper just runs the action. - */ - private static void withFocusCanvas(Runnable action) - { - action.run(); - } - /** * Delivers a synthetic KeyEvent to the canvas's registered listeners directly, * bypassing AWT's focus-aware dispatch pipeline. This is what eliminates the @@ -50,26 +44,45 @@ private static void withFocusCanvas(Runnable action) * @param keyChar the character to type, if applicable * @param delay the delay in milliseconds before the event time is set */ - private static void dispatchKeyEvent(int id, int keyCode, char keyChar, int delay) + private static boolean dispatchKeyEvent(int id, int keyCode, char keyChar, int delay) { + // Keyboard emission never goes through InputLoop, so this is its only checkpoint. Without + // it a takeover mid-typeString sprays the rest of the string into whatever the human just + // took over. RELEASED is exempt: releaseHeldKeys runs while the human owns input, and + // suppressing it would strand a key down. + if (id != KeyEvent.KEY_RELEASED && InputArbiter.isHuman()) + { + return false; + } Canvas canvas = getCanvas(); KeyEvent event = new KeyEvent(canvas, id, System.currentTimeMillis() + delay, 0, keyCode, keyChar); KeyListener[] listeners = canvas.getKeyListeners(); - for (KeyListener l : listeners) + // The arbiter's observe-only KeyListener is one of these, so without the guard the bot + // reads its own keystrokes as a takeover. + BotEventGuard.begin(); + try { - switch (id) + for (KeyListener l : listeners) { - case KeyEvent.KEY_TYPED: - l.keyTyped(event); - break; - case KeyEvent.KEY_PRESSED: - l.keyPressed(event); - break; - case KeyEvent.KEY_RELEASED: - l.keyReleased(event); - break; + switch (id) + { + case KeyEvent.KEY_TYPED: + l.keyTyped(event); + break; + case KeyEvent.KEY_PRESSED: + l.keyPressed(event); + break; + case KeyEvent.KEY_RELEASED: + l.keyReleased(event); + break; + } } } + finally + { + BotEventGuard.end(); + } + return true; } /** @@ -80,14 +93,16 @@ private static void dispatchKeyEvent(int id, int keyCode, char keyChar, int dela */ public static void typeString(final String word) { - withFocusCanvas(() -> { - for (char c : word.toCharArray()) + for (char c : word.toCharArray()) + { + int delay = Rs2Random.logNormalBounded(20, 200); + // Stop at the first suppressed character rather than spinning through the rest. + if (!dispatchKeyEvent(KeyEvent.KEY_TYPED, KeyEvent.VK_UNDEFINED, c, delay)) { - int delay = Rs2Random.logNormalBounded(20, 200); - dispatchKeyEvent(KeyEvent.KEY_TYPED, KeyEvent.VK_UNDEFINED, c, delay); - Global.sleep(Rs2Random.logNormalBounded(100, 200)); + return; } - }); + Global.sleep(Rs2Random.logNormalBounded(100, 200)); + } } /** @@ -97,10 +112,8 @@ public static void typeString(final String word) */ public static void keyPress(final char key) { - withFocusCanvas(() -> { - int delay = Rs2Random.logNormalBounded(20, 200); - dispatchKeyEvent(KeyEvent.KEY_TYPED, KeyEvent.VK_UNDEFINED, key, delay); - }); + int delay = Rs2Random.logNormalBounded(20, 200); + dispatchKeyEvent(KeyEvent.KEY_TYPED, KeyEvent.VK_UNDEFINED, key, delay); } /** @@ -108,10 +121,22 @@ public static void keyPress(final char key) */ public static void holdShift() { - withFocusCanvas(() -> { - int delay = Rs2Random.logNormalBounded(20, 200); - dispatchKeyEvent(KeyEvent.KEY_PRESSED, KeyEvent.VK_SHIFT, CHAR_UNDEFINED, delay); - }); + hold(KeyEvent.VK_SHIFT, Rs2Random.logNormalBounded(20, 200)); + } + + /** + * Not locked against {@link #releaseHeldKeys()}. A takeover landing between the dispatch and + * the add leaves the key down until the next {@code Script.run} tick releases it, at most 600ms. + * A lock here would have to be held across the dispatch, which runs the client's own key + * handler, and stalling every script thread behind that is the worse failure. + */ + private static void hold(int key, int delay) + { + // Only if the press went out, or releaseHeldKeys would release a key never held. + if (dispatchKeyEvent(KeyEvent.KEY_PRESSED, key, CHAR_UNDEFINED, delay)) + { + HELD_KEYS.add(key); + } } /** @@ -119,10 +144,7 @@ public static void holdShift() */ public static void releaseShift() { - withFocusCanvas(() -> { - int delay = Rs2Random.logNormalBounded(20, 200); - dispatchKeyEvent(KeyEvent.KEY_RELEASED, KeyEvent.VK_SHIFT, CHAR_UNDEFINED, delay); - }); + keyRelease(KeyEvent.VK_SHIFT); } /** @@ -132,9 +154,7 @@ public static void releaseShift() */ public static void keyHold(int key) { - withFocusCanvas(() -> - dispatchKeyEvent(KeyEvent.KEY_PRESSED, key, CHAR_UNDEFINED, 0) - ); + hold(key, 0); } /** @@ -144,10 +164,36 @@ public static void keyHold(int key) */ public static void keyRelease(int key) { - withFocusCanvas(() -> { - int delay = Rs2Random.logNormalBounded(20, 200); - dispatchKeyEvent(KeyEvent.KEY_RELEASED, key, CHAR_UNDEFINED, delay); - }); + // Only for a key this class saw go down. A takeover suppresses the press, and releasing + // anyway sends a RELEASED with no PRESSED before it, which no keyboard produces. + if (!HELD_KEYS.remove(key)) + { + return; + } + int delay = Rs2Random.logNormalBounded(20, 200); + dispatchKeyEvent(KeyEvent.KEY_RELEASED, key, CHAR_UNDEFINED, delay); + } + + /** + * Releases every key the bot still holds. A hold spans arbitrary script code rather than one + * gesture, so InputLoop cannot unwind it; {@code Script.run} calls this on a takeover instead. + * + *

Idempotent and safe from several script threads at once. + */ + public static void releaseHeldKeys() + { + for (Integer key : new ArrayList<>(HELD_KEYS)) + { + if (HELD_KEYS.remove(key)) + { + dispatchKeyEvent(KeyEvent.KEY_RELEASED, key, CHAR_UNDEFINED, 0); + } + } + } + + static boolean isKeyHeld(int key) + { + return HELD_KEYS.contains(key); } /** @@ -165,13 +211,17 @@ public static void keyPress(int key) return; } - withFocusCanvas(() -> { - dispatchKeyEvent(KeyEvent.KEY_PRESSED, key, typed, 0); - int delay = Rs2Random.logNormalBounded(20, 200); - dispatchKeyEvent(KeyEvent.KEY_TYPED, KeyEvent.VK_UNDEFINED, typed, delay); - int releaseDelay = Rs2Random.between(20, 200); - dispatchKeyEvent(KeyEvent.KEY_RELEASED, key, CHAR_UNDEFINED, releaseDelay); - }); + // A suppressed press must not be followed by a release. + if (!dispatchKeyEvent(KeyEvent.KEY_PRESSED, key, typed, 0)) + { + return; + } + int delay = Rs2Random.logNormalBounded(20, 200); + dispatchKeyEvent(KeyEvent.KEY_TYPED, KeyEvent.VK_UNDEFINED, typed, delay); + // Unconditional: the press went out, so the release owes the client its pair even if the + // human took over in between. + int releaseDelay = Rs2Random.between(20, 200); + dispatchKeyEvent(KeyEvent.KEY_RELEASED, key, CHAR_UNDEFINED, releaseDelay); } /** @@ -211,6 +261,6 @@ public static void enter() * Sends a KEY_TYPED event for the Enter key to ensure it is released. */ public static void resetEnter() { - withFocusCanvas(() -> dispatchKeyEvent(KeyEvent.KEY_TYPED, KeyEvent.VK_UNDEFINED, '\n', 10)); + dispatchKeyEvent(KeyEvent.KEY_TYPED, KeyEvent.VK_UNDEFINED, '\n', 10); } } \ No newline at end of file diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/Mouse.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/Mouse.java index 903e9b975bb..1a4e4200660 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/Mouse.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/Mouse.java @@ -21,7 +21,9 @@ public abstract class Mouse { Point lastClick = new Point(-1, -1); // getter for last click // getter for click before last click Point lastClick2 = new Point(-1, -1); - Point lastMove = new Point(-1, -1); // getter for last move + // No lastMove: it was written only by the bot, so NaturalMouse.moveTo compared its target + // against a point human input could never update, and early-returned. Position lives in + // PointerState. `points` stays for the debug overlay's trail and is not a position source. float hue = 0.0f; // Initial hue value Timer timer = new Timer(POINT_LIFETIME, e -> points.pollFirst()); @@ -47,8 +49,6 @@ public int randomizeClick() { public abstract void setLastClick(Point point); - public abstract void setLastMove(Point point); - public abstract Mouse click(int x, int y); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/VirtualMouse.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/VirtualMouse.java index 0651f20b6e8..fbd6349f291 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/VirtualMouse.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/VirtualMouse.java @@ -1,9 +1,12 @@ package net.runelite.client.plugins.microbot.util.mouse; import lombok.extern.slf4j.Slf4j; -import net.runelite.api.Client; import net.runelite.api.Point; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.input.AwtEmitter; +import net.runelite.client.plugins.microbot.util.input.InputArbiter; +import net.runelite.client.plugins.microbot.util.input.InputLoop; +import net.runelite.client.plugins.microbot.util.input.PointerState; 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; @@ -11,7 +14,6 @@ import javax.inject.Inject; import java.awt.*; import java.awt.event.MouseEvent; -import java.awt.event.MouseWheelEvent; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -34,89 +36,25 @@ public void setLastClick(Point point) { lastClick = point; } - public void setLastMove(Point point) { - lastMove = point; - points.add(point); - if (points.size() > MAX_POINTS) { - points.pollFirst(); - } - } - - private int[] scaleForDispatch(int x, int y) { - Client c; - try { - c = Microbot.getClient(); - } catch (Exception ex) { - return new int[]{x, y}; + // Feeds the debug overlay's fading trail only; position itself lives in PointerState. + private void recordTrailPoint(Point point) { + points.add(point); + if (points.size() > MAX_POINTS) { + points.pollFirst(); } - if (c == null || !c.isStretchedEnabled()) { - return new int[]{x, y}; - } - Dimension stretched = c.getStretchedDimensions(); - Dimension real = c.getRealDimensions(); - if (stretched == null || real == null || real.width == 0 || real.height == 0) { - return new int[]{x, y}; - } - return new int[]{ - (int) ((long) x * stretched.width / real.width), - (int) ((long) y * stretched.height / real.height) - }; - } - - private void dispatchMouse(int id, Point point, int button, int clickCount) { - int[] s = scaleForDispatch(point.getX(), point.getY()); - Canvas canvas = getCanvas(); - MouseEvent event = new MouseEvent(canvas, id, System.currentTimeMillis(), 0, - s[0], s[1], clickCount, false, button); - dispatchWithoutFocusGrab(canvas, event); } - private void dispatchMouseMove(int id, Point point) { - int[] s = scaleForDispatch(point.getX(), point.getY()); - Canvas canvas = getCanvas(); - MouseEvent event = new MouseEvent(canvas, id, System.currentTimeMillis(), 0, - s[0], s[1], 0, false); - dispatchWithoutFocusGrab(canvas, event); - } - - private void dispatchWheel(Point point, int wheelRotation, int unitsToScroll) { - int[] s = scaleForDispatch(point.getX(), point.getY()); - Canvas canvas = getCanvas(); - MouseWheelEvent event = new MouseWheelEvent(canvas, MouseEvent.MOUSE_WHEEL, - System.currentTimeMillis(), 0, s[0], s[1], 0, false, 0, unitsToScroll, wheelRotation); - dispatchWithoutFocusGrab(canvas, event); - } - - // Jagex's MOUSE_PRESSED listener calls canvas.requestFocus() when the event source is the - // Canvas, which yanks OS keyboard focus away from whatever app the user is typing in. Flip - // focusable off for the duration of the synthetic dispatch so requestFocus is a no-op; mouse - // delivery itself is unaffected by focusable state. - // - // IMPORTANT: only do this when the canvas is NOT currently the focus owner. If the user is - // actively typing in the in-game chat (which lives inside the canvas), the canvas IS the focus - // owner, and setFocusable(false) immediately yanks focus away to the parent container — exactly - // the opposite of what this method is trying to prevent. Detect that case and skip the toggle. - private void dispatchWithoutFocusGrab(Canvas canvas, AWTEvent event) { - boolean canvasIsFocused = canvas.isFocusOwner(); - boolean wasFocusable = canvas.isFocusable(); - boolean shouldGuard = wasFocusable && !canvasIsFocused; - if (shouldGuard) canvas.setFocusable(false); - BotEventGuard.begin(); - try { - canvas.dispatchEvent(event); - } finally { - BotEventGuard.end(); - if (shouldGuard) canvas.setFocusable(true); + private void handleClick(InputLoop.Emit emit, Point point, boolean rightClick) { + int button = rightClick ? MouseEvent.BUTTON3 : MouseEvent.BUTTON1; + // A human clicking where the pointer already is sends no fresh MOVED, so emit one only + // when it is not there, which happens when NaturalMouse was skipped. No ENTERED/EXITED: + // no human click sends those, and the EXITED wrote (-1,-1) into the tracked position. + if (!PointerState.isAt(point.getX(), point.getY())) { + emit.move(point.getX(), point.getY()); } - } - - private void handleClick(Point point, boolean rightClick) { - entered(point); - exited(point); - moved(point); - pressed(point, rightClick ? MouseEvent.BUTTON3 : MouseEvent.BUTTON1); - released(point, rightClick ? MouseEvent.BUTTON3 : MouseEvent.BUTTON1); - clicked(point, rightClick ? MouseEvent.BUTTON3 : MouseEvent.BUTTON1); + emit.press(point.getX(), point.getY(), button); + emit.release(point.getX(), point.getY(), button); + emit.click(point.getX(), point.getY(), button); setLastClick(point); } @@ -126,21 +64,27 @@ private boolean shouldMoveNaturally(Point point) { && Microbot.naturalMouse != null; } + /** + * A gesture takes the {@link InputLoop} lock and sleeps while holding it, and neither may + * happen on the client thread. Every gesture goes through here so none can forget. + */ + private void runGesture(Runnable gesture) { + if (Microbot.getClient().isClientThread()) { + scheduledExecutorService.schedule(gesture, 0, TimeUnit.MILLISECONDS); + } else { + gesture.run(); + } + } + public Mouse click(Point point, boolean rightClick) { if (point == null) return this; - Runnable clickAction = () -> { + runGesture(() -> InputLoop.run(emit -> { if (shouldMoveNaturally(point)) { Microbot.naturalMouse.moveTo(point.getX(), point.getY()); } - handleClick(point, rightClick); - }; - - if (Microbot.getClient().isClientThread()) { - scheduledExecutorService.schedule(clickAction, 0, TimeUnit.MILLISECONDS); - } else { - clickAction.run(); - } + handleClick(emit, point, rightClick); + })); return this; } @@ -149,7 +93,7 @@ public Mouse click(Point point, boolean rightClick) { public Mouse click(Point point, boolean rightClick, NewMenuEntry entry) { if (point == null) return this; - Runnable clickAction = () -> { + runGesture(() -> InputLoop.run(emit -> { Point newPoint = point; if (shouldMoveNaturally(point)) { Microbot.naturalMouse.moveTo(point.getX(), point.getY()); @@ -173,14 +117,8 @@ public Mouse click(Point point, boolean rightClick, NewMenuEntry entry) { } Microbot.targetMenu = entry; - handleClick(newPoint, rightClick); - }; - - if (Microbot.getClient().isClientThread()) { - scheduledExecutorService.schedule(clickAction, 0, TimeUnit.MILLISECONDS); - } else { - clickAction.run(); - } + handleClick(emit, newPoint, rightClick); + })); return this; } @@ -218,45 +156,55 @@ public Mouse click() { return click(Microbot.getClient().getMouseCanvasPosition()); } + // NaturalMouse steps through here, so this one check also stops a trajectory mid-curve. public Mouse move(Point point) { - setLastMove(point); - dispatchMouseMove(MouseEvent.MOUSE_MOVED, point); + if (InputArbiter.isHuman()) { + return this; + } + recordTrailPoint(point); + AwtEmitter.moved(point.getX(), point.getY()); return this; } public Mouse move(Rectangle rect) { - Point pt = new Point((int) rect.getCenterX(), (int) rect.getCenterY()); - setLastMove(pt); - dispatchMouseMove(MouseEvent.MOUSE_MOVED, pt); - return this; + return move(new Point((int) rect.getCenterX(), (int) rect.getCenterY())); } public Mouse move(Polygon polygon) { - Point point = new Point((int) polygon.getBounds().getCenterX(), (int) polygon.getBounds().getCenterY()); - setLastMove(point); - dispatchMouseMove(MouseEvent.MOUSE_MOVED, point); - return this; + return move(new Point((int) polygon.getBounds().getCenterX(), (int) polygon.getBounds().getCenterY())); } public Mouse scrollDown(Point point) { - move(point); - scheduledExecutorService.schedule( - () -> dispatchWheel(point, 2, 10), - Rs2Random.logNormalBounded(40, 100), TimeUnit.MILLISECONDS); - return this; + return scroll(point, 2, 10); } public Mouse scrollUp(Point point) { - move(point); - scheduledExecutorService.schedule( - () -> dispatchWheel(point, -2, -10), - Rs2Random.logNormalBounded(40, 100), TimeUnit.MILLISECONDS); + return scroll(point, -2, -10); + } + + /** + * One gesture covering both the move and the wheel, so another script's click cannot land + * between them and leave the wheel firing at a point the cursor has left. + * + *

The pause stays: a human turns the wheel a moment after arriving, not in the same + * instant. A takeover during it aborts at the wheel's checkpoint. + */ + private Mouse scroll(Point point, int wheelRotation, int unitsToScroll) { + if (point == null) return this; + + runGesture(() -> InputLoop.run(emit -> { + emit.move(point.getX(), point.getY()); + recordTrailPoint(point); + sleep(Rs2Random.logNormalBounded(40, 100)); + emit.wheel(point.getX(), point.getY(), wheelRotation, unitsToScroll); + })); + return this; } @Override public java.awt.Point getMousePosition() { - Point point = lastMove; + Point point = PointerState.get(); return new java.awt.Point(point.getX(), point.getY()); } @@ -270,50 +218,32 @@ public Mouse move(double x, double y) { return move(new Point((int) x, (int) y)); } - private synchronized void pressed(Point point, int button) { - dispatchMouse(MouseEvent.MOUSE_PRESSED, point, button, 1); - } - - private synchronized void released(Point point, int button) { - dispatchMouse(MouseEvent.MOUSE_RELEASED, point, button, 1); - } - - private synchronized void clicked(Point point, int button) { - dispatchMouse(MouseEvent.MOUSE_CLICKED, point, button, 1); - } - - private synchronized void exited(Point point) { - dispatchMouseMove(MouseEvent.MOUSE_EXITED, point); - } - - private synchronized void entered(Point point) { - dispatchMouseMove(MouseEvent.MOUSE_ENTERED, point); - } - - private synchronized void moved(Point point) { - dispatchMouseMove(MouseEvent.MOUSE_MOVED, point); - } - public void shutdown() { scheduledExecutorService.shutdownNow(); } + private void moveTowards(Point point) { + if (shouldMoveNaturally(point)) + Microbot.naturalMouse.moveTo(point.getX(), point.getY()); + else + move(point); + } + + // The one gesture holding a button across time, and the reason the held-button set exists. + // The sleeps return immediately under a takeover and the next emit aborts, releasing at the + // human's point rather than falling through to a RELEASED at the stale end point. public Mouse drag(Point startPoint, Point endPoint) { if (startPoint == null || endPoint == null) return this; - if (shouldMoveNaturally(startPoint)) - Microbot.naturalMouse.moveTo(startPoint.getX(), startPoint.getY()); - else - move(startPoint); - sleep(Rs2Random.logNormalBounded(50, 80)); - pressed(startPoint, MouseEvent.BUTTON1); - sleep(Rs2Random.logNormalBounded(80, 120)); - if (shouldMoveNaturally(endPoint)) - Microbot.naturalMouse.moveTo(endPoint.getX(), endPoint.getY()); - else - move(endPoint); - sleep(Rs2Random.logNormalBounded(80, 120)); - released(endPoint, MouseEvent.BUTTON1); + runGesture(() -> InputLoop.run(emit -> { + moveTowards(startPoint); + sleep(Rs2Random.logNormalBounded(50, 80)); + emit.press(startPoint.getX(), startPoint.getY(), MouseEvent.BUTTON1); + sleep(Rs2Random.logNormalBounded(80, 120)); + moveTowards(endPoint); + sleep(Rs2Random.logNormalBounded(80, 120)); + emit.release(endPoint.getX(), endPoint.getY(), MouseEvent.BUTTON1); + })); return this; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/naturalmouse/NaturalMouse.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/naturalmouse/NaturalMouse.java index fb76e1f1775..a7ee83a7fb6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/naturalmouse/NaturalMouse.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/mouse/naturalmouse/NaturalMouse.java @@ -154,17 +154,28 @@ public void moveOffScreen() { * 0.0 and 0.99; use values representing a whole percentage (e.g., 25.0, 50.0). */ public void moveOffScreen(double chancePercentage) { - if (chancePercentage >= 100 || Rs2Random.dicePercentage(chancePercentage)) { - // Move off screen if the chance is met - int horizontal = random.nextBoolean() ? -1 : client.getCanvasWidth() + 1; - int vertical = random.nextBoolean() ? -1 : client.getCanvasHeight() + 1; - - boolean exitHorizontally = random.nextBoolean(); - if (exitHorizontally) { - moveTo(horizontal, random.nextInt(0, client.getCanvasHeight() + 1)); - } else { - moveTo(random.nextInt(0, client.getCanvasWidth() + 1), vertical); - } + if (chancePercentage < 100 && !Rs2Random.dicePercentage(chancePercentage)) { + return; + } + + int horizontal = random.nextBoolean() ? -1 : client.getCanvasWidth() + 1; + int vertical = random.nextBoolean() ? -1 : client.getCanvasHeight() + 1; + + boolean exitHorizontally = random.nextBoolean(); + int targetX = exitHorizontally ? horizontal : random.nextInt(0, client.getCanvasWidth() + 1); + int targetY = exitHorizontally ? random.nextInt(0, client.getCanvasHeight() + 1) : vertical; + + Runnable travelThenCross = () -> { + // MouseMotion clamps to the canvas, so this reaches the edge and the next call crosses. + move(targetX, targetY); + Microbot.getMouse().move(targetX, targetY); + }; + + // One task: moveTo is async on the client thread, where two calls would cross first. + if (Microbot.getClient().isClientThread()) { + executorService.submit(travelThenCross); + } else { + travelThenCross.run(); } } 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 0871e8a71a6..730710c4b8f 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 @@ -31,6 +31,7 @@ import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.input.InputArbiter; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; @@ -1472,6 +1473,11 @@ public static WalkerState walkStep(WorldPoint target, int distance) { log.warn("Please do not call the walker from the main thread"); return WalkerState.EXIT; } + // Caller-driven: one click per call, never enters processWalk's loop, so isWalkCancelled + // never runs for it. + if (InputArbiter.isHuman()) { + return WalkerState.EXIT; + } WorldPoint playerLoc = Rs2Player.getWorldLocation(); if (playerLoc == null) { @@ -3375,6 +3381,11 @@ private static boolean isKnownWalkableOrUnloaded(WorldPoint target) { } private static boolean isWalkCancelled(WorldPoint target) { + // The single choke point for stopping a walk: processWalk already consults it at every + // checkpoint and inside the movement-wait predicates. + if (InputArbiter.isHuman()) { + return true; + } WalkCompletionContext completion = walkCompletionContext.get(); if (completion != null && Objects.equals(completion.target, target) && evaluateWalkCompletion(completion)) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/CanvasBoundaryTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/CanvasBoundaryTest.java new file mode 100644 index 00000000000..7e1da5ffb4a --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/CanvasBoundaryTest.java @@ -0,0 +1,451 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Client; +import net.runelite.api.Point; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.mouse.BotEventGuard; +import net.runelite.client.plugins.microbot.util.mouse.VirtualMouse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.awt.Canvas; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.HashSet; +import java.util.Random; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Alt-tab away and the canvas gets a real MOUSE_EXITED, after which the client believes there is + * no pointer. Ignoring it left this layer reporting one wherever the human abandoned it. + */ +public class CanvasBoundaryTest +{ + private Client client; + private Canvas canvas; + private final List received = new ArrayList<>(); + + private Object previousClient; + private Object previousNaturalMouse; + + @Before + public void before() throws Exception + { + canvas = new Canvas(); + MouseAdapter recorder = new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + received.add(e); + } + + @Override + public void mouseReleased(MouseEvent e) + { + received.add(e); + } + + @Override + public void mouseClicked(MouseEvent e) + { + received.add(e); + } + + @Override + public void mouseEntered(MouseEvent e) + { + received.add(e); + } + + @Override + public void mouseExited(MouseEvent e) + { + received.add(e); + } + }; + canvas.addMouseListener(recorder); + canvas.addMouseMotionListener(new java.awt.event.MouseMotionAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + received.add(e); + } + }); + + client = mock(Client.class); + when(client.getCanvas()).thenReturn(canvas); + when(client.isClientThread()).thenReturn(false); + when(client.isStretchedEnabled()).thenReturn(false); + // Without a size the emitter cannot tell an edge exit from a covered one and falls back to + // exact re-entry, which would let these pass without exercising anything. + when(client.getCanvasWidth()).thenReturn(765); + when(client.getCanvasHeight()).thenReturn(503); + + previousClient = swapStatic("client", client); + previousNaturalMouse = swapStatic("naturalMouse", null); + + PointerState.reset(); + InputArbiter.resetForTest(); + CanvasInputListener.detach(); + CanvasInputListener.attach(); + } + + @After + public void after() throws Exception + { + CanvasInputListener.detach(); + swapStatic("client", previousClient); + swapStatic("naturalMouse", previousNaturalMouse); + PointerState.reset(); + InputArbiter.resetForTest(); + while (BotEventGuard.isSynthetic()) + { + BotEventGuard.end(); + } + } + + @Test + public void aRealExitIsRecordedWithoutClaimingATakeover() + { + PointerState.setFromBot(100, 100); + + realExit(412, 318); + + assertTrue(PointerState.isOutside()); + assertEquals(412, PointerState.getX()); + assertEquals(318, PointerState.getY()); + assertFalse("leaving the canvas is not an intent to take over", InputArbiter.isHuman()); + } + + @Test + public void theFirstEmitAfterAnExitAnnouncesItselfFirst() + { + PointerState.setFromBot(100, 100); + realExit(412, 318); + received.clear(); + + AwtEmitter.moved(500, 400); + + assertEquals("a pointer believed absent must announce its return before moving", + ids(MouseEvent.MOUSE_ENTERED, MouseEvent.MOUSE_MOVED), receivedIds()); + // The point itself is drawn from a distribution, exercised below. + assertTrue(received.get(0).getX() >= 0 && received.get(0).getX() < W); + assertTrue(received.get(0).getY() >= 0 && received.get(0).getY() < H); + assertFalse(PointerState.isOutside()); + } + + @Test + public void onlyTheFirstEmitAnnouncesItself() + { + PointerState.setFromBot(100, 100); + realExit(412, 318); + + AwtEmitter.moved(500, 400); + received.clear(); + AwtEmitter.moved(510, 410); + AwtEmitter.moved(520, 420); + + assertEquals(ids(MouseEvent.MOUSE_MOVED, MouseEvent.MOUSE_MOVED), receivedIds()); + } + + @Test + public void aClickAfterAnExitStillLeadsWithTheEntry() + { + PointerState.setFromBot(100, 100); + realExit(412, 318); + received.clear(); + + new VirtualMouse().click(new Point(412, 318), false); + + // The middle is not pinned: whether a MOVED appears depends on where the entry was drawn. + List got = receivedIds(); + assertEquals("the return announces itself before anything else", + Integer.valueOf(MouseEvent.MOUSE_ENTERED), got.get(0)); + assertEquals(ids(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED), + got.subList(got.size() - 3, got.size())); + } + + @Test + public void aRealEntryClearsTheFlagWithoutAnySyntheticEntry() + { + PointerState.setFromBot(100, 100); + realExit(412, 318); + + realEnter(400, 300); + assertFalse(PointerState.isOutside()); + assertEquals(400, PointerState.getX()); + + // Cleared after the real entry, so what follows is only what the emitter itself produced. + received.clear(); + AwtEmitter.moved(500, 400); + + assertEquals("the human already brought the pointer back; announcing it again would be a lie", + ids(MouseEvent.MOUSE_MOVED), receivedIds()); + } + + @Test + public void syntheticBoundaryEventsAreIgnoredByTheListener() + { + PointerState.setFromBot(100, 100); + realExit(412, 318); + + // The emitted ENTERED goes out under the guard, so the listener must not read it as the + // human bringing the pointer back. + AwtEmitter.moved(500, 400); + + assertEquals(500, PointerState.getX()); + assertEquals(400, PointerState.getY()); + } + + @Test + public void aBotMoveOffTheCanvasCrossesInsteadOfReportingMotionOutThere() + { + PointerState.setFromBot(400, 300); + received.clear(); + + new VirtualMouse().move(new Point(-1, 300)); + + assertEquals("a pointer past the boundary sends the crossing and nothing else", + ids(MouseEvent.MOUSE_EXITED), receivedIds()); + assertTrue(PointerState.isOutside()); + assertEquals("the exit coordinate is kept, so the return can be drawn from it", + -1, PointerState.getX()); + } + + @Test + public void furtherMovementWhileOffCanvasIsSilent() + { + PointerState.setFromBot(400, 300); + new VirtualMouse().move(new Point(-1, 300)); + received.clear(); + + new VirtualMouse().move(new Point(-40, 320)); + new VirtualMouse().move(new Point(-80, 340)); + + assertTrue("the canvas hears nothing from a pointer that has left it", received.isEmpty()); + assertTrue(PointerState.isOutside()); + } + + @Test + public void movingBackInAnnouncesTheReturn() + { + PointerState.setFromBot(400, 300); + new VirtualMouse().move(new Point(-1, 300)); + received.clear(); + + new VirtualMouse().move(new Point(420, 260)); + + assertEquals(ids(MouseEvent.MOUSE_ENTERED, MouseEvent.MOUSE_MOVED), receivedIds()); + assertFalse(PointerState.isOutside()); + } + + @Test + public void aBotExitCarriesTheBotReferenceOutWithIt() + { + PointerState.setFromBot(760, 300); + + new VirtualMouse().move(new Point(W + 1, 300)); + + assertEquals("left behind, drift would be measured from a point the bot has left", + W + 1, PointerState.lastBotPoint().getX()); + } + + @Test + public void theBotCannotDragTheHumanPointerOffTheCanvas() + { + PointerState.setFromBot(400, 300); + InputArbiter.onRealButtonPressed(MouseEvent.BUTTON1); + received.clear(); + + // Straight at the emitter: VirtualMouse.move returns on isHuman before reaching the guard + // under test, so going through it would pass either way. + AwtEmitter.moved(-1, 300); + + assertTrue("real events win, so a synthetic exit must not go out", received.isEmpty()); + assertFalse("nor may it claim the human's pointer left", PointerState.isOutside()); + } + + @Test + public void anInBoundsMoveStillJustMoves() + { + PointerState.setFromBot(400, 300); + received.clear(); + + new VirtualMouse().move(new Point(W - 1, H - 1)); + + assertEquals("the last pixel is inside", ids(MouseEvent.MOUSE_MOVED), receivedIds()); + assertFalse(PointerState.isOutside()); + } + + @Test + public void anUnknownCanvasSizeKeepsMovingRatherThanGoingSilent() + { + when(client.getCanvasWidth()).thenReturn(0); + when(client.getCanvasHeight()).thenReturn(0); + PointerState.setFromBot(400, 300); + received.clear(); + + new VirtualMouse().move(new Point(-1, 300)); + + assertEquals("with no size to compare against, suppressing every emit is the worse guess", + ids(MouseEvent.MOUSE_MOVED), receivedIds()); + assertFalse(PointerState.isOutside()); + } + + // Fixed-mode size. Re-entry is random by design, so these assert the rule over many draws. + private static final int W = 765; + private static final int H = 503; + + @Test + public void leavingThroughAnEdgeReturnsAlongThatEdgeButNotAlwaysAtTheSameSpot() + { + Random random = new Random(1); + Set heights = new HashSet<>(); + + for (int i = 0; i < 200; i++) + { + Point entry = AwtEmitter.reentryPoint(W - 1, 250, W, H, random); + assertEquals("the edge is geometry and is kept", W - 1, entry.getX()); + assertTrue(entry.getY() >= 0 && entry.getY() < H); + heights.add(entry.getY()); + } + + // Returning to exactly 250 every time would make ENTERED and EXITED agree perfectly. + assertTrue("the free axis must vary, got " + heights.size() + " distinct heights", heights.size() > 20); + } + + @Test + public void leavingThroughTheTopVariesTheOtherAxis() + { + Random random = new Random(2); + Set widths = new HashSet<>(); + + for (int i = 0; i < 200; i++) + { + Point entry = AwtEmitter.reentryPoint(300, 0, W, H, random); + assertEquals(0, entry.getY()); + widths.add(entry.getX()); + } + + assertTrue(widths.size() > 20); + } + + @Test + public void aCoveredExitReturnsBothInsideAndAcrossAnEdge() + { + // A mid-canvas exit means a window covered the client, and whatever the user did there they + // probably did with the mouse. Both outcomes must occur. + Random random = new Random(3); + int acrossAnEdge = 0; + int inside = 0; + + for (int i = 0; i < 400; i++) + { + Point entry = AwtEmitter.reentryPoint(400, 250, W, H, random); + assertTrue(entry.getX() >= 0 && entry.getX() < W); + assertTrue(entry.getY() >= 0 && entry.getY() < H); + + boolean onEdge = entry.getX() == 0 || entry.getX() == W - 1 + || entry.getY() == 0 || entry.getY() == H - 1; + if (onEdge) acrossAnEdge++; + else inside++; + } + + assertTrue("some returns cross an edge, got " + acrossAnEdge, acrossAnEdge > 20); + assertTrue("some returns land inside, got " + inside, inside > 20); + } + + @Test + public void aCoveredExitDoesNotAlwaysReturnToTheExactExitPoint() + { + Random random = new Random(4); + int exact = 0; + + for (int i = 0; i < 400; i++) + { + Point entry = AwtEmitter.reentryPoint(400, 250, W, H, random); + if (entry.getX() == 400 && entry.getY() == 250) exact++; + } + + // Still reachable, that being the user who never touched the mouse; just not the rule. + assertTrue("returning to the exact exit point must not be the rule, got " + exact + "/400", exact < 40); + } + + @Test + public void everyReentryStaysInsideTheCanvas() + { + Random random = new Random(5); + + for (int i = 0; i < 400; i++) + { + for (Point exit : new Point[]{new Point(W - 1, 500), new Point(0, 3), new Point(400, 250)}) + { + Point entry = AwtEmitter.reentryPoint(exit.getX(), exit.getY(), W, H, random); + assertTrue("x out of bounds: " + entry.getX(), entry.getX() >= 0 && entry.getX() < W); + assertTrue("y out of bounds: " + entry.getY(), entry.getY() >= 0 && entry.getY() < H); + } + } + } + + @Test + public void anUnknownCanvasSizeFallsBackToTheExitPoint() + { + Point entry = AwtEmitter.reentryPoint(764, 250, 0, 0, new Random(6)); + + assertEquals(764, entry.getX()); + assertEquals(250, entry.getY()); + } + + private void realExit(int componentX, int componentY) + { + canvas.dispatchEvent(new MouseEvent(canvas, MouseEvent.MOUSE_EXITED, 0L, 0, + componentX, componentY, 0, false)); + } + + private void realEnter(int componentX, int componentY) + { + canvas.dispatchEvent(new MouseEvent(canvas, MouseEvent.MOUSE_ENTERED, 0L, 0, + componentX, componentY, 0, false)); + } + + private List receivedIds() + { + List out = new ArrayList<>(); + for (MouseEvent event : received) + { + out.add(event.getID()); + } + return out; + } + + private static List ids(int... values) + { + List out = new ArrayList<>(); + for (int value : values) + { + out.add(value); + } + return out; + } + + 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; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/FocusLossReleasesHeldInputTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/FocusLossReleasesHeldInputTest.java new file mode 100644 index 00000000000..b7bb33b0d87 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/FocusLossReleasesHeldInputTest.java @@ -0,0 +1,169 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Client; +import net.runelite.client.plugins.microbot.Microbot; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.awt.Canvas; +import java.awt.event.FocusEvent; +import java.awt.event.FocusListener; +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; +import java.awt.event.MouseEvent; +import java.lang.reflect.Field; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * A key held when the window deactivates never delivers its KEY_RELEASED, and since a held key + * suppresses idle resume the stale entry pins HUMAN forever. + * + *

Observed live: Ctrl held for a screenshot, cleared only by pressing Ctrl again. + */ +public class FocusLossReleasesHeldInputTest +{ + private Canvas canvas; + private final AtomicLong now = new AtomicLong(1_000_000L); + private Object previousClient; + + @Before + public void before() throws Exception + { + canvas = new Canvas(); + Client client = mock(Client.class); + when(client.getCanvas()).thenReturn(canvas); + when(client.isStretchedEnabled()).thenReturn(false); + previousClient = swapStatic("client", client); + + PointerState.reset(); + InputArbiter.resetForTest(); + InputArbiter.setClockForTest(now::get); + CanvasInputListener.detach(); + CanvasInputListener.attach(); + } + + @After + public void after() throws Exception + { + CanvasInputListener.detach(); + swapStatic("client", previousClient); + PointerState.reset(); + InputArbiter.resetForTest(); + } + + @Test + public void aKeyHeldWhenFocusLeavesDoesNotPinHumanForever() + { + PointerState.setFromBot(100, 100); + pressKey(KeyEvent.VK_CONTROL); + assertTrue(InputArbiter.isHuman()); + + // Long past the idle window; without the fix this stays HUMAN indefinitely. + advanceMs(60_000); + assertTrue("a held key suppresses resume while focus is on the canvas", InputArbiter.isHuman()); + + loseFocus(); + + assertEquals("none", InputDiagnostics.readout().get("real held")); + advanceMs(1_801); + assertFalse("the bot must resume once the releases stop coming", InputArbiter.isHuman()); + } + + @Test + public void focusLossStartsTheIdleWindowFreshRatherThanResumingInstantly() + { + PointerState.setFromBot(100, 100); + pressKey(KeyEvent.VK_CONTROL); + advanceMs(60_000); + + loseFocus(); + + assertTrue("the user was interacting a moment ago", InputArbiter.isHuman()); + advanceMs(1_799); + assertTrue(InputArbiter.isHuman()); + advanceMs(2); + assertFalse(InputArbiter.isHuman()); + } + + @Test + public void aHeldMouseButtonIsDroppedToo() + { + PointerState.setFromBot(100, 100); + dispatch(new MouseEvent(canvas, MouseEvent.MOUSE_PRESSED, now.get(), 0, 10, 10, 1, false, + MouseEvent.BUTTON1)); + assertTrue(InputArbiter.isRealButtonOrKeyDown()); + + loseFocus(); + + assertFalse("alt-tabbing mid-drag loses the release too", InputArbiter.isRealButtonOrKeyDown()); + } + + @Test + public void focusLossWithNothingHeldChangesNothing() + { + PointerState.setFromBot(100, 100); + assertFalse(InputArbiter.isHuman()); + + loseFocus(); + + assertFalse("clearing nothing must not look like activity", InputArbiter.isHuman()); + } + + @Test + public void temporaryFocusLossCountsBecauseWindowDeactivationReportsOne() + { + PointerState.setFromBot(100, 100); + pressKey(KeyEvent.VK_SHIFT); + + for (FocusListener listener : canvas.getFocusListeners()) + { + listener.focusLost(new FocusEvent(canvas, FocusEvent.FOCUS_LOST, true)); + } + + assertEquals("none", InputDiagnostics.readout().get("real held")); + } + + private void advanceMs(long millis) + { + now.addAndGet(millis * 1_000_000L); + } + + private void pressKey(int keyCode) + { + KeyEvent event = new KeyEvent(canvas, KeyEvent.KEY_PRESSED, now.get(), 0, keyCode, + KeyEvent.CHAR_UNDEFINED); + for (KeyListener listener : canvas.getKeyListeners()) + { + listener.keyPressed(event); + } + } + + private void loseFocus() + { + for (FocusListener listener : canvas.getFocusListeners()) + { + listener.focusLost(new FocusEvent(canvas, FocusEvent.FOCUS_LOST, false)); + } + } + + private void dispatch(java.awt.AWTEvent event) + { + canvas.dispatchEvent(event); + } + + 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; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/GestureAbortTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/GestureAbortTest.java new file mode 100644 index 00000000000..647faf6ca41 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/GestureAbortTest.java @@ -0,0 +1,378 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Client; +import net.runelite.api.Point; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; +import net.runelite.client.plugins.microbot.util.mouse.BotEventGuard; +import net.runelite.client.plugins.microbot.util.mouse.VirtualMouse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.awt.Canvas; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.lang.reflect.Field; +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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** A gesture interrupted partway through unwinds instead of finishing. */ +public class GestureAbortTest +{ + private Client client; + private Canvas canvas; + private final List received = new ArrayList<>(); + + private Object previousClient; + private Object previousNaturalMouse; + + @Before + public void before() throws Exception + { + canvas = new Canvas(); + canvas.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + received.add(e); + } + + @Override + public void mouseReleased(MouseEvent e) + { + received.add(e); + } + + @Override + public void mouseClicked(MouseEvent e) + { + received.add(e); + } + }); + canvas.addMouseMotionListener(new java.awt.event.MouseMotionAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + received.add(e); + } + }); + + client = mock(Client.class); + when(client.getCanvas()).thenReturn(canvas); + when(client.isClientThread()).thenReturn(false); + when(client.isStretchedEnabled()).thenReturn(false); + + previousClient = swapStatic("client", client); + previousNaturalMouse = swapStatic("naturalMouse", null); + + PointerState.reset(); + InputArbiter.resetForTest(); + Microbot.targetMenu = null; + } + + @After + public void after() throws Exception + { + swapStatic("client", previousClient); + swapStatic("naturalMouse", previousNaturalMouse); + PointerState.reset(); + InputArbiter.resetForTest(); + InputLoop.setLockTimeoutForTest(5_000L); + Microbot.targetMenu = null; + while (BotEventGuard.isSynthetic()) + { + BotEventGuard.end(); + } + } + + @Test + public void midPressAbortEmitsOneReleaseAndNoClick() + { + PointerState.setFromBot(100, 100); + + InputLoop.Result result = InputLoop.run(emit -> { + emit.press(100, 100, MouseEvent.BUTTON1); + takeOver(); + emit.click(100, 100, MouseEvent.BUTTON1); + }); + + assertEquals(InputLoop.Result.ABORTED, result); + assertEquals("PRESSED then exactly one RELEASED, and no CLICKED for a cancelled click", + ids(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED), receivedIds()); + } + + @Test + public void abortReleasesAtTheCurrentPointNotTheStaleTarget() + { + PointerState.setFromBot(100, 100); + + InputLoop.run(emit -> { + emit.press(100, 100, MouseEvent.BUTTON1); + // The human moves away with the button down, as in a drag. + takeOverAt(640, 480); + emit.release(900, 900, MouseEvent.BUTTON1); + }); + + MouseEvent release = last(); + assertEquals(MouseEvent.MOUSE_RELEASED, release.getID()); + assertEquals("release must land where the pointer actually is", 640, release.getX()); + assertEquals(480, release.getY()); + } + + @Test + public void abortClearsTargetMenuSoTheHumansNextClickIsNotHijacked() + { + PointerState.setFromBot(100, 100); + NewMenuEntry entry = new NewMenuEntry(); + + InputLoop.Result result = InputLoop.run(emit -> { + Microbot.targetMenu = entry; + takeOver(); + emit.press(100, 100, MouseEvent.BUTTON1); + }); + + assertEquals(InputLoop.Result.ABORTED, result); + assertNull("an entry left armed is consumed by whatever clicks next, which is the human", + Microbot.targetMenu); + } + + @Test + public void completedGestureKeepsTargetMenu() + { + PointerState.setFromBot(100, 100); + NewMenuEntry entry = new NewMenuEntry(); + + InputLoop.Result result = InputLoop.run(emit -> { + Microbot.targetMenu = entry; + emit.press(100, 100, MouseEvent.BUTTON1); + emit.release(100, 100, MouseEvent.BUTTON1); + emit.click(100, 100, MouseEvent.BUTTON1); + }); + + assertEquals(InputLoop.Result.COMPLETED, result); + assertEquals("the client consumes the entry on the click it was armed for", entry, Microbot.targetMenu); + } + + @Test + public void aGestureStartedAfterTakeoverNeverDispatches() + { + PointerState.setFromBot(100, 100); + takeOver(); + + InputLoop.Result result = InputLoop.run(emit -> emit.press(100, 100, MouseEvent.BUTTON1)); + + assertEquals(InputLoop.Result.ABORTED, result); + assertTrue("no AWT at all once the human owns input", received.isEmpty()); + } + + @Test + public void aDeferredItemQueuedBeforeTakeoverIsAbortedWhenItRuns() + { + PointerState.setFromBot(100, 100); + + // Stands in for the client-thread deferral: scheduled while BOT, may run after a takeover. + Runnable deferred = () -> InputLoop.run(emit -> emit.wheel(100, 100, 2, 10)); + takeOver(); + deferred.run(); + + assertTrue(received.isEmpty()); + } + + @Test + public void realKeyDuringAMouseGestureAbortsItAndReleasesTheHeldButton() + { + PointerState.setFromBot(100, 100); + + InputLoop.Result result = InputLoop.run(emit -> { + emit.press(100, 100, MouseEvent.BUTTON1); + // A key, not a mouse event: a mouse-only abort path would miss this. + InputArbiter.onRealKeyPressed(java.awt.event.KeyEvent.VK_A); + emit.release(100, 100, MouseEvent.BUTTON1); + }); + + assertEquals(InputLoop.Result.ABORTED, result); + assertEquals(ids(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED), receivedIds()); + } + + @Test + public void oneGestureAtATimeAcrossThreads() throws Exception + { + PointerState.setFromBot(100, 100); + CountDownLatch inside = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + // Ran and ordering are separate: a probe read after the latch drops is false whether the + // second gesture waited its turn or never started at all. + AtomicBoolean secondRan = new AtomicBoolean(false); + AtomicBoolean firstHadFinished = new AtomicBoolean(false); + AtomicBoolean firstFinished = new AtomicBoolean(false); + + Thread first = new Thread(() -> InputLoop.run(emit -> { + inside.countDown(); + try + { + release.await(2, TimeUnit.SECONDS); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + // Inside the gesture, so the lock is still held when it is set. + firstFinished.set(true); + })); + first.start(); + assertTrue(inside.await(2, TimeUnit.SECONDS)); + + Thread second = new Thread(() -> InputLoop.run(emit -> { + secondRan.set(true); + firstHadFinished.set(firstFinished.get()); + })); + second.start(); + Thread.sleep(120); + + assertFalse("second gesture must not run while the first holds the lock", secondRan.get()); + release.countDown(); + first.join(2_000); + second.join(2_000); + + assertTrue("it has to actually run, or the assertion above passes for the wrong reason", + secondRan.get()); + assertTrue("second gesture ran only after the first finished", firstHadFinished.get()); + } + + @Test + public void aSecondGestureGivesUpRatherThanBlockingForever() throws Exception + { + PointerState.setFromBot(100, 100); + InputLoop.setLockTimeoutForTest(150L); + CountDownLatch holding = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + Thread hog = new Thread(() -> InputLoop.run(emit -> { + holding.countDown(); + try + { + release.await(30, TimeUnit.SECONDS); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + })); + hog.setDaemon(true); + hog.start(); + assertTrue(holding.await(2, TimeUnit.SECONDS)); + + // Unbounded, one wedged gesture would hold every other script's input indefinitely. + long start = System.nanoTime(); + InputLoop.Result result = InputLoop.run(emit -> emit.press(100, 100, MouseEvent.BUTTON1)); + long waitedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + + release.countDown(); + hog.join(2_000); + + assertEquals(InputLoop.Result.ABORTED, result); + assertTrue("must give up on a timeout, waited " + waitedMs + "ms", waitedMs < 5_000); + } + + @Test + public void scrollMovesAndWheelsAsOneUninterruptibleGesture() throws Exception + { + PointerState.setFromBot(100, 100); + canvas.addMouseWheelListener(received::add); + + Thread scroller = new Thread(() -> new VirtualMouse().scrollDown(new Point(300, 200))); + scroller.setDaemon(true); + scroller.start(); + scroller.join(3_000); + + // Separately, another script's click could land between the two and leave the wheel firing + // at a point the cursor had left. + assertEquals(ids(MouseEvent.MOUSE_MOVED, MouseEvent.MOUSE_WHEEL), receivedIds()); + for (MouseEvent event : received) + { + assertEquals(300, event.getX()); + assertEquals(200, event.getY()); + } + } + + @Test(expected = IllegalStateException.class) + public void aNestedGestureIsRejected() + { + PointerState.setFromBot(100, 100); + + // The inner run would get its own Emit and unwind the outer gesture's state. + InputLoop.run(outer -> InputLoop.run(inner -> inner.move(10, 10))); + } + + @Test + public void facadeReturnsItselfEvenWhenTheGestureAborted() + { + PointerState.setFromBot(100, 100); + takeOver(); + VirtualMouse mouse = new VirtualMouse(); + + assertEquals("the facade cannot report failure; scripts re-validate on the next loop", + mouse, mouse.click(new Point(100, 100), false)); + assertTrue(received.isEmpty()); + } + + private void takeOver() + { + InputArbiter.onRealButtonPressed(MouseEvent.BUTTON1); + assertTrue(InputArbiter.isHuman()); + } + + private void takeOverAt(int canvasX, int canvasY) + { + PointerState.setFromReal(canvasX, canvasY); + takeOver(); + } + + private MouseEvent last() + { + return received.get(received.size() - 1); + } + + private List receivedIds() + { + List out = new ArrayList<>(); + for (MouseEvent event : received) + { + out.add(event.getID()); + } + return out; + } + + private static List ids(int... values) + { + List out = new ArrayList<>(); + for (int value : values) + { + out.add(value); + } + return out; + } + + 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; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputArbiterTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputArbiterTest.java new file mode 100644 index 00000000000..76ea3065d23 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputArbiterTest.java @@ -0,0 +1,311 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Client; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.mouse.BotEventGuard; +import net.runelite.client.plugins.microbot.util.mouse.VirtualMouse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.awt.Canvas; +import java.awt.Dimension; +import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; +import java.lang.reflect.Field; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Who the arbiter believes owns input, and that the listener sees real events without being fooled + * by synthetic ones. + */ +public class InputArbiterTest +{ + private Client client; + private Canvas canvas; + private final AtomicLong now = new AtomicLong(1_000_000L); + + private Object previousClient; + private Object previousNaturalMouse; + + @Before + public void before() throws Exception + { + canvas = new Canvas(); + client = mock(Client.class); + when(client.getCanvas()).thenReturn(canvas); + when(client.isClientThread()).thenReturn(false); + when(client.isStretchedEnabled()).thenReturn(false); + + previousClient = swapStatic("client", client); + previousNaturalMouse = swapStatic("naturalMouse", null); + + PointerState.reset(); + InputArbiter.resetForTest(); + InputArbiter.setClockForTest(now::get); + CanvasInputListener.detach(); + CanvasInputListener.attach(); + } + + @After + public void after() throws Exception + { + CanvasInputListener.detach(); + swapStatic("client", previousClient); + swapStatic("naturalMouse", previousNaturalMouse); + PointerState.reset(); + InputArbiter.resetForTest(); + while (BotEventGuard.isSynthetic()) + { + BotEventGuard.end(); + } + } + + @Test + public void attachIsIdempotentAndFollowsACanvasSwap() + { + CanvasInputListener.attach(); + assertEquals("repeated attach must not stack listeners", 1, canvas.getMouseListeners().length); + + Canvas replacement = new Canvas(); + when(client.getCanvas()).thenReturn(replacement); + CanvasInputListener.attach(); + + assertEquals("old canvas must be released", 0, canvas.getMouseListeners().length); + assertEquals(1, replacement.getMouseListeners().length); + assertTrue(CanvasInputListener.isAttachedTo(replacement)); + } + + @Test + public void realMovePastThresholdFlipsHuman() + { + PointerState.setFromBot(100, 100); + assertFalse(InputArbiter.isHuman()); + + realMove(100, 111); + + assertTrue(InputArbiter.isHuman()); + } + + @Test + public void realMoveUnderThresholdDoesNotFlipHuman() + { + PointerState.setFromBot(100, 100); + + realMove(103, 100); + + assertFalse(InputArbiter.isHuman()); + } + + @Test + public void slowDriftAccumulatesBecauseTheReferenceIsTheBotPoint() + { + PointerState.setFromBot(100, 100); + + // Measured against the previous real event, no single delta crosses the threshold. + for (int i = 1; i <= 20; i++) + { + realMove(100 + i * 3, 100); + } + + assertTrue("60px of real travel must be seen even though no single delta exceeded 10px", + InputArbiter.isHuman()); + } + + @Test + public void motionBeforeAnyBotEmitDoesNotFlipHuman() + { + // No bot point means no reference, and nothing in flight to abort. + realMove(900, 900); + + assertFalse(InputArbiter.isHuman()); + } + + @Test + public void realKeyFlipsHumanAndSyntheticKeysDoNot() + { + PointerState.setFromBot(100, 100); + + Rs2Keyboard.keyPress(KeyEvent.VK_A); + assertFalse("Rs2Keyboard hand-delivers to this same listener", InputArbiter.isHuman()); + + realKeyPressed(KeyEvent.VK_A); + assertTrue(InputArbiter.isHuman()); + } + + @Test + public void syntheticClickDoesNotFlipHuman() + { + PointerState.setFromBot(10, 10); + + new VirtualMouse().click(new net.runelite.api.Point(400, 300), false); + + assertFalse("the emitter's own events must not read as a takeover", InputArbiter.isHuman()); + } + + @Test + public void idleWindowReturnsToBot() + { + PointerState.setFromBot(100, 100); + realMove(100, 200); + assertTrue(InputArbiter.isHuman()); + + advanceMs(1799); + assertTrue("still inside the 1800ms window", InputArbiter.isHuman()); + + advanceMs(2); + assertFalse(InputArbiter.isHuman()); + } + + @Test + public void heldButtonSuppressesIdleResume() + { + PointerState.setFromBot(100, 100); + realButtonPressed(MouseEvent.BUTTON1); + + advanceMs(60_000); + + assertTrue("a held button generates no further events, so the idle window alone would " + + "resume under the user's hand", InputArbiter.isHuman()); + + realButtonReleased(MouseEvent.BUTTON1); + advanceMs(1801); + assertFalse(InputArbiter.isHuman()); + } + + @Test + public void heldKeySuppressesIdleResume() + { + PointerState.setFromBot(100, 100); + realKeyPressed(KeyEvent.VK_SHIFT); + + advanceMs(60_000); + assertTrue(InputArbiter.isHuman()); + + realKeyReleased(KeyEvent.VK_SHIFT); + advanceMs(1801); + assertFalse(InputArbiter.isHuman()); + } + + @Test + public void killSwitchForcesBot() + { + PointerState.setFromBot(100, 100); + InputArbiter.setDisabled(true); + + realMove(500, 500); + realButtonPressed(MouseEvent.BUTTON1); + + assertFalse("with yielding disabled no real input may flip HUMAN", InputArbiter.isHuman()); + + InputArbiter.setDisabled(false); + assertTrue("re-enabling must not lose the button that is still held", InputArbiter.isHuman()); + } + + @Test + public void realEventsAreConvertedToCanvasSpaceBeforeBeingRecorded() + { + when(client.isStretchedEnabled()).thenReturn(true); + when(client.getStretchedDimensions()).thenReturn(new Dimension(1600, 1200)); + when(client.getRealDimensions()).thenReturn(new Dimension(800, 600)); + + realMove(400, 600); + + assertEquals("PointerState is canvas space, never the component pair off the wire", 200, PointerState.getX()); + assertEquals(300, PointerState.getY()); + } + + @Test + public void whileHumanASyntheticEmitDoesNotClobberTheHumanPoint() + { + PointerState.setFromBot(100, 100); + realMove(640, 480); + assertTrue(InputArbiter.isHuman()); + + AwtEmitter.moved(20, 20); + + assertEquals("real events win; a late synthetic must not move the recorded point", 640, PointerState.getX()); + assertEquals(480, PointerState.getY()); + } + + @Test + public void aClockStepBackwardsDoesNotPinHumanForever() + { + PointerState.setFromBot(100, 100); + realMove(100, 200); + assertTrue(InputArbiter.isHuman()); + + // A wall clock does this on NTP correction or a VM resuming, and the elapsed comparison + // then reads as "inside the idle window". The production clock is monotonic, so this + // drives the second guard: a negative elapsed is treated as expired. + now.addAndGet(-600_000L * 1_000_000L); + + assertFalse("a backwards clock step must not strand the bot in HUMAN", InputArbiter.isHuman()); + } + + /** The arbiter's clock is nanos. */ + private void advanceMs(long millis) + { + now.addAndGet(millis * 1_000_000L); + } + + private void realMove(int componentX, int componentY) + { + dispatch(new MouseEvent(canvas, MouseEvent.MOUSE_MOVED, now.get(), 0, componentX, componentY, 0, false)); + } + + private void realButtonPressed(int button) + { + dispatch(new MouseEvent(canvas, MouseEvent.MOUSE_PRESSED, now.get(), 0, 0, 0, 1, false, button)); + } + + private void realButtonReleased(int button) + { + dispatch(new MouseEvent(canvas, MouseEvent.MOUSE_RELEASED, now.get(), 0, 0, 0, 1, false, button)); + } + + // Key events cannot go through dispatchEvent here: AWT routes them via the KeyboardFocusManager, + // which drops them for a component that is not the focus owner, and a test Canvas never is. + // Invoking the listeners is what AWT does once a key event reaches a focused component. + // + // That same focus requirement is why keys typed into another window never yield. + private void realKeyPressed(int keyCode) + { + KeyEvent event = new KeyEvent(canvas, KeyEvent.KEY_PRESSED, now.get(), 0, keyCode, KeyEvent.CHAR_UNDEFINED); + for (java.awt.event.KeyListener listener : canvas.getKeyListeners()) + { + listener.keyPressed(event); + } + } + + private void realKeyReleased(int keyCode) + { + KeyEvent event = new KeyEvent(canvas, KeyEvent.KEY_RELEASED, now.get(), 0, keyCode, KeyEvent.CHAR_UNDEFINED); + for (java.awt.event.KeyListener listener : canvas.getKeyListeners()) + { + listener.keyReleased(event); + } + } + + // No guard raised, which is what makes these real rather than synthetic. + private void dispatch(java.awt.AWTEvent event) + { + canvas.dispatchEvent(event); + } + + 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; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputDiagnosticsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputDiagnosticsTest.java new file mode 100644 index 00000000000..5a41d21574a --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputDiagnosticsTest.java @@ -0,0 +1,89 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Client; +import net.runelite.client.plugins.microbot.Microbot; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.awt.Canvas; +import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; +import java.lang.reflect.Field; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Only the formatting the readout does itself. What it reports on is {@link InputArbiterTest}'s + * subject, and asserting the display strings again there would pin wording rather than behaviour. + */ +public class InputDiagnosticsTest +{ + private Object previousClient; + + @Before + public void before() throws Exception + { + Client client = mock(Client.class); + when(client.getCanvas()).thenReturn(new Canvas()); + previousClient = swapStatic("client", client); + + PointerState.reset(); + InputArbiter.resetForTest(); + CanvasInputListener.detach(); + } + + @After + public void after() throws Exception + { + CanvasInputListener.detach(); + swapStatic("client", previousClient); + PointerState.reset(); + InputArbiter.resetForTest(); + } + + @Test + public void isOffUnlessTheSystemPropertyIsSet() + { + assertFalse("must stay invisible in normal use", InputDiagnostics.isEnabled()); + } + + @Test + public void readsCleanlyBeforeAnythingHasHappened() + { + Map readout = InputDiagnostics.readout(); + + assertEquals("none yet", readout.get("bot point")); + assertEquals("a distance from (-1,-1) would read as a bug", "n/a until first emit", + readout.get("drift")); + assertEquals("never", readout.get("last real")); + assertEquals("none", readout.get("real held")); + } + + @Test + public void namesWhatIsPhysicallyHeld() + { + PointerState.setFromBot(100, 100); + InputArbiter.onRealButtonPressed(MouseEvent.BUTTON1); + InputArbiter.onRealKeyPressed(KeyEvent.VK_SHIFT); + + assertEquals("btn1 Shift", InputDiagnostics.readout().get("real held")); + + InputArbiter.onRealButtonReleased(MouseEvent.BUTTON1); + InputArbiter.onRealKeyReleased(KeyEvent.VK_SHIFT); + assertEquals("none", InputDiagnostics.readout().get("real held")); + } + + 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; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputEmissionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputEmissionTest.java new file mode 100644 index 00000000000..88e22d764e2 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/InputEmissionTest.java @@ -0,0 +1,269 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Client; +import net.runelite.api.Point; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.mouse.BotEventGuard; +import net.runelite.client.plugins.microbot.util.mouse.VirtualMouse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.awt.Canvas; +import java.awt.Dimension; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Emission shape and position source, asserted on the AWT events that actually reach a listener + * on a real {@link Canvas} rather than on calls into a mock. + */ +public class InputEmissionTest +{ + private Client client; + private Canvas canvas; + private final List received = new ArrayList<>(); + private final List syntheticDuringDispatch = new ArrayList<>(); + + private Object previousClient; + private Object previousNaturalMouse; + + @Before + public void before() throws Exception + { + canvas = new Canvas(); + canvas.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + record(e); + } + + @Override + public void mouseReleased(MouseEvent e) + { + record(e); + } + + @Override + public void mouseClicked(MouseEvent e) + { + record(e); + } + }); + canvas.addMouseMotionListener(new java.awt.event.MouseMotionAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + record(e); + } + }); + + client = mock(Client.class); + when(client.getCanvas()).thenReturn(canvas); + when(client.isClientThread()).thenReturn(false); + when(client.isStretchedEnabled()).thenReturn(false); + + previousClient = swapStatic("client", client); + // Null naturalMouse means a click emits only what handleClick itself produces. + previousNaturalMouse = swapStatic("naturalMouse", null); + + PointerState.reset(); + // Static, so a prior test left in HUMAN would abort every click here. + InputArbiter.resetForTest(); + } + + @After + public void after() throws Exception + { + swapStatic("client", previousClient); + swapStatic("naturalMouse", previousNaturalMouse); + PointerState.reset(); + InputArbiter.resetForTest(); + while (BotEventGuard.isSynthetic()) + { + BotEventGuard.end(); + } + } + + @Test + public void aMoveIsSuppressedWhileTheHumanOwnsInput() + { + PointerState.setFromBot(100, 100); + InputArbiter.onRealButtonPressed(MouseEvent.BUTTON1); + received.clear(); + + new VirtualMouse().move(new Point(400, 300)); + + // NaturalMouse drives every step of a trajectory through this method, so the guard here is + // what stops one mid-curve rather than at the next gesture boundary. + assertTrue(received.isEmpty()); + } + + @Test + public void sameSpotClickEmitsTriadWithNoEnterExitOrMove() + { + PointerState.setFromBot(100, 50); + + new VirtualMouse().click(new Point(100, 50), false); + + assertEquals("same-spot click is the triad only; a human already at the point sends no fresh MOVED", + ids(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED), + receivedIds()); + } + + @Test + public void clickFromElsewhereEmitsExactlyOneMoveThenTriad() + { + PointerState.setFromBot(10, 10); + + new VirtualMouse().click(new Point(100, 50), false); + + assertEquals("off-target click with no NaturalMouse must still put the pointer on the target first", + ids(MouseEvent.MOUSE_MOVED, MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED), + receivedIds()); + } + + @Test + public void rightClickUsesButton3() + { + PointerState.setFromBot(100, 50); + + new VirtualMouse().click(new Point(100, 50), true); + + for (MouseEvent event : received) + { + assertEquals(MouseEvent.BUTTON3, event.getButton()); + } + } + + @Test + public void dispatchCoordinatesAreStretchMappedWhileStateStaysCanvas() + { + when(client.isStretchedEnabled()).thenReturn(true); + when(client.getStretchedDimensions()).thenReturn(new Dimension(1600, 1200)); + when(client.getRealDimensions()).thenReturn(new Dimension(800, 600)); + PointerState.setFromBot(100, 50); + + new VirtualMouse().click(new Point(100, 50), false); + + MouseEvent pressed = received.get(0); + assertEquals("dispatched x is toComponent output", 200, pressed.getX()); + assertEquals("dispatched y is toComponent output", 100, pressed.getY()); + assertEquals("PointerState must never hold the pre-convert component pair", 100, PointerState.getX()); + assertEquals(50, PointerState.getY()); + } + + @Test + public void stretchMappingRoundTripsAndIsIdentityWhenOff() + { + when(client.isStretchedEnabled()).thenReturn(true); + when(client.getStretchedDimensions()).thenReturn(new Dimension(1600, 1200)); + when(client.getRealDimensions()).thenReturn(new Dimension(800, 600)); + + Point component = StretchMapper.toComponent(100, 50); + assertEquals(200, component.getX()); + assertEquals(100, component.getY()); + + Point canvasPoint = StretchMapper.toCanvas(200, 100); + assertEquals(100, canvasPoint.getX()); + assertEquals(50, canvasPoint.getY()); + + when(client.isStretchedEnabled()).thenReturn(false); + assertEquals(100, StretchMapper.toComponent(100, 50).getX()); + assertEquals(50, StretchMapper.toComponent(100, 50).getY()); + assertEquals(100, StretchMapper.toCanvas(100, 50).getX()); + assertEquals(50, StretchMapper.toCanvas(100, 50).getY()); + } + + @Test + public void zeroDimensionsMapAsIdentityInBothDirections() + { + when(client.isStretchedEnabled()).thenReturn(true); + when(client.getStretchedDimensions()).thenReturn(new Dimension(0, 0)); + when(client.getRealDimensions()).thenReturn(new Dimension(800, 600)); + + // toCanvas divides by the stretched pair, which the outbound-only guard never checked. + assertEquals(100, StretchMapper.toCanvas(100, 50).getX()); + assertEquals(50, StretchMapper.toCanvas(100, 50).getY()); + assertEquals(100, StretchMapper.toComponent(100, 50).getX()); + } + + @Test + public void guardReportsSyntheticWhileTheListenerRuns() + { + PointerState.setFromBot(100, 50); + + new VirtualMouse().click(new Point(100, 50), false); + + assertTrue("no events were observed, so the assertion below would pass vacuously", + !syntheticDuringDispatch.isEmpty()); + for (Boolean synthetic : syntheticDuringDispatch) + { + assertTrue("the guard is a ThreadLocal depth counter, so this requires dispatchEvent to " + + "run listeners synchronously on the dispatching thread", synthetic); + } + assertTrue("guard must not leak past dispatch", !BotEventGuard.isSynthetic()); + } + + @Test + public void mousePositionFollowsRealInputNotJustBotEmits() + { + VirtualMouse mouse = new VirtualMouse(); + + mouse.click(new Point(100, 50), false); + assertEquals(100, mouse.getMousePosition().x); + assertEquals(50, mouse.getMousePosition().y); + + // The write the old bot-only lastMove field never received. + PointerState.setFromReal(640, 480); + + assertEquals(640, mouse.getMousePosition().x); + assertEquals(480, mouse.getMousePosition().y); + } + + private void record(MouseEvent event) + { + received.add(event); + syntheticDuringDispatch.add(BotEventGuard.isSynthetic()); + } + + private List receivedIds() + { + List out = new ArrayList<>(); + for (MouseEvent event : received) + { + out.add(event.getID()); + } + return out; + } + + private static List ids(int... values) + { + List out = new ArrayList<>(); + for (int value : values) + { + out.add(value); + } + return out; + } + + 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; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/YieldOnHumanTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/YieldOnHumanTest.java new file mode 100644 index 00000000000..70680c9aeb9 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/input/YieldOnHumanTest.java @@ -0,0 +1,225 @@ +package net.runelite.client.plugins.microbot.util.input; + +import net.runelite.api.Client; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.Global; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.awt.event.MouseEvent; +import java.lang.reflect.Field; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * The waits observe a human takeover. Drives the real {@link Global} methods rather than stubbing + * them, so what is asserted is the elapsed behaviour a script would see. + */ +public class YieldOnHumanTest +{ + private Object previousClient; + + @Before + public void before() throws Exception + { + Client client = mock(Client.class); + when(client.isClientThread()).thenReturn(false); + previousClient = swapStatic("client", client); + InputArbiter.resetForTest(); + PointerState.reset(); + } + + @After + public void after() throws Exception + { + swapStatic("client", previousClient); + InputArbiter.resetForTest(); + PointerState.reset(); + } + + @Test + public void longFixedSleepDoesNotFinishItsRemainingTime() + { + long start = System.nanoTime(); + takeOver(); + + Global.sleep(30_000); + + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + assertTrue("a 30s sleep must be cut short by a takeover, took " + elapsedMs + "ms", elapsedMs < 1_000); + } + + @Test + public void aSleepAlreadyRunningIsCutShort() throws Exception + { + Thread sleeper = new Thread(() -> Global.sleep(30_000)); + long start = System.nanoTime(); + sleeper.start(); + + Thread.sleep(80); + takeOver(); + sleeper.join(3_000); + + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + assertFalse("sleeper thread should have returned", sleeper.isAlive()); + assertTrue("takeover mid-sleep must cut the remainder, took " + elapsedMs + "ms", elapsedMs < 2_000); + } + + @Test + public void sleepUntilStopsPollingAndReportsFailure() + { + takeOver(); + AtomicInteger polls = new AtomicInteger(); + + boolean result = Global.sleepUntil(() -> { + polls.incrementAndGet(); + return false; + }, 30_000); + + assertFalse(result); + assertEquals("the condition must not be polled at all under HUMAN", 0, polls.get()); + } + + @Test + public void sleepUntilWithActionStopsRunningTheAction() + { + takeOver(); + AtomicInteger actions = new AtomicInteger(); + + boolean result = Global.sleepUntil(() -> false, actions::incrementAndGet, 30_000L, 50); + + assertFalse(result); + assertEquals(0, actions.get()); + } + + @Test + public void sleepUntilTrueOverloadsAllStop() + { + takeOver(); + + assertFalse(Global.sleepUntilTrue(() -> true)); + assertFalse(Global.sleepUntilTrue(() -> true, 50, 30_000)); + assertFalse(Global.sleepUntilTrue(() -> true, () -> false, 50, 30_000)); + } + + @Test + public void sleepUntilNotNullStops() + { + takeOver(); + + assertNull(Global.sleepUntilNotNull(() -> "value", 30_000)); + } + + @Test + public void awaitExecutionUntilStopsPollingAndSkipsTheCallback() throws Exception + { + takeOver(); + AtomicInteger callbacks = new AtomicInteger(); + + ScheduledFuture future = Global.awaitExecutionUntil(callbacks::incrementAndGet, () -> true, 10); + + Thread.sleep(200); + assertTrue("poller must cancel itself under HUMAN", future.isCancelled() || future.isDone()); + assertEquals("the callback belongs to the condition, not to the abort", 0, callbacks.get()); + } + + @Test + public void awaitExecutionUntilStillRunsTheCallbackNormally() throws Exception + { + AtomicInteger callbacks = new AtomicInteger(); + + Global.awaitExecutionUntil(callbacks::incrementAndGet, () -> true, 10); + + Thread.sleep(200); + assertEquals("exactly once: the task cancels itself after firing", 1, callbacks.get()); + } + + @Test + public void concurrentAwaitExecutionUntilCallsDoNotCancelEachOther() throws Exception + { + AtomicInteger first = new AtomicInteger(); + AtomicInteger second = new AtomicInteger(); + + // In one static field these raced, each cancelling the other's future. + Global.awaitExecutionUntil(first::incrementAndGet, () -> true, 10); + Global.awaitExecutionUntil(second::incrementAndGet, () -> true, 10); + + Thread.sleep(300); + assertEquals(1, first.get()); + assertEquals(1, second.get()); + } + + @Test + public void waitsResumeOnceTheIdleWindowElapses() + { + takeOver(); + assertFalse(Global.sleepUntil(() -> true, 200)); + + InputArbiter.onRealButtonReleased(MouseEvent.BUTTON1); + InputArbiter.setIdleResumeMs(0); + assertFalse(InputArbiter.isHuman()); + + assertTrue("after resume the waits behave normally again", Global.sleepUntil(() -> true, 200)); + } + + /** + * The gate in {@code Script.run()} is what idles a script on takeover, and every other test here + * passes without it: the waits returning early only matter if the loop then declines to run. + */ + @Test + public void theScriptLoopGateDeclinesToRunOnTakeover() throws Exception + { + // run() consults the tutorial-island varp before reaching the gate, and that walks a cache + // nothing else in these tests needs. Real instance: the class is final. + net.runelite.client.callback.ClientThread clientThread = + mock(net.runelite.client.callback.ClientThread.class); + when(clientThread.runOnClientThreadOptional(org.mockito.ArgumentMatchers.any())) + .thenReturn(java.util.Optional.empty()); + Object previousCache = swapStatic("rs2PlayerStateCache", + new net.runelite.client.plugins.microbot.api.playerstate.Rs2PlayerStateCache( + new net.runelite.client.eventbus.EventBus(), Microbot.getClient(), clientThread)); + try + { + net.runelite.client.plugins.microbot.Script script = + new net.runelite.client.plugins.microbot.Script() + { + }; + + PointerState.setFromBot(100, 100); + assertTrue("baseline, or the assertion below would hold for the wrong reason", script.run()); + + takeOver(); + + assertFalse(script.run()); + } + finally + { + swapStatic("rs2PlayerStateCache", previousCache); + } + } + + private void takeOver() + { + PointerState.setFromBot(100, 100); + InputArbiter.onRealButtonPressed(MouseEvent.BUTTON1); + assertTrue(InputArbiter.isHuman()); + } + + 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; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2KeyboardHeldKeysTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2KeyboardHeldKeysTest.java new file mode 100644 index 00000000000..37baa8e970a --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2KeyboardHeldKeysTest.java @@ -0,0 +1,255 @@ +package net.runelite.client.plugins.microbot.util.keyboard; + +import net.runelite.api.Client; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.mouse.BotEventGuard; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.awt.Canvas; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * A key the bot holds is not gesture-scoped, so InputLoop cannot unwind it. Without a release + * path, a takeover mid-routine leaves shift stuck down at the client. + */ +public class Rs2KeyboardHeldKeysTest +{ + private Canvas canvas; + private final List received = new ArrayList<>(); + private final List syntheticDuringDelivery = new ArrayList<>(); + private Object previousClient; + private boolean takeOverAfterFirstChar; + + @Before + public void before() throws Exception + { + canvas = new Canvas(); + canvas.addKeyListener(new KeyAdapter() + { + @Override + public void keyPressed(KeyEvent e) + { + record(e); + } + + @Override + public void keyReleased(KeyEvent e) + { + record(e); + } + + // typeString emits only KEY_TYPED; without this its assertion cannot fail. + @Override + public void keyTyped(KeyEvent e) + { + record(e); + // Dispatch runs the listeners on the calling thread, so taking over from in here + // lands the takeover strictly between two characters. No second thread, no race. + if (takeOverAfterFirstChar && received.size() == 1) + { + net.runelite.client.plugins.microbot.util.input.InputArbiter.onRealButtonPressed( + java.awt.event.MouseEvent.BUTTON1); + } + } + }); + + Client client = mock(Client.class); + when(client.getCanvas()).thenReturn(canvas); + when(client.isClientThread()).thenReturn(false); + previousClient = swapStatic("client", client); + + net.runelite.client.plugins.microbot.util.input.InputArbiter.resetForTest(); + net.runelite.client.plugins.microbot.util.input.PointerState.reset(); + Rs2Keyboard.releaseHeldKeys(); + received.clear(); + syntheticDuringDelivery.clear(); + takeOverAfterFirstChar = false; + } + + @After + public void after() throws Exception + { + net.runelite.client.plugins.microbot.util.input.InputArbiter.resetForTest(); + net.runelite.client.plugins.microbot.util.input.PointerState.reset(); + Rs2Keyboard.releaseHeldKeys(); + swapStatic("client", previousClient); + while (BotEventGuard.isSynthetic()) + { + BotEventGuard.end(); + } + } + + @Test + public void holdShiftIsTrackedAndReleasedOnDemand() + { + Rs2Keyboard.holdShift(); + assertTrue(Rs2Keyboard.isKeyHeld(KeyEvent.VK_SHIFT)); + + received.clear(); + Rs2Keyboard.releaseHeldKeys(); + + assertFalse(Rs2Keyboard.isKeyHeld(KeyEvent.VK_SHIFT)); + assertEquals(1, received.size()); + assertEquals(KeyEvent.KEY_RELEASED, received.get(0).getID()); + assertEquals(KeyEvent.VK_SHIFT, received.get(0).getKeyCode()); + } + + @Test + public void keyHoldIsTrackedSoATakeoverCanReleaseIt() + { + // The tested path, not holdShift: Rs2Camera holds through keyHold, and holdShift has no + // production callers at all. + Rs2Keyboard.keyHold(KeyEvent.VK_UP); + assertTrue(Rs2Keyboard.isKeyHeld(KeyEvent.VK_UP)); + + received.clear(); + Rs2Keyboard.releaseHeldKeys(); + + assertEquals("untracked, a takeover strands the camera key down", 1, received.size()); + assertEquals(KeyEvent.KEY_RELEASED, received.get(0).getID()); + assertEquals(KeyEvent.VK_UP, received.get(0).getKeyCode()); + } + + @Test + public void aNormalReleaseClearsTheHold() + { + Rs2Keyboard.holdShift(); + Rs2Keyboard.releaseShift(); + assertFalse(Rs2Keyboard.isKeyHeld(KeyEvent.VK_SHIFT)); + + received.clear(); + Rs2Keyboard.releaseHeldKeys(); + + assertTrue("nothing left to release, so no second RELEASED for the same key", received.isEmpty()); + } + + @Test + public void releaseHeldKeysIsIdempotent() + { + Rs2Keyboard.keyHold(KeyEvent.VK_W); + Rs2Keyboard.releaseHeldKeys(); + received.clear(); + + Rs2Keyboard.releaseHeldKeys(); + + assertTrue(received.isEmpty()); + } + + @Test + public void typeStringStopsAtTheCharacterWhereTheHumanTookOver() + { + net.runelite.client.plugins.microbot.util.input.PointerState.setFromBot(100, 100); + takeOverAfterFirstChar = true; + + Rs2Keyboard.typeString("myBankPin"); + + // One, not zero and not nine. Zero would mean the takeover beat the first character, and a + // check hoisted out of the loop instead of run per character would let all nine through. + assertEquals("the string must stop where the takeover landed", 1, received.size()); + assertEquals(KeyEvent.KEY_TYPED, received.get(0).getID()); + } + + @Test + public void typeStringSendsNothingWhenTheHumanAlreadyOwnsInput() + { + net.runelite.client.plugins.microbot.util.input.PointerState.setFromBot(100, 100); + net.runelite.client.plugins.microbot.util.input.InputArbiter.onRealButtonPressed( + java.awt.event.MouseEvent.BUTTON1); + + Rs2Keyboard.typeString("myBankPin"); + + // Global.sleep returns instantly under HUMAN, so without an emission-side check the whole + // string lands in microseconds, in the widget the human just took. + assertTrue("not one character may reach the canvas", received.isEmpty()); + } + + @Test + public void aReleaseStillGoesOutWhileTheHumanOwnsInput() + { + Rs2Keyboard.holdShift(); + assertTrue(Rs2Keyboard.isKeyHeld(KeyEvent.VK_SHIFT)); + received.clear(); + + net.runelite.client.plugins.microbot.util.input.PointerState.setFromBot(100, 100); + net.runelite.client.plugins.microbot.util.input.InputArbiter.onRealButtonPressed( + java.awt.event.MouseEvent.BUTTON1); + + Rs2Keyboard.releaseHeldKeys(); + + assertEquals("suppressing the release would strand shift down", 1, received.size()); + assertEquals(KeyEvent.KEY_RELEASED, received.get(0).getID()); + assertFalse(Rs2Keyboard.isKeyHeld(KeyEvent.VK_SHIFT)); + } + + @Test + public void aSuppressedHoldIsNotFollowedByARelease() + { + net.runelite.client.plugins.microbot.util.input.PointerState.setFromBot(100, 100); + net.runelite.client.plugins.microbot.util.input.InputArbiter.onRealButtonPressed( + java.awt.event.MouseEvent.BUTTON1); + + // Rs2Camera holds a key and releases it in a finally, so the release runs even when the + // press never went out. + Rs2Keyboard.keyHold(KeyEvent.VK_UP); + Rs2Keyboard.keyRelease(KeyEvent.VK_UP); + + assertTrue("a RELEASED with no PRESSED before it is not a shape a keyboard produces", + received.isEmpty()); + } + + @Test + public void aSuppressedHoldIsNotRecordedAsHeld() + { + net.runelite.client.plugins.microbot.util.input.PointerState.setFromBot(100, 100); + net.runelite.client.plugins.microbot.util.input.InputArbiter.onRealButtonPressed( + java.awt.event.MouseEvent.BUTTON1); + + Rs2Keyboard.holdShift(); + + assertFalse("releaseHeldKeys would then release a key that was never down", + Rs2Keyboard.isKeyHeld(KeyEvent.VK_SHIFT)); + } + + @Test + public void everyBotKeystrokeIsGuarded() + { + Rs2Keyboard.keyHold(KeyEvent.VK_W); + Rs2Keyboard.releaseHeldKeys(); + + assertFalse("no events were observed, so the check below would pass vacuously", + syntheticDuringDelivery.isEmpty()); + for (Boolean synthetic : syntheticDuringDelivery) + { + assertTrue("the arbiter's listener is one of canvas.getKeyListeners(), so without the " + + "guard the bot reads its own keystrokes as a takeover", synthetic); + } + assertFalse("guard must not leak past delivery", BotEventGuard.isSynthetic()); + } + + private void record(KeyEvent event) + { + received.add(event); + syntheticDuringDelivery.add(BotEventGuard.isSynthetic()); + } + + 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 c82c786d63db2bddf5955505d85afadb87b7f525 Mon Sep 17 00:00:00 2001 From: ProductOfAmerica <14712114+ProductOfAmerica@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:13:37 -0500 Subject: [PATCH 2/2] fix: repair the IntelliJ run configurations All three referenced a module that does not exist. The Gradle project is named runelite with a client subproject, so IntelliJ's module is runelite.client.main, where the files asked for client.main and, in the proxy one, plain client. With no module to resolve, the generated command line carried no classpath and every run died before reaching main(). Shorten the classpath through an args file, since the full one exceeds what Windows accepts on a command line. Drop the JDK pin from the two configs that carried one. It was an SDK name from one person's IntelliJ, and the project compiles to Java 11 bytecode and builds on Java 11 in CI, so the project SDK is already the right answer. --- .run/Microbot.run.xml | 7 +++---- .run/Runelite with proxy.run.xml | 5 +++-- .run/Runelite.run.xml | 7 +++---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.run/Microbot.run.xml b/.run/Microbot.run.xml index 34e9c1bbce5..26a71de737e 100644 --- a/.run/Microbot.run.xml +++ b/.run/Microbot.run.xml @@ -1,13 +1,12 @@ - - \ No newline at end of file + diff --git a/.run/Runelite with proxy.run.xml b/.run/Runelite with proxy.run.xml index c6694b8344a..9c6de61242a 100644 --- a/.run/Runelite with proxy.run.xml +++ b/.run/Runelite with proxy.run.xml @@ -1,7 +1,8 @@ - \ No newline at end of file + diff --git a/.run/Runelite.run.xml b/.run/Runelite.run.xml index 355455be082..ec16d709597 100644 --- a/.run/Runelite.run.xml +++ b/.run/Runelite.run.xml @@ -1,13 +1,12 @@ - - \ No newline at end of file +