Skip to main content

Getting started

Lua plugins run under two different hosts. The desktop app's trainer is the default way to run one — no flags, no terminal. The console is a terminal tool for more advanced or scriptable use, and is what the rest of this wiki's command-line examples use. A plugin file does not care which one loaded it; both hosts run the exact same Lua API.

This page writes one plugin and runs it under both. Budget about ten minutes.

Before you start

Neither host can do anything until it is attached to a running game client:

  • One or more Wizard101 clients running and logged in to a character. A client sitting on the login or character-select screen hooks fine but reports nonsense for zone, health, and entities.
  • The memory backend installed. The desktop app installs it for you the first time you start the memory inspector; the console needs it present already, and its doctor command reports whether it found one.
  • A licensed install. Every trainer binding in the desktop app returns empty for an unlicensed copy, so an empty Plugins list with no error is worth checking against your license status before you debug the plugin.

Where plugins and strategies live

Both hosts read from the same two folders:

  • ~/.kebab/plugins — Lua plugin files (*.lua)
  • ~/.kebab/strategies — combat strategy files (*.yml / *.yaml)

Neither folder is created for you on a fresh install. Create them before dropping a file in:

mkdir -p ~/.kebab/plugins ~/.kebab/strategies

The loader walks ~/.kebab/plugins recursively and loads every .lua file it finds, in sorted path order. Subfolders are fine. A file that fails to load does not stop the others; it is reported and skipped.

Two files may not declare the same plugin.name. The second one is rejected with duplicate plugin name, and since the name is what you type to run it, copying a plugin to farm_tw_copy.lua without editing the name field just loses one of them.

Write the plugin

Create ~/.kebab/plugins/hello.lua:

---@type KebabPlugin
plugin = {
name = 'hello',
version = '1.0.0',
description = 'Reports what the wizard is doing',
}

local ticks = utils.Ticks
local REPORT_EVERY = ticks(5)

local state = {}

plugin.on_load = function()
state = { waited = 0 }
utils.Log('[hello] loaded')
end

plugin.on_tick = function()
state.waited = state.waited + 1
if state.waited < REPORT_EVERY then return end
state.waited = 0

local c = clients:First()
if not c then
utils.Log('[hello] no clients hooked')
return
end

local hp, max_hp = c:GetHealth(), c:GetMaxHealth()
local pct = max_hp > 0 and math.floor((hp / max_hp) * 100) or 0

utils.Log(string.format('[hello] %s is in %s at %d%% health',
tostring(c:GetID()), tostring(c:GetZone()), pct))
end

plugin.on_zone_change = function(data)
utils.Log('[hello] zone is now ' .. tostring(data.new_zone))
end

plugin.on_stop = function()
utils.Log('[hello] stopped')
end

Four things in that file are worth calling out.

utils.Ticks(5) converts five seconds into a number of ticks. Both hosts tick at 250ms today, so this evaluates to 20 — but writing 20 directly would silently break if the host changed its interval. Always derive.

max_hp > 0 and ... or 0 is not paranoia. Every stat read can fail, and a failed read comes back as 0, not as an error. Dividing by it gives you inf, and string.format('%d', inf) is a runtime error that takes the whole tick down.

state is reset in on_load rather than initialised at file scope. Plugins get reloaded — from the console with scripts reload, from the desktop app with its Reload control — and file-scope state survives a reload in ways that are hard to reason about. Put mutable state in a table and rebuild it in the lifecycle hook.

plugin.on_zone_change is never registered anywhere. The loader inspects the plugin table for known callback names and wires up whatever it finds.

GetName() is the window title, not the character name

Client:GetName() returns the OS window title of the game client, which is the same string for every window unless you have renamed them. For something you can match against event payloads, use c:GetID(). There is no binding that returns the wizard's in-game name.

Running it: the desktop app

Make sure one or more Wizard101 clients are running and logged in.

Open Forge → Trainer → Plugins. This is where the plugins found in ~/.kebab/plugins are listed, each row showing name, description, version, and status (Running / Stopped / Error), with a Run/Stop control and a Reload control.

An empty Plugins list usually means the trainer has not started

The directory is scanned exactly once, when the trainer starts. Refresh re-reads the list of already-loaded plugins from the backend; it does not rescan the folder, so it will never find a file that was not there at startup.

The trainer is started by the Start control on Forge → Combat's auto-combat panel. If your plugin does not appear, start that, then come back to the Plugins tab.

Adding a brand-new .lua file to the folder likewise needs another scan before it shows up — Reload only works on a plugin that is already loaded. Restarting the app is the clean way to get one. Stopping and starting the trainer also works, but it rescans on top of what is already loaded, so the log fills with duplicate plugin name for every file that was already there; the new one still loads.

Click Run to start hello — that calls on_load and starts delivering ticks — and Stop to end it. After editing a file that is already loaded, Reload picks up the change.

Strategies in ~/.kebab/strategies do not get their own list. Instead, any automation step that takes a strategy — for example a combat action, built under the Trainer's Build tab — offers every strategy found there as an option, by name.

Running it: the console

The console does the same job from a terminal, with everything spelled out on the command line instead of clicked. It reads the same two folders the desktop app does, so if ~/.kebab/plugins and ~/.kebab/strategies already exist, plain console finds them:

console --sudo

To use folders somewhere else, name them explicitly. A flag, a config file entry, or a profile always wins over the default:

console --sudo --plugins-dir ~/work/plugins --strategy ~/work/strategies

The default only applies to a folder that actually exists. If you have no ~/.kebab/plugins and pass no flag, scripting stays switched off and every scripting command fails with lua plugins are disabled; configure --plugins-dir.

Prerequisites specific to the console:

  • An interactive terminal. The console refuses to start on a non-TTY.
  • On Linux, a working X11 or XWayland display. Pure Wayland without XWayland is not supported.
  • On Linux, global hotkeys read /dev/input/event*, which usually means running under sudo.

--sudo is a Linux thing — Linux needs it for /dev/input hotkey access and for ptrace against a Wine-hosted client. Windows and macOS installs generally do not need it; drop the flag there unless a specific feature tells you otherwise.

Per platform:

  • Windows runs on the Win32 window/input/process APIs and needs nothing extra.
  • Linux needs a working X11 session, or a Wayland session with DISPLAY pointed at a working XWayland server — WAYLAND_DISPLAY alone is not enough. Pure Wayland without XWayland is not supported. Global hotkeys read /dev/input/event*, which is what --sudo is for.
  • macOS may prompt you to grant the console Accessibility and Screen Recording permissions the first time a feature needs them.

Inside the console:

hook
scripts
scripts run hello

hook attaches to the running clients and assigns them aliases (p1, p2, …). scripts lists what the loader found. scripts run hello calls on_load and starts delivering ticks.

hook is the only thing that populates clients

The console has no background scan for new game windows. Until you run hook, every plugin sees an empty clients list. Launch another game client later and you must hook again — which detaches every client first (firing client_disconnected for each) and then re-attaches them (firing client_connected), so a plugin that caches client handles across ticks will be holding stale ones.

The desktop app is the opposite: its memory inspector rescans for clients every two seconds once started, so clients appear and disappear on their own.

To stop it:

scripts stop hello

To pick up file edits without restarting the console:

scripts reload hello -- reloads one already-loaded plugin
scripts reload -- tears down the runtime and rescans the whole folder

The bare scripts reload is the one that finds files you have only just created. Note that it also stops every running plugin.

Console commands you will actually use

These commands only exist in the console — the desktop app's trainer does the same jobs through its own controls, not a command line.

CommandWhat it does
hookAttach to running clients, assign aliases
statusShow hooked clients and their state
doctorDiagnose the console, libwiz and memory-backend setup
scriptsList loaded Lua plugins
scripts run <name> [args]Start a plugin
scripts stop <name>Stop a plugin
scripts reload [name]Reload one plugin, or rescan the whole folder
scripts eval <lua>Run a one-off snippet against a fresh VM
scripts hotkey <key>Fire a plugin hotkey by hand
enable combat [name]Start the libstrat runner with a named strategy
disable combatStop it
strategiesList loaded strategies
strategies validateCheck every strategy file for errors
dump entitiesPrint what the entity finder can see
dump windowsPrint the live UI window tree

scripts eval is the fastest way to answer "what does this actually return":

scripts eval local c = clients:First(); utils.Log(tostring(c:GetZone()))

eval runs in its own throwaway VM against the currently selected client, so it cannot see your plugin's locals and cannot change its state. Use it to inspect the game, not to poke at a running plugin.

When it does not work

SymptomUsual cause
lua plugins are disabled; configure --plugins-dirThe console found no plugins folder. Create ~/.kebab/plugins or pass --plugins-dir.
plugin metadata.name is requiredThe file did not assign a global plugin table with a name, or assigned it to a local.
duplicate plugin name: XTwo files declare the same plugin.name.
Plugin loads but on_tick never runsIt is loaded, not running. scripts run <name>, or Run in the desktop app.
Status flips to errorAn uncaught Lua error in a callback. scripts prints the message next to the status.
Everything returns 0 or emptyNothing is hooked (clients:First() is nil), or the character is not fully loaded into a zone.

Next