/** * @fileoverview Runtime for scripts generated by jsgen */ /* eslint-disable no-unused-vars */ /* eslint-disable prefer-template */ /* eslint-disable valid-jsdoc */ /* eslint-disable max-len */ const globalState = { Timer: require('../util/timer'), Cast: require('../util/cast'), log: require('../util/log'), blockUtility: require('./compat-block-utility'), thread: null }; let baseRuntime = ''; const runtimeFunctions = {}; /** * Determine whether the current tick is likely stuck. * This implements similar functionality to the warp timer found in Scratch. * @returns {boolean} true if the current tick is likely stuck. */ baseRuntime += `let stuckCounter = 0; const isStuck = () => { // The real time is not checked on every call for performance. stuckCounter++; if (stuckCounter === 100) { stuckCounter = 0; return globalState.thread.target.runtime.sequencer.timer.timeElapsed() > 500; } return false; };`; /** * Alternative for nullish Coalescing * @param {string} name The variable to get * @returns {any} The value of the temp var or an empty string if its nullish */ runtimeFunctions.nullish = `const nullish = (check, alt) => { if (!check) { if (val === undefined) return alt if (val === null) return alt return check } else { return check } }`; /** * Start hats by opcode. * @param {string} requestedHat The opcode of the hat to start. * @param {*} optMatchFields Fields to match. * @returns {Array} A list of threads that were started. */ runtimeFunctions.startHats = `const startHats = (requestedHat, optMatchFields) => { const thread = globalState.thread; const threads = thread.target.runtime.startHats(requestedHat, optMatchFields); return threads; }`; /** * Implements "thread waiting", where scripts are halted until all the scripts have finished executing. * @param {Array} threads The list of threads. */ runtimeFunctions.waitThreads = `const waitThreads = function*(threads) { const thread = globalState.thread; const runtime = thread.target.runtime; while (true) { // determine whether any threads are running let anyRunning = false; for (let i = 0; i < threads.length; i++) { if (runtime.threads.indexOf(threads[i]) !== -1) { anyRunning = true; break; } } if (!anyRunning) { // all threads are finished, can resume return; } let allWaiting = true; for (let i = 0; i < threads.length; i++) { if (!runtime.isWaitingThread(threads[i])) { allWaiting = false; break; } } if (allWaiting) { thread.status = 3; // STATUS_YIELD_TICK } yield; } }`; /** * waitPromise: Wait until a Promise resolves or rejects before continuing. * @param {Promise} promise The promise to wait for. * @returns {*} the value that the promise resolves to, otherwise undefined if the promise rejects */ runtimeFunctions.waitPromise = ` const waitPromise = function*(promise) { const thread = globalState.thread; let returnValue; let errorReturn; promise .then(value => { returnValue = value; thread.status = 0; // STATUS_RUNNING }) .catch(error => { errorReturn = error; // i realized, i dont actually know what would happen if we never do this but throw and exit anyways thread.status = 0; // STATUS_RUNNING }); // enter STATUS_PROMISE_WAIT and yield // this will stop script execution until the promise handlers reset the thread status thread.status = 1; // STATUS_PROMISE_WAIT yield; // throw the promise error if ee got one if (errorReturn) throw errorReturn return returnValue; }`; /** * isPromise: Determine if a value is Promise-like * @param {unknown} promise The value to check * @returns {promise is PromiseLike} True if the value is Promise-like (has a .then()) */ /** * executeInCompatibilityLayer: Execute a scratch-vm primitive. * @param {*} inputs The inputs to pass to the block. * @param {function} blockFunction The primitive's function. * @param {boolean} useFlags Whether to set flags (hasResumedFromPromise) * @param {string} blockId Block ID to set on the emulated block utility. * @param {*|null} branchInfo Extra information object for CONDITIONAL and LOOP blocks. See createBranchInfo(). * @returns {*} the value returned by the block, if any. */ runtimeFunctions.executeInCompatibilityLayer = `let hasResumedFromPromise = false; const isPromise = value => ( // see engine/execute.js value !== null && typeof value === 'object' && typeof value.then === 'function' ); const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, useFlags, blockId, branchInfo, visualReport) { const thread = globalState.thread; const blockUtility = globalState.blockUtility; const stackFrame = branchInfo ? branchInfo.stackFrame : {}; const finish = (returnValue) => { if (branchInfo) { if (typeof returnValue === 'undefined' && blockUtility._startedBranch) { branchInfo.isLoop = blockUtility._startedBranch[1]; return blockUtility._startedBranch[0]; } branchInfo.isLoop = branchInfo.defaultIsLoop; return returnValue; } return returnValue; }; // reset the stackframe // we only ever use one stackframe at a time, so this shouldn't cause issues thread.stackFrames[thread.stackFrames.length - 1].reuse(isWarp); const executeBlock = () => { blockUtility.init(thread, blockId, stackFrame, branchInfo); return blockFunction(inputs, blockUtility, visualReport); }; let returnValue = executeBlock(); if (isPromise(returnValue)) { returnValue = finish(yield* waitPromise(returnValue)); if (useFlags) hasResumedFromPromise = true; return returnValue; } if (thread.status === 1 /* STATUS_PROMISE_WAIT */ || thread.status === 4 /* STATUS_DONE */) { // Something external is forcing us to stop yield; // Make up a return value because whatever is forcing us to stop can't specify one return ''; } while (thread.status === 2 /* STATUS_YIELD */ || thread.status === 3 /* STATUS_YIELD_TICK */) { // Yielded threads will run next iteration. if (thread.status === 2 /* STATUS_YIELD */) { thread.status = 0; // STATUS_RUNNING // Yield back to the event loop when stuck or not in warp mode. if (!isWarp || isStuck()) { yield; } } else { // status is STATUS_YIELD_TICK, always yield to the event loop yield; } returnValue = executeBlock(); if (isPromise(returnValue)) { returnValue = finish(yield* waitPromise(returnValue)); if (useFlags) hasResumedFromPromise = true; return returnValue; } if (thread.status === 1 /* STATUS_PROMISE_WAIT */ || thread.status === 4 /* STATUS_DONE */) { yield; return finish(''); } } return finish(returnValue); }`; /** * @param {boolean} isLoop True if the block is a LOOP by default (can be overridden by startBranch() call) * @returns {unknown} Branch info object for compatibility layer. */ runtimeFunctions.createBranchInfo = `const createBranchInfo = (isLoop) => ({ defaultIsLoop: isLoop, isLoop: false, branch: 0, stackFrame: {}, onEnd: [], });`; /** * End the current script. */ runtimeFunctions.retire = `const retire = () => { const thread = globalState.thread; thread.target.runtime.sequencer.retireThread(thread); }`; /** * Scratch cast to boolean. * Similar to Cast.toBoolean() * @param {*} value The value to cast * @returns {boolean} The value cast to a boolean */ runtimeFunctions.toBoolean = `const toBoolean = value => { if (typeof value === 'boolean') { return value; } if (typeof value === 'string') { if (value === '' || value === '0' || value.toLowerCase() === 'false') { return false; } return true; } return !!value; }`; /** * If a number is very close to a whole number, round to that whole number. * @param {number} value Value to round * @returns {number} Rounded number or original number */ runtimeFunctions.limitPrecision = `const limitPrecision = value => { const rounded = Math.round(value); const delta = value - rounded; return (Math.abs(delta) < 1e-9) ? rounded : value; }`; /** * Used internally by the compare family of function. * See similar method in cast.js. * @param {*} val A value that evaluates to 0 in JS string-to-number conversation such as empty string, 0, or tab. * @returns {boolean} True if the value should not be treated as the number zero. */ baseRuntime += `const isNotActuallyZero = val => { if (typeof val !== 'string') return false; for (let i = 0; i < val.length; i++) { const code = val.charCodeAt(i); if (code === 48 || code === 9) { return false; } } return true; };`; /** * Determine if two values are equal. * @param {*} v1 First value * @param {*} v2 Second value * @returns {boolean} true if v1 is equal to v2 */ baseRuntime += `const compareEqualSlow = (v1, v2) => { const n1 = +v1; if (isNaN(n1) || (n1 === 0 && isNotActuallyZero(v1))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase(); const n2 = +v2; if (isNaN(n2) || (n2 === 0 && isNotActuallyZero(v2))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase(); return n1 === n2; }; const compareEqual = (v1, v2) => (typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v1) && !isNaN(v2) || v1 === v2) ? v1 === v2 : compareEqualSlow(v1, v2);`; /** * Determine if one value is greater than another. * @param {*} v1 First value * @param {*} v2 Second value * @returns {boolean} true if v1 is greater than v2 */ runtimeFunctions.compareGreaterThan = `const compareGreaterThanSlow = (v1, v2) => { let n1 = +v1; let n2 = +v2; if (n1 === 0 && isNotActuallyZero(v1)) { n1 = NaN; } else if (n2 === 0 && isNotActuallyZero(v2)) { n2 = NaN; } if (isNaN(n1) || isNaN(n2)) { const s1 = ('' + v1).toLowerCase(); const s2 = ('' + v2).toLowerCase(); return s1 > s2; } return n1 > n2; }; const compareGreaterThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v1) ? v1 > v2 : compareGreaterThanSlow(v1, v2)`; /** * Determine if one value is less than another. * @param {*} v1 First value * @param {*} v2 Second value * @returns {boolean} true if v1 is less than v2 */ runtimeFunctions.compareLessThan = `const compareLessThanSlow = (v1, v2) => { let n1 = +v1; let n2 = +v2; if (n1 === 0 && isNotActuallyZero(v1)) { n1 = NaN; } else if (n2 === 0 && isNotActuallyZero(v2)) { n2 = NaN; } if (isNaN(n1) || isNaN(n2)) { const s1 = ('' + v1).toLowerCase(); const s2 = ('' + v2).toLowerCase(); return s1 < s2; } return n1 < n2; }; const compareLessThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v2) ? v1 < v2 : compareLessThanSlow(v1, v2)`; /** * Generate a random integer. * @param {number} low Lower bound * @param {number} high Upper bound * @returns {number} A random integer between low and high, inclusive. */ runtimeFunctions.randomInt = `const randomInt = (low, high) => low + Math.floor(Math.random() * ((high + 1) - low))`; /** * Generate a random float. * @param {number} low Lower bound * @param {number} high Upper bound * @returns {number} A random floating point number between low and high. */ runtimeFunctions.randomFloat = `const randomFloat = (low, high) => (Math.random() * (high - low)) + low`; /** * Create and start a timer. * @returns {Timer} A started timer */ runtimeFunctions.timer = `const timer = () => { const t = new globalState.Timer({ now: () => globalState.thread.target.runtime.currentMSecs }); t.start(); return t; }`; /** * Returns the amount of days since January 1st, 2000. * @returns {number} Days since 2000. */ // Date.UTC(2000, 0, 1) === 946684800000 // Hardcoding it is marginally faster runtimeFunctions.daysSince2000 = `const daysSince2000 = () => (Date.now() - 946684800000) / (24 * 60 * 60 * 1000)`; /** * Determine distance to a sprite or point. * @param {string} menu The name of the sprite or location to find. * @returns {number} Distance to the point, or 10000 if it cannot be calculated. */ runtimeFunctions.distance = `const distance = menu => { const thread = globalState.thread; if (thread.target.isStage) return 10000; let targetX = 0; let targetY = 0; if (menu === '_mouse_') { targetX = thread.target.runtime.ioDevices.mouse.getScratchX(); targetY = thread.target.runtime.ioDevices.mouse.getScratchY(); } else { const distTarget = thread.target.runtime.getSpriteTargetByName(menu); if (!distTarget) return 10000; targetX = distTarget.x; targetY = distTarget.y; } const dx = thread.target.x - targetX; const dy = thread.target.y - targetY; return Math.sqrt((dx * dx) + (dy * dy)); }`; /** * Convert a Scratch list index to a JavaScript list index. * "all" is not considered as a list index. * Similar to Cast.toListIndex() * @param {number} index Scratch list index. * @param {number} length Length of the list. * @returns {number} 0 based list index, or -1 if invalid. */ baseRuntime += `const listIndexSlow = (index, length) => { if (index === 'last') { return length - 1; } else if (index === 'random' || index === 'any') { if (length > 0) { return (Math.random() * length) | 0; } return -1; } index = (+index || 0) | 0; if (index < 1 || index > length) { return -1; } return index - 1; }; const listIndex = (index, length) => { if (typeof index !== 'number') { return listIndexSlow(index, length); } index = index | 0; return index < 1 || index > length ? -1 : index - 1; };`; /** * Get a value from a list. * @param {Array} list The list * @param {*} idx The 1-indexed index in the list. * @returns {*} The list item, otherwise empty string if it does not exist. */ runtimeFunctions.listGet = `const listGet = (list, idx) => { const index = listIndex(idx, list.length); if (index === -1) { return ''; } return list[index]; }`; /** * Replace a value in a list. * @param {import('../engine/variable')} list The list * @param {*} idx List index, Scratch style. * @param {*} value The new value. */ runtimeFunctions.listReplace = `const listReplace = (list, idx, value) => { const index = listIndex(idx, list.value.length); if (index === -1) { return; } list.value[index] = value; list._monitorUpToDate = false; }`; /** * Insert a value in a list. * @param {import('../engine/variable')} list The list. * @param {*} idx The Scratch index in the list. * @param {*} value The value to insert. */ runtimeFunctions.listInsert = `const listInsert = (list, idx, value) => { const index = listIndex(idx, list.value.length + 1); if (index === -1) { return; } list.value.splice(index, 0, value); list._monitorUpToDate = false; }`; /** * Delete a value from a list. * @param {import('../engine/variable')} list The list. * @param {*} idx The Scratch index in the list. */ runtimeFunctions.listDelete = `const listDelete = (list, idx) => { if (idx === 'all') { list.value = []; return; } const index = listIndex(idx, list.value.length); if (index === -1) { return; } list.value.splice(index, 1); list._monitorUpToDate = false; }`; /** * Return whether a list contains a value. * @param {import('../engine/variable')} list The list. * @param {*} item The value to search for. * @returns {boolean} True if the list contains the item */ runtimeFunctions.listContains = `const listContains = (list, item) => { // TODO: evaluate whether indexOf is worthwhile here if (list.value.indexOf(item) !== -1) { return true; } for (let i = 0; i < list.value.length; i++) { if (compareEqual(list.value[i], item)) { return true; } } return false; }`; /** * pm: Returns whether a list contains a value, using Array.some * @param {import('../engine/variable')} list The list. * @param {*} item The value to search for. * @returns {boolean} True if the list contains the item */ runtimeFunctions.listContainsFastest = `const listContainsFastest = (list, item) => { return list.value.some(litem => compareEqual(litem, item)); }`; /** * Find the 1-indexed index of an item in a list. * @param {import('../engine/variable')} list The list. * @param {*} item The item to search for * @returns {number} The 1-indexed index of the item in the list, otherwise 0 */ runtimeFunctions.listIndexOf = `const listIndexOf = (list, item) => { for (let i = 0; i < list.value.length; i++) { if (compareEqual(list.value[i], item)) { return i + 1; } } return 0; }`; /** * Get the stringified form of a list. * @param {import('../engine/variable')} list The list. * @returns {string} Stringified form of the list. */ runtimeFunctions.listContents = `const listContents = list => { for (let i = 0; i < list.value.length; i++) { const listItem = list.value[i]; // this is an intentional break from what scratch 3 does to address our automatic string -> number conversions // it fixes more than it breaks if ((listItem + '').length !== 1) { return list.value.join(' '); } } return list.value.join(''); }`; /** * Convert a color to an RGB list * @param {*} color The color value to convert * @return {Array.} [r,g,b], values between 0-255. */ runtimeFunctions.colorToList = `const colorToList = color => globalState.Cast.toRgbColorList(color)`; /** * Implements Scratch modulo (floored division instead of truncated division) * @param {number} n Number * @param {number} modulus Base * @returns {number} n % modulus (floored division) */ runtimeFunctions.mod = `const mod = (n, modulus) => { let result = n % modulus; if (result / modulus < 0) result += modulus; return result; }`; /** * Implements Scratch tangent. * @param {number} angle Angle in degrees. * @returns {number} value of tangent or Infinity or -Infinity */ runtimeFunctions.tan = `const tan = (angle) => { switch (angle % 360) { case -270: case 90: return Infinity; case -90: case 270: return -Infinity; } return Math.round(Math.tan((Math.PI * angle) / 180) * 1e10) / 1e10; }`; runtimeFunctions.resolveImageURL = `const resolveImageURL = imgURL => typeof imgURL === 'object' && imgURL.type === 'canvas' ? Promise.resolve(imgURL.canvas) : new Promise(resolve => { const image = new Image(); image.crossOrigin = "anonymous"; image.onload = resolve(image); image.onerror = resolve; // ignore loading errors lol! image.src = ''+imgURL; })`; runtimeFunctions.parseJSONSafe = `const parseJSONSafe = json => { try return JSON.parse(json) catch return {} }`; runtimeFunctions._resolveKeyPath = `const _resolveKeyPath = (obj, keyPath) => { const path = keyPath.matchAll(/(\\.|^)(?[^.[]+)|\\[(?(\\\\\\]|\\\\|[^]])+)\\]/g); let top = obj; let pre; let tok; let key; while (!(tok = path.next()).done) { key = tok.value.groups.key ?? tok.value.groups.litKey.replaceAll('\\\\\\\\', '\\\\').replaceAll('\\\\]', ']'); pre = top; top = top?.get?.(key) ?? top?.[key]; if (top === undefined) return [obj, keyPath]; } return [pre, key]; }`; runtimeFunctions.get = `const get = (obj, keyPath) => { const [root, key] = _resolveKeyPath(obj, keyPath); return typeof root === 'undefined' ? '' : root.get?.(key) ?? root[key]; }`; runtimeFunctions.set = `const set = (obj, keyPath, val) => { const [root, key] = _resolveKeyPath(obj, keyPath); return typeof root === 'undefined' ? '' : root.set?.(key) ?? (root[key] = val); }`; runtimeFunctions.remove = `const remove = (obj, keyPath) => { const [root, key] = _resolveKeyPath(obj, keyPath); return typeof root === 'undefined' ? '' : root.delete?.(key) ?? root.remove?.(key) ?? (delete root[key]); }`; runtimeFunctions.includes = `const includes = (obj, keyPath) => { const [root, key] = _resolveKeyPath(obj, keyPath); return typeof root === 'undefined' ? '' : root.has?.(key) ?? (key in root); }`; /** * Step a compiled thread. * @param {Thread} thread The thread to step. */ const execute = thread => { globalState.thread = thread; thread.generator.next(); }; const threadStack = []; const saveGlobalState = () => { threadStack.push(globalState.thread); }; const restoreGlobalState = () => { globalState.thread = threadStack.pop(); }; const insertRuntime = source => { let result = baseRuntime; for (const functionName of Object.keys(runtimeFunctions)) { if (source.includes(functionName)) { result += `${runtimeFunctions[functionName]};`; } } if (result.includes('executeInCompatibilityLayer') && !result.includes('const waitPromise')) { result = result.replace('let hasResumedFromPromise = false;', `let hasResumedFromPromise = false;\n${runtimeFunctions.waitPromise}`); } if (result.includes('_resolveKeyPath') && !result.includes('const _resolveKeyPath')) { result = runtimeFunctions._resolveKeyPath + ';' + result; } result += `return ${source}`; return result; }; /** * Evaluate arbitrary JS in the context of the runtime. * @param {string} source The string to evaluate. * @returns {*} The result of evaluating the string. */ const scopedEval = source => { const withRuntime = insertRuntime(source); try { return new Function('globalState', withRuntime)(globalState); } catch (e) { globalState.log.error('was unable to compile script', withRuntime); console.log(e); throw e; } }; execute.scopedEval = scopedEval; execute.runtimeFunctions = runtimeFunctions; execute.saveGlobalState = saveGlobalState; execute.restoreGlobalState = restoreGlobalState; // not actually used, this is an export for extensions execute.globalState = globalState; module.exports = execute;