Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/entity-guides/items.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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,
"interact",
Type.getMethodDescriptor(Type.BOOLEAN_TYPE,
Type.getType(InteractModel.class), Type.getType(String.class)));
}

@Test
public void tileItemUsesSyntheticTargetMenu() throws IOException
{
assertSyntheticTargetMenuDispatch(
Rs2TileItemModel.class,
"click",
Type.getMethodDescriptor(Type.BOOLEAN_TYPE, Type.getType(String.class)));
}

private static void assertSyntheticTargetMenuDispatch(Class<?> type, String methodName,
String methodDescriptor) throws IOException
{
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, String expectedName,
String expectedDescriptor) 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)
{
if (!name.equals(expectedName) || !descriptor.equals(expectedDescriptor))
{
return null;
}
calls.matchedMethods++;
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 matchedMethods;
private int doInvoke;
private int reflectionInvokeMenu;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
Loading