JNEXT
Skip to content

5.2 Build configurations

The make targets already pass the right options for you, so this table matters mainly when you invoke CMake directly.

Option Default Meaning
ENABLE_QT_UI OFF Build the Qt6 native UI. make gui-* turns it on; a plain cmake without it gives the SDL frontend
ENABLE_DEBUGGER ON Include the Qt debugger UI. It opens from the Qt main window, so it is only reachable when ENABLE_QT_UI is also on
ENABLE_TESTS ON Build the unit-test binaries
USE_CCACHE ON Use ccache as the compiler launcher when it is found; a no-op if it is not
JNEXT_ENABLE_LTO ON LTO/IPO for Release builds. Flipped OFF in exactly one place — see below
JNEXT_FORCE_QT5 OFF Build the GUI and debugger against Qt 5 instead of Qt 6 (the legacy Windows legs only)
CYCLE_ACCURATE OFF 28 MHz cycle-accurate mode
STATIC_BUILD OFF Link statically; needs static SDL3/Qt6 builds, which distribution packages are not
MACOS_APP_BUNDLE OFF Build a relocatable jnext.app bundle (macOS packaging only)
GIT_SUBMODULE ON Run git submodule update --init --recursive at configure time

The combinations are gated, not assumed

ENABLE_QT_UI and ENABLE_DEBUGGER between them give four combinations, and only one of those is the one you build by hand. make build-matrix builds all four and fails if any of them breaks.

That target exists because of a specific and rather subtle failure mode. CMake derives static-link order from the dependency graph you declare, which means a target that under-declares its dependencies still links successfully whenever some other library happens to pull them in at a usable position. The bug is real but invisible, and it shipped twice: jnext_core never declared its subsystem libraries and jnext_platform never declared jnext_core, and in both cases the default combination — both options ON — hid the problem, so the link only broke for someone building without the debugger. Link rot of this kind appears at build time and only in the combinations the default build does not exercise, so the only way to find it is to build them all. The matrix keeps going after a failure and reports every broken combination rather than stopping at the first, because knowing whether three are broken or only one is the difference between a single missing edge and a wrong graph.

Building is not the same as running, though, and for a long time it was all the matrix did: every configuration was proved to link, and only the default one ever had a suite run in it, so a suite could stay red in a supported configuration indefinitely — which had happened. Two configurations now run their suites: the default Qt+debugger build through make unit-test, and the SDL-only build through make unit-test-sdl. The other two, Qt without the debugger and SDL with it, are not used in practice and remain build-only. CI runs both targets.

The configurations are about what is built; the suites also run on three operating systems. The SDL-only set runs natively on Linux (make unit-test-sdl), natively on macOS (the same target, with Homebrew's GNU tools first on PATH), and as MinGW-built Windows executables under wine (make unit-test-win). Which suites each OS owes is declared by # os: in test/unit-tests.conf (4.2). The Qt and debugger suites run on Linux only, and the regression suite, the documentation gates and the traceability matrix are Linux-only by design: they gate committed artifacts that are byte-checked against the fedora:44 toolchain.

Where each target builds

There is one scheme, build/<variant>-<config>, and everything lives under build/: build/sdl-release, build/gui-release, build/win-release, build/rpm-release, build/deb-release, build/mac-release, build/flatpak-release, and so on.

build/ itself is the canonical development tree, holding build/jnext plus every test binary. make unit-test-build configures it, and it does so deliberately with ENABLE_QT_UI=ON and ENABLE_DEBUGGER=ON rather than inheriting whatever the defaults happen to be. The reason is that ENABLE_QT_UI defaults OFF, so an unconfigured build/ would silently produce an SDL binary with no main window — and the next person to check something in the GUI would find no window at all and reasonably conclude their own change had broken it. For the same reason the target refuses to build on a build/ that someone has configured by hand with either flag off, rather than handing back a binary that is not what it claims to be. If you hit that refusal, make clean and retry.

Dev build versus release build

With no -DCMAKE_BUILD_TYPE given, CMake here defaults to RelWithDebInfo instead of to the empty build type. An unoptimised build/jnext measured 5.8× slower than Release, which made it a trap for anyone benchmarking anything. RelWithDebInfo additionally keeps frame pointers, so perf record -g produces usable call graphs from it. Release does not keep them: frame pointers were measured at a 5.8% cost there, and that is an unacceptable permanent tax on the binary users actually run.

Hence the rule that follows from those two facts: any performance measurement or benchmark uses a Release build, never build/jnext — and which Release build depends on the question. To compare two code changes, use build/gui-release-non-pgo/jnext (make gui-release-non-pgo; make bench uses it): its speed does not move when a PGO profile is retrained. To ask how fast the shipped binary is, use build/gui-release/jnext, the PGO build (make bench BENCH_BUILD=gui-release). When you do need to profile a release binary, use perf record --call-graph dwarf. 5.5 describes how to measure a change.

LTO is enabled for Release only, through CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE, and it is guarded by check_ipo_supported() so that a toolchain without working LTO simply builds as it did before. It earns its keep here because the emulator is split into per-subsystem static libraries — eighteen in the shipped build with the Qt GUI and the debugger, fifteen in the SDL-only one — and that split makes every hot cross-library call — the CPU into Mmu::read, the CPU into PortDispatch — un-inlinable at compile time.

JNEXT_ENABLE_LTO=OFF exists for exactly one build, the Flatpak one: the org.kde.Sdk GCC miscompiles the QApplication init path under whole-program LTO and segfaults at GUI launch. That was bisected to the LTO process in that toolchain rather than to any single translation unit or transform, which is why nothing short of disabling it helps. Every other build keeps LTO on — including the Windows executables, which is why every win-*-release target passes -DCMAKE_BUILD_TYPE=Release explicitly (WIN_BUILD_TYPE in the Makefile). They used to pass no build type, fell back to RelWithDebInfo, and shipped without LTO.

Profile-guided optimisation

make gui-release — and every package, see 5.3 — is a gcc PGO build: an instrumented twin in build/gui-release-pgo-gen runs a short training set (tools/pgo-train.sh), and build/gui-release is then compiled with the profile it wrote to build/gui-release-pgo-profile. cmake/JnextPgo.cmake adds the flags under the CMake option JNEXT_PGO (GENERATE or USE, with the profile in JNEXT_PGO_DIR). Training needs the NextZXOS SD image, is skipped when nothing that decides the profile changed, and a missing or mismatched profile fails the build. 5.5 Performance and optimisation describes the pipeline, the training set, the gates and the measured gains.

The stack-frame gate

No production function may need a stack frame over 256 KiB: -Werror=frame-larger-than=262144 on GCC, -Wframe-larger-than=262144 -Werror=frame-larger-than on Clang. It exists because Windows runs on MinGW's default 2 MB main-thread reserve (jnext sets no --stack), and main()'s frame once outgrew it, by a margin that depended on GCC's stack-slot sharing: only the cross build saw it. CMakeLists.txt applies the flag to jnext and to every STATIC_LIBRARY target under src/ (derived from the directory tree, not listed), at both compile and link: under LTO the compile-time check is blind and only the link sees real frames. Test executables are excluded on purpose: their fixtures hold Emulators on the stack and are never built for Windows. Under Clang LTO both halves are blind (measured with clang 22 on Linux; ld64 untested), so only GCC enforces there. The runtime witness is startup-stack-func, which boots all three frontends on a 1 MiB stack.

ccache

CMakeLists.txt finds ccache and uses it as CMAKE_{C,CXX}_COMPILER_LAUNCHER. This is what makes the project's mandatory clean-rebuild discipline affordable: a clean rebuild of gui-release plus build/ drops from roughly 65 s to 8 s on a warm cache. Reverting a change and rebuilding — which is the core reviewer move, and one this project asks for often — is a pure cache hit, because the source is byte-identical to a state that has already been compiled once.

Give it room, once per machine; the 5 GB default thrashes on a tree this size:

$ ccache -M 20G

That is a user-level setting rather than repository state, so it has to be re-applied on any new machine. ccache never hits on the PGO build's optimised tree; 5.5 explains why.