Skip to main content

Windows and UI

Clicking the game's own interface is the safest way to make it do things. The alternative — making the client execute its own functions on your behalf — is a confirmed account-ban vector, and the binding that used to do it (Client:ExecuteCommand) is hard-disabled: it always fails with remote code execution is disabled. Everything on this page goes through the same input path a player uses.

Window paths

A window path is an array of names from the UI root down to the widget:

local P_GARDEN_SUB = { 'WorldView', 'windowHUD', 'GardeningWindow', 'GardeningSubWindow' }

Every segment is matched by name against the children of the previous one. Names are the game's own internal widget names, not anything the player sees, and they are case-sensitive.

Rather than retyping those, paths mirrors libwiz's registry of known windows:

paths.CombatPass -- indexed directly, a WindowPath
paths.Get('CombatPass') -- looked up, returns (path, err)
paths.Names() -- every registered name, sorted

The constants are stamped onto the global at VM setup, so paths.CombatPass is a plain table lookup with no function call. Reading an unregistered name that way gives you nil, which then fails inside ClickWindow with window path is empty — so use paths.Get when the name is computed at runtime and you want a real error:

local path, err = paths.Get(name)
if not path then
log('unknown window: ' .. tostring(err)) -- "unknown window path: X"
return
end

There are about 176 registered names, covering combat, the backpack, the deck editor, the spiral door, friends, sigils, potions, shops, gardening, pet games, quests, and the login screen. A one-off utils.Log(table.concat(paths.Names(), ', ')) in on_load prints them all. From the console, scripts eval gets you there without even writing a file:

scripts eval utils.Log(table.concat(paths.Names(), ', '))

Reading the UI

c:IsWindowVisible(path) -- (visible, err)
c:IsControlGrayed(path) -- (grayed, err)
c:GetWindowText(path) -- (text, err)
c:WaitForWindow(path, ms) -- (ok, err), blocks; ms defaults to 10000

IsControlGrayed deserves attention. It reads the ControlGrayed field, which is what the game uses to mark a control busy — not the generic window-disabled flag. It is how you tell "this button exists but is not ready yet" from "this button is clickable".

It returns true when the window is missing

That is deliberate. A readiness poll should treat an absent window as not-ready rather than as ready, so IsControlGrayed fails closed. You can loop on not grayed without separately checking existence.

GetWindowText ignores visibility

It resolves the path and reads the text, whether or not the window is currently shown. A popup you closed three zones ago still has its old text sitting in memory, so a plugin that polls GetWindowText(paths.PopupMessage) will happily act on a message nobody is looking at.

Always gate the read:

local visible = c:IsWindowVisible(paths.PopupMessage)
if not visible then return end
local text = c:GetWindowText(paths.PopupMessage)

GetWindowText only works on ControlText-derived widgets. Other window types return whatever bytes live at that offset, which will look like garbage or a plausible-but-wrong string. If you get nonsense, the widget is the wrong type, not the offset.

WaitForWindow blocks until the window is visible or the timeout elapses, defaulting to ten seconds. That default is exactly the per-callback budget, so an unqualified WaitForWindow that times out takes the whole plugin down with context deadline exceeded instead of returning false. Pass an explicit timeout well under 10000, or poll across ticks instead — which is what the worked example below does.

Clicking

local ok, err = c:ClickWindow(paths.CombatPass)

The path is resolved, the widget's rectangle is scaled to client coordinates, and a click is delivered at its centre. That means:

  • A window that is not visible can still be clicked. The click lands at wherever the hidden widget's rectangle happens to be, which is somewhere unhelpful. Check IsWindowVisible first for anything that is not permanently on screen.
  • ok means the click was delivered, not that it worked. Verify the effect separately:
c:ClickWindow(P_PLANT_BUTTON)
state.wait = utils.Ticks(1)
state.step = 'verify'
-- next tick, check the plot is actually planted

The cursor can also be positioned without clicking:

c:SetCursor(x, y) -- client-relative pixels

Keys

c:SendKey('w', 300) -- hold 'w' for 300ms
c:SendKey('x') -- hold for the default 100ms
c:SendKey('space')
c:SendKey('pagedown')

The second argument is a hold duration in milliseconds and defaults to 100, not to an instantaneous tap. Some in-game actions need a longer hold to register; movement in particular does nothing useful below about 200ms.

Accepted names:

GroupNames
Lettersaz
Digits09
Editingenter / return, escape / esc, space, tab
Arrowsup, down, left, right
Navigationpageup / page_up / prior, pagedown / page_down / next, home, end
Modifiersshift, ctrl / control, alt
An unrecognised key name fails silently

There is no function-key supportc:SendKey('f1') is not a mistake the API catches. An unknown name is converted to key code 0, sent anyway, and SendKey returns true with no error. The only symptom is that nothing happens.

If a key press appears to do nothing, check it against the table above before looking anywhere else. You can also pass a raw virtual key code as a number if you need one that is not listed.

pagedown marks your current location and pageup recalls to it — the round trip a farm bot uses to leave a spot and come back without knowing any coordinates. Getting them the wrong way round makes a plugin re-mark wherever it happens to be standing instead of returning.

A short forward/back tap is the standard trick for unsticking a character that the game thinks is mid-animation or wedged on geometry:

local NUDGE_MS = 300

local function nudge(c)
local ok, err = c:SendKey('w', NUDGE_MS)
if not ok then
log_throttled('nudge', 'forward key failed: ' .. tostring(err))
return false
end
ok, err = c:SendKey('s', NUDGE_MS)
if not ok then
log_throttled('nudge', 'backward key failed: ' .. tostring(err))
return false
end
return true
end

farm_tw fires this every third pull, on the theory that a character that has teleported repeatedly onto the same spot eventually needs a physical step to reset its collision state.

The first key after a cold start can fail

On Linux under Wine, the first SendKey after hooking sometimes fails because the window handle lookup takes a slow path. Do not treat a single failure as fatal; log it throttled and try again next tick.

Both keys and clicks go to a specific game window, so they work while that window is in the background — which is the point of driving several clients at once. What they cannot do is work while the window does not exist: a client mid-zone-load or mid-crash will fail these with a window-handle error until it comes back.

A worked example: driving a modal

The shipped garden69.lua plants a seed by walking a sequence of UI states. Reduced to its shape:

local WINDOW_TIMEOUT_TICKS = utils.Ticks(10)
local GRAY_TIMEOUT_TICKS = utils.Ticks(15)

local P_GARDEN_SUB = { 'WorldView', 'windowHUD', 'GardeningWindow', 'GardeningSubWindow' }
local P_FAVORITES = { 'WorldView', 'windowHUD', 'GardeningWindow', 'GardeningSubWindow', 'Tab_Favorites' }
local P_PLACEMENT = { 'WorldView', 'windowHUD', 'OpenObjectPlacement' }

local function seed_icon_path(slot)
return { 'WorldView', 'windowHUD', 'GardeningWindow', 'GardeningSubWindow',
'BottomFrame', 'Icon' .. slot }
end

local function open_window(c)
local ok, err = c:SendKey('g', 100)
if not ok then
fail_plot('open gardening window: ' .. tostring(err))
return
end
state.waited = 0
state.step = 'await_window'
end

local function await_window(c)
if c:IsWindowVisible(P_GARDEN_SUB) then
state.waited = 0
state.step = 'pick_seed'
return
end

state.waited = state.waited + 1
if state.waited > WINDOW_TIMEOUT_TICKS then
fail_plot('gardening window never opened')
end
end

local function pick_seed(c)
c:ClickWindow(P_FAVORITES)
c:ClickWindow(seed_icon_path(SEED_SLOT))
state.waited = 0
state.step = 'await_placement'
end

local function await_placement(c)
local grayed, err = c:IsControlGrayed(P_PLACEMENT)
if err == nil and grayed then
state.waited = 0
state.step = 'await_planted'
return
end

state.waited = state.waited + 1
if state.waited > GRAY_TIMEOUT_TICKS then
fail_plot('placement never grayed, the seed click did not register')
end
end

Four habits worth copying:

Poll across ticks, do not block. Every await_* step returns immediately and is re-entered on the next tick. Nothing here calls WaitForWindow, which is what lets a step wait fifteen seconds without ever exceeding the ten-second call budget.

Send the action once, then only poll for its effect. open_window sends g a single time and moves straight to a dedicated waiting state. Resending the key every tick while await_window is waiting could just as easily toggle the window shut again before the visibility check catches up.

Every waiting step has a timeout. A UI step that can hang forever will hang forever. Each one counts ticks and fails the current unit of work rather than the whole run.

Failure advances the machine. fail_plot increments a counter, logs, and moves to the next plot. A bot that stops on the first failure is a bot that stops.

Path discovery

When you need a path that is not in the registry, the console has a debug command for it:

dump windows

This prints the live window tree from the client — there is no desktop-app equivalent verified for this page. Find your widget, read the path off the indentation, and hardcode it in your plugin as a local; the path itself works the same regardless of which host later runs the plugin.

dump probe <window-name> prints the raw field values of one window, which is how you find out whether a widget is a ControlText before writing a GetWindowText against it, and dump click <window-name> prints the rectangle and the client coordinates a click would land on. Both take a bare widget name rather than a path.

If a path looks generally useful, it is worth reporting — a path that earns a spot in the built-in registry becomes a paths.* constant for everyone in a future update.

Do not walk the tree from Lua in a poll loop

Recursively enumerating windows every tick will freeze the game client. That is not a figure of speech; it is a known hang.

Use a known path with ClickWindow / IsWindowVisible, which resolves segment by segment from the root, rather than searching the whole tree for a window by name each time. There is deliberately no Lua binding that enumerates children, and dump windows is a one-shot diagnostic, not something to build a loop around.