// 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(); } class Wasi { #encodedStdin; #envEncodedStrings; #argEncodedStrings; instance; #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(); } 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", ]; 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). 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; } random_get() { 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; } 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})`); // Trigger a final flush so anything written this session has the // best chance of reaching OPFS before the page is torn down. opfsFlushAll(); const e = new Error(`wasi proc_exit(${code})`); e.crafterWasiExit = code; throw e; } } const wasmUrl = window.CRAFTER_WASM_URL; if (!wasmUrl) { throw new Error("runtime.js: window.CRAFTER_WASM_URL is not set (set it in index.html before loading runtime.js)"); } // 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