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".
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 visibilityIt 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
IsWindowVisiblefirst for anything that is not permanently on screen. okmeans 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:
| Group | Names |
|---|---|
| Letters | a–z |
| Digits | 0–9 |
| Editing | enter / return, escape / esc, space, tab |
| Arrows | up, down, left, right |
| Navigation | pageup / page_up / prior, pagedown / page_down / next, home, end |
| Modifiers | shift, ctrl / control, alt |
There is no function-key support — c: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.
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.
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.