// 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(); class Wasi { #encodedStdin; #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, 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() { // 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) { 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 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; } const dataView = new DataView(this.instance.exports.memory.buffer); dataView.setInt32(bytesWrittenPtr, totalBytesWritten, true); (fd === 2 ? console.error : console.log)(text); } return 0; } 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 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(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(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(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; } fd_pwrite() { return 0; } fd_readdir() { return 0; } fd_renumber() { return 0; } fd_sync() { return 0; } fd_tell() { return 0; } path_create_directory() { return 0; } path_filestat_get() { return 0; } path_filestat_set_times() { return 0; } path_link() { 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; } path_symlink() { return 0; } path_unlink_file() { 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})`); 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 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. // // 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 but // key the VFS by basename so `path_open`'s basename-reduction can find // it regardless of the C++ side's cwd or prefix. 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) { console.warn(`[wasi] failed to preload ${name}: HTTP ${r.status}`); return; } const base = name.split(/[\\/]/).filter(Boolean).pop() || name; vfs.set(base, 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); } 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