When to use
Read this before typing a build command, not after one fails. The correct invocation differs per platform, and the wrong one is not merely slower — see Pitfalls.
First: does this need a build at all?
On Linux, a change confined to src/mudlet-lua/lua/ or src/mudlet-lua/tests/ needs none. Those
are read from disk at startup, so a Mudlet binary built anywhere on the machine can run this
worktree's Lua and specs:
.claude/scripts/run-lua-tests.sh ../otherworktree/build-linux-debug-nosan/src/mudlet
The script detects a binary from another build tree, shims around it with a note: line, and
fails loudly if this worktree's Lua is not what ended up loading.
These look Lua-only but still need a build:
src/packages/andsrc/mudlet-lua/lua/utf8_filenames.luaare compiled into the binary as Qt resources (src/mudlet.qrc), so editing them cannot affect a borrowed binary.- On macOS the build copies mudlet-lua into the
.appbundle and that copy is preferred oversrc/, so a Lua change does need a rebuild there. The script is Linux-only regardless - it drives Mudlet underxvfb-run. - Anything under
src/*.cpporsrc/*.h.
Choose a donor whose branch already contains the C++ the specs rely on - one missing it fails
specs in a way that reads exactly like a regression in the change under test. Prefer a -nosan
tree: the plain build/ preset is an AddressSanitizer build, and this script does not pass it
the ASAN_OPTIONS CI uses, so a leak surfaces as a bare non-zero exit with every spec green.
Use a preset — every platform, one command
CMakePresets.json in the repository root encodes the generator, build type and sanitizer
settings, so no platform-specific flags need to be remembered or typed. Run
cmake --list-presets to see the ones available on the current machine.
Presets need CMake 3.25.1 or newer, which is the same floor CMakeLists.txt already sets for
building Mudlet at all. An older CMake reports an unknown argument or an unsupported preset
version rather than anything informative, so check cmake --version if --preset is rejected.
cmake --preset macos-debug # configure
cmake --build --preset macos-debug # build
ctest --preset macos-debug # run the test suite
| Preset | Platform | Notes |
|---|---|---|
macos-debug / linux-debug |
macOS / Linux | Ninja, Debug, AddressSanitizer on |
windows-debug |
Windows | MSYS2 CLANG64, Ninja, Debug |
<platform>-debug-nosan |
macOS / Linux | No sanitizers — faster to build and to run |
<platform>-debug-tsan |
macOS / Linux | ThreadSanitizer instead of AddressSanitizer |
<platform>-debug-ubsan |
macOS / Linux | UndefinedBehaviorSanitizer |
<platform>-static-analysis |
macOS / Linux | Runs clang-tidy and cppcheck during compilation |
linux-lowspec |
Linux | No sanitizers, no updater, no 3D mapper, 2 jobs — Raspberry Pi and similar |
<platform>-release |
macOS / Linux / Windows | Release build, no sanitizers - the flags CI ships to players |
Every developer preset has a matching build and test preset of the same name, and all three are
conditioned on the host system — so cmake --list-presets on macOS will not offer linux-debug,
and ctest --preset X always runs against the tree that cmake --build --preset X produced.
The plain <platform>-debug presets build into build/. Every variant builds into
build-<preset-name>/ instead, so an AddressSanitizer tree and a sanitizer-free tree can coexist
without forcing each other to rebuild. The /build* entry in .gitignore covers all of them.
On Linux, add -DUSE_ALTERNATE_LINKER=mold to the configure command when mold is installed - it cut
CI's link tail from 4m13s to 29s (PR #9927), and only takes effect on a tree configured with it.
Reproducing what CI configures
ci-linux, ci-macos, ci-macos-no-tests, ci-windows and ci-codeql are the presets the
workflows themselves configure with, so cmake --preset ci-linux reproduces a CI build rather
than approximating one.
They take CMAKE_BUILD_TYPE, USE_SANITIZER, WITH_SENTRY, SENTRY_DSN and
SENTRY_SEND_DEBUG from the environment, since a run varies those by tag and by matrix entry.
Leaving one unset is not the same as what CI passes: a pull request build sets WITH_SENTRY=ON
on every platform and USE_SANITIZER=Address on Linux, and a Mudlet-* tag sets
CMAKE_BUILD_TYPE=Release with USE_SANITIZER empty and SENTRY_SEND_DEBUG=1. So
USE_SANITIZER=Address cmake --preset ci-linux reproduces the Linux PR job; SENTRY_DSN is a
repository secret and cannot be matched locally. ci-macos-no-tests is ci-macos with
BUILD_TESTING=OFF, for the Intel job that ships a binary and leaves the testing to the arm64
one. ci-windows builds into build-$MSYSTEM/, but the rest build into ../b/ninja — beside
the checkout, not inside it, which is where the workflows' ctest and packaging steps look — so
reach for them to investigate a CI failure, not for day-to-day work. They have no test presets:
the workflows call ctest themselves, with per-platform labels and environment.
When to use a release preset
Reach for <platform>-release when the speed and size of the binary are what is being measured:
performance work, benchmarking, or reproducing something a player reports that a Debug build may
not show. It sets CMAKE_BUILD_TYPE=Release and clears USE_SANITIZER, which is what
.github/workflows/build-mudlet.yml hands the ci-linux and ci-macos presets on a
Mudlet-* tag.
CI/build-mudlet-for-windows.sh builds Release on every Windows run and has no sanitizer to
clear. A linux-debug binary is unoptimised and close to seven times the size - 297MB against
43MB - so timings taken on one say little about the shipped client.
It is not a substitute for the CI release job. The preset stops at compiler flags: it leaves out
the packaging, signing, Sentry DSN and MUDLET_VERSION_BUILD wiring, so the binary still reports
itself as a -dev-<sha> build. Debug builds remain the right default for development: assertions
and sanitizers catch what a release build quietly tolerates.
Qt discovery
The presets do not pin a Qt location. CMake installed via Homebrew or a distribution package finds Qt on its default search path. If configuring fails to find Qt — likely with a CMake from the Qt online installer — pass the prefix explicitly:
cmake --preset macos-debug -DCMAKE_PREFIX_PATH="$(brew --prefix qt6)"
Windows
Builds run under MSYS2. Use the CLANG64 environment: CI/setup-windows-sdk.sh, which installs
the dependencies, accepts only that one and exits on any other MSYSTEM. Open a CLANG64 shell, not
MINGW64, and make sure it is a real MSYS2 shell — Git for Windows' bash can carry an inherited
MSYSTEM that makes it look like one, in which case MSYSTEM_PREFIX is empty.
The preset itself is not tied to a particular environment: it reads MSYSTEM_PREFIX, which MSYS2
sets in each of its shells, so it follows whichever one is provisioned. On an ARM64 host the native
environment is CLANGARM64, which the setup script does not currently handle, so dependencies have
to come from a CLANG64 shell.
Sanitizers are not enabled on Windows, so there is no -nosan variant.
Running the result
# macOS
./build/src/mudlet.app/Contents/MacOS/mudlet
# Linux
./build/src/mudlet
Mudlet is a graphical desktop application; launching it opens a window. Variant presets put the
binary under build-<preset-name>/ instead. Allow up to 10 minutes for a full build.
Claude Code on the web (remote sessions)
The .claude/hooks/session-start.sh SessionStart hook provisions the remote Ubuntu container:
apt dependencies, Qt 6.9.0 via aqtinstall under /opt/qt (Ubuntu's packaged Qt 6.4 is older
than the 6.8.2 minimum), the Lua rocks, submodules, and a CMake configure of the
linux-debug-nosan preset. The hook exports CMAKE_PREFIX_PATH pointing at the aqt Qt, so the
documented preset commands work unchanged. It takes ~3 minutes on a cold container and seconds
on a warm one. ccache starts cold, so budget ~18 minutes for the first full build of a session
on the 4 cores these containers get.
The hook also pre-configures build-linux-debug-nosan/ with -DUSE_ALTERNATE_LINKER=mold - keep
that flag if you reconfigure the tree from scratch.
Run Mudlet headlessly there with QT_QPA_PLATFORM=offscreen.
Both test harnesses work in the remote container (validated: 112/112 ctest, 3202 busted successes):
C++ tests:
QT_QPA_PLATFORM=offscreen ctest --preset linux-debug-nosan. The functional tests loadLuaGlobal.lua, which needs the--localLua rocks onLUA_PATH/LUA_CPATH— the hook exports both; without them ~12 tests fail withattempt to index global 'yajl'or'rex'errors.Lua specs (busted):
.claude/scripts/run-lua-tests.sh— starts the HTTP/Discord/MMCP fixtures fromCI/and runs the self-test profile under xvfb exactly like the "(Linux) Run Lua tests" CI step. Concurrent runs are safe (one per worktree, or even the same tree): fixtures bind ephemeral ports handed over via a per-run temp directory, cleanup kills only that run's fixture PIDs, and each run gets a private HOME so no two Mudlets — nor leftovers of an aborted run — share the self-test profile's saved state. Sharing a profile tree is not survivable: stale state fails ~38 Networking specs with "Expected objects to be the same" at theensurePeerassertion.The egress proxy blocks GitHub codeload tarballs (403), so
luarocks installof rocks whose rockspecs point at tarballs fails; the hook falls back togit clone+luarocks make(git-protocol GitHub access is allowed).LuaSQL-SQLite3must stay pinned at 2.6.1 — 2.8.0 breaksDB.lua'sPRAGMA table_infohandling and errors 8 DB specs.Seeing and driving the real UI: to verify a feature or fix visually, run Mudlet on a virtual display and work it like a user — the hook installs the whole toolchain from
docs/demo-videos.md(xvfb, openbox, xdotool, imagemagick, ffmpeg):export DISPLAY=:78 Xvfb :78 -screen 0 1280x800x24 & sleep 2; openbox & sleep 1 HOME=$(mktemp -d) ./build-linux-debug-nosan/src/mudlet & sleep 8 xdotool mousemove <x> <y> click 1 # or: xdotool key Return, xdotool type "text" import -window root /tmp/shot.png # screenshot; read it to verify, then iterateA throwaway
HOMEkeeps the real profile tree untouched. Screenshot after every interaction — coordinates come from looking at the previous shot, not from guessing. The same display servesdocs/demo-videos.md's before/after recording workflow via ffmpeg. All of this is Linux/X11-only, and XTEST events work headlessly on Xvfb only; from a Wayland desktop it needsQT_QPA_PLATFORM=xcb GDK_BACKEND=x11.
The docker/ directory is a separate developer convenience (QtCreator-in-container); its
Ubuntu 22.04 base only offers Qt 6.2 from apt, so it cannot build current Mudlet until it is
modernised — do not reach for it in remote sessions.
Pitfalls
Never pass --parallel without a job count on a Makefiles build. cmake --build . --parallel
with no number passes a bare -j to make, which imposes no limit on concurrent jobs: make starts
as many compilers as the dependency graph allows, exhausting RAM and swap. Ninja defaults to a
bounded job count, which is why the presets use it. In a pre-existing Makefiles tree, use
make -j $(nproc) on Linux or make -j $(sysctl -n hw.ncpu) on macOS.
ccache is wired in automatically — CMakeLists.txt sets it as the compiler launcher whenever
ccache is installed. A full cache evicts objects continuously, so switching branches can trigger a
near-full rebuild. Run ccache -s; if Cache size has reached Max cache size, raise it with
ccache -M <n>G.
Sanitizers are on by default on every non-Windows build, regardless of build type
(src/cmake/EnableSanitizers.cmake defaults USE_SANITIZER to address). They cost both compile
time and runtime speed. Use a -nosan or -release preset when not chasing a memory bug; both
clear USE_SANITIZER explicitly, because a Release build type alone does not.
For a combination the presets do not cover, pass a CMake list — semicolon-separated, not
comma-separated: -DUSE_SANITIZER="Address;Undefined". A comma-separated value is treated as one
name, which silently skips the per-sanitizer options such as -fno-omit-frame-pointer.
Usable names are Address, Thread and Undefined on macOS, plus Memory and Leak on Linux.
MemoryWithOrigins appears in the USE_SANITIZER cache docstring but has no mapping declared, so
it always fails. An unavailable or incompatible selection raises a SEND_ERROR: configure runs to
completion, but generation is blocked.
Static analysis (<platform>-static-analysis) needs clang-tidy and cppcheck on PATH. They
are independent: whichever is present runs. A missing clang-tidy produces a CMake warning, but a
missing cppcheck only prints a STATUS line that is easy to miss — so check the configure output
rather than assuming both ran. On macOS, Homebrew's llvm is keg-only, so clang-tidy is not on
PATH by default.
Configuring a second generator in an existing build directory fails. CMake cannot switch generator in place; configure into a fresh directory instead.
Related
docs/platform-builds.md— platform detail and the compile-time debugging defines- https://wiki.mudlet.org/w/Compiling_Mudlet — full setup, including dependency installation