From 15df4ead037ffd5645ed0be5022f3729bd684062 Mon Sep 17 00:00:00 2001 From: JogOnJohn <223066319+JogOnJohn@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:14:05 +1000 Subject: [PATCH 1/3] fix: restore targeted ground item interactions --- docs/entity-guides/items.md | 28 ++++++ .../api/tileitem/models/Rs2TileItemModel.java | 37 +++----- .../util/grounditem/Rs2GroundItem.java | 37 +++----- .../GroundItemInteractionDispatchTest.java | 85 +++++++++++++++++++ 4 files changed, 135 insertions(+), 52 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grounditem/GroundItemInteractionDispatchTest.java diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index b63152a902..f85ded75f1 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -152,3 +152,31 @@ Do not make call sites compensate by assuming `index == 0` when the action array **Where this applies:** `Rs2Reflection.getGroundItemActions`, `Rs2GroundItem.interact`, `Rs2TileItemModel.click`, and any future ground-item interaction helper that derives a `MenuAction` from item actions. **Defensive check:** Live smoke with ExampleScript's "Drop and loot item" check after any RuneLite or injected-client version bump. + +--- + +## 9. Dispatch ground-item actions through the synthetic target menu + +Resolve the ground item's action and `MenuAction` first, then dispatch it with `Microbot.doInvoke(NewMenuEntry, bounds)`. Do not call `Rs2Reflection.invokeMenu` directly for ground-item interactions. + +**Why this matters:** A raw canvas click can select a door or NPC that visually overlaps the ground item's tile. The reflected client-menu call can loot successfully but still emit `Unable to find clicked menu op` engine messages because it bypasses the normal clicked-menu correlation. `Microbot.doInvoke` sets `Microbot.targetMenu` before clicking, allowing `MicrobotPlugin` to replace the generated scene menu with the intended ground-item entry regardless of what is under the cursor. + +**Pattern to follow:** + +```java +Microbot.doInvoke(new NewMenuEntry() + .option(action) + .target(target) + .identifier(itemId) + .opcode(menuAction.getId()) + .param0(sceneX) + .param1(sceneY) + .itemId(-1) + .worldViewId(worldViewId), bounds); +``` + +Keep action discovery and dispatch separate: `Rs2Reflection.getGroundItemActions` retains the third-slot `Take` fallback described above, while `Microbot.doInvoke` owns the interaction. + +**Where this applies:** `Rs2GroundItem.interact`, `Rs2TileItemModel.click`, and future ground-item interaction helpers. + +**Defensive check:** Drop loot on a tile visually overlapped by an NPC and beside an openable door. Verify the intended item is taken from multiple camera angles and no `Unable to find clicked menu op` engine message appears. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java index 6140f4a52a..7e05649e9c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java @@ -8,6 +8,7 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.api.IEntity; import net.runelite.client.plugins.microbot.util.camera.Rs2Camera; +import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.reflection.Rs2Reflection; @@ -264,33 +265,17 @@ public boolean click(String action) { Rectangle bounds = canvas == null ? new Rectangle(1, 1, Microbot.getClient().getCanvasWidth(), Microbot.getClient().getCanvasHeight()) : canvas.getBounds(); - MenuAction selectedMenuAction = menuAction; - String selectedAction = action; int worldViewId = localPoint1.getWorldView(); - Microbot.getClientThread().runOnClientThreadOptional(() -> { - MenuEntry entry = Microbot.getClient().getMenu().createMenuEntry(-1) - .setOption(selectedAction) - .setTarget(target) - .setIdentifier(identifier) - .setType(selectedMenuAction) - .setParam0(param0) - .setParam1(param1) - .setItemId(-1) - .setWorldViewId(worldViewId); - Microbot.getClient().setMenuEntries(new MenuEntry[]{entry}); - return true; - }); - Rs2Reflection.invokeMenu( - param0, - param1, - menuAction.getId(), - identifier, - -1, - worldViewId, - action, - target, - (int) bounds.getCenterX(), - (int) bounds.getCenterY()); + Microbot.doInvoke(new NewMenuEntry() + .option(action) + .target(target) + .identifier(identifier) + .opcode(menuAction.getId()) + .param0(param0) + .param1(param1) + .itemId(-1) + .worldViewId(worldViewId), + bounds); return true; } catch (Exception ex) { Microbot.logStackTrace("Rs2TileItemModel", ex); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grounditem/Rs2GroundItem.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grounditem/Rs2GroundItem.java index 111364126a..3b02285769 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grounditem/Rs2GroundItem.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grounditem/Rs2GroundItem.java @@ -11,6 +11,7 @@ import net.runelite.client.plugins.microbot.util.camera.Rs2Camera; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; 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.models.RS2Item; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.reflection.Rs2Reflection; @@ -110,33 +111,17 @@ private static boolean interact(InteractModel groundItem, String action) { Rectangle bounds = canvas == null ? new Rectangle(1, 1, Microbot.getClient().getCanvasWidth(), Microbot.getClient().getCanvasHeight()) : canvas.getBounds(); - MenuAction selectedMenuAction = menuAction; - String selectedAction = action; int worldViewId = localPoint1.getWorldView(); - Microbot.getClientThread().runOnClientThreadOptional(() -> { - MenuEntry entry = Microbot.getClient().getMenu().createMenuEntry(-1) - .setOption(selectedAction) - .setTarget(target) - .setIdentifier(identifier) - .setType(selectedMenuAction) - .setParam0(param0) - .setParam1(param1) - .setItemId(-1) - .setWorldViewId(worldViewId); - Microbot.getClient().setMenuEntries(new MenuEntry[]{entry}); - return true; - }); - Rs2Reflection.invokeMenu( - param0, - param1, - menuAction.getId(), - identifier, - -1, - worldViewId, - action, - target, - (int) bounds.getCenterX(), - (int) bounds.getCenterY()); + Microbot.doInvoke(new NewMenuEntry() + .option(action) + .target(target) + .identifier(identifier) + .opcode(menuAction.getId()) + .param0(param0) + .param1(param1) + .itemId(-1) + .worldViewId(worldViewId), + bounds); return true; } catch (Exception ex) { Microbot.logStackTrace("Rs2GroundItem", ex); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grounditem/GroundItemInteractionDispatchTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grounditem/GroundItemInteractionDispatchTest.java new file mode 100644 index 0000000000..42f40bb027 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grounditem/GroundItemInteractionDispatchTest.java @@ -0,0 +1,85 @@ +package net.runelite.client.plugins.microbot.util.grounditem; + +import java.io.IOException; +import java.io.InputStream; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.api.tileitem.models.Rs2TileItemModel; +import net.runelite.client.plugins.microbot.util.reflection.Rs2Reflection; +import org.junit.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class GroundItemInteractionDispatchTest +{ + @Test + public void legacyGroundItemUsesSyntheticTargetMenu() throws IOException + { + assertSyntheticTargetMenuDispatch(Rs2GroundItem.class); + } + + @Test + public void tileItemUsesSyntheticTargetMenu() throws IOException + { + assertSyntheticTargetMenuDispatch(Rs2TileItemModel.class); + } + + private static void assertSyntheticTargetMenuDispatch(Class type) throws IOException + { + DispatchCalls calls = readDispatchCalls(type); + + assertTrue(type.getSimpleName() + " must dispatch through Microbot.doInvoke", calls.doInvoke > 0); + assertEquals(type.getSimpleName() + " must not dispatch through Rs2Reflection.invokeMenu", + 0, calls.reflectionInvokeMenu); + } + + private static DispatchCalls readDispatchCalls(Class type) throws IOException + { + String resource = "/" + Type.getInternalName(type) + ".class"; + DispatchCalls calls = new DispatchCalls(); + try (InputStream input = type.getResourceAsStream(resource)) + { + if (input == null) + { + throw new IOException("Unable to load " + resource); + } + + new ClassReader(input).accept(new ClassVisitor(Opcodes.ASM9) + { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) + { + return new MethodVisitor(Opcodes.ASM9) + { + @Override + public void visitMethodInsn(int opcode, String owner, String methodName, + String methodDescriptor, boolean isInterface) + { + if (owner.equals(Type.getInternalName(Microbot.class)) && methodName.equals("doInvoke")) + { + calls.doInvoke++; + } + if (owner.equals(Type.getInternalName(Rs2Reflection.class)) && methodName.equals("invokeMenu")) + { + calls.reflectionInvokeMenu++; + } + } + }; + } + }, ClassReader.SKIP_FRAMES); + } + return calls; + } + + private static final class DispatchCalls + { + private int doInvoke; + private int reflectionInvokeMenu; + } +} From a77cf7e37b6eaa98b9d0983ba92388f322723574 Mon Sep 17 00:00:00 2001 From: JogOnJohn <223066319+JogOnJohn@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:20:15 +1000 Subject: [PATCH 2/3] test: refresh client-thread guardrail baseline --- .../client-thread-guardrail-baseline.txt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index 3395d9f6e9..a99fa58ac2 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -394,18 +394,18 @@ net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#interact(Inte net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#interact(RS2Item, String): boolean -> net.runelite.api.ItemComposition#getName(): String net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#interact(RS2Item, String): boolean -> net.runelite.api.Tile#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#interact(RS2Item, String): boolean -> net.runelite.api.TileItem#getId(): int -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$exists$29(int, RS2Item): boolean -> net.runelite.api.ItemComposition#getId(): int -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$exists$30(String, RS2Item): boolean -> net.runelite.api.ItemComposition#getName(): String -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$getAllAt$2(Tile, TileItem): RS2Item -> net.runelite.api.TileItem#getId(): int -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$getAllFromWorldPoint$5(RS2Item): int -> net.runelite.api.Client#getLocalPlayer(): Player -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$getAllFromWorldPoint$5(RS2Item): int -> net.runelite.api.Player#getLocalLocation(): LocalPoint -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$getAllFromWorldPoint$5(RS2Item): int -> net.runelite.api.Tile#getLocalLocation(): LocalPoint -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$interact$27(String, RS2Item): boolean -> net.runelite.api.ItemComposition#getName(): String -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$interact$28(int, RS2Item): boolean -> net.runelite.api.ItemComposition#getId(): int -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$loot$26(int, RS2Item): boolean -> net.runelite.api.ItemComposition#getId(): int -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$loot$31(int, RS2Item): boolean -> net.runelite.api.ItemComposition#getId(): int -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$loot$8(String, int, RS2Item): boolean -> net.runelite.api.ItemComposition#getName(): String -net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$loot$8(String, int, RS2Item): boolean -> net.runelite.api.TileItem#getQuantity(): int +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$exists$28(int, RS2Item): boolean -> net.runelite.api.ItemComposition#getId(): int +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$exists$29(String, RS2Item): boolean -> net.runelite.api.ItemComposition#getName(): String +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$getAllAt$1(Tile, TileItem): RS2Item -> net.runelite.api.TileItem#getId(): int +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$getAllFromWorldPoint$4(RS2Item): int -> net.runelite.api.Client#getLocalPlayer(): Player +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$getAllFromWorldPoint$4(RS2Item): int -> net.runelite.api.Player#getLocalLocation(): LocalPoint +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$getAllFromWorldPoint$4(RS2Item): int -> net.runelite.api.Tile#getLocalLocation(): LocalPoint +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$interact$26(String, RS2Item): boolean -> net.runelite.api.ItemComposition#getName(): String +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$interact$27(int, RS2Item): boolean -> net.runelite.api.ItemComposition#getId(): int +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$loot$25(int, RS2Item): boolean -> net.runelite.api.ItemComposition#getId(): int +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$loot$30(int, RS2Item): boolean -> net.runelite.api.ItemComposition#getId(): int +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$loot$7(String, int, RS2Item): boolean -> net.runelite.api.ItemComposition#getName(): String +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lambda$loot$7(String, int, RS2Item): boolean -> net.runelite.api.TileItem#getQuantity(): int net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#lootAllItemBasedOnValue(int, int): boolean -> net.runelite.api.ItemComposition#getName(): String net.runelite.client.plugins.microbot.util.grounditem.models.Rs2SpawnLocation#getItemName(): String -> net.runelite.api.ItemComposition#getName(): String net.runelite.client.plugins.microbot.util.huntkit.Rs2HuntKit#depositActionIndex(Rs2ItemModel, String): int -> net.runelite.api.widgets.Widget#getActions(): String[] From c766f3509f536928d7853f0d72930cd7821ee489 Mon Sep 17 00:00:00 2001 From: JogOnJohn <223066319+JogOnJohn@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:50:29 +1000 Subject: [PATCH 3/3] test: scope ground item dispatch assertions --- .../GroundItemInteractionDispatchTest.java | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grounditem/GroundItemInteractionDispatchTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grounditem/GroundItemInteractionDispatchTest.java index 42f40bb027..5f1ccb31e8 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grounditem/GroundItemInteractionDispatchTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grounditem/GroundItemInteractionDispatchTest.java @@ -20,25 +20,36 @@ public class GroundItemInteractionDispatchTest @Test public void legacyGroundItemUsesSyntheticTargetMenu() throws IOException { - assertSyntheticTargetMenuDispatch(Rs2GroundItem.class); + assertSyntheticTargetMenuDispatch( + Rs2GroundItem.class, + "interact", + Type.getMethodDescriptor(Type.BOOLEAN_TYPE, + Type.getType(InteractModel.class), Type.getType(String.class))); } @Test public void tileItemUsesSyntheticTargetMenu() throws IOException { - assertSyntheticTargetMenuDispatch(Rs2TileItemModel.class); + assertSyntheticTargetMenuDispatch( + Rs2TileItemModel.class, + "click", + Type.getMethodDescriptor(Type.BOOLEAN_TYPE, Type.getType(String.class))); } - private static void assertSyntheticTargetMenuDispatch(Class type) throws IOException + private static void assertSyntheticTargetMenuDispatch(Class type, String methodName, + String methodDescriptor) throws IOException { - DispatchCalls calls = readDispatchCalls(type); + DispatchCalls calls = readDispatchCalls(type, methodName, methodDescriptor); + assertEquals(type.getSimpleName() + " must contain the expected interaction method", + 1, calls.matchedMethods); assertTrue(type.getSimpleName() + " must dispatch through Microbot.doInvoke", calls.doInvoke > 0); assertEquals(type.getSimpleName() + " must not dispatch through Rs2Reflection.invokeMenu", 0, calls.reflectionInvokeMenu); } - private static DispatchCalls readDispatchCalls(Class type) throws IOException + private static DispatchCalls readDispatchCalls(Class type, String expectedName, + String expectedDescriptor) throws IOException { String resource = "/" + Type.getInternalName(type) + ".class"; DispatchCalls calls = new DispatchCalls(); @@ -55,6 +66,11 @@ private static DispatchCalls readDispatchCalls(Class type) throws IOException public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { + if (!name.equals(expectedName) || !descriptor.equals(expectedDescriptor)) + { + return null; + } + calls.matchedMethods++; return new MethodVisitor(Opcodes.ASM9) { @Override @@ -79,6 +95,7 @@ public void visitMethodInsn(int opcode, String owner, String methodName, private static final class DispatchCalls { + private int matchedMethods; private int doInvoke; private int reflectionInvokeMenu; }