Skip to content

Build a SITL binary

A fresh ArduPilot checkout needs ./waf configure --board sitl and ./waf plane before there is anything to fly. build_sitl runs both as a background job, so an agent can go from a clone to a flying SITL without a shell.

build_sitl() # the server's ardupilot tree, plane, board sitl
build_sitl(vehicle="copter") # another waf target
build_sitl(tree="~/ardupilot-dev", configure_args=["--debug"])

Configure runs only when the tree is not already configured for the board (waf records it in build/c4che/_cache.py), or when you pass configure=true or any configure_args. The call waits up to about 85 s and returns the job’s state; a first build takes minutes, so it usually comes back still running:

{ "build_id": "a41f0c", "status": "building", "step": 522, "steps": 1426,
"progress_pct": 36.6, "configured": false, "python": "/home/you/venv-ardupilot/bin/python3" }
build_status(build_id="a41f0c") # waits up to about 85 s, with progress notifications
{ "build_id": "a41f0c", "status": "done", "step": 1426, "steps": 1426,
"binary": "/home/you/ardupilot/build/sitl/bin/arduplane",
"provenance": { "binary_mtime": "2026-10-03T21:19:05+00:00", "ardupilot_sha": "4c98c92",
"ardupilot_patch_sha256": null, "stale_binary": [] } }

A failed build returns status: "failed" with the error and the last lines of waf’s log. cancel_build stops one, process group and all; list_builds shows this server’s builds. An incremental build after a small change took under a minute on the stock tree.

A rebuild replaces a binary other runs may be flying, and a binary rebuilt mid-run makes results lie about what flew. So the server builds:

  • any tree named in MCARDUPILOT_BUILD_TREES (separated by : or ,), and
  • the default MCARDUPILOT_ARDUPILOT tree, only while its git checkout is clean.

A tree with uncommitted changes, a patched tree, is refused unless you name it. Set MCARDUPILOT_BUILD_DEFAULT_TREE=0 to build named trees only. One build runs per tree at a time.

ArduPilot’s build needs Python packages (empy 3.3.4 and the rest) that ArduPilot’s install-prereqs script puts in ~/venv-ardupilot. A login shell activates that venv; an MCP server does not, and under uvx the server’s own venv comes first on PATH. So the build uses, in order: MCARDUPILOT_BUILD_PYTHON, then ~/venv-ardupilot/bin/python3, then python3 on PATH outside the server’s venv. A one-second check before configure turns a missing package into a clear error rather than a failure deep in waf’s output.

If your tree is a git worktree, or a copy of one, its submodule pointers may not resolve: pass configure_args=["--no-submodule-update"].

A tree build compiles whatever the working tree holds. For a binary you can name, rebuild and check later, give build_sitl its inputs explicitly and let it build in a pinned image:

build_sitl(
tree="~/ardupilot", # the source repository: only read
commit="4c98c92",
patches=["~/my-project/sim/patches/sitl.patch"], # applied in order
image="ardupilot/ardupilot-dev-chibios@sha256:8bb0f850fb3f...",
reproduce=True, # build twice and compare
)

What happens, in order:

  1. A fresh local clone of the source is checked out at the commit. Its objects are hardlinked, so it costs little disk on the same filesystem, and it does not depend on the source afterwards.
  2. Submodules are cloned from the source’s own module repositories, recursively, so nothing needs the network. A source that never ran git submodule update --init --recursive is refused with that instruction.
  3. Each patch is checked with git apply --check, then applied. One that does not apply fails the build with git’s reason.
  4. ./waf configure --board sitl --no-submodule-update and ./waf plane run in the image as your user, with no network, the checkout mounted at one fixed path, SOURCE_DATE_EPOCH set from the commit time and ccache off.
  5. The binary and the frame tables are copied to <data_dir>/builds/<build_id>/, in tree layout, with manifest.json beside them, and the checkout is deleted.
{ "build_id": "449c85e46491", "status": "done", "cached": false,
"manifest": {
"inputs": { "commit": "4c98c922...", "patch_sha256": "8762cf5c2eca", "image": "...@sha256:8bb0f850..." },
"toolchain": { "gcc": "gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0", "python": "Python 3.12.3",
"waf": "waf 2.0.27 (c3e645e3...)" },
"binary": { "path": "build/sitl/bin/arduplane", "sha256": "cc3b984b...", "size": 6124568 },
"build_s": 133.3, "reproduced": true, "rebuild_sha256": "cc3b984b..." } }

build_id is a hash of the inputs (commit, patch hashes in order, image digest, vehicle, board, configure flags), so the same inputs always name the same directory. Asking again answers at once with cached: true; asking again with reproduce=true runs only the verification build.

When a rebuild does not match, differences says where: the two sizes, the first differing byte, and which ELF sections differ.

Fly the result by pointing open_sitl at the directory:

open_sitl(tree="~/.local/share/mcardupilot/builds/449c85e46491")

Frame defaults come from the copied frame tables, and the session’s provenance carries build_id, image, the commit, the patch hashes and the binary’s sha256, read from the manifest rather than a git tree.