Browser wasm: feature-detected module variants (relaxed-SIMD) + variant-aware runtime.js #25

Merged
catbot merged 1 commit from claude/issue-24 into master 2026-06-15 17:15:08 +02:00
7 changed files with 395 additions and 5 deletions
Showing only changes of commit 022ada70a6 - Show all commits

feat: feature-detected browser-wasm variants (relaxed-SIMD) + variant-aware runtime
Some checks failed
CI / build-test-release (pull_request) Failing after 7m19s

The browser wasm pipeline hardcoded -msimd128 for every wasm32 target and
baked a single wasm URL into index.html, so newer codegen features that
aren't yet baseline across engines (relaxed SIMD today; threads, future SIMD
revisions later) couldn't be adopted without dropping the browsers that lack
them.

Add a general, feature-parameterized mechanism owned entirely by
Crafter.Build:

- Configuration::wasmVariants declares N codegen variants (label, extra -m
  flags, runtime probes). Build() compiles the baseline plus one
  outputName.<label>.wasm per variant, recompiling the whole graph (incl.
  dep libs + std PCM) with the variant's flags — relaxed-SIMD is per-TU
  codegen, not a link switch. wasmVariantFlags folds into VariantId so each
  variant's objects/PCMs land in their own build+bin dir.
- EnableWasiBrowserRuntime emits a variants.json manifest (label -> url +
  probes), preferred-first with the baseline as the universal fallback.
- The shipped runtime.js runs inlined wasm-feature-detect probes
  (relaxed-simd, simd, tail-call, bulk-memory, exception-handling, threads),
  picks the first variant whose probes all pass, and falls back to the single
  baked CRAFTER_WASM_URL when no manifest is present (backward compatible).
- EnableWasiRelaxedSimdVariant registers the relaxed-SIMD variant — the
  motivating case (Chrome 114+/Firefox 120+ enable it by default; Safari
  still flag-gates it as of mid-2026).

Verified end to end: a wasm32-wasip1 build emits both wasi-hello.wasm and
wasi-hello.relaxed-simd.wasm + variants.json; Firefox selects the
relaxed-simd variant and runs it.

Resolves #24

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
catbot 2026-06-15 15:14:18 +00:00

View file

@ -447,7 +447,16 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
}); });
} }
fs::path stdPcmDir = GetCacheDir()/(config.target+"-"+config.march); // The std PCM is cached per target+march, but a wasm variant pass compiles
// it with extra codegen flags (e.g. -mrelaxed-simd) — its BMI must not be
// shared with the baseline's, or the consuming TUs see a target-feature
// mismatch. Suffix the cache dir with the variant flags when present.
std::string stdPcmKey = config.target + "-" + config.march;
for (const std::string& f : config.wasmVariantFlags) {
stdPcmKey += "+";
for (char c : f) stdPcmKey += (c == '/' || c == '\\') ? '_' : c;
}
fs::path stdPcmDir = GetCacheDir()/stdPcmKey;
if (!fs::exists(stdPcmDir)) { if (!fs::exists(stdPcmDir)) {
fs::create_directories(stdPcmDir); fs::create_directories(stdPcmDir);
@ -494,6 +503,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// same command line; quiet the unused-flag warning rather than split // same command line; quiet the unused-flag warning rather than split
// compile and link commands. // compile and link commands.
command += " -fno-exceptions -msimd128 -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL -Wno-unused-command-line-argument"; command += " -fno-exceptions -msimd128 -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL -Wno-unused-command-line-argument";
// Active variant pass (see the variant driver at the end of Build):
// extra codegen flags (e.g. -mrelaxed-simd) applied to every TU. Empty
// for the baseline build. Part of VariantId, so these objects/PCMs
// land in their own dir.
for (const std::string& f : config.wasmVariantFlags) {
command += " " + f;
}
} }
if (config.target == "x86_64-w64-mingw32") { if (config.target == "x86_64-w64-mingw32") {
// mingw libstdc++ defines TLS via __emutls_v.* (emulated TLS); without // mingw libstdc++ defines TLS via __emutls_v.* (emulated TLS); without
@ -1117,6 +1133,71 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
buildResult.libs.insert(std::format("-l{}", config.outputName)); buildResult.libs.insert(std::format("-l{}", config.outputName));
} }
// Browser-wasm feature-detected variants. The baseline outputName.wasm is
// now built (above). For each declared variant, recompile the entire build
// graph with the variant's extra codegen flags and drop the result next to
// the baseline as outputName.<label>.wasm. Relaxed-SIMD (and friends) is
// per-translation-unit codegen, not a link switch, so a full rebuild — incl.
// dep libs and the std PCM — is the only correct way to produce it.
//
// Guard: only fires on the top-level config that *declared* wasmVariants
// (deps leave it empty) and never on a variant pass itself (those carry
// wasmVariantFlags), so there's no recursion. Skipped on build error.
if (buildResult.result.empty()
&& config.target.starts_with("wasm32")
&& config.type == ConfigurationType::Executable
&& !config.wasmVariants.empty()
&& config.wasmVariantFlags.empty()) {
fs::path baselineBin = config.BinDir();
// The whole transitive graph, so the variant flags reach every TU.
std::vector<Configuration*> graph;
{
std::unordered_set<Configuration*> graphSeen;
std::function<void(Configuration*)> collect = [&](Configuration* c) {
if (!c || !graphSeen.insert(c).second) return;
graph.push_back(c);
for (Configuration* dep : c->dependencies) collect(dep);
};
collect(&config);
}
for (const WasmVariant& variant : config.wasmVariants) {
// A labelless or flagless "variant" is just the baseline under
// another name — nothing extra to compile.
if (variant.label.empty() || variant.flags.empty()) continue;
Progress::Task task(std::format("Building wasm variant {} ({})",
variant.label, config.outputName));
for (Configuration* c : graph) c->wasmVariantFlags = variant.flags;
fs::path variantBin = config.BinDir();
std::unordered_map<fs::path, std::shared_future<BuildResult>> variantDeps;
std::mutex variantMutex;
BuildResult vr = Build(config, variantDeps, variantMutex);
for (Configuration* c : graph) c->wasmVariantFlags.clear();
if (!vr.result.empty()) {
buildResult.result = std::format(
"wasm variant '{}' build failed: {}", variant.label, vr.result);
break;
}
std::error_code ec;
fs::path src = variantBin / (config.outputName + ".wasm");
fs::path dst = baselineBin / std::format("{}.{}.wasm", config.outputName, variant.label);
fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec);
if (ec) {
buildResult.result = std::format(
"copy wasm variant '{}' {} -> {}: {}",
variant.label, src.string(), dst.string(), ec.message());
break;
}
}
}
return buildResult; return buildResult;
} }
@ -1250,6 +1331,50 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
m << "]"; m << "]";
} }
// Variant manifest. runtime.js fetches this, runs each entry's probes in
// order, and instantiates the first whose probes all pass — so the
// preferred (most feature-rich) variants come first and the baseline last
// as the universal fallback (empty probes always pass). A variant with a
// non-empty label maps to outputName.<label>.wasm (produced by Build's
// variant driver); the baseline maps to outputName.wasm. Even with no
// declared variants we still emit a one-entry manifest so the runtime has a
// single, uniform code path.
auto jsonEscape = [](std::string_view s) {
std::string out;
for (char c : s) {
if (c == '"' || c == '\\') out += '\\';
out += c;
}
return out;
};
fs::path variantsPath = htmlOutDir / "variants.json";
{
std::ofstream v(variantsPath);
v << "[";
bool first = true;
auto emit = [&](const std::string& label, const std::vector<std::string>& probes) {
if (!first) v << ",";
first = false;
std::string url = label.empty()
? cfg.outputName + ".wasm"
: std::format("{}.{}.wasm", cfg.outputName, label);
v << "{\"label\":\"" << jsonEscape(label) << "\""
<< ",\"url\":\"" << jsonEscape(url) << "\""
<< ",\"probes\":[";
for (std::size_t i = 0; i < probes.size(); ++i) {
if (i) v << ",";
v << "\"" << jsonEscape(probes[i]) << "\"";
}
v << "]}";
};
for (const WasmVariant& variant : cfg.wasmVariants) {
if (variant.label.empty()) continue; // baseline emitted below
emit(variant.label, variant.probes);
}
emit("", {}); // baseline fallback, always last
v << "]";
}
std::ifstream in(htmlTemplate); std::ifstream in(htmlTemplate);
std::stringstream buf; std::stringstream buf;
buf << in.rdbuf(); buf << in.rdbuf();
@ -1264,6 +1389,18 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
cfg.files.push_back(runtimeJs); cfg.files.push_back(runtimeJs);
cfg.files.push_back(htmlPath); cfg.files.push_back(htmlPath);
cfg.files.push_back(manifestPath); cfg.files.push_back(manifestPath);
cfg.files.push_back(variantsPath);
}
void Crafter::EnableWasiRelaxedSimdVariant(Configuration& cfg) {
for (const WasmVariant& v : cfg.wasmVariants) {
if (v.label == "relaxed-simd") return; // idempotent
}
cfg.wasmVariants.push_back(WasmVariant{
.label = "relaxed-simd",
.flags = {"-mrelaxed-simd"},
.probes = {"relaxed-simd"},
});
} }
std::string Crafter::HostTarget() { std::string Crafter::HostTarget() {

View file

@ -778,6 +778,12 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
std::string archFlags = isWasm std::string archFlags = isWasm
? std::string(" -fno-exceptions -msimd128 -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL") ? std::string(" -fno-exceptions -msimd128 -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL")
: std::format(" -march={} -mtune={}", config.march, config.mtune); : std::format(" -march={} -mtune={}", config.march, config.mtune);
// Match the variant codegen flags the consuming TUs are built with
// (e.g. -mrelaxed-simd) so the std BMI is feature-compatible. wasm only;
// wasmVariantFlags is empty on every other path.
if (isWasm) {
for (const std::string& f : config.wasmVariantFlags) archFlags += " " + f;
}
if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCppm)) { if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCppm)) {
return RunCommand(std::format("clang++ --target={} -std=c++26 -stdlib=libc++{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string())); return RunCommand(std::format("clang++ --target={} -std=c++26 -stdlib=libc++{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string()));
} else { } else {

View file

@ -44,6 +44,21 @@ export namespace Crafter {
std::string value; std::string value;
}; };
// A browser-wasm codegen variant. The same wasm32 target is compiled more
// than once, each pass adding `flags` to every translation unit, so newer
// ISA features (relaxed SIMD today; threads, future SIMD revisions, … later)
// can be adopted without dropping engines that lack them. Each non-empty
// `label` emits an additional `outputName.<label>.wasm` alongside the
// baseline `outputName.wasm`; `probes` names the runtime feature-detect
// checks (see wasi-runtime/runtime.js's probe registry) that must all pass
// for a browser to be served this variant. The first variant whose probes
// pass wins; the baseline is the universal fallback.
struct WasmVariant {
std::string label;
std::vector<std::string> flags;
std::vector<std::string> probes;
};
enum class ConfigurationType { enum class ConfigurationType {
Executable, Executable,
LibraryStatic, LibraryStatic,
@ -157,6 +172,21 @@ export namespace Crafter {
std::vector<ExternalDependency> externalDependencies; std::vector<ExternalDependency> externalDependencies;
std::vector<std::string> compileFlags; std::vector<std::string> compileFlags;
std::vector<std::string> linkFlags; std::vector<std::string> linkFlags;
// Browser-wasm feature-detected variants. Empty (the default) builds a
// single outputName.wasm with the baseline wasm flag set. Non-empty
// builds the baseline plus one outputName.<label>.wasm per entry, each
// recompiling the whole build graph with that entry's `flags`;
// EnableWasiBrowserRuntime emits a variants.json manifest the shipped
// runtime.js uses to pick the right variant per browser. Populate via
// EnableWasiRelaxedSimdVariant for the common case.
std::vector<WasmVariant> wasmVariants;
// Extra wasm codegen flags injected into this configuration's compile +
// link command for the active variant pass. Set across the whole build
// graph by the variant driver inside Build(); part of VariantId so each
// variant's objects/PCMs land in their own build+bin dir and never
// clobber the baseline's. Not for direct project use — declare
// wasmVariants instead.
std::vector<std::string> wasmVariantFlags;
std::vector<Test> tests; std::vector<Test> tests;
CRAFTER_API void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations); CRAFTER_API void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations);
// Declare a test. Sources default to `tests/<name>/main.cpp` resolved // Declare a test. Sources default to `tests/<name>/main.cpp` resolved
@ -199,6 +229,13 @@ export namespace Crafter {
compileKey += "|F:"; compileKey += "|F:";
compileKey += f; compileKey += f;
} }
// Wasm variant codegen flags perturb every object/PCM, so they must
// key into a distinct build+bin dir from the baseline (and from
// each other) to avoid clobbering.
for (const std::string& f : wasmVariantFlags) {
compileKey += "|W:";
compileKey += f;
}
std::size_t configHash = std::hash<std::string>{}(compileKey); std::size_t configHash = std::hash<std::string>{}(compileKey);
return std::format("{}-{}-{}-{}-{:08x}", name, target, march, mtune, configHash); return std::format("{}-{}-{}-{}-{:08x}", name, target, march, mtune, configHash);
} }
@ -237,6 +274,17 @@ export namespace Crafter {
// outputName so renaming the binary later requires another call. // outputName so renaming the binary later requires another call.
CRAFTER_API void EnableWasiBrowserRuntime(Configuration& cfg); CRAFTER_API void EnableWasiBrowserRuntime(Configuration& cfg);
// Register the relaxed-SIMD codegen variant on cfg. Builds an additional
// outputName.relaxed-simd.wasm compiled with -mrelaxed-simd (FMA,
// dot-product, relaxed swizzle/laneselect, …); the shipped runtime serves
// it only to engines that report relaxed-SIMD support (Chrome 114+, Firefox
// 120+) and falls back to the baseline outputName.wasm everywhere else
// (e.g. Safari, which still gates relaxed SIMD behind a flag as of mid
// 2026). Idempotent. Call before EnableWasiBrowserRuntime so the emitted
// variants.json manifest includes it. A no-op for non-wasm targets at build
// time (no wasm is produced), but harmless to declare unconditionally.
CRAFTER_API void EnableWasiRelaxedSimdVariant(Configuration& cfg);
// View over the project's args with simple query helpers. Has(flag) for // View over the project's args with simple query helpers. Has(flag) for
// boolean switches; Get(prefix) for valued options (e.g. Get("--prefix=") // boolean switches; Get(prefix) for valued options (e.g. Get("--prefix=")
// returns the substring after the equals). Definitions are out-of-line // returns the substring after the equals). Definitions are out-of-line

View file

@ -106,6 +106,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("TestRunnerSpec").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("TestRunnerSpec").Dependencies({ crafterBuildLib.get() });
cfg.AddTest("VariantId").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("VariantId").Dependencies({ crafterBuildLib.get() });
cfg.AddTest("WasiBrowserRuntime").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("WasiBrowserRuntime").Dependencies({ crafterBuildLib.get() });
cfg.AddTest("WasmVariants").Dependencies({ crafterBuildLib.get() });
cfg.AddTest("RunSingleTestExit").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("RunSingleTestExit").Dependencies({ crafterBuildLib.get() });
// LoadProject dlopens the synthesized project.so, which references // LoadProject dlopens the synthesized project.so, which references
// Crafter:: symbols (HostTarget, Configuration ctors) that have to be // Crafter:: symbols (HostTarget, Configuration ctors) that have to be

View file

@ -47,8 +47,8 @@ int main() {
EnableWasiBrowserRuntime(cfg); EnableWasiBrowserRuntime(cfg);
// Three files registered: runtime.js, generated index.html, files.json. // Three files registered: runtime.js, generated index.html, files.json.
if (cfg.files.size() != 3) { if (cfg.files.size() != 4) {
std::println(std::cerr, "expected 3 cfg.files entries, got {}", cfg.files.size()); std::println(std::cerr, "expected 4 cfg.files entries, got {}", cfg.files.size());
for (const auto& f : cfg.files) std::println(std::cerr, " - {}", f.string()); for (const auto& f : cfg.files) std::println(std::cerr, " - {}", f.string());
return 1; return 1;
} }
@ -63,10 +63,12 @@ int main() {
const fs::path* runtimeJs = findByName("runtime.js"); const fs::path* runtimeJs = findByName("runtime.js");
const fs::path* indexHtml = findByName("index.html"); const fs::path* indexHtml = findByName("index.html");
const fs::path* manifest = findByName("files.json"); const fs::path* manifest = findByName("files.json");
const fs::path* variants = findByName("variants.json");
if (!runtimeJs) { std::println(std::cerr, "runtime.js not registered"); return 1; } if (!runtimeJs) { std::println(std::cerr, "runtime.js not registered"); return 1; }
if (!indexHtml) { std::println(std::cerr, "index.html not registered"); return 1; } if (!indexHtml) { std::println(std::cerr, "index.html not registered"); return 1; }
if (!manifest) { std::println(std::cerr, "files.json not registered"); return 1; } if (!manifest) { std::println(std::cerr, "files.json not registered"); return 1; }
if (!variants) { std::println(std::cerr, "variants.json not registered"); return 1; }
// The generated index.html and manifest live under the project's build/ // The generated index.html and manifest live under the project's build/
// wasi-runtime/<name>/ directory and must actually exist on disk. // wasi-runtime/<name>/ directory and must actually exist on disk.
@ -78,6 +80,25 @@ int main() {
std::println(std::cerr, "files.json not produced at {}", manifest->string()); std::println(std::cerr, "files.json not produced at {}", manifest->string());
return 1; return 1;
} }
if (!fs::exists(*variants)) {
std::println(std::cerr, "variants.json not produced at {}", variants->string());
return 1;
}
// With no declared variants, variants.json is a single baseline entry
// pointing at outputName.wasm with no probes.
{
std::ifstream vin(*variants);
std::string vjson{std::istreambuf_iterator<char>(vin), std::istreambuf_iterator<char>()};
if (vjson.find("\"wasi-helper.wasm\"") == std::string::npos) {
std::println(std::cerr, "variants.json missing baseline url: {}", vjson);
return 1;
}
if (vjson.find("relaxed-simd") != std::string::npos) {
std::println(std::cerr, "variants.json unexpectedly mentions relaxed-simd: {}", vjson);
return 1;
}
}
// The template's {{WASM}} placeholder should have been replaced with // The template's {{WASM}} placeholder should have been replaced with
// the configured outputName. // the configured outputName.

115
tests/WasmVariants/main.cpp Normal file
View file

@ -0,0 +1,115 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" int setenv(const char* name, const char* value, int overwrite);
// Covers the browser-wasm variant plumbing that does NOT require a wasm
// toolchain run: EnableWasiRelaxedSimdVariant's registration, the
// variants.json manifest EnableWasiBrowserRuntime emits, and the VariantId
// perturbation that keeps each variant's objects in their own build dir. The
// actual multi-.wasm production is exercised by a real wasm build (manual /
// integration), not here — same boundary the WasiBrowserRuntime test draws.
namespace {
int failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) { std::println(std::cerr, "FAIL: {}", msg); ++failures; }
}
}
int main() {
// ── EnableWasiRelaxedSimdVariant: registration + idempotency ──────────
{
Configuration cfg;
cfg.target = "wasm32-wasip1";
EnableWasiRelaxedSimdVariant(cfg);
Check(cfg.wasmVariants.size() == 1, "one variant registered");
if (!cfg.wasmVariants.empty()) {
const WasmVariant& v = cfg.wasmVariants.front();
Check(v.label == "relaxed-simd", "label is relaxed-simd");
Check(v.flags.size() == 1 && v.flags.front() == "-mrelaxed-simd", "flag is -mrelaxed-simd");
Check(v.probes.size() == 1 && v.probes.front() == "relaxed-simd", "probe is relaxed-simd");
}
EnableWasiRelaxedSimdVariant(cfg);
Check(cfg.wasmVariants.size() == 1, "second call is idempotent");
}
// ── wasmVariantFlags perturbs VariantId / BuildDir / BinDir ──────────
// Configuration owns move-only members (unique_ptr<Module>), so build the
// two cases independently rather than copying.
{
auto make = [] {
Configuration c;
c.path = "/tmp/wasm-variant-id-test";
c.name = "wv"; c.outputName = "wv";
c.target = "wasm32-wasip1";
c.type = ConfigurationType::Executable;
return c;
};
Configuration a = make();
Configuration b = make();
b.wasmVariantFlags = {"-mrelaxed-simd"};
Check(a.VariantId() != b.VariantId(), "wasmVariantFlags perturbs VariantId");
Check(a.BuildDir() != b.BuildDir(), "wasmVariantFlags perturbs BuildDir");
Check(a.BinDir() != b.BinDir(), "wasmVariantFlags perturbs BinDir");
}
// ── variants.json manifest contents ──────────────────────────────────
{
fs::path repoWasi = fs::current_path() / "wasi-runtime";
fs::path systemHome = "/usr/local/share/crafter-build";
if (fs::exists(repoWasi / "runtime.js")) {
setenv("CRAFTER_BUILD_HOME", fs::current_path().c_str(), 1);
} else if (fs::exists(systemHome / "wasi-runtime" / "runtime.js")) {
setenv("CRAFTER_BUILD_HOME", systemHome.c_str(), 1);
} else {
std::println(std::cerr, "wasi-runtime assets not found; skipping manifest check");
return failures ? 1 : 77;
}
Configuration cfg;
cfg.path = fs::temp_directory_path() / "crafter-build-wasm-variants-test";
cfg.name = "wvhelper";
cfg.outputName = "wvhelper";
cfg.target = "wasm32-wasip1";
cfg.type = ConfigurationType::Executable;
std::error_code ec;
fs::remove_all(cfg.path, ec);
fs::create_directories(cfg.path);
EnableWasiRelaxedSimdVariant(cfg);
EnableWasiBrowserRuntime(cfg);
const fs::path* variants = nullptr;
for (const fs::path& f : cfg.files) {
if (f.filename() == "variants.json") variants = &f;
}
Check(variants != nullptr, "variants.json registered");
if (variants) {
std::ifstream vin(*variants);
std::string j{std::istreambuf_iterator<char>(vin), std::istreambuf_iterator<char>()};
// relaxed-simd entry, with its labelled url + probe.
Check(j.find("\"label\":\"relaxed-simd\"") != std::string::npos, "manifest has relaxed-simd label");
Check(j.find("\"wvhelper.relaxed-simd.wasm\"") != std::string::npos, "manifest has labelled url");
Check(j.find("\"relaxed-simd\"") != std::string::npos, "manifest has relaxed-simd probe");
// baseline fallback entry.
Check(j.find("\"wvhelper.wasm\"") != std::string::npos, "manifest has baseline url");
// The relaxed-simd variant must precede the baseline so the runtime
// prefers it when probes pass.
auto relaxedPos = j.find("relaxed-simd");
auto baselinePos = j.find("\"wvhelper.wasm\"");
Check(relaxedPos != std::string::npos && baselinePos != std::string::npos && relaxedPos < baselinePos,
"relaxed-simd entry precedes baseline");
}
fs::remove_all(cfg.path, ec);
}
if (failures > 0) {
std::println(std::cerr, "{} assertions failed", failures);
return 1;
}
return 0;
}

View file

@ -820,9 +820,71 @@ class Wasi {
} }
} }
const wasmUrl = window.CRAFTER_WASM_URL; // ─── wasm feature-detect probes ────────────────────────────────────────
// Each probe validates a ~dozen-byte module that only validates on an engine
// implementing the named feature — the same technique as
// GoogleChromeLabs/wasm-feature-detect, inlined so we ship no dependency. A
// variant in variants.json lists the probe names that must all pass for that
// variant to be served; an unknown name is treated as unsupported (we fall
// through to a less demanding variant, ultimately the baseline). Keep the keys
// in sync with the `probes` a WasmVariant declares in Crafter.Build.
const WASM_PROBES = {
// relaxed SIMD: i8x16.relaxed_swizzle (Chrome 114+, Firefox 120+; Safari
// still flag-gated as of mid-2026).
"relaxed-simd": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,5,1,96,0,1,123,3,2,1,0,10,15,1,13,0,65,1,253,15,65,2,253,15,253,128,2,11])),
// fixed-width SIMD128 (v128.const + i8x16.popcnt) — the current baseline,
// here for completeness / future baseline shifts.
"simd": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,5,1,96,0,1,123,3,2,1,0,10,10,1,8,0,65,0,253,15,253,98,11])),
// tail calls (return_call).
"tail-call": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,10,6,1,4,0,18,0,11])),
// bulk memory ops (memory.init).
"bulk-memory": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,5,3,1,0,1,10,14,1,12,0,65,0,65,0,65,0,252,10,0,0,11])),
// legacy exception handling (try/catch).
"exception-handling": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,10,8,1,6,0,6,64,25,11,11])),
// threads/atomics — also requires SharedArrayBuffer (cross-origin isolated).
"threads": () => {
try {
if (typeof SharedArrayBuffer === "undefined") return false;
return WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,5,4,1,3,1,1,10,11,1,9,0,65,0,254,16,2,0,26,11]));
} catch (e) { return false; }
},
};
function variantSupported(probes) {
return (probes || []).every((name) => {
const probe = WASM_PROBES[name];
if (!probe) {
console.warn(`[wasi] unknown wasm probe '${name}' — treating variant as unsupported`);
return false;
}
try { return !!probe(); } catch (e) { return false; }
});
}
// Pick the wasm URL: prefer the variants.json manifest (first entry whose
// probes all pass), else fall back to the single baked window.CRAFTER_WASM_URL.
async function pickWasmUrl() {
let variants = null;
try {
const r = await fetch("variants.json");
if (r.ok) variants = await r.json();
} catch (e) {
// No manifest (older build) — fall back below.
}
if (Array.isArray(variants) && variants.length) {
for (const v of variants) {
if (variantSupported(v.probes)) {
if (v.label) console.log(`[wasi] selected wasm variant '${v.label}' (${(v.probes || []).join(", ")})`);
return v.url;
}
}
}
return window.CRAFTER_WASM_URL;
}
const wasmUrl = await pickWasmUrl();
if (!wasmUrl) { if (!wasmUrl) {
throw new Error("runtime.js: window.CRAFTER_WASM_URL is not set (set it in index.html before loading runtime.js)"); throw new Error("runtime.js: no wasm variant selected and window.CRAFTER_WASM_URL is not set");
} }
// Preload asset files listed in files.json (emitted by // Preload asset files listed in files.json (emitted by