Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
aafaa74
Replace isNaN with Number.isNaN in jsexecute
GarboMuffin Sep 24, 2025
67cb7cf
Remove unnecessary string casts
GarboMuffin Oct 8, 2025
3b947da
Remove unnecessary number index casts
GarboMuffin Oct 8, 2025
efb502a
Remove unnecessary boolean casts
GarboMuffin Oct 8, 2025
eb0d678
Only analyze repeat block inputs at the start of the loop
GarboMuffin Oct 12, 2025
8860e2d
Fix repeat block iteration count not being optimizable in yielding loops
GarboMuffin Oct 12, 2025
4187f55
Record which keys the project has used
GarboMuffin Nov 16, 2025
d8c26ad
Merge pull request #307 from TurboWarp/optimize-repeat-better
GarboMuffin Dec 5, 2025
fd1b855
Merge pull request #301 from TurboWarp/more-unnecessary-casts
GarboMuffin Dec 5, 2025
8252e2e
Merge pull request #285 from TurboWarp/use-Number.isNaN
GarboMuffin Dec 5, 2025
6f6845b
Fix all remaining negative zero bugs
GarboMuffin Sep 23, 2025
06dbe65
Use Number.isNaN instead
GarboMuffin Sep 24, 2025
08e850a
Merge pull request #280 from TurboWarp/fix-negative-zero
GarboMuffin Dec 5, 2025
9624ea5
Compile looks_say and looks_think without compatibility layer
GarboMuffin Oct 9, 2025
fd13a98
Merge pull request #305 from TurboWarp/compile-say
GarboMuffin Dec 5, 2025
36bf998
Fix evalAndReturn with comments at the bottom of a script (#322)
8to16 Dec 16, 2025
779f0a1
Add no-op rAF loop to improve frame pacing
GarboMuffin Dec 30, 2025
7aa8930
Fix incorrect types for sensing_of
GarboMuffin Dec 28, 2025
c3a47de
Fix incorrect type for looks_size
GarboMuffin Dec 30, 2025
0e5043d
Fix missing boolean cast in control_repeat_until
GarboMuffin Dec 30, 2025
7fefa6f
Check for either input never being numbers first in OP_EQUALS
GarboMuffin Dec 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/blocks/scratch3_looks.js
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,10 @@ class Scratch3LooksBlocks {
}

think (args, util) {
this.runtime.emit(Scratch3LooksBlocks.SAY_OR_THINK, util.target, 'think', args.MESSAGE);
this._think(args.MESSAGE, util.target);
}
_think (message, target) { // used by compiler
this.runtime.emit(Scratch3LooksBlocks.SAY_OR_THINK, target, 'think', message);
}

thinkforsecs (args, util) {
Expand Down
2 changes: 0 additions & 2 deletions src/compiler/compat-blocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@
const stacked = [
'looks_changestretchby',
'looks_hideallsprites',
'looks_say',
'looks_sayforsecs',
'looks_setstretchto',
'looks_switchbackdroptoandwait',
'looks_think',
'looks_thinkforsecs',
'motion_align_scene',
'motion_glidesecstoxy',
Expand Down
2 changes: 2 additions & 0 deletions src/compiler/enums.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ const StackOpcode = {
LOOKS_BACKDROP_SET: 'looks.switchBackdrop',
LOOKS_COSTUME_NEXT: 'looks.nextCostume',
LOOKS_COSTUME_SET: 'looks.switchCostume',
LOOKS_SAY: 'looks.say',
LOOKS_THINK: 'looks.think',

MOTION_X_SET: 'motion.setX',
MOTION_X_CHANGE: 'motion.changeX',
Expand Down
20 changes: 15 additions & 5 deletions src/compiler/irgen.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ class ScriptTreeGenerator {
}
return new IntermediateInput(InputOpcode.LOOKS_COSTUME_NAME, InputType.STRING);
case 'looks_size':
return new IntermediateInput(InputOpcode.LOOKS_SIZE_GET, InputType.NUMBER_POS);
return new IntermediateInput(InputOpcode.LOOKS_SIZE_GET, InputType.NUMBER_POS | InputType.NUMBER_ZERO);

case 'motion_direction':
return new IntermediateInput(InputOpcode.MOTION_DIRECTION_GET, InputType.NUMBER_REAL);
Expand Down Expand Up @@ -520,6 +520,7 @@ class ScriptTreeGenerator {
}

if (object.isConstant('_stage_')) {
// We assume that the stage always exists, so these don't need to be able to return 0.
switch (property) {
case 'background #': // fallthrough for scratch 1.0 compatibility
case 'backdrop #':
Expand All @@ -528,6 +529,7 @@ class ScriptTreeGenerator {
return new IntermediateInput(InputOpcode.SENSING_OF_BACKDROP_NAME, InputType.STRING);
}
} else {
// If the target sprite does not exist, these may all return 0, even the costume name one.
switch (property) {
case 'x position':
return new IntermediateInput(InputOpcode.SENSING_OF_POS_X, InputType.NUMBER, {object});
Expand All @@ -536,11 +538,11 @@ class ScriptTreeGenerator {
case 'direction':
return new IntermediateInput(InputOpcode.SENSING_OF_DIRECTION, InputType.NUMBER_REAL, {object});
case 'costume #':
return new IntermediateInput(InputOpcode.SENSING_OF_COSTUME_NUMBER, InputType.NUMBER_POS_INT, {object});
return new IntermediateInput(InputOpcode.SENSING_OF_COSTUME_NUMBER, InputType.NUMBER_POS_INT | InputType.NUMBER_ZERO, {object});
case 'costume name':
return new IntermediateInput(InputOpcode.SENSING_OF_COSTUME_NAME, InputType.STRING, {object});
return new IntermediateInput(InputOpcode.SENSING_OF_COSTUME_NAME, InputType.STRING | InputType.NUMBER_ZERO, {object});
case 'size':
return new IntermediateInput(InputOpcode.SENSING_OF_SIZE, InputType.NUMBER_POS, {object});
return new IntermediateInput(InputOpcode.SENSING_OF_SIZE, InputType.NUMBER_POS | InputType.NUMBER_ZERO, {object});
}
}

Expand Down Expand Up @@ -660,7 +662,7 @@ class ScriptTreeGenerator {
// Dirty hack: automatically enable warp timer for this block if it uses timer
// This fixes project that do things like "repeat until timer > 0.5"
this.usesTimer = false;
const condition = this.descendInputOfBlock(block, 'CONDITION');
const condition = this.descendInputOfBlock(block, 'CONDITION').toType(InputType.BOOLEAN);
const needsWarpTimer = this.usesTimer;
return new IntermediateStackBlock(StackOpcode.CONTROL_WHILE, {
condition: new IntermediateInput(InputOpcode.OP_NOT, InputType.BOOLEAN, {
Expand Down Expand Up @@ -806,6 +808,10 @@ class ScriptTreeGenerator {
return new IntermediateStackBlock(StackOpcode.LOOKS_BACKDROP_NEXT);
case 'looks_nextcostume':
return new IntermediateStackBlock(StackOpcode.LOOKS_COSTUME_NEXT);
case 'looks_say':
return new IntermediateStackBlock(StackOpcode.LOOKS_SAY, {
message: this.descendInputOfBlock(block, 'MESSAGE')
});
case 'looks_seteffectto':
return new IntermediateStackBlock(StackOpcode.LOOKS_EFFECT_SET, {
effect: block.fields.EFFECT.value.toLowerCase(),
Expand All @@ -825,6 +831,10 @@ class ScriptTreeGenerator {
return new IntermediateStackBlock(StackOpcode.LOOKS_COSTUME_SET, {
costume: this.descendInputOfBlock(block, 'COSTUME', true)
});
case 'looks_think':
return new IntermediateStackBlock(StackOpcode.LOOKS_THINK, {
message: this.descendInputOfBlock(block, 'MESSAGE')
});

case 'motion_changexby':
return new IntermediateStackBlock(StackOpcode.MOTION_X_CHANGE, {
Expand Down
72 changes: 64 additions & 8 deletions src/compiler/iroptimizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,16 +158,36 @@ class IROptimizer {
case InputOpcode.ADDON_CALL:
break;

case InputOpcode.CAST_BOOLEAN: {
const innerType = inputs.target.type;
if (innerType & InputType.BOOLEAN) return innerType;
return InputType.BOOLEAN;
}

case InputOpcode.CAST_NUMBER: {
const innerType = inputs.target.type;
if (innerType & InputType.NUMBER) return innerType;
return InputType.NUMBER;
} case InputOpcode.CAST_NUMBER_OR_NAN: {
}

case InputOpcode.CAST_NUMBER_INDEX: {
const innerType = inputs.target.type;
if (innerType & InputType.NUMBER_INDEX) return innerType;
return InputType.NUMBER_INDEX;
}

case InputOpcode.CAST_NUMBER_OR_NAN: {
const innerType = inputs.target.type;
if (innerType & InputType.NUMBER_OR_NAN) return innerType;
return InputType.NUMBER_OR_NAN;
}

case InputOpcode.CAST_STRING: {
const innerType = inputs.target.type;
if (innerType & InputType.STRING) return innerType;
return InputType.STRING;
}

case InputOpcode.OP_ADD: {
const leftType = inputs.left.type;
const rightType = inputs.right.type;
Expand Down Expand Up @@ -559,9 +579,12 @@ class IROptimizer {
break;
case StackOpcode.CONTROL_WHILE:
case StackOpcode.CONTROL_FOR:
modified = this.analyzeInputs(inputs, state) || modified;
modified = this.analyzeLoopedStack(inputs.do, state, stackBlock, true) || modified;
break;
case StackOpcode.CONTROL_REPEAT:
modified = this.analyzeInputs(inputs, state) || modified;
modified = this.analyzeLoopedStack(inputs.do, state, stackBlock) || modified;
modified = this.analyzeLoopedStack(inputs.do, state, stackBlock, false) || modified;
break;
case StackOpcode.CONTROL_IF_ELSE: {
modified = this.analyzeInputs(inputs, state) || modified;
Expand Down Expand Up @@ -642,20 +665,24 @@ class IROptimizer {
* @param {IntermediateStack} stack
* @param {TypeState} state
* @param {IntermediateStackBlock} block
* @param {boolean} willReevaluateInputs
* @returns {boolean}
* @private
*/
analyzeLoopedStack (stack, state, block) {
analyzeLoopedStack (stack, state, block, willReevaluateInputs) {
let modified = false;

if (block.yields && !this.ignoreYields) {
let modified = state.clear();
modified = state.clear();
if (willReevaluateInputs) {
modified = this.analyzeInputs(block.inputs, state) || modified;
}
block.entryState = state.clone();
block.exitState = state.clone();
modified = this.analyzeInputs(block.inputs, state) || modified;
return this.analyzeStack(stack, state) || modified;
}

let iterations = 0;
let modified = false;
let keepLooping;
do {
// If we are stuck in an apparent infinite loop, give up and assume the worst.
Expand All @@ -672,7 +699,10 @@ class IROptimizer {
const newState = state.clone();
modified = this.analyzeStack(stack, newState) || modified;
modified = (keepLooping = state.or(newState)) || modified;
modified = this.analyzeInputs(block.inputs, state) || modified;

if (willReevaluateInputs) {
modified = this.analyzeInputs(block.inputs, state) || modified;
}
} while (keepLooping);
block.entryState = state.clone();
return modified;
Expand All @@ -693,19 +723,45 @@ class IROptimizer {
}

switch (input.opcode) {
case InputOpcode.CAST_BOOLEAN: {
const targetType = input.inputs.target.type;
if ((targetType & InputType.BOOLEAN) === targetType) {
return input.inputs.target;
}
return input;
}

case InputOpcode.CAST_NUMBER: {
const targetType = input.inputs.target.type;
if ((targetType & InputType.NUMBER) === targetType) {
return input.inputs.target;
}
return input;
} case InputOpcode.CAST_NUMBER_OR_NAN: {
}

case InputOpcode.CAST_NUMBER_INDEX: {
const targetType = input.inputs.target.type;
if ((targetType & InputType.NUMBER_INDEX) === targetType) {
return input.inputs.target;
}
return input;
}

case InputOpcode.CAST_NUMBER_OR_NAN: {
const targetType = input.inputs.target.type;
if ((targetType & InputType.NUMBER_OR_NAN) === targetType) {
return input.inputs.target;
}
return input;
}

case InputOpcode.CAST_STRING: {
const targetType = input.inputs.target.type;
if ((targetType & InputType.STRING) === targetType) {
return input.inputs.target;
}
return input;
}
}

return input;
Expand Down
23 changes: 16 additions & 7 deletions src/compiler/jsexecute.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,15 @@ runtimeFunctions.retire = `const retire = () => {
thread.target.runtime.sequencer.retireThread(thread);
}`;

/**
* Converts NaN to zero. Used to match Scratch's string-to-number.
* Unlike (x || 0), -0 stays as -0 and is not converted to 0.
* This function needs to be written such that it's very easy for browsers to inline it.
* @param {number} value A number. Might be NaN.
* @returns {number} A number. Never NaN.
*/
runtimeFunctions.toNotNaN = `const toNotNaN = value => Number.isNaN(value) ? 0 : value`;

/**
* Scratch cast to boolean.
* Similar to Cast.toBoolean()
Expand Down Expand Up @@ -278,12 +287,12 @@ baseRuntime += `const isNotActuallyZero = val => {
*/
baseRuntime += `const compareEqualSlow = (v1, v2) => {
const n1 = +v1;
if (isNaN(n1) || (n1 === 0 && isNotActuallyZero(v1))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase();
if (Number.isNaN(n1) || (n1 === 0 && isNotActuallyZero(v1))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase();
const n2 = +v2;
if (isNaN(n2) || (n2 === 0 && isNotActuallyZero(v2))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase();
if (Number.isNaN(n2) || (n2 === 0 && isNotActuallyZero(v2))) return ('' + v1).toLowerCase() === ('' + v2).toLowerCase();
return n1 === n2;
};
const compareEqual = (v1, v2) => (typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v1) && !isNaN(v2) || v1 === v2) ? v1 === v2 : compareEqualSlow(v1, v2);`;
const compareEqual = (v1, v2) => (typeof v1 === 'number' && typeof v2 === 'number' && !Number.isNaN(v1) && !Number.isNaN(v2) || v1 === v2) ? v1 === v2 : compareEqualSlow(v1, v2);`;

/**
* Determine if one value is greater than another.
Expand All @@ -299,14 +308,14 @@ runtimeFunctions.compareGreaterThan = `const compareGreaterThanSlow = (v1, v2) =
} else if (n2 === 0 && isNotActuallyZero(v2)) {
n2 = NaN;
}
if (isNaN(n1) || isNaN(n2)) {
if (Number.isNaN(n1) || Number.isNaN(n2)) {
const s1 = ('' + v1).toLowerCase();
const s2 = ('' + v2).toLowerCase();
return s1 > s2;
}
return n1 > n2;
};
const compareGreaterThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v1) ? v1 > v2 : compareGreaterThanSlow(v1, v2)`;
const compareGreaterThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !Number.isNaN(v1) ? v1 > v2 : compareGreaterThanSlow(v1, v2)`;

/**
* Determine if one value is less than another.
Expand All @@ -322,14 +331,14 @@ runtimeFunctions.compareLessThan = `const compareLessThanSlow = (v1, v2) => {
} else if (n2 === 0 && isNotActuallyZero(v2)) {
n2 = NaN;
}
if (isNaN(n1) || isNaN(n2)) {
if (Number.isNaN(n1) || Number.isNaN(n2)) {
const s1 = ('' + v1).toLowerCase();
const s2 = ('' + v2).toLowerCase();
return s1 < s2;
}
return n1 < n2;
};
const compareLessThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !isNaN(v2) ? v1 < v2 : compareLessThanSlow(v1, v2)`;
const compareLessThan = (v1, v2) => typeof v1 === 'number' && typeof v2 === 'number' && !Number.isNaN(v2) ? v1 < v2 : compareLessThanSlow(v1, v2)`;

/**
* Generate a random integer.
Expand Down
18 changes: 12 additions & 6 deletions src/compiler/jsgen.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,9 @@ class JSGenerator {
return `(+${this.descendInput(node.target.toType(InputType.BOOLEAN))})`;
}
if (node.target.isAlwaysType(InputType.NUMBER_OR_NAN)) {
return `(${this.descendInput(node.target)} || 0)`;
return `toNotNaN(${this.descendInput(node.target)})`;
}
return `(+${this.descendInput(node.target)} || 0)`;
return `toNotNaN(+${this.descendInput(node.target)})`;
case InputOpcode.CAST_NUMBER_OR_NAN:
return `(+${this.descendInput(node.target)})`;
case InputOpcode.CAST_NUMBER_INDEX:
Expand Down Expand Up @@ -297,6 +297,10 @@ class JSGenerator {
const left = node.left;
const right = node.right;

// When either operand is known to never be a number, only use string comparison to avoid all number parsing.
if (!left.isSometimesType(InputType.NUMBER_INTERPRETABLE) || !right.isSometimesType(InputType.NUMBER_INTERPRETABLE)) {
return `(${this.descendInput(left.toType(InputType.STRING))}.toLowerCase() === ${this.descendInput(right.toType(InputType.STRING))}.toLowerCase())`;
}
// When both operands are known to be numbers, we can use ===
if (left.isAlwaysType(InputType.NUMBER_INTERPRETABLE) && right.isAlwaysType(InputType.NUMBER_INTERPRETABLE)) {
return `(${this.descendInput(left.toType(InputType.NUMBER))} === ${this.descendInput(right.toType(InputType.NUMBER))})`;
Expand All @@ -305,10 +309,6 @@ class JSGenerator {
if (isSafeInputForEqualsOptimization(left, right) || isSafeInputForEqualsOptimization(right, left)) {
return `(${this.descendInput(left.toType(InputType.NUMBER))} === ${this.descendInput(right.toType(InputType.NUMBER))})`;
}
// When either operand is known to never be a number, only use string comparison to avoid all number parsing.
if (!left.isSometimesType(InputType.NUMBER_INTERPRETABLE) || !right.isSometimesType(InputType.NUMBER_INTERPRETABLE)) {
return `(${this.descendInput(left.toType(InputType.STRING))}.toLowerCase() === ${this.descendInput(right.toType(InputType.STRING))}.toLowerCase())`;
}
// No compile-time optimizations possible - use fallback method.
return `compareEqual(${this.descendInput(left)}, ${this.descendInput(right)})`;
}
Expand Down Expand Up @@ -779,6 +779,12 @@ class JSGenerator {
case StackOpcode.LOOKS_COSTUME_SET:
this.source += `runtime.ext_scratch3_looks._setCostume(target, ${this.descendInput(node.costume)});\n`;
break;
case StackOpcode.LOOKS_SAY:
this.source += `runtime.ext_scratch3_looks._say(${this.descendInput(node.message)}, target);\n`;
break;
case StackOpcode.LOOKS_THINK:
this.source += `runtime.ext_scratch3_looks._think(${this.descendInput(node.message)}, target);\n`;
break;

case StackOpcode.MOTION_X_CHANGE:
this.source += `target.setXY(target.x + ${this.descendInput(node.dx)}, target.y);\n`;
Expand Down
Loading
Loading