Skip to main content

Gardening

Plant a seed in every empty plot in a garden. This is garden69.lua, one of the example plugins included with your installation, and unlike the farm loop it involves no combat at all — it is a pure UI automation, which makes it the best worked example of driving the game's interface.

It plants. It does not water, harvest, or replant — this is the "fill an empty garden in one pass" job, and it stops when it runs out of empty plots.

What it needs

  • Standing in the house, with the garden loaded. The plugin reads plots out of the loaded garden; it will not walk you to one.
  • Seeds actually in your backpack. The plugin clicks a favourites slot; it cannot tell an empty slot from a stocked one before it tries.
  • The seed you want in Favorites slot 1 of the gardening window, or SEED_SLOT changed to whichever slot it is in.
  • require('garden') — the plugin does this for you, and it is what makes c.garden available.
  • Nothing else claiming the mouse. Every step here is a real UI click.

Setup

---@type KebabPlugin
plugin = { name = 'garden69', version = '1.0.0' }

require('garden')

local SEED_SLOT = 1
local Z_OFFSET = 125

local WINDOW_TIMEOUT_TICKS = 40
local GRAY_TIMEOUT_TICKS = 60
local PLANT_TIMEOUT_TICKS = 240
local SETTLE_TICKS = 2

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

Z_OFFSET = 125 puts the character above the plot rather than inside it — plot coordinates are ground level and the placement UI needs you looking down at the spot.

These constants predate utils.Ticks

WINDOW_TIMEOUT_TICKS = 40 is a raw tick count, which assumes a 250ms tick. Written today it would be utils.Ticks(10). If you copy this plugin, convert them — see Ticks and timing.

Reading the garden

local function snapshot(c)
local plots, err = c.garden:Plots()
if err then
log('cannot read garden: ' .. tostring(err))
return false
end

state.total = #plots
if state.total == 0 then
log('no plots found. either no garden is loaded, or GardeningBehavior resolved to the wrong object')
return false
end

local empty = 0
for i, p in ipairs(plots) do
if p.empty then
empty = empty + 1
state.targets[#state.targets + 1] = { index = i, x = p.x, y = p.y, z = p.z }
end
end

log(string.format('%d plot(s) visible, %d empty', state.total, empty))
if empty == 0 then
log('nothing to plant. if the garden IS empty, m_plantList may only hold planted plants (spec Q4)')
return false
end
return true
end

The whole plot list is captured once at the start rather than re-read each cycle. Planting changes the list, and iterating a collection you are mutating leads to skipped plots.

Both failure logs name more than one possible cause rather than picking one. A zero-length plot list could mean no garden is loaded, or the behaviour object resolved to something else; a fully-planted garden could genuinely have nothing left to plant, or the read could be missing plots the game does not expose the same way. Naming the alternatives up front saves you from debugging the wrong one.

The UI sequence

Six steps, each with its own timeout.

Teleport and open

local function begin_plot(c)
state.cursor = state.cursor + 1
local target = state.targets[state.cursor]
if not target then
finish()
return
end

state.index = target.index
local ok, err = c:Teleport({ x = target.x, y = target.y, z = target.z + Z_OFFSET })
if not ok then
fail_plot('teleport: ' .. tostring(err))
return
end

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

Plain Teleport, not TeleportWithRecovery — the destination is a known-good plot position and the recovery step-back would fight the Z offset.

Wait for the window

local function await_window(c)
if c:IsWindowVisible(P_GARDEN_SUB) then
state.waited = 0
state.step = 'select_seed'
return
end
state.waited = state.waited + 1
if state.waited > WINDOW_TIMEOUT_TICKS then
fail_plot('gardening window did not open')
end
end

Check for success first, count toward the timeout second. Every waiting step in this plugin has the same shape.

Select the seed

local function select_seed(c)
local ok, err = c:ClickWindow(P_FAVORITES)
if not ok then
fail_plot('favorites tab: ' .. tostring(err))
return
end

local icon = seed_icon_path(SEED_SLOT)
for _ = 1, 2 do
ok, err = c:ClickWindow(icon)
if not ok then
fail_plot('seed icon ' .. SEED_SLOT .. ': ' .. tostring(err))
return
end
end

c:SetCursor(0, 0)
state.waited = 0
state.step = 'await_gray'
end

Two things here look wrong and are not.

The icon is clicked twice. The first click selects, the second confirms. The game's gardening UI wants both.

SetCursor(0, 0) afterwards. Moving the cursor out of the way stops a hover tooltip from covering the placement widget the next step needs to read.

Watch the placement control

This is the clever part. Rather than guessing how long planting takes, the plugin watches the placement control's grayed state:

local function await_gray(c)
local grayed, err = c:IsControlGrayed(P_PLACEMENT)
if err == nil and grayed then
state.waited = 0
state.step = 'await_ungray'
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

local function await_ungray(c)
local grayed, err = c:IsControlGrayed(P_PLACEMENT)
if err == nil and not grayed then
state.planted = state.planted + 1
log(string.format('planted plot %d (%d/%d)', state.index, state.planted, #state.targets))
state.waited = 0
state.step = 'settle'
return
end
state.waited = state.waited + 1
if state.waited > PLANT_TIMEOUT_TICKS then
fail_plot('planting did not finish')
end
end

Grayed means busy. So: wait for it to go grayed (the plant started), then wait for it to un-gray (the plant finished). That is a real completion signal rather than a sleep, and it adapts automatically to a slow client or a laggy server.

The failure message on await_gray is diagnostic rather than descriptive — never going grayed means the click did not register, which is a different problem from planting failing.

err == nil and grayed guards both conditions. Recall that IsControlGrayed returns true when the window is missing, so checking the error separately keeps "window not there" from reading as "busy".

Failure handling

local function fail_plot(reason)
state.failed = state.failed + 1
log(string.format('plot %d failed: %s', state.index or -1, reason))
state.step = 'next'
end

A failed plot increments a counter and moves to the next one. A gardening run that stops on the first stubborn plot is not much use, and the final tally tells you whether to investigate:

local function finish()
if state.failed > 0 then
log(string.format('done: %d planted, %d failed, of %d plot(s)',
state.planted, state.failed, state.total))
else
log(string.format('done: %d planted of %d plot(s)', state.planted, state.total))
end
state.step = 'done'
state.enabled = false
end

Self-disabling on completion is right for a one-shot job. The farm loop, by contrast, is meant to run forever.

Running it

Get into position first. The plugin starts working on the tick after you start it — there is no arming step and no confirmation.

In the desktop app: put garden69.lua in ~/.kebab/plugins, open Forge → Trainer → Plugins, refresh, and click Run on garden69.

From the console:

hook
scripts run garden69

Watch the first line it logs. N plot(s) visible, M empty means it read the garden; anything else means it did not, and the run is already over.

Planting a second garden needs a stop first

garden69 disables itself when it finishes, but as far as the host is concerned the plugin is still running — so starting it again does nothing, and F8 does not restart it either (the state machine has parked in its done step). Stop it, then start it again:

scripts stop garden69
scripts run garden69

In the desktop app, Stop then Run on the same row.

The console also has a separate, built-in gardening automation driven by its own YAML config rather than this Lua plugin, if you would rather not use it:

enable gardening
enable gardening garden.yml

That built-in automation is console-only; the desktop app does not have an equivalent.

Adapting it

Different seed: change SEED_SLOT to the favorites slot number.

Different Z offset: if the character ends up inside the ground or too high to place, adjust Z_OFFSET. 125 works for standard house gardens.

Timeouts: the defaults are generous — 10 seconds for the window, 15 for the seed click to register, 60 for planting. Tighten them on a fast local setup if you want failures reported sooner. Convert them to utils.Ticks(seconds) while you are in there.

When it fails

Each failure is logged against the plot it happened on, and the run continues to the next plot. Read the log line, not the final tally.

Log lineWhat it means
no plots foundNo garden is loaded where you are standing, or the gardening behaviour resolved to something else. Move into the house and try again.
nothing to plantEvery plot the plugin can see already holds a plant.
plot N failed: teleport: ...The position write was rejected. Usually a plot the character cannot legally occupy; try a different Z_OFFSET.
gardening window did not openThe G key did not reach the client, or the window is slower than WINDOW_TIMEOUT_TICKS.
placement never grayed, the seed click did not registerThe click landed somewhere else. Wrong SEED_SLOT, an empty slot, or the favourites tab did not switch.
planting did not finishPlanting started and never completed within PLANT_TIMEOUT_TICKS.

If nothing is logged at all, the plugin is not running — see the note above about stopping before restarting.

  • Windows and UI — how window paths are resolved, and why you should never walk the window tree in a tick loop.
  • Opt-in modules — what else require() unlocks, including c.garden.