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