All checks were successful
CI / build-test-release (push) Successful in 1h4m52s
1615 lines
80 KiB
C++
1615 lines
80 KiB
C++
/*
|
|
Crafter® Build
|
|
Copyright (C) 2026 Catcrafts®
|
|
Catcrafts.net
|
|
|
|
This library is free software; you can redistribute it and/or
|
|
modify it under the terms of the GNU Lesser General Public
|
|
License version 3.0 as published by the Free Software Foundation;
|
|
|
|
This library is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
Lesser General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Lesser General Public
|
|
License along with this library; if not, write to the Free Software
|
|
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
*/
|
|
|
|
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 :Progress;
|
|
import :Asset;
|
|
namespace fs = std::filesystem;
|
|
using namespace Crafter;
|
|
|
|
|
|
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) -> bool {
|
|
for(const std::unique_ptr<Module>& interface : this->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 = (depCfg->PcmDir() / depInterface->path.filename()).string() + ".pcm";
|
|
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 : this->dependencies) {
|
|
if (walk(depCfg)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
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};
|
|
}
|
|
|
|
std::erase_if(tempModulePaths, [this](std::tuple<fs::path, std::string, ModulePartition*, Module*>& file) {
|
|
std::smatch match;
|
|
if (std::regex_search(std::get<1>(file), match, std::regex(R"(export module ([a-zA-Z0-9_\.\-]+);)"))) {
|
|
std::get<0>(file).replace_extension("");
|
|
this->interfaces.push_back(std::make_unique<Module>(std::move(match[1].str()), std::move(std::get<0>(file))));
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
for(std::uint16_t i = 0; i < tempModulePaths.size(); i++) {
|
|
std::smatch match;
|
|
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);
|
|
|
|
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);
|
|
++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);
|
|
}
|
|
++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);
|
|
++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). Walks cfg.dependencies recursively
|
|
// with a seen-set so diamond deps don't loop.
|
|
{
|
|
std::unordered_set<Configuration*> resetSeen;
|
|
std::function<void(Configuration*)> reset = [&](Configuration* c) {
|
|
if (!resetSeen.insert(c).second) return;
|
|
for (auto& iface : c->interfaces) {
|
|
iface->checked = false;
|
|
iface->compiled.store(false);
|
|
for (auto& part : iface->partitions) {
|
|
part->checked = false;
|
|
part->compiled.store(false);
|
|
}
|
|
}
|
|
for (Configuration* dep : c->dependencies) {
|
|
reset(dep);
|
|
}
|
|
};
|
|
reset(&config);
|
|
}
|
|
|
|
// 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<unsigned char>(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;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fs::path stdPcmDir = GetCacheDir()/(config.target+"-"+config.march);
|
|
|
|
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";
|
|
}
|
|
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 += " -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 += " " + flag;
|
|
}
|
|
command += userFlags;
|
|
|
|
std::string cmakeBuildType;
|
|
|
|
if(config.debug) {
|
|
cmakeBuildType = "Debug";
|
|
command += " -g -D CRAFTER_BUILD_CONFIGURATION_DEBUG";
|
|
} else {
|
|
cmakeBuildType = "Release";
|
|
command += " -O3";
|
|
}
|
|
|
|
// 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 = (buildDir / cFile.filename()).string() + "_source.o";
|
|
const std::string srcPath = cFile.string() + ".c";
|
|
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]() {
|
|
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, 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 = (buildDir / cFile.filename()).string() + "_source.o";
|
|
const std::string srcPath = cFile.string() + ".cu";
|
|
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<unsigned char>(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 += " " + 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 += " " + 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;
|
|
}
|
|
|
|
for(std::unique_ptr<Module>& interface : config.interfaces) {
|
|
if(interface->Check(pcmDir, externalFloor)) {
|
|
Module* mod = interface.get();
|
|
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());
|
|
}
|
|
}
|
|
});
|
|
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(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 += " " + flag;
|
|
}
|
|
// 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 / (c.outputName + ".wasm");
|
|
return c.target == "x86_64-w64-mingw32"
|
|
? dir / (c.outputName + ".exe")
|
|
: 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 / (iface->path.filename().string() + ".o"))) {
|
|
buildResult.repack = true;
|
|
break;
|
|
}
|
|
bool partHit = false;
|
|
for (const std::unique_ptr<ModulePartition>& part : iface->partitions) {
|
|
if (objNewer(buildDir / (part->path.filename().string() + ".o"))) {
|
|
partHit = true;
|
|
break;
|
|
}
|
|
}
|
|
if (partHit) { buildResult.repack = true; break; }
|
|
}
|
|
if (!buildResult.repack) {
|
|
for (const Implementation& impl : config.implementations) {
|
|
if (objNewer(buildDir / (impl.path.filename().string() + "_impl.o"))) {
|
|
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
|
|
buildResult.result = RunCommand(std::format("ar rcs {}.a {}", (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));
|
|
}
|
|
|
|
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<unsigned char>(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 << "]";
|
|
}
|
|
|
|
std::ifstream in(htmlTemplate);
|
|
std::stringstream buf;
|
|
buf << in.rdbuf();
|
|
std::string html = buf.str();
|
|
html = std::regex_replace(html, std::regex(R"(\{\{WASM\}\})"), cfg.outputName + ".wasm");
|
|
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);
|
|
}
|
|
|
|
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, 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()));
|
|
}
|
|
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};
|
|
}
|
|
|
|
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} 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*').
|
|
|
|
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.
|
|
|
|
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
|
|
1 build failure or one or more tests failed
|
|
)", 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 runAfterBuild = false;
|
|
RunTestsOptions testOpts;
|
|
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 && arg == "help")) {
|
|
PrintHelp(argv0);
|
|
return 0;
|
|
} else if (arg == "test") {
|
|
runTests = true;
|
|
} 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 {
|
|
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;
|
|
}
|
|
|
|
Configuration config = LoadProject(projectFile, projectArgs);
|
|
SetParentProject(&config);
|
|
|
|
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 = [](int basePort, int span) -> int {
|
|
#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_t = SOCKET;
|
|
const sock_t invalid = INVALID_SOCKET;
|
|
auto closesock = [](sock_t s){ closesocket(s); };
|
|
#else
|
|
using sock_t = int;
|
|
const sock_t invalid = -1;
|
|
auto closesock = [](sock_t s){ ::close(s); };
|
|
#endif
|
|
for (int p = basePort; p < basePort + span; ++p) {
|
|
sock_t 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 int 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;
|
|
}
|
|
if (isolated) {
|
|
std::println("serving {} via {} at http://localhost:{}/ (cross-origin isolated)",
|
|
absDir.string(), picked, port);
|
|
} else {
|
|
std::println("serving {} via {} at http://localhost:{}/", absDir.string(), picked, port);
|
|
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);
|
|
}
|
|
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;
|
|
}
|
|
}
|