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 + 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; + } +}