Some checks failed
CI / build-test-release (pull_request) Failing after 6m5s
A primary module interface unit — `export module Widget;`, no partitions — recorded nothing about what it imported. GetInterfacesAndImplementations registered the Module and then erased the file from the scan list, so the import pass only ever saw partitions, and Module had no vectors to hold an edge anyway. Two consequences, both reported as issue #26: Module::Check consulted only its own .cppm and its partitions. A data member added to an imported module left Widget.pcm, Widget.o and every consumer object untouched while the imported library rebuilt and both binaries relinked — one executable holding two class layouts, no diagnostic, and a crash somewhere unrelated. Wiping build/ was the only cure, so `crafter-build test` could not be trusted straight after an interface edit. Module::Compile waited on nothing. Two modules in one Configuration compile on concurrent threads, so a primary interface importing a sibling was a coin flip between working and "module 'Base' not found". Partitions never had either problem — they carry the same three vectors and Check/Compile honour them — which is why the gap only surfaced on a module whose interface is one flat unit. Module now carries moduleDependencies, externalModuleDependencies and pendingImports with the same meanings as on ModulePartition; primary units stay in the scan list so their imports land there; Check sees through them; Compile orders itself behind a local sibling; and ResolvePendingImports sweeps them so an edge survives dependencies being wired up afterwards. Build() now Checks every interface before spawning any compile thread — the `compiled` flag a waiter blocks on is raised either by a Compile that runs or by the Check that decides none is needed, so a Check still pending while another module's thread waits would have hung the build. Resolves #26 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1903 lines
97 KiB
C++
1903 lines
97 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module;
|
|
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
|
#include <winsock2.h>
|
|
#include <ws2tcpip.h>
|
|
// <rpc.h> (pulled in transitively) defines `interface` as a macro for `struct`,
|
|
// which collides with local variables named `interface` in this TU.
|
|
#undef interface
|
|
#else
|
|
#include <sys/socket.h>
|
|
#include <netinet/in.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
export module Crafter.Build:Clang_impl;
|
|
import std;
|
|
import :Clang;
|
|
import :Platform;
|
|
import :Test;
|
|
import :Lint;
|
|
import :Progress;
|
|
import :Asset;
|
|
namespace fs = std::filesystem;
|
|
using namespace Crafter;
|
|
|
|
|
|
namespace {
|
|
// Map one `import X;` name onto either a module this Configuration owns or
|
|
// one exported by a Configuration reachable through its dependency DAG,
|
|
// appending the matching staleness edge. False means nothing in reach
|
|
// provides X — either it's supplied from outside the graph (`std`) or the
|
|
// dependency isn't wired up *yet*, which is why callers remember the name.
|
|
bool ResolveImportName(Configuration& cfg, const std::string& importName, std::vector<Module*>& localDeps, std::vector<std::pair<Module*, fs::path>>& externalDeps) {
|
|
for(const std::unique_ptr<Module>& interface : cfg.interfaces) {
|
|
if(interface->name == importName) {
|
|
localDeps.push_back(interface.get());
|
|
return true;
|
|
}
|
|
}
|
|
|
|
std::unordered_set<Configuration*> seen;
|
|
std::function<bool(Configuration*)> walk = [&](Configuration* depCfg) -> bool {
|
|
if (!seen.insert(depCfg).second) return false;
|
|
for(const std::unique_ptr<Module>& depInterface : depCfg->interfaces) {
|
|
if(depInterface->name == importName) {
|
|
fs::path depPcmPath = std::format("{}.pcm", (depCfg->PcmDir() / depInterface->path.filename()).string());
|
|
externalDeps.emplace_back(depInterface.get(), std::move(depPcmPath));
|
|
return true;
|
|
}
|
|
}
|
|
for(Configuration* sub : depCfg->dependencies) {
|
|
if (walk(sub)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
for(Configuration* depCfg : cfg.dependencies) {
|
|
if (walk(depCfg)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void Configuration::ResolvePendingImports() {
|
|
// Same resolution the scan used, and against both dependency kinds — a
|
|
// second GetInterfacesAndImplementations call can add interfaces that an
|
|
// earlier batch's import was looking for, so a pending name may land on a
|
|
// local module and not just an external one.
|
|
auto sweep = [this](std::vector<std::string>& pending, std::vector<Module*>& localDeps, std::vector<std::pair<Module*, fs::path>>& externalDeps) {
|
|
std::erase_if(pending, [&](const std::string& name) {
|
|
return ResolveImportName(*this, name, localDeps, externalDeps);
|
|
});
|
|
};
|
|
for(const std::unique_ptr<Module>& interface : interfaces) {
|
|
sweep(interface->pendingImports, interface->moduleDependencies, interface->externalModuleDependencies);
|
|
for(const std::unique_ptr<ModulePartition>& partition : interface->partitions) {
|
|
sweep(partition->pendingImports, partition->moduleDependencies, partition->externalModuleDependencies);
|
|
}
|
|
}
|
|
for(Implementation& implementation : implementations) {
|
|
sweep(implementation.pendingImports, implementation.moduleDependencies, implementation.externalModuleDependencies);
|
|
}
|
|
}
|
|
|
|
void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations) {
|
|
auto resolveImport = [this](const std::string& importName, std::vector<Module*>& localDeps, std::vector<std::pair<Module*, fs::path>>& externalDeps, std::vector<std::string>& pending) {
|
|
if (!ResolveImportName(*this, importName, localDeps, externalDeps)) {
|
|
pending.push_back(importName);
|
|
}
|
|
};
|
|
|
|
std::vector<std::tuple<fs::path, std::string, ModulePartition*, Module*>> tempModulePaths = std::vector<std::tuple<fs::path, std::string, ModulePartition*, Module*>>(interfaces.size());
|
|
for(std::uint16_t i = 0; i < interfaces.size(); i++){
|
|
// Resolve to absolute now so the stored path survives cwd changes
|
|
// (matters for GitProject deps loaded from a different working dir).
|
|
fs::path file = fs::absolute(path / interfaces[i]).lexically_normal();
|
|
file += ".cppm";
|
|
std::ifstream t(file);
|
|
std::stringstream buffer;
|
|
buffer << t.rdbuf();
|
|
std::string fileContent = buffer.str();
|
|
fileContent = std::regex_replace(fileContent, std::regex(R"(//[^\n]*)"), "");
|
|
fileContent = std::regex_replace(fileContent, std::regex(R"(/\*.*?\*/)"), "");
|
|
tempModulePaths[i] = {file, fileContent, nullptr, nullptr};
|
|
}
|
|
|
|
// Primary interface units first, so the partition pass below can find their
|
|
// parent Module. They stay in tempModulePaths — a primary unit's own
|
|
// `import X;` lines are layout dependencies exactly like a partition's, and
|
|
// dropping the entry here is what left them unrecorded (issue #26). Marked
|
|
// by a null partition slot with the Module slot filled in.
|
|
for(std::tuple<fs::path, std::string, ModulePartition*, Module*>& file : tempModulePaths) {
|
|
std::smatch match;
|
|
if (std::regex_search(std::get<1>(file), match, std::regex(R"(export module ([a-zA-Z0-9_\.\-]+);)"))) {
|
|
fs::path pthCpy = std::get<0>(file);
|
|
pthCpy.replace_extension("");
|
|
this->interfaces.push_back(std::make_unique<Module>(std::move(match[1].str()), std::move(pthCpy)));
|
|
std::get<3>(file) = this->interfaces.back().get();
|
|
}
|
|
}
|
|
|
|
for(std::uint16_t i = 0; i < tempModulePaths.size(); i++) {
|
|
std::smatch match;
|
|
if (std::get<3>(tempModulePaths[i]) != nullptr) {
|
|
continue;
|
|
}
|
|
if (std::regex_search(std::get<1>(tempModulePaths[i]), match, std::regex(R"(export module ([a-zA-Z_0-9\.\-]+):([a-zA-Z_0-9\.\-]+);)"))) {
|
|
for(const std::unique_ptr<Module>& modulee : this->interfaces) {
|
|
if(modulee->name == match[1]) {
|
|
std::string name = match[2].str();
|
|
fs::path pthCpy = std::get<0>(tempModulePaths[i]);
|
|
pthCpy.replace_extension("");
|
|
std::unique_ptr<ModulePartition> partition = std::make_unique<ModulePartition>(std::move(name), std::move(pthCpy));
|
|
std::get<2>(tempModulePaths[i]) = partition.get();
|
|
modulee->partitions.push_back(std::move(partition));
|
|
std::get<3>(tempModulePaths[i]) = modulee.get();
|
|
goto next;
|
|
}
|
|
}
|
|
throw std::runtime_error(std::format("Module {} not found, referenced in {}", match[1].str(), std::get<0>(tempModulePaths[i]).string()));
|
|
} else {
|
|
throw std::runtime_error(std::format("No module declaration found in {}", std::get<0>(tempModulePaths[i]).string()));
|
|
}
|
|
next:;
|
|
}
|
|
|
|
for(std::tuple<fs::path, std::string, ModulePartition*, Module*>& file : tempModulePaths) {
|
|
ModulePartition* partition = std::get<2>(file);
|
|
Module* parentModule = std::get<3>(file);
|
|
const std::string& fileContent = std::get<1>(file);
|
|
|
|
// Primary interface unit: record only its module imports. Its
|
|
// `[export] import :Part;` lines need no edge — Module::Check walks
|
|
// every partition it owns and Module::Compile builds them all before
|
|
// precompiling itself, so partitions are covered wholesale.
|
|
if (partition == nullptr) {
|
|
std::regex primaryPattern(R"(import ([a-zA-Z_0-9\.\-]+);)");
|
|
std::sregex_iterator primaryCurrent(fileContent.begin(), fileContent.end(), primaryPattern);
|
|
std::sregex_iterator primaryLast;
|
|
while (primaryCurrent != primaryLast) {
|
|
std::smatch match = *primaryCurrent;
|
|
resolveImport(match[1].str(), parentModule->moduleDependencies, parentModule->externalModuleDependencies, parentModule->pendingImports);
|
|
++primaryCurrent;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
std::regex partitionPattern(R"(import :([a-zA-Z_\-0-9\.]+);)");
|
|
std::sregex_iterator currentMatch(fileContent.begin(), fileContent.end(), partitionPattern);
|
|
std::sregex_iterator lastMatch;
|
|
while (currentMatch != lastMatch) {
|
|
std::smatch match = *currentMatch;
|
|
for(std::unique_ptr<ModulePartition>& sibling : parentModule->partitions) {
|
|
if(sibling->name == match[1]) {
|
|
partition->partitionDependencies.push_back(sibling.get());
|
|
goto next2;
|
|
}
|
|
}
|
|
throw std::runtime_error(std::format("imported partition {}:{} not found, referenced in {}", parentModule->name, match[1].str(), std::get<0>(file).string()));
|
|
next2: ++currentMatch;
|
|
}
|
|
|
|
std::regex modulePattern(R"(import ([a-zA-Z_0-9\.\-]+);)");
|
|
std::sregex_iterator modCurrent(fileContent.begin(), fileContent.end(), modulePattern);
|
|
while (modCurrent != lastMatch) {
|
|
std::smatch match = *modCurrent;
|
|
resolveImport(match[1].str(), partition->moduleDependencies, partition->externalModuleDependencies, partition->pendingImports);
|
|
++modCurrent;
|
|
}
|
|
}
|
|
|
|
for(const fs::path& tempFile : implementations) {
|
|
fs::path file = fs::absolute(path / tempFile).lexically_normal();
|
|
file += ".cpp";
|
|
std::ifstream t(file);
|
|
std::stringstream buffer;
|
|
buffer << t.rdbuf();
|
|
std::string fileContent = buffer.str();
|
|
fileContent = std::regex_replace(fileContent, std::regex(R"(//[^\n]*)"), "");
|
|
fileContent = std::regex_replace(fileContent, std::regex(R"(/\*.*?\*/)"), "");
|
|
std::smatch match;
|
|
fs::path fileCopy = file;
|
|
fileCopy.replace_extension("");
|
|
Implementation& implementation = this->implementations.emplace_back(std::move(fileCopy));
|
|
if (std::regex_search(fileContent, match, std::regex(R"(module ([a-zA-Z0-9_\.\-]+)(:[a-zA-Z0-9_\.\-]+)?\s*;)"))) {
|
|
bool isPartitionImpl = match[2].length() > 0;
|
|
for(const std::unique_ptr<Module>& interface : this->interfaces) {
|
|
if(interface->name == match[1]) {
|
|
if (!isPartitionImpl) {
|
|
implementation.moduleDependencies.push_back(interface.get());
|
|
}
|
|
std::regex partitionPattern(R"(import :([a-zA-Z_\-0-9\.]+);)");
|
|
std::sregex_iterator currentMatch(fileContent.begin(), fileContent.end(), partitionPattern);
|
|
std::sregex_iterator lastMatch;
|
|
while (currentMatch != lastMatch) {
|
|
std::smatch match2 = *currentMatch;
|
|
for(const std::unique_ptr<ModulePartition>& partition : interface->partitions) {
|
|
if(partition->name == match2[1]) {
|
|
implementation.partitionDependencies.push_back(partition.get());
|
|
goto next3;
|
|
}
|
|
}
|
|
throw std::runtime_error(std::format("imported partition {}:{} not found, referenced in {}", match[1].str(), match2[1].str(), file.string()));
|
|
next3: ++currentMatch;
|
|
}
|
|
|
|
std::regex modulePattern(R"(import ([a-zA-Z_0-9\.\-]+);)");
|
|
std::sregex_iterator modCurrent(fileContent.begin(), fileContent.end(), modulePattern);
|
|
while (modCurrent != lastMatch) {
|
|
std::smatch match2 = *modCurrent;
|
|
if (match2[1] != match[1]) {
|
|
resolveImport(match2[1].str(), implementation.moduleDependencies, implementation.externalModuleDependencies, implementation.pendingImports);
|
|
}
|
|
++modCurrent;
|
|
}
|
|
goto next4;
|
|
}
|
|
}
|
|
throw std::runtime_error(std::format("Module {} not found not found, referenced in {}", match[1].str(), file.string()));
|
|
next4:;
|
|
} else {
|
|
std::regex pattern(R"(import ([a-zA-Z_\-0-9\.]+);)");
|
|
std::sregex_iterator currentMatch(fileContent.begin(), fileContent.end(), pattern);
|
|
std::sregex_iterator lastMatch;
|
|
while (currentMatch != lastMatch) {
|
|
std::smatch match2 = *currentMatch;
|
|
resolveImport(match2[1].str(), implementation.moduleDependencies, implementation.externalModuleDependencies, implementation.pendingImports);
|
|
++currentMatch;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex) {
|
|
// Reset per-build cached state on every Module/ModulePartition so that
|
|
// successive Build() calls on the same Configuration re-evaluate mtimes
|
|
// (incremental-rebuild test scenarios).
|
|
//
|
|
// Reset ONLY this configuration's own modules — never recurse into
|
|
// dependencies. Configuration objects are shared across the dependency DAG
|
|
// (diamond deps point at the same Configuration*), and every config in the
|
|
// tree gets its own Build() call: the per-PcmDir builder registered in
|
|
// depResults resets its own state here, at the top of its own Build(),
|
|
// sequenced before that config's compile threads spawn. Recursing into
|
|
// dependencies from here would re-clear a shared dependency's `compiled`
|
|
// atomic from a parent/sibling thread *while that dependency's own Build()
|
|
// is concurrently compiling it* — after its module-compile thread set the
|
|
// flag true and exited but before its impl/partition waiter ran
|
|
// compiled.wait(false). The waiter would then block forever on a flag
|
|
// nothing re-signals: the build freezes mid-compile, idle, with no compiler
|
|
// process alive (issue #16). Cross-config module state is consulted only via
|
|
// PCM file mtimes and the depResults futures, never via these flags, so the
|
|
// narrower reset is correct.
|
|
for (auto& iface : config.interfaces) {
|
|
iface->checked = false;
|
|
iface->compiled.store(false);
|
|
for (auto& part : iface->partitions) {
|
|
part->checked = false;
|
|
part->compiled.store(false);
|
|
}
|
|
}
|
|
|
|
// Sources were scanned when they were declared, which for most callers is
|
|
// before `dependencies` exists — AddTest resolves tests/<name>/main.cpp and
|
|
// only then hands back a builder whose .Dependencies() supplies the library.
|
|
// Any `import <DepModule>;` in such a TU resolved to nothing and so carried
|
|
// no staleness edge, which meant a member added to a dependency's interface
|
|
// rebuilt the library, relinked the consumer, and silently kept the
|
|
// consumer's object compiled against the *old* class layout (issue #27).
|
|
// Re-resolving here — the last point before mtimes are compared, with the
|
|
// DAG fully wired — closes that window for every caller rather than for the
|
|
// ones that remember to declare in the right order.
|
|
config.ResolvePendingImports();
|
|
|
|
// Auto-detect the WASI sysroot before any compile step runs so BuildStdPcm
|
|
// and the main compile command see the same value. Linux-only — Windows
|
|
// users supply cfg.sysroot pointing at their wasi-sdk install. Covers all
|
|
// wasm32-* triples (wasi, wasip1, wasip2, ...); the sysroot's per-triple
|
|
// subdirs handle the differences.
|
|
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
|
if (config.sysroot.empty() && config.target.starts_with("wasm32")) {
|
|
config.sysroot = "/usr/share/wasi-sysroot";
|
|
}
|
|
#endif
|
|
|
|
fs::path buildDir = config.BuildDir();
|
|
fs::path outputDir = config.BinDir();
|
|
|
|
if (!fs::exists(buildDir)) {
|
|
fs::create_directories(buildDir);
|
|
}
|
|
|
|
if (!fs::exists(outputDir)) {
|
|
fs::create_directories(outputDir);
|
|
}
|
|
|
|
BuildResult buildResult{};
|
|
|
|
// glslang #include search paths for every shader compiled in this
|
|
// configuration: each transitive (incl. self) buildFiles entry's parent
|
|
// dir, or the entry itself if it points at a directory. Collected once
|
|
// up front so the per-shader threads can capture the resulting span by
|
|
// reference. No file copy involved — glslang reads the includes in
|
|
// place from the dep's source tree.
|
|
std::vector<fs::path> shaderIncludeDirs;
|
|
{
|
|
std::unordered_set<std::string> seenDirs;
|
|
std::unordered_set<const Configuration*> seenCfg;
|
|
std::function<void(const Configuration*)> collect = [&](const Configuration* c) {
|
|
if (!seenCfg.insert(c).second) return;
|
|
for (const fs::path& bf : c->buildFiles) {
|
|
fs::path dir = fs::is_directory(bf) ? bf : bf.parent_path();
|
|
if (seenDirs.insert(dir.string()).second) {
|
|
shaderIncludeDirs.push_back(std::move(dir));
|
|
}
|
|
}
|
|
for (const Configuration* sub : c->dependencies) collect(sub);
|
|
};
|
|
collect(&config);
|
|
}
|
|
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(config.shaders.size() + 1 + config.interfaces.size() + config.implementations.size());
|
|
|
|
std::string buildError;
|
|
std::atomic<bool> buildCancelled{false};
|
|
for (const Shader& shader : config.shaders) {
|
|
if (shader.Check(outputDir)) continue;
|
|
threads.emplace_back([&shader, &outputDir, &shaderIncludeDirs, &buildError, &buildCancelled]() {
|
|
Progress::Task task(std::format("Compiling shader {}", shader.path.filename().string()));
|
|
if (buildCancelled.load(std::memory_order_relaxed)) return;
|
|
|
|
std::string result = shader.Compile(outputDir, shaderIncludeDirs);
|
|
if (result.empty()) return;
|
|
|
|
bool expected = false;
|
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
|
buildError = std::move(result);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Asset compilation: each cfg.assets entry is either a single .png/.obj
|
|
// file (flat output: outputDir/<filename>.ctex/cmesh — preserves the
|
|
// original behavior) or a directory (recursed, with the relative tree
|
|
// mirrored under outputDir/<dirname>/; .png/.obj are compressed, every
|
|
// other file is copied through unchanged). Directory mode lets mod/map
|
|
// trees keep their nested layout so mod.json paths like
|
|
// "cannon/base.cmesh" resolve correctly at runtime.
|
|
// Skipped per-file if the output is newer than the source. Each
|
|
// compress runs in its own thread; passthrough copies are bundled into
|
|
// a single thread to match the cfg.files pattern.
|
|
auto compressedName = [](const fs::path& src) -> std::optional<fs::path> {
|
|
std::string ext = src.extension().string();
|
|
for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<std::uint8_t>(c)));
|
|
// stb_image (used by CompressAsset → TextureAsset::LoadPNG) handles
|
|
// png/tga/jpg/bmp; all map to .ctex.
|
|
if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") {
|
|
return fs::path(src.filename()).replace_extension(".ctex");
|
|
}
|
|
if (ext == ".obj") return fs::path(src.filename()).replace_extension(".cmesh");
|
|
return std::nullopt;
|
|
};
|
|
auto submitCompress = [&](fs::path sourcePath, fs::path outRelative) {
|
|
fs::path out = outputDir / outRelative;
|
|
if (fs::exists(out) && fs::exists(sourcePath) && fs::last_write_time(sourcePath) <= fs::last_write_time(out)) return;
|
|
threads.emplace_back([sourcePath = std::move(sourcePath), out = std::move(out), &buildError, &buildCancelled]() {
|
|
Progress::Task task(std::format("Compressing asset {}", sourcePath.filename().string()));
|
|
if (buildCancelled.load(std::memory_order_relaxed)) return;
|
|
std::error_code ec;
|
|
fs::create_directories(out.parent_path(), ec);
|
|
std::string result = CompressAsset(sourcePath, out);
|
|
if (result.empty()) return;
|
|
bool expected = false;
|
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
|
buildError = std::move(result);
|
|
}
|
|
});
|
|
};
|
|
std::vector<std::pair<fs::path, fs::path>> assetPassthroughs;
|
|
for (const fs::path& asset : config.assets) {
|
|
if (fs::is_directory(asset)) {
|
|
fs::path topName = asset.filename();
|
|
for (const auto& entry : fs::recursive_directory_iterator(asset)) {
|
|
if (!entry.is_regular_file()) continue;
|
|
fs::path rel = fs::relative(entry.path(), asset);
|
|
if (std::optional<fs::path> compName = compressedName(entry.path())) {
|
|
submitCompress(entry.path(), topName / rel.parent_path() / *compName);
|
|
} else {
|
|
assetPassthroughs.emplace_back(entry.path(), topName / rel);
|
|
}
|
|
}
|
|
} else {
|
|
std::optional<fs::path> outName = compressedName(asset);
|
|
if (!outName) {
|
|
buildCancelled.store(true);
|
|
buildError = std::format("{}: unsupported asset extension (expected .png/.tga/.jpg/.bmp/.obj, or a directory)", asset.string());
|
|
break;
|
|
}
|
|
submitCompress(asset, *outName);
|
|
}
|
|
}
|
|
if (!assetPassthroughs.empty()) {
|
|
threads.emplace_back([passthroughs = std::move(assetPassthroughs), &outputDir, &buildCancelled, &buildError]() {
|
|
Progress::Task task("Copying asset passthrough files");
|
|
if (buildCancelled.load(std::memory_order_relaxed)) return;
|
|
try {
|
|
for (const auto& [src, rel] : passthroughs) {
|
|
fs::path dst = outputDir / rel;
|
|
fs::create_directories(dst.parent_path());
|
|
if (!fs::exists(dst)) {
|
|
fs::copy_file(src, dst);
|
|
} else if (fs::last_write_time(src) > fs::last_write_time(dst)) {
|
|
fs::copy_file(src, dst, fs::copy_options::overwrite_existing);
|
|
}
|
|
}
|
|
} catch (const std::exception& e) {
|
|
bool expected = false;
|
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
|
buildError = e.what();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
threads.emplace_back([&config, &outputDir, &buildCancelled, &buildError]() {
|
|
Progress::Task task(std::format("Copying files for {}", config.name));
|
|
if (buildCancelled.load(std::memory_order_relaxed)) return;
|
|
try {
|
|
for (const fs::path& additionalFile : config.files) {
|
|
fs::path destination = outputDir / additionalFile.filename();
|
|
if (fs::is_directory(additionalFile)) {
|
|
for (const auto& entry : fs::recursive_directory_iterator(additionalFile)) {
|
|
const fs::path& sourcePath = entry.path();
|
|
fs::path relativePath = fs::relative(sourcePath, additionalFile);
|
|
fs::path destPath = destination / relativePath;
|
|
if (entry.is_directory()) {
|
|
if (!fs::exists(destPath)) fs::create_directories(destPath);
|
|
} else if (entry.is_regular_file()) {
|
|
fs::create_directories(destPath.parent_path());
|
|
if (!fs::exists(destPath)) {
|
|
fs::copy_file(sourcePath, destPath);
|
|
} else if (fs::last_write_time(sourcePath) > fs::last_write_time(destPath)) {
|
|
fs::copy_file(sourcePath, destPath, fs::copy_options::overwrite_existing);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (!fs::exists(destination)) {
|
|
fs::copy_file(additionalFile, destination);
|
|
} else if (fs::last_write_time(additionalFile) > fs::last_write_time(destination)) {
|
|
fs::copy_file(additionalFile, destination, fs::copy_options::overwrite_existing);
|
|
}
|
|
}
|
|
}
|
|
} catch(std::exception& e) {
|
|
bool expected = false;
|
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
|
buildError = e.what();
|
|
}
|
|
}
|
|
});
|
|
|
|
std::vector<ExternalBuildResult> externalResults(config.externalDependencies.size());
|
|
std::vector<std::thread> externalThreads;
|
|
externalThreads.reserve(config.externalDependencies.size());
|
|
for (std::size_t i = 0; i < config.externalDependencies.size(); ++i) {
|
|
externalThreads.emplace_back([&, i]() {
|
|
Progress::Task task(std::format("Building external dep {}", config.externalDependencies[i].name));
|
|
if (buildCancelled.load(std::memory_order_relaxed)) return;
|
|
externalResults[i] = BuildExternal(config.externalDependencies[i], config.target, buildCancelled);
|
|
if (!externalResults[i].error.empty()) {
|
|
bool expected = false;
|
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
|
buildError = externalResults[i].error;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 = std::format("{}-{}", 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)) {
|
|
fs::create_directories(stdPcmDir);
|
|
}
|
|
|
|
std::string stdPcmResult;
|
|
{
|
|
Progress::Task task(std::format("Building std PCM ({}-{})", config.target, config.march));
|
|
stdPcmResult = BuildStdPcm(config, stdPcmDir/"std.pcm");
|
|
}
|
|
if(!stdPcmResult.empty()) {
|
|
buildCancelled.store(true);
|
|
for(std::thread& thread : threads) thread.join();
|
|
for(std::thread& thread : externalThreads) thread.join();
|
|
return {stdPcmResult, false, {}};
|
|
}
|
|
|
|
fs::path pcmDir;
|
|
|
|
if(config.type != ConfigurationType::Executable) {
|
|
pcmDir = outputDir;
|
|
} else {
|
|
pcmDir = buildDir;
|
|
}
|
|
|
|
fs::copy_file(stdPcmDir/"std.pcm", pcmDir/"std.pcm", fs::copy_options::update_existing);
|
|
|
|
std::string editedTarget = config.target;
|
|
std::replace(editedTarget.begin(), editedTarget.end(), '-', '_');
|
|
|
|
// wasm32 targets reject -march and silently ignore -mtune (clang errors on
|
|
// the former). Skip both for any wasm32-* triple.
|
|
bool isWasm = config.target.starts_with("wasm32");
|
|
std::string archFlags = isWasm
|
|
? std::string()
|
|
: std::format(" -march={} -mtune={}", config.march, config.mtune);
|
|
std::string command = std::format("{} --target={}{} -std=c++26 -D CRAFTER_BUILD_CONFIGURATION_TARGET=\\\"{}\\\" -D CRAFTER_BUILD_CONFIGURATION_TARGET_{} -fprebuilt-module-path={} -fprebuilt-module-path={}", GetBaseCommand(config), config.target, archFlags, editedTarget, editedTarget, stdPcmDir.string(), pcmDir.string());
|
|
|
|
if (!config.sysroot.empty()) {
|
|
command += std::format(" --sysroot={}", config.sysroot);
|
|
}
|
|
if (config.target.starts_with("wasm32")) {
|
|
// -mllvm is consumed by codegen but not the link driver, which is the
|
|
// same command line; quiet the unused-flag warning rather than split
|
|
// 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";
|
|
// 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 += std::format(" {}", f);
|
|
}
|
|
}
|
|
if (config.target == "x86_64-w64-mingw32") {
|
|
// mingw libstdc++ defines TLS via __emutls_v.* (emulated TLS); without
|
|
// -femulated-tls clang generates native-TLS references that don't
|
|
// match. Symptom: undefined std::__once_callable / __once_call at
|
|
// link time. Also -Wno-unused… because -femulated-tls is a codegen
|
|
// flag the link driver doesn't consume.
|
|
command += " -femulated-tls -Wno-unused-command-line-argument";
|
|
}
|
|
|
|
if(config.type == ConfigurationType::LibraryDynamic) {
|
|
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
|
command += " -fPIC -D CRAFTER_BUILD_CONFIGURATION_TYPE_SHARED_LIBRARY";
|
|
#endif
|
|
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
|
command += " -D CRAFTER_BUILD_CONFIGURATION_TYPE_SHARED_LIBRARY";
|
|
#endif
|
|
} else if(config.type == ConfigurationType::Executable) {
|
|
command += " -D CRAFTER_BUILD_CONFIGURATION_TYPE_EXECUTABLE";
|
|
// On Windows targets the API uses __declspec(dllimport) when consuming
|
|
// a DLL. Set the macro for executables so CRAFTER_API resolves to
|
|
// dllimport in their PCM cache (separate from the lib's PCM cache,
|
|
// which gets dllexport). Harmless if the exe doesn't actually link a
|
|
// crafter DLL — CRAFTER_API only matters at API call sites.
|
|
if (config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc") {
|
|
command += " -D CRAFTER_BUILD_DLL_IMPORT";
|
|
}
|
|
} else {
|
|
command += " -D CRAFTER_BUILD_CONFIGURATION_TYPE_LIBRARY";
|
|
}
|
|
|
|
std::string files;
|
|
std::unordered_set<std::string> libSet;
|
|
std::unordered_set<std::string> publicFlagSet;
|
|
std::mutex fileMutex;
|
|
std::vector<std::thread> depThreads;
|
|
depThreads.reserve(config.dependencies.size());
|
|
std::atomic<bool> repack(false);
|
|
|
|
// -I propagation that's valid for both C and C++ compiles. Module-only
|
|
// bits (-fprebuilt-module-path) stay on `command` only.
|
|
std::string includeFlags;
|
|
{
|
|
std::unordered_set<Configuration*> seen;
|
|
std::function<void(Configuration*)> addFlags = [&](Configuration* dep) {
|
|
if (!seen.insert(dep).second) return;
|
|
for (const auto& entry : fs::recursive_directory_iterator(dep->path)) {
|
|
if (entry.is_directory() && entry.path().filename() == "include") {
|
|
includeFlags += std::format(" -I{}", entry.path().string());
|
|
}
|
|
}
|
|
includeFlags += std::format(" -I{}", dep->path.string());
|
|
command += std::format(" -fprebuilt-module-path={}", dep->PcmDir().string());
|
|
for (Configuration* sub : dep->dependencies) {
|
|
addFlags(sub);
|
|
}
|
|
};
|
|
for (Configuration* dep : config.dependencies) {
|
|
addFlags(dep);
|
|
}
|
|
}
|
|
command += includeFlags;
|
|
|
|
for(Configuration* dep : config.dependencies) {
|
|
depThreads.emplace_back([&, dep](){
|
|
try {
|
|
if (buildCancelled.load(std::memory_order_relaxed)) return;
|
|
|
|
std::shared_ptr<std::promise<BuildResult>> promise;
|
|
std::shared_future<BuildResult> resultFuture;
|
|
bool isBuilder = false;
|
|
|
|
depMutex.lock();
|
|
fs::path cacheKey = dep->PcmDir();
|
|
auto it = depResults.find(cacheKey);
|
|
if (it == depResults.end()) {
|
|
isBuilder = true;
|
|
promise = std::make_shared<std::promise<BuildResult>>();
|
|
resultFuture = promise->get_future().share();
|
|
depResults.emplace(cacheKey, resultFuture);
|
|
} else {
|
|
resultFuture = it->second;
|
|
}
|
|
depMutex.unlock();
|
|
|
|
if (isBuilder) {
|
|
BuildResult built;
|
|
try {
|
|
built = Build(*dep, depResults, depMutex);
|
|
} catch (...) {
|
|
promise->set_exception(std::current_exception());
|
|
throw;
|
|
}
|
|
promise->set_value(std::move(built));
|
|
}
|
|
|
|
const BuildResult& result = resultFuture.get();
|
|
fileMutex.lock();
|
|
for (const std::string& lib : result.libs) libSet.insert(lib);
|
|
for (const std::string& f : result.publicCompileFlags) publicFlagSet.insert(f);
|
|
fileMutex.unlock();
|
|
if (!result.result.empty()) {
|
|
bool expected = false;
|
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
|
buildError = result.result;
|
|
}
|
|
}
|
|
if (result.repack) {
|
|
repack = true;
|
|
}
|
|
} catch (const std::exception& e) {
|
|
bool expected = false;
|
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
|
buildError = std::format("dep build for {} threw: {}", dep->path.string(), e.what());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Defines belong on both C and C++ compiles so vendored C dependencies
|
|
// can see configuration-level macros consistently with module sources.
|
|
std::string defineFlags;
|
|
for(const Define& define : config.defines) {
|
|
if(define.value.empty()) {
|
|
defineFlags += std::format(" -D {}", define.name);
|
|
} else {
|
|
defineFlags += std::format(" -D {}={}", define.name, define.value);
|
|
}
|
|
}
|
|
command += defineFlags;
|
|
|
|
// Track caller-provided compileFlags separately so the .c compile can
|
|
// pick them up too (vendored C deps usually need -I from this set).
|
|
std::string userFlags;
|
|
for(const std::string& flag : config.compileFlags) {
|
|
userFlags += std::format(" {}", flag);
|
|
}
|
|
command += userFlags;
|
|
|
|
std::string cmakeBuildType;
|
|
|
|
// Behaviour-neutral release performance for the binaries we emit: ThinLTO
|
|
// (cross-TU inlining) plus dead-section GC and safe identical-code folding.
|
|
// Default-on in Release, never Debug (keeps builds fast and debuggable).
|
|
// Excluded for wasm32 — its -mllvm/sjlj codegen flags don't compose with
|
|
// LTO here — and for nvcc .cu objects below (they can't emit LLVM bitcode,
|
|
// so they link in as ordinary objects alongside the bitcode ones). ThinLTO
|
|
// rather than monolithic -flto so link time and memory scale with arbitrary
|
|
// user project sizes. Compile-side flags (-flto, -ffunction/-fdata-sections)
|
|
// ride on `command`, which is reused as the link base; the link-only -Wl
|
|
// flags go on linkExtras so they don't warn on each -c compile.
|
|
const bool useLto = !config.debug && !isWasm;
|
|
const std::string ltoCompileFlags = useLto ? " -flto=thin -ffunction-sections -fdata-sections" : "";
|
|
const std::string ltoLinkFlags = useLto ? " -flto=thin -Wl,--gc-sections -Wl,--icf=safe" : "";
|
|
|
|
if(config.debug) {
|
|
cmakeBuildType = "Debug";
|
|
command += " -g -D CRAFTER_BUILD_CONFIGURATION_DEBUG";
|
|
} else {
|
|
cmakeBuildType = "Release";
|
|
command += " -O3";
|
|
}
|
|
command += ltoCompileFlags;
|
|
|
|
// Same target-aware setup as the C++ compile path (line 459-): wasm32
|
|
// rejects -march, silently ignores -mtune, and needs --sysroot to find
|
|
// wasi-libc headers. Build the prefix once so all C compiles share it.
|
|
const bool cIsWasm = config.target.starts_with("wasm32");
|
|
std::string cArchFlags = cIsWasm
|
|
? std::string()
|
|
: std::format(" -march={} -mtune={}", config.march, config.mtune);
|
|
if (!config.sysroot.empty()) {
|
|
cArchFlags += std::format(" --sysroot={}", config.sysroot);
|
|
}
|
|
if (cIsWasm) {
|
|
// Matches the C++ path's wasi flag set so any libc shim defines
|
|
// (e.g. _WASI_EMULATED_SIGNAL → signal.h shims) are visible to
|
|
// C dependencies that drag in signal.h transitively.
|
|
cArchFlags += " -D_WASI_EMULATED_SIGNAL";
|
|
}
|
|
for (const fs::path& cFile : config.cFiles) {
|
|
files += std::format(" {}_source.o ", (buildDir / cFile.filename()).string());
|
|
const std::string objPath = std::format("{}_source.o", (buildDir / cFile.filename()).string());
|
|
const std::string srcPath = std::format("{}.c", cFile.string());
|
|
if (!fs::exists(objPath) || (fs::exists(srcPath) && fs::last_write_time(srcPath) > fs::last_write_time(objPath))) {
|
|
threads.emplace_back([&cFile, &buildDir, &buildError, &buildCancelled, &config, &includeFlags, &defineFlags, &userFlags, &cArchFlags, <oCompileFlags]() {
|
|
Progress::Task task(std::format("Compiling {}.c", cFile.filename().string()));
|
|
if (buildCancelled.load(std::memory_order_relaxed)) return;
|
|
|
|
std::string result = RunCommand(std::format("clang {}.c --target={}{} -O3{} -c{}{}{} -o {}_source.o", cFile.string(), config.target, cArchFlags, ltoCompileFlags, includeFlags, defineFlags, userFlags, (buildDir / cFile.filename()).string()));
|
|
if (result.empty()) return;
|
|
|
|
bool expected = false;
|
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
|
buildError = std::move(result);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const fs::path& cFile : config.cuda) {
|
|
files += std::format(" {}_source.o ", (buildDir / cFile.filename()).string());
|
|
const std::string objPath = std::format("{}_source.o", (buildDir / cFile.filename()).string());
|
|
const std::string srcPath = std::format("{}.cu", cFile.string());
|
|
if (!fs::exists(objPath) || (fs::exists(srcPath) && fs::last_write_time(srcPath) > fs::last_write_time(objPath))) {
|
|
threads.emplace_back([&cFile, &buildDir, &buildError, &buildCancelled]() {
|
|
Progress::Task task(std::format("Compiling {}.cu", cFile.filename().string()));
|
|
if (buildCancelled.load(std::memory_order_relaxed)) return;
|
|
|
|
std::string result = RunCommand(std::format("nvcc {}.cu -c -o {}_source.o -O3 -arch=sm_89", cFile.string(), (buildDir / cFile.filename()).string()));
|
|
if (result.empty()) return;
|
|
|
|
bool expected = false;
|
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
|
buildError = std::move(result);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
for(std::thread& thread : depThreads) {
|
|
thread.join();
|
|
}
|
|
for(std::thread& thread : externalThreads) {
|
|
thread.join();
|
|
}
|
|
|
|
if(buildCancelled.load()) {
|
|
for(std::thread& thread : threads) thread.join();
|
|
return {buildError, false, {}};
|
|
}
|
|
|
|
// Ship runtime artifacts from transitive deps' bin dirs alongside the
|
|
// executable: compiled .spv files (cfg.shaders) and asset files/dirs
|
|
// (cfg.files). The lib already mirrored these into its own bin dir
|
|
// during its build, but a consumer exe loads them from its own dir at
|
|
// runtime. Only an exe is a deployment unit; intermediate libs don't
|
|
// need to forward since the exe walks all transitive deps. Runs outside
|
|
// the repack gate because the relink mtime check above only watches
|
|
// .so/.dll/.a, so a shader/file-only change in a dep wouldn't trigger
|
|
// repack but still needs the new artifact copied across.
|
|
// (cfg.buildFiles use a different mechanism — they're exposed to shader
|
|
// compiles as #include search paths in place, no copy.)
|
|
if (config.type == ConfigurationType::Executable) {
|
|
try {
|
|
auto copyTree = [](const fs::path& src, const fs::path& dest) {
|
|
if (fs::is_directory(src)) {
|
|
for (const auto& entry : fs::recursive_directory_iterator(src)) {
|
|
fs::path rel = fs::relative(entry.path(), src);
|
|
fs::path destPath = dest / rel;
|
|
if (entry.is_directory()) {
|
|
if (!fs::exists(destPath)) fs::create_directories(destPath);
|
|
} else if (entry.is_regular_file()) {
|
|
fs::create_directories(destPath.parent_path());
|
|
if (!fs::exists(destPath)) {
|
|
fs::copy_file(entry.path(), destPath);
|
|
} else if (fs::last_write_time(entry.path()) > fs::last_write_time(destPath)) {
|
|
fs::copy_file(entry.path(), destPath, fs::copy_options::overwrite_existing);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (!fs::exists(dest)) {
|
|
fs::copy_file(src, dest);
|
|
} else if (fs::last_write_time(src) > fs::last_write_time(dest)) {
|
|
fs::copy_file(src, dest, fs::copy_options::overwrite_existing);
|
|
}
|
|
}
|
|
};
|
|
std::unordered_set<Configuration*> seen;
|
|
std::function<void(Configuration*)> forwardDepArtifacts = [&](Configuration* dep) {
|
|
if (!seen.insert(dep).second) return;
|
|
fs::path depBinDir = dep->BinDir();
|
|
for (const Shader& shader : dep->shaders) {
|
|
fs::path src = depBinDir / shader.path.filename().replace_extension("spv");
|
|
if (!fs::exists(src)) continue;
|
|
copyTree(src, outputDir / src.filename());
|
|
}
|
|
for (const fs::path& additionalFile : dep->files) {
|
|
fs::path src = depBinDir / additionalFile.filename();
|
|
if (!fs::exists(src)) continue;
|
|
copyTree(src, outputDir / additionalFile.filename());
|
|
}
|
|
for (const fs::path& asset : dep->assets) {
|
|
// Directory entry: the dep already mirrored the
|
|
// (compressed + passthrough) tree under
|
|
// depBinDir/<asset.filename()>/. Forward it wholesale
|
|
// so our bin dir gets the same layout.
|
|
if (fs::is_directory(asset)) {
|
|
fs::path src = depBinDir / asset.filename();
|
|
if (!fs::exists(src)) continue;
|
|
copyTree(src, outputDir / asset.filename());
|
|
continue;
|
|
}
|
|
std::string ext = asset.extension().string();
|
|
for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<std::uint8_t>(c)));
|
|
fs::path srcName = asset.filename();
|
|
if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") {
|
|
srcName.replace_extension(".ctex");
|
|
} else if (ext == ".obj") {
|
|
srcName.replace_extension(".cmesh");
|
|
} else {
|
|
continue;
|
|
}
|
|
fs::path src = depBinDir / srcName;
|
|
if (!fs::exists(src)) continue;
|
|
copyTree(src, outputDir / srcName);
|
|
}
|
|
for (Configuration* sub : dep->dependencies) forwardDepArtifacts(sub);
|
|
};
|
|
for (Configuration* dep : config.dependencies) forwardDepArtifacts(dep);
|
|
} catch (const fs::filesystem_error& e) {
|
|
for(std::thread& thread : threads) thread.join();
|
|
return {e.what(), false, {}};
|
|
}
|
|
}
|
|
|
|
if(repack.load()) {
|
|
buildResult.repack = true;
|
|
}
|
|
buildResult.libs = std::move(libSet);
|
|
for(const std::string& flag : config.linkFlags) {
|
|
buildResult.libs.insert(flag);
|
|
}
|
|
// Public compile flags propagated from sub-deps. Add them to this build's
|
|
// command so config sees the headers its deps expose, and re-publish them
|
|
// so config's own consumers see them transitively.
|
|
for (const std::string& flag : publicFlagSet) command += std::format(" {}", flag);
|
|
buildResult.publicCompileFlags = std::move(publicFlagSet);
|
|
fs::file_time_type externalFloor = fs::file_time_type::min();
|
|
for(const ExternalBuildResult& ext : externalResults) {
|
|
for(const std::string& flag : ext.compileFlags) {
|
|
command += std::format(" {}", flag);
|
|
// Headers a dep links via ExternalDependency are part of its
|
|
// public surface (its modules can include them in declarations
|
|
// visible to consumers), so propagate the -I to consumers.
|
|
buildResult.publicCompileFlags.insert(flag);
|
|
}
|
|
for(const std::string& flag : ext.linkFlags) {
|
|
buildResult.libs.insert(flag);
|
|
}
|
|
if (ext.latestArtifact > externalFloor) externalFloor = ext.latestArtifact;
|
|
}
|
|
|
|
// Check every interface before compiling any of them. Module::Compile waits
|
|
// on the `compiled` flag of each sibling module it imports, and that flag is
|
|
// set either by a Compile that runs or by the Check that decides none is
|
|
// needed — so a Check still pending while another module's thread is already
|
|
// waiting would block on a flag nothing goes on to raise.
|
|
std::vector<Module*> staleInterfaces;
|
|
for(std::unique_ptr<Module>& interface : config.interfaces) {
|
|
if(interface->Check(pcmDir, externalFloor)) {
|
|
staleInterfaces.push_back(interface.get());
|
|
buildResult.repack = true;
|
|
}
|
|
files += std::format(" {}/{}.o", buildDir.string(), interface->path.filename().string());
|
|
for(std::unique_ptr<ModulePartition>& part : interface->partitions) {
|
|
files += std::format(" {}/{}.o", buildDir.string(), part->path.filename().string());
|
|
}
|
|
}
|
|
|
|
for(Module* mod : staleInterfaces) {
|
|
threads.emplace_back([mod, &command, &pcmDir, &buildDir, &buildCancelled, &buildError]() {
|
|
Progress::Task task(std::format("Compiling interface {}", mod->path.filename().string()));
|
|
try {
|
|
mod->Compile(command, pcmDir, buildDir, buildCancelled, buildError);
|
|
} catch (const std::exception& e) {
|
|
bool expected = false;
|
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
|
buildError = std::format("Module::Compile threw: {}", e.what());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
for(Implementation& implementation : config.implementations) {
|
|
if(implementation.Check(buildDir, pcmDir, externalFloor)) {
|
|
buildResult.repack = true;
|
|
Implementation* impl = &implementation;
|
|
threads.emplace_back([impl, &command, &buildDir, &buildCancelled, &buildError]() {
|
|
Progress::Task task(std::format("Compiling {}.cpp", impl->path.filename().string()));
|
|
try {
|
|
impl->Compile(command, buildDir, buildCancelled, buildError);
|
|
} catch (const std::exception& e) {
|
|
bool expected = false;
|
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
|
buildError = std::format("Implementation::Compile threw: {}", e.what());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
files += std::format(" {}/{}_impl.o", buildDir.string(), implementation.path.filename().string());
|
|
}
|
|
|
|
for(std::thread& thread : threads) {
|
|
thread.join();
|
|
}
|
|
|
|
if(buildCancelled.load()) {
|
|
return {buildError, false, {}};
|
|
}
|
|
|
|
std::string linkExtras;
|
|
for(const std::string& flag : buildResult.libs) {
|
|
linkExtras += std::format(" {}", flag);
|
|
}
|
|
// Link-only LTO/section flags (empty in Debug and for wasm). Every exe/lib
|
|
// link below ends with linkExtras, so this reaches them all; the static-lib
|
|
// archive path uses ar instead and is handled separately.
|
|
linkExtras += ltoLinkFlags;
|
|
// mingw uses libstdc++; C++26 std::print/format extras live in libstdc++exp.
|
|
// libstdc++ on mingw uses winpthreads for std::atomic_wait /
|
|
// counting_semaphore / stop_token, so -lpthread is required as soon as
|
|
// those primitives appear (they do, transitively, in any non-trivial std
|
|
// import). -static-libstdc++ bundles libstdc++ into the exe so we don't
|
|
// chase libstdc++-6.dll TLS symbol mismatches across mingw versions and
|
|
// the resulting binary stands alone. Auto-link so user projects don't
|
|
// carry boilerplate.
|
|
if (config.target == "x86_64-w64-mingw32") {
|
|
// Static-link libstdc++/libgcc/libwinpthread so the resulting .exe
|
|
// or .dll doesn't depend on a specific runtime DLL being on the
|
|
// consumer's PATH. The mingw runtime ABI varies subtly between
|
|
// distributions (Arch UCRT vs msys2 UCRT vs msys2 MSVCRT) and
|
|
// STATUS_ENTRYPOINT_NOT_FOUND at LoadLibrary time is the symptom
|
|
// of a mismatch. Static linkage trades binary size for portability.
|
|
// The -Bstatic/-Bdynamic bracketing forces -lpthread to resolve
|
|
// against libwinpthread.a rather than the import lib; everything
|
|
// else (KERNEL32, UCRT) stays dynamic. -lstdc++exp adds C++26
|
|
// std::print/format extras. -femulated-tls is already on the
|
|
// compile so __once_callable et al resolve in static libstdc++.
|
|
linkExtras += " -lstdc++exp -static-libstdc++ -static-libgcc -Wl,-Bstatic -lpthread -Wl,-Bdynamic";
|
|
}
|
|
|
|
// Force a relink if the expected output is missing or older than any dep
|
|
// artifact. Missing covers: previous build produced a different outputName,
|
|
// or the binary was deleted by hand. Older-than-dep covers: dep's library
|
|
// was rebuilt by an earlier run (so dep.repack is false this time around)
|
|
// but the consumer was never relinked against the new dep.
|
|
{
|
|
auto expectedOutputFor = [](const Configuration& c) -> fs::path {
|
|
fs::path dir = c.BinDir();
|
|
if (c.type == ConfigurationType::Executable) {
|
|
if (c.target.starts_with("wasm32")) return dir / (std::format("{}.wasm", c.outputName));
|
|
return c.target == "x86_64-w64-mingw32"
|
|
? dir / (std::format("{}.exe", c.outputName))
|
|
: dir / c.outputName;
|
|
}
|
|
if (c.type == ConfigurationType::LibraryStatic) {
|
|
return c.target == "x86_64-w64-mingw32" || c.target == "x86_64-pc-windows-msvc"
|
|
? dir / std::format("{}.lib", c.outputName)
|
|
: dir / std::format("lib{}.a", c.outputName);
|
|
}
|
|
// LibraryDynamic — point at the .dll on Windows targets so the
|
|
// mtime check sees the newly-built DLL; on Unix the .so suffices.
|
|
if (c.target == "x86_64-w64-mingw32" || c.target == "x86_64-pc-windows-msvc") {
|
|
return dir / std::format("{}.dll", c.outputName);
|
|
}
|
|
return dir / std::format("lib{}.so", c.outputName);
|
|
};
|
|
|
|
fs::path expected = expectedOutputFor(config);
|
|
if (!fs::exists(expected)) {
|
|
buildResult.repack = true;
|
|
} else {
|
|
auto consumerMtime = fs::last_write_time(expected);
|
|
for (Configuration* dep : config.dependencies) {
|
|
fs::path depArtifact = expectedOutputFor(*dep);
|
|
if (fs::exists(depArtifact) && fs::last_write_time(depArtifact) > consumerMtime) {
|
|
buildResult.repack = true;
|
|
break;
|
|
}
|
|
}
|
|
// Also relink if any .o this archive bundles is newer than the
|
|
// archive itself. Covers a build that compiled the .o but never
|
|
// reached the link step (interrupt, crash, or a source touched
|
|
// after compile but before link): on the next run the .cpp is
|
|
// already ≤ .o so Implementation/Module Check returns false and
|
|
// nothing else would notice the archive is stale.
|
|
if (!buildResult.repack) {
|
|
auto objNewer = [&](const fs::path& obj) {
|
|
std::error_code ec;
|
|
auto t = fs::last_write_time(obj, ec);
|
|
return !ec && t > consumerMtime;
|
|
};
|
|
for (const std::unique_ptr<Module>& iface : config.interfaces) {
|
|
if (objNewer(buildDir / std::format("{}.o", iface->path.filename().string()))) {
|
|
buildResult.repack = true;
|
|
break;
|
|
}
|
|
bool partHit = false;
|
|
for (const std::unique_ptr<ModulePartition>& part : iface->partitions) {
|
|
if (objNewer(buildDir / std::format("{}.o", part->path.filename().string()))) {
|
|
partHit = true;
|
|
break;
|
|
}
|
|
}
|
|
if (partHit) { buildResult.repack = true; break; }
|
|
}
|
|
if (!buildResult.repack) {
|
|
for (const Implementation& impl : config.implementations) {
|
|
if (objNewer(buildDir / std::format("{}_impl.o", impl.path.filename().string()))) {
|
|
buildResult.repack = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if(buildResult.repack) {
|
|
Progress::Task task(std::format("Linking {}", config.outputName));
|
|
if(config.type == ConfigurationType::Executable) {
|
|
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
|
if(config.target == "x86_64-w64-mingw32") {
|
|
try {
|
|
// Copy any LibraryDynamic dependency DLLs alongside
|
|
// the launcher exe — Windows resolves DLLs from the exe's
|
|
// own directory at load time, so this is the simplest
|
|
// equivalent of rpath $ORIGIN.
|
|
std::unordered_set<Configuration*> dllSeen;
|
|
std::function<void(Configuration*)> copyDepDlls = [&](Configuration* dep) {
|
|
if (!dllSeen.insert(dep).second) return;
|
|
if (dep->type == ConfigurationType::LibraryDynamic && dep->target == "x86_64-w64-mingw32") {
|
|
fs::path depDir = dep->BinDir();
|
|
// The DLL itself (Windows resolves it from the
|
|
// exe's directory at load time) and the mingw
|
|
// import lib (so a downstream `crafter-build.exe`
|
|
// can link a fresh project.dll against it without
|
|
// hunting through sibling output dirs).
|
|
for (auto fname : {std::format("{}.dll", dep->outputName), std::format("lib{}.dll.a", dep->outputName)}) {
|
|
fs::path src = depDir / fname;
|
|
if (!fs::exists(src)) continue;
|
|
fs::path dest = outputDir / src.filename();
|
|
if (!fs::exists(dest) || fs::last_write_time(src) > fs::last_write_time(dest)) {
|
|
fs::copy(src, dest, fs::copy_options::overwrite_existing);
|
|
}
|
|
}
|
|
}
|
|
for (Configuration* sub : dep->dependencies) copyDepDlls(sub);
|
|
};
|
|
for (Configuration* dep : config.dependencies) copyDepDlls(dep);
|
|
} catch (const fs::filesystem_error& e) {
|
|
return {e.what(), false, {}};
|
|
}
|
|
}
|
|
if (config.target.starts_with("wasm32")) {
|
|
buildResult.result = RunCommand(std::format("{}{} -o {}.wasm -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
|
|
} else {
|
|
buildResult.result = RunCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
|
|
}
|
|
#endif
|
|
|
|
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
|
if (config.target == "x86_64-w64-mingw32") {
|
|
// Windows host, mingw target: same shape as the Linux→mingw
|
|
// path (no LIBCXX_DIR / -lc++ / -nostdlib++ — those are MSVC
|
|
// libc++ flags). Copy LibraryDynamic dep DLLs + import libs
|
|
// alongside the launcher exe so Windows resolves them from
|
|
// the exe's own directory at load time. Runtime DLLs (libstdc++,
|
|
// libgcc, libwinpthread) come from msys2 on PATH.
|
|
std::unordered_set<Configuration*> dllSeen;
|
|
std::function<void(Configuration*)> copyDepDlls = [&](Configuration* dep) {
|
|
if (!dllSeen.insert(dep).second) return;
|
|
if (dep->type == ConfigurationType::LibraryDynamic && dep->target == "x86_64-w64-mingw32") {
|
|
fs::path depDir = dep->BinDir();
|
|
for (auto fname : {std::format("{}.dll", dep->outputName), std::format("lib{}.dll.a", dep->outputName)}) {
|
|
fs::path src = depDir / fname;
|
|
if (!fs::exists(src)) continue;
|
|
fs::path dest = outputDir / src.filename();
|
|
if (!fs::exists(dest) || fs::last_write_time(src) > fs::last_write_time(dest)) {
|
|
fs::copy(src, dest, fs::copy_options::overwrite_existing);
|
|
}
|
|
}
|
|
}
|
|
for (Configuration* sub : dep->dependencies) copyDepDlls(sub);
|
|
};
|
|
for (Configuration* dep : config.dependencies) copyDepDlls(dep);
|
|
|
|
buildResult.result = RunCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
|
|
} else {
|
|
std::system(std::format("copy \"%LIBCXX_DIR%\\lib\\c++.dll\" \"{}\\c++.dll\"", outputDir.string()).c_str());
|
|
buildResult.result = RunCommand(std::format("{}{} -o {}.exe -fuse-ld=lld -L %LIBCXX_DIR%\\lib -lc++ -nostdinc++ -nostdlib++{}", command, files, (outputDir/config.outputName).string(), linkExtras));
|
|
}
|
|
#endif
|
|
} else if(config.type == ConfigurationType::LibraryStatic) {
|
|
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
|
// ThinLTO emits LLVM bitcode objects; plain `ar` writes an archive
|
|
// index that omits their symbols, so a consumer's lld link can't
|
|
// pull the needed members. llvm-ar writes a bitcode-aware index.
|
|
buildResult.result = RunCommand(std::format("{} rcs {}.a {}", useLto ? "llvm-ar" : "ar", (outputDir/fs::path(std::string("lib")+config.outputName)).string(), files));
|
|
#endif
|
|
|
|
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
|
buildResult.result = RunCommand(std::format("llvm-lib.exe {} /OUT:{}.lib", files, (outputDir/fs::path(config.outputName)).string()));
|
|
#endif
|
|
} else {
|
|
// LibraryDynamic. Output names follow each target's convention so
|
|
// consumers can link via the standard linker search:
|
|
// mingw: <name>.dll + lib<name>.dll.a (lld --out-implib)
|
|
// msvc: <name>.dll + <name>.lib (lld /IMPLIB)
|
|
// unix: lib<name>.so (rpath $ORIGIN)
|
|
if (config.target == "x86_64-w64-mingw32") {
|
|
fs::path dll = outputDir / std::format("{}.dll", config.outputName);
|
|
fs::path implib = outputDir / std::format("lib{}.dll.a", config.outputName);
|
|
buildResult.result = RunCommand(std::format("{}{} -shared -o {} -Wl,--out-implib,{} -fuse-ld=lld{}", command, files, dll.string(), implib.string(), linkExtras));
|
|
} else if (config.target == "x86_64-pc-windows-msvc") {
|
|
fs::path dll = outputDir / std::format("{}.dll", config.outputName);
|
|
fs::path implib = outputDir / std::format("{}.lib", config.outputName);
|
|
buildResult.result = RunCommand(std::format("{}{} -shared -o {} -Wl,/IMPLIB:{} -fuse-ld=lld{}", command, files, dll.string(), implib.string(), linkExtras));
|
|
} else {
|
|
buildResult.result = RunCommand(std::format("{}{} -shared -o {}.so -Wl,-rpath,'$ORIGIN' -fuse-ld=lld{}", command, files, (outputDir/(std::string("lib")+config.outputName)).string(), linkExtras));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (config.type == ConfigurationType::LibraryStatic || config.type == ConfigurationType::LibraryDynamic) {
|
|
buildResult.libs.insert(std::format("-L{}", outputDir.string()));
|
|
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 / (std::format("{}.wasm", config.outputName));
|
|
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;
|
|
}
|
|
|
|
void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
|
|
fs::path runtimeDir = GetCrafterBuildHome() / "wasi-runtime";
|
|
fs::path runtimeJs = runtimeDir / "runtime.js";
|
|
fs::path htmlTemplate = runtimeDir / "index.html.in";
|
|
if (!fs::exists(runtimeJs) || !fs::exists(htmlTemplate)) {
|
|
throw std::runtime_error(std::format("wasi-runtime assets missing under {} (set CRAFTER_BUILD_HOME or reinstall)", runtimeDir.string()));
|
|
}
|
|
|
|
fs::path htmlOutDir = cfg.path / "build" / "wasi-runtime" / cfg.name;
|
|
fs::create_directories(htmlOutDir);
|
|
fs::path htmlPath = htmlOutDir / "index.html";
|
|
|
|
// Walk the dep graph for env-style JS bridges that need to load BEFORE
|
|
// runtime.js so they can populate `window.crafter_webbuild_env`. Any
|
|
// `*.js` entry in a (transitive) dep's `cfg.files` qualifies — the
|
|
// file is already going to be copied into the consumer's bin dir, we
|
|
// just need its basename for a `<script src=...>` tag.
|
|
std::vector<std::string> envScripts;
|
|
std::unordered_set<Configuration*> seen;
|
|
std::function<void(Configuration*)> walk = [&](Configuration* c) {
|
|
if (!c || !seen.insert(c).second) return;
|
|
for (const fs::path& f : c->files) {
|
|
if (f.extension() == ".js" && f.filename() != "runtime.js") {
|
|
std::string name = f.filename().string();
|
|
if (std::find(envScripts.begin(), envScripts.end(), name) == envScripts.end()) {
|
|
envScripts.push_back(std::move(name));
|
|
}
|
|
}
|
|
}
|
|
for (Configuration* dep : c->dependencies) walk(dep);
|
|
};
|
|
walk(&cfg);
|
|
|
|
// Per-build cache-busting token. Stamped onto every script src + the
|
|
// wasm URL so a regular browser reload sees fresh files even though
|
|
// the dev server (python -m http.server) sends no Cache-Control
|
|
// headers. Using ms-since-epoch is enough to be unique per build
|
|
// without invoking any version-control machinery.
|
|
const std::string buildId = std::to_string(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count());
|
|
|
|
std::string envScriptTags;
|
|
for (const std::string& name : envScripts) {
|
|
envScriptTags += std::format(" <script src=\"{}?v={}\" type=\"module\"></script>\n", name, buildId);
|
|
}
|
|
|
|
// Walk the dep graph again for non-JS assets — these get pre-loaded by
|
|
// runtime.js into an in-memory VFS so the wasm's std::ifstream et al.
|
|
// can actually read them (the wasi-runtime in this repo otherwise
|
|
// stubs every fd syscall to zero).
|
|
//
|
|
// The manifest lists *relative paths* (e.g. "assets/Inter.ttf") so
|
|
// runtime.js's fetch() resolves against the bin-dir layout the asset
|
|
// copy step actually emits. The VFS is keyed by basename — path_open
|
|
// strips to basename on lookup, so subdir layouts collapse on the
|
|
// wasm side. Basename collisions across subdirs aren't supported on
|
|
// the wasi runtime today; if two assets share a basename, the last
|
|
// one preloaded wins. Avoid collisions in the source tree.
|
|
auto compressedExt = [](const fs::path& src) -> std::optional<std::string> {
|
|
std::string ext = src.extension().string();
|
|
for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<std::uint8_t>(c)));
|
|
if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") {
|
|
return std::string(".ctex");
|
|
}
|
|
if (ext == ".obj") return std::string(".cmesh");
|
|
return std::nullopt;
|
|
};
|
|
auto compressedRel = [&](const fs::path& rel) -> fs::path {
|
|
if (auto ext = compressedExt(rel)) {
|
|
fs::path out = rel;
|
|
out.replace_extension(*ext);
|
|
return out;
|
|
}
|
|
return rel;
|
|
};
|
|
std::vector<std::string> assetFiles;
|
|
auto pushUnique = [&](std::string name) {
|
|
if (name.empty()) return;
|
|
if (std::find(assetFiles.begin(), assetFiles.end(), name) == assetFiles.end()) {
|
|
assetFiles.push_back(std::move(name));
|
|
}
|
|
};
|
|
seen.clear();
|
|
std::function<void(Configuration*)> walkAssets = [&](Configuration* c) {
|
|
if (!c || !seen.insert(c).second) return;
|
|
for (const fs::path& f : c->files) {
|
|
std::string ext = f.extension().string();
|
|
if (ext == ".js" || ext == ".html") continue;
|
|
if (f.filename() == "runtime.js") continue;
|
|
// cfg.files lands flat next to the .wasm by `name = filename()`.
|
|
pushUnique(f.filename().string());
|
|
}
|
|
// cfg.assets — mirror the bin-dir layout the build emits: a
|
|
// directory entry becomes <topName>/<rel inside dir>, single
|
|
// files land flat at the bin root. .png/.obj are compressed in
|
|
// place; everything else passes through under its original name.
|
|
for (const fs::path& a : c->assets) {
|
|
if (fs::is_directory(a)) {
|
|
const fs::path topName = a.filename();
|
|
std::error_code ec;
|
|
for (const auto& entry : fs::recursive_directory_iterator(a, ec)) {
|
|
if (ec) break;
|
|
if (!entry.is_regular_file()) continue;
|
|
fs::path rel = fs::relative(entry.path(), a);
|
|
pushUnique((topName / compressedRel(rel)).generic_string());
|
|
}
|
|
} else {
|
|
pushUnique(compressedRel(a.filename()).generic_string());
|
|
}
|
|
}
|
|
for (Configuration* dep : c->dependencies) walkAssets(dep);
|
|
};
|
|
walkAssets(&cfg);
|
|
|
|
fs::path manifestPath = htmlOutDir / "files.json";
|
|
{
|
|
std::ofstream m(manifestPath);
|
|
m << "[";
|
|
for (std::size_t i = 0; i < assetFiles.size(); ++i) {
|
|
if (i) m << ",";
|
|
m << "\"" << assetFiles[i] << "\"";
|
|
}
|
|
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()
|
|
? std::format("{}.wasm", cfg.outputName)
|
|
: 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::stringstream buf;
|
|
buf << in.rdbuf();
|
|
std::string html = buf.str();
|
|
html = std::regex_replace(html, std::regex(R"(\{\{WASM\}\})"), std::format("{}.wasm", cfg.outputName));
|
|
html = std::regex_replace(html, std::regex(R"(\{\{ENV_SCRIPTS\}\})"), envScriptTags);
|
|
html = std::regex_replace(html, std::regex(R"(\{\{BUILDID\}\})"), buildId);
|
|
std::ofstream out(htmlPath);
|
|
out << html;
|
|
out.close();
|
|
|
|
cfg.files.push_back(runtimeJs);
|
|
cfg.files.push_back(htmlPath);
|
|
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() {
|
|
static const std::string Cached = []() -> std::string {
|
|
CommandResult r = RunCommandChecked("clang++ -print-target-triple");
|
|
if (r.exitCode != 0) return {};
|
|
std::string out = std::move(r.output);
|
|
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
|
|
return out;
|
|
}();
|
|
return Cached;
|
|
}
|
|
|
|
bool Crafter::ArgQuery::Has(std::string_view flag) const {
|
|
for (std::string_view a : args) if (a == flag) return true;
|
|
return false;
|
|
}
|
|
|
|
std::optional<std::string> Crafter::ArgQuery::Get(std::string_view prefix) const {
|
|
for (std::string_view a : args) {
|
|
if (a.starts_with(prefix)) return std::string(a.substr(prefix.size()));
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
ArgQuery Crafter::ApplyStandardArgs(Configuration& cfg, std::span<const std::string_view> args) {
|
|
if (const char* envMarch = std::getenv("CRAFTER_BUILD_MARCH"); envMarch && *envMarch) {
|
|
cfg.march = envMarch;
|
|
}
|
|
if (const char* envMtune = std::getenv("CRAFTER_BUILD_MTUNE"); envMtune && *envMtune) {
|
|
cfg.mtune = envMtune;
|
|
}
|
|
bool sawLib = false;
|
|
bool sawShared = false;
|
|
for (std::string_view a : args) {
|
|
if (a == "--debug") cfg.debug = true;
|
|
else if (a == "--lib") sawLib = true;
|
|
else if (a == "--shared") sawShared = true;
|
|
else if (a.starts_with("--target=")) cfg.target = std::string(a.substr(std::string_view("--target=").size()));
|
|
else if (a.starts_with("--march=")) cfg.march = std::string(a.substr(std::string_view("--march=").size()));
|
|
else if (a.starts_with("--mtune=")) cfg.mtune = std::string(a.substr(std::string_view("--mtune=").size()));
|
|
else if (a.starts_with("--sysroot=")) cfg.sysroot = std::string(a.substr(std::string_view("--sysroot=").size()));
|
|
// Anything else is the project's own flag. Its effect on the output is
|
|
// opaque to the framework, so it has to key into VariantId or two flag
|
|
// settings share a bin dir and leave a bundle matching neither.
|
|
else cfg.projectArgs.emplace_back(a);
|
|
}
|
|
// Sorted and deduplicated so the identity depends on the set of flags, not
|
|
// on how they were ordered or repeated.
|
|
std::ranges::sort(cfg.projectArgs);
|
|
cfg.projectArgs.erase(std::ranges::unique(cfg.projectArgs).begin(), cfg.projectArgs.end());
|
|
if (sawLib && cfg.type == ConfigurationType::Executable) cfg.type = ConfigurationType::LibraryStatic;
|
|
if (sawShared && cfg.type == ConfigurationType::LibraryStatic) cfg.type = ConfigurationType::LibraryDynamic;
|
|
// WASI sysroot autodetect, applied at config-load time so the VariantId
|
|
// includes it. (Build() also runs this once more for callers that bypassed
|
|
// ApplyStandardArgs, but doing it here makes dep PcmDirs consistent
|
|
// between the consumer's command-construction and the dep's own build.)
|
|
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
|
if (cfg.sysroot.empty() && cfg.target.starts_with("wasm32")) {
|
|
cfg.sysroot = "/usr/share/wasi-sysroot";
|
|
}
|
|
#endif
|
|
return ArgQuery{args};
|
|
}
|
|
|
|
std::vector<fs::path> Crafter::CleanProject(const fs::path& projectFile) {
|
|
fs::path projectDir = fs::absolute(projectFile).lexically_normal().parent_path();
|
|
std::vector<fs::path> removed;
|
|
for (std::string_view name : { "bin", "build" }) {
|
|
fs::path dir = projectDir / name;
|
|
std::error_code ec;
|
|
if (!fs::is_directory(dir, ec)) continue;
|
|
if (fs::remove_all(dir, ec) == static_cast<std::uintmax_t>(-1) || ec) {
|
|
throw std::runtime_error(std::format("could not remove {}: {}", dir.string(), ec.message()));
|
|
}
|
|
removed.push_back(std::move(dir));
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
static void PrintHelp(std::string_view argv0) {
|
|
std::println(
|
|
R"(Usage:
|
|
{0} [options] [-- project-args...] Build the project in the current directory
|
|
{0} test [test-options] [globs...] Build and run the project's tests
|
|
{0} lint [lint-options] [globs...] Run the project's lint rules over its sources
|
|
{0} format [format-options] [globs...] Apply the project's transform rules (rewrites files)
|
|
{0} clean Delete the project's bin/ and build/ trees
|
|
{0} help | -h | --help Show this help
|
|
|
|
Loads ./project.cpp (override with --project=<path>), compiles it to a shared
|
|
object, and invokes its CrafterBuildProject() to obtain a Configuration that
|
|
drives the build. Outputs land at bin/<name>-<target>-<march>/, intermediates
|
|
at build/<name>-<target>-<march>/.
|
|
|
|
Build options:
|
|
-r Run the produced executable after a successful build
|
|
(host targets only; libraries cannot be run).
|
|
-v, --verbose Verbose progress output.
|
|
-q, --quiet Suppress progress output.
|
|
--project=<path> Path to the project file (default: ./project.cpp).
|
|
|
|
Test options (after the `test` subcommand):
|
|
--list Enumerate matching tests without running them.
|
|
--jobs=<N> Parallel job count (default: hardware_concurrency).
|
|
--timeout=<seconds> Per-test timeout override.
|
|
--runner=<spec> Override the test runner for this run. Specs:
|
|
local
|
|
cmd:<command> (e.g. cmd:wine)
|
|
--target=<triple> Filter to tests whose cfg.target matches. Default:
|
|
sweep across every distinct target declared by the
|
|
project's tests plus the host triple.
|
|
<glob> One or more name globs to filter tests (e.g. 'Unit*').
|
|
|
|
Lint options (after the `lint` subcommand):
|
|
--list Enumerate matching lint rules without running them.
|
|
<glob> One or more name globs to filter rules (e.g. 'spdx*').
|
|
|
|
Lint rules are defined in project.cpp via cfg.AddLintRule(name, callback) —
|
|
C++ callbacks run once per source file. No rules ship by default.
|
|
|
|
Format options (after the `format` subcommand):
|
|
--check Dry run: list files that would change, exit 1 if any.
|
|
--list Enumerate matching rules without running them.
|
|
<glob> One or more name globs to filter rules.
|
|
|
|
Rules are shared with lint — a rule that calls ctx.SetContent is a
|
|
transform. `format` rewrites changed files in place; `lint` reports the
|
|
same transforms as would-reformat findings without writing.
|
|
|
|
Clean (after the `clean` subcommand):
|
|
Removes bin/ and build/ next to the project file. Does not load project.cpp,
|
|
so it works when the project itself no longer compiles. Every target, variant
|
|
and dependency artifact under those trees goes with it.
|
|
|
|
Project args:
|
|
Any flag not consumed above is forwarded verbatim to CrafterBuildProject as
|
|
part of its `args` span. Project-specific flags (e.g. --target=, custom
|
|
feature toggles) live there. Flags ApplyStandardArgs does not itself
|
|
interpret are folded into the variant hash, so switching one lands in its own
|
|
bin/ and build/ directory instead of overwriting the other setting's.
|
|
|
|
Environment:
|
|
CRAFTER_BUILD_MARCH Override -march (default: native).
|
|
CRAFTER_BUILD_MTUNE Override -mtune (default: native).
|
|
CRAFTER_BUILD_RUNNER_<TARGET> Default test runner for a target triple.
|
|
Replace '-' and '.' with '_' in the
|
|
triple. CLI --runner= overrides this.
|
|
CRAFTER_MINGW_DIR Override mingw-w64 sysroot auto-detect.
|
|
LIBCXX_DIR Windows libc++ install (MSVC ABI builds).
|
|
|
|
Exit status:
|
|
0 success / all non-skipped tests passed / lint clean / format applied
|
|
1 build failure, one or more tests failed, lint findings, no rules defined,
|
|
format write errors, or `format --check` found files that would change
|
|
)", argv0);
|
|
}
|
|
|
|
int Crafter::Run(int argc, char** argv) {
|
|
try {
|
|
std::string_view argv0 = argc > 0 ? argv[0] : "crafter-build";
|
|
fs::path projectFile = "./project.cpp";
|
|
std::vector<std::string_view> projectArgs;
|
|
projectArgs.reserve(argc);
|
|
|
|
bool runTests = false;
|
|
bool runLint = false;
|
|
bool runFormat = false;
|
|
bool runClean = false;
|
|
bool runAfterBuild = false;
|
|
RunTestsOptions testOpts;
|
|
RunLintOptions lintOpts;
|
|
Progress::Verbosity verbosity = Progress::Verbosity::Default;
|
|
|
|
for (int i = 1; i < argc; ++i) {
|
|
std::string_view arg = argv[i];
|
|
if (arg == "-h" || arg == "--help" || (!runTests && !runLint && !runFormat && arg == "help")) {
|
|
PrintHelp(argv0);
|
|
return 0;
|
|
} else if (!runLint && !runFormat && !runClean && arg == "test") {
|
|
runTests = true;
|
|
} else if (!runTests && !runFormat && !runClean && arg == "lint") {
|
|
runLint = true;
|
|
} else if (!runTests && !runLint && !runClean && arg == "clean") {
|
|
runClean = true;
|
|
} else if (!runTests && !runLint && !runClean && arg == "format") {
|
|
runFormat = true;
|
|
lintOpts.mode = LintMode::Apply;
|
|
} else if (runFormat && arg == "--check") {
|
|
lintOpts.mode = LintMode::Check;
|
|
} else if (arg == "-r") {
|
|
runAfterBuild = true;
|
|
} else if (arg == "-v" || arg == "--verbose") {
|
|
verbosity = Progress::Verbosity::Verbose;
|
|
} else if (arg == "-q" || arg == "--quiet") {
|
|
verbosity = Progress::Verbosity::Quiet;
|
|
} else if (arg.starts_with("--project=")) {
|
|
projectFile = arg.substr(std::string_view("--project=").size());
|
|
} else if (runTests && arg.starts_with("--jobs=")) {
|
|
testOpts.jobs = std::stoi(std::string(arg.substr(std::string_view("--jobs=").size())));
|
|
} else if (runTests && arg.starts_with("--timeout=")) {
|
|
testOpts.timeoutOverride = std::chrono::seconds(std::stoi(std::string(arg.substr(std::string_view("--timeout=").size()))));
|
|
} else if (runTests && arg == "--list") {
|
|
testOpts.listOnly = true;
|
|
} else if (runTests && arg.starts_with("--runner=")) {
|
|
testOpts.runnerOverride = std::string(arg.substr(std::string_view("--runner=").size()));
|
|
} else if (runTests && !arg.starts_with("-")) {
|
|
testOpts.globs.emplace_back(arg);
|
|
} else if ((runLint || runFormat) && arg == "--list") {
|
|
lintOpts.listOnly = true;
|
|
} else if ((runLint || runFormat) && !arg.starts_with("-")) {
|
|
lintOpts.globs.emplace_back(arg);
|
|
} else {
|
|
projectArgs.push_back(arg);
|
|
}
|
|
}
|
|
|
|
Progress::SetVerbosity(verbosity);
|
|
|
|
// The test run is target-scoped: only tests whose cfg.target equals
|
|
// testOpts.targetFilter are included. Default = host triple, so a
|
|
// bare `crafter-build test` runs everything that targets host.
|
|
for (auto& a : projectArgs) {
|
|
if (a.starts_with("--target=")) {
|
|
testOpts.targetFilter = std::string(a.substr(std::string_view("--target=").size()));
|
|
}
|
|
}
|
|
|
|
if (!fs::exists(projectFile)) {
|
|
std::println(std::cerr, "No project file at {}", projectFile.string());
|
|
return 1;
|
|
}
|
|
|
|
// Ahead of LoadProject on purpose — see CleanProject.
|
|
if (runClean) {
|
|
std::vector<fs::path> removed = CleanProject(projectFile);
|
|
if (removed.empty()) {
|
|
std::println("Nothing to clean");
|
|
} else {
|
|
for (const fs::path& dir : removed) std::println("Removed {}", dir.string());
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
Configuration config = LoadProject(projectFile, projectArgs);
|
|
SetParentProject(&config);
|
|
|
|
if (runLint || runFormat) {
|
|
// lexically_normal: fs::absolute keeps a "./" component, which
|
|
// would leak into displayed paths and the project-root derivation.
|
|
lintOpts.projectFile = fs::absolute(projectFile).lexically_normal();
|
|
LintSummary lintSummary = RunLint(config, lintOpts);
|
|
if (!runFormat) return lintSummary.Clean() ? 0 : 1;
|
|
if (lintSummary.noRulesDefined || lintSummary.errors > 0) return 1;
|
|
// Apply: formatting files is success (gofmt convention).
|
|
// Check: a would-change file is the CI failure signal.
|
|
return lintOpts.mode == LintMode::Check && !lintSummary.changedFiles.empty() ? 1 : 0;
|
|
}
|
|
|
|
if (runTests) {
|
|
TestSummary summary = RunTests(config, testOpts, projectArgs);
|
|
return summary.AllPassed() ? 0 : 1;
|
|
}
|
|
|
|
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
|
|
std::mutex depMutex;
|
|
BuildResult result = Build(config, depResults, depMutex);
|
|
|
|
if (!result.result.empty()) {
|
|
Progress::Clear();
|
|
std::println(std::cerr, "{}", result.result);
|
|
return 1;
|
|
}
|
|
|
|
Progress::Finalize();
|
|
|
|
if (runAfterBuild) {
|
|
if (config.type != ConfigurationType::Executable) {
|
|
std::println(std::cerr, "-r: cannot run a library");
|
|
return 1;
|
|
}
|
|
fs::path dir = config.BinDir();
|
|
fs::path artifact = dir / config.outputName;
|
|
if (config.target.starts_with("wasm32")) {
|
|
artifact += ".wasm";
|
|
} else if (config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc") {
|
|
artifact += ".exe";
|
|
}
|
|
artifact = fs::absolute(artifact);
|
|
fs::path absDir = fs::absolute(dir);
|
|
|
|
// wasm targets need either a wasm runtime (wasi-cli) or an HTTP
|
|
// server (browser build with index.html). std::system on the
|
|
// .wasm path goes nowhere useful — replace with detection.
|
|
if (config.target.starts_with("wasm32")) {
|
|
bool browserBuild = fs::exists(absDir / "index.html");
|
|
auto have = [](std::string_view exe) {
|
|
#ifdef _WIN32
|
|
std::string probe = std::format("where {} > NUL 2>&1", exe);
|
|
#else
|
|
std::string probe = std::format("command -v {} > /dev/null 2>&1", exe);
|
|
#endif
|
|
return std::system(probe.c_str()) == 0;
|
|
};
|
|
if (browserBuild) {
|
|
// Probe-bind to find a free port starting at 8080 — if the
|
|
// user has another dev server running we shift up rather
|
|
// than letting caddy/python exit with EADDRINUSE.
|
|
auto findFreePort = [](std::int32_t basePort, std::int32_t span) -> std::int32_t {
|
|
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
|
static const bool WsaInit = []{ WSADATA d; return WSAStartup(MAKEWORD(2, 2), &d) == 0; }();
|
|
if (!WsaInit) return basePort;
|
|
using Sock = SOCKET;
|
|
const Sock invalid = INVALID_SOCKET;
|
|
auto closesock = [](Sock s){ closesocket(s); };
|
|
#else
|
|
using Sock = std::int32_t;
|
|
const Sock invalid = -1;
|
|
auto closesock = [](Sock s){ ::close(s); };
|
|
#endif
|
|
for (std::int32_t p = basePort; p < basePort + span; ++p) {
|
|
Sock s = ::socket(AF_INET, SOCK_STREAM, 0);
|
|
if (s == invalid) return basePort;
|
|
sockaddr_in addr{};
|
|
addr.sin_family = AF_INET;
|
|
addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
|
addr.sin_port = htons(static_cast<std::uint16_t>(p));
|
|
bool ok = ::bind(s, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == 0;
|
|
closesock(s);
|
|
if (ok) return p;
|
|
}
|
|
return basePort;
|
|
};
|
|
const std::int32_t port = findFreePort(8080, 16);
|
|
// Cross-origin isolation: the browser coarsens
|
|
// performance.now() (and chrono::steady_clock under wasi)
|
|
// to ~0.1ms unless the response carries COOP/COEP, which
|
|
// floors the --timing overlay's sub-ms phase counters to
|
|
// zero. Emitting same-origin / require-corp drops the
|
|
// resolution to ~5µs and also unlocks SharedArrayBuffer
|
|
// for any future threading work. CORP keeps the local
|
|
// asset fetches passing under require-corp.
|
|
auto writeFile = [](const fs::path& p, std::string_view contents) {
|
|
std::ofstream f(p, std::ios::binary | std::ios::trunc);
|
|
f.write(contents.data(), static_cast<std::streamsize>(contents.size()));
|
|
};
|
|
std::string cmd;
|
|
std::string_view picked;
|
|
bool isolated = false;
|
|
if (have("caddy")) {
|
|
picked = "caddy";
|
|
isolated = true;
|
|
// caddy file-server has no --header flag; write a
|
|
// Caddyfile next to the build output. Adapter is
|
|
// inferred from the .caddyfile extension when run
|
|
// via `caddy run`.
|
|
fs::path cf = absDir / "Caddyfile.coi";
|
|
writeFile(cf, std::format(
|
|
":{} {{\n"
|
|
" root * {}\n"
|
|
" header Cross-Origin-Opener-Policy \"same-origin\"\n"
|
|
" header Cross-Origin-Embedder-Policy \"require-corp\"\n"
|
|
" header Cross-Origin-Resource-Policy \"same-origin\"\n"
|
|
" header Cache-Control \"no-store\"\n"
|
|
" file_server\n"
|
|
"}}\n",
|
|
port, absDir.string()));
|
|
cmd = std::format("caddy run --config {} --adapter caddyfile", cf.string());
|
|
} else if (have("python3") || have("python")) {
|
|
std::string_view py = have("python3") ? "python3" : "python";
|
|
picked = py;
|
|
isolated = true;
|
|
// Inline a tiny SimpleHTTPRequestHandler subclass
|
|
// that appends the COI headers on every response.
|
|
// Lives in absDir so the user can re-run it manually
|
|
// (`python3 absDir/.serve-coi.py 8080`).
|
|
fs::path sp = absDir / ".serve-coi.py";
|
|
writeFile(sp,
|
|
"import http.server, socketserver, sys, os\n"
|
|
"class H(http.server.SimpleHTTPRequestHandler):\n"
|
|
" def end_headers(self):\n"
|
|
" self.send_header('Cross-Origin-Opener-Policy', 'same-origin')\n"
|
|
" self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')\n"
|
|
" self.send_header('Cross-Origin-Resource-Policy', 'same-origin')\n"
|
|
" self.send_header('Cache-Control', 'no-store')\n"
|
|
" super().end_headers()\n"
|
|
"socketserver.TCPServer.allow_reuse_address = True\n"
|
|
"port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080\n"
|
|
"os.chdir(os.path.dirname(os.path.abspath(__file__)))\n"
|
|
"with socketserver.TCPServer(('', port), H) as s:\n"
|
|
" s.serve_forever()\n");
|
|
cmd = std::format("{} {} {}", py, sp.string(), port);
|
|
} else if (have("php")) {
|
|
picked = "php";
|
|
// php -S supports a router script — emit one that
|
|
// sets COI headers then delegates to the built-in
|
|
// static-file handler by returning false.
|
|
fs::path rp = absDir / ".serve-coi.php";
|
|
writeFile(rp,
|
|
"<?php\n"
|
|
"header('Cross-Origin-Opener-Policy: same-origin');\n"
|
|
"header('Cross-Origin-Embedder-Policy: require-corp');\n"
|
|
"header('Cross-Origin-Resource-Policy: same-origin');\n"
|
|
"header('Cache-Control: no-store');\n"
|
|
"return false;\n");
|
|
isolated = true;
|
|
cmd = std::format("php -S 0.0.0.0:{} -t {} {}", port, absDir.string(), rp.string());
|
|
} else if (have("ruby")) {
|
|
picked = "ruby";
|
|
cmd = std::format("ruby -run -e httpd {} -p{}", absDir.string(), port);
|
|
} else if (have("busybox")) {
|
|
picked = "busybox httpd";
|
|
cmd = std::format("busybox httpd -f -p {} -h {}", port, absDir.string());
|
|
} else if (have("npx")) {
|
|
picked = "npx http-server";
|
|
cmd = std::format("npx --yes http-server {} -p {} --silent", absDir.string(), port);
|
|
} else {
|
|
std::println(std::cerr, "-r wasm: no HTTP server found in PATH. Install one of: " "caddy, python3, python, php, ruby, busybox, npx (Node.js).");
|
|
return 1;
|
|
}
|
|
std::println("listening on port :{}", port);
|
|
if (!isolated) {
|
|
std::println(std::cerr, "warning: {} does not emit COOP/COEP — performance.now() will be coarse " "(~0.1ms). Install caddy, python3, or php for cross-origin isolation.", picked);
|
|
}
|
|
// Silence the backend's own banner/request logs so the
|
|
// experience is identical regardless of which server is
|
|
// picked — the line above is the only output.
|
|
#ifdef _WIN32
|
|
cmd += " > NUL 2>&1";
|
|
#else
|
|
cmd += " > /dev/null 2>&1";
|
|
#endif
|
|
return std::system(cmd.c_str()) == 0 ? 0 : 1;
|
|
}
|
|
// wasi-cli wasm — needs a standalone runtime.
|
|
if (have("wasmtime")) {
|
|
return std::system(std::format("wasmtime {}", artifact.string()).c_str()) == 0 ? 0 : 1;
|
|
}
|
|
if (have("wasmer")) {
|
|
return std::system(std::format("wasmer run {}", artifact.string()).c_str()) == 0 ? 0 : 1;
|
|
}
|
|
std::println(std::cerr, "-r wasm: no wasm runtime found in PATH. Install wasmtime or wasmer, " "or call EnableWasiBrowserRuntime(cfg) in project.cpp for a browser build.");
|
|
return 1;
|
|
}
|
|
|
|
// Resolve to absolute — cmd.exe on Windows mishandles a leading
|
|
// "./" by trying to interpret it as a command. system() invokes
|
|
// through cmd /c, so the relative-prefixed path makes cmd error
|
|
// with "'.' is not recognized as an internal or external command".
|
|
// Run from the artifact's own directory so relative file opens
|
|
// (shaders, assets copied alongside the exe via cfg.files) resolve
|
|
// against the bin dir rather than the user's cwd. We exit the
|
|
// process immediately after, so no cwd restore needed.
|
|
fs::current_path(dir);
|
|
return std::system(artifact.string().c_str()) == 0 ? 0 : 1;
|
|
}
|
|
|
|
return 0;
|
|
} catch (const std::exception& e) {
|
|
Progress::Clear();
|
|
std::println(std::cerr, "{}", e.what());
|
|
return 1;
|
|
}
|
|
}
|