soiz1's picture
Upload 811 files
30c32c8 verified
raw
history blame
93.2 kB
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.<string, VariableInput>}
*/
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 => `\`<video src="${url}" height="\${height}" autoplay loop style="alignment:center;"></video>\``;
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<string, unknown>} 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;