Skip to content

0.3.0: one surface for every embedded payload, a rule for Slang, and a generated island boundary - #11

Merged
Sunrisepeak merged 20 commits into
mainfrom
feat/module-surface-and-slang
Sep 7, 2026
Merged

0.3.0: one surface for every embedded payload, a rule for Slang, and a generated island boundary#11
Sunrisepeak merged 20 commits into
mainfrom
feat/module-surface-and-slang

Conversation

@Sunrisepeak

@Sunrisepeak Sunrisepeak commented Sep 7, 2026

Copy link
Copy Markdown
Member

0.3.0. One surface for every embedded payload, a rule for Slang, a generator for
an island's extern "C" boundary, and the two declarations that let a rule
package introduce a device language without an engine release.

Requires mcpp 2026.9.7.1.

The surface: a payload is reached by importing a module

mcpp::plugins::surface is in the lib root because three rules produce the same
declarations for a consumer and a fourth copy would drift. It writes, from one
description, either a C header or a C++ module interface:

export module myapp.shaders;

extern "C" {
const unsigned int* mcpp_embed_scale_comp_data();
unsigned long       mcpp_embed_scale_comp_size();
}

export namespace myapp::shaders {
struct payload { const unsigned int* code; unsigned long size_bytes; };
inline payload scale_comp() { return { mcpp_embed_scale_comp_data(),
                                       mcpp_embed_scale_comp_size() }; }
}

The consumer writes import myapp.shaders; and myapp::shaders::scale_comp().
Nothing in a project names a generated file, so the rule is free to change where
it writes and how it stores the words.

The interface names no standard-library type, and that is measured rather
than stylistic: an interface carrying std::span puts that header's templates
into every consumer's BMI, at 727 times the size of the std-free form on GCC 16.

The accessor answers with the address and the byte count together. sizeof
is not merely awkward at this boundary, it is unanswerable: under object storage
there is no array to take the size of, and under null_terminate neither
sizeof nor the declared _size is the payload's length.

From a file name to a call

shaders/a/scale.comp becomes myapp::shaders::a::scale_comp(). The directory
below the group's base is part of the namespace, which is what makes two shaders
with one stem in two directories two things rather than a collision -- the
fixture asserts they are DISTINCT objects, because a build that collapsed them
would still print two correct lines. GCC's #pragma once treats two files of the
same size and content as the same file, so before the namespace reached the
symbol name the second include silently did nothing and both accessors returned
one array.

Three storages behind it

header, object and sidecar, each with a fixture that builds and runs. The
crossover is around 1 MB: below it the header is faster to compile than the
assembler round trip; above it the compiler's own parse dominates.

mcpp.rules.slang

Slang is a language rather than a second driver for GLSL, so it has its own rule
and its own extension. -source-embed-style u32, with the output named
<base>_embed.h because slangc appends .h to whatever -o names.

Each rule declares what it compiles, and who compiles it

[features.rules-slang]
sources           = ["rules/slang.cppm"]
device_extensions = [".slang"]
rule_module       = "mcpp.rules.slang"

mcpp 2026.9.7.1 reads both. The engine holds no package name, no feature
spelling and no module name, and .slang is not in its built-in table --
tests/slang-consumer builds and runs anyway, which is the whole criterion.
With the two keys declared, a project that names the feature needs no
build.mcpp at all: tests/spirv-zero-config has neither a build program nor
host-module, and asserts both absences.

mcpp.tools.island: the extern "C" boundary, generated

A member behind tools-island, like tools-embed: nothing in this collection
uses it, a project does.

Entry points are marked where they are defined:

MCPP_EXPORT_C
int saxpy_device(float a, const float* x, const float* y, float* out, unsigned n) { ... }

scan reads the marked declarations, emit writes the boundary header the
island's compiler reads and the module the C++ side imports. The module
re-exports NAMES (export using ::saxpy_device;) rather than restating
signatures, because a second copy of a signature is the thing this exists to
remove.

A seam has two halves, and scan is where they are compared. A device island
and a host fallback implement one boundary and exactly one is in any link. Both
are handed to scan unconditionally; entries merge by name, and two definitions
that declare one name differently are refused there, naming both files and both
signatures. Nothing else catches that: the two never meet at the link and C
language linkage does not mangle, so a disagreement builds cleanly and the
artifact reads its arguments by whichever signature it was compiled with.

The island writes no #include. force_include_flags returns the flags that
make the compiler read the generated header before the island's first line. Those
flags now have somewhere to go: cuda, hip and sycl gained the
options::flags pass-through ascendc already had, because the island is
compiled by a driver mcpp did not invoke and inherits nothing from mcpp::cflag.

The project-wide channels are not merely too wide for that job, they are wrong
for it: cxxflag would force the header into every C++ translation unit,
including a seam .cppm, and a module interface unit must begin with
export module.

The module root is the package's name

module_root_from_package derived it from the leaf of MCPP_MANIFEST_DIR --
a directory name -- because that was the only thing a build program was told.
mcpp 2026.9.7.1 answers the right question, so it reads mcpp::package_name().

Every fixture had a package name equal to its directory name, so neither
derivation was observable. tests/spirv-module-consumer now declares
name = "shader-app" while keeping its directory, and imports
shader_app.shaders; reverting the function makes that import unresolvable.

A generated name may not be a C++ keyword

Six sites ran the same character filter -- letters, digits and _ survive,
everything else becomes _, a leading digit gets a _ -- and none asked whether
the result is reserved. shaders/default/ is an ordinary shader directory name,
and it produced namespace default { in a generated file: an error on a line the
author of that directory has never opened.

surface::identifier owns all three transformations now, with a TRAILING
underscore for a keyword because a leading one is itself reserved at namespace
scope. The accessor is sanitised where it is EMITTED rather than in each
producer -- a rule that builds the name from a file stem cannot know it has
produced my-shader_comp or default until it reaches the line that writes the
function's name.

A module_name whose segment is not an identifier is refused where it was
written, naming the segment and the spelling that would work, rather than in a
generated file.

Four CI criteria that read text where they meant code

Each of these failed and none was about the package.

  • The all-rules-compile DENOMINATOR read [features] inline keys, and 0.3.0
    spells features as sub-tables, so it printed
    package: sources sources sources sources and reported the fixture as
    incomplete while the extractor was what broke.
  • The duplicate-stem step asserted a refusal this release deliberately removed:
    the directory is now part of the name, so two shaders sharing a stem are two
    payloads. It asserts that instead.
  • Two steps grepped a fixture for a symbol or a key that the fixture EXPLAINS in
    a comment it does not use, so each reported the file for saying what it does.
    Comment lines are stripped first now.
  • Going the other way, grep 'include' build.mcpp matched that program's own
    comments and its mcpp::include_dir line, so it would have passed with the
    forced include removed. It names force_include_flags.

The last one was found by reading all eighteen assertions that grep a source
file at once, rather than by waiting for another round trip.

Fixtures

Ten, all building and running locally against the 2026.9.7.1 engine:
spirv-consumer, spirv-module-consumer, spirv-zero-config,
spirv-object-storage, spirv-sidecar, embed-consumer,
embed-module-consumer, island-interface, slang-consumer,
all-rules-compile.

A consumer of `mcpp.rules.spirv` or `mcpp.tools.embed` had to include a
generated header by name. Both now hand their payloads to one generator,
`mcpp::plugins::surface`, and a consumer writes `import myapp.shaders;` instead.
`mcpp.rules.slang` joins them, producing the same declarations from a different
language.

WHAT A CONSUMER NAMES

The module name and the namespace are one identifier path, and a payload's
directory below the globbed tree adds a segment: `shaders/post/tone.comp`
becomes `myapp::shaders::post::tone_comp()`. The name is derived from the
package unless the project sets `options::module_name`.

The interface is a function rather than a variable, because `constexpr` and
`extern` are mutually exclusive and a variable could not keep one shape across
the ways bytes can be stored. It names no standard-library type: measured with
GCC 16.1 on a 1 MB payload, an interface returning `std::span` produced a
1 313 968-byte BMI against 1 808 bytes for the std-free equivalent, and that
cost is fixed rather than proportional to the payload -- it is `<span>`'s
templates, present whether the payload is 16 KB or 16 MB.

The accessors have C language linkage so an ordinary translation unit can define
them. Declaring them with module linkage would have needed a module
implementation unit, and that unit includes the data headers, which would put
the arrays back into the interface's own compilation.

THE DEFAULT FOLLOWS THE PROJECT, AND AN OLDER ENGINE KEEPS ITS BEHAVIOUR

`[language] modules` decides the surface, reported as `MCPP_LANGUAGE_MODULES`.
An engine that does not report it leaves the header surface in place, which is
what every consumer of this package had before, so the upgrade needs no project
to declare anything.

TWO DEFECTS THE NEW FIXTURES FOUND

Two shaders with one stem in two directories produced one array, silently. Their
generated headers were byte-identical, and GCC's `#pragma once` treats two files
with the same size and content as the same file, so the second include did
nothing and both accessors returned the first array. The program printed the
right magic number twice. The array's name now carries the directory, and the
fixture asserts the two pointers differ rather than only that both look like
SPIR-V. With no subdirectory the name is unchanged.

`tools.embed`'s `<id>_size` counts elements while the surface reports bytes, and
`null_terminate` appends a byte that `_size` does not count -- so neither `_size`
nor `sizeof` is the answer on its own. Items now carry the size expression their
producer knows to be right.

ONE PACKAGE, ONE VERSION

`mcpp::plugins::version` said `0.1.1` while the package said `0.2.6`, which
nothing noticed because nothing read it. Every rule now states it with
`mcpp::fact`, and CI compares the two spellings.

SLANG

A different language, not a second driver for GLSL: its own module system,
generics, and targets beyond SPIR-V, for which the Vulkan axis `rules.spirv`
reads has no answer. `slangc`'s embedded output names `uint32_t` and `size_t`
and includes nothing, which is the defect 0.2.6 fixed for glslang, so this rule
writes the public header around it from the start. The size comes from the
`_sizeInBytes` constant the compiler emits rather than from `sizeof`.

A Slang module package needs no new concept: it is an ordinary mcpp package
whose `include_dirs` names its `.slang` directory.

`xim:slang` is pinned exactly rather than floored. Slang's version is coupled to
no driver, so the rule this package follows would make it a `>=` -- but a `>=`
cannot reach xlings on Windows, where cmd.exe reads the `>` as a redirection,
and a floor holding on two platforms of three would resolve different payloads
for one project depending on the machine that built it.

`rules.slang` needs an engine whose device-source table lists `.slang`. A
constrained glob's `accel` key does not make a file a device source; the
engine's `kDeviceExtensions` does, and before it listed `.slang` the file fell
through to the ordinary source scan and was refused.
…kes expressible

`MCPP_VERSION` moves to 2026.9.7.1, the release carrying three things this
package needs: `.slang` in the device-source extension table, without which
`rules.slang` cannot route a file at all; `MCPP_LANGUAGE_MODULES`, which the
surface's default reads; and `mcpp.platform.shell`, which escapes a provisioning
argument for both cmd.exe and the child's MSVCRT parser.

The third restores a constraint. `xim:shaderc` was an exact version on macOS and
Windows for one release because the `>` in `>=2026.3` was read by cmd.exe as a
redirection and arrived as a packaging failure; the comment in mcpp.toml said
both would revert once a released engine escaped the argument. They do here.

`xim:slang` becomes a floor for the same reason, and its comment records why the
obvious middle ground was rejected: `>=` on the two platforms that tolerated it
and an exact version on the third would resolve different payloads for one
project depending on the machine that built it, which is how a defect comes to
exist on one operating system only.
A consumer now writes one dependency edge and no build program. The two keys are
what the engine reads: which device sources a rule compiles, and which module a
build program imports to reach it. The engine holds neither this package's name
nor any feature spelling, so a third-party rule for the same extension works the
same way.

tests/spirv-zero-config is the whole path: an edge naming a feature, no
host-module, no build.mcpp, and a shader reached through a generated module.
`storage` decides where a payload's bytes live; the surface decides how a
consumer names them. The declarations are identical under all three, so a
project changes the first and no consumer changes -- which is what the three new
fixtures assert by having the same `src/main.cpp` shape.

    header   a C array in generated source, compiled in     (default)
    object   a section, through `.incbin` in a generated .S
    sidecar  a file beside the artifact, read at run time

WHICH ONE IS A MEASUREMENT. With GCC 16.1 on 100 payloads of 16 KB each -- an
ordinary compute shader's size -- the header route takes 1.10s against the
object route's 2.17s, because at that size neither has a measurable marginal
cost and the total is decided by how many processes start. The crossover is the
TOTAL embedded byte count rather than the payload count: below about 1 MB the
header route wins, above about 4 MB it loses by an order of magnitude (2.31s
against 0.116s). So `header` stays the default and `object` is what a project
reaches for when it has more payload than that.

Object storage removes the one place the two shader compilers differed. Under it
neither `-mfmt=c` nor `-x --vn` is passed, so glslang and glslc write the same
thing: a bare `.spv`. The wrapper header exists only for the storage that needs
a translation unit.

The assembly is per object format, and the differences are not cosmetic: ELF
takes `.section .rodata`, Mach-O `.section __TEXT,__const` with an underscore
PREFIXED to every symbol, COFF `.section .rdata,"dr"`. Mach-O's underscore is
the one that fails in the quiet direction -- a label without it defines a symbol
the C++ side never resolves, and the link error names the accessor rather than
the missing prefix. `.balign 4` is written because `VkShaderModuleCreateInfo::
pCode` requires it and a section directive alone does not promise it.

MSVC has no GAS and mcpp refuses `.S` under it, so object storage degrades to
header storage there with one warning. The surface is unchanged, so a consumer
compiled either way is the same source.

Sidecar states its cost instead of hiding it: the accessor opens a path relative
to the working directory. `tests/spirv-sidecar` asserts both halves -- found
from the package root, and reported missing when run from `/tmp`, which is the
leg that would otherwise pass against an implementation that had embedded the
payload after all.
…dary

Every name a consumer writes is derived, and the derivation was documented
nowhere. The table states it end to end, with the three invariants and the
defect each one exists to prevent -- including the byte-identical generated
headers that GCC's `#pragma once` collapsed into one.

The generator is not shader-specific and three members already share it. A rule
package outside this collection reaches it by depending on `mcpp:plugins` and
activating no feature; whether it should become a package of its own is left
open rather than answered by silence.
…wice

`surface` generates the whole interface for a data payload, because an address
and a size are all there is to decide. `island` generates what is mechanical
about a code island, and only that: the `extern "C"` header its compiler
includes -- guards and `__cplusplus` dance included -- and the module the C++
side imports.

THE DECLARATION EXISTS ONCE. Without this a project writes it twice, in a header
and again wherever the C++ side reaches it, and C language linkage does not
mangle: two copies that disagree are one symbol, the link is clean, and each
side reads the arguments by its own ABI with no compile error and no link error.
That is the copy this removes, and it is the worst one available at this
boundary.

`export using ::name;` IS WHY THERE IS NO C PARSER HERE. The module re-exports
identifiers rather than restating signatures, so the generator needs only the
name before the `(` and the header stays the only place a signature is written.
Measured on both implementations this package supports before anything was built
on it: a consumer that imports the module and never includes the header calls
the entry point and links against an implementation compiled by a different
driver, under GCC 16.1 and clang 22.1.8.

The C++ interface is still the project's. A seam that turns raw pointers into
something worth calling is a design decision no generator makes well; this
removes the boilerplate around the boundary, not the boundary's design.
`emit_module = false` emits the header alone for a project that keeps a seam
that includes rather than imports.

`tests/island-interface` uses a `.c` island rather than a `.cu`, because the
generator does not know what compiler produced the object and a fixture needing
a vendor toolkit could only run where that toolkit is published. It has two
entry points, since a generator that re-exported only the first would satisfy a
one-function fixture, and the CI step refuses a module that restated a signature
even though such a module would compile.

The README states why both generators live in this package rather than in mcpp:
the engine's module carries the protocol, and a generator is a library on top of
it. Code inside the engine changes only with an engine release, which is the
coupling this version's `device_extensions` work exists to remove.
…arker names the mechanism

`island::scan` reads the marked declarations out of the island's own source, so
the signature sits beside the definition and exists once. `island::emit` still
takes a list directly, for entry points a scan cannot see, and a project that
wants neither writes its own header and module wrapper. Each layer overrides the
one above; the default is the one that keeps the signature in one place.

THE MARKER SELECTS, AND THAT IS THE POINT. An island has internal functions, and
a generator that exported whatever the file contained would make the boundary an
accident of the file's contents. The fixture has a static helper and an unmarked
external function, and CI refuses either reaching the generated files -- a check
that would otherwise pass, because both files would merely be larger.

`MCPP_EXPORT_C` names the mechanism rather than the domain: what is marked is
exported across a generated boundary with C linkage, and both halves are in the
name. `MCPP_ISLAND_EXPORT` was precise inside docs/20's vocabulary and narrow
outside it -- the generator works for any `extern "C"` boundary, and the fixture
proves it with a plain `.c`. Anything ending `_API` was rejected outright: that
suffix conventionally expands to a visibility attribute and this expands to
nothing, so borrowing it would promise what it does not do and collide with a
project that later wants the real thing.

THE SCAN IS NOT A C PARSER. From the marker it copies verbatim to the
parenthesis that closes the parameter list, matching nesting. The fixture's
second entry point wraps across three lines for that reason, and CI asserts the
flattened declaration, because a line-oriented scan would pass every other check
here.

A GCC 16 defect was found and worked around while writing it: `for (char c : s)`
over a `std::string` inside an exported inline function makes the consumer's
build program fail with `inlining failed in call to 'always_inline' ...
function body not available` in `<bits/stl_iterator.h>`, naming neither this
file nor the loop. Indexing touches no iterator type. The comment at the site
records the message so the next person recognises it.

The island still includes the generated header, and the README says why that
line is not new: a project writes the same include today and writes the header
as well. It also earns its place twice, defining the marker and declaring the
entry points, so a definition whose signature drifted fails where it was written
rather than at the link. Removing it with `-include` is left as an option, since
a file whose marker comes from a flag is no longer self-contained.
…did survives

`force_include_flags` returns what makes a compiler read the generated header
before the island's first line -- `-include <path>` for gcc and clang,
`/FI<path>` for MSVC. A project using this generator now has no header in its
source tree and no line naming one; the fixture's island is a `.c` with zero
includes.

The line was defended in the previous commit on the grounds that it kept the
file self-contained. It did not. The header is generated, so an `#include` of it
names a file that does not exist until mcpp writes it, and that translation unit
could not be compiled outside mcpp with or without the line. What the line
actually bought was a name its author never opens.

THE CHECK IT ALSO DID IS STILL THERE, and this was verified rather than assumed.
The compiler still sees the declarations, so a definition whose signature
drifted fails where it was written:

    src/kernels/saxpy.c:22:5: error: conflicting types for 'scale_device'

That is only reachable on the `emit` path, where a hand-written list and the
definition are separate things. Under `scan` the header is generated FROM the
definition and the two cannot disagree, so the check is vacuous there -- worth
saying, because a criterion that cannot fail is one this project has paid for
before.

The first attempt at that control reported success for the wrong reason: the
fixture was copied to a scratch directory, its `path = "../.."` dependency
stopped resolving, and the build failed with `has no mcpp.toml` while the check
read "refused". Re-run in place, it fails on the conflicting type.
The lib root carries what members share. `mcpp::plugins::surface` is there
because three rules produce the same declarations for a consumer and a
fourth copy would drift. Nothing in this collection uses the island
generator: a project calls it from its own `build.mcpp`, exactly as it
calls `mcpp.tools.embed`. Leaving it in the lib root handed it to every
consumer whether or not they asked for it, while `tools-embed` beside it
required a feature.

It moves to `tools/island.cppm` behind `tools-island`, exporting
`mcpp.tools.island`. `all-rules-compile` activates the feature and calls
`entry_name`, so the parser is compiled and exercised on every host.
`module_root_from_package` derived the module a consumer imports from the
leaf of MCPP_MANIFEST_DIR, because that was the only thing a build program
was told about itself. It is a directory name, and the two answers differ
whenever a project lays a package out under a generic folder:
mcpp's own `examples/09-heterogeneous/vulkan/app/` declares
`name = "vulkan-saxpy"` and got `app.shaders`, which every
`<something>/app/` in a workspace would also claim.

mcpp 2026.9.7.1 answers the right question, so this reads
`mcpp::package_name()`. The directory leaf stays only for a value that
cannot occur -- a manifest with no `[package] name` does not load -- so an
empty answer would mean the contract moved.

The fixture could not see the difference. Every project under tests/ had a
package name equal to its directory name, so both derivations produced the
same string. `spirv-module-consumer` now declares `name = "shader-app"`
while keeping its directory, and its consumer imports `shader_app.shaders`;
reverting this function makes that import unresolvable, which is the
measurement the fixture was missing.
The generator served one island. A seam has two implementations of one
`extern "C"` boundary -- a device island and a host fallback -- and exactly
one of them is in any link, which is the arrangement every example under
`examples/09-heterogeneous` has. Handed only the device half, the generator
produced an empty boundary in a `--no-accel` build and the C++ side failed
on an unresolved name.

`scan` now merges entries by name, so a build program hands it both files
unconditionally: both exist on disk in either build, and which one is
compiled is the manifest's decision rather than a condition the build
program repeats.

Two definitions of one name that declare it differently are refused there,
naming both files and both signatures. Nothing else catches that. The two
halves are never in one translation unit and never in one link, and C
language linkage does not mangle, so a build with disagreeing halves is
clean and the artifact reads its arguments by whichever signature it was
compiled with. `scan` is the only point at which both texts exist at once.

The fixture gains the host half under `cfg(not(accelerator = "vulkan"))`,
so both legs are built and run, and a CI step perturbs one signature and
asserts the refusal names both files. The README paragraph claiming the two
could not disagree under `scan` was true only while `scan` took one file.
Every example under `examples/09-heterogeneous` puts a module of the
project between the consumer and the island: `app.cppm` imports the
boundary and turns pointers and a count back into spans, and it is the one
place a backend can be exchanged. The fixture reached the generated module
from `src/main.cpp`, which is not a module unit -- so its import said
nothing about the case the examples are in, where one module interface
written by the project imports a module interface written into the build
directory during the same build.

`src/app.cppm` is that seam, and `src/main.cpp` now names no boundary
symbol at all. Both properties are asserted: a consumer that named
`saxpy_device` would mean the seam is not hiding which island is
underneath.

Both legs build and run with it.
`mcpp.tools.island::force_include_flags` returns the flags that make a
compiler read the generated boundary header before the island's first line,
and three of the four device rules had no way to deliver them. Measured on
`examples/09-heterogeneous/cuda`: the island is compiled by nvcc, or by
clang in CUDA mode, on a command line the rule assembles -- a driver mcpp
did not invoke, which inherits nothing from `mcpp::cflag` or
`mcpp::cxxflag`.

The project-wide channels are not merely too wide for the job, they are
wrong for it. `cxxflag` forces the header into every C++ translation unit,
including the seam `src/app.cppm`, and a module interface unit must begin
with `export module` -- declarations ahead of that line are ill-formed.

`mcpp.rules.ascendc` already had `options::flags`, appended last. The other
three now carry the same field with the same position and the same
pass-through-unexamined contract, because one rule with it and three
without is the shape where a project's build program works for one backend
and not the next -- which is what a seam exists to prevent.

Verified end to end on this host: the cuda example's island compiles with
`-include .../app.kernels.h` on its own command line, and the artifact runs
on an RTX 4080.
The `rules-slang` row said its engine need is `.slang` in the device-source
table. That was the first fix and it inverts the dependency; what shipped is
the feature declaring `device_extensions` and `rule_module` itself, with
`.slang` REMOVED from the engine's table. Its absence from the list of
per-member floors is now stated rather than left as a gap, because that
absence is the criterion for the whole arrangement.

The shared floor moves to 2026.9.7.1, and the two usage snippets pin 0.3.0.
Six sites across four files ran the same character filter -- letters, digits
and `_` survive, everything else becomes `_`, a leading digit gets a `_` --
and none of them asked whether the result is reserved. `default`,
`template`, `operator`, `private` and `union` are valid identifiers to that
filter, and `shaders/default/` is an ordinary name for a shader directory.

Measured: renaming the fixture's `shaders/a` to `shaders/default` makes the
generator write

    namespace default {

and the build fails at `expected identifier before 'default'`, inside a
generated file, on a line the author of that directory has never opened.

`surface::identifier` does all three transformations in one place, and a
reserved result gets a TRAILING underscore -- a leading one is itself
reserved at namespace scope, so prefixing would trade one reserved name for
another. Namespace segments in `rules-spirv` and `rules-slang` go through
it, as does `tools-embed`'s accessor name.

The accessor is sanitised where it is EMITTED rather than in each producer.
A rule that builds `item::identifier` from a file stem cannot know it has
produced `my-shader_comp` or `default` until it reaches the one line that
writes the function's name. `accessor_base` and the `_spv` symbols are left
alone: both carry fixed affixes and cannot come out reserved, and routing
them would rename symbols already published.

The fixture could not see any of this: `a` and `b` are ordinary identifiers,
so both filters produce the same file. It gains `shaders/default/`, and CI
asserts the generated interface contains `namespace default_ {`.
The module name is the one part of this surface a project writes itself,
and each of its segments becomes a namespace. `app.my-shaders` produced
`namespace my-shaders {` in a generated file, so the error named a line
nobody wrote and a file the author had never opened.

Refused where it was written, naming the offending segment and the spelling
that would work:

    mcpp.plugins.surface: `app.my-shaders` is not a usable module name: the
    segment `my-shaders` is not a C++ identifier.
      Each segment becomes a namespace, so it has to be one; `my_shaders`
      would work.

The predicate is `identifier()` applied to the segment and compared with it,
so the refusal and the derivation cannot disagree about what an identifier
is -- including the keyword case.
`all-rules-compile` asserts EVERY feature is compiled for this host, and
"every" is a list the fixture carries -- so the list is compared against
the package's own features before the build. That extractor was written for

    [features]
    rules-cuda = { sources = ["rules/cuda.cppm"] }

and 0.3.0 spells them as sub-tables, because a feature now carries
`device_extensions` and `rule_module` as well and TOML 1.0 forbids a
multi-line inline table. `grep -oE '^[a-z-]+ +='` then matched the keys
INSIDE those tables:

    package: sources sources sources sources
    fixture: rules-ascendc rules-cuda rules-hip rules-slang ...

which reads as "the fixture is wrong" while the fixture is complete and the
check is what broke. A denominator that cannot state the set it measures
against is worse than none: it fails loudly for the wrong reason, and the
next person edits the fixture.

The names come off `[features.<name>]` headers now, and the empty-result
guard says the manifest's spelling changed rather than that the package has
no features. Both copies of the check are replaced; fixing one would leave
the other failing on the next host.
Two shaders differing only by directory used to produce one output -- the
name was the stem and the stage, so `a/x.vert` and `b/x.vert` both gave
`x_vert.h` declaring `x_vert_spv` -- and the rule refused. The shader's
directory is now part of the generated file's path and of the array's name,
so there is nothing to refuse and the step failed with

    FAIL: two shaders mapping to one output were accepted

which is the correct new behaviour reported as a defect.

That change was not made for ergonomics: two byte-identical generated
headers collapse under GCC's `#pragma once`, which treats two files of the
same size and content as the same file, so the second include silently did
nothing and both accessors returned one array.

The step now asserts the property that made the refusal unnecessary --
`scale_comp_spv` and `dup_scale_comp_spv` are both declared -- and the
criterion is the generated names rather than the run, because a build that
collapsed them would also produce a working binary.

The refusal stays in the rule. Its condition is now two shaders with one
name in one directory, which a single glob cannot construct and an explicit
`options::base_dir` can. Verified locally against the released 2026.9.7.1
binary.
The step asserts that `tests/island-interface/src/main.cpp` names no
boundary symbol, because the seam exists to hide which island is
underneath. It was a plain grep, and that file EXPLAINS the property in a
comment -- "Nothing here names \`saxpy_device\`" -- so the criterion
reported the file for saying what it does.

Comment lines are stripped first. The difference is between asking whether
the file mentions the name and whether it uses it, and only the second is
the property. Measured both ways: `src/main.cpp` has zero uses after
stripping and `src/app.cppm`, which is the seam and is supposed to call
them, has two.
`spirv-zero-config` explains in its own header that `host-module = true`
is not written and not needed, and the step asserting that greps the
manifest -- so the fixture was reported for saying what it does. Comment
lines are stripped first.

The island step's `grep 'include' build.mcpp` had the mirror-image
problem: it matched that program's comments and its `mcpp::include_dir`
line, so it would have passed with the forced include removed. A criterion
that cannot fail is not one. It names `force_include_flags` now, which is
the mechanism it is there to protect.

Both were found by reading every CI assertion that greps a source file
rather than by waiting for the next round trip; the other sixteen are
either over program output, which has no comments, or over patterns no
comment in those files carries.
@Sunrisepeak
Sunrisepeak merged commit 177534c into main Sep 7, 2026
3 checks passed
@Sunrisepeak
Sunrisepeak deleted the feat/module-surface-and-slang branch September 7, 2026 19:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant