diff --git a/apps/micropolis/src/lib/micropolisengine.js b/apps/micropolis/src/lib/micropolisengine.js index e6aa63cc..83d3f398 100644 --- a/apps/micropolis/src/lib/micropolisengine.js +++ b/apps/micropolis/src/lib/micropolisengine.js @@ -5,8 +5,7 @@ // When targeting node and ES6 we use `await import ..` in the generated code // so the outer function needs to be marked as async. async function Module(moduleArg = {}) { - var moduleRtn; - + var Module = moduleArg; // include: shell.js // include: minimum_runtime_check.js (function() { @@ -25,9 +24,14 @@ async function Module(moduleArg = {}) { // Note: We use a typeof check here instead of optional chaining using // globalThis because older browsers might not have globalThis defined. - var currentNodeVersion = typeof process !== 'undefined' && process.versions?.node ? humanReadableVersionToPacked(process.versions.node) : TARGET_NOT_SUPPORTED; - if (currentNodeVersion < 180300) { - throw new Error(`This emscripten-generated code requires node v${ packedVersionToHumanReadable(180300) } (detected v${packedVersionToHumanReadable(currentNodeVersion)})`); + + // We skip the node version checking when running on Bun/Deno since the node + // version they report doesn't seem to be useful. + if (typeof process !== 'undefined' && !process.versions?.bun && typeof Deno == "undefined") { + var currentNodeVersion = process.versions?.node ? humanReadableVersionToPacked(process.versions.node) : TARGET_NOT_SUPPORTED; + if (currentNodeVersion < 180300) { + throw new Error(`This emscripten-generated code requires node v${ packedVersionToHumanReadable(180300) } (detected v${packedVersionToHumanReadable(currentNodeVersion)})`); + } } var userAgent = typeof navigator !== 'undefined' && navigator.userAgent; @@ -65,7 +69,6 @@ async function Module(moduleArg = {}) { // after the generated code, you will need to define var Module = {}; // before the code. Then that object will be used in the code, and you // can continue to use Module afterwards as well. -var Module = moduleArg; // Determine the runtime environment we are in. You can customize this by // setting the ENVIRONMENT setting at compile time (see settings.js). @@ -89,7 +92,7 @@ if (ENVIRONMENT_IS_NODE) { // --pre-jses are emitted after the Module integration code, so that they can // refer to Module (if they choose; they can also define Module) -// include: /var/folders/78/byld8t797qb4qt1fj9r1xz_w0000gn/T/tmpkkkgbbly.js +// include: /var/folders/mk/wy170mjx2p5dkf12nlrqcsmc0000gn/T/tmpm6t8wvg0.js if (!Module['expectedDataFileDownloads']) Module['expectedDataFileDownloads'] = 0; Module['expectedDataFileDownloads']++; @@ -179,11 +182,6 @@ if (ENVIRONMENT_IS_NODE) { } Module['FS_createPath']("/", "cities", true, true); - for (var file of metadata['files']) { - var name = file['filename'] - Module['addRunDependency'](`fp ${name}`); - } - async function processPackageData(arrayBuffer) { assert(arrayBuffer, 'Loading data file failed.'); assert(arrayBuffer.constructor.name === ArrayBuffer.name, 'bad input to processPackageData ' + arrayBuffer.constructor.name); @@ -195,7 +193,6 @@ Module['FS_createPath']("/", "cities", true, true); var data = byteArray.subarray(file['start'], file['end']); // canOwn this data in the filesystem, it is a slice into the heap that will never change Module['FS_createDataFile'](name, null, data, true, true, true); - Module['removeRunDependency'](`fp ${name}`); } Module['removeRunDependency']('datafile_build/micropolisengine.data'); } @@ -207,10 +204,11 @@ Module['FS_createPath']("/", "cities", true, true); if (!fetched) { fetched = await fetchPromise; } - processPackageData(fetched); + await processPackageData(fetched); } - if (Module['calledRun']) { + // Detect whether the module JS file has already been loaded. + if (Module['FS_createPath']) { runWithFS(Module); } else { if (!Module['preRun']) Module['preRun'] = []; @@ -222,24 +220,24 @@ Module['FS_createPath']("/", "cities", true, true); })(); -// end include: /var/folders/78/byld8t797qb4qt1fj9r1xz_w0000gn/T/tmpkkkgbbly.js -// include: /var/folders/78/byld8t797qb4qt1fj9r1xz_w0000gn/T/tmp9cazitgs.js +// end include: /var/folders/mk/wy170mjx2p5dkf12nlrqcsmc0000gn/T/tmpm6t8wvg0.js +// include: /var/folders/mk/wy170mjx2p5dkf12nlrqcsmc0000gn/T/tmptjaue3o2.js // All the pre-js content up to here must remain later on, we need to run // it. if ((typeof ENVIRONMENT_IS_WASM_WORKER != 'undefined' && ENVIRONMENT_IS_WASM_WORKER) || (typeof ENVIRONMENT_IS_PTHREAD != 'undefined' && ENVIRONMENT_IS_PTHREAD) || (typeof ENVIRONMENT_IS_AUDIO_WORKLET != 'undefined' && ENVIRONMENT_IS_AUDIO_WORKLET)) Module['preRun'] = []; var necessaryPreJSTasks = Module['preRun'].slice(); - // end include: /var/folders/78/byld8t797qb4qt1fj9r1xz_w0000gn/T/tmp9cazitgs.js -// include: /var/folders/78/byld8t797qb4qt1fj9r1xz_w0000gn/T/tmpbrr8dyda.js + // end include: /var/folders/mk/wy170mjx2p5dkf12nlrqcsmc0000gn/T/tmptjaue3o2.js +// include: /var/folders/mk/wy170mjx2p5dkf12nlrqcsmc0000gn/T/tmp7sdfo8w2.js if (!Module['preRun']) throw 'Module.preRun should exist because file support used it; did a pre-js delete it?'; necessaryPreJSTasks.forEach((task) => { if (Module['preRun'].indexOf(task) < 0) throw 'All preRun tasks that exist before user pre-js code should remain after; did you replace Module or modify Module.preRun?'; }); - // end include: /var/folders/78/byld8t797qb4qt1fj9r1xz_w0000gn/T/tmpbrr8dyda.js + // end include: /var/folders/mk/wy170mjx2p5dkf12nlrqcsmc0000gn/T/tmp7sdfo8w2.js -var arguments_ = []; +var programArgs = []; var thisProgram = './this.program'; var quit_ = (status, toThrow) => { throw toThrow; @@ -292,7 +290,7 @@ readAsync = async (filename, binary = true) => { thisProgram = process.argv[1].replace(/\\/g, '/'); } - arguments_ = process.argv.slice(2); + programArgs = process.argv.slice(2); quit_ = (status, toThrow) => { process.exitCode = status; @@ -360,7 +358,7 @@ var NODEFS = 'NODEFS is no longer included by default; build with -lnodefs.js'; // perform assertions in shell.js after we set up out() and err(), as otherwise // if an assertion fails it cannot print the message -assert(!ENVIRONMENT_IS_SHELL, 'shell environment detected but not enabled at build time. Add `shell` to `-sENVIRONMENT` to enable.'); +assert(!ENVIRONMENT_IS_SHELL, 'shell environment detected but not enabled at build time (add `shell` to `-sENVIRONMENT` to enable)'); // end include: shell.js @@ -417,44 +415,6 @@ function assert(condition, text) { var isFileURI = (filename) => filename.startsWith('file://'); // include: runtime_common.js -// include: runtime_stack_check.js -// Initializes the stack cookie. Called at the startup of main and at the startup of each thread in pthreads mode. -function writeStackCookie() { - var max = _emscripten_stack_get_end(); - assert((max & 3) == 0); - // If the stack ends at address zero we write our cookies 4 bytes into the - // stack. This prevents interference with SAFE_HEAP and ASAN which also - // monitor writes to address zero. - if (max == 0) { - max += 4; - } - // The stack grow downwards towards _emscripten_stack_get_end. - // We write cookies to the final two words in the stack and detect if they are - // ever overwritten. - HEAPU32[((max)>>2)] = 0x02135467; - HEAPU32[(((max)+(4))>>2)] = 0x89BACDFE; - // Also test the global address 0 for integrity. - HEAPU32[((0)>>2)] = 1668509029; -} - -function checkStackCookie() { - if (ABORT) return; - var max = _emscripten_stack_get_end(); - // See writeStackCookie(). - if (max == 0) { - max += 4; - } - var cookie1 = HEAPU32[((max)>>2)]; - var cookie2 = HEAPU32[(((max)+(4))>>2)]; - if (cookie1 != 0x02135467 || cookie2 != 0x89BACDFE) { - abort(`Stack overflow! Stack cookie has been overwritten at ${ptrToString(max)}, expected hex dwords 0x89BACDFE and 0x2135467, but received ${ptrToString(cookie2)} ${ptrToString(cookie1)}`); - } - // Also test the global address 0 for integrity. - if (HEAPU32[((0)>>2)] != 0x63736d65 /* 'emsc' */) { - abort('Runtime error: The application has corrupted its heap memory area (address zero)!'); - } -} -// end include: runtime_stack_check.js // include: runtime_exceptions.js // Base Emscripten EH error class class EmscriptenEH {} @@ -482,15 +442,31 @@ function dbg(...args) { })(); function consumedModuleProp(prop) { - if (!Object.getOwnPropertyDescriptor(Module, prop)) { - Object.defineProperty(Module, prop, { - configurable: true, - set() { - abort(`Attempt to set \`Module.${prop}\` after it has already been processed. This can happen, for example, when code is injected via '--post-js' rather than '--pre-js'`); - + var value = Module[prop]; + var msg = `Attempt to modify \`Module.${prop}\` after it has already been processed. This can happen, for example, when code is injected via '--post-js' rather than '--pre-js'`; + if (Array.isArray(value)) { + value = new Proxy(value, { + set(target, key, val) { + abort(msg); + return false; + }, + defineProperty(target, key, descriptor) { + abort(msg); + return false; + }, + deleteProperty(target, key) { + abort(msg); + return false; } }); } + Object.defineProperty(Module, prop, { + configurable: true, + get() { return value; }, + set() { + abort(msg); + } + }); } function makeInvalidEarlyAccess(name) { @@ -541,16 +517,68 @@ function unexportedRuntimeSymbol(sym) { } // end include: runtime_debug.js -var readyPromiseResolve, readyPromiseReject; +// include: runtime_stack_check.js +const stackCookie1 = 0x02135467; +const stackCookie2 = 0x89BACDFE; +// Initializes the stack cookie. Called at the startup of main and at the startup of each thread in pthreads mode. +function writeStackCookie() { + var max = _emscripten_stack_get_end(); + assert((max & 3) == 0); + // If the stack ends at address zero we write our cookies 4 bytes into the + // stack. This prevents interference with SAFE_HEAP and ASAN which also + // monitor writes to address zero. + if (max == 0) { + max += 4; + } + // The stack grow downwards towards _emscripten_stack_get_end. + // We write cookies to the final two words in the stack and detect if they are + // ever overwritten. + HEAPU32[((max)>>2)] = stackCookie1; + HEAPU32[(((max)+(4))>>2)] = stackCookie2; + // Also test the global address 0 for integrity. + HEAPU32[((0)>>2)] = 1668509029; +} + +function u32ToHexString(num) { + return '0x' + (num >>> 0).toString(16).padStart(8, '0'); +} + +function checkStackCookie() { + if (ABORT) return; + var max = _emscripten_stack_get_end(); + // See writeStackCookie(). + if (max == 0) { + max += 4; + } + var val1 = HEAPU32[((max)>>2)]; + var val2 = HEAPU32[(((max)+(4))>>2)]; + if (val1 != stackCookie1 || val2 != stackCookie2) { + abort(`Stack overflow! Stack cookie has been overwritten at ${ptrToString(max)}, expected hex dwords ${u32ToHexString(stackCookie2)} and ${u32ToHexString(stackCookie1)}, but received ${u32ToHexString(val2)} ${u32ToHexString(val1)}`); + } + // Also test the global address 0 for integrity. + if (HEAPU32[((0)>>2)] != 0x63736d65 /* 'emsc' */) { + abort('Runtime error: The application has corrupted its heap memory area (address zero)!'); + } +} +// end include: runtime_stack_check.js // Memory management var runtimeInitialized = false; +// When ALLOW_MEMORY_GROWTH is enabled, the conversion from Wasm +// memory to ArrayBuffer requires some additional logic. +function getMemoryBuffer() { + return wasmMemory.buffer; +} + function updateMemoryViews() { - var b = wasmMemory.buffer; + // If we already have a heap that is resizeable/growable buffer we don't + // need to do anything in updateMemoryViews. + if (HEAP8?.buffer?.resizable) return; + var b = getMemoryBuffer(); HEAP8 = new Int8Array(b); HEAP16 = new Int16Array(b); HEAPU8 = new Uint8Array(b); @@ -570,11 +598,10 @@ assert(globalThis.Int32Array && globalThis.Float64Array && Int32Array.prototype. 'JS engine does not provide full typed array support'); function preRun() { - if (Module['preRun']) { - if (typeof Module['preRun'] == 'function') Module['preRun'] = [Module['preRun']]; - while (Module['preRun'].length) { - addOnPreRun(Module['preRun'].shift()); - } + var preRun = Module['preRun']; + if (preRun) { + if (typeof preRun == 'function') preRun = [preRun]; + onPreRuns.push(...preRun); } consumedModuleProp('preRun'); // Begin ATPRERUNS hooks @@ -598,17 +625,17 @@ TTY.init(); // Begin ATPOSTCTORS hooks FS.ignorePermissions = false; // End ATPOSTCTORS hooks + + checkStackCookie(); } function postRun() { checkStackCookie(); - // PThreads reuse the runtime from the main thread. - if (Module['postRun']) { - if (typeof Module['postRun'] == 'function') Module['postRun'] = [Module['postRun']]; - while (Module['postRun'].length) { - addOnPostRun(Module['postRun'].shift()); - } + var postRun = Module['postRun']; + if (postRun) { + if (typeof postRun == 'function') postRun = [postRun]; + onPostRuns.push(...postRun); } consumedModuleProp('postRun'); @@ -646,21 +673,19 @@ function abort(what) { /** @suppress {checkTypes} */ var e = new WebAssembly.RuntimeError(what); - readyPromiseReject?.(e); // Throw the error whether or not MODULARIZE is set because abort is used // in code paths apart from instantiation where an exception is expected // to be thrown when abort is called. throw e; } -function createExportWrapper(name, nargs) { +function createExportWrapper(name, func, nargs) { + assert(func); return (...args) => { assert(runtimeInitialized, `native function \`${name}\` called before runtime initialization`); - var f = wasmExports[name]; - assert(f, `exported native function \`${name}\` not found`); // Only assert for too many arguments. Too few can be valid since the missing arguments will be zero filled. assert(args.length <= nargs, `native function \`${name}\` called with ${args.length} args but expects ${nargs}`); - return f(...args); + return func(...args); }; } @@ -761,8 +786,7 @@ async function createWasm() { // Load the wasm module and create an instance of using native support in the JS engine. // handle a generated wasm instance, receiving its exports and // performing other necessary setup - /** @param {WebAssembly.Module=} module*/ - function receiveInstance(instance, module) { + function receiveInstance(instance) { wasmExports = instance.exports; assignWasmExports(wasmExports); @@ -795,15 +819,14 @@ async function createWasm() { // performing. // Also pthreads and wasm workers initialize the wasm instance through this // path. - if (Module['instantiateWasm']) { - return new Promise((resolve, reject) => { + var instantiateWasm = Module['instantiateWasm']; + if (instantiateWasm) { + return new Promise((resolve) => { try { - Module['instantiateWasm'](info, (inst, mod) => { - resolve(receiveInstance(inst, mod)); - }); + instantiateWasm(info, (inst) => resolve(receiveInstance(inst))); } catch(e) { err(`Module.instantiateWasm callback failed with error: ${e}`); - reject(e); + throw e; } }); } @@ -827,36 +850,15 @@ async function createWasm() { } } - /** @type {!Int16Array} */ - var HEAP16; - /** @type {!Int32Array} */ var HEAP32; - /** not-@type {!BigInt64Array} */ - var HEAP64; - /** @type {!Int8Array} */ var HEAP8; - /** @type {!Float32Array} */ - var HEAPF32; - - /** @type {!Float64Array} */ - var HEAPF64; - - /** @type {!Uint16Array} */ - var HEAPU16; - /** @type {!Uint32Array} */ var HEAPU32; - /** not-@type {!BigUint64Array} */ - var HEAPU64; - - /** @type {!Uint8Array} */ - var HEAPU8; - var callRuntimeCallbacks = (callbacks) => { while (callbacks.length > 0) { // Pass the module as the first argument. @@ -870,26 +872,6 @@ async function createWasm() { var addOnPreRun = (cb) => onPreRuns.push(cb); - - /** - * @param {number} ptr - * @param {string} type - */ - function getValue(ptr, type = 'i8') { - if (type.endsWith('*')) type = '*'; - switch (type) { - case 'i1': return HEAP8[ptr]; - case 'i8': return HEAP8[ptr]; - case 'i16': return HEAP16[((ptr)>>1)]; - case 'i32': return HEAP32[((ptr)>>2)]; - case 'i64': return HEAP64[((ptr)>>3)]; - case 'float': return HEAPF32[((ptr)>>2)]; - case 'double': return HEAPF64[((ptr)>>3)]; - case '*': return HEAPU32[((ptr)>>2)]; - default: abort(`invalid type for getValue: ${type}`); - } - } - var noExitRuntime = true; function ptrToString(ptr) { @@ -899,27 +881,6 @@ async function createWasm() { return '0x' + ptr.toString(16).padStart(8, '0'); } - - /** - * @param {number} ptr - * @param {number} value - * @param {string} type - */ - function setValue(ptr, value, type = 'i8') { - if (type.endsWith('*')) type = '*'; - switch (type) { - case 'i1': HEAP8[ptr] = value; break; - case 'i8': HEAP8[ptr] = value; break; - case 'i16': HEAP16[((ptr)>>1)] = value; break; - case 'i32': HEAP32[((ptr)>>2)] = value; break; - case 'i64': HEAP64[((ptr)>>3)] = BigInt(value); break; - case 'float': HEAPF32[((ptr)>>2)] = value; break; - case 'double': HEAPF64[((ptr)>>3)] = value; break; - case '*': HEAPU32[((ptr)>>2)] = value; break; - default: abort(`invalid type for setValue: ${type}`); - } - } - var stackRestore = (val) => __emscripten_stack_restore(val); var stackSave = () => _emscripten_stack_get_current(); @@ -937,6 +898,14 @@ async function createWasm() { var UTF8Decoder = globalThis.TextDecoder && new TextDecoder(); + + /** + * heapOrArray is either a regular array, or a JavaScript typed array view. + * @param {number} idx + * @param {number=} maxBytesToRead + * @param {boolean=} ignoreNul + * @return {number} + */ var findStringEnd = (heapOrArray, idx, maxBytesToRead, ignoreNul) => { var maxIdx = idx + maxBytesToRead; if (ignoreNul) return maxIdx; @@ -995,6 +964,9 @@ async function createWasm() { return str; }; + /** @type {!Uint8Array} */ + var HEAPU8; + /** * Given a pointer 'ptr' to a null-terminated UTF8-encoded string in the * emscripten HEAP, returns a copy of that string as a Javascript String object. @@ -1015,6 +987,7 @@ async function createWasm() { var ___assert_fail = (condition, filename, line, func) => abort(`Assertion failed: ${UTF8ToString(condition)}, at: ` + [filename ? UTF8ToString(filename) : 'unknown filename', line, func ? UTF8ToString(func) : 'unknown function']); + class ExceptionInfo { // excPtr - Thrown object pointer to wrap. Metadata pointer is calculated from it. constructor(excPtr) { @@ -1073,12 +1046,16 @@ async function createWasm() { } var uncaughtExceptionCount = 0; + + var __Unwind_RaiseException = (ex) => { + assert(false, 'Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.'); + }; var ___cxa_throw = (ptr, type, destructor) => { var info = new ExceptionInfo(ptr); // Initialize ExceptionInfo content after it was allocated in __cxa_allocate_exception. info.init(type, destructor); uncaughtExceptionCount++; - assert(false, 'Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.'); + __Unwind_RaiseException(ptr); }; var syscallGetVarargI = () => { @@ -1156,7 +1133,7 @@ var initRandomFill = () => { // This block is not needed on v19+ since crypto.getRandomValues is builtin if (ENVIRONMENT_IS_NODE) { var nodeCrypto = require('node:crypto'); - return (view) => nodeCrypto.randomFillSync(view); + return (view) => (nodeCrypto.randomFillSync(view), 0); } return (view) => (crypto.getRandomValues(view), 0); @@ -1396,7 +1373,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { } catch (e) { throw new FS.ErrnoError(29); } - if (result === undefined && bytesRead === 0) { + if (result === undefined && !bytesRead) { throw new FS.ErrnoError(6); } if (result === null || result === undefined) break; @@ -1487,6 +1464,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { var mmapAlloc = (size) => { abort('internal error: mmapAlloc called but `emscripten_builtin_memalign` native symbol not exported'); }; + var MEMFS = { ops_table:null, mount(mount) { @@ -1630,7 +1608,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return attr; }, setattr(node, attr) { - for (const key of ["mode", "atime", "mtime", "ctime"]) { + for (const key of ['mode', 'atime', 'mtime', 'ctime']) { if (attr[key] != null) { node[key] = attr[key]; } @@ -1716,10 +1694,10 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { node.mtime = node.ctime = Date.now(); if (canOwn) { - assert(position === 0, 'canOwn must imply no weird position inside the file'); + assert(!position, 'canOwn must imply no weird position inside the file'); node.contents = buffer.subarray(offset, offset + length); node.usedBytes = length; - } else if (node.usedBytes === 0 && position === 0) { // If this is a simple first write to an empty file, do a fast set since we don't need to care about old data. + } else if (!node.usedBytes && !position) { // If this is a simple first write to an empty file, do a fast set since we don't need to care about old data. node.contents = buffer.slice(offset, offset + length); node.usedBytes = length; } else { @@ -1965,10 +1943,12 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { } }; + var dependenciesPromise = null; + var resolveRunDependencies = async () => dependenciesPromise; var runDependencies = 0; - var dependenciesFulfilled = null; + var dependenciesPromiseResolve = null; var runDependencyTracking = { }; @@ -1982,21 +1962,22 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { assert(id, 'removeRunDependency requires an ID'); assert(runDependencyTracking[id]); delete runDependencyTracking[id]; - if (runDependencies == 0) { + if (!runDependencies) { if (runDependencyWatcher !== null) { clearInterval(runDependencyWatcher); runDependencyWatcher = null; } - if (dependenciesFulfilled) { - var callback = dependenciesFulfilled; - dependenciesFulfilled = null; - callback(); // can add another dependenciesFulfilled - } + dependenciesPromiseResolve(); } }; + + var addRunDependency = (id) => { + if (!runDependencies) { + dependenciesPromise = new Promise((resolve) => dependenciesPromiseResolve = resolve); + } runDependencies++; Module['monitorRunDependencies']?.(runDependencies); @@ -2004,7 +1985,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { assert(id, 'addRunDependency requires an ID') assert(!runDependencyTracking[id]); runDependencyTracking[id] = 1; - if (runDependencyWatcher === null && globalThis.setInterval) { + if (!runDependencyWatcher && globalThis.setInterval) { // Check for missing dependencies every few seconds runDependencyWatcher = setInterval(() => { if (ABORT) { @@ -2071,6 +2052,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { var FS_createPreloadedFile = (parent, name, url, canRead, canWrite, onload, onerror, dontCreateFile, canOwn, preFinish) => { FS_preloadFile(parent, name, url, canRead, canWrite, dontCreateFile, canOwn, preFinish).then(onload).catch(onerror); }; + var FS = { root:null, mounts:[], @@ -2169,6 +2151,48 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { get isDevice() { return FS.isChrdev(this.mode); } + // The per-inode readiness wait-queue. The node carries a Set of listener + // entries {cb}; producers (SOCKFS, PIPEFS) call notifyListeners on a + // readiness transition, and poll()/epoll consume it. It lives on the node + // (not the fd) so dup'd fds share one queue. Only nodes that derive real + // readiness (sockets, pipes, and an epoll's own node) ever use this - + // always-ready types (regular files, ttys) never register or notify. + addListener(cb, exclusive = false) { + var entry = {cb, exclusive}; + var listeners = (this.listeners ??= new Set()); + listeners.add(entry); + return {listeners, entry}; + } + notifyListeners(flags) { + // Iterates the set without copying, which is safe ONLY under a + // load-bearing contract that every internal listener must honour: + // 1. A listener must not run user code synchronously (a poll waiter only + // resolves a Promise; an epoll registration only re-lists + + // re-notifies; the epoll callback only schedules a tick). User code + // runs on a later tick, never inside this loop. + // 2. A listener may delete entries only from ITS OWN waiter, never from + // a sibling node's set that may be mid-iteration. (Deleting an entry + // of the set being iterated here is fine - a Set tolerates removal of + // a not-yet-visited entry mid-iteration; mutating a *different* node's + // set is fine because that set is not being iterated.) + // Violating either gives silently skipped wakeups that are near-impossible + // to reproduce. Any new producer/listener must preserve it. + if (!this.listeners) return; + // Fire every non-exclusive listener. Among EPOLLEXCLUSIVE registrations + // (one fd watched by several epolls) wake only one, rotating round-robin + // per node, to avoid a thundering herd. (Only epoll registrations are ever + // exclusive; poll waiters and a node's own consumers are not.) + var excl; + for (var entry of this.listeners) { + if (entry.exclusive) (excl ||= []).push(entry); + else entry.cb(flags); + } + if (excl) { + var i = (this.exclTurn || 0) % excl.length; + this.exclTurn = i + 1; + excl[i].cb(flags); + } + } }, lookupPath(path, opts = {}) { if (!path) { @@ -2180,7 +2204,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { path = FS.cwd() + '/' + path; } - // limit max consecutive symlinks to 40 (SYMLOOP_MAX). + // limit max consecutive symlinks to SYMLOOP_MAX. linkloop: for (var nlinks = 0; nlinks < 40; nlinks++) { // split the absolute path var parts = path.split('/').filter((p) => !!p); @@ -2472,7 +2496,14 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { var arg = setattr ? stream : node; setattr ??= node.node_ops.setattr; FS.checkOpExists(setattr, 63) - setattr(arg, attr); + try { + setattr(arg, attr); + } catch (e) { + if (e instanceof RangeError) { + throw new FS.ErrnoError(22); + } + throw e; + } }, chrdev_stream_ops:{ open(stream) { @@ -2739,6 +2770,25 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { } return parent.node_ops.symlink(parent, newname, oldpath); }, + link(oldpath, newpath, flags) { + var lookup = FS.lookupPath(newpath, { parent: true }); + var parent = lookup.node; + if (!parent) { + throw new FS.ErrnoError(44); + } + var newname = PATH.basename(newpath); + var errCode = FS.mayCreate(parent, newname); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + // Hardlinks are only supported by filesystem backends that provide a + // `link` node op (e.g. NODERAWFS backed by the host). NODEFS omits it: + // a host hardlink cannot be confined to the mount root. + if (!parent.node_ops.link) { + throw new FS.ErrnoError(34); + } + return parent.node_ops.link(parent, newname, oldpath, flags); + }, rename(old_path, new_path) { var old_dirname = PATH.dirname(old_path); var new_dirname = PATH.dirname(new_path); @@ -2985,17 +3035,16 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { } FS.doTruncate(stream, stream.node, len); }, - utime(path, atime, mtime) { - var lookup = FS.lookupPath(path, { follow: true }); - var node = lookup.node; - var setattr = FS.checkOpExists(node.node_ops.setattr, 63); - setattr(node, { + utime(path, atime, mtime, dontFollow) { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + FS.doSetAttr(null, lookup.node, { atime: atime, - mtime: mtime + mtime: mtime, + dontFollow }); }, open(path, flags, mode = 0o666) { - if (path === "") { + if (path === '') { throw new FS.ErrnoError(44); } flags = FS_modeStringToFlags(flags); @@ -3009,7 +3058,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { if (typeof path == 'object') { node = path; } else { - isDirPath = path.endsWith("/"); + isDirPath = path.endsWith('/'); // noent_okay makes it so that if the final component of the path // doesn't exist, lookupPath returns `node: undefined`. `path` will be // updated to point to the target of all symlinks. @@ -3092,6 +3141,11 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { throw new FS.ErrnoError(8); } if (stream.getdents) stream.getdents = null; // free readdir state + // The fd is going away: wake anything waiting on it (poll/epoll) with + // POLLNVAL so a blocking wait unblocks and an epoll registration is evicted + // on its next derive. Only sockets/pipes/epoll ever carry a wait-queue, so + // for every other stream (incl. nodeless noderawfs stdio) this is a no-op. + stream.node?.notifyListeners(32); try { if (stream.stream_ops.close) { stream.stream_ops.close(stream); @@ -3186,8 +3240,8 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { // to write to file opened in read-only mode with MAP_PRIVATE flag, // as all modifications will be visible only in the memory of // the current process. - if ((prot & 2) !== 0 - && (flags & 2) === 0 + if ((prot & 2) + && !(flags & 2) && (stream.flags & 2097155) !== 2) { throw new FS.ErrnoError(2); } @@ -3216,8 +3270,8 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return stream.stream_ops.ioctl(stream, cmd, arg); }, readFile(path, opts = {}) { - opts.flags = opts.flags || 0; - opts.encoding = opts.encoding || 'binary'; + opts.flags = opts.flags ?? 0; + opts.encoding = opts.encoding ?? 'binary'; if (opts.encoding !== 'utf8' && opts.encoding !== 'binary') { abort(`Invalid encoding type "${opts.encoding}"`); } @@ -3233,7 +3287,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return buf; }, writeFile(path, data, opts = {}) { - opts.flags = opts.flags || 577; + opts.flags = opts.flags ?? 577; var stream = FS.open(path, opts.flags, opts.mode); data = FS_fileDataToTypedArray(data); FS.write(stream, data, 0, data.byteLength, undefined, opts.canOwn); @@ -3280,7 +3334,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { // use a buffer to avoid overhead of individual crypto calls per byte var randomBuffer = new Uint8Array(1024), randomLeft = 0; var randomByte = () => { - if (randomLeft === 0) { + if (!randomLeft) { randomFill(randomBuffer); randomLeft = randomBuffer.byteLength; } @@ -3496,7 +3550,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { } catch (e) { throw new FS.ErrnoError(29); } - if (result === undefined && bytesRead === 0) { + if (result === undefined && !bytesRead) { throw new FS.ErrnoError(6); } if (result === null || result === undefined) break; @@ -3527,7 +3581,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { forceLoadFile(obj) { if (obj.isDevice || obj.isFolder || obj.link || obj.contents) return true; if (globalThis.XMLHttpRequest) { - abort("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread."); + abort('Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.'); } else { // Command-line. try { obj.contents = readBinary(obj.url); @@ -3558,11 +3612,11 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { var xhr = new XMLHttpRequest(); xhr.open('HEAD', url, false); xhr.send(null); - if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) abort("Couldn't load " + url + ". Status: " + xhr.status); - var datalength = Number(xhr.getResponseHeader("Content-length")); + if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) abort(`Couldn't load ${url}. Status: ${xhr.status}`); + var datalength = Number(xhr.getResponseHeader('Content-length')); var header; - var hasByteServing = (header = xhr.getResponseHeader("Accept-Ranges")) && header === "bytes"; - var usesGzip = (header = xhr.getResponseHeader("Content-Encoding")) && header === "gzip"; + var hasByteServing = (header = xhr.getResponseHeader('Accept-Ranges')) && header === 'bytes'; + var usesGzip = (header = xhr.getResponseHeader('Content-Encoding')) && header === 'gzip'; var chunkSize = 1024*1024; // Chunk size in bytes @@ -3570,13 +3624,13 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { // Function to get a range from the remote URL. var doXHR = (from, to) => { - if (from > to) abort("invalid range (" + from + ", " + to + ") or no bytes requested!"); - if (to > datalength-1) abort("only " + datalength + " bytes available! programmer error!"); + if (from > to) abort(`invalid range (${from}, ${to}) or no bytes requested!`); + if (to > datalength-1) abort(`only ${datalength} bytes available! programmer error!`); // TODO: Use mozResponseArrayBuffer, responseStream, etc. if available. var xhr = new XMLHttpRequest(); xhr.open('GET', url, false); - if (datalength !== chunkSize) xhr.setRequestHeader("Range", "bytes=" + from + "-" + to); + if (datalength !== chunkSize) xhr.setRequestHeader('Range', `bytes=${from}-${to}`); // Some hints to the browser that we want binary data. xhr.responseType = 'arraybuffer'; @@ -3585,11 +3639,11 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { } xhr.send(null); - if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) abort("Couldn't load " + url + ". Status: " + xhr.status); + if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) abort(`Couldn't load ${url}. Status: ${xhr.status}`); if (xhr.response !== undefined) { return new Uint8Array(/** @type{Array} */(xhr.response || [])); } - return intArrayFromString(xhr.responseText || '', true); + return intArrayFromString(xhr.responseText ?? '', true); }; var lazyArray = this; lazyArray.setDataGetter((chunkNum) => { @@ -3608,7 +3662,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { chunkSize = datalength = 1; // this will force getter(0)/doXHR do download the whole file datalength = this.getter(0).length; chunkSize = datalength; - out("LazyFiles on gzip forces download of the whole file when length is accessed"); + out('LazyFiles on gzip forces download of the whole file when length is accessed'); } this._length = datalength; @@ -3698,7 +3752,14 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { }, }; + + + + + /** not-@type {!BigInt64Array} */ + var HEAP64; var SYSCALLS = { + currentUmask:18, calculateAt(dirfd, path, allowEmpty) { if (PATH.isAbs(path)) { return path; @@ -3761,7 +3822,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { // MAP_PRIVATE calls need not to be synced back to underlying fs return 0; } - var buffer = HEAPU8.slice(addr, addr + len); + var buffer = HEAPU8.subarray(addr, addr + len); FS.msync(stream, buffer, offset, len, flags); }, getStreamFromFD(fd) { @@ -3774,6 +3835,9 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return ret; }, }; + + /** @type {!Int16Array} */ + var HEAP16; function ___syscall_fcntl64(fd, cmd, varargs) { SYSCALLS.varargs = varargs; try { @@ -3799,7 +3863,8 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return stream.flags; case 4: { var arg = syscallGetVarargI(); - stream.flags |= arg; + var mask = 289792; + stream.flags = (stream.flags & ~mask) | (arg & mask); return 0; } case 12: { @@ -3826,6 +3891,9 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { + + + function ___syscall_ioctl(fd, op, varargs) { SYSCALLS.varargs = varargs; try { @@ -3931,6 +3999,9 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { path = SYSCALLS.getStr(path); path = SYSCALLS.calculateAt(dirfd, path); var mode = varargs ? syscallGetVarargI() : 0; + if (flags & 64) { + mode &= ~SYSCALLS.currentUmask; + } return FS.open(path, flags, mode).fd; } catch (e) { if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; @@ -3960,7 +4031,12 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { var typeDependencies = { }; - var BindingError = class BindingError extends Error { constructor(message) { super(message); this.name = 'BindingError'; }}; + class BindingError extends Error { + constructor(message) { + super(message); + this.name = 'BindingError'; + } + } var throwBindingError = (message) => { throw new BindingError(message); }; /** @param {Object=} options */ function sharedRegisterType(rawType, registeredInstance, options = {}) { @@ -3990,6 +4066,17 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return sharedRegisterType(rawType, registeredInstance, options); } + + + + /** @type {!Uint16Array} */ + var HEAPU16; + + + + + /** not-@type {!BigUint64Array} */ + var HEAPU64; var integerReadValueFromPointer = (name, width, signed) => { // integers are quite common, so generate very specialized functions switch (width) { @@ -4047,10 +4134,10 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { name, fromWireType: fromWireType, toWireType: (destructors, value) => { - if (typeof value == "number") { + if (typeof value == 'number') { value = BigInt(value); } - else if (typeof value != "bigint") { + else if (typeof value != 'bigint') { throw new TypeError(`Cannot convert "${embindRepr(value)}" to ${name}`); } assertIntegerRange(name, value, minRange, maxRange); @@ -4062,6 +4149,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { }; + /** @suppress {globalThis} */ var __embind_register_bool = (rawType, name, trueValue, falseValue) => { name = AsciiToString(name); @@ -4158,7 +4246,12 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return registeredInstances[ptr]; }; - var InternalError = class InternalError extends Error { constructor(message) { super(message); this.name = 'InternalError'; }}; + class InternalError extends Error { + constructor(message) { + super(message); + this.name = 'InternalError'; + } + } var throwInternalError = (message) => { throw new InternalError(message); }; var makeClassHandle = (prototype, record) => { @@ -4278,10 +4371,10 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { // This is more useful than the empty stacktrace of `FinalizationRegistry` // callback. var cls = $$.ptrType.registeredClass; - var err = new Error(`Embind found a leaked C++ instance ${cls.name} <${ptrToString($$.ptr)}>.\n` + - "We'll free it automatically in this case, but this functionality is not reliable across various environments.\n" + - "Make sure to invoke .delete() manually once you're done with the instance instead.\n" + - "Originally allocated"); // `.stack` will add "at ..." after this sentence + var err = new Error(`Embind found a leaked C++ instance ${cls.name} <${ptrToString($$.ptr)}>. +We'll free it automatically in this case, but this functionality is not reliable across various environments. +Make sure to invoke .delete() manually once you're done with the instance instead. +Originally allocated`); // `.stack` will add "at ..." after this sentence if ('captureStackTrace' in Error) { Error.captureStackTrace(err, RegisteredPointer_fromWireType); } @@ -4311,7 +4404,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { let proto = ClassHandle.prototype; Object.assign(proto, { - "isAliasOf"(other) { + 'isAliasOf'(other) { if (!(this instanceof ClassHandle)) { return false; } @@ -4338,7 +4431,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return leftClass === rightClass && left === right; }, - "clone"() { + 'clone'() { if (!this.$$.ptr) { throwInstanceAlreadyDeleted(this); } @@ -4359,7 +4452,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { } }, - "delete"() { + 'delete'() { if (!this.$$.ptr) { throwInstanceAlreadyDeleted(this); } @@ -4377,11 +4470,11 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { } }, - "isDeleted"() { + 'isDeleted'() { return !this.$$.ptr; }, - "deleteLater"() { + 'deleteLater'() { if (!this.$$.ptr) { throwInstanceAlreadyDeleted(this); } @@ -4716,11 +4809,11 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { wasmTableMirror[funcPtr] = func = wasmTable.get(funcPtr); } /** @suppress {checkTypes} */ - assert(wasmTable.get(funcPtr) == func, 'JavaScript-side Wasm function table mirror is out of date!'); + assert(wasmTable.get(funcPtr) == func, 'table mirror is out of date'); return func; }; var embind__requireFunction = (signature, rawFunction, isAsync = false) => { - assert(!isAsync, 'Async bindings are only supported with JSPI.'); + assert(!isAsync, 'async bindings are only supported with JSPI'); signature = AsciiToString(signature); @@ -4968,19 +5061,19 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { argsList.push(`arg${i}`) argsListWired.push(`arg${i}Wired`) } - argsList = argsList.join(',') - argsListWired = argsListWired.join(',') + argsList = argsList.join() + argsListWired = argsListWired.join() var invokerFnBody = `return function (${argsList}) {\n`; - invokerFnBody += "checkArgCount(arguments.length, minArgs, maxArgs, humanName, throwBindingError);\n"; + invokerFnBody += 'checkArgCount(arguments.length, minArgs, maxArgs, humanName, throwBindingError);\n'; if (needsDestructorStack) { - invokerFnBody += "var destructors = [];\n"; + invokerFnBody += 'var destructors = [];\n'; } - var dtorStack = needsDestructorStack ? "destructors" : "null"; - var args1 = ["humanName", "throwBindingError", "invoker", "fn", "runDestructors", "fromRetWire", "toClassParamWire"]; + var dtorStack = needsDestructorStack ? 'destructors' : 'null'; + var args1 = ['humanName', 'throwBindingError', 'invoker', 'fn', 'runDestructors', 'fromRetWire', 'toClassParamWire']; if (isClassMethodFunc) { invokerFnBody += `var thisWired = toClassParamWire(${dtorStack}, this);\n`; @@ -4992,15 +5085,15 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { args1.push(argName); } - invokerFnBody += (returns || isAsync ? "var rv = ":"") + `invoker(${argsListWired});\n`; + invokerFnBody += (returns || isAsync ? 'var rv = ' : '') + `invoker(${argsListWired});\n`; - var returnVal = returns ? "rv" : ""; + var returnVal = returns ? 'rv' : ''; if (needsDestructorStack) { - invokerFnBody += "runDestructors(destructors);\n"; + invokerFnBody += 'runDestructors(destructors);\n'; } else { for (var i = isClassMethodFunc?1:2; i < argTypes.length; ++i) { // Skip return value at index 0 - it's not deleted here. Also skip class type if not a method. - var paramName = (i === 1 ? "thisWired" : ("arg"+(i - 2)+"Wired")); + var paramName = (i === 1 ? 'thisWired' : `arg${i - 2}Wired`); if (argTypes[i].destructorFunction !== null) { invokerFnBody += `${paramName}_dtor(${paramName});\n`; args1.push(`${paramName}_dtor`); @@ -5009,12 +5102,12 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { } if (returns) { - invokerFnBody += "var ret = fromRetWire(rv);\n" + - "return ret;\n"; + invokerFnBody += 'var ret = fromRetWire(rv);\n' + + 'return ret;\n'; } else { } - invokerFnBody += "}\n"; + invokerFnBody += '}\n'; args1.push('checkArgCount', 'minArgs', 'maxArgs'); invokerFnBody = `if (arguments.length !== ${args1.length}){ throw new Error(humanName + "Expected ${args1.length} closure arguments " + arguments.length + " given."); }\n${invokerFnBody}`; @@ -5045,15 +5138,15 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { var argCount = argTypes.length; if (argCount < 2) { - throwBindingError("argTypes array size mismatch! Must at least get return value and 'this' types!"); + throwBindingError('argTypes array size mismatch! Must at least get return value and receiver (this) types!'); } - assert(!isAsync, 'Async bindings are only supported with JSPI.'); + assert(!isAsync, 'async bindings are only supported with JSPI'); var isClassMethodFunc = (argTypes[1] !== null && classType !== null); // Free functions with signature "void function()" do not need an invoker that marshalls between wire types. // TODO: This omits argument count check - enable only at -O3 or similar. - // if (ENABLE_UNSAFE_OPTS && argCount == 2 && argTypes[0].name == "void" && !isClassMethodFunc) { + // if (ENABLE_UNSAFE_OPTS && argCount == 2 && argTypes[0].name == 'void' && !isClassMethodFunc) { // return FUNCTION_TABLE[fn]; // } @@ -5134,9 +5227,9 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { var getFunctionName = (signature) => { signature = signature.trim(); - const argsIndex = signature.indexOf("("); + const argsIndex = signature.indexOf('('); if (argsIndex === -1) return signature; - assert(signature.endsWith(")"), "Parentheses for argument names should match."); + assert(signature.endsWith(')'), 'Parentheses for argument names should match.'); return signature.slice(0, argsIndex); }; var __embind_register_class_function = (rawClassType, @@ -5158,7 +5251,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { classType = classType[0]; var humanName = `${classType.name}.${methodName}`; - if (methodName.startsWith("@@")) { + if (methodName.startsWith('@@')) { methodName = Symbol[methodName.substring(2)]; } @@ -5308,7 +5401,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { var emval_handles = [0,1,,1,null,1,true,1,false,1]; var __emval_decref = (handle) => { if (handle > 9 && 0 === --emval_handles[handle + 1]) { - assert(emval_handles[handle] !== undefined, `Decref for unallocated handle.`); + assert(emval_handles[handle] !== undefined, `decref for unallocated handle`); var value = emval_handles[handle]; emval_handles[handle] = undefined; emval_freelist.push(handle); @@ -5359,6 +5452,11 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { var __embind_register_emval = (rawType) => registerType(rawType, EmValType); + + + + + var enumReadValueFromPointer = (name, width, signed) => { switch (width) { case 1: return signed ? @@ -5379,7 +5477,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { function getEnumValueType(rawValueType) { // This must match the values of enum_value_type in wire.h - return rawValueType === 0 ? 'object' : (rawValueType === 1 ? 'number' : 'string'); + return !rawValueType ? 'object' : (rawValueType === 1 ? 'number' : 'string'); } /** @suppress {globalThis} */ var __embind_register_enum = (rawType, name, size, isSigned, rawValueType) => { @@ -5492,6 +5590,11 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { } }; + /** @type {!Float32Array} */ + var HEAPF32; + + /** @type {!Float64Array} */ + var HEAPF64; var floatReadValueFromPointer = (name, width) => { switch (width) { case 4: return function(pointer) { @@ -5513,7 +5616,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { name, fromWireType: (value) => value, toWireType: (destructors, value) => { - if (typeof value != "number" && typeof value != "boolean") { + if (typeof value != 'number' && typeof value != 'boolean') { throw new TypeError(`Cannot convert ${embindRepr(value)} to ${name}`); } // The VM will perform JS to Wasm value conversion, according to the spec: @@ -5572,7 +5675,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { name, fromWireType: fromWireType, toWireType: (destructors, value) => { - if (typeof value != "number" && typeof value != "boolean") { + if (typeof value != 'number' && typeof value != 'boolean') { throw new TypeError(`Cannot convert "${embindRepr(value)}" to ${name}`); } assertIntegerRange(name, value, minRange, maxRange); @@ -5586,6 +5689,8 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { }; + + var __embind_register_memory_view = (rawType, dataTypeIndex, name) => { var typeMapping = [ Int8Array, @@ -5622,14 +5727,17 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { + var stringToUTF8 = (str, outPtr, maxBytesToWrite) => { - assert(typeof maxBytesToWrite == 'number', 'stringToUTF8(str, outPtr, maxBytesToWrite) is missing the third parameter that specifies the length of the output buffer!'); + assert(typeof maxBytesToWrite == 'number', 'stringToUTF8 requires a third parameter that specifies the length of the output buffer'); return stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite); }; + + var __embind_register_std_string = (rawType, name) => { name = AsciiToString(name); var stdStringIsUTF8 = true; @@ -5712,8 +5820,9 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { var UTF16Decoder = globalThis.TextDecoder ? new TextDecoder('utf-16le') : undefined;; + var UTF16ToString = (ptr, maxBytesToRead, ignoreNul) => { - assert(ptr % 2 == 0, 'Pointer passed to UTF16ToString must be aligned to two bytes!'); + assert(ptr % 2 == 0, 'pointer passed to UTF16ToString must be 2-byte aligned'); var idx = ((ptr)>>1); var endIdx = findStringEnd(HEAPU16, idx, maxBytesToRead / 2, ignoreNul); @@ -5737,11 +5846,9 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return str; }; - var stringToUTF16 = (str, outPtr, maxBytesToWrite) => { - assert(outPtr % 2 == 0, 'Pointer passed to stringToUTF16 must be aligned to two bytes!'); - assert(typeof maxBytesToWrite == 'number', 'stringToUTF16(str, outPtr, maxBytesToWrite) is missing the third parameter that specifies the length of the output buffer!'); - // Backwards compatibility: if max bytes is not specified, assume unsafe unbounded write is allowed. - maxBytesToWrite ??= 0x7FFFFFFF; + var stringToUTF16 = (str, outPtr, maxBytesToWrite = 0x7FFFFFFF) => { + assert(outPtr % 2 == 0, 'pointer passed to stringToUTF16 must be 2-byte aligned'); + assert(typeof maxBytesToWrite == 'number', 'stringToUTF16 requires a third parameter that specifies the length of the output buffer'); if (maxBytesToWrite < 2) return 0; maxBytesToWrite -= 2; // Null terminator. var startPtr = outPtr; @@ -5760,7 +5867,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { var lengthBytesUTF16 = (str) => str.length*2; var UTF32ToString = (ptr, maxBytesToRead, ignoreNul) => { - assert(ptr % 4 == 0, 'Pointer passed to UTF32ToString must be aligned to four bytes!'); + assert(ptr % 4 == 0, 'pointer passed to UTF32ToString must be 2-byte aligned'); var str = ''; var startIdx = ((ptr)>>2); // If maxBytesToRead is not passed explicitly, it will be undefined, and this @@ -5773,11 +5880,9 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return str; }; - var stringToUTF32 = (str, outPtr, maxBytesToWrite) => { - assert(outPtr % 4 == 0, 'Pointer passed to stringToUTF32 must be aligned to four bytes!'); - assert(typeof maxBytesToWrite == 'number', 'stringToUTF32(str, outPtr, maxBytesToWrite) is missing the third parameter that specifies the length of the output buffer!'); - // Backwards compatibility: if max bytes is not specified, assume unsafe unbounded write is allowed. - maxBytesToWrite ??= 0x7FFFFFFF; + var stringToUTF32 = (str, outPtr, maxBytesToWrite = 0x7FFFFFFF) => { + assert(outPtr % 4 == 0, 'pointer passed to stringToUTF32 must be 4-byte aligned'); + assert(typeof maxBytesToWrite == 'number', 'stringToUTF32 requires a third parameter that specifies the length of the output buffer'); if (maxBytesToWrite < 4) return 0; var startPtr = outPtr; var endPtr = startPtr + maxBytesToWrite - 4; @@ -5811,6 +5916,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return len; }; + var __embind_register_std_wstring = (rawType, charSize, name) => { name = AsciiToString(name); var decodeString, encodeString, lengthBytesUTF; @@ -5878,6 +5984,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { return id; }; + var emval_lookupTypes = (argCount, argTypes) => { var a = new Array(argCount); for (var i = 0; i < argCount; ++i) { @@ -5888,6 +5995,7 @@ var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { }; + var emval_returnValue = (toReturnWire, destructorsRef, handle) => { var destructors = []; var result = toReturnWire(destructors, handle); @@ -5999,7 +6107,7 @@ ${functionBody} 2147483648; var alignMemory = (size, alignment) => { - assert(alignment, "alignment argument is required"); + assert(alignment, 'alignment argument is required'); return Math.ceil(size / alignment) * alignment; }; @@ -6014,9 +6122,10 @@ ${functionBody} } catch(e) { err(`growMemory: Attempted to grow heap from ${oldHeapSize} bytes to ${size} bytes, but got error: ${e}`); } - // implicit 0 return to save code size (caller will cast "undefined" into 0 + // implicit 0 return to save code size (caller will cast 'undefined' into 0 // anyhow) }; + var _emscripten_resize_heap = (requestedSize) => { var oldSize = HEAPU8.length; // With CAN_ADDRESS_2GB or MEMORY64, pointers are already unsigned. @@ -6092,7 +6201,6 @@ ${functionBody} // if exit() was called explicitly, warn the user if the runtime isn't actually being shut down if (keepRuntimeAlive() && !implicit) { var msg = `program exited (with status: ${status}), but keepRuntimeAlive() is set (counter=${runtimeKeepaliveCounter}) due to an async operation, so halting execution but not exiting the runtime or preventing further async execution (you can use emscripten_force_exit, if you want to force a true shutdown)`; - readyPromiseReject?.(msg); err(msg); } @@ -6113,6 +6221,7 @@ ${functionBody} } + /** @param {number=} offset */ var doReadv = (stream, iov, iovcnt, offset) => { var ret = 0; @@ -6120,7 +6229,18 @@ ${functionBody} var ptr = HEAPU32[((iov)>>2)]; var len = HEAPU32[(((iov)+(4))>>2)]; iov += 8; - var curr = FS.read(stream, HEAP8, ptr, len, offset); + try { + var curr = FS.read(stream, HEAP8, ptr, len, offset); + } catch (e) { + // On a non-blocking stream a subsequent read may would-block after we + // already gathered data. POSIX readv is a single gather-read: return + // what we have rather than failing the whole call. + if (ret > 0 && e instanceof FS.ErrnoError && + (e.errno == 6 || e.errno == 6)) { + break; + } + throw e; + } if (curr < 0) return -1; ret += curr; if (curr < len) break; // nothing more to read @@ -6131,6 +6251,7 @@ ${functionBody} return ret; }; + function _fd_read(fd, iov, iovcnt, pnum) { try { @@ -6150,17 +6271,18 @@ ${functionBody} var INT53_MIN = -9007199254740992; var bigintToI53Checked = (num) => (num < INT53_MIN || num > INT53_MAX) ? NaN : Number(num); + function _fd_seek(fd, offset, whence, newOffset) { offset = bigintToI53Checked(offset); try { - if (isNaN(offset)) return 61; + if (isNaN(offset)) return 22; var stream = SYSCALLS.getStreamFromFD(fd); FS.llseek(stream, offset, whence); HEAP64[((newOffset)>>3)] = BigInt(stream.position); - if (stream.getdents && offset === 0 && whence === 0) stream.getdents = null; // reset readdir state + if (stream.getdents && !offset && whence === 0) stream.getdents = null; // reset readdir state return 0; } catch (e) { if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; @@ -6169,27 +6291,34 @@ ${functionBody} ; } + + /** @param {number=} offset */ var doWritev = (stream, iov, iovcnt, offset) => { - var ret = 0; - for (var i = 0; i < iovcnt; i++) { + // Gather all iovecs into one contiguous buffer and issue a single + // FS.write, matching POSIX writev's single gather-write semantics (as + // __syscall_sendmsg already does). Per-iovec writes fragment a stream + // socket send into multiple segments, breaking stream byte semantics. + if (iovcnt == 1) { + // Single iovec: write directly from HEAP8, no gather buffer needed. + return FS.write(stream, HEAP8, HEAPU32[((iov)>>2)], HEAPU32[(((iov)+(4))>>2)], offset); + } + var total = 0; + for (var i = 0, p = iov; i < iovcnt; i++, p += 8) { + total += HEAPU32[(((p)+(4))>>2)]; + } + var view = new Uint8Array(total); + var voff = 0; + for (var i = 0; i < iovcnt; i++, iov += 8) { var ptr = HEAPU32[((iov)>>2)]; var len = HEAPU32[(((iov)+(4))>>2)]; - iov += 8; - var curr = FS.write(stream, HEAP8, ptr, len, offset); - if (curr < 0) return -1; - ret += curr; - if (curr < len) { - // No more space to write. - break; - } - if (typeof offset != 'undefined') { - offset += curr; - } + view.set(HEAPU8.subarray(ptr, ptr + len), voff); + voff += len; } - return ret; + return FS.write(stream, view, 0, total, offset); }; + function _fd_write(fd, iov, iovcnt, pnum) { try { @@ -6235,7 +6364,7 @@ assert(emval_handles.length === 5 * 2); // Begin ATMODULES hooks if (Module['noExitRuntime']) noExitRuntime = Module['noExitRuntime']; -if (Module['preloadPlugins']) preloadPlugins = Module['preloadPlugins']; + if (Module['print']) out = Module['print']; if (Module['printErr']) err = Module['printErr']; if (Module['wasmBinary']) wasmBinary = Module['wasmBinary']; @@ -6243,7 +6372,7 @@ if (Module['wasmBinary']) wasmBinary = Module['wasmBinary']; checkIncomingModuleAPI(); - if (Module['arguments']) arguments_ = Module['arguments']; + if (Module['arguments']) programArgs = Module['arguments']; if (Module['thisProgram']) thisProgram = Module['thisProgram']; // Assertions on removed incoming Module JS APIs. @@ -6262,10 +6391,13 @@ if (Module['wasmBinary']) wasmBinary = Module['wasmBinary']; assert(typeof Module['wasmMemory'] == 'undefined', 'Use of `wasmMemory` detected. Use -sIMPORTED_MEMORY to define wasmMemory externally'); assert(typeof Module['INITIAL_MEMORY'] == 'undefined', 'Detected runtime INITIAL_MEMORY setting. Use -sIMPORTED_MEMORY to define wasmMemory dynamically'); - if (Module['preInit']) { - if (typeof Module['preInit'] == 'function') Module['preInit'] = [Module['preInit']]; - while (Module['preInit'].length > 0) { - Module['preInit'].shift()(); + var preInit = Module['preInit']; + if (preInit) { + if (typeof preInit == 'function') Module['preInit'] = preInit = [preInit]; + // Written as a loop so that preInit functions that themselves add more + // preInit functions. Is this actually needed? + while (preInit.length > 0) { + preInit.shift()(); } } consumedModuleProp('preInit'); @@ -6331,6 +6463,8 @@ if (Module['wasmBinary']) wasmBinary = Module['wasmBinary']; 'getFunctionAddress', 'addFunction', 'removeFunction', + 'setValue', + 'getValue', 'intArrayToString', 'stringToAscii', 'stringToNewUTF8', @@ -6354,12 +6488,14 @@ if (Module['wasmBinary']) wasmBinary = Module['wasmBinary']; 'registerOrientationChangeEventCallback', 'fillFullscreenChangeEventData', 'registerFullscreenChangeEventCallback', + 'callCanvasResizedCallback', 'JSEvents_requestFullscreen', 'JSEvents_resizeCanvasForFullscreen', 'registerRestoreOldStyle', 'hideEverythingExceptGivenElement', 'restoreHiddenElements', 'setLetterbox', + 'currentFullscreenStrategy', 'softFullscreenResizeWebGLRenderTarget', 'doRequestFullscreen', 'fillPointerlockChangeEventData', @@ -6391,6 +6527,7 @@ if (Module['wasmBinary']) wasmBinary = Module['wasmBinary']; 'registerPreMainLoop', 'getPromise', 'makePromise', + 'addPromise', 'idsToPromises', 'makePromiseCallback', 'findMatchingCatch', @@ -6419,6 +6556,7 @@ if (Module['wasmBinary']) wasmBinary = Module['wasmBinary']; 'colorChannelsInGlTextureFormat', 'emscriptenWebGLGetTexPixelData', 'emscriptenWebGLGetUniform', + 'webglGetProgramUniformLocation', 'webglGetUniformLocation', 'webglPrepareUniformLocationsBeforeFirstUse', 'webglGetLeftBracePos', @@ -6427,9 +6565,6 @@ if (Module['wasmBinary']) wasmBinary = Module['wasmBinary']; 'writeGLArray', 'registerWebGlEventCallback', 'runAndAbortIfError', - 'ALLOC_NORMAL', - 'ALLOC_STACK', - 'allocate', 'writeStringToMemory', 'writeAsciiToMemory', 'allocateUTF8', @@ -6498,8 +6633,6 @@ missingLibrarySymbols.forEach(missingLibrarySymbol) 'addOnPostRun', 'freeTableIndexes', 'functionsInTableMap', - 'setValue', - 'getValue', 'PATH', 'PATH_FS', 'UTF8Decoder', @@ -6520,7 +6653,6 @@ missingLibrarySymbols.forEach(missingLibrarySymbol) 'JSEvents', 'specialHTMLTargets', 'findCanvasEventTarget', - 'currentFullscreenStrategy', 'restoreOldWindowedStyle', 'UNWIND_CACHE', 'ExitStatus', @@ -6537,7 +6669,6 @@ missingLibrarySymbols.forEach(missingLibrarySymbol) 'ExceptionInfo', 'Browser', 'requestFullscreen', - 'requestFullScreen', 'setCanvasSize', 'getUserMedia', 'createContext', @@ -6618,6 +6749,7 @@ missingLibrarySymbols.forEach(missingLibrarySymbol) 'FS_mkdir', 'FS_mkdev', 'FS_symlink', + 'FS_link', 'FS_rename', 'FS_rmdir', 'FS_readdir', @@ -6772,6 +6904,25 @@ function checkIncomingModuleAPI() { ignoredModuleProp('onRealloc'); ignoredModuleProp('onFree'); ignoredModuleProp('onSbrkGrow'); + ignoredModuleProp('onCOSCacheHit'); + ignoredModuleProp('onCOSCacheMiss'); + ignoredModuleProp('onCOSStore'); + ignoredModuleProp('GL_MAX_TEXTURE_IMAGE_UNITS'); + ignoredModuleProp('SDL_canPlayWithWebAudio'); + ignoredModuleProp('SDL_numSimultaneouslyQueuedBuffers'); + ignoredModuleProp('freePreloadedMediaOnUse'); + ignoredModuleProp('preinitializedWebGLContext'); + ignoredModuleProp('keyboardListeningElement'); + ignoredModuleProp('doNotCaptureKeyboard'); + ignoredModuleProp('extraStackTrace'); + ignoredModuleProp('preloadPlugins'); + ignoredModuleProp('preMainLoop'); + ignoredModuleProp('postMainLoop'); + ignoredModuleProp('forcedAspectRatio'); + ignoredModuleProp('mainScriptUrlOrBlob'); + ignoredModuleProp('onFullScreen'); + ignoredModuleProp('INITIAL_MEMORY'); + ignoredModuleProp('wasmMemory'); } // Imports from the Wasm binary. @@ -6807,13 +6958,13 @@ function assignWasmExports(wasmExports) { assert(typeof wasmExports['emscripten_stack_get_current'] != 'undefined', 'missing Wasm export: emscripten_stack_get_current'); assert(typeof wasmExports['memory'] != 'undefined', 'missing Wasm export: memory'); assert(typeof wasmExports['__indirect_function_table'] != 'undefined', 'missing Wasm export: __indirect_function_table'); - ___getTypeName = createExportWrapper('__getTypeName', 1); - _malloc = createExportWrapper('malloc', 1); - _free = createExportWrapper('free', 1); - _fflush = createExportWrapper('fflush', 1); + ___getTypeName = createExportWrapper('__getTypeName', wasmExports['__getTypeName'], 1); + _malloc = createExportWrapper('malloc', wasmExports['malloc'], 1); + _free = createExportWrapper('free', wasmExports['free'], 1); + _fflush = createExportWrapper('fflush', wasmExports['fflush'], 1); _emscripten_stack_get_end = wasmExports['emscripten_stack_get_end']; _emscripten_stack_get_base = wasmExports['emscripten_stack_get_base']; - _strerror = createExportWrapper('strerror', 1); + _strerror = createExportWrapper('strerror', wasmExports['strerror'], 1); _emscripten_stack_init = wasmExports['emscripten_stack_init']; _emscripten_stack_get_free = wasmExports['emscripten_stack_get_free']; __emscripten_stack_restore = wasmExports['_emscripten_stack_restore']; @@ -6919,54 +7070,37 @@ function stackCheckInit() { writeStackCookie(); } -function run() { - - if (runDependencies > 0) { - dependenciesFulfilled = run; - return; - } +async function run() { + assert(!calledRun); + calledRun = true; stackCheckInit(); preRun(); - // a preRun added a dependency, run will be called later - if (runDependencies > 0) { - dependenciesFulfilled = run; - return; + if (runDependencies) { + await resolveRunDependencies(); } - function doRun() { - // run may have just been called through dependencies being fulfilled just in this very frame, - // or while the async setStatus time below was happening - assert(!calledRun); - calledRun = true; - Module['calledRun'] = true; - - if (ABORT) return; + var setStatus = Module['setStatus']; + if (setStatus) { + setStatus('Running...'); + // Yield to the event loop to allow the browser to paint "Running..." + await new Promise((resolve) => setTimeout(resolve, 1)); + // Then we want to clear the status text, but only after the rest of this function runs. + setTimeout(setStatus, 1, ''); + } - initRuntime(); + if (ABORT) return; - readyPromiseResolve?.(Module); - Module['onRuntimeInitialized']?.(); - consumedModuleProp('onRuntimeInitialized'); + initRuntime(); - assert(!Module['_main'], 'compiled without a main, but one is present. if you added it from JS, use Module["onRuntimeInitialized"]'); + Module['onRuntimeInitialized']?.(); + consumedModuleProp('onRuntimeInitialized'); - postRun(); - } + assert(!Module['_main'], 'compiled without a main, but one is present. if you added it from JS, use Module["onRuntimeInitialized"]'); - if (Module['setStatus']) { - Module['setStatus']('Running...'); - setTimeout(() => { - setTimeout(() => Module['setStatus'](''), 1); - doRun(); - }, 1); - } else - { - doRun(); - } - checkStackCookie(); + postRun(); } function checkUnflushedContent() { @@ -7012,28 +7146,14 @@ var wasmExports; // In modularize mode the generated code is within a factory function so we // can use await here (since it's not top-level-await). -wasmExports = await (createWasm()); - -run(); +wasmExports = await createWasm(); +await run(); // end include: postamble.js // include: postamble_modularize.js // In MODULARIZE mode we wrap the generated code in a factory function // and return either the Module itself, or a promise of the module. -// -// We assign to the `moduleRtn` global here and configure closure to see -// this as an extern so it won't get minified. - -if (runtimeInitialized) { - moduleRtn = Module; -} else { - // Set up the promise that indicates the Module is initialized - moduleRtn = new Promise((resolve, reject) => { - readyPromiseResolve = resolve; - readyPromiseReject = reject; - }); -} // Assertion for attempting to access module properties on the incoming // moduleArg. In the past we used this object as the prototype of the module @@ -7054,7 +7174,7 @@ for (const prop of Object.keys(Module)) { - return moduleRtn; + return Module; } // Export using a UMD style export, or ES6 exports if selected diff --git a/apps/micropolis/src/lib/micropolisengine.wasm b/apps/micropolis/src/lib/micropolisengine.wasm index f991f6c3..7a63c0a6 100755 Binary files a/apps/micropolis/src/lib/micropolisengine.wasm and b/apps/micropolis/src/lib/micropolisengine.wasm differ diff --git a/apps/micropolis/src/lib/seedDeterminism.test.ts b/apps/micropolis/src/lib/seedDeterminism.test.ts new file mode 100644 index 00000000..8b6e234e --- /dev/null +++ b/apps/micropolis/src/lib/seedDeterminism.test.ts @@ -0,0 +1,165 @@ +/** + * Deterministic-seeding tests. + * + * The engine exposes seedRandom(), but initWillStuff() reseeds the RNG from the + * wall clock (randomlySeedRandom() -> gettimeofday) and every caller of + * initWillStuff() runs doSimInit() before returning to the embedder. The map + * scans in doSimInit() draw from the RNG and write the results straight into map + * tiles (e.g. zone.cpp: `map[x][y] = ... + getRandom(2)`), so a caller-supplied + * seed is discarded before it can affect anything observable. + * + * Covers: + * - generateMap(seed) is reproducible (control: the PRNG itself is fine) + * - seedRandom(n) + loadCity() + ticks is reproducible across processes + * - seedRandom(n) + loadCity() is reproducible on repeat loads + * - seedRandom(n) + loadCity() + ticks is reproducible on repeat loads + * + * Everything runs on a single Micropolis instance, on purpose. Constructing a + * second instance in one process is its own (unrelated) bug, so reusing one + * instance keeps these tests measuring seeding and nothing else. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { loadMicropolisMainModule } from './wasm/node'; +import { callbackMethodNames } from './wasm/callbacks'; +import type { MainModule, JSCallback, Micropolis } from '../types/micropolisengine.d.js'; + +const SEED = 42; +const CITY = '/cities/kobe.cty'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(testDir, '../..'); +const nodeLoaderUrl = pathToFileURL(path.join(testDir, 'wasm/node.ts')).href; +const callbacksUrl = pathToFileURL(path.join(testDir, 'wasm/callbacks.ts')).href; + +/** One in-game turn is one cityTime increment = 16 ticks (simulate.cpp phaseCycle). */ +const TICKS_PER_TURN = 16; + +describe('deterministic seeding', () => { + let engine: MainModule; + let micropolis: Micropolis; + let jsCallbackWrapper: JSCallback; + + beforeAll(async () => { + engine = await loadMicropolisMainModule(); + micropolis = new engine.Micropolis(); + jsCallbackWrapper = new engine.JSCallback( + Object.fromEntries(callbackMethodNames.map((name) => [name, () => {}])) + ); + micropolis.setCallback(jsCallbackWrapper, {}); + micropolis.init(); + }); + + afterAll(() => { + try { micropolis.delete(); } catch { /* ignore */ } + try { jsCallbackWrapper.delete(); } catch { /* ignore */ } + }); + + /** + * Digest of the entire tile map — the sim state that actually matters, and + * where the RNG output physically lands. + */ + function mapDigest(): string { + const buffer = Buffer.allocUnsafe(engine.WORLD_W * engine.WORLD_H * 2); + let offset = 0; + for (let x = 0; x < engine.WORLD_W; x++) { + for (let y = 0; y < engine.WORLD_H; y++) { + buffer.writeUInt16LE(micropolis.getTile(x, y) & 0xffff, offset); + offset += 2; + } + } + return createHash('sha256').update(buffer).digest('hex'); + } + + /** Collect a digest per repetition of `run`. */ + function digestsOf(runs: number, run: () => void): string[] { + const digests: string[] = []; + for (let i = 0; i < runs; i++) { + run(); + digests.push(mapDigest()); + } + return digests; + } + + it('seedRandom() + loadCity() + ticks is reproducible across processes', () => { + // The property that actually matters, and the one the bug broke: a fresh + // process doing init() -> seedRandom(n) -> loadCity() -> tick must land on + // the same world every time. This has to run in child processes because the + // first load in a process is the only one that starts from a virgin world, + // and because init() itself seeds from the clock. Ticking is included here + // precisely because this is the only test that covers a first load, so it is + // where a first-load simulation difference would have to show up. + const script = ` + import { createHash } from 'node:crypto'; + import { loadMicropolisMainModule } from ${JSON.stringify(nodeLoaderUrl)}; + import { callbackMethodNames } from ${JSON.stringify(callbacksUrl)}; + const engine = await loadMicropolisMainModule(); + const m = new engine.Micropolis(); + m.setCallback(new engine.JSCallback(Object.fromEntries(callbackMethodNames.map((n) => [n, () => {}]))), {}); + m.init(); + m.seedRandom(${SEED}); + m.loadCity(${JSON.stringify(CITY)}); + for (let tick = 0; tick < ${3 * TICKS_PER_TURN}; tick++) m.simTick(); + const buf = Buffer.allocUnsafe(engine.WORLD_W * engine.WORLD_H * 2); + let o = 0; + for (let x = 0; x < engine.WORLD_W; x++) + for (let y = 0; y < engine.WORLD_H; y++) { buf.writeUInt16LE(m.getTile(x, y) & 0xffff, o); o += 2; } + process.stdout.write(createHash('sha256').update(buf).digest('hex')); + `; + + const digests = Array.from({ length: 3 }, () => + execFileSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: projectRoot, + encoding: 'utf8' + }).trim() + ); + + expect(digests[0]).toMatch(/^[0-9a-f]{64}$/); + expect(new Set(digests).size).toBe(1); + }); + + it('generateMap(seed) produces an identical map every time', () => { + // Control. generateMap() calls seedRandom(seed) and never routes through + // initWillStuff(), so this isolates the PRNG from the reseeding bug: if this + // ever fails, the problem is the generator, not the seed handling. + const digests = digestsOf(3, () => micropolis.generateMap(SEED)); + + expect(new Set(digests).size).toBe(1); + }); + + it('seedRandom() + loadCity() produces an identical map every time', () => { + // Reloading the same city with the same seed must land on the same map, every + // time, including the very first load into a virgin post-init() world. + const digests = digestsOf(4, () => { + micropolis.seedRandom(SEED); + micropolis.loadCity(CITY); + }); + + expect(new Set(digests).size).toBe(1); + }); + + it('seedRandom() + loadCity() + ticks is reproducible on repeat loads', () => { + // Reseeding happens after the load, not before it, and the first iteration is + // excluded -- both for the same reason. simLoadInit() runs only on the first + // load of a process, and it draws a different number of values from the RNG + // than the reload path does, so the RNG stream position entering the tick + // loop is not the same on load 1 as on loads 2+. Pinning the seed + // immediately before ticking removes that offset and isolates what this test + // is actually about: that ticking from an identical map yields an identical + // result. Cross-process first-load-with-ticks coverage is the test above. + const [, ...digests] = digestsOf(4, () => { + micropolis.seedRandom(SEED); + micropolis.loadCity(CITY); + micropolis.seedRandom(SEED); + for (let tick = 0; tick < 3 * TICKS_PER_TURN; tick++) { + micropolis.simTick(); + } + }); + + expect(new Set(digests).size).toBe(1); + }); +}); diff --git a/packages/micropolis-engine/makefile b/packages/micropolis-engine/makefile index 7655a936..98d14aad 100644 --- a/packages/micropolis-engine/makefile +++ b/packages/micropolis-engine/makefile @@ -64,6 +64,15 @@ ######################################################################## # Emscripten settings +# +# INCOMING_MODULE_JS_API: Emscripten only honours Module.* properties named in +# this list, and since 4.x it hard-aborts on any it does not know +# ("Aborted(`Module.wasmBinary` was supplied but `wasmBinary` not included in +# INCOMING_MODULE_JS_API)"). The default list omits `wasmBinary` and +# `getPreloadedPackage`, both of which apps/micropolis/src/lib/wasm/node.ts +# passes to load the engine from a Buffer in Node instead of over HTTP. So the +# list below is the upstream default plus those two. If a loader starts passing +# another Module property, add it here too. EMCC = em++ @@ -79,6 +88,7 @@ EMLDFLAGS = \ -s 'ENVIRONMENT=web,worker,node' \ -s ALLOW_MEMORY_GROWTH=1 \ -s 'EXPORTED_RUNTIME_METHODS=["HEAPU16","wasmMemory"]' \ + -s 'INCOMING_MODULE_JS_API=["ENVIRONMENT","arguments","canvas","dynamicLibraries","elementPointerLock","getPreloadedPackage","instantiateWasm","locateFile","monitorRunDependencies","noExitRuntime","noInitialRun","onAbort","onExit","onRuntimeInitialized","postRun","preInit","preRun","print","printErr","setStatus","statusMessage","stderr","stdin","stdout","thisProgram","wasm","wasmBinary","websocket"]' \ -lembind \ --emit-tsd=micropolisengine.d.ts \ --shell-file src/micropolisengine_template.html \ diff --git a/packages/micropolis-engine/src/fileio.cpp b/packages/micropolis-engine/src/fileio.cpp index 30e91a04..94cea582 100644 --- a/packages/micropolis-engine/src/fileio.cpp +++ b/packages/micropolis-engine/src/fileio.cpp @@ -592,8 +592,6 @@ bool Micropolis::loadCity(const std::string &filename) std::string newCityName = cityFileName.substr(pos, last - pos); setCityName(newCityName); - doSimInit(); - didLoadCity(filename); return true; diff --git a/packages/micropolis-engine/src/initialize.cpp b/packages/micropolis-engine/src/initialize.cpp index 9ff81d07..caf04822 100644 --- a/packages/micropolis-engine/src/initialize.cpp +++ b/packages/micropolis-engine/src/initialize.cpp @@ -81,10 +81,18 @@ //////////////////////////////////////////////////////////////////////// -/** Reset many game state variables */ +/** + * Reset many game state variables. + * + * Note that this deliberately does not seed the random number generator. + * Reseeding here would discard any seed the caller set with seedRandom(), and + * since every caller runs doSimInit() before returning -- whose map scans draw + * from the RNG and store the results in map tiles -- there would be no point at + * which an externally supplied seed could take effect. Callers that do want a + * fresh random world call randomlySeedRandom() themselves. + */ void Micropolis::initWillStuff() { - randomlySeedRandom(); initGraphMax(); destroyAllSprites(); diff --git a/packages/micropolis-engine/src/micropolis.cpp b/packages/micropolis-engine/src/micropolis.cpp index 5230c74e..c71e0f15 100644 --- a/packages/micropolis-engine/src/micropolis.cpp +++ b/packages/micropolis-engine/src/micropolis.cpp @@ -738,6 +738,12 @@ void Micropolis::simInit() resetMapState(); resetEditorState(); clearMap(); + + // A freshly initialized simulation gets a random world by default. Embedders + // that want reproducible runs call seedRandom() after init() and before + // loading or generating a city. + randomlySeedRandom(); + initWillStuff(); setFunds(5000); setGameLevelFunds(LEVEL_EASY);