English | 简体中文
Reader: an author whose build needs a step mcpp has no rule for — code generation, an embedded asset, a check, a second compiler.
The question this chapter answers: how do I add work to the build graph, so that it is ordered, fingerprinted and incremental like everything else.
Not here: packaging that step so other projects can use it, which is 31 — Authoring a Rule Package, and the tools the step runs, which are 23 — The Project Environment.
Most projects need nothing more than mcpp.toml. When build-time logic is required —
probe the host, generate a source, decide a flag from the environment — put a
build.mcpp in the project root. It is the mcpp analog of Zig's build.zig and
Cargo's build.rs, but written in C++: no second language, and it dogfoods
mcpp itself.
mcpp compiles build.mcpp with the project's toolchain and runs it before the main
build. The program talks to mcpp by printing mcpp: directives to stdout; those
directives augment the build.
// build.mcpp
#include <cstdio>
#include <fstream>
int main() {
// Generate a source the main build will compile + link.
std::ofstream("src/generated.cpp") << "const char* banner() { return \"hi\"; }\n";
std::puts("mcpp:generated=src/generated.cpp"); // add it to the build
std::puts("mcpp:cxxflag=-DHAVE_BANNER=1"); // define a macro for all C++ TUs
if (std::getenv("USE_FAST")) std::puts("mcpp:cxxflag=-DFAST_PATH=1");
std::puts("mcpp:rerun-if-env-changed=USE_FAST"); // re-run me when USE_FAST changes
return 0;
}mcpp build # compiles + runs build.mcpp, then builds the projectPrint these to stdout (one per line). Any line that does not start with mcpp:
is ignored, so diagnostics may be logged freely.
| Directive | Effect |
|---|---|
mcpp:cxxflag=<flag> |
add <flag> to the C++ compile flags |
mcpp:cflag=<flag> |
add <flag> to the C compile flags |
mcpp:link-lib=<name> |
link -l<name> |
mcpp:link-search=<dir> |
add a library search dir (-L; relative dirs resolve against the project root) |
mcpp:cfg=<name> |
define -D<name> for both C and C++ |
mcpp:generated=<path> |
add a generated source to the build. A relative path resolves against the project root for the root package, but against MCPP_OUT_DIR for a dependency's build.mcpp — emit an absolute path if the package is both (see below) |
mcpp:source=<path> (0.0.100+) |
select a pre-existing source file into the build (absolute, or relative to the package root). Same downstream effect as generated=; use it for files the program chose (payload/vendored tree) rather than wrote — e.g. a per-target source selection over a large tarball |
mcpp:include-dir=<dir> (0.0.100+) |
add a private include directory (-I) for this package's own TUs (absolute, or relative to the package root; normalized). Replaces the cxxflag=-I + cflag=-I double emission |
mcpp:include-dir-after=<dir> (0.0.100+) |
like include-dir, but searched after the system directories (-idirafter) — for payload trees that shadow system headers |
mcpp:runner=<token> (2026.8.19.2+) |
one argv token of the command that EXECUTES this build's artifact, when the host cannot. Emitted once per token, in order; the artifact path is appended (or substituted for {}). Reaches the consumer. Emit the executable as an ABSOLUTE path, and only one dependency may supply it |
mcpp:link-flag=<flag> (2026.9.6.5+) |
add a linker flag this program computed, verbatim. The outlet link-lib / link-search / link-script leave open: a generated version script (-Wl,--version-script=), -Wl,--wrap=malloc for a runtime that takes over a C-library symbol, -Wl,--exclude-libs,ALL so a statically absorbed third party does not become part of this package's ABI. Appended after [build] ldflags, in emission order. Reaches the consumer, exactly as [build] ldflags does — see below |
mcpp:link-script=<path> (2026.8.19+) |
link with this linker script (-T; relative resolves against the package root, and the emitted path is absolute because the link runs in the build directory). Reaches the consumer, unlike include-dir — a board's memory layout is the one thing a consumer cannot write for itself |
mcpp:warning=<text> (2026.8.21.2+) |
say something to the user and keep going. The one directive that changes no compile line, no link line and no source set. Survives the build cache — see below |
mcpp:fact=<name>=<version> (2026.9.5.2+) |
state something the program established about the machine (cuda.driver=12.4). Compared against floors before anything is compiled; see below |
mcpp:floor=<name> >= <version> (2026.9.5.2+) |
state what this package needs of that quantity. Unmet ⇒ the build is refused with both values (version-floor-unmet); a floor nobody stated a fact for is silent |
mcpp:rerun-if-changed=<path> |
re-run build.mcpp when this file changes |
mcpp:rerun-if-env-changed=<VAR> |
re-run build.mcpp when this env var changes |
The program requests build edges (flags, libraries, sources). It cannot add a
registry dependency — the dependency graph stays declarative in mcpp.toml
(including platform-conditional [target.windows.dependencies]). build.mcpp
is for leaf decisions: flags, codegen, link requirements.
link-flag is deliberately not private, and the reason is worth stating
because the opposite looks safer. A compile interface has a declarative public
counterpart ([build] include_dirs), so a build-time program widening it would
go behind the manifest's back — hence include-dir's privateness. Link flags
have no such split: [build] ldflags already propagates to consumers, so a
private computed form would behave differently from its own declarative twin.
The consequence is stated rather than hidden. A dependency emitting
-Wl,--version-script= puts it on the consumer's link line too, which is
usually not what that dependency meant. That hazard is not new — a dependency
writing the same flag in [build] ldflags has always done this — so this
directive widens who can compute the value, not what the value can reach.
include-dir/include-dir-after are deliberately private (Cargo
discipline): they color only this package's own TUs and are never propagated
to consumers. An include directory consumers must see is part of the public
interface and belongs in the declarative manifest/descriptor
([build] include_dirs), not in a build-time program.
Instead of printing raw strings, build.mcpp can be written modules-first —
import mcpp;, no #include needed. The mcpp module is bundled in the
mcpp binary (so it always matches that mcpp's protocol) and is compiled on demand;
its functions just emit the directives above:
// build.mcpp
import mcpp;
int main() {
mcpp::cxxflag("-DHAVE_BANNER=1");
mcpp::link_lib("m"); // -lm
mcpp::link_search("vendor/lib"); // -L…
mcpp::define("HAVE_FEATURE"); // == mcpp:cfg= → -DHAVE_FEATURE
mcpp::generated("src/gen.cpp");
mcpp::rerun_if_changed("config.h");
mcpp::rerun_if_env_changed("USE_FAST");
}| Function | Emits |
|---|---|
mcpp::cxxflag(s) / mcpp::cflag(s) |
mcpp:cxxflag= / mcpp:cflag= |
mcpp::link_lib(s) / mcpp::link_search(s) |
mcpp:link-lib= / mcpp:link-search= |
mcpp::define(s) |
mcpp:cfg= (i.e. -D<s>) |
mcpp::generated(p) |
mcpp:generated= |
mcpp::source(p) |
mcpp:source= |
mcpp::include_dir(d) / mcpp::include_dir_after(d) |
mcpp:include-dir= / mcpp:include-dir-after= |
mcpp::rerun_if_changed(p) / mcpp::rerun_if_env_changed(v) |
the matching rerun-* directives |
mcpp::rerun_if_changed_glob(pat) (2026.8.6.2+) |
mcpp:rerun-if-changed-glob= — re-run when the set of files matching pat changes (see below) |
mcpp::dep_bin(pkg, tool) (2026.8.5.1+) |
reads MCPP_DEP_<PKG>_BIN_<TOOL> — the absolute path of a host tool built by a dependency (see below) |
mcpp::link_flag(s) (2026.9.6.5+) |
mcpp:link-flag= |
mcpp::link_script(p) (2026.8.19+) |
mcpp:link-script= |
mcpp::runner(tok) (2026.8.19.2+) |
mcpp:runner= — see below |
mcpp::xpkg_dir(ns, name) / mcpp::xpkg_dir(name) (2026.8.19+) |
the payload directory of a package declared in [xlings.workspace] — by this manifest, or by a dependency compiled into this build program (2026.9.6.6+); "" when it was not declared or is not installed (see below) |
mcpp::warning(text) (2026.8.21.2+) |
mcpp:warning= — see below |
mcpp::action{…}.submit() (2026.8.5.1+) |
mcpp:action= — declares a build-graph node instead of doing the work here (see below) |
A build program's output reaches the user only when the program exits
non-zero: mcpp captures it and prints what it captured on failure. So a
std::printf or std::fprintf(stderr, ...) note is invisible on exactly the
successful builds that needed it.
if (const char* dir = mcpp::xpkg_dir("xim", "qemu-riscv"); dir && *dir) {
mcpp::runner(std::format("{}/bin/qemu-system-riscv64", dir).c_str());
// … the rest of the argv …
} else {
mcpp::warning("qemu-riscv is not installed, so `mcpp run` has no runner. "
"Install it once: xlings install qemu-riscv -y");
}This exists because the alternatives are worse, and both were tried. A
note on stderr printed nothing on a successful build. Exiting non-zero would be
wrong too: mcpp build has no need of an emulator, and failing a build that is
correct trades a missing sentence for a broken command.
Use it for a condition the program handled correctly but the user would want to know about — most often "I could not find X, so I configured nothing that depends on it." For an error, exit non-zero; that output is printed already.
It does not fail the build. mcpp build still exits 0.
It is attributed. The line appears as <package>: <text>, because in a
workspace several programs may speak and the reader needs to know which manifest
to open.
It survives the build cache. A build program's result is cached, and a cache hit does not re-run it — so an advisory that lived only on the run path would appear on a project's first build and never again, which reads as "the condition was resolved". mcpp replays it on every hit.
A whole-project no-op build prints nothing at all, including this. When
there is nothing to do the build never reaches the build.mcpp stage — it also
does not report which target it built or which sources it inferred. Touch a
source and the advisory returns.
A rule package is the thing that knows how to ask a machine what it has — which library to open, which function to call — and the engine is the thing that must not. So the package measures and the engine compares:
mcpp::fact("cuda.driver", "12.4"); // what this machine has
mcpp::floor("cuda.driver >= 12.0"); // what this package needs of itBefore anything is compiled, an unmet floor refuses the build and names the
quantity, both versions and who stated the fact; mcpp why toolchain --format json classifies it as reason: version-floor-unmet. A floor for which nobody stated a fact is
silent: not knowing is not failing, and a refusal manufactured from
ignorance is the worse error.
The failure this prevents is not visible at build time on its own. A program built against a device runtime newer than the driver it will meet compiles cleanly, links cleanly and fails at first use with a message naming neither side. The rule package that resolved the runtime knows both numbers before the first compile.
Neither string means anything to the engine. cuda.driver is data
flowing through; the engine reads a name, a relation and a version, and a
second backend needs no engine change. The spelling of a fact matches what a
package could also have declared statically in [runtime] provides, and a
floor matches [[runtime.requirements]] with kind = "version-floor": the
two channels land in one list.
A fact is cached with the program's other output and replayed on a
cache hit. Declare what would change it — rerun_if_changed on the library
the version was read from — or the fact outlives the machine it described.
A board-support package knows the emulator, its machine model and its firmware mode. It also knows where the emulator IS, which a static manifest cannot: the payload path carries a home and a version.
const char* qemu = mcpp::xpkg_dir("xim", "qemu-riscv");
mcpp::runner(std::format("{}/bin/qemu-system-riscv64", qemu).c_str());
for (auto a : {"-machine","virt","-nographic","-no-reboot","-kernel"})
mcpp::runner(a);The consumer then needs no [target.<triple>] section at all. If it writes one
anyway, it wins — swapping -bios default for -bios none -semihosting
while debugging is a legitimate thing to want — and mcpp says which dependency
it overrode.
Emit the executable as an absolute path. A bare name resolves through
PATH to a shim that dispatches against its own owner home, which is not
necessarily the home this build uses.
Exactly one dependency may supply a runner. Two board-support packages both claiming to know how to run the artifact is a configuration error, and mcpp reports it naming both rather than merging them into an argv that is neither one's.
const char* tc = mcpp::toolchain_dir(); // the resolved toolchain's payload root
const char* sr = mcpp::sysroot_dir(); // the TARGET's C library root, or ""A package that needs headers shipped by the toolchain — libc++'s, for a freestanding standard-library subset — or a file inside the target's C library — a linker script, for a board-support package — asks for the directory rather than declaring a dependency on the thing that provides it.
The difference is not cosmetic. Declaring xim:llvm pins a package to one
standard-library implementation; declaring xim:picolibc-riscv@1.8.12 pins it
to one C library, one architecture and one version. Neither is a property of a
package whose content is implementation-neutral. Asking follows whatever
[toolchain] and --target actually resolved.
sysroot_dir() is empty on a hosted target: there the C library arrives with
the compiler payload or through the runtime binding, and nothing has to look
for it.
const char* sr = mcpp::toolchain_sysroot(); // the `--sysroot` mcpp passes, or ""
const char* bu = mcpp::toolchain_binutils_dir(); // the dir mcpp names with `-B`, or ""A rule package sometimes has to run a compiler mcpp did not resolve. nvcc
rejects a libc++ host compiler and fails inside GCC 16's <type_traits>, so a
CUDA rule package resolves a second host compiler from a declared payload;
hipcc and -fsycl-host-compiler pose the same question.
That compiler starts knowing nothing about the environment it was placed in.
Under a sub-OS the C library is not at /usr/include and the assembler is not
at /usr/bin, so the first #include it reaches fails:
crt/host_config.h:218: fatal error: features.h: No such file or directory
These two answers are the flags mcpp passes to its own compiler for the same
target. Forwarding them — --sysroot=<value> and -B<value>, through whatever
the outer tool spells host options with — makes the second compiler see what
the first one sees.
Not sysroot_dir(). That answers a question about the target's tier
and is empty on a hosted target, which is exactly the case this pair exists
for. Either of these two is empty when mcpp passes no such flag.
const char* impl = mcpp::cxx_stdlib(); // "libstdc++" | "libc++" | "msvc-stl" | ""compiler() does not answer this. clang links libc++ on one machine and
libstdc++ on another and reports clang in both cases, and the two
implementations differ in what they accept — a unique_ptr to an incomplete
type destroyed in a header compiles under libstdc++ and does not under libc++.
A build program that must refuse such a configuration by name cannot ask
compiler(), because that answer would also refuse the configuration that
works:
if (std::string_view(mcpp::cxx_stdlib()) == "libc++") {
std::fprintf(stderr,
"this feature does not compile under libc++; select a libstdc++ "
"toolchain, or turn the feature off\n");
return 1;
}cxx is in the name because MCPP_TARGET_LIBC is the C library. The two are
different questions and, in an ecosystem that names glibc and musl constantly,
must not share a word.
dep_dir answers for mcpp dependencies. An xlings package is a different
namespace with a different store layout, and xpkg_dir is the interface for it:
// mcpp.toml
// [xlings]
// deps = ["xim:picolibc-riscv@1.8.12"]
const char* sysroot = mcpp::xpkg_dir("xim", "picolibc-riscv"); // exact
const char* same = mcpp::xpkg_dir("picolibc-riscv"); // bare nameThe namespaced form answers only for a package declared under that namespace
and is the one to prefer; the bare form is a convenience for the common single
declaration, and when two namespaces claim one name it answers for the first
declared. Both return "" when the package was not declared or is not
installed — a program that needs it should say so itself, because only it knows
whether the absence is fatal.
It is an interface rather than a documented path because the alternative is a
build program encoding <home>/data/xpkgs/<ns>-x-<name>/<version>, which is
store internals mcpp is free to change — the same reason dep_dir exists.
A pinned reference resolves to exactly that version or to nothing. A
build that asked for 1.8.12 and silently got 1.9.0 is an answer only
discovered later, in the artifact.
A constrained one (>=8.5.0, ^1.2) resolves to the highest installed
version satisfying it (2026.9.6.6+). Before that release the whole version
position was compared against a directory name, so a range installed a payload
and then answered that nothing was installed — which is why a rule package
could not state a floor and every project repeated its rule's package list.
A package a DEPENDENCY declared is answered too (2026.9.6.6+), at the version this build actually installed rather than the one the local manifest wrote. One package means one version: where a project and a rule both name it, the declaration nearer the artifact wins and both sides are told the same answer. See One package, one version in 23 — The Project Environment.
[feature-xlings.<f>] is answered too, while <f> is active
(2026.9.6.2+). That table has provisioned its packages since it existed --
naming one downloads and installs it -- but the build program's environment was
filled from [xlings.workspace] alone, so xpkg_dir returned "" for a
payload that was on disk. The only sensible thing a program can print then is
"declare this package", naming a declaration its author had already written.
Declare the need in mcpp.toml, then call it:
[dependencies]
protobuf = { version = "35.1", tools = ["protoc"] }// build.mcpp
import mcpp;
int main() {
const char* protoc = mcpp::dep_bin("protobuf", "protoc");
// … invoke it, then declare what it produced …
}mcpp builds that kind = "bin" target for the build machine (even under
--target), caches it globally, and returns the path. The request lives in
mcpp.toml rather than here for the same reason a dependency does: asking the
graph for an extra artifact is a graph-level request, and the graph stays
statically analysable. See Host tools from a dependency in this chapter for the full contract,
including [tools.overrides] and reexport = true (which is how a library
provides the whole toolchain, so a project declares one dependency instead of
four).
The re-run key is built from declared inputs. Declare files and it works;
glob a directory and it does not — adding a .proto changes no declared file's
hash, so the program never re-runs and the new file is silently never
generated. rerun_if_changed_glob is how a program says "my output depends on
which files are here":
import mcpp;
int main() {
mcpp::rerun_if_changed_glob("proto/**/*.proto");
// … scan the directory, declare one action per file …
}The pattern is relative to the manifest directory and uses the same * / **
grammar as sources = [...]. Its fingerprint is the sorted set of matching
paths and nothing else:
- not contents — a file whose bytes matter is an ordinary
rerun_if_changedinput, which already hashes them; - not mtime or size — mtime is unstable across
git checkout, container builds andrsync, and size is a weaker signal than the hash above.
The build output tree and .git are never part of the set, so a wide pattern
cannot make the program re-run forever against its own outputs.
Every declared input is compared on the fast path too (2026.9.5.4+). A
project whose sources are all older than build.ninja takes a fast path that
skips the phase where the program's cache is normally consulted, and until
2026.9.5.4 that path asked only about glob path sets. A data file a program
reads is neither under src/ nor named with a C++ extension, so the mtime
sweep cannot see it either: editing it left the previous run's output in place
and the build reported Finished dev in 0.00s. The fast path now compares what
the cache records — a glob's path set, a declared file's content hash, and a
declared environment variable's value — so rerun_if_changed means the same
thing under both paths.
Generating a source by writing it here is the easy path and the wrong one past a certain size: it happens once per prepare, for the whole set, serially, and a failure is reported as "build.mcpp exited 1". Declare the work and it becomes an edge in the build graph — incremental, parallel, and attributable to the edge that failed.
import mcpp;
int main() {
const std::string out = std::string(mcpp::out_dir()) + "/foo.pb.cc";
mcpp::action a;
a.id = "protoc:foo";
a.role = "source"; // "source" | "check" | "object" | "artifact"
a.arg(mcpp::dep_bin("protobuf", "protoc"))
.arg("--cpp_out=...").arg("proto/foo.proto")
.input("proto/foo.proto")
.output(out.c_str())
.submit();
}Four roles, one primitive — role only decides where the edge's outputs
attach:
role |
Outputs | Ordering | Typical |
|---|---|---|---|
source |
compilable ones join the compile set; the rest are produced but not compiled | every compile edge of the declaring package waits for them | protoc, a transpiler, a protocol/IDL generator |
check |
a stamp file, written by mcpp | runs alongside compilation; blocking = true makes the package's compile edges wait for it |
clang-tidy, a format or ABI check |
object |
join the link set | the link edge consumes them | a resource compiler, objcopy embedding a blob, a generated .def, a pre-built .o |
artifact |
a new file | its inputs are link outputs, so it runs after the link | codesign, packaging, size budgets |
No phase machinery is involved. object and artifact are sequenced by
ninja's own file dependencies — which is also why an artifact action cannot
double-apply itself the way a naive "post-build hook" would. source and a
blocking check are sequenced by an order-only edge from the declaring
package's compile edges to that package's action outputs.
Why
sourceneeds the edge (mcpp 2026.8.30.2+). A generated.cppbecomes an input of the edge that compiles it, so it was ordered for free. A generated header never does: it is reached through-I, and the depfile that would record it does not exist until a compile has already succeeded. Before this, an action whose outputs were all headers had a node inbuild.ninjathat nothing could reach — notdefault, not the goal set, no consuming edge — so it never ran, and what the compiler read was the empty placeholder mcpp writes for a declared output. The ordering is per package, becauseinclude_dircolours only the declaring package's own translation units.
An action whose command discovers its own dependencies declares a depfile
(mcpp 2026.9.7.1+). input() fixes the edge's inputs when build.mcpp runs,
before the command has executed, so a compiler that learns its #include graph
by parsing the source has no channel to report it — and editing a file the
command merely read reruns nothing, leaving mcpp build green over a stale
artifact.
a.depfile = dep.c_str(); // a path the command writes
a.arg("--depfile").arg(dep.c_str());mcpp emits depfile = and deps = gcc for that edge, so ninja reads the file
and folds what it names into the edge's dependencies. Every device compiler this
matters for already emits one: glslangValidator --depfile, glslc -MD -MF,
slangc -depfile, nvcc/clang -MD -MF.
Do not also declare the depfile as an
output().deps = gccmakes ninja consume and delete it after reading, so an edge that promised it as an output would be permanently dirty.
A check's command does not have to write its stamp (mcpp 2026.8.29.1+). The verdict is the exit code; the stamp is bookkeeping the graph needs, and mcpp creates it when the command succeeds. Before this, every check needed a wrapper script to touch the file — and a command is an argv with no shell assumed, so that wrapper could not be written portably at all. A command that already writes its own stamp is unaffected: an existing file is left alone.
A missing stamp does not fail the build. ninja leaves the declared output absent and re-runs that edge on every build afterwards, which looks like a passing check that is quietly never satisfied.
object (2026.8.7.1+) takes an optional .target("name"), repeatable. It needs
a name at all because, unlike artifact, it runs before the link and so has
no ${mcpp.target_file:…} to infer one from; every name that matches no link
unit is an error, including one written next to a name that does match.
Prefer omitting it. With no target, the outputs attach to every image the
declaring package produces in this build — binary, shared library and test
binary. Test binaries are in that set because they link the same library code:
leave them out and mcpp build succeeds while mcpp test dies with undefined symbol on the very symbol the action exists to provide. Naming them instead is
not an option — test link units are discovered from tests/*.cpp, so their
names are not in mcpp.toml, and a build.mcpp that spells one stops building
under plain mcpp build, where that unit does not exist.
If nothing in the build can receive the outputs (an archive-only package), mcpp reports a degradation: the edge is reachable only through a link, so with no link the command would never run and the build would say nothing.
Naming a pre-built object in
[build].ldflagsalso reaches the linker, and should not be used for anything the build produces: ldflags is a flat string in the link command, not a file in the graph, so nothing tracks it and editing it reportsninja: no work to do. For Windows resources specifically, use[resources]—objectis the escape hatch for everything else.
You must name the output files. mcpp fixes the source set, the fingerprint and the module graph during prepare, so an output whose name is unknown cannot be built. Content may arrive later; names may not. A malformed action is a hard error, never a silent skip.
For a generated module interface, declare its interface too:
a.output(gen.c_str()).provides("my.generated").imports("std").submit();mcpp seeds a placeholder carrying exactly that declaration so the prepare-time
scan agrees with what the generator will emit — the same assertion-plus-
verification trade [modules].scan_overrides makes, and the compiler's own
P1689 output checks it at build time.
Commands are an argv, not a shell string (no shell is assumed — Windows has none to rely on), and the only interpolations are a closed set:
| Variable | Value |
|---|---|
${mcpp.out_dir} |
the build output directory |
${mcpp.bin_dir} |
where produced binaries land |
${mcpp.compile_db} |
path to compile_commands.json (what clang-tidy's -p wants) |
${mcpp.target_file:<name>} |
the built file of target <name> |
The raw stdout protocol above remains the low-level substrate; import mcpp;
is the typed layer over it.
Two ways to talk to mcpp, and they carry different compatibility promises:
import mcpp; |
hand-written printf("mcpp:…") |
|
|---|---|---|
| Compatibility | The module is bundled in the mcpp binary and recompiled by the mcpp that runs it, so program and engine can never disagree | Your string is frozen text; nothing checks it against the engine |
| New directives | Arrive as new functions | Will not be added |
| Unknown directive | Hard error | Warning, then ignored |
Programs using import mcpp; automatically announce the protocol version they
were built against (mcpp:protocol=<N>, emitted before main runs — it never
write it yourself). mcpp uses that two ways:
- A program announcing a newer protocol than mcpp understands is refused, with an upgrade hint. Continuing would silently drop directives the build depends on — and "the build succeeded but the flag never arrived" is the worst class of build bug.
- An unrecognized directive is an error rather than a warning, and the error names both possible causes. It cannot name one: the protocol number is stamped by whichever mcpp compiled the program, not carried by the package, so a package written for a newer mcpp arrives at an older one wearing the older engine's number. Two matching numbers therefore say nothing about whether the key came from the future.
A printf-style program announces nothing, so it keeps the historical
warn-and-ignore behaviour. That surface is frozen at the eleven directives in
the table above — it still works and will keep working, but new capabilities
land only in the typed API. Prefer import mcpp; for anything intended to
maintain.
When a published package calls a typed function this mcpp does not have, the compile error naming it is followed by:
The `mcpp` build module this engine bundles does not have that name.
Either the package was written for a newer mcpp (try `mcpp self update`;
this is mcpp 2026.8.19.2), or the name is misspelled …
The package cannot handle this itself, and it is worth knowing why — the obvious guard does not compile:
if constexpr (requires { mcpp::runner("qemu"); }) // hard error when absent
mcpp::runner("qemu");A requires-expression over a qualified name that does not exist is
ill-formed, not false. So there is no in-language feature probe, and a
package that adopts a new directive states its floor in prose (its README) and
relies on the diagnostic above. Such a package should name the mcpp version it
requires.
A build.mcpp may import std; (and import std.compat;), alone or together
with import mcpp;:
// build.mcpp
import std;
import mcpp;
int main() {
for (auto const& f : std::vector<std::string>{"FOO", "BAR"})
mcpp::define(f.c_str());
}mcpp stages the same std module its own build uses, keyed on
(toolchain × standard × dialect) — so for an ordinary build this costs
nothing, the artifact is already there. A cross build (--target …) pays for
one extra std module, because build.mcpp compiles and runs on the host
while the project targets something else.
#include still works and stays the right choice for a program that only
needs std::fopen; there is no requirement to modularize a build script.
Every toolchain mcpp can build a host program with can build a build.mcpp,
including native MSVC — the module handling reads the same tables the main
build does, so cl.exe's .ifc + /reference needs no separate support.
The running program receives the build context as MCPP_* variables
(Cargo's env-family equivalent), also exposed through typed readers:
| Variable | Typed reader | Value |
|---|---|---|
MCPP_TARGET |
mcpp::target() |
resolved canonical triple (the --target triple under cross; the host triple natively) |
MCPP_TARGET_OS (0.0.100+) |
mcpp::target_os() |
the target's OS segment (linux/macos/windows) — no need to hand-split MCPP_TARGET |
MCPP_TARGET_ARCH (0.0.100+) |
mcpp::target_arch() |
the target's arch segment (GNU spelling: x86_64, aarch64, …) |
MCPP_TARGET_ENV (0.0.100+) |
mcpp::target_env() |
the target's env segment (gnu/musl/msvc); empty string when the triple has none (macOS) |
MCPP_HOST |
mcpp::host() |
the host triple |
MCPP_PROFILE |
mcpp::profile() |
effective profile name (dev/release/…) |
MCPP_TOOLCHAIN_SYSROOT (2026.9.5.2+) |
mcpp::toolchain_sysroot() |
the --sysroot mcpp passes to its own compiler; empty when it passes none. For a rule package that runs a second compiler — see "Driving a second compiler" above |
MCPP_TOOLCHAIN_BINUTILS_DIR (2026.9.5.2+) |
mcpp::toolchain_binutils_dir() |
the directory mcpp names with -B; empty when it names none (a musl or MinGW payload brings its own assembler and linker) |
MCPP_CXX_STDLIB (2026.9.6.3+) |
mcpp::cxx_stdlib() |
the C++ standard library the resolved toolchain uses — libstdc++, libc++, msvc-stl; empty when no toolchain resolved. A different question from MCPP_TARGET_LIBC, which is the C library |
MCPP_ACCEL (2026.9.5.2+) |
mcpp::accel() |
the device axis of this build, resolved — --accel / --no-accel over [build] accel — in the wire form cuda12.9+{sm_89} ptx>=89; empty when the build asks for no accelerator. A rule package derives its own flags (-gencode, --offload-arch) from it, so the architecture set is written once, in the manifest. The same value feeds the cfg(accelerator = "…") layer key |
MCPP_LANGUAGE_MODULES (2026.9.7.1+) |
-- | 1 when the declaring package sets [language] modules, 0 otherwise. A rule that GENERATES a consumer-facing declaration reads it to choose between a module interface and a header, so a project states that once and never again. An older engine leaves it absent, which a rule reads as 0 -- the behaviour every consumer had before the variable existed |
MCPP_PKG_NAME (2026.9.7.1+) |
-- | The [package] name of the package this program builds. Every name a rule generates is derived from it: the module a consumer imports, the namespace the accessors sit in, the symbols in a generated header. Before it existed the closest available answer was the leaf of MCPP_MANIFEST_DIR, which is a directory name -- so a package named vulkan-saxpy in a directory named app generated app.shaders, and every <something>/app/ in a workspace claimed the same module. Absent under an older engine, which a rule reads as a signal to keep its previous derivation |
MCPP_PKG_NAMESPACE (2026.9.7.1+) |
-- | The [package] namespace. Empty when the package declares none. A rule that must produce a name unique across an index uses the pair rather than the name alone, because package identity is (namespace, name) |
MCPP_DEVICE_SOURCES (2026.9.5.2+) |
mcpp::device_sources() |
the device-kind sources (.cu, .hip, …) the package's effective sources match, package-root-relative, one per line; empty when there are none. The engine compiles none of them — the rule package this program imports turns each into an mcpp::action. Already narrowed: a { glob, accel } entry the build does not cover contributes nothing, so --no-accel yields an empty list |
MCPP_OUT_DIR |
mcpp::out_dir() |
a writable scratch/output dir owned by mcpp |
MCPP_MANIFEST_DIR |
mcpp::manifest_dir() |
the package root (= CWD) |
MCPP_FEATURE_<NAME> |
mcpp::has_feature("name") |
set to 1 per active feature (same <NAME> sanitization as the MCPP_FEATURE_ compile macro) |
MCPP_FEATURES |
— | comma-separated active feature list |
MCPP_DEP_<NAME>_DIR |
mcpp::dep_dir("name") |
the resolved install dir of each declared dependency (canonical and namespace-stripped name spellings; same <NAME> sanitization as MCPP_FEATURE_). Received by dependencies' build.mcpp and the root project's (the root runs after dependency resolution, 0.0.100+) |
These values are folded into the re-run key unconditionally — changing the
target, profile, or feature set re-runs the program without any
rerun-if-env-changed declaration.
A project that declares [xlings].subos runs its build programs with that
environment's bin at the front of PATH:
PATH=<the declared environment's bin>:<the PATH mcpp itself was started with>
so a bare command name in a build program resolves inside the environment the project named, on every machine that builds it.
Only for projects that declare one. A project with no [xlings].subos
gets the PATH mcpp was started with, byte for byte. A shared directory in
front of every project would make what a build sees depend on what else had
been installed on that machine — two projects on one machine would agree with
each other, and the same project on two machines would not.
Why it is a prefix and not a replacement: a build program legitimately calls
git, python3 or a shell, none of which live in a sub-OS. Front position
makes the declared environment the default answer; the host stays reachable
behind it.
command -v answers about the machine, not about this build. Before
this, a program asking PATH for a declared tool could get an unrelated one —
measured on qemu-system-riscv64, where the answer was a shim that reports
"is not installed in this subos" when executed, while a working copy sat in the
project's own environment and was not on PATH at all.
The selection is the one chapter 8 already
describes — the same declaration that decides which C library the project links
against, delivered to one more consumer. See
chapter 17 for what a declared environment is
and when to want one; examples/07-project-subos/ is a working project.
A rule — "run protoc over these .proto files", "run clang-tidy over these
sources" — belongs in a package, not copy-pasted into every consumer's
build.mcpp. The mechanism is
host-module = true; this section is about the shape of
what goes inside.
The guidance below generalises from mcpplibs.grpcgen, the first such package,
with each of its traits judged individually. It is guidance and not a rule
because none of it admits a criterion the engine could check.
Every device source must reach an action (2026.9.5.2+ contract, enforced
from 2026.9.6.5). A device-kind file is the one source the engine has no
compile rule for: it is handed to the package's build program through
MCPP_DEVICE_SOURCES and comes back as an action, or it is not compiled at
all. mcpp refuses a build in which one did not, naming the files:
error: `opkit`: device sources that no action compiles:
src/backends/cuda/saxpy.cu
src/backends/vulkan/saxpy.comp
The criterion is the action inputs, not that a build program ran: a program
that ran and claimed nothing is the common case, because a rule takes the
extensions it knows and leaves the rest. It is also the condition an action
needs anyway — one that compiles a file it does not declare as an input does
not rerun when that file changes — so a rule that satisfies it is a rule that
rebuilds correctly. What it replaces is an undefined reference at the link
naming a symbol and never the file, and for a kind = "lib" target not even
that, because an archive is not resolved.
A rule takes the extensions it claims. mcpp::device_sources() is the
package's whole device set, and every rule in one build program reads the same
value. A project with two backends puts a .cu and a .comp in that one list,
so a rule that consumes all of it hands its compiler a file the compiler does
not accept. A rule selects by extension, and returns without complaint when
this build names no backend it serves — a build program with several rules
calls them all.
An import nothing provides is refused by name. A build program may import
std, std.compat, the bundled mcpp, and the host modules its dependency
edges asked for. Anything else is refused before the compiler is reached, with
the key that would have made it importable:
error: build.mcpp imports 'mcpp.rules.spirv', and no dependency provides it
as a host module.
...
[build-dependencies.<namespace>]
<name> = { version = "...", host-module = true }
declared without `host-module = true`: mcpp.plugins (in [build-dependencies])
The module name is declared by the rule's source, and mcpp.* is reserved.
A host module is registered under the name its interface unit declares, not
under the package name, so export module mcpp.rules.spirv; is what a consumer
then imports. Official plugins live in one package, mcpp:plugins (repository
mcpp-community/mcpp-plugins): rule packages are named mcpp.rules.<x>,
build-time utilities mcpp.tools.<x>, and each member is selected by a feature
of that package (see host-module = true). mcpp.build.*
is the engine's own module family and is not used for plugins. The engine
cannot tell who is official, so it keys the check on the package namespace
and warns when the two disagree —
warning: build rule 'mcpplibs.plugins' declares the module
'mcpp.rules.spirv'; the 'mcpp.' prefix is reserved for rules maintained by
the mcpp project.
Nothing breaks; the name claims an origin the package does not have. A rule outside the project picks its own prefix.
A tool is not a rule. A rule states how a translation unit is compiled by a
compiler mcpp does not drive: it submits an action and the engine schedules it.
A tool states something the build program needs that no compiler performs, and
does it while the program runs. mcpp.tools.embed (feature tools-embed,
mcpp 2026.9.5.4+) is the first: it writes a data file into a header the program
compiles in, as a byte array or a 32-bit word array, and rewrites nothing when
the content is unchanged, so calling it unconditionally costs no rebuild.
examples/09-heterogeneous/cuda and
examples/09-heterogeneous/vulkan consume mcpp.rules.cuda and mcpp.rules.spirv
from mcpp:plugins, the way any project does.
Layers must not have a cliff, and each layer must be the composition of the
one below it. generate_all(opt) is submit(plan_all(opt)), and
.grpc = true is .plugins = {cpp()}. Past two knobs a consumer who cannot
descend writes sixty lines by hand to work around the rule, and those sixty
lines then drift away from it silently.
Expose a plan/submit pair. The bottom layer has to hand back the planned edges so a consumer can modify them and submit them again. This is the mechanism that makes the previous paragraph true; it is not a naming preference.
Do not reproduce truth the engine already holds. mcpp writes every action's
full argv into build.ninja, recoverable with ninja -t commands. A second
source of that would only drift. What a rule owns is the other half — which
knobs produced the command — and that belongs in each edge's description.
Failure and advice use different channels. mcpp prints what it captured
from a build program only when the program exits non-zero, so a failure writes
to stderr and returns non-zero. A message that must be seen on a successful
build has to go through mcpp::warning;
stderr on success is discarded, which means the wrong channel is silent on
exactly the builds that needed the message.
One (name, version) names one payload. mcpp identifies an installed
package by that pair, so a repackaged rule that keeps its version string does
not trigger a reinstall and the consumer keeps running the old rule with no
diagnostic. Which numbering scheme to use is the author's call — versioning in
lock-step with the wrapped tool is legitimate when the two ship from one tag,
and tells a consumer something true — but a payload change is a version change.
Test it through a consumer. A rule is consumed only by a build.mcpp, so
compiling it proves nothing. Its test is an example project that depends on it,
builds, and asserts on the produced artefact.
A dependency that ships a build.mcpp gets it compiled and run too (the
Cargo build.rs model — building a package means trusting its build program),
after its features are resolved and before the source scan. Scope follows
Cargo: cxxflag/cflag/cfg directives color only that package's own
TUs; link-lib/link-search reach the final link. Its artifacts (binary,
cache, MCPP_OUT_DIR) live in the consuming project's
target/.build-mcpp/deps/<pkg>@<ver>/ — a registry package root is shared
across projects (and may be read-only), so it is never written to; relative
generated= paths resolve against MCPP_OUT_DIR, not the package root.
Those two rules — project root for the root package, MCPP_OUT_DIR for a
dependency — mean a relative generated= cannot be right in both roles. A
library is built standalone by its own CI and consumed from the registry by
everyone else, so it plays both.
Writing into MCPP_OUT_DIR and emitting the bare filename works as a
dependency and fails at the root with:
error: build.mcpp declared generated source 'foo.cppm' but it does not exist after the run
Write to MCPP_OUT_DIR (the package root may be read-only) and emit the
absolute path:
const auto out = std::filesystem::path(mcpp::out_dir()) / "foo.cppm";
// ... write it ...
mcpp::generated(out.string().c_str());mcpp::out_dir() is always absolute, so this is correct in both roles and
needs no branch on which of the two applies.
A generated module interface is fine here: .cppm goes through the same
scan as any other source, so a generated file declaring export module … can
be imported by the package's own TUs.
mcpp does not re-run build.mcpp on every build. It caches the program's
directives and re-runs only when something it depends on changed:
- the
build.mcppsource itself, - the toolchain,
- any file declared with
rerun-if-changed, - any env var declared with
rerun-if-env-changed, - (or a
generatedoutput /source=selection went missing), - (or the cache was written by an mcpp that interpreted a directive differently — the entry carries a format epoch, and a foreign one re-runs the program once instead of replaying values under the wrong meaning).
So declare the inputs: if the program reads config.h or the USE_FAST
variable, emit mcpp:rerun-if-changed=config.h / mcpp:rerun-if-env-changed=USE_FAST.
This replaces the old "process exited 0, so assume it's fine" guesswork with an
explicit input/output contract — incremental builds stay correct.
When nothing changed the output is build.mcpp up to date (cached); otherwise
build.mcpp compiling / running.
A package can build a binary its consumers need at build time — protoc, a
grpc_cpp_plugin, flatc, moc, a transpiler. Ask for it on the dependency:
[dependencies]
protobuf = { version = "35.1", tools = ["protoc"] }
grpc = { version = "1.83.0", tools = ["grpc_cpp_plugin"] }Each name must be a kind = "bin" target of that package. mcpp builds it for
the build machine and hands build.mcpp its absolute path as
MCPP_DEP_<PKG>_BIN_<TOOL> — read it with mcpp::dep_bin("protobuf", "protoc")
(see 30 — build.mcpp).
Four properties worth knowing:
- Always a host binary. Under
mcpp build --target <triple>the tool is still built for this machine, because a code generator has to run here. It is a separate, host-targeted sub-build — the tool package's own[toolchain]and its own dependency resolution apply, and none of it has to agree with the consuming build. That is safe precisely because an executable has no ABI contact with the consuming code. - One version axis. The tool's version is the dependency's version, so
a
protocthat does not match its runtime is not expressible. (This is the problem with packaging the tool separately, and it is the failure mode that bites at run time rather than compile time.) - Default off. Nothing is built unless someone asks; the cost is the
consumer's to pay. A package gates the expensive part with
[features]+required_features(protobuf'sprotocneeds libprotoc's ~157 extra TUs, which the runtime's users must not compile). - Cached globally, keyed on package version × host toolchain × features × its own dependency closure — built once per machine, not once per project.
The key holds no source content, and for a path dependency that is visible.
A published version is immutable, so for a tool that arrives from an index the
key is exact. A tool being edited next door has the same version from one build
to the next, and the cached binary stays: measured on
examples/12-a-new-device-language,
a change to the tool's emitter left mcpp run printing the previous answer,
while bumping the tool package's version rebuilt it and changed the artifact.
Bump the version, or empty the build cache with mcpp cache clean — the tool
store lives inside it, at <mcpp cache dir>/tool/<index>/<name>@<version>/.
This is a gap in the rebuild, not in the tracking. An action that declares the tool among its inputs does re-run when that file's bytes change, measured by overwriting the binary in the store: the artifact followed. What does not happen is the rebuild that would change those bytes.
[tools.overrides]
"compat.protobuf:protoc" = "/usr/bin/protoc"or, without editing the manifest (CI, distro packaging):
MCPP_TOOL_PROTOBUF_PROTOC=/usr/bin/protoc mcpp buildAn override skips the build entirely. Every comparable system provides this
escape hatch (vcpkg's VCPKG_HOST_TRIPLET, CMake's LLVM_NATIVE_TOOL_DIR,
Qt's QT_HOST_PATH), and for the same reason: a tool that cannot be built from
source on this machine must not be a dead end. It is deliberately not part
of the cache key — an override is an escape hatch, not a reproducible input.
A rule (say "run protoc over these .proto files") should be written once, not
copy-pasted into every consumer's build.mcpp. Ship it as an ordinary mcpp
library package and import it:
[dependencies]
protobufgen = { version = "0.1.0", host-module = true }// build.mcpp
import mcpp;
import protobufgen;
int main() { return protobufgen::generate({"schema"}) ? 0 : 1; }mcpp compiles that package's lib-root module for the host, in the same
command as build.mcpp — which is what makes the BMI usable at all, since a
module interface is only importable by a compile that agrees with it on
standard, dialect and compiler identity.
Rules are therefore versioned, testable and distributable through the package
manager already in use, written in C++ — no second language, which is the
whole point of build.mcpp existing.
The module name is what the rule's source declares (mcpp 2026.8.29.1+).
export module acme.rules.protobuf; is imported as acme.rules.protobuf,
whatever the package is called. Module names are authored API and do not mirror
package identity — the rule ordinary library packages have always followed.
Until 2026.8.29.1 the host-module path registered the bare package.name
instead, which made a divergent name build under GCC and fail under Clang and
MSVC: GCC's BMIs are implicit under gcm.cache and keyed by the declared name,
while the other two are handed an explicit <name>=<bmi> mapping. Package names
carry no C++ naming constraint as a result, and grpc-rules is a legal package
name again.
Two rules may not declare one module name. import addresses the module,
so two such packages are indistinguishable to the compiler, and their BMIs and
objects share a filename — the second overwrites the first and the surviving
object reaches the link twice. mcpp refuses this, naming both packages and both
interface paths. The check covers the rules one build.mcpp can see; it is not
an index-wide uniqueness rule, which path dependencies and private registries
would escape anyway.
mcpp. is reserved for rules maintained by the mcpp project. A module name
under that prefix from a package outside the mcpp namespace produces a
warning naming both, and the build proceeds. It is a warning because the engine
cannot decide who is official: a path dependency, a private mirror and an
internal fork are all legitimate and indistinguishable from here.
The lib root must be at src/<name>.cppm (or wherever [lib] path points); a
missing one is reported as "host module 'x': no interface unit at …".
A package may offer several rules, selected by features (mcpp 2026.9.5.3+).
Every module interface unit among the package's resolved [build] sources —
including the sources a feature adds — is compiled as a host module under the
name it declares, the lib root first. A feature unit may import the lib root;
units are otherwise compiled alone, so they import std, mcpp and nothing
else. Only listed sources take part: the inferred src/** of a package that
declares no sources is not consulted, so a rule package published before this
release exposes exactly what it exposed then.
# the collection's manifest
[build]
sources = ["src/plugins.cppm"] # export module mcpp.plugins;
[features]
rules-cuda = { sources = ["rules/cuda.cppm"] } # export module mcpp.rules.cuda;
rules-spirv = { sources = ["rules/spirv.cppm"] } # export module mcpp.rules.spirv;# a consumer
[build-dependencies.mcpp]
plugins = { version = "0.3.0", features = ["rules-spirv"], host-module = true }[build-dependencies], not [dependencies] — a rule package is the case
04 §2.6.1 describes exactly: its library must never reach the target while its
rule is still wanted. The two axes are separate, so host-module = true says
which build-time product is wanted and the section says whether the package
reaches the target; a rule package answers "no" on the second axis, and the
section is where that is said. Written in [dependencies] it still works, and
that is precisely why the distinction has to be stated rather than enforced by
a failure.
The module set is the feature set: a unit whose feature is not active is not
compiled, and importing it fails as an unknown module. mcpp:plugins is the
collection the mcpp project maintains (repository mcpp-community/mcpp-plugins);
its members are named mcpp.rules.<x> for rule packages and mcpp.tools.<x>
for build-time utilities.
Build-time only: a host-module = true dependency is not compiled into
or linked with the target, and neither is anything it depends on. It exists to
run during build.mcpp and nowhere else. (Before 2026.8.5.2 it was also built
as an ordinary library, which made import mcpp; inside a rule fail: the
bundled module does not exist in that second compile. Until 2026.8.29.1 the
rule itself was excluded but its own [dependencies] were not, so they were
compiled and linked into the consumer's binary while the rule could not import
them.)
A rule declares what it needs in its own [build-dependencies], and may import
any entry there marked host-module = true:
# inside the rule package's manifest
[build-dependencies]
globbing = { path = "../globbing", host-module = true }// the rule's own interface
export module tidyrule;
import std;
import mcpp;
import globbing;mcpp compiles the inner rule first, in the same command and with the same flags, so BMI agreement stays structural rather than checked.
The consumer may not import globbing: build-time provisions cross one
further edge only on a reexport = true edge, and mcpp enforces that rather
than leaving it to the compiler, which on GCC would allow the import and then
fail on someone else's machine.
Limit: one interface unit per host module. A library with implementation units or several modules cannot yet be a rule's build dependency.
Everything above is declared by whoever uses the tool. That is the wrong
place when the knowledge belongs to a library: gRPC's code generation needs
protobuf's protoc, and no user of a gRPC package should have to know that.
reexport = true hands an edge's build-time provisions — its tools, its
host-module, and the dependency's directory — to this package's own
consumers:
# inside the grpc package's manifest
[feature-deps.codegen]
"compat.protobuf" = { version = "35.1", tools = ["protoc"], reexport = true }
grpc-plugin = { version = "1.83.0", tools = ["grpc_cpp_plugin"], reexport = true }
grpcgen = { version = "1.83.0", host-module = true, reexport = true }Its user then writes one line, and imports the rule:
[dependencies]
grpc = { version = "1.83.0", features = ["codegen"] }// build.mcpp
import mcpp;
import grpcgen;
int main() { return grpcgen::generate_all() ? 0 : 1; }- Off by default, and deliberately not the edge's
visibility.visibilityalready defaults to"public", so riding it would let any dependency at any depth put entries into the build program's tool namespace without saying so. Handing something to consumers is a supply-chain statement; it has to be written down. - One hop per declaration. A re-exported provision reaches the consumers of the package that declared it. For it to travel further, the next package must re-export in turn — each package decides only what it hands on.
- A feature may add a request to an already-declared dependency. gRPC
depends on protobuf unconditionally and its
codegenfeature addstools = ["protoc"], reexport = trueto that same edge.toolsandfeaturesunion,host-moduleandreexportOR together;version/path/gitdo not merge, so a feature still cannot silently override the unconditional entry's identity. - Visibility, not execution.
dep_bin()returns a path; whether anything runs is still the consumer'sbuild.mcpp's decision. Nothing changes about who builds the tool or how the tool store is keyed. - Unqualified names are resolved by a ladder, not by luck. Once two
libraries can re-export, both may offer the tail
protobuf. The fully-qualifiedMCPP_DEP_<NS>_<NAME>_BIN_<TOOL>is always published; the bare spelling is bound tomcpplibs.<x>, elsecompat.<x>, else an unnamespaced<x>, else the single remaining candidate — and when it is contested mcpp says so instead of picking silently.
An unrecognized dependency key is reported as a degradation and ignored (mcpp 2026.8.6.2+), so a package written for a newer mcpp still loads and the parts this reader understands still apply. Before that release it was a hard load failure with a misleading message, which is why a published package could not adopt a new key at all — the same property the index floor establishes: data must not decide whether the program works.
Consequently a package that relies on reexport for its ergonomics still
needs a client new enough to implement it; what changed is that everything else
about that package keeps working on an older one.
A package may declare a bin target on some platforms only. Because the
library now decides what is requested, an unconditional request turns an
unsupported platform into an error its user cannot edit away. Scope it:
[target.'cfg(not(windows))'.feature-deps.codegen]
"compat.protobuf" = { version = "35.1", tools = ["protoc"], reexport = true }[target.<sel>.feature-deps.<feature>] (2026.8.6.2+) follows the same rules as
the other conditional dependency tables (22 — The Target Side). The feature itself is
registered on every platform — only what it pulls in is conditional — so
requesting it where no predicate matches is not an unknown-feature error.
-
Runs on the host — including under cross (mcpp 0.0.95+). Under
mcpp build --target <triple>the program is compiled with a host-resolved toolchain, runs on the host, and seesMCPP_TARGET= the cross triple. For purely declarative target gating,[target.'cfg(...)']tables remain the first choice — see 04 - mcpp.toml Manifest Guide. -
CWD is the project root, so relative paths (
src/generated.cpp) land where expected. -
A non-zero exit from
build.mcppaborts the build and prints its output. -
The run is bounded (mcpp 2026.8.5.1+): a build program gets 600 s by default, after which mcpp kills it and fails the build naming the package. Configure it per package:
[build] build_program_timeout = 1800 # seconds; 0 = no limit
Precedence, highest first — the same shape
macos_deployment_targetuses:MCPP_BUILD_PROGRAM_TIMEOUT=<seconds> this invocation only > [build] build_program_timeout the manifest of the package that OWNS the build.mcpp > 600 built-in defaultThe value comes from the owning package's manifest, because its author is the one who knows how long the generator takes. When a dependency's build program times out, the error names the exact
mcpp.tomlto edit — editing a hand-written one would change nothing.Omitting the key is not the same as
0: unset means "use the default bound",0means "no bound at all".The bound is enforced on every platform as of mcpp 2026.8.11.1. It used to be POSIX-only: the Windows launcher fell through to an unbounded path, so this knob — and
mcpp test --timeout, and--build-timeout— silently did nothing there. Windows now runs the child in a Job object and closes it on expiry, which takes the whole process tree rather than just the direct child (a grandchild left holding the capture pipe would otherwise hang the drain after the kill).The compile is deliberately not bounded — the same asymmetry
mcpp testuses: a long compile is usually legitimate (a first-runstdmodule build is minutes) and killing it produces a baffling failure, while a long-running build program is usually stuck, and an unbounded one hangs the whole build with no diagnostic at all.Why not "ask the user instead of aborting" (#410): the program's stdout is already dup2'd into a pipe that carries the
mcpp:directive protocol, so there is no interaction channel; most builds run where nobody is watching (CI, a pipeline, ninja's child), and a build blocked on a prompt is harder to diagnose than one that failed; and a build whose outcome depends on a keystroke is not reproducible. The configurable bound plus an error that names the file to edit answers the same need.