Skip to main content

Menu Action Dispatch

Verification boundary

Unit tests verify bytecode matching, cache behavior, and action-layer routing. They skip proving that the resolved method works in a live RuneLite client after a game update. Complete the live checks on this page for each supported client revision.

com.n3plugins.PacketUtils.reflection owns native menu dispatch. Action code calls MenuDispatcher. It avoids calling an injected Client.menuAction(...) method. The production singleton, ReflectionMenuDispatcher, invokes the vanilla client's static obfuscated menu-action method.

Current native-menu consumers include:

  • WidgetActions for listener-backed CC_OP operations.
  • BankActions.close() for the client-local WIDGET_CLOSE operation.
  • Deprecated raw N3Client.invoke(...) compatibility calls, which now reuse the same dispatcher instead of owning obfuscated class/method constants.

SyntheticMenuTarget bypasses the reflection dispatcher. Synthetic mouse arrival installs a one-shot RuneLite MenuEntry and completes with a native canvas click.


Action Dispatch & Resolution Flow


Resolution

The first dispatch for a client revision checks the disk cache. On a miss, MenuActionAsmResolver reads the runtime client class and analyzes public RuneLite wrappers in strict order: the named menuAction wrapper, another public wrapper with the same logical descriptor, and diagnostic hooks. The diagnostic hooks act as target hints only and cannot produce a dispatch plan:

  • menuAction(int, int, MenuAction, int, int, String, String) when present.
  • openWorldHopper().
  • hopToWorld(World).

The resolver uses ASM data-flow analysis and accepts an invocation only when its descriptor reads exactly:

(IIIIIILjava/lang/String;Ljava/lang/String;II[BSIJ])V

Every target operand must trace to one logical argument, a string, a supported numeric constant, MenuAction.getId(), or a modeled integer add/subtract/multiply/xor transformation. The plan records the actual argument permutation, world-view and canvas constants, and typed trailing obfuscator value. Multiple calls, incomplete or duplicate logical bindings, unsupported transformations, descriptor drift, and non-constant contextual operands fail closed. It provides no injected-API or packet fallback.

The dispatcher builds the reflection array solely by evaluating this plan. It invokes on the caller's thread. Callers remain responsible for using the client thread where their action contract requires it.

Cache

The cache path lives at:

~/.runelite/cache/menu-action-plan.json

Schema version 2 records the revision, source hook and bytecode fingerprint, target method descriptor, and all typed bindings and numeric operations. A load re-resolves the source and target methods and recomputes the fingerprint. Schema-v1/method-only data, revision or fingerprint mismatch, malformed plans, missing methods, and descriptor drift invalidate the entry and trigger ASM resolution.

Cache writes operate best-effort. A read-only home directory or failed write leaves a method that ASM already resolved intact; the next process start scans again. Delete the file to force resolution on the next native menu dispatch. The cache omits account or session credentials.

ASM 9.6, ASM Tree 9.6, and ASM Analysis 9.6 operate as runtime dependencies included in the n3 fat jars. The dispatcher runs bytecode scanning only after a cache miss.

Failure semantics

ReflectionMenuDispatcher throws IllegalStateException when the client drops offline, resolution fails, or invocation fails. WidgetActions and BankActions convert that exception to PACKET_NOT_QUEUED and skip recording the action in ActionPacer. A successful reflective invocation reports DISPATCHED. Callers must still observe the expected widget or interface state before advancing a workflow.

Validation

Run the focused source checks:

./gradlew test --tests 'com.n3plugins.PacketUtils.reflection.*' \
--tests 'com.n3plugins.Api.actions.BankActionsTest' \
--tests 'com.n3plugins.Api.actions.WidgetActionsTest' \
--console plain
./gradlew FatJar FatJarWithHidden --console plain

For a live revision check:

  1. Start the supported RuneLite client without a custom Client.menuAction mixin.
  2. Delete menu-action-plan.json, then run one listener-backed widget action.
  3. Confirm the interface transition succeeds and the cache records schema 2 and the live revision.
  4. Restart RuneLite and repeat the action. Confirm the cache loads without an ASM scan.
  5. Change the cached revision, restart, and repeat. Confirm the stale record replaces safely.
  6. Verify two tab transitions, a prayer or other listener-owned control, and BankActions.close() by observing every resulting interface state.
  7. Corrupt the hook fingerprint and confirm safe re-resolution.

Record the RuneLite version, client revision, selected action tuple, cache contents with obfuscated names redacted if necessary, and observed postcondition. Live verification for this implementation remains pending.