DwarfSpec lets you write Busted tests that interact with a running Dwarf Fortress game through DFHack.
Your tests run inside DFHack, where they can open real UI screens, move the
pointer, send input, and wait across game frames. You still use normal Busted
features such as describe, it, hooks, and Luassert assertions. DwarfSpec
starts the run from your terminal, reports progress, cleans up test-owned UI,
and writes a machine-readable JSON result.
local widgets = require('gui.widgets')
---@class tests.SettingsPanel: gui.widgets.Panel
local SettingsPanel = defclass(nil, widgets.Panel)
---Builds the settings controls under test.
function SettingsPanel:init()
self:addviews{
widgets.HotkeyLabel{
view_id='notifications',
frame={l=0, t=0},
label='Enable notifications',
on_activate=self:callback('enable_notifications'),
},
widgets.Label{
view_id='status',
frame={l=0, t=2},
text='disabled',
},
}
end
---Updates the visible settings state.
function SettingsPanel:enable_notifications()
self.subviews.status:setText('enabled')
end
describe('settings screen', function()
it('enables notifications', function()
ds.mount(SettingsPanel)
ds.get('notifications'):click()
assert.equals('enabled', ds.get('status'):text())
end)
end)- Dwarf Fortress with DFHack installed and running;
- Lua 5.3 or newer;
- LuaRocks for the selected Lua installation; and
- access to
dfhack-run, preferably through a project-local.envfile whoseDFHACK_ROOTpoints to the directory containing the runner.
The external Lua toolchain does not need to match DFHack's embedded Lua version. DwarfSpec sends pure-Lua dependencies to the live host, which loads them with DFHack's own interpreter and replaces native system modules with host adapters.
Install DwarfSpec from LuaRocks:
luarocks install dwarfspec
dwarfspec versionIf the command is not found, add the selected LuaRocks tree's bin directory
to PATH.
The recommended DFHack configuration is a .env file in the consumer project
root. Add .env to the project's .gitignore, then set DFHACK_ROOT to the
directory containing dfhack-run.exe or dfhack-run:
DFHACK_ROOT=C:\Games\Dwarf Fortress\hack
DwarfSpec loads this file automatically when invoked for the project. This
keeps the machine-specific DFHack installation path out of commands and source
control. Existing process environment variables override the project file;
use --runner only when one invocation needs a different executable.
See the installation guide for local rocks, custom LuaRocks trees, and development servers.
For local live automation from this source checkout, copy .env.example to
.env, set DFHACK_ROOT to the DFHack installation containing
dfhack-run.exe, and run:
.\tools\Run-AutomationTests.ps1With no arguments, the script runs the product live specifications under
tests/automation/. Pass normal dwarfspec run selectors after the
script name. The .env file is local-only and is not read by GitHub Actions.
By default, DwarfSpec recursively discovers files named *.ds.lua beneath
your project's tests/ directory. A typical layout is:
tests/
settings/
settings.ds.lua
support/
settings_data.lua
dwarfspec/
config.lua
Run commands from the project root. Use list to check discovery without
loading or executing the test files:
dwarfspec list
dwarfspec runYou can select a subset with a project-relative glob:
dwarfspec run 'tests/settings/**'
dwarfspec run --filter 'enables notifications'Run dwarfspec help run for all selection, timeout, reporting, and runner
options.
DwarfSpec provides a run-scoped ds object inside each live spec. It does not
add ds to the process-wide Lua globals.
Mount a component class or already-created instance to create test-owned UI:
describe('search dialog', function()
before_each(function()
ds.mount(SearchScreen, {initial_pause=false})
end)
it('accepts a query', function()
ds.get('query'):click():type('granite')
assert.equals('granite', ds.get('query'):text())
end)
end)DwarfSpec accepts widgets.Widget, overlay.OverlayWidget, and gui.ZScreen
classes or instances through the same entry point. It owns the host,
instruments successful renders automatically, and cleans up the current mount
after each example. Every mount uses a 128 by 64 DF-cell viewport by default;
pass viewport={width=..., height=...} to select another size. Reusable
factories remain ordinary Lua helpers.
local gui = require('gui')
---@class tests.SearchScreen: gui.ZScreen
local SearchScreen = defclass(nil, gui.ZScreen)
ds.mount(SearchScreen, {initial_pause=false})Mounts are automatically removed after each example, even when an assertion
fails. Call ds.unmount() when a test specifically needs to remove one early.
Use ds.await(description, query) when a result depends on future game
frames. The query runs once per frame until it returns a truthy value:
local results = ds.await('search results appear', function()
local screen = ds.root():raw()
return #screen.results > 0 and screen.results
end)
assert.equals('granite', results[1].text)The description is included in timeout diagnostics. You can override the default limits for one wait:
ds.await('world finishes loading', function()
return dfhack.isWorldLoaded()
end, {frame_budget=600, timeout_ms=20000})Input commands perform their required render or frame synchronization
automatically. Use ds.wait_frames(count) only when the number of elapsed game
frames is itself part of the behavior being tested.
| Command | Purpose |
|---|---|
ds.await(description, query, options) |
Poll a condition between live frames. |
ds.wait_frames(count, options) |
Wait for a specific number of DFHack frames. |
ds.mount(component, options) |
Mount a widget, overlay widget, or complete screen and return its root subject. |
ds.root() |
Return a subject for the implicit current mount root. |
ds.get(control_path) |
Select one direct-child control path from the implicit current mount. |
ds.unmount() |
Cleanly remove and settle the implicit current mount. |
ds.viewport(width, height) |
Change the mounted viewport in DF cells and wait for its render. |
subject:inspect() |
Return stable, read-only information about the selected view. |
subject:text() |
Return the selected view's inspected text value. |
subject:raw() |
Access the native object as an exceptional escape hatch. |
subject:move_pointer(anchor) |
Move the pointer into the selected view. |
subject:hover(anchor) |
Hover the selected view and preserve the subject. |
subject:click(button) |
Click the selected view and preserve the subject. |
subject:input(keys) |
Send native DFHack input through the mounted screen. |
subject:type(text) |
Type ASCII text through the mounted screen. |
ds.capture_view_tree(name) |
Retain the implicit mount's structured view tree. |
ds.capture_screen(name, options) |
Retain a bounded screen-cell capture. |
ds.stage_overlay_registration(source, name) |
Stage a run-owned script only for separately selected real-registration integration coverage. |
See Writing live tests for component and overlay contracts.
Configuration is optional. Put project-wide settings in
tests/dwarfspec/config.lua:
return {
settings={
discovery={test_glob='*.ds.lua'},
wait={frame_budget=300, timeout_ms=10000},
},
}Lua modules directly beneath tests/dwarfspec/ can also add project-specific
commands to ds:
return {
commands={
selected_text=function(_, subject)
return subject:text()
end,
},
}The command is then available as ds.selected_text(ds.get('status')) in every
live spec.
Keep module top-level code portable and make DFHack-only calls inside command
callbacks. See Consumer configuration for discovery
overrides and extension rules.
The terminal shows each example as it starts and finishes. A run succeeds only when all Busted examples pass and DwarfSpec confirms cleanup.
By default, the final DFHack-generated report is written to:
.test-results/dwarfspec/<run-id>.json
Use --results PATH to choose another project-relative directory or
--no-results to disable report files. The JSON document uses the
dwarfspec.run.v1 schema and includes totals, failures, run state, and cleanup
status.
See the command-line reference for glob syntax, runner selection, abort behavior, and exit codes.
DwarfSpec is available under the MIT License.