From c466d90eec36149fc9adc58fa47257b6ea0b65fe Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Mon, 18 May 2026 05:23:11 +0200 Subject: [PATCH] wasm improvements --- implementations/Crafter.Build-Clang.cpp | 163 +++++++++++++- implementations/Crafter.Build-Platform.cpp | 2 +- wasi-runtime/index.html.in | 2 +- wasi-runtime/runtime.js | 243 +++++++++++++++++++-- 4 files changed, 386 insertions(+), 24 deletions(-) diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index 3880b21..edff5a0 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -471,7 +471,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map fs::last_write_time(objPath))) { - threads.emplace_back([&cFile, &buildDir, &buildError, &buildCancelled, &config, &includeFlags, &defineFlags, &userFlags]() { + 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={} -march={} -mtune={} -O3 -c{}{}{} -o {}_source.o", cFile.string(), config.target, config.march, config.mtune, includeFlags, defineFlags, userFlags, (buildDir / cFile.filename()).string())); + 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; @@ -1034,16 +1050,79 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) { 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 `\n", name); + } + + // 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 basenames only; + // each file lives next to the .wasm in the bin dir. + std::vector assetFiles; + seen.clear(); + std::function 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; + std::string name = f.filename().string(); + if (name.empty()) continue; + if (std::find(assetFiles.begin(), assetFiles.end(), name) == assetFiles.end()) { + assetFiles.push_back(std::move(name)); + } + } + 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 = std::regex_replace(buf.str(), std::regex(R"(\{\{WASM\}\})"), cfg.outputName + ".wasm"); + 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); 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() { @@ -1087,6 +1166,15 @@ ArgQuery Crafter::ApplyStandardArgs(Configuration& cfg, std::span 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) { + // Try installed HTTP servers in priority order: lightweight + // / dependency-free first, ad-hoc ones last. Foreground + // (Ctrl-C to stop); we exec, not fork. + const int port = 8080; + std::string cmd; + std::string_view picked; + if (have("caddy")) { + picked = "caddy"; + cmd = std::format("caddy file-server --listen :{} --root {}", port, absDir.string()); + } else if (have("python3")) { + picked = "python3"; + cmd = std::format("python3 -m http.server --directory {} {}", absDir.string(), port); + } else if (have("python")) { + picked = "python"; + cmd = std::format("python -m http.server --directory {} {}", absDir.string(), port); + } else if (have("php")) { + picked = "php"; + cmd = std::format("php -S 0.0.0.0:{} -t {}", port, absDir.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("serving {} via {} at http://localhost:{}/", absDir.string(), picked, port); + 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". - artifact = fs::absolute(artifact); // 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 diff --git a/implementations/Crafter.Build-Platform.cpp b/implementations/Crafter.Build-Platform.cpp index 16bdc81..3e9bf96 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -705,7 +705,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) { // EH); signal.h requires the emulation define; and EH itself isn't // wired up so -fno-exceptions stays. std::string archFlags = isWasm - ? std::string(" -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL") + ? std::string(" -fno-exceptions -msimd128 -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL") : std::format(" -march={} -mtune={}", config.march, config.mtune); if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCppm)) { return RunCommand(std::format("clang++ --target={} -std=c++26 -stdlib=libc++{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string())); diff --git a/wasi-runtime/index.html.in b/wasi-runtime/index.html.in index a1cc797..d22b057 100644 --- a/wasi-runtime/index.html.in +++ b/wasi-runtime/index.html.in @@ -4,7 +4,7 @@ {{WASM}} - +{{ENV_SCRIPTS}} diff --git a/wasi-runtime/runtime.js b/wasi-runtime/runtime.js index 4de1270..ceaae77 100644 --- a/wasi-runtime/runtime.js +++ b/wasi-runtime/runtime.js @@ -11,22 +11,41 @@ class Wasi { #envEncodedStrings; #argEncodedStrings; instance; + // VFS: file basename → Uint8Array, populated by EnableWasiBrowserRuntime's + // preload step before instantiateStreaming runs. Used by path_open / fd_read + // / fd_seek / fd_tell / fd_filestat_get / fd_close so wasi-libc's + // std::ifstream and friends actually work in the browser. + vfs; + #fdTable; + #nextFd; + #decoder; - constructor({ env, stdin, args }) { + constructor({ env, stdin, args, vfs }) { this.#encodedStdin = textEncoder.encode(stdin); const envStrings = Object.entries(env).map(([k, v]) => `${k}=${v}`); this.#envEncodedStrings = envStrings.map(s => textEncoder.encode(s + "\0")); this.#argEncodedStrings = args.map(s => textEncoder.encode(s + "\0")); + this.vfs = vfs || new Map(); + this.#fdTable = new Map(); + this.#nextFd = 100; // stay clear of stdio + this.#decoder = new TextDecoder(); this.bind(); } bind() { - this.args_get = this.args_get.bind(this); - this.args_sizes_get = this.args_sizes_get.bind(this); - this.environ_get = this.environ_get.bind(this); - this.environ_sizes_get = this.environ_sizes_get.bind(this); - this.fd_read = this.fd_read.bind(this); - this.fd_write = this.fd_write.bind(this); + // wasi imports are looked up as plain function references at + // instantiate time, so any method that touches `this` MUST be + // explicitly bound here. Anything purely no-op (returning 0) can + // stay unbound. + const m = [ + "args_get", "args_sizes_get", + "environ_get", "environ_sizes_get", + "fd_read", "fd_write", "fd_close", "fd_seek", "fd_tell", + "fd_filestat_get", "fd_fdstat_get", + "fd_prestat_get", "fd_prestat_dir_name", + "path_open", + ]; + for (const name of m) this[name] = this[name].bind(this); } args_sizes_get(argCountPtr, argBufferSizePtr) { @@ -71,6 +90,7 @@ class Wasi { fd_read(fd, iovsPtr, iovsLength, bytesReadPtr) { const memory = new Uint8Array(this.instance.exports.memory.buffer); const iovs = new Uint32Array(this.instance.exports.memory.buffer, iovsPtr, iovsLength * 2); + const dataView = new DataView(this.instance.exports.memory.buffer); let totalBytesRead = 0; if (fd === 0) { for (let i = 0; i < iovsLength * 2; i += 2) { @@ -82,24 +102,120 @@ class Wasi { totalBytesRead += chunk.byteLength; if (this.#encodedStdin.length === 0) break; } - const dataView = new DataView(this.instance.exports.memory.buffer); dataView.setInt32(bytesReadPtr, totalBytesRead, true); + return 0; } + const entry = this.#fdTable.get(fd); + if (!entry) { + dataView.setInt32(bytesReadPtr, 0, true); + return 8; // EBADF + } + const file = this.vfs.get(entry.name); + for (let i = 0; i < iovsLength * 2; i += 2) { + const offset = iovs[i]; + const length = iovs[i + 1]; + const remaining = file.byteLength - entry.offset; + if (remaining <= 0) break; + const n = Math.min(length, remaining); + memory.set(file.subarray(entry.offset, entry.offset + n), offset); + entry.offset += n; + totalBytesRead += n; + if (n < length) break; + } + dataView.setInt32(bytesReadPtr, totalBytesRead, true); return 0; } fd_advise() { return 0; } - fd_close() { return 0; } - fd_fdstat_get() { return 0; } - fd_prestat_get() { return 0; } - fd_prestat_dir_name() { return 0; } + fd_close(fd) { + this.#fdTable.delete(fd); + return 0; + } + fd_fdstat_get(fd, statPtr) { + // 24 bytes: filetype(1) + flags(2) + padding + rights_base(8) + rights_inheriting(8). + const dv = new DataView(this.instance.exports.memory.buffer); + const isFile = this.#fdTable.has(fd); + const filetype = isFile ? 4 : 0; // 4 = regular_file + dv.setUint8(statPtr + 0, filetype); + dv.setUint16(statPtr + 2, 0, true); + dv.setBigUint64(statPtr + 8, 0xFFFFFFFFFFFFFFFFn, true); + dv.setBigUint64(statPtr + 16, 0xFFFFFFFFFFFFFFFFn, true); + return 0; + } + // wasi-libc walks preopens starting at fd=3 until fd_prestat_get returns + // EBADF, then resolves every relative open against one of the discovered + // dirs. Without at least one preopen, std::ifstream et al can't open any + // path. We expose a single "/" preopen on fd=3, rooted at the VFS map. + fd_prestat_get(fd, prestatPtr) { + const dv = new DataView(this.instance.exports.memory.buffer); + if (fd === 3) { + // prestat_t: tag(u8) + padding to 4 + u.dir.pr_name_len(u32). 8 bytes. + dv.setUint8(prestatPtr + 0, 0); // tag = preopentype_dir + dv.setUint32(prestatPtr + 4, 1, true); // strlen("/") + return 0; + } + return 8; // EBADF + } + fd_prestat_dir_name(fd, pathPtr, pathLen) { + if (fd !== 3) return 8; + const memory = new Uint8Array(this.instance.exports.memory.buffer); + const name = textEncoder.encode("/"); + memory.set(name.subarray(0, Math.min(name.byteLength, pathLen)), pathPtr); + return 0; + } clock_res_get() { return 0; } clock_time_get() { return 0; } - fd_seek() { return 0; } + fd_seek(fd, offsetLow, whence, newOffsetPtr) { + // offsetLow is a BigInt under wasi-snapshot-preview1's two-i32 ABI? + // No — wasi-snapshot-preview1 fd_seek's signature is (fd, filedelta:i64, + // whence:u8, newoffset:ptr). JS receives the i64 as BigInt. + const entry = this.#fdTable.get(fd); + const dv = new DataView(this.instance.exports.memory.buffer); + if (!entry) { + dv.setBigUint64(newOffsetPtr, 0n, true); + return 8; + } + const delta = typeof offsetLow === "bigint" ? Number(offsetLow) : Number(offsetLow); + const file = this.vfs.get(entry.name); + let newOff; + switch (whence) { + case 0: newOff = delta; break; // SEEK_SET + case 1: newOff = entry.offset + delta; break; // SEEK_CUR + case 2: newOff = file.byteLength + delta; break; // SEEK_END + default: return 28; // EINVAL + } + if (newOff < 0) newOff = 0; + if (newOff > file.byteLength) newOff = file.byteLength; + entry.offset = newOff; + dv.setBigUint64(newOffsetPtr, BigInt(newOff), true); + return 0; + } + fd_tell(fd, offsetPtr) { + const entry = this.#fdTable.get(fd); + const dv = new DataView(this.instance.exports.memory.buffer); + if (!entry) { dv.setBigUint64(offsetPtr, 0n, true); return 8; } + dv.setBigUint64(offsetPtr, BigInt(entry.offset), true); + return 0; + } fd_allocate() { return 0; } fd_datasync() { return 0; } fd_fdstat_set_flags() { return 0; } fd_fdstat_set_rights() { return 0; } - fd_filestat_get() { return 0; } + fd_filestat_get(fd, statPtr) { + // Layout: dev(8) ino(8) filetype(1) +pad nlink(8) size(8) atim(8) mtim(8) ctim(8) = 64 bytes. + const dv = new DataView(this.instance.exports.memory.buffer); + const entry = this.#fdTable.get(fd); + if (!entry) return 8; + const file = this.vfs.get(entry.name); + dv.setBigUint64(statPtr + 0, 0n, true); + dv.setBigUint64(statPtr + 8, 0n, true); + dv.setUint8(statPtr + 16, 4); // filetype = regular_file + dv.setBigUint64(statPtr + 24, 1n, true); + dv.setBigUint64(statPtr + 32, BigInt(file.byteLength), true); + dv.setBigUint64(statPtr + 40, 0n, true); + dv.setBigUint64(statPtr + 48, 0n, true); + dv.setBigUint64(statPtr + 56, 0n, true); + return 0; + } fd_filestat_set_size() { return 0; } fd_filestat_set_times() { return 0; } fd_pread() { return 0; } @@ -112,7 +228,22 @@ class Wasi { path_filestat_get() { return 0; } path_filestat_set_times() { return 0; } path_link() { return 0; } - path_open() { return 0; } + path_open(_dirfd, _dirflags, pathPtr, pathLen, _oflags, _rightsBase, _rightsInh, _fdflags, openedFdPtr) { + const memory = new Uint8Array(this.instance.exports.memory.buffer); + const dv = new DataView(this.instance.exports.memory.buffer); + const raw = this.#decoder.decode(memory.subarray(pathPtr, pathPtr + pathLen)); + // wasi-libc may pass paths like "./font.ttf" or "/font.ttf" or "font.ttf" + // depending on how std::filesystem::path was constructed. Reduce to basename. + const base = raw.split(/[\\/]/).filter(Boolean).pop() || raw; + if (!this.vfs.has(base)) { + dv.setInt32(openedFdPtr, -1, true); + return 44; // ENOENT + } + const fd = this.#nextFd++; + this.#fdTable.set(fd, { name: base, offset: 0 }); + dv.setInt32(openedFdPtr, fd, true); + return 0; + } path_readlink() { return 0; } path_remove_directory() { return 0; } path_rename() { return 0; } @@ -147,7 +278,17 @@ class Wasi { return 0; } proc_exit(code) { + // Throw a sentinel so the wasm stack unwinds back to runtime.js + // WITHOUT executing the `unreachable` instruction wasi-libc emits + // after __wasi_proc_exit (which is declared noreturn). Trapping + // there would mark the wasm call as crashed and may interrupt any + // browser-side render loop that called _Exit on purpose (e.g. + // Crafter::Window::StartSync on DOM, which hands the loop to rAF + // and then exits main without running static destructors). console.log(`[wasi] proc_exit(${code})`); + const e = new Error(`wasi proc_exit(${code})`); + e.crafterWasiExit = code; + throw e; } } @@ -156,12 +297,80 @@ if (!wasmUrl) { throw new Error("runtime.js: window.CRAFTER_WASM_URL is not set (set it in index.html before loading runtime.js)"); } -const wasi = new Wasi({ stdin: "", env: {}, args: [] }); +// Preload asset files listed in files.json (emitted by +// EnableWasiBrowserRuntime) into an in-memory VFS so wasi-libc's file +// syscalls work against them. Browser builds otherwise can't open assets +// shipped alongside the .wasm — sync XHR is too deprecated to rely on. +const vfs = new Map(); +try { + const manifestResp = await fetch("files.json"); + if (manifestResp.ok) { + const names = await manifestResp.json(); + await Promise.all(names.map(async (name) => { + const r = await fetch(name); + if (r.ok) vfs.set(name, new Uint8Array(await r.arrayBuffer())); + else console.warn(`[wasi] failed to preload ${name}: HTTP ${r.status}`); + })); + } +} catch (e) { + console.warn("[wasi] no files.json manifest (or fetch failed); file I/O syscalls will return ENOENT:", e.message); +} + +const wasi = new Wasi({ stdin: "", env: {}, args: [], vfs }); + +// Modules that need env imports (Crafter.Graphics DOM mode, etc.) ship a +// co-located env.js that sets `window.crafter_webbuild_env`. The +// `EnableWasiBrowserRuntime` injects each one as a regular