Crafter.Build/wasi-runtime/runtime.js
Jorijn van der Graaf 0dabce0b74
fix(wasi-runtime): end exit and park without running the program
runtime.js implemented proc_exit by throwing a JavaScript error through
the wasm stack, and the switch to -fwasm-exceptions made that error
catchable: every cleanup pad, catch (...) and noexcept terminate pad
catches a foreign exception. An exit therefore ran the destructors of
every frame it left, a catch (...) swallowed it and carried on, and
under a noexcept frame it ended in std::terminate. Crafter.Graphics
calls std::_Exit(0) in Window::StartSync to hand the thread to
requestAnimationFrame; once the exit's destructors had torn down
main's locals, every later frame read destroyed state.

proc_exit is now a function of a small helper module, bound directly
as the program's import, that reports to the runtime and then executes
unreachable. A trap is the one thing wasm code cannot catch, and raised
in wasm rather than from JS it does not depend on each engine keeping
a trap uncatchable when it passes back through a JS frame. Nothing of
the program runs on the way out and its shadow stack stays as it was,
which is what exit and _Exit require. window.crafter_wasi.exitCode
holds the status, also after a return from main, so that bridges which
call into the program on their own can stop.

Handing the thread to the browser for good is a separate import,
crafter_runtime.park. It ends the call the same way but leaves the
program alive, and main's locals valid for the calls that follow, so
StartSync keeps its contract that no caller code after it runs. Until
Crafter.Graphics moves to park, its _Exit behaves as before minus the
destructors: the runtime records an exit but cannot stop a bridge that
calls exports itself.

A WASI reactor build, where main returns and state lives in statics,
was the alternative. It breaks StartSync's promise never to return,
which the native targets rely on. JSPI would keep the whole stack
alive, but Safari does not have it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:55:21 +01:00

1049 lines
47 KiB
JavaScript

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Minimal browser WASI shim. Loaded by index.html, which sets
// window.CRAFTER_WASM_URL before this script runs so a single runtime.js
// can serve any output name. Most syscalls return 0 — enough to host a
// hello-world that uses fd_write (stdout) and the args/environ probes
// libc invokes during startup. Extend as needed.
const textEncoder = new TextEncoder();
// ─── Two VFS roots ─────────────────────────────────────────────────────
// fd=3 ⟶ read-only baked layer at "/". Populated from files.json at
// startup, keyed by basename — the bin-dir layout `cfg.assets`
// produces is flat enough that basename collapse doesn't
// collide for legitimate assets. Writes are rejected.
// fd=4 ⟶ read/write persistent layer at "/persistent". Backed by OPFS
// (Origin Private File System), keyed by relative path so
// nested directories work. The wasm sees an in-memory map
// (writes are synchronous from its POV); a background task
// mirrors dirty entries back to OPFS so they survive reload.
//
// wasi-libc walks preopens starting at fd=3 and resolves every open()
// against the longest matching preopen. "/persistent/foo.json" hits fd=4
// with relpath "foo.json"; "/Inter.ttf" hits fd=3 with relpath "Inter.ttf".
const readonlyVfs = new Map(); // basename → Uint8Array (immutable)
const persistentVfs = new Map(); // relpath → Uint8Array (mutable)
// Paths the wasm has mkdir'd. Our backing store is flat-keyed so dirs
// don't otherwise exist on their own — but libstdc++'s
// `create_directories` mkdir's, then re-stats each component, and
// expects the stat to report "directory". Tracking explicit mkdir'd
// paths here keeps that loop convergent.
const persistentDirs = new Set();
const ROOTS = {
3: { kind: "ro", map: readonlyVfs, name: "/" },
4: { kind: "rw", map: persistentVfs, name: "/persistent" },
};
function resolveKey(_root, raw) {
// Both VFS layers key by relative path now. We did keep the read-
// only side basename-collapsed up to Phase 1, but that broke as
// soon as two bundled dirs shipped a file with the same basename
// (e.g. maps/A/map.json vs maps/B/map.json — only one survives the
// preload). Full-path keying matches what files.json actually
// serves and what cfg.assets actually deploys.
let key = raw.replace(/\\/g, "/");
while (key.startsWith("./")) key = key.slice(2);
while (key.startsWith("/")) key = key.slice(1);
return key;
}
// Directory tree for the read-only baked layer. Built after files.json
// preload completes — each path like "maps/A/map.json" registers as:
// "" → { "maps": "dir" }
// "maps" → { "A": "dir" }
// "maps/A" → { "map.json":"file" }
// Used by path_open (recognize directory paths) and fd_readdir
// (iterate entries).
const readonlyDirs = new Map();
function roBuildDirTree() {
readonlyDirs.clear();
const addChild = (parent, name, kind) => {
let entries = readonlyDirs.get(parent);
if (!entries) {
entries = new Map();
readonlyDirs.set(parent, entries);
}
if (!entries.has(name)) entries.set(name, kind);
};
for (const path of readonlyVfs.keys()) {
const parts = path.split("/").filter(Boolean);
for (let i = 0; i < parts.length; ++i) {
const parent = parts.slice(0, i).join("/");
const name = parts[i];
const kind = (i === parts.length - 1) ? "file" : "dir";
addChild(parent, name, kind);
}
}
}
// ─── OPFS mirror for the persistent layer ──────────────────────────────
let opfsRoot = null;
const dirtyPaths = new Set();
let flushTimer = null;
const FLUSH_DEBOUNCE_MS = 750;
async function opfsResolveDir(parts, create) {
if (!opfsRoot) return null;
let dir = opfsRoot;
for (const part of parts) {
if (!part) continue;
dir = await dir.getDirectoryHandle(part, { create });
}
return dir;
}
async function opfsHydrate() {
if (!navigator.storage || !navigator.storage.getDirectory) {
console.warn("[wasi] OPFS unavailable — persistent writes will not survive reload");
return;
}
try {
opfsRoot = await navigator.storage.getDirectory();
} catch (e) {
console.warn("[wasi] OPFS getDirectory failed:", e?.message);
return;
}
async function walk(dir, prefix) {
for await (const [name, handle] of dir.entries()) {
const full = prefix ? `${prefix}/${name}` : name;
if (handle.kind === "file") {
try {
const file = await handle.getFile();
persistentVfs.set(full, new Uint8Array(await file.arrayBuffer()));
} catch (e) {
console.warn(`[wasi] OPFS read failed for ${full}:`, e?.message);
}
} else if (handle.kind === "directory") {
await walk(handle, full);
}
}
}
await walk(opfsRoot, "");
}
async function opfsFlushOne(path) {
if (!opfsRoot) return;
const parts = path.split("/").filter(Boolean);
if (parts.length === 0) return;
const data = persistentVfs.get(path);
if (data === undefined) {
// Removed entry — delete from OPFS, walking parents.
try {
const parent = await opfsResolveDir(parts.slice(0, -1), false);
await parent.removeEntry(parts[parts.length - 1]);
} catch (e) {
// Missing entry is fine; other errors get reported.
if (e && e.name !== "NotFoundError") {
console.warn(`[wasi] OPFS remove failed for ${path}:`, e?.message);
}
}
return;
}
try {
const dir = await opfsResolveDir(parts.slice(0, -1), true);
const fh = await dir.getFileHandle(parts[parts.length - 1], { create: true });
const w = await fh.createWritable();
await w.write(data);
await w.close();
} catch (e) {
console.warn(`[wasi] OPFS write failed for ${path}:`, e?.message);
}
}
async function opfsFlushAll() {
if (!opfsRoot) return;
const batch = Array.from(dirtyPaths);
dirtyPaths.clear();
for (const path of batch) {
await opfsFlushOne(path);
}
}
function scheduleFlush() {
if (flushTimer !== null) return;
flushTimer = setTimeout(() => {
flushTimer = null;
opfsFlushAll();
}, FLUSH_DEBOUNCE_MS);
}
function markDirty(path) {
dirtyPaths.add(path);
scheduleFlush();
}
// ─── leaving the program ───────────────────────────────────────────────
// exit and _Exit (proc_exit) end the program. park hands the thread to the
// browser for good while the program goes on serving the calls bridges make
// into it (Crafter::Window::StartSync on DOM): the import
// crafter_runtime.park, `void park(void)`, which never returns. Either way
// the current call into the program has to end without running any more of
// its code: not the destructors of the frames it leaves, not a catch (...),
// not the std::terminate of a noexcept function. Built with
// -fwasm-exceptions, every one of those catches a JavaScript exception
// thrown through it, so the way out cannot be a throw from JS. A trap is
// the one thing wasm code cannot catch, and this module raises it in wasm
// itself, bound directly as the program's import; a trap raised from JS
// would depend on each engine keeping it uncatchable when it passes back
// into wasm. It reports first, through host.ended, which has returned by
// the time it traps:
// (import "host" "ended" (func $ended (param $how i32) (param $code i32)))
// (func (export "exit") (param $code i32)
// i32.const 1 local.get $code call $ended unreachable)
// (func (export "park")
// i32.const 2 i32.const 0 call $ended unreachable)
// The program's own state is left as it is, its shadow stack included, so
// the locals of a parked main stay valid for the calls that follow.
const PROGRAM_END = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x0d, 0x03, 0x60, 0x02, 0x7f, 0x7f, 0x00, 0x60, 0x01, 0x7f, 0x00, 0x60, 0x00, 0x00,
0x02, 0x0e, 0x01, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x05, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x00, 0x00,
0x03, 0x03, 0x02, 0x01, 0x02,
0x07, 0x0f, 0x02, 0x04, 0x65, 0x78, 0x69, 0x74, 0x00, 0x01, 0x04, 0x70, 0x61, 0x72, 0x6b, 0x00, 0x02,
0x0a, 0x15, 0x02,
0x09, 0x00, 0x41, 0x01, 0x20, 0x00, 0x10, 0x00, 0x00, 0x0b,
0x09, 0x00, 0x41, 0x02, 0x41, 0x00, 0x10, 0x00, 0x00, 0x0b,
]);
const ENDED_BY_EXIT = 1;
const ENDED_BY_PARK = 2;
class Wasi {
#encodedStdin;
#envEncodedStrings;
#argEncodedStrings;
instance;
// The program's exit status once it has ended, through exit or _Exit or
// by returning from main; null while it runs or is parked. A bridge that
// calls into the program on its own, such as a frame loop, stops once
// this is set: nothing of an ended program may run.
exitCode = null;
// True once the program has parked (see PROGRAM_END).
parked = false;
#fdTable;
#nextFd;
#decoder;
constructor({ env, stdin, args }) {
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.#fdTable = new Map();
this.#nextFd = 100; // stay clear of stdio + preopens
this.#decoder = new TextDecoder();
this.bind();
const end = new WebAssembly.Instance(new WebAssembly.Module(PROGRAM_END), {
host: { ended: (how, code) => this.ended(how, code) },
}).exports;
// Own properties, so instantiation takes these wasm functions as the
// program's imports rather than a JS method.
this.proc_exit = end.exit;
this.park = end.park;
}
// Called by PROGRAM_END just before its trap ends the call into the
// program. An exit after a park ends the program all the same.
ended(how, code) {
if (how === ENDED_BY_PARK) {
this.parked = true;
} else if (how === ENDED_BY_EXIT) {
console.log(`[wasi] proc_exit(${code})`);
this.exitCode = code;
// The last chance for anything written this session to reach
// OPFS before the page is torn down.
opfsFlushAll();
}
}
bind() {
// 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_pwrite", "fd_readdir",
"fd_close", "fd_seek", "fd_tell",
"fd_filestat_get", "fd_fdstat_get",
"fd_prestat_get", "fd_prestat_dir_name",
"path_open", "path_create_directory", "path_remove_directory",
"path_unlink_file", "path_rename", "path_filestat_get",
// clock_res_get + clock_time_get touch this.instance.exports
// .memory; the old stubs were no-ops and didn't need binding.
"clock_res_get", "clock_time_get",
// random_get now writes into this.instance.exports.memory too.
"random_get",
];
for (const name of m) this[name] = this[name].bind(this);
}
args_sizes_get(argCountPtr, argBufferSizePtr) {
const argByteLength = this.#argEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0);
const countPointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, argCountPtr, 1);
const sizePointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, argBufferSizePtr, 1);
countPointerBuffer[0] = this.#argEncodedStrings.length;
sizePointerBuffer[0] = argByteLength;
return 0;
}
args_get(argsPtr, argBufferPtr) {
const argsByteLength = this.#argEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0);
const argsPointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, argsPtr, this.#argEncodedStrings.length);
const argsBuffer = new Uint8Array(this.instance.exports.memory.buffer, argBufferPtr, argsByteLength);
let pointerOffset = 0;
for (let i = 0; i < this.#argEncodedStrings.length; i++) {
argsPointerBuffer[i] = argBufferPtr + pointerOffset;
argsBuffer.set(this.#argEncodedStrings[i], pointerOffset);
pointerOffset += this.#argEncodedStrings[i].byteLength;
}
return 0;
}
fd_write(fd, iovsPtr, iovsLength, bytesWrittenPtr) {
const dv = new DataView(this.instance.exports.memory.buffer);
const iovs = new Uint32Array(this.instance.exports.memory.buffer, iovsPtr, iovsLength * 2);
if (fd === 1 || fd === 2) {
let text = "";
let totalBytesWritten = 0;
const decoder = new TextDecoder();
for (let i = 0; i < iovsLength * 2; i += 2) {
const offset = iovs[i];
const length = iovs[i + 1];
text += decoder.decode(new Int8Array(this.instance.exports.memory.buffer, offset, length));
totalBytesWritten += length;
}
dv.setInt32(bytesWrittenPtr, totalBytesWritten, true);
(fd === 2 ? console.error : console.log)(text);
return 0;
}
const entry = this.#fdTable.get(fd);
if (!entry) {
dv.setInt32(bytesWrittenPtr, 0, true);
return 8; // EBADF
}
const root = ROOTS[entry.rootFd];
if (!root || root.kind !== "rw") {
dv.setInt32(bytesWrittenPtr, 0, true);
return 76; // EROFS — close enough; libc surfaces this as "no permission"
}
// Sum iov lengths so we can grow the entry once.
let total = 0;
for (let i = 0; i < iovsLength * 2; i += 2) total += iovs[i + 1];
let data = root.map.get(entry.name) ?? new Uint8Array(0);
const end = entry.offset + total;
if (data.byteLength < end) {
const grown = new Uint8Array(end);
grown.set(data, 0);
data = grown;
root.map.set(entry.name, data);
}
const memory = new Uint8Array(this.instance.exports.memory.buffer);
let written = 0;
for (let i = 0; i < iovsLength * 2; i += 2) {
const offset = iovs[i];
const length = iovs[i + 1];
data.set(memory.subarray(offset, offset + length), entry.offset + written);
written += length;
}
entry.offset += written;
dv.setInt32(bytesWrittenPtr, written, true);
markDirty(entry.name);
return 0;
}
fd_pwrite(fd, iovsPtr, iovsLength, offsetArg, bytesWrittenPtr) {
// pwrite is positioned write — same as fd_write but the seek pos
// isn't advanced. We just temporarily override entry.offset.
const entry = this.#fdTable.get(fd);
if (!entry) {
new DataView(this.instance.exports.memory.buffer)
.setInt32(bytesWrittenPtr, 0, true);
return 8;
}
const saved = entry.offset;
entry.offset = typeof offsetArg === "bigint" ? Number(offsetArg) : Number(offsetArg);
const rc = this.fd_write(fd, iovsPtr, iovsLength, bytesWrittenPtr);
entry.offset = saved;
return rc;
}
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) {
const offset = iovs[i];
const length = iovs[i + 1];
const chunk = this.#encodedStdin.slice(0, length);
this.#encodedStdin = this.#encodedStdin.slice(length);
memory.set(chunk, offset);
totalBytesRead += chunk.byteLength;
if (this.#encodedStdin.length === 0) break;
}
dataView.setInt32(bytesReadPtr, totalBytesRead, true);
return 0;
}
const entry = this.#fdTable.get(fd);
if (!entry) {
dataView.setInt32(bytesReadPtr, 0, true);
return 8; // EBADF
}
const root = ROOTS[entry.rootFd];
const file = root ? root.map.get(entry.name) : undefined;
if (!file) {
dataView.setInt32(bytesReadPtr, 0, true);
return 0; // EOF
}
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(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);
let filetype;
if (ROOTS[fd]) {
filetype = 3; // directory — preopen mount
} else {
const entry = this.#fdTable.get(fd);
if (entry) filetype = entry.isDir ? 3 : 4;
else filetype = 0;
}
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. Two preopens: fd=3 ("/") for baked assets, fd=4 ("/persistent")
// for OPFS-backed mutable state.
fd_prestat_get(fd, prestatPtr) {
const dv = new DataView(this.instance.exports.memory.buffer);
const root = ROOTS[fd];
if (!root) return 8; // EBADF — terminates the walk
// 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, root.name.length, true);
return 0;
}
fd_prestat_dir_name(fd, pathPtr, pathLen) {
const root = ROOTS[fd];
if (!root) return 8;
const memory = new Uint8Array(this.instance.exports.memory.buffer);
const name = textEncoder.encode(root.name);
memory.set(name.subarray(0, Math.min(name.byteLength, pathLen)), pathPtr);
return 0;
}
// WASI clock_res_get(clock_id, resPtr): writes a u64 nanosecond
// resolution to *resPtr. We back every clock with performance.now()
// which the platform documents as ≥5µs resolution on modern browsers
// — round-trip via Math.round() loses sub-µs precision but anything
// more aggressive would mis-represent the actual host resolution.
clock_res_get(_clockId, resPtr) {
const dv = new DataView(this.instance.exports.memory.buffer);
dv.setBigUint64(resPtr, 1000n, /*littleEndian*/ true); // 1µs in ns
return 0;
}
// WASI clock_time_get(clock_id, precision, timePtr): writes a u64
// nanosecond timestamp. The stub used to return 0 unconditionally,
// which made every std::chrono::high_resolution_clock::now() in wasm
// return the same value — Physics::Update read delta=0 each frame
// and never advanced. Use performance.now() for MONOTONIC and a
// Date.now()-relative reference for REALTIME so std::chrono actually
// works.
clock_time_get(clockId, _precision, timePtr) {
const dv = new DataView(this.instance.exports.memory.buffer);
// clock IDs per wasi-snapshot-preview1:
// 0 = realtime, 1 = monotonic, 2 = process_cputime_id, 3 = thread_cputime_id
let nanos;
if (clockId === 0) {
// Date.now() returns ms since the Unix epoch.
nanos = BigInt(Date.now()) * 1000000n;
} else {
// performance.now() returns ms since the page navigation
// start with sub-ms precision. Anchor against
// performance.timeOrigin so caller sees a clean monotonic
// walltime equivalent.
const ms = performance.now();
nanos = BigInt(Math.round(ms * 1e6));
}
dv.setBigUint64(timePtr, nanos, /*littleEndian*/ true);
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<filesize>). 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 root = ROOTS[entry.rootFd];
const file = root ? root.map.get(entry.name) : undefined;
const size = file ? file.byteLength : 0;
let newOff;
switch (whence) {
case 0: newOff = delta; break; // SEEK_SET
case 1: newOff = entry.offset + delta; break; // SEEK_CUR
case 2: newOff = size + delta; break; // SEEK_END
default: return 28; // EINVAL
}
if (newOff < 0) newOff = 0;
// Writeable fds may seek past EOF (the next write extends the
// backing buffer); read-only fds clamp to EOF.
if (!root || root.kind !== "rw") {
if (newOff > size) newOff = size;
}
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(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);
let filetype = 4; // regular_file (default)
let size = 0;
if (ROOTS[fd]) {
filetype = 3; // directory — preopen mount itself
} else {
const entry = this.#fdTable.get(fd);
if (!entry) return 8;
if (entry.isDir) {
filetype = 3;
} else {
const root = ROOTS[entry.rootFd];
const file = root ? root.map.get(entry.name) : undefined;
size = file ? file.byteLength : 0;
}
}
dv.setBigUint64(statPtr + 0, 0n, true);
dv.setBigUint64(statPtr + 8, 0n, true);
dv.setUint8(statPtr + 16, filetype);
dv.setBigUint64(statPtr + 24, 1n, true);
dv.setBigUint64(statPtr + 32, BigInt(size), 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; }
fd_readdir(fd, bufPtr, bufLen, cookieArg, bytesUsedPtr) {
// wasi dirent: dircookie(8) + inode(8) + namlen(4) + filetype(1)
// = 24 bytes header (the 3-byte tail is implicit padding before
// the variable-length name). The cookie returned in each entry
// is the value the caller passes in to read the *next* entry.
// We return at most bufLen bytes; libc reads, processes, calls
// back with the last entry's cookie, repeats until short read.
const dv = new DataView(this.instance.exports.memory.buffer);
const mem = new Uint8Array(this.instance.exports.memory.buffer);
const entry = this.#fdTable.get(fd);
if (!entry || !entry.isDir) {
dv.setInt32(bytesUsedPtr, 0, true);
return 8; // EBADF
}
const root = ROOTS[entry.rootFd];
if (!root) {
dv.setInt32(bytesUsedPtr, 0, true);
return 8;
}
// Gather the immediate children of `entry.name`.
let children;
if (root.kind === "ro") {
const dirMap = readonlyDirs.get(entry.name);
children = dirMap ? Array.from(dirMap.entries()) : [];
} else {
// Persistent: derive children from the keys themselves —
// strip the prefix, take the first remaining component,
// dedupe (a subdir contributes one entry per child file).
const prefix = entry.name === "" ? "" : entry.name + "/";
const seen = new Map();
for (const k of root.map.keys()) {
if (!k.startsWith(prefix)) continue;
const rest = k.slice(prefix.length);
if (rest === "") continue;
const slash = rest.indexOf("/");
if (slash < 0) {
if (!seen.has(rest)) seen.set(rest, "file");
} else {
const name = rest.slice(0, slash);
if (!seen.has(name)) seen.set(name, "dir");
}
}
// Surface mkdir'd dirs that have no files yet too.
const dirPrefix = entry.name === "" ? "" : entry.name + "/";
for (const d of persistentDirs) {
if (!d.startsWith(dirPrefix)) continue;
const rest = d.slice(dirPrefix.length);
const slash = rest.indexOf("/");
const name = slash < 0 ? rest : rest.slice(0, slash);
if (name && !seen.has(name)) seen.set(name, "dir");
}
children = Array.from(seen.entries());
}
const startCookie = typeof cookieArg === "bigint" ? Number(cookieArg) : Number(cookieArg);
let cursor = bufPtr;
let used = 0;
const textEnc = new TextEncoder();
for (let i = startCookie; i < children.length; ++i) {
const [name, kind] = children[i];
const nameBytes = textEnc.encode(name);
const recSize = 24 + nameBytes.byteLength;
if (used + 24 > bufLen) break;
const nextCookie = BigInt(i + 1);
dv.setBigUint64(cursor + 0, nextCookie, true); // d_next
dv.setBigUint64(cursor + 8, BigInt(i + 1), true); // d_ino (synthetic)
dv.setUint32(cursor + 16, nameBytes.byteLength, true); // d_namlen
dv.setUint8(cursor + 20, kind === "dir" ? 3 : 4); // d_type
mem.fill(0, cursor + 21, cursor + 24); // padding
const writeName = Math.min(nameBytes.byteLength, bufLen - used - 24);
mem.set(nameBytes.subarray(0, writeName), cursor + 24);
used += 24 + writeName;
cursor += 24 + writeName;
if (writeName < nameBytes.byteLength) break; // truncated name; libc will retry
}
dv.setInt32(bytesUsedPtr, used, true);
return 0;
}
fd_renumber() { return 0; }
fd_sync() { return 0; }
path_filestat_get(dirfd, _dirflags, pathPtr, pathLen, statPtr) {
const root = ROOTS[dirfd];
if (!root) return 8;
const memory = new Uint8Array(this.instance.exports.memory.buffer);
const raw = this.#decoder.decode(memory.subarray(pathPtr, pathPtr + pathLen));
const key = resolveKey(root, raw);
const file = root.map.get(key);
const dv = new DataView(this.instance.exports.memory.buffer);
// Resolve filetype: 4 = regular_file, 3 = directory. The
// preopen mount itself (empty key) is always a directory. On
// the read-only side we built an explicit directory tree at
// startup (readonlyDirs) — anything that key-matches a node is
// a directory. On the rw side, dirs are remembered explicitly
// via persistentDirs (path_create_directory) OR implicitly by
// being a prefix of any persistent file key (so libstdc++'s
// create_directories stat→mkdir→stat loop converges).
let filetype;
let size = 0n;
if (file !== undefined) {
filetype = 4;
size = BigInt(file.byteLength);
} else if (key === "") {
filetype = 3;
} else if (root.kind === "ro" && readonlyDirs.has(key)) {
filetype = 3;
} else if (root.kind === "rw" && persistentDirs.has(key)) {
filetype = 3;
} else if (root.kind === "rw") {
const prefix = key + "/";
let isDir = false;
for (const k of root.map.keys()) {
if (k.startsWith(prefix)) { isDir = true; break; }
}
if (!isDir) return 44;
filetype = 3;
} else {
return 44; // ENOENT
}
dv.setBigUint64(statPtr + 0, 0n, true);
dv.setBigUint64(statPtr + 8, 0n, true);
dv.setUint8(statPtr + 16, filetype);
dv.setBigUint64(statPtr + 24, 1n, true);
dv.setBigUint64(statPtr + 32, size, true);
dv.setBigUint64(statPtr + 40, 0n, true);
dv.setBigUint64(statPtr + 48, 0n, true);
dv.setBigUint64(statPtr + 56, 0n, true);
return 0;
}
path_create_directory(dirfd, pathPtr, pathLen) {
const root = ROOTS[dirfd];
if (!root) return 8;
if (root.kind !== "rw") return 76;
// Flat-keyed Map: directories don't need to be "created" for
// files to exist under them. Track the path in persistentDirs
// so a subsequent path_filestat_get reports it as a directory
// — that's what libstdc++'s create_directories looks for to
// confirm the mkdir landed.
const memory = new Uint8Array(this.instance.exports.memory.buffer);
const raw = this.#decoder.decode(memory.subarray(pathPtr, pathPtr + pathLen));
const key = resolveKey(root, raw);
if (key) persistentDirs.add(key);
return 0;
}
path_remove_directory(dirfd, pathPtr, pathLen) {
const root = ROOTS[dirfd];
if (!root || root.kind !== "rw") return 8;
const memory = new Uint8Array(this.instance.exports.memory.buffer);
const raw = this.#decoder.decode(memory.subarray(pathPtr, pathPtr + pathLen));
const key = resolveKey(root, raw);
// Delete every entry under this prefix and mark them for OPFS removal.
const prefix = key.endsWith("/") ? key : key + "/";
for (const k of [...root.map.keys()]) {
if (k === key || k.startsWith(prefix)) {
root.map.delete(k);
markDirty(k);
}
}
return 0;
}
path_unlink_file(dirfd, pathPtr, pathLen) {
const root = ROOTS[dirfd];
if (!root || root.kind !== "rw") return 8;
const memory = new Uint8Array(this.instance.exports.memory.buffer);
const raw = this.#decoder.decode(memory.subarray(pathPtr, pathPtr + pathLen));
const key = resolveKey(root, raw);
if (!root.map.has(key)) return 44; // ENOENT
root.map.delete(key);
markDirty(key);
return 0;
}
path_rename(oldDirfd, oldPathPtr, oldPathLen,
newDirfd, newPathPtr, newPathLen) {
const oldRoot = ROOTS[oldDirfd];
const newRoot = ROOTS[newDirfd];
if (!oldRoot || !newRoot) return 8;
if (oldRoot.kind !== "rw" || newRoot.kind !== "rw") return 76;
const memory = new Uint8Array(this.instance.exports.memory.buffer);
const oldRaw = this.#decoder.decode(memory.subarray(oldPathPtr, oldPathPtr + oldPathLen));
const newRaw = this.#decoder.decode(memory.subarray(newPathPtr, newPathPtr + newPathLen));
const oldKey = resolveKey(oldRoot, oldRaw);
const newKey = resolveKey(newRoot, newRaw);
const data = oldRoot.map.get(oldKey);
if (data === undefined) return 44;
oldRoot.map.delete(oldKey);
newRoot.map.set(newKey, data);
markDirty(oldKey);
markDirty(newKey);
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 root = ROOTS[dirfd];
if (!root) {
dv.setInt32(openedFdPtr, -1, true);
return 8; // EBADF
}
const raw = this.#decoder.decode(memory.subarray(pathPtr, pathPtr + pathLen));
const key = resolveKey(root, raw);
const O_CREAT = 0x1;
const O_DIRECTORY = 0x2;
const O_TRUNC = 0x8;
const create = (oflags & O_CREAT) !== 0;
const wantDir = (oflags & O_DIRECTORY) !== 0;
const trunc = (oflags & O_TRUNC) !== 0;
// Directory open path. wasi-libc passes O_DIRECTORY for
// `fs::directory_iterator`; we also treat "empty key + ro" as
// the preopen root.
const isRoDir = root.kind === "ro" && (key === "" || readonlyDirs.has(key));
const isRwDir = root.kind === "rw" && (key === "" || persistentDirs.has(key) ||
(() => {
const prefix = key + "/";
for (const k of root.map.keys()) if (k.startsWith(prefix)) return true;
return false;
})());
if (wantDir || (isRoDir && !root.map.has(key)) || (isRwDir && !root.map.has(key))) {
if (!isRoDir && !isRwDir) {
dv.setInt32(openedFdPtr, -1, true);
return 54; // ENOTDIR
}
const fd = this.#nextFd++;
this.#fdTable.set(fd, { rootFd: dirfd, name: key, offset: 0, isDir: true });
dv.setInt32(openedFdPtr, fd, true);
return 0;
}
let present = root.map.has(key);
if (!present) {
if (!create || root.kind !== "rw") {
dv.setInt32(openedFdPtr, -1, true);
return 44; // ENOENT
}
root.map.set(key, new Uint8Array(0));
markDirty(key);
present = true;
} else if (trunc && root.kind === "rw") {
root.map.set(key, new Uint8Array(0));
markDirty(key);
}
const fd = this.#nextFd++;
this.#fdTable.set(fd, { rootFd: dirfd, name: key, offset: 0, isDir: false });
dv.setInt32(openedFdPtr, fd, true);
return 0;
}
path_readlink() { return 0; }
path_symlink() { return 0; }
path_link() { return 0; }
path_filestat_set_times() { return 0; }
poll_oneoff() { return 0; }
sched_yield() { return 0; }
// WASI random_get(buf, buf_len): fill buf_len bytes at *buf with
// high-quality randomness. The old stub returned success without
// writing anything, so the destination kept its prior contents (zero
// in practice) — every std::random_device user got all-zero
// "randomness". That collided WebRTC peer ids across tabs in 3DForts
// (Catcrafts/3DForts#50). Back it with crypto.getRandomValues, which
// is a CSPRNG. crypto.getRandomValues rejects views longer than 65536
// bytes, so chunk for buffers above that bound.
random_get(ptr, len) {
const MAX = 65536; // QuotaExceededError above this per the WebCrypto spec
for (let off = 0; off < len; off += MAX) {
const chunk = Math.min(MAX, len - off);
// Re-view per chunk: the buffer can detach/grow between calls,
// and a fresh view also keeps each slice within the 65536 cap.
const view = new Uint8Array(this.instance.exports.memory.buffer, ptr + off, chunk);
crypto.getRandomValues(view);
}
return 0;
}
sock_accept() { return 0; }
sock_recv() { return 0; }
sock_send() { return 0; }
sock_shutdown() { return 0; }
environ_get(environPtr, environBufferPtr) {
const envByteLength = this.#envEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0);
const environsPointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, environPtr, this.#envEncodedStrings.length);
const environsBuffer = new Uint8Array(this.instance.exports.memory.buffer, environBufferPtr, envByteLength);
let pointerOffset = 0;
for (let i = 0; i < this.#envEncodedStrings.length; i++) {
environsPointerBuffer[i] = environBufferPtr + pointerOffset;
environsBuffer.set(this.#envEncodedStrings[i], pointerOffset);
pointerOffset += this.#envEncodedStrings[i].byteLength;
}
return 0;
}
environ_sizes_get(environCountPtr, environBufferSizePtr) {
const envByteLength = this.#envEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0);
const countPointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, environCountPtr, 1);
const sizePointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, environBufferSizePtr, 1);
countPointerBuffer[0] = this.#envEncodedStrings.length;
sizePointerBuffer[0] = envByteLength;
return 0;
}
}
// ─── wasm feature-detect probes ────────────────────────────────────────
// Each probe validates a ~dozen-byte module that only validates on an engine
// implementing the named feature — the same technique as
// GoogleChromeLabs/wasm-feature-detect, inlined so we ship no dependency. A
// variant in variants.json lists the probe names that must all pass for that
// variant to be served; an unknown name is treated as unsupported (we fall
// through to a less demanding variant, ultimately the baseline). Keep the keys
// in sync with the `probes` a WasmVariant declares in Crafter.Build, and with
// WasmBaselineProbes, which every entry carries.
const WASM_PROBES = {
// relaxed SIMD: i8x16.relaxed_swizzle (Chrome 114+, Firefox 120+; Safari
// still flag-gated as of mid-2026).
"relaxed-simd": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,5,1,96,0,1,123,3,2,1,0,10,15,1,13,0,65,1,253,15,65,2,253,15,253,128,2,11])),
// fixed-width SIMD128 (v128.const + i8x16.popcnt) — the current baseline,
// here for completeness / future baseline shifts.
"simd": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,5,1,96,0,1,123,3,2,1,0,10,10,1,8,0,65,0,253,15,253,98,11])),
// tail calls (return_call).
"tail-call": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,10,6,1,4,0,18,0,11])),
// bulk memory ops (memory.init).
"bulk-memory": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,5,3,1,0,1,10,14,1,12,0,65,0,65,0,65,0,252,10,0,0,11])),
// exception handling in its standardized form (try_table, exnref; Chrome
// 137, Firefox 131, Safari 18.4), which every crafter-build wasm uses for
// C++ exceptions. Probing the legacy try/catch encoding instead says
// nothing about this one: V8 accepts it with exnref switched off.
"exception-handling": () => WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,10,8,1,6,0,31,64,0,11,11])),
// threads/atomics — also requires SharedArrayBuffer (cross-origin isolated).
"threads": () => {
try {
if (typeof SharedArrayBuffer === "undefined") return false;
return WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,5,4,1,3,1,1,10,11,1,9,0,65,0,254,16,2,0,26,11]));
} catch (e) { return false; }
},
};
function variantSupported(probes) {
return (probes || []).every((name) => {
const probe = WASM_PROBES[name];
if (!probe) {
console.warn(`[wasi] unknown wasm probe '${name}' — treating variant as unsupported`);
return false;
}
try { return !!probe(); } catch (e) { return false; }
});
}
// Pick the wasm URL: prefer the variants.json manifest (first entry whose
// probes all pass), else fall back to the single baked window.CRAFTER_WASM_URL.
// When the manifest has no entry this engine can run, not even the baseline,
// say which features it lacks: loading the baseline anyway would only fail
// with a CompileError about an unknown opcode.
async function pickWasmUrl() {
let variants = null;
try {
const r = await fetch("variants.json");
if (r.ok) variants = await r.json();
} catch (e) {
// No manifest (older build) — fall back below.
}
if (Array.isArray(variants) && variants.length) {
for (const v of variants) {
if (variantSupported(v.probes)) {
if (v.label) console.log(`[wasi] selected wasm variant '${v.label}' (${(v.probes || []).join(", ")})`);
return v.url;
}
}
const baseline = variants[variants.length - 1];
const missing = (baseline.probes || []).filter((name) => !variantSupported([name]));
throw new Error(`[wasi] ${baseline.url} needs WebAssembly ${missing.join(", ")}, which this browser does not support; a current Chrome, Firefox or Safari does`);
}
return window.CRAFTER_WASM_URL;
}
const wasmUrl = await pickWasmUrl();
if (!wasmUrl) {
throw new Error("runtime.js: no wasm variant selected and window.CRAFTER_WASM_URL is not set");
}
// Preload asset files listed in files.json (emitted by
// EnableWasiBrowserRuntime) into the read-only baked layer 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.
//
// Manifest entries are relative paths under the bin dir (the layout
// `cfg.assets` produces — e.g. "assets/Inter.ttf",
// "mods/3DForts_Base/foo.cmesh"). We fetch each at its full path and
// key the read-only map by that same relative path so directory
// iteration + nested dirs work; path_open's resolveKey normalises
// caller-side variations (leading "./" or "/").
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) {
console.warn(`[wasi] failed to preload ${name}: HTTP ${r.status}`);
return;
}
readonlyVfs.set(name, new Uint8Array(await r.arrayBuffer()));
}));
}
} catch (e) {
console.warn("[wasi] no files.json manifest (or fetch failed); file I/O syscalls will return ENOENT:", e.message);
}
roBuildDirTree();
// Hydrate the persistent layer from OPFS. Must complete before main()
// runs so any std::ifstream on /persistent/* sees the on-disk bytes.
await opfsHydrate();
// Best-effort final flush as the page is torn down. `pagehide` is the
// modern equivalent of `unload`; it fires on tab close + bfcache eviction
// + cross-document navigation. The browser MAY kill the page before the
// async writes complete — the periodic FLUSH_DEBOUNCE_MS timer is the
// real durability guarantee for writes that happen close to shutdown.
window.addEventListener("pagehide", () => { opfsFlushAll(); });
const wasi = new Wasi({ stdin: "", env: {}, args: [] });
// 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 <script> tag
// before this module script, so by the time we instantiate they've all
// populated the global. Pure-WASI builds leave the global undefined and
// we pass an empty object — extra imports on the JS side are harmless
// (WebAssembly only complains about *missing* declared imports).
if (!window.crafter_webbuild_env) {
window.crafter_webbuild_env = {};
}
// Some env.js bridges (notably the Crafter.CppDOM one) expect an indirect
// function table they can push function references onto. Set it up so we
// don't break compatibility with that pattern.
window.crafter_webbuild_env.table = new WebAssembly.Table({ initial: 4, element: "anyfunc" });
// Async env bridges (notably the WebGPU one in Crafter.Graphics) set
// `window.crafter_webbuild_env_ready` to a Promise that resolves once
// their init (adapter/device requests, pipeline compilation, etc.) is
// finished AND env.* has been swapped from sync stubs to real impls.
// We MUST await this BEFORE WebAssembly.instantiateStreaming — the wasm
// snapshots its import functions at instantiate time, so stubs captured
// then are baked in. Sibling <script type="module"> top-level awaits
// aren't reliably serialized cross-browser, so we can't rely on dom-*
// scripts' own TLAs to block us here.
if (window.crafter_webbuild_env_ready) {
try {
await window.crafter_webbuild_env_ready;
} catch (e) {
console.error("[crafter] env bridge init failed:", e);
// Continue to instantiation anyway — the bridge's stubs throw a
// clearer error from their own call sites.
}
}
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), {
wasi_snapshot_preview1: wasi,
env: window.crafter_webbuild_env,
crafter_runtime: { park: wasi.park },
});
wasi.instance = instance;
// Alias under both names — `crafter_wasi` is the canonical, but the older
// CppDOM env.js reads from `crafter_webbuild_wasi`. Keep both wired so
// either generation of env.js works against this runtime.
window.crafter_wasi = wasi;
window.crafter_webbuild_wasi = wasi;
try {
instance.exports._start();
// main returned 0; any other status reaches proc_exit.
wasi.exitCode = 0;
opfsFlushAll();
} catch (e) {
// proc_exit and park end _start with PROGRAM_END's trap after saying
// which they were. Any other error, a crash or a C++ exception that
// nothing caught, is the program failing.
if (wasi.exitCode === null && !wasi.parked) throw e;
}