Development¶
Principles¶
dzo is written the lazy way: lazy meaning efficient, not careless.
Build what is needed now. No features, options or abstractions “for later”. An interface with one implementation, a factory for one product or a setting for a value that never changes is not added.
Reuse before writing. In this order: code that already exists in dzo, the Go standard library, a feature of the platform (systemd, podman, btrfs, the database), an existing well-maintained library, and only then new code.
Boring over clever. Short, obvious code that someone can debug at 3 am.
Never lazy about safety. Input validation, error handling that prevents data loss, security measures and tests are not simplified away.
A deliberate shortcut with a known limit gets a
ponytail:comment that names the limit and the upgrade path.
Building¶
dzo is a single static Go binary built with the current upstream Go toolchain. Modules are vendored, so builds work offline.
make build # static binary
make servermods # pack the dzo-admin mod into dist/servermods/ (needs the submodules; see below)
make test # tests with race detector and coverage gate
make lint # includes reuse lint (licence headers)
make licenses # dependency licences must be AGPL-compatible
make docs # this documentation (needs python3-sphinx)
make deb # Debian package
The apt repository¶
The Docs workflow (.github/workflows/docs.yml) starts when CI has passed
on main, takes the package the deb job of that CI run built (the
dzo-deb artifact), runs scripts/build-apt-repo.sh (reprepro, configured in
apt/conf/distributions) and publishes the result under /apt/ next to the
documentation. The repository holds the latest build of main only.
Versions: the top entry of debian/changelog is always the latest release; the
upcoming one is added when it is cut. In CI, scripts/debian-snapshot.sh adds a changelog entry
<version at the top of debian/changelog>+git<commits since the last tag>.<sha>
before the build. + sorts after that release
and before the next one (0.1.0-1+git5.abc is older than 0.1.0-2 and
0.2.0-1), and the commit count only grows, so snapshots stay in order. The
commit of a release itself is built as it is.
Signing: the key is the repository secret APT_GPG_PRIVATE_KEY (an
ASCII-armored secret key without a passphrase). The public half is
apt/dzo-archive-keyring.asc; the workflow fails if the two do not match.
To replace the key, create one in a throwaway GnuPG home, never in your own keyring:
export GNUPGHOME=$(mktemp -d)
gpg --batch --passphrase '' --quick-gen-key "dzo apt repository <bernd@bzed.de>" ed25519 sign never
gpg --armor --export > apt/dzo-archive-keyring.asc
gpg --armor --export-secret-keys | gh secret set APT_GPG_PRIVATE_KEY
rm -rf "$GNUPGHOME"
Then commit the new public key and update the fingerprint here and in the README. Users have to fetch the new key.
Building and testing the servermod¶
make servermods packs servermods/*/src/* with dzo’s own Go packer (no
DayZ Tools, no armake2) into dist/servermods/<mod>/addons/*.pbo and writes
compat.yaml. The output is reproducible, and servermods are never sent to
clients, so nothing is signed and no key is needed. The scripts go in as plain
text and config.cpp is not rapified; the server reads both.
servermods.lock in the repository pins the submodule commit and the sha256 of every PBO. CI (the
servermods job, make servermods-check) builds the mods on GitHub and fails when they differ from the
lock, which also catches a build that is not reproducible. After a change of a servermod, run
make servermods-lock and commit the lock file with the new submodule commit. The mod’s commit must
be pushed to its own repository first, or CI cannot check it out.
While 1.30 is experimental, a servermod has to load on both 1.29 (stable,
Steam app 223350) and 1.30 (experimental, app 1042420, “DayZ Server Exp”). One
PBO serves both; version-specific code belongs behind #ifdef DAYZ_1_29 with
the new code in #else. Boot it on each, one server tree per version, never
two servers on one tree and never default ports. dzo test boot --server
<dir> takes the experimental install directory; --server steam is the
stable one. Each server build needs its own vanilla baseline
(dzo test boot --vanilla).
The 1.30 FindFile bug has a test of its own. make test-findfile (scripts/test-findfile.sh)
boots an experimental server with CF-Test (workshop item 1625463737, subscribed in the Steam
client) and the servermod testmods/dzo-findfile-probe. The probe waits five seconds after the
mission started, writes two files into $profile:dzo_probe/ and looks up files with
CF.FindFileEx on $profile: (the server’s .RPT, the two probe files) and on $mission:
(*.xml, db/*.xml, *.c). Each lookup logs a DZO-PROBE <name> OK|FAIL line and the run ends
with DZO-PROBE RESULT OK|FAIL; the script passes the lines to dzo test boot --expect. control
lines show what the engine’s own FindFile finds for the same patterns (nothing for $profile: on
1.30, which is the bug). Like every boot test it needs a machine that does not run dzo instances, and
network access for the vanilla mission.
The player actions of dzo-admin need a connected player, so they have their own test:
make test-client (scripts/test-client.sh, --exp for 1.30). It builds the servermod and
the probe servermod testmods/dzo-client-probe, starts a local server with both (BattlEye off,
no password, free ports), a real dzo serve and the DayZ client headless (sway, through the
dayz-dev skill’s scripts, DAYZ_DEV_SCRIPTS), and drives them with dzo player and
dzo vehicle: the players state, messages in the three styles, teleport (and its refusal in a
vehicle), spawn_item into the inventory, the hands and the ground, an unknown class, a class
watch rule on an item, and the crew of a car. It also follows the event feed (dzo events -f --json)
for the whole run: the client’s join and leave, the kill of the player as a death, and, from a marker file
of the probe that makes the game events happen without a player (a rifle shot, a death without a killer,
building, dismantling and destroying a fence, a flag raised and lowered; stashes need a real dig),
those events, once each. The probe mod logs what the player has and, on a
marker file, seats the player in a new car; it is a test aid and never part of a release. It needs
the Steam DayZ Server tool and the client of the same build, a logged-in Steam and one game at a
time; on a machine that runs dzo instances, run it elsewhere.
The BattlEye event patterns of internal/battleye come from a real capture (see FAQ: things we found out the hard way). To repeat it you need a client that runs BattlEye: the headless client of the
dayz-dev skill starts DayZ_x64.exe without it, so set
PROTON_BATTLEYE_RUNTIME="$STEAM_ROOT/steamapps/common/Proton BattlEye Runtime" in its launch
script, as Steam does. Build and sign testmods/dzo-be-test (dayz-mod-pack.sh -C testmods/dzo-be-test
keygen once, then build), link build/@DZOBETest into the client’s DayZ directory, put its
keys/DZOBETest.bikey into the server’s keys (or add the mod with dzo mod add dzo-be-test
--instance <name> --client --force, see Debugging a client mod you build yourself) and join with -mod=@DZOBETest.
A client with the mod sends two chat lines a minute after the mission started; dzo rcon console
<instance> prints the events, and kick <id> <reason> over RCon gives the kick line. A local server needs an
absolute -BEpath and an RConIP in profiles/battleye/beserver_x64.cfg.
The ban design (Bans and evidence) was verified the same way. scripts/test-client.sh --bans runs the
server with BattlEye (RCon on localhost, an absolute -BEpath) and checks, in this order:
V1, what BattlEye writes to bans.txt (internal/battleye/livebans_test.go, gated by
DZO_LIVE_BE_ADDR, DZO_LIVE_BE_PASSWORD, DZO_LIVE_BE_DIR and DZO_LIVE_CAPTURE, with the
captures in internal/moderation/testdata/v1); V2, that the GUID of a connected player is
md5("BE" + the Steam ID) (testdata/v2); V3, that a new ban kicks the online player, a banned player
cannot join, a revoked one can, and an IP ban refuses the address. --bans --no-client runs only V1 and
needs no game. It needs the BattlEye client described above.
A quick check without a configured instance: symlink dist/servermods/dzo-admin
into a tree of each server as @dzo-admin, start it with
-servermod=@dzo-admin (a relative path), and compare the script module file
counts with an unmodded boot of the same version. Both of these must hold:
no
SCRIPT (E)line that the vanilla boot does not also log (the experimental server logsLeaked 'BunkerBroadcastManager'without any mod);the counts rise by the mod’s files:
DZO_ADMINappears in thedefinesof theModule:lines, and the Game, World and Mission counts are higher than vanilla.
Measured with dzo-admin at a0e644b:
Server |
Game |
World |
Mission |
|---|---|---|---|
1.29.163709 (build 24570360), vanilla / with mod |
416 / 422 |
2123 / 2125 |
209 / 210 |
1.30.164014 (build 25319221), vanilla / with mod |
440 / 446 |
2321 / 2323 |
219 / 220 |
Tests¶
The test suite needs no Steam account and no DayZ server: steamcmd, systemd, podman, RCon and the Steam API are replaced by fakes and recorded fixtures. Coverage must stay at 85 % or more; CI fails below that.
Test conventions¶
Tests use testify.
requireby default;assertonly for independent checks after which the test still makes sense.No
t.Fatal*ort.Error*(t.Skip*andt.Log*are fine).t.Context(),t.TempDir(),t.Setenv()andt.Chdir()instead of their global counterparts.Helpers start with
t.Helper().No
time.Sleep: wait withrequire.Eventually, a channel or a fake clock.The linters in
.golangci.ymlenforce this for test files:testifylint(all checks),thelper,usetestingandforbidigo(time.Sleep,t.Fatal*,t.Error*).make testandmake test-racerun with-shuffle=on. A failure prints the seed; reproduce it withgo test -shuffle=<seed> ./pkg/, then fix the order dependency in the test.A package that starts goroutines has a
TestMainwithgoleak.VerifyTestMain, so a goroutine that outlives its context fails the package.goleak.IgnoreTopFunctionis only for third-party goroutines, with a comment naming the library.Fuzzing and the browser tests are described below.
Fuzzing¶
The parsers of untrusted input have native Go fuzz targets: BattlEye packets
and events, the A2S info response, serverDZ.cfg and the quantity of the
spawn commands. Their seeds run in every go test. make fuzz fuzzes each
target for FUZZTIME (default 10 s); CI runs the same in the fuzz job
and uploads the crashing input on failure. To fuzz one target longer:
go test ./internal/servercfg -run '^$' -fuzz '^FuzzServercfgParse$' -fuzztime 5m
A crash is a real bug. Go leaves the input in testdata/fuzz/<Target>/;
commit that file together with the fix, it is the regression test.
Browser end-to-end tests¶
make e2e drives the web UI in a headless Chrome or Chromium with
chromedp (build tag e2e, files
internal/web/e2e*_test.go, tests TestE2E*). chromedp is pure Go and
talks the DevTools protocol to the browser that is installed anyway (the
cheat sheet build needs one); Selenium needs a matching WebDriver binary and
Playwright downloads its own browsers at test time. The cost is that only
Chrome-based browsers are covered.
The tests run against the fake backends of newEnv but with the JavaScript
a dzo installation really serves: htmx, Leaflet and Fork Awesome from the
Debian packages libjs-htmx, libjs-leaflet and fonts-fork-awesome.
A missing browser or missing assets fail the test, they never skip it.
DZO_E2E_CHROMEthe browser binary; default: chromedp’s lookup (
google-chrome,chromium)DZO_E2E_ASSETSthe directory with
htmx/,leaflet/andfork-awesome/(default/usr/share/javascript)
The browser reports every JavaScript exception, console error and CSP
violation, and the test fails on any of them. An unexpected confirm()
dialog fails the test too; a test that expects one answers it through
browser.dialogs. To watch a run while debugging, add
chromedp.Flag("headless", false) to the allocator options in
newBrowser; never commit that.
CI runs make e2e in a debian:trixie container with Debian’s
chromium, as an unprivileged user with the sandbox on. A second run in
debian:forky is allowed to fail: it warns early about the next htmx (4.0
beta).
The browser against a real DayZ server¶
CI cannot start DayZ, so the tests above run against fakes. The fakes can lie:
a field the real mod sends differently, an error text, timing. make
test-client (scripts/test-client.sh, see below) starts a real server with
dzo-admin, a real dzo serve and, unless --no-client, the headless DayZ
client; its last check drives the web UI over that stack with the same browser
harness (internal/web/live_test.go, build tags e2e live, tests
TestLive*). Run it by hand, with DZO_E2E_CHROME and DZO_E2E_ASSETS
as above; it fails without them. The script sets:
DZO_LIVE_APIthe base URL of the API of the running
dzo serve; without it the tests fail (thelivetag is an explicit opt-in)DZO_LIVE_TOKEN_FILEa web token (
dzo token create web --role admin --web)DZO_LIVE_DBthe database of that
dzo serve; the test logs the admin76561198000000077(granted by the script) in by making a session there, because the pages need a loginDZO_LIVE_INSTANCEthe instance,
t1DZO_LIVE_PLAYERthe client’s player name,
dzotester; empty with--no-client, which skipsTestLivePlayer
What the live run catches that the CI tests did not gets a fake-backed regression test in the same commit as the fix: the live tier finds the bug, CI keeps it fixed.
Boot test with a real server¶
Unit tests cannot prove that DayZ accepts what dzo renders. dzo test boot
starts a real DayZServer headless on a render result and checks the logs.
Warning
Only on a development machine or a dedicated test machine. dzo test boot
refuses to run on a host where dzo manages instances (instance directories
below paths.instances, or dzo quadlets for the user), and there is no
option to override that.
dzo test boot --vanilla deerisle # once per server build: record the baseline
dzo test boot deerisle # boot the instance
dzo test boot deerisle --expect 'MyMod: loaded' --out ./logs --keep-tree
--server steam (the default) uses the “DayZ Server” tool installed by your
Steam client (Library, Tools). dzo finds it through Steam’s own manifests, and
neither installs it nor writes into Steam’s directories; --server <dir>
uses another installation. Mods come from your client’s workshop folder
(--mods-from steam-client, the default with --server steam), from dzo’s
cache (--mods-from cache), or from a directory of @Mod folders. dzo-admin
and other local servermods always come from dzo’s cache. With --container the
server runs in the instance’s runtime image instead (podman): the tree, the server install and the mods
are mounted at their own paths, the install and the mods as overlays, so the links of the tree resolve and
nothing is written into them. --port-from and --port-to choose the range of the test ports on a host that reserves
one for the game.
The test builds a throwaway tree (symlinks to the server install, a copy of the
mission as a render would apply it, your serverDZ.cfg with test ports, a
random password and a hostname that starts with [dzo boot test]), starts the
server on free ports and stops it afterwards. The Steam query answers a couple
of seconds after the start, long before the mission has loaded, so dzo waits for
the query, then for the mission’s scripts, then for --settle seconds (20 by
default), and with dzo-admin for the mod’s first contact. --timeout limits
the whole wait. The exit code is 0 if everything passed, 1 if a check failed and
2 if the test itself could not run.
Check |
What fails it |
|---|---|
|
the server exited or did not answer the Steam query in time |
|
the query answers but the mission’s scripts never load |
|
a crash dump was written |
|
a script compile error ( |
|
a mod ships scripts for a module but its file count did not rise over the vanilla baseline: the scripts were not loaded (an absolute mod path or a PBO without a prefix loads nothing and prints no error) |
|
an XML file that does not parse, a |
|
an |
|
the mod never made contact at the test endpoint |
Everything else that is new against the baseline is shown as a warning, verbatim,
for example a class name in types.xml that does not exist
(Type 'X' will be ignored), because Bohemia’s own missions have a few of
those too.
The vanilla baseline holds, per server build, the number of script files each
module loads and the error lines an unmodded server logs. A server installed
through Steam logs several hundred of them, because its map data is stripped.
Without a baseline the counts and error lines are only shown. The baseline is
cached below paths.cache and keyed by the server binary, so a game update
needs a new one.
What a real 1.29 server logs for broken input¶
These were captured with a real server (build 24570360) and are the fixtures
the checks are tested against (internal/boottest/testdata).
Input |
Log |
|---|---|
|
|
|
|
class that does not exist in |
|
script error in the mission’s |
|
broken |
nothing: the file is read only if |
The central economy marks its errors with !!!. error.log also holds
plain information lines (ENTITY : Load entity type ...) that differ from run
to run; only its (E) and (W) lines count.
Logs are kept in --out (default ./boottest-<instance>-<time>/), and the
whole tree with --keep-tree.
Booting by hand¶
Without dzo, the same procedure: build a tree of symlinks to the Steam server
install (never write into it), give the server free ports for the game, the
Steam query (steamQueryPort in serverDZ.cfg) and RCon (RConPort in
profiles/battleye/beserver_x64.cfg), pass mods as paths relative to the tree
(absolute ones are silently ignored), run it from the tree with ulimit -c 0
and stop it with timeout -k 15. Then read script_*.log, the .RPT and
error.log in profiles/.
Contributing¶
Code and documentation change together: a change in behaviour updates the matching page here.
The documentation is plain reStructuredText in
docs/, built with Sphinx.make docsmust build without warnings.dzo is licensed under the AGPL-3.0-or-later. The repository follows the REUSE specification: every new file starts with SPDX headers in the file’s comment syntax, for example in Go:
// SPDX-FileCopyrightText: 2026 Bernd Zeimetz <bernd@bzed.de> // SPDX-License-Identifier: AGPL-3.0-or-later
Files that cannot carry comments (images, test fixtures) get a
<file>.licensefile next to them or an entry inREUSE.toml.make lintrunsreuse lintand fails on files without licence information.