const log = require('../util/log'); const Cast = require('../util/cast'); const BlockType = require('../extension-support/block-type'); const VariablePool = require('./variable-pool'); const jsexecute = require('./jsexecute'); const environment = require('./environment'); // Imported for JSDoc types, not to actually use // eslint-disable-next-line no-unused-vars const {IntermediateScript, IntermediateRepresentation} = require('./intermediate'); /** * @fileoverview Convert intermediate representations to JavaScript functions. */ /* eslint-disable max-len */ /* eslint-disable prefer-template */ const sanitize = string => { if (typeof string !== 'string') { log.warn(`sanitize got unexpected type: ${typeof string}`); string = '' + string; } return JSON.stringify(string).slice(1, -1); }; const TYPE_NUMBER = 1; const TYPE_STRING = 2; const TYPE_BOOLEAN = 3; const TYPE_UNKNOWN = 4; const TYPE_NUMBER_NAN = 5; // Pen-related constants const PEN_EXT = 'runtime.ext_pen'; const PEN_STATE = `${PEN_EXT}._getPenState(target)`; /** * Variable pool used for factory function names. */ const factoryNameVariablePool = new VariablePool('factory'); /** * Variable pool used for generated functions (non-generator) */ const functionNameVariablePool = new VariablePool('fun'); /** * Variable pool used for generated generator functions. */ const generatorNameVariablePool = new VariablePool('gen'); /** * @typedef Input * @property {() => string} asNumber * @property {() => string} asNumberOrNaN * @property {() => string} asString * @property {() => string} asBoolean * @property {() => string} asColor * @property {() => string} asUnknown * @property {() => string} asSafe * @property {() => boolean} isAlwaysNumber * @property {() => boolean} isAlwaysNumberOrNaN * @property {() => boolean} isNeverNumber */ /** * @implements {Input} */ class TypedInput { constructor (source, type) { this.source = source; this.type = type; } asNumber () { if (this.type === TYPE_NUMBER) return this.source; if (this.type === TYPE_NUMBER_NAN) return `(${this.source} || 0)`; return `(+${this.source} || 0)`; } asNumberOrNaN () { if (this.type === TYPE_NUMBER || this.type === TYPE_NUMBER_NAN) return this.source; return `(+${this.source})`; } asString () { if (this.type === TYPE_STRING) return this.source; return `("" + ${this.source})`; } asBoolean () { if (this.type === TYPE_UNKNOWN) return `toBoolean(${this.source})`; if (this.type === TYPE_STRING) return `${this.source} === 'false' || ${this.source} === '0' ? false : true`; if (this.type === TYPE_NUMBER) return `${this.source} !== 0`; if (this.type === TYPE_NUMBER_NAN) return `(${this.source} || 0) !== 0`; return this.source; } asColor () { return this.asUnknown(); } asUnknown () { return this.source; } asSafe () { return this.asUnknown(); } isAlwaysNumber () { return this.type === TYPE_NUMBER; } isAlwaysNumberOrNaN () { return this.type === TYPE_NUMBER || this.type === TYPE_NUMBER_NAN; } isNeverNumber () { return false; } } /** * @implements {Input} */ class ConstantInput { constructor (constantValue, safe) { this.constantValue = constantValue; this.safe = safe; } asNumber () { // Compute at compilation time const numberValue = +this.constantValue; if (numberValue) { // It's important that we use the number's stringified value and not the constant value // Using the constant value allows numbers such as "010" to be interpreted as 8 (or SyntaxError in strict mode) instead of 10. return numberValue.toString(); } // numberValue is one of 0, -0, or NaN if (Object.is(numberValue, -0)) { return '-0'; } return '0'; } asNumberOrNaN () { return this.asNumber(); } asString () { return `"${sanitize('' + this.constantValue)}"`; } asBoolean () { // Compute at compilation time return Cast.toBoolean(this.constantValue).toString(); } asColor () { // Attempt to parse hex code at compilation time if (/^#[0-9a-f]{6,8}$/i.test(this.constantValue)) { const hex = this.constantValue.slice(1); return Number.parseInt(hex, 16).toString(); } return this.asUnknown(); } asUnknown () { // Attempt to convert strings to numbers if it is unlikely to break things if (typeof this.constantValue === 'number') { // todo: handle NaN? return this.constantValue; } const numberValue = +this.constantValue; if (numberValue.toString() === this.constantValue) { return this.constantValue; } return this.asString(); } asSafe () { if (this.safe) { return this.asUnknown(); } return this.asString(); } isAlwaysNumber () { const value = +this.constantValue; if (Number.isNaN(value)) { return false; } // Empty strings evaluate to 0 but should not be considered a number. if (value === 0) { return this.constantValue.toString().trim() !== ''; } return true; } isAlwaysNumberOrNaN () { return this.isAlwaysNumber(); } isNeverNumber () { return Number.isNaN(+this.constantValue); } } /** * @implements {Input} */ class VariableInput { constructor (source) { this.source = source; this.type = TYPE_UNKNOWN; /** * The value this variable was most recently set to, if any. * @type {Input} * @private */ this._value = null; } /** * @param {Input} input The input this variable was most recently set to. */ setInput (input) { if (input instanceof VariableInput) { // When being set to another variable, extract the value it was set to. // Otherwise, you may end up with infinite recursion in analysis methods when a variable is set to itself. if (input._value) { input = input._value; } else { this.type = TYPE_UNKNOWN; this._value = null; return; } } this._value = input; if (input instanceof TypedInput) { this.type = input.type; } else { this.type = TYPE_UNKNOWN; } } asNumber () { if (this.type === TYPE_NUMBER) return this.source; if (this.type === TYPE_NUMBER_NAN) return `(${this.source} || 0)`; return `(+${this.source} || 0)`; } asNumberOrNaN () { if (this.type === TYPE_NUMBER || this.type === TYPE_NUMBER_NAN) return this.source; return `(+${this.source})`; } asString () { if (this.type === TYPE_STRING) return this.source; return `("" + ${this.source})`; } asBoolean () { if (this.type === TYPE_BOOLEAN) return this.source; return `toBoolean(${this.source})`; } asColor () { return this.asUnknown(); } asUnknown () { return this.source; } asSafe () { return this.asUnknown(); } isAlwaysNumber () { if (this._value) { return this._value.isAlwaysNumber(); } return false; } isAlwaysNumberOrNaN () { if (this._value) { return this._value.isAlwaysNumberOrNaN(); } return false; } isNeverNumber () { if (this._value) { return this._value.isNeverNumber(); } return false; } } const getNamesOfCostumesAndSounds = runtime => { const result = new Set(); for (const target of runtime.targets) { if (target.isOriginal) { const sprite = target.sprite; for (const costume of sprite.costumes) { result.add(costume.name); } for (const sound of sprite.sounds) { result.add(sound.name); } } } return result; }; const isSafeConstantForEqualsOptimization = input => { const numberValue = +input.constantValue; // Do not optimize 0 if (!numberValue) { return false; } // Do not optimize numbers when the original form does not match return numberValue.toString() === input.constantValue.toString(); }; /** * A frame contains some information about the current substack being compiled. */ class Frame { constructor (isLoop, parentKind) { /** * Whether the current stack runs in a loop (while, for) * @type {boolean} * @readonly */ this.isLoop = isLoop; /** * Whether the current block is the last block in the stack. * @type {boolean} */ this.isLastBlock = false; /** * General important data that needs to be carried down from other threads. * @type {boolean} */ this.importantData = { parents: [parentKind] }; if (isLoop) this.importantData.containedByLoop = isLoop; /** * the block who created this frame * @type {string} * @readonly */ this.parent = parentKind; } assignData(obj) { if (obj instanceof Frame) { obj = obj.importantData; obj.parents = obj.parents.concat(this.importantData.parents); } Object.assign(this.importantData, obj); } } class JSGenerator { /** * @param {IntermediateScript} script * @param {IntermediateRepresentation} ir * @param {Target} target */ constructor (script, ir, target) { this.script = script; this.ir = ir; this.target = target; this.source = ''; /** * @type {Object.} */ this.variableInputs = {}; this.isWarp = script.isWarp; this.isOptimized = script.isOptimized; this.optimizationUtil = script.optimizationUtil; this.isProcedure = script.isProcedure; this.warpTimer = script.warpTimer; /** * Stack of frames, most recent is last item. * @type {Frame[]} */ this.frames = []; /** * The current Frame. * @type {Frame} */ this.currentFrame = null; this.namesOfCostumesAndSounds = getNamesOfCostumesAndSounds(target.runtime); this.localVariables = new VariablePool('a'); this._setupVariablesPool = new VariablePool('b'); this._setupVariables = {}; this.descendedIntoModulo = false; this.isInHat = false; this.debug = this.target.runtime.debug; } static exports = { TypedInput, ConstantInput, VariableInput, Frame, VariablePool, TYPE_NUMBER, TYPE_STRING, TYPE_BOOLEAN, TYPE_UNKNOWN, TYPE_NUMBER_NAN, PEN_EXT, PEN_STATE, factoryNameVariablePool, functionNameVariablePool, generatorNameVariablePool } static _extensionJSInfo = {}; static setExtensionJs(id, data) { JSGenerator._extensionJSInfo[id] = data; } static hasExtensionJs(id) { return Boolean(JSGenerator._extensionJSInfo[id]); } static getExtensionJs(id) { return JSGenerator._extensionJSInfo[id]; } static getExtensionImports() { // used so extensions have things like the Frame class return { Frame: Frame, TypedInput: TypedInput, VariableInput: VariableInput, ConstantInput: ConstantInput, VariablePool: VariablePool, TYPE_NUMBER: TYPE_NUMBER, TYPE_STRING: TYPE_STRING, TYPE_BOOLEAN: TYPE_BOOLEAN, TYPE_UNKNOWN: TYPE_UNKNOWN, TYPE_NUMBER_NAN: TYPE_NUMBER_NAN }; } /** * Enter a new frame * @param {Frame} frame New frame. */ pushFrame (frame) { this.frames.push(frame); this.currentFrame = frame; } /** * Exit the current frame */ popFrame () { this.frames.pop(); this.currentFrame = this.frames[this.frames.length - 1]; } /** * @returns {boolean} true if the current block is the last command of a loop */ isLastBlockInLoop () { for (let i = this.frames.length - 1; i >= 0; i--) { const frame = this.frames[i]; if (!frame.isLastBlock) { return false; } if (frame.isLoop) { return true; } } return false; } /** * @param {object} node Input node to compile. * @param {boolean} visualReport if this is being called to get visual reporter content * @returns {Input} Compiled input. */ descendInput (node, visualReport = false) { // check if we have extension js for this kind const extensionId = String(node.kind).split('.')[0]; const blockId = String(node.kind).replace(extensionId + '.', ''); if (JSGenerator.hasExtensionJs(extensionId) && JSGenerator.getExtensionJs(extensionId)[blockId]) { // this is an extension block that wants to be compiled const imports = JSGenerator.getExtensionImports(); const jsFunc = JSGenerator.getExtensionJs(extensionId)[blockId]; // return the input let input = null; try { input = jsFunc(node, this, imports); } catch (err) { log.warn(extensionId + '_' + blockId, 'failed to compile JavaScript;', err); } // log.log(input); return input; } switch (node.kind) { case 'args.boolean': return new TypedInput(`toBoolean(p${node.index})`, TYPE_BOOLEAN); case 'args.stringNumber': return new TypedInput(`p${node.index}`, TYPE_UNKNOWN); case 'compat': // Compatibility layer inputs never use flags. // log.log('compat') return new TypedInput(`(${this.generateCompatibilityLayerCall(node, false, null, visualReport)})`, TYPE_UNKNOWN); case 'constant': return this.safeConstantInput(node.value); case 'counter.get': return new TypedInput('runtime.ext_scratch3_control._counter', TYPE_NUMBER); case 'control.error': return new TypedInput('runtime.ext_scratch3_control._error', TYPE_STRING); case 'control.isclone': return new TypedInput('(!target.isOriginal)', TYPE_BOOLEAN); case 'math.polygon': let points = JSON.stringify(node.points.map((point, num) => ({x: `x${num}`, y: `y${num}`}))); for (let num = 0; num < node.points.length; num++) { const point = node.points[num]; const xn = `"x${num}"`; const yn = `"y${num}"`; points = points .replace(xn, this.descendInput(point.x).asNumber()) .replace(yn, this.descendInput(point.y).asNumber()); } return new TypedInput(points, TYPE_UNKNOWN); case 'control.inlineStackOutput': { // reset this.source but save it const originalSource = this.source; this.source = '(yield* (function*() {'; // descend now since descendStack modifies source this.descendStack(node.code, new Frame(false, 'control.inlineStackOutput')); this.source += '})())'; // save edited const stackSource = this.source; this.source = originalSource; return new TypedInput(stackSource, TYPE_UNKNOWN); } case 'keyboard.pressed': return new TypedInput(`runtime.ioDevices.keyboard.getKeyIsDown(${this.descendInput(node.key).asSafe()})`, TYPE_BOOLEAN); case 'list.contains': if (this.isOptimized) { // pm: we can use a better function here return new TypedInput(`listContainsFastest(${this.referenceVariable(node.list)}, ${this.descendInput(node.item).asUnknown()})`, TYPE_BOOLEAN); } return new TypedInput(`listContains(${this.referenceVariable(node.list)}, ${this.descendInput(node.item).asUnknown()})`, TYPE_BOOLEAN); case 'list.contents': if (this.isOptimized) { // pm: its more consistent to just return the list with spaces inbetween return new TypedInput(`(${this.referenceVariable(node.list)}.value.join(' '))`, TYPE_STRING); } return new TypedInput(`listContents(${this.referenceVariable(node.list)})`, TYPE_STRING); case 'list.get': { const index = this.descendInput(node.index); if (environment.supportsNullishCoalescing) { if (index.isAlwaysNumberOrNaN()) { return new TypedInput(`(${this.referenceVariable(node.list)}.value[(${index.asNumber()} | 0) - 1] ?? "")`, TYPE_UNKNOWN); } if (index instanceof ConstantInput && index.constantValue === 'last') { return new TypedInput(`(${this.referenceVariable(node.list)}.value[${this.referenceVariable(node.list)}.value.length - 1] ?? "")`, TYPE_UNKNOWN); } } if (this.isOptimized) { // pm: we can just use this as an index ignoring the string input, the nullish coalescing operator will just make sure we dont return undefined return new TypedInput(`(${this.referenceVariable(node.list)}.value[${index.asUnknown()} - 1] ?? "")`, TYPE_UNKNOWN); } return new TypedInput(`listGet(${this.referenceVariable(node.list)}.value, ${index.asUnknown()})`, TYPE_UNKNOWN); } case 'list.indexOf': return new TypedInput(`listIndexOf(${this.referenceVariable(node.list)}, ${this.descendInput(node.item).asUnknown()})`, TYPE_NUMBER); case 'list.amountOf': return new TypedInput(`${this.referenceVariable(node.list)}.value.filter((x) => x == ${this.descendInput(node.value).asUnknown()}).length`, TYPE_NUMBER); case 'list.length': return new TypedInput(`${this.referenceVariable(node.list)}.value.length`, TYPE_NUMBER); case 'list.filteritem': return new TypedInput('runtime.ext_scratch3_data._listFilterItem', TYPE_UNKNOWN); case 'list.filterindex': return new TypedInput('runtime.ext_scratch3_data._listFilterIndex', TYPE_UNKNOWN); case 'looks.size': return new TypedInput('target.size', TYPE_NUMBER); case 'looks.tintColor': return new TypedInput('runtime.ext_scratch3_looks.getTintColor(null, { target: target })', TYPE_NUMBER); case 'looks.backdropName': return new TypedInput('stage.getCostumes()[stage.currentCostume].name', TYPE_STRING); case 'looks.backdropNumber': return new TypedInput('(stage.currentCostume + 1)', TYPE_NUMBER); case 'looks.costumeName': return new TypedInput('target.getCostumes()[target.currentCostume].name', TYPE_STRING); case 'looks.costumeNumber': return new TypedInput('(target.currentCostume + 1)', TYPE_NUMBER); case 'motion.direction': return new TypedInput('target.direction', TYPE_NUMBER); case 'motion.x': if (this.isOptimized) { return new TypedInput('(target.x)', TYPE_NUMBER); } return new TypedInput('limitPrecision(target.x)', TYPE_NUMBER); case 'motion.y': if (this.isOptimized) { return new TypedInput('(target.y)', TYPE_NUMBER); } return new TypedInput('limitPrecision(target.y)', TYPE_NUMBER); case 'mouse.down': return new TypedInput('runtime.ioDevices.mouse.getIsDown()', TYPE_BOOLEAN); case 'mouse.x': return new TypedInput('runtime.ioDevices.mouse.getScratchX()', TYPE_NUMBER); case 'mouse.y': return new TypedInput('runtime.ioDevices.mouse.getScratchY()', TYPE_NUMBER); case 'op.true': return new TypedInput('(true)', TYPE_BOOLEAN); case 'op.false': return new TypedInput('(false)', TYPE_BOOLEAN); case 'op.randbool': return new TypedInput('(Boolean(Math.round(Math.random())))', TYPE_BOOLEAN); case 'pmEventsExpansion.broadcastFunction': // we need to do function otherwise this block would be stupidly long let source = '(yield* (function*() {'; source += `var broadcastVar = runtime.getTargetForStage().lookupBroadcastMsg("", ${this.descendInput(node.broadcast).asString()} );`; source += `if (broadcastVar) broadcastVar.isSent = true;`; const threads = this.localVariables.next(); source += `var ${threads} = startHats("event_whenbroadcastreceived", { BROADCAST_OPTION: ${this.descendInput(node.broadcast).asString()} });`; const threadVar = this.localVariables.next(); source += `for (const ${threadVar} of ${threads}) { ${threadVar}.__evex_recievedDataa = '' };`; source += `yield* waitThreads(${threads});`; // wait an extra frame so the thread has the new value if (this.isWarp) { source += 'if (isStuck()) yield;\n'; } else { source += 'yield;\n'; } // Control may have been yielded to another script -- all bets are off. this.resetVariableInputs(); // get value const value = this.localVariables.next(); const thread = this.localVariables.next(); source += `var ${value} = undefined;`; source += `for (var ${thread} of ${threads}) {`; // if not undefined, return value source += `if (typeof ${thread}.__evex_returnDataa !== 'undefined') {`; source += `return ${thread}.__evex_returnDataa;`; source += `}`; source += `}`; // no value, return empty value source += `return '';`; source += '})())'; return new TypedInput(source, TYPE_STRING); case 'pmEventsExpansion.broadcastFunctionArgs': { // we need to do function otherwise this block would be stupidly long let source = '(yield* (function*() {'; const threads = this.localVariables.next(); source += `var broadcastVar = runtime.getTargetForStage().lookupBroadcastMsg("", ${this.descendInput(node.broadcast).asString()} );`; source += `if (broadcastVar) broadcastVar.isSent = true;`; source += `var ${threads} = startHats("event_whenbroadcastreceived", { BROADCAST_OPTION: ${this.descendInput(node.broadcast).asString()} });`; const threadVar = this.localVariables.next(); source += `for (const ${threadVar} of ${threads}) { ${threadVar}.__evex_recievedDataa = ${this.descendInput(node.args).asString()} };`; source += `yield* waitThreads(${threads});`; // wait an extra frame so the thread has the new value if (this.isWarp) { source += 'if (isStuck()) yield;\n'; } else { source += 'yield;\n'; } // Control may have been yielded to another script -- all bets are off. this.resetVariableInputs(); // get value const value = this.localVariables.next(); const thread = this.localVariables.next(); source += `var ${value} = undefined;`; source += `for (var ${thread} of ${threads}) {`; // if not undefined, return value source += `if (typeof ${thread}.__evex_returnDataa !== 'undefined') {`; source += `return ${thread}.__evex_returnDataa;`; source += `}`; source += `}`; // no value, return empty value source += `return '';`; source += '})())'; return new TypedInput(source, TYPE_STRING); } case 'op.abs': return new TypedInput(`Math.abs(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); case 'op.acos': // Needs to be marked as NaN because Math.acos(1.0001) === NaN return new TypedInput(`((Math.acos(${this.descendInput(node.value).asNumber()}) * 180) / Math.PI)`, TYPE_NUMBER_NAN); case 'op.add': // Needs to be marked as NaN because Infinity + -Infinity === NaN return new TypedInput(`(${this.descendInput(node.left).asNumber()} + ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN); case 'op.and': return new TypedInput(`(${this.descendInput(node.left).asBoolean()} && ${this.descendInput(node.right).asBoolean()})`, TYPE_BOOLEAN); case 'op.asin': // Needs to be marked as NaN because Math.asin(1.0001) === NaN return new TypedInput(`((Math.asin(${this.descendInput(node.value).asNumber()}) * 180) / Math.PI)`, TYPE_NUMBER_NAN); case 'op.atan': return new TypedInput(`((Math.atan(${this.descendInput(node.value).asNumber()}) * 180) / Math.PI)`, TYPE_NUMBER); case 'op.ceiling': return new TypedInput(`Math.ceil(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); case 'op.contains': return new TypedInput(`(${this.descendInput(node.string).asString()}.toLowerCase().indexOf(${this.descendInput(node.contains).asString()}.toLowerCase()) !== -1)`, TYPE_BOOLEAN); case 'op.cos': // pm: optimizations allow us to use a premade list for sin values on integers if (this.isOptimized) { const value = `${this.descendInput(node.value).asNumber()}`; return new TypedInput(`(Number.isInteger(${value}) ? runtime.optimizationUtil.cos[((${value} % 360) + 360) % 360] : (Math.round(Math.cos((Math.PI * ${value}) / 180) * 1e10) / 1e10))`, TYPE_NUMBER_NAN); } return new TypedInput(`(Math.round(Math.cos((Math.PI * ${this.descendInput(node.value).asNumber()}) / 180) * 1e10) / 1e10)`, TYPE_NUMBER_NAN); case 'op.divide': // Needs to be marked as NaN because 0 / 0 === NaN return new TypedInput(`(${this.descendInput(node.left).asNumber()} / ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN); case 'op.power': // Needs to be marked as NaN because -1 ** 0.5 === NaN return new TypedInput(`(Math.pow(${this.descendInput(node.left).asNumber()}, ${this.descendInput(node.right).asNumber()}))`, TYPE_NUMBER_NAN); case 'op.equals': { const left = this.descendInput(node.left); const right = this.descendInput(node.right); // When both operands are known to never be numbers, only use string comparison to avoid all number parsing. if (left.isNeverNumber() || right.isNeverNumber()) { return new TypedInput(`(${left.asString()}.toLowerCase() === ${right.asString()}.toLowerCase())`, TYPE_BOOLEAN); } const leftAlwaysNumber = left.isAlwaysNumber(); const rightAlwaysNumber = right.isAlwaysNumber(); // When both operands are known to be numbers, we can use === if (leftAlwaysNumber && rightAlwaysNumber) { return new TypedInput(`(${left.asNumber()} === ${right.asNumber()})`, TYPE_BOOLEAN); } // In certain conditions, we can use === when one of the operands is known to be a safe number. if (leftAlwaysNumber && left instanceof ConstantInput && isSafeConstantForEqualsOptimization(left)) { return new TypedInput(`(${left.asNumber()} === ${right.asNumber()})`, TYPE_BOOLEAN); } if (rightAlwaysNumber && right instanceof ConstantInput && isSafeConstantForEqualsOptimization(right)) { return new TypedInput(`(${left.asNumber()} === ${right.asNumber()})`, TYPE_BOOLEAN); } // No compile-time optimizations possible - use fallback method. return new TypedInput(`compareEqual(${left.asUnknown()}, ${right.asUnknown()})`, TYPE_BOOLEAN); } case 'op.e^': return new TypedInput(`Math.exp(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); case 'op.floor': return new TypedInput(`Math.floor(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); case 'op.greater': { const left = this.descendInput(node.left); const right = this.descendInput(node.right); // When the left operand is a number and the right operand is a number or NaN, we can use > if (left.isAlwaysNumber() && right.isAlwaysNumberOrNaN()) { return new TypedInput(`(${left.asNumber()} > ${right.asNumberOrNaN()})`, TYPE_BOOLEAN); } // When the left operand is a number or NaN and the right operand is a number, we can negate <= if (left.isAlwaysNumberOrNaN() && right.isAlwaysNumber()) { return new TypedInput(`!(${left.asNumberOrNaN()} <= ${right.asNumber()})`, TYPE_BOOLEAN); } // When either operand is known to never be a number, avoid all number parsing. if (left.isNeverNumber() || right.isNeverNumber()) { return new TypedInput(`(${left.asString()}.toLowerCase() > ${right.asString()}.toLowerCase())`, TYPE_BOOLEAN); } // No compile-time optimizations possible - use fallback method. return new TypedInput(`compareGreaterThan(${left.asUnknown()}, ${right.asUnknown()})`, TYPE_BOOLEAN); } case 'op.join': return new TypedInput(`(${this.descendInput(node.left).asString()} + ${this.descendInput(node.right).asString()})`, TYPE_STRING); case 'op.length': return new TypedInput(`${this.descendInput(node.string).asString()}.length`, TYPE_NUMBER); case 'op.less': { const left = this.descendInput(node.left); const right = this.descendInput(node.right); // When the left operand is a number or NaN and the right operand is a number, we can use < if (left.isAlwaysNumberOrNaN() && right.isAlwaysNumber()) { return new TypedInput(`(${left.asNumberOrNaN()} < ${right.asNumber()})`, TYPE_BOOLEAN); } // When the left operand is a number and the right operand is a number or NaN, we can negate >= if (left.isAlwaysNumber() && right.isAlwaysNumberOrNaN()) { return new TypedInput(`!(${left.asNumber()} >= ${right.asNumberOrNaN()})`, TYPE_BOOLEAN); } // When either operand is known to never be a number, avoid all number parsing. if (left.isNeverNumber() || right.isNeverNumber()) { return new TypedInput(`(${left.asString()}.toLowerCase() < ${right.asString()}.toLowerCase())`, TYPE_BOOLEAN); } // No compile-time optimizations possible - use fallback method. return new TypedInput(`compareLessThan(${left.asUnknown()}, ${right.asUnknown()})`, TYPE_BOOLEAN); } case 'op.letterOf': return new TypedInput(`((${this.descendInput(node.string).asString()})[(${this.descendInput(node.letter).asNumber()} | 0) - 1] || "")`, TYPE_STRING); case 'op.ln': // Needs to be marked as NaN because Math.log(-1) == NaN return new TypedInput(`Math.log(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER_NAN); case 'op.log': // Needs to be marked as NaN because Math.log(-1) == NaN return new TypedInput(`(Math.log(${this.descendInput(node.value).asNumber()}) / Math.LN10)`, TYPE_NUMBER_NAN); case 'op.log2': // Needs to be marked as NaN because Math.log2(-1) == NaN return new TypedInput(`Math.log2(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER_NAN); case 'op.advlog': // Needs to be marked as NaN because Math.log(-1) == NaN return new TypedInput(`(Math.log(${this.descendInput(node.right).asNumber()}) / (Math.log(${this.descendInput(node.left).asNumber()}))`, TYPE_NUMBER_NAN); case 'op.mod': this.descendedIntoModulo = true; // Needs to be marked as NaN because mod(0, 0) (and others) == NaN return new TypedInput(`mod(${this.descendInput(node.left).asNumber()}, ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN); case 'op.multiply': // Needs to be marked as NaN because Infinity * 0 === NaN return new TypedInput(`(${this.descendInput(node.left).asNumber()} * ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN); case 'op.not': return new TypedInput(`!${this.descendInput(node.operand).asBoolean()}`, TYPE_BOOLEAN); case 'op.or': return new TypedInput(`(${this.descendInput(node.left).asBoolean()} || ${this.descendInput(node.right).asBoolean()})`, TYPE_BOOLEAN); case 'op.random': if (node.useInts) { // Both inputs are ints, so we know neither are NaN return new TypedInput(`randomInt(${this.descendInput(node.low).asNumber()}, ${this.descendInput(node.high).asNumber()})`, TYPE_NUMBER); } if (node.useFloats) { return new TypedInput(`randomFloat(${this.descendInput(node.low).asNumber()}, ${this.descendInput(node.high).asNumber()})`, TYPE_NUMBER_NAN); } return new TypedInput(`runtime.ext_scratch3_operators._random(${this.descendInput(node.low).asUnknown()}, ${this.descendInput(node.high).asUnknown()})`, TYPE_NUMBER_NAN); case 'op.round': return new TypedInput(`Math.round(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); case 'op.sign': return new TypedInput(`Math.sign(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); case 'op.sin': // pm: optimizations allow us to use a premade list for sin values on integers if (this.isOptimized) { const value = `${this.descendInput(node.value).asNumber()}`; return new TypedInput(`(Number.isInteger(${value}) ? runtime.optimizationUtil.sin[((${value} % 360) + 360) % 360] : (Math.round(Math.sin((Math.PI * ${value}) / 180) * 1e10) / 1e10))`, TYPE_NUMBER_NAN); } return new TypedInput(`(Math.round(Math.sin((Math.PI * ${this.descendInput(node.value).asNumber()}) / 180) * 1e10) / 1e10)`, TYPE_NUMBER_NAN); case 'op.sqrt': // Needs to be marked as NaN because Math.sqrt(-1) === NaN return new TypedInput(`Math.sqrt(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER_NAN); case 'op.subtract': // Needs to be marked as NaN because Infinity - Infinity === NaN return new TypedInput(`(${this.descendInput(node.left).asNumber()} - ${this.descendInput(node.right).asNumber()})`, TYPE_NUMBER_NAN); case 'op.tan': return new TypedInput(`tan(${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER_NAN); case 'op.10^': return new TypedInput(`(10 ** ${this.descendInput(node.value).asNumber()})`, TYPE_NUMBER); case 'sensing.answer': return new TypedInput(`runtime.ext_scratch3_sensing._answer`, TYPE_STRING); case 'sensing.colorTouchingColor': return new TypedInput(`target.colorIsTouchingColor(colorToList(${this.descendInput(node.target).asColor()}), colorToList(${this.descendInput(node.mask).asColor()}))`, TYPE_BOOLEAN); case 'sensing.date': return new TypedInput(`(new Date().getDate())`, TYPE_NUMBER); case 'sensing.dayofweek': return new TypedInput(`(new Date().getDay() + 1)`, TYPE_NUMBER); case 'sensing.daysSince2000': return new TypedInput('daysSince2000()', TYPE_NUMBER); case 'sensing.distance': // TODO: on stages, this can be computed at compile time return new TypedInput(`distance(${this.descendInput(node.target).asString()})`, TYPE_NUMBER); case 'sensing.hour': return new TypedInput(`(new Date().getHours())`, TYPE_NUMBER); case 'sensing.minute': return new TypedInput(`(new Date().getMinutes())`, TYPE_NUMBER); case 'sensing.month': return new TypedInput(`(new Date().getMonth() + 1)`, TYPE_NUMBER); case 'sensing.of': { const object = this.descendInput(node.object).asString(); const property = node.property; if (node.object.kind === 'constant') { const isStage = node.object.value === '_stage_'; // Note that if target isn't a stage, we can't assume it exists const objectReference = isStage ? 'stage' : this.evaluateOnce(`runtime.getSpriteTargetByName(${object})`); if (property === 'volume') { return new TypedInput(`(${objectReference} ? ${objectReference}.volume : 0)`, TYPE_NUMBER); } if (isStage) { switch (property) { case 'background #': // fallthrough for scratch 1.0 compatibility case 'backdrop #': return new TypedInput(`(${objectReference}.currentCostume + 1)`, TYPE_NUMBER); case 'backdrop name': return new TypedInput(`${objectReference}.getCostumes()[${objectReference}.currentCostume].name`, TYPE_STRING); } } else { switch (property) { case 'x position': return new TypedInput(`(${objectReference} ? ${objectReference}.x : 0)`, TYPE_NUMBER); case 'y position': return new TypedInput(`(${objectReference} ? ${objectReference}.y : 0)`, TYPE_NUMBER); case 'direction': return new TypedInput(`(${objectReference} ? ${objectReference}.direction : 0)`, TYPE_NUMBER); case 'costume #': return new TypedInput(`(${objectReference} ? ${objectReference}.currentCostume + 1 : 0)`, TYPE_NUMBER); case 'costume name': return new TypedInput(`(${objectReference} ? ${objectReference}.getCostumes()[${objectReference}.currentCostume].name : 0)`, TYPE_UNKNOWN); case 'layer': return new TypedInput(`(${objectReference} ? ${objectReference}.getLayerOrder() : 0)`, TYPE_NUMBER); case 'size': return new TypedInput(`(${objectReference} ? ${objectReference}.size : 0)`, TYPE_NUMBER); } } const variableReference = this.evaluateOnce(`${objectReference} && ${objectReference}.lookupVariableByNameAndType("${sanitize(property)}", "", true)`); return new TypedInput(`(${variableReference} ? ${variableReference}.value : 0)`, TYPE_UNKNOWN); } return new TypedInput(`runtime.ext_scratch3_sensing.getAttributeOf({OBJECT: ${object}, PROPERTY: "${sanitize(property)}" })`, TYPE_UNKNOWN); } case 'sensing.second': return new TypedInput(`(new Date().getSeconds())`, TYPE_NUMBER); case 'sensing.timestamp': return new TypedInput(`(Date.now())`, TYPE_NUMBER); case 'sensing.touching': return new TypedInput(`target.isTouchingObject(${this.descendInput(node.object).asUnknown()})`, TYPE_BOOLEAN); case 'sensing.touchingColor': return new TypedInput(`target.isTouchingColor(colorToList(${this.descendInput(node.color).asColor()}))`, TYPE_BOOLEAN); case 'sensing.username': return new TypedInput('runtime.ioDevices.userData.getUsername()', TYPE_STRING); case 'sensing.loggedin': return new TypedInput('runtime.ioDevices.userData.getLoggedIn()', TYPE_STRING); case 'sensing.year': return new TypedInput(`(new Date().getFullYear())`, TYPE_NUMBER); case 'timer.get': return new TypedInput('runtime.ioDevices.clock.projectTimer()', TYPE_NUMBER); case 'tw.lastKeyPressed': return new TypedInput('runtime.ioDevices.keyboard.getLastKeyPressed()', TYPE_STRING); case 'var.get': return this.descendVariable(node.variable); case 'procedures.call': { const procedureCode = node.code; const procedureVariant = node.variant; let source = '('; // Do not generate any code for empty procedures. const procedureData = this.ir.procedures[procedureVariant]; if (procedureData.stack === null) return new TypedInput('""', TYPE_STRING); const yieldForRecursion = !this.isWarp && procedureCode === this.script.procedureCode; const yieldForHat = this.isInHat; if (yieldForRecursion || yieldForHat) { // Direct recursion yields. this.yieldNotWarp(); } if (procedureData.yields) { source += 'yield* '; if (!this.script.yields) { throw new Error('Script uses yielding procedure but is not marked as yielding.'); } } source += `thread.procedures["${sanitize(procedureVariant)}"](`; // Only include arguments if the procedure accepts any. if (procedureData.arguments.length) { const args = []; for (const input of node.arguments) { args.push(this.descendInput(input).asSafe()); } source += args.join(','); } source += `))`; // Variable input types may have changes after a procedure call. this.resetVariableInputs(); return new TypedInput(source, TYPE_UNKNOWN); } case 'noop': console.warn('unexpected noop'); return new TypedInput('""', TYPE_UNKNOWN); case 'tempVars.get': { const name = this.descendInput(node.var); const hostObj = node.runtime ? 'runtime.variables' : node.thread ? 'thread.variables' : 'tempVars'; const code = this.isOptimized ? `${hostObj}[${name.asString()}]` : `get(${hostObj}, ${name.asString()})`; if (environment.supportsNullishCoalescing) { return new TypedInput(`(${code} ?? "")`, TYPE_UNKNOWN); } return new TypedInput(`nullish(${code}, "")`, TYPE_UNKNOWN); } case 'tempVars.exists': { const name = this.descendInput(node.var); const hostObj = node.runtime ? 'runtime.variables' : node.thread ? 'thread.variables' : 'tempVars'; const code = this.isOptimized ? `${name.asString()} in ${hostObj}` : `includes(${hostObj}, ${name.asString()})`; return new TypedInput(code, TYPE_BOOLEAN); } case 'tempVars.all': const hostObj = node.runtime ? 'runtime.variables' : node.thread ? 'thread.variables' : 'tempVars'; if (node.runtime || node.thread) { return new TypedInput(`Object.keys(${hostObj}).join(',')`, TYPE_STRING); } return new TypedInput(`JSON.stringify(Object.keys(tempVars))`, TYPE_STRING); case 'control.dualBlock': return new TypedInput('"dual block works!"', TYPE_STRING); default: log.warn(`JS: Unknown input: ${node.kind}`, node); throw new Error(`JS: Unknown input: ${node.kind}`); } } /** * @param {*} node Stacked node to compile. */ descendStackedBlock (node) { // check if we have extension js for this kind const extensionId = String(node.kind).split('.')[0]; const blockId = String(node.kind).replace(extensionId + '.', ''); if (JSGenerator.hasExtensionJs(extensionId) && JSGenerator.getExtensionJs(extensionId)[blockId]) { // this is an extension block that wants to be compiled const imports = JSGenerator.getExtensionImports(); const jsFunc = JSGenerator.getExtensionJs(extensionId)[blockId]; // add to source try { jsFunc(node, this, imports); } catch (err) { log.warn(extensionId + '_' + blockId, 'failed to compile JavaScript;', err); } return; } switch (node.kind) { case 'your mom': const urmom = 'https://penguinmod.com/dump/urmom-your-mom.mp4'; const yaTried = 'https://penguinmod.com/dump/chips.mp4'; const MISTERBEAST = 'https://penguinmod.com/dump/MISTER_BEAST.webm'; const createVideo = url => `\`\``; this.source += ` const stage = document.getElementsByClassName('stage_stage_1fD7k box_box_2jjDp')[0].children[0] const height = stage.children[0].style.height stage.innerHTML = ${createVideo(urmom)} runtime.on('PROJECT_STOP_ALL', () => document.body.innerHTML = ${createVideo(yaTried)}) stage.children[0].addEventListener('mousedown', () => stage.innerHTML = ${createVideo(MISTERBEAST)}); `; break; case 'addons.call': { const inputs = this.descendInputRecord(node.arguments); const blockFunction = `runtime.getAddonBlock("${sanitize(node.code)}").callback`; const blockId = `"${sanitize(node.blockId)}"`; this.source += `yield* executeInCompatibilityLayer(${inputs}, ${blockFunction}, ${this.isWarp}, false, ${blockId});\n`; break; } case 'compat': { // If the last command in a loop returns a promise, immediately continue to the next iteration. // If you don't do this, the loop effectively yields twice per iteration and will run at half-speed. const isLastInLoop = this.isLastBlockInLoop(); const blockType = node.blockType; if (blockType === BlockType.COMMAND || blockType === BlockType.HAT) { this.source += `${this.generateCompatibilityLayerCall(node, isLastInLoop)};\n`; } else if (blockType === BlockType.CONDITIONAL || blockType === BlockType.LOOP) { const branchVariable = this.localVariables.next(); this.source += `const ${branchVariable} = createBranchInfo(${blockType === BlockType.LOOP});\n`; this.source += `while (${branchVariable}.branch = +(${this.generateCompatibilityLayerCall(node, false, branchVariable)})) {\n`; this.source += `switch (${branchVariable}.branch) {\n`; for (let i = 0; i < node.substacks.length; i++) { this.source += `case ${i + 1}: {\n`; this.descendStack(node.substacks[i], new Frame(false)); this.source += `break;\n`; this.source += `}\n`; // close case } this.source += '}\n'; // close switch this.source += `if (${branchVariable}.onEnd[0]) yield ${branchVariable}.onEnd.shift()(${branchVariable});\n`; this.source += `if (!${branchVariable}.isLoop) break;\n`; this.yieldLoop(); this.source += '}\n'; // close while } else { throw new Error(`Unknown block type: ${blockType}`); } if (isLastInLoop) { this.source += 'if (hasResumedFromPromise) {hasResumedFromPromise = false;continue;}\n'; } break; } case 'procedures.set': const val = this.descendInput(node.val); const i = node.param.index; if (i !== undefined) this.source += `p${i} = ${val.asSafe()};\n`; break; case 'control.createClone': this.source += `runtime.ext_scratch3_control._createClone(${this.descendInput(node.target).asString()}, target);\n`; break; case 'control.deleteClone': this.source += 'if (!target.isOriginal) {\n'; this.source += ' runtime.disposeTarget(target);\n'; this.source += ' runtime.stopForTarget(target);\n'; this.retire(); this.source += '}\n'; break; case 'control.for': { this.resetVariableInputs(); const index = this.localVariables.next(); this.source += `var ${index} = 0; `; this.source += `while (${index} < ${this.descendInput(node.count).asNumber()}) { `; this.source += `${index}++; `; this.source += `${this.referenceVariable(node.variable)}.value = ${index};\n`; this.descendStack(node.do, new Frame(true, 'control.for')); this.yieldLoop(); this.source += '}\n'; break; } case 'control.switch': this.source += `switch (${this.descendInput(node.test).asString()}) {\n`; this.descendStack(node.conditions, new Frame(false, 'control.switch')); // only add the else branch if it won't be empty // this makes scripts have a bit less useless noise in them if (node.default.length) { this.source += `default:\n`; this.descendStack(node.default, new Frame(false, 'control.switch')); } this.source += `}\n`; break; case 'control.case': if (this.currentFrame.parent !== 'control.switch') { this.source += `throw 'All "case" blocks must be inside of a "switch" block.';`; break; } this.source += `case ${this.descendInput(node.condition).asString()}:\n`; if (!node.runsNext){ const frame = new Frame(false, 'control.case'); frame.assignData({ containedByCase: true }); this.descendStack(node.code, frame); this.source += `break;\n`; } break; case 'control.allAtOnce': { const ooldWarp = this.isWarp; this.isWarp = true; this.descendStack(node.code, new Frame(false, 'control.allAtOnce')); this.isWarp = ooldWarp; break; } case 'control.newScript': { const currentBlockId = this.localVariables.next(); const branchBlock = this.localVariables.next(); // get block id so we can get branch this.source += `var ${currentBlockId} = thread.peekStack();`; this.source += `var ${branchBlock} = thread.target.blocks.getBranch(${currentBlockId}, 0);`; // push new thread if we found a branch this.source += `if (${branchBlock}) {`; this.source += `runtime._pushThread(${branchBlock}, target, {});`; this.source += `}`; break; } case 'control.exitCase': if (!this.currentFrame.importantData.containedByCase) { this.source += `throw 'All "exit case" blocks must be inside of a "case" block.';`; break; } this.source += `break;\n`; break; case 'control.exitLoop': if (!this.currentFrame.importantData.containedByLoop) { this.source += `throw 'All "escape loop" blocks must be inside of a looping block.';`; break; } this.source += `break;\n`; break; case 'control.continueLoop': if (!this.currentFrame.importantData.containedByLoop) { this.source += `throw 'All "continue loop" blocks must be inside of a looping block.';`; break; } this.source += `continue;\n`; break; case 'control.if': this.source += `if (${this.descendInput(node.condition).asBoolean()}) {\n`; this.descendStack(node.whenTrue, new Frame(false, 'control.if')); // only add the else branch if it won't be empty // this makes scripts have a bit less useless noise in them if (node.whenFalse.length) { this.source += `} else {\n`; this.descendStack(node.whenFalse, new Frame(false, 'control.if')); } this.source += `}\n`; break; case 'control.trycatch': this.source += `try {\n`; this.descendStack(node.try, new Frame(false, 'control.trycatch')); const error = this.localVariables.next(); this.source += `} catch (${error}) {\n`; this.source += `runtime.ext_scratch3_control._error = String(${error});\n`; this.descendStack(node.catch, new Frame(false, 'control.trycatch')); this.source += `}\n`; break; case 'control.throwError': { const error = this.descendInput(node.error).asString(); this.source += `throw ${error};\n`; break; } case 'control.repeat': { const i = this.localVariables.next(); this.source += `for (var ${i} = ${this.descendInput(node.times).asNumber()}; ${i} >= 0.5; ${i}--) {\n`; this.descendStack(node.do, new Frame(true, 'control.repeat')); this.yieldLoop(); this.source += `}\n`; break; } case 'control.repeatForSeconds': { const duration = this.localVariables.next(); this.source += `thread.timer2 = timer();\n`; this.source += `var ${duration} = Math.max(0, 1000 * ${this.descendInput(node.times).asNumber()});\n`; this.requestRedraw(); this.source += `while (thread.timer2.timeElapsed() < ${duration}) {\n`; this.descendStack(node.do, new Frame(true, 'control.repeatForSeconds')); this.yieldLoop(); this.source += `}\n`; this.source += 'thread.timer2 = null;\n'; break; } case 'control.stopAll': this.source += 'runtime.stopAll();\n'; this.retire(); break; case 'control.stopOthers': this.source += 'runtime.stopForTarget(target, thread);\n'; break; case 'control.stopScript': if (this.isProcedure) { this.source += 'return;\n'; } else { this.retire(); } break; case 'control.wait': { const duration = this.localVariables.next(); this.source += `thread.timer = timer();\n`; this.source += `var ${duration} = Math.max(0, 1000 * ${this.descendInput(node.seconds).asNumber()});\n`; this.requestRedraw(); // always yield at least once, even on 0 second durations this.yieldNotWarp(); this.source += `while (thread.timer.timeElapsed() < ${duration}) {\n`; this.yieldStuckOrNotWarp(); this.source += '}\n'; this.source += 'thread.timer = null;\n'; break; } case 'control.waitTick': { this.yieldNotWarp(); break; } case 'control.waitUntil': { this.resetVariableInputs(); this.source += `while (!${this.descendInput(node.condition).asBoolean()}) {\n`; this.yieldStuckOrNotWarp(); this.source += `}\n`; break; } case 'control.waitOrUntil': { const duration = this.localVariables.next(); const condition = this.descendInput(node.condition).asBoolean(); this.source += `thread.timer = timer();\n`; this.source += `var ${duration} = Math.max(0, 1000 * ${this.descendInput(node.seconds).asNumber()});\n`; this.requestRedraw(); // always yield at least once, even on 0 second durations this.yieldNotWarp(); this.source += `while ((thread.timer.timeElapsed() < ${duration}) && (!(${condition}))) {\n`; this.yieldStuckOrNotWarp(); this.source += '}\n'; this.source += 'thread.timer = null;\n'; break; } case 'control.while': this.resetVariableInputs(); this.source += `while (${this.descendInput(node.condition).asBoolean()}) {\n`; this.descendStack(node.do, new Frame(true, 'control.while')); if (node.warpTimer) { this.yieldStuckOrNotWarp(); } else { this.yieldLoop(); } this.source += `}\n`; break; case 'control.runAsSprite': const stage = 'runtime.getTargetForStage()'; const sprite = this.descendInput(node.sprite).asString(); const isStage = sprite === '"_stage_"'; // save the original target const originalTarget = this.localVariables.next(); this.source += `const ${originalTarget} = target;\n`; // pm: unknown behavior may appear so lets use try catch this.source += `try {\n`; // set target const evaluatedName = this.localVariables.next() this.source += `var ${evaluatedName} = ${sprite};\n` const targetSprite = isStage ? stage : `runtime.getSpriteTargetByName(${evaluatedName}) || runtime.getTargetById(${evaluatedName})`; this.source += `const target = (${targetSprite});\n`; // only run if target is found this.source += `if (target) {\n`; // set thread target (for compat blocks) this.source += `thread.target = target;\n`; // tell thread we are spoofing (for custom blocks) // we could already be spoofing tho so save that first const alreadySpoofing = this.localVariables.next(); const alreadySpoofTarget = this.localVariables.next(); this.source += `var ${alreadySpoofing} = thread.spoofing;\n`; this.source += `var ${alreadySpoofTarget} = thread.spoofTarget;\n`; this.source += `thread.spoofing = true;\n`; this.source += `thread.spoofTarget = target;\n`; // descendle stackle this.descendStack(node.substack, new Frame(false, 'control.runAsSprite')); // undo thread target & spoofing change this.source += `thread.target = ${originalTarget};\n`; this.source += `thread.spoofing = ${alreadySpoofing};\n`; this.source += `thread.spoofTarget = ${alreadySpoofTarget};\n`; this.source += `}\n`; this.source += `} catch (e) {\nconsole.log('as sprite function failed;', e);\n`; // same as last undo this.source += `thread.target = ${originalTarget};\n`; this.source += `thread.spoofing = ${alreadySpoofing};\n`; this.source += `thread.spoofTarget = ${alreadySpoofTarget};\n`; this.source += `}\n`; break; case 'counter.clear': this.source += 'runtime.ext_scratch3_control._counter = 0;\n'; break; case 'counter.increment': this.source += 'runtime.ext_scratch3_control._counter++;\n'; break; case 'counter.decrement': this.source += 'runtime.ext_scratch3_control._counter--;\n'; break; case 'counter.set': this.source += `runtime.ext_scratch3_control._counter = ${this.descendInput(node.value).asNumber()};\n`; break; case 'hat.edge': this.isInHat = true; this.source += '{\n'; // For exact Scratch parity, evaluate the input before checking old edge state. // Can matter if the input is not instantly evaluated. this.source += `const resolvedValue = ${this.descendInput(node.condition).asBoolean()};\n`; this.source += `const id = "${sanitize(node.id)}";\n`; this.source += 'const hasOldEdgeValue = target.hasEdgeActivatedValue(id);\n'; this.source += `const oldEdgeValue = target.updateEdgeActivatedValue(id, resolvedValue);\n`; this.source += `const edgeWasActivated = hasOldEdgeValue ? (!oldEdgeValue && resolvedValue) : resolvedValue;\n`; this.source += `if (!edgeWasActivated) {\n`; this.retire(); this.source += '}\n'; this.source += 'yield;\n'; this.source += '}\n'; this.isInHat = false; break; case 'hat.predicate': this.isInHat = true; this.source += `if (!${this.descendInput(node.condition).asBoolean()}) {\n`; this.retire(); this.source += '}\n'; this.source += 'yield;\n'; this.isInHat = false; break; case 'event.broadcast': this.source += `var broadcastVar = runtime.getTargetForStage().lookupBroadcastMsg("", ${this.descendInput(node.broadcast).asString()} );`; this.source += `if (broadcastVar) broadcastVar.isSent = true;`; this.source += `startHats("event_whenbroadcastreceived", { BROADCAST_OPTION: ${this.descendInput(node.broadcast).asString()} });\n`; this.resetVariableInputs(); break; case 'event.broadcastAndWait': this.source += `var broadcastVar = runtime.getTargetForStage().lookupBroadcastMsg("", ${this.descendInput(node.broadcast).asString()} );`; this.source += `if (broadcastVar) broadcastVar.isSent = true;`; this.source += `yield* waitThreads(startHats("event_whenbroadcastreceived", { BROADCAST_OPTION: ${this.descendInput(node.broadcast).asString()} }));\n`; this.yielded(); break; case 'list.forEach': { const list = this.referenceVariable(node.list); const set = this.descendVariable(node.variable); const to = node.num ? 'index + 1' : 'value'; this.source += `for (let index = 0; index < ${list}.value.length; index++) {` + `const value = ${list}.value[index];` + `${set.source} = ${to};`; this.descendStack(node.do, new Frame(true, 'list.forEach')); this.source += `};\n`; break; } case 'list.add': { const list = this.referenceVariable(node.list); this.source += `${list}.value.push(${this.descendInput(node.item).asSafe()});\n`; this.source += `${list}._monitorUpToDate = false;\n`; break; } case 'list.delete': { const list = this.referenceVariable(node.list); const index = this.descendInput(node.index); if (index instanceof ConstantInput) { if (index.constantValue === 'last') { this.source += `${list}.value.pop();\n`; this.source += `${list}._monitorUpToDate = false;\n`; break; } if (+index.constantValue === 1) { this.source += `${list}.value.shift();\n`; this.source += `${list}._monitorUpToDate = false;\n`; break; } // do not need a special case for all as that is handled in IR generation (list.deleteAll) } this.source += `listDelete(${list}, ${index.asUnknown()});\n`; break; } case 'list.deleteAll': this.source += `${this.referenceVariable(node.list)}.value = [];\n`; break; case 'list.shift': const list = this.referenceVariable(node.list); const index = this.descendInput(node.index).asNumber(); if (index <= 0) break; this.source += `${list}.value = ${list}.value.slice(${index});\n` this.source += `${list}._monitorUpToDate = false;\n` break case 'list.hide': this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.list.id)}", element: "checkbox", value: false }, runtime);\n`; break; case 'list.insert': { const list = this.referenceVariable(node.list); const index = this.descendInput(node.index); const item = this.descendInput(node.item); if (index instanceof ConstantInput && +index.constantValue === 1) { this.source += `${list}.value.unshift(${item.asSafe()});\n`; this.source += `${list}._monitorUpToDate = false;\n`; break; } this.source += `listInsert(${list}, ${index.asUnknown()}, ${item.asSafe()});\n`; break; } case 'list.replace': this.source += `listReplace(${this.referenceVariable(node.list)}, ${this.descendInput(node.index).asUnknown()}, ${this.descendInput(node.item).asSafe()});\n`; break; case 'list.show': this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.list.id)}", element: "checkbox", value: true }, runtime);\n`; break; case 'list.filter': this.source += `${this.referenceVariable(node.list)}.value = ${this.referenceVariable(node.list)}.value.filter(function* (item, index) {`; this.source += ` runtime.ext_scratch3_data._listFilterItem = item;`; this.source += ` runtime.ext_scratch3_data._listFilterIndex = index + 1;`; this.source += ` return ${this.descendInput(node.bool).asBoolean()};`; this.source += `})`; this.source += `runtime.ext_scratch3_data._listFilterItem = "";`; this.source += `runtime.ext_scratch3_data._listFilterIndex = 0;`; break; case 'looks.backwardLayers': if (!this.target.isStage) { this.source += `target.goBackwardLayers(${this.descendInput(node.layers).asNumber()});\n`; } break; case 'looks.clearEffects': this.source += 'target.clearEffects();\nruntime.ext_scratch3_looks._resetBubbles(target)\n'; break; case 'looks.changeEffect': if (this.target.effects.hasOwnProperty(node.effect)) { this.source += `target.setEffect("${sanitize(node.effect)}", runtime.ext_scratch3_looks.clampEffect("${sanitize(node.effect)}", ${this.descendInput(node.value).asNumber()} + target.effects["${sanitize(node.effect)}"]));\n`; } break; case 'looks.changeSize': this.source += `target.setSize(target.size + ${this.descendInput(node.size).asNumber()});\n`; break; case 'looks.forwardLayers': if (!this.target.isStage) { this.source += `target.goForwardLayers(${this.descendInput(node.layers).asNumber()});\n`; } break; case 'looks.goToBack': if (!this.target.isStage) { this.source += 'target.goToBack();\n'; } break; case 'looks.goToFront': if (!this.target.isStage) { this.source += 'target.goToFront();\n'; } break; case 'looks.targetFront': if (!this.target.isStage) { const reqTarget = this.target.runtime.getSpriteTargetByName(node.layers.value); if (reqTarget) { this.source += `target.goBehindOther(${JSON.stringify(reqTarget)});\n`; this.source += `target.goForwardLayers(1);\n`; } } break; case 'looks.targetBack': if (!this.target.isStage) { const reqTarget = this.target.runtime.getSpriteTargetByName(node.layers.value); if (reqTarget && reqTarget.getLayerOrder() < this.target.getLayerOrder()) { this.source += `target.goBehindOther(${JSON.stringify(reqTarget)});\n`; } } break; case 'looks.hide': this.source += 'target.setVisible(false);\n'; this.source += 'runtime.ext_scratch3_looks._renderBubble(target);\n'; break; case 'looks.nextBackdrop': this.source += 'runtime.ext_scratch3_looks._setBackdrop(stage, stage.currentCostume + 1, true);\n'; break; case 'looks.nextCostume': this.source += 'target.setCostume(target.currentCostume + 1);\n'; break; case 'looks.setEffect': if (this.target.effects.hasOwnProperty(node.effect)) { this.source += `target.setEffect("${sanitize(node.effect)}", runtime.ext_scratch3_looks.clampEffect("${sanitize(node.effect)}", ${this.descendInput(node.value).asNumber()}));\n`; } break; case 'looks.setSize': this.source += `target.setSize(${this.descendInput(node.size).asNumber()});\n`; break; case 'looks.setFont': this.source += `runtime.ext_scratch3_looks.setFont({ font: ${this.descendInput(node.font).asString()}, size: ${this.descendInput(node.size).asNumber()} }, { target: target });\n`; break; case 'looks.setColor': this.source += `runtime.ext_scratch3_looks.setColor({ prop: "${sanitize(node.prop)}", color: ${this.descendInput(node.color).asColor()} }, { target: target });\n`; break; case 'looks.setTintColor': this.source += `runtime.ext_scratch3_looks.setTintColor({ color: ${this.descendInput(node.color).asColor()} }, { target: target });\n`; break; case 'looks.setShape': this.source += `runtime.ext_scratch3_looks.setShape({ prop: "${sanitize(node.prop)}", color: ${this.descendInput(node.value).asColor()} }, { target: target });\n`; break; case 'looks.show': this.source += 'target.setVisible(true);\n'; this.source += 'runtime.ext_scratch3_looks._renderBubble(target);\n'; break; case 'looks.switchBackdrop': this.source += `runtime.ext_scratch3_looks._setBackdrop(stage, ${this.descendInput(node.backdrop).asSafe()});\n`; break; case 'looks.switchCostume': this.source += `runtime.ext_scratch3_looks._setCostume(target, ${this.descendInput(node.costume).asSafe()});\n`; break; case 'motion.changeX': this.source += `target.setXY(target.x + ${this.descendInput(node.dx).asNumber()}, target.y);\n`; break; case 'motion.changeY': this.source += `target.setXY(target.x, target.y + ${this.descendInput(node.dy).asNumber()});\n`; break; case 'motion.ifOnEdgeBounce': this.source += `runtime.ext_scratch3_motion._ifOnEdgeBounce(target);\n`; break; case 'motion.setDirection': this.source += `target.setDirection(${this.descendInput(node.direction).asNumber()});\n`; break; case 'motion.setRotationStyle': this.source += `target.setRotationStyle("${sanitize(node.style)}");\n`; break; case 'motion.setX': // fallthrough case 'motion.setY': // fallthrough case 'motion.setXY': { this.descendedIntoModulo = false; const x = 'x' in node ? this.descendInput(node.x).asNumber() : 'target.x'; const y = 'y' in node ? this.descendInput(node.y).asNumber() : 'target.y'; this.source += `target.setXY(${x}, ${y});\n`; if (this.descendedIntoModulo) { this.source += `if (target.interpolationData) target.interpolationData = null;\n`; } break; } case 'motion.step': this.source += `runtime.ext_scratch3_motion._moveSteps(${this.descendInput(node.steps).asNumber()}, target);\n`; break; case 'noop': console.warn('unexpected noop'); break; case 'pen.clear': this.source += `${PEN_EXT}.clear();\n`; break; case 'pen.down': this.source += `${PEN_EXT}._penDown(target);\n`; break; case 'pen.changeParam': this.source += `${PEN_EXT}._setOrChangeColorParam(${this.descendInput(node.param).asString()}, ${this.descendInput(node.value).asNumber()}, ${PEN_STATE}, true);\n`; break; case 'pen.changeSize': this.source += `${PEN_EXT}._changePenSizeBy(${this.descendInput(node.size).asNumber()}, target);\n`; break; case 'pen.legacyChangeHue': this.source += `${PEN_EXT}._changePenHueBy(${this.descendInput(node.hue).asNumber()}, target);\n`; break; case 'pen.legacyChangeShade': this.source += `${PEN_EXT}._changePenShadeBy(${this.descendInput(node.shade).asNumber()}, target);\n`; break; case 'pen.legacySetHue': this.source += `${PEN_EXT}._setPenHueToNumber(${this.descendInput(node.hue).asNumber()}, target);\n`; break; case 'pen.legacySetShade': this.source += `${PEN_EXT}._setPenShadeToNumber(${this.descendInput(node.shade).asNumber()}, target);\n`; break; case 'pen.setColor': this.source += `${PEN_EXT}._setPenColorToColor(${this.descendInput(node.color).asColor()}, target);\n`; break; case 'pen.setParam': this.source += `${PEN_EXT}._setOrChangeColorParam(${this.descendInput(node.param).asString()}, ${this.descendInput(node.value).asNumber()}, ${PEN_STATE}, false);\n`; break; case 'pen.setSize': this.source += `${PEN_EXT}._setPenSizeTo(${this.descendInput(node.size).asNumber()}, target);\n`; break; case 'pen.stamp': this.source += `${PEN_EXT}._stamp(target);\n`; break; case 'pen.up': this.source += `${PEN_EXT}._penUp(target);\n`; break; case 'procedures.return': this.source += `return ${this.descendInput(node.return).asUnknown()};`; break; case 'procedures.call': { const procedureCode = node.code; const procedureVariant = node.variant; // Do not generate any code for empty procedures. const procedureData = this.ir.procedures[procedureVariant]; if (procedureData.stack === null) { break; } if (!this.isWarp && procedureCode === this.script.procedureCode) { // Direct recursion yields. this.yieldNotWarp(); } if (procedureData.yields) { this.source += 'yield* '; if (!this.script.yields) { throw new Error('Script uses yielding procedure but is not marked as yielding.'); } } this.source += `thread.procedures["${sanitize(procedureVariant)}"](`; // Only include arguments if the procedure accepts any. if (procedureData.arguments.length) { const args = []; for (const input of node.arguments) { args.push(this.descendInput(input).asSafe()); } this.source += args.join(','); } this.source += `);\n`; if (node.type === 'hat') { throw new Error('Custom hat blocks are not supported'); } // Variable input types may have changes after a procedure call. this.resetVariableInputs(); break; } case 'timer.reset': this.source += 'runtime.ioDevices.clock.resetProjectTimer();\n'; break; case 'tw.debugger': this.source += 'debugger;\n'; break; case 'var.hide': this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.variable.id)}", element: "checkbox", value: false }, runtime);\n`; break; case 'var.set': { const variable = this.descendVariable(node.variable); const value = this.descendInput(node.value); variable.setInput(value); this.source += `${variable.source} = ${value.asSafe()};\n`; if (node.variable.isCloud) { this.source += `runtime.ioDevices.cloud.requestUpdateVariable("${sanitize(node.variable.name)}", ${variable.source});\n`; } break; } case 'var.show': this.source += `runtime.monitorBlocks.changeBlock({ id: "${sanitize(node.variable.id)}", element: "checkbox", value: true }, runtime);\n`; break; case 'visualReport': { const value = this.localVariables.next(); this.source += `const ${value} = ${this.descendInput(node.input, true).asUnknown()};`; // blocks like legacy no-ops can return a literal `undefined` this.source += `if (${value} !== undefined) runtime.visualReport("${sanitize(this.script.topBlockId)}", ${value});\n`; break; } case 'sensing.set.of': { const object = this.descendInput(node.object).asString(); const value = this.descendInput(node.value); const property = node.property; const isStage = node.object.value === '_stage_'; const objectReference = isStage ? 'stage' : this.evaluateOnce(`runtime.getSpriteTargetByName(${object})`); this.source += `if (${objectReference})`; switch (property) { case 'volume': this.source += `runtime.ext_scratch3_sound._updateVolume(${value.asNumber()}, ${objectReference});`; break; case 'x position': // comment this.source += `${objectReference}.setXY(${value.asNumber()}, ${objectReference}.y);`; break; case 'y position': this.source += `${objectReference}.setXY(${objectReference}.x, ${value.asNumber()});`; break; case 'direction': this.source += `${objectReference}.setDirection(${value.asNumber()});`; break; case 'costume': const costume = value.type === TYPE_NUMBER ? value.asNumber() : value.asString(); this.source += `runtime.ext_scratch3_looks._setCostume(${objectReference}, ${costume});`; break; case 'backdrop': const backdrop = value.type === TYPE_NUMBER ? value.asNumber() : value.asString(); this.source += `runtime.ext_scratch3_looks._setBackdrop(${objectReference}, ${backdrop});`; break; case 'size': this.source += `${objectReference}.setSize(${value.asNumber()});`; break; default: const variableReference = this.evaluateOnce(`${objectReference} && ${objectReference}.lookupVariableByNameAndType("${sanitize(property)}", "", true)`); this.source += `if (${variableReference}) `; this.source += `${variableReference}.value = ${value.asString()};`; break; } break; } case 'tempVars.set': { const name = this.descendInput(node.var); const val = this.descendInput(node.val); const hostObj = node.runtime ? 'runtime.variables' : node.thread ? 'thread.variables' : 'tempVars'; this.source += this.isOptimized ? `${hostObj}[${name.asString()}] = ${val.asUnknown()};` : `set(${hostObj}, ${name.asString()}, ${val.asUnknown()});`; break; } case 'tempVars.delete': { const name = this.descendInput(node.var); const hostObj = node.runtime ? 'runtime.variables' : node.thread ? 'thread.variables' : 'tempVars'; this.source += this.isOptimized ? `delete ${hostObj}[${name.asString()}];` : `remove(${hostObj}, ${name.asString()});`; break; } case 'tempVars.deleteAll': { const hostObj = node.runtime ? 'runtime.variables' : node.thread ? 'thread.variables' : 'tempVars'; this.source += `${hostObj} = Object.create(null);`; break; } case 'tempVars.forEach': { const name = this.descendInput(node.var); const loops = this.descendInput(node.loops); const hostObj = node.runtime ? 'runtime.variables' : node.thread ? 'thread.variables' : 'tempVars'; const rootVar = this.localVariables.next(); const keyVar = this.localVariables.next(); const index = this.isOptimized ? `${hostObj}[${name.asString()}]` : `${rootVar}[${keyVar}]`; if (!this.isOptimized) this.source += `const [${rootVar},${keyVar}] = _resolveKeyPath(${hostObj}, ${name.asString()}); `; this.source += `${index} = 0; `; this.source += `while (${index} < ${loops.asNumber()}) { `; this.source += `${index}++;\n`; this.descendStack(node.do, new Frame(true, 'tempVars.forEach')); this.yieldLoop(); this.source += '}\n'; break; } case 'control.dualBlock': this.source += `console.log("dual block works");` break default: log.warn(`JS: Unknown stacked block: ${node.kind}`, node); throw new Error(`JS: Unknown stacked block: ${node.kind}`); } } /** * Compile a Record of input objects into a safe JS string. * @param {Record} inputs * @returns {string} */ descendInputRecord (inputs) { let result = '{'; for (const name of Object.keys(inputs)) { const node = inputs[name]; result += `"${sanitize(name)}":${this.descendInput(node).asSafe()},`; } result += '}'; return result; } resetVariableInputs () { this.variableInputs = {}; } descendStack (nodes, frame) { // Entering a stack -- all bets are off. // TODO: allow if/else to inherit values this.resetVariableInputs(); frame.assignData(this.currentFrame); this.pushFrame(frame); for (let i = 0; i < nodes.length; i++) { frame.isLastBlock = i === nodes.length - 1; this.descendStackedBlock(nodes[i]); } // Leaving a stack -- any assumptions made in the current stack do not apply outside of it // TODO: in if/else this might create an extra unused object this.resetVariableInputs(); this.popFrame(); } descendVariable (variable) { if (this.variableInputs.hasOwnProperty(variable.id)) { return this.variableInputs[variable.id]; } const input = new VariableInput(`${this.referenceVariable(variable)}.value`); this.variableInputs[variable.id] = input; return input; } referenceVariable (variable) { if (variable.scope === 'target') { return this.evaluateOnce(`target.variables["${sanitize(variable.id)}"]`); } return this.evaluateOnce(`stage.variables["${sanitize(variable.id)}"]`); } evaluateOnce (source) { if (this._setupVariables.hasOwnProperty(source)) { return this._setupVariables[source]; } const variable = this._setupVariablesPool.next(); this._setupVariables[source] = variable; return variable; } retire () { // After running retire() (sets thread status and cleans up some unused data), we need to return to the event loop. // When in a procedure, return will only send us back to the previous procedure, so instead we yield back to the sequencer. // Outside of a procedure, return will correctly bring us back to the sequencer. if (this.isProcedure) { this.source += 'retire(); yield;\n'; } else { this.source += 'retire(); return;\n'; } } yieldLoop () { if (this.warpTimer) { this.yieldStuckOrNotWarp(); } else { this.yieldNotWarp(); } } /** * Write JS to yield the current thread if warp mode is disabled. */ yieldNotWarp () { if (!this.isWarp) { this.source += 'yield;\n'; this.yielded(); } } /** * Write JS to yield the current thread if warp mode is disabled or if the script seems to be stuck. */ yieldStuckOrNotWarp () { if (this.isWarp) { this.source += 'if (isStuck()) yield;\n'; } else { this.source += 'yield;\n'; } this.yielded(); } yielded () { if (!this.script.yields) { throw new Error('Script yielded but is not marked as yielding.'); } // Control may have been yielded to another script -- all bets are off. this.resetVariableInputs(); } /** * Write JS to request a redraw. */ requestRedraw () { this.source += 'runtime.requestRedraw();\n'; } safeConstantInput (value) { const unsafe = typeof value === 'string' && this.namesOfCostumesAndSounds.has(value); return new ConstantInput(value, !unsafe); } /** * Generate a call into the compatibility layer. * @param {*} node The "compat" kind node to generate from. * @param {boolean} setFlags Whether flags should be set describing how this function was processed. * @param {string|null} [frameName] Name of the stack frame variable, if any * @param {boolean} visualReport if this is being called to get visual reporter content * @returns {string} The JS of the call. */ generateCompatibilityLayerCall (node, setFlags, frameName = null, visualReport) { const opcode = node.opcode; let result = 'yield* executeInCompatibilityLayer({'; for (const inputName of Object.keys(node.inputs)) { const input = node.inputs[inputName]; if (inputName.startsWith('substack')) { result += `"${sanitize(inputName.toLowerCase())}":(function* () {\n`; this.descendStack(input, new Frame(true, opcode)); result += '}),'; continue; } const compiledInput = this.descendInput(input).asSafe(); result += `"${sanitize(inputName)}":${compiledInput},`; } for (const fieldName of Object.keys(node.fields)) { const field = node.fields[fieldName]; if (typeof field !== 'string') { result += `"${sanitize(fieldName)}":${JSON.stringify(field)},`; continue; } result += `"${sanitize(fieldName)}":"${sanitize(field)}",`; } result += `"mutation":${JSON.stringify(node.mutation)},`; const opcodeFunction = this.evaluateOnce(`runtime.getOpcodeFunction("${sanitize(opcode)}")`); result += `}, ${opcodeFunction}, ${this.isWarp}, ${setFlags}, "${sanitize(node.id)}", ${frameName}, ${visualReport})`; return result; } getScriptFactoryName () { return factoryNameVariablePool.next(); } getScriptName (yields) { let name = yields ? generatorNameVariablePool.next() : functionNameVariablePool.next(); if (this.isProcedure) { const simplifiedProcedureCode = this.script.procedureCode .replace(/%[\w]/g, '') // remove arguments .replace(/[^a-zA-Z0-9]/g, '_') // remove unsafe .substring(0, 20); // keep length reasonable name += `_${simplifiedProcedureCode}`; } return name; } /** * Generate the JS to pass into eval() based on the current state of the compiler. * @returns {string} JS to pass into eval() */ createScriptFactory () { let script = ''; // Setup the factory script += `(function ${this.getScriptFactoryName()}(thread) { `; script += 'let __target = thread.target; '; script += 'let target = __target; '; script += 'const runtime = __target.runtime; '; script += 'const stage = runtime.getTargetForStage();\n'; for (const varValue of Object.keys(this._setupVariables)) { const varName = this._setupVariables[varValue]; script += `const ${varName} = ${varValue};\n`; } // Generated script script += 'return '; if (this.script.yields) { script += `function* `; } else { script += `function `; } script += this.getScriptName(this.script.yields); script += ' ('; if (this.script.arguments.length) { const args = []; for (let i = 0; i < this.script.arguments.length; i++) { args.push(`p${i}`); } script += args.join(','); } script += ') {\n'; script += 'let tempVars = Object.create(null);'; // pm: check if we are spoofing the target // ex: as (Sprite) {} block needs to replace the target // with a different one // create new var with target so we can define target as the current one script += `let target = __target;\n`; script += `if (thread.spoofing) {\n`; script += `target = thread.spoofTarget;\n`; script += `};\n`; script += 'try {\n'; script += this.source; script += '} catch (err) {'; script += `console.log("${sanitize(script)}");`; script += 'console.error(err);'; script += `runtime.emit("BLOCK_STACK_ERROR", {`; script += `id:"${sanitize(this.script.topBlockId)}",`; script += `value:String(err)`; script += `});`; script += '}\n'; if (!this.isProcedure) { script += 'retire();\n'; } script += '}; })'; return script; } /** * Compile this script. * @returns {Function} The factory function for the script. */ compile () { if (this.script.stack) { this.descendStack(this.script.stack, new Frame(false)); } const factory = this.createScriptFactory(); const fn = jsexecute.scopedEval(factory); if (this.debug) { log.info(`JS: ${this.target.getName()}: compiled ${this.script.procedureCode || 'script'}`, factory); } if (JSGenerator.testingApparatus) { JSGenerator.testingApparatus.report(this, factory); } return fn; } } // Test hook used by automated snapshot testing. JSGenerator.testingApparatus = null; module.exports = JSGenerator;