diff --git a/src/compiler/compile.js b/src/compiler/compile.js index eb910c42..fb431645 100644 --- a/src/compiler/compile.js +++ b/src/compiler/compile.js @@ -29,7 +29,8 @@ const compile = thread => { return { startingFunction: entry, - procedures + procedures, + executableHat: ir.entry.executableHat }; }; diff --git a/src/compiler/intermediate.js b/src/compiler/intermediate.js index 45b6b467..a7e0eb6a 100644 --- a/src/compiler/intermediate.js +++ b/src/compiler/intermediate.js @@ -69,6 +69,12 @@ class IntermediateScript { * @type {Function|null} */ this.cachedCompileResult = null; + + /** + * Whether the top block of this script is an executable hat. + * @type {boolean} + */ + this.executableHat = false; } } diff --git a/src/compiler/irgen.js b/src/compiler/irgen.js index 45f37351..7a059a5b 100644 --- a/src/compiler/irgen.js +++ b/src/compiler/irgen.js @@ -1453,6 +1453,58 @@ class ScriptTreeGenerator { } } + /** + * @param {Block} hatBlock + */ + walkHat (hatBlock) { + const nextBlock = hatBlock.next; + const opcode = hatBlock.opcode; + const hatInfo = this.runtime._hats[opcode]; + + if (this.thread.stackClick) { + // We still need to treat the hat as a normal block (so executableHat should be false) for + // interpreter parity, but the reuslt is ignored. + const opcodeFunction = this.runtime.getOpcodeFunction(opcode); + if (opcodeFunction) { + return [ + this.descendCompatLayer(hatBlock), + ...this.walkStack(nextBlock) + ]; + } + return this.walkStack(nextBlock); + } + + if (hatInfo.edgeActivated) { + // Edge-activated HAT + this.script.yields = true; + this.script.executableHat = true; + return [ + { + kind: 'hat.edge', + id: hatBlock.id, + condition: this.descendCompatLayer(hatBlock) + }, + ...this.walkStack(nextBlock) + ]; + } + + const opcodeFunction = this.runtime.getOpcodeFunction(opcode); + if (opcodeFunction) { + // Predicate-based HAT + this.script.yields = true; + this.script.executableHat = true; + return [ + { + kind: 'hat.predicate', + condition: this.descendCompatLayer(hatBlock) + }, + ...this.walkStack(nextBlock) + ]; + } + + return this.walkStack(nextBlock); + } + /** * @param {string} topBlockId The ID of the top block of the script. * @returns {IntermediateScript} @@ -1475,24 +1527,26 @@ class ScriptTreeGenerator { this.readTopBlockComment(topBlock.comment); } - // If the top block is a hat, advance to its child. - let entryBlock; - if (this.runtime.getIsHat(topBlock.opcode) || topBlock.opcode === 'procedures_definition') { - if (this.runtime.getIsEdgeActivatedHat(topBlock.opcode)) { - throw new Error(`Not compiling an edge-activated hat: ${topBlock.opcode}`); - } - entryBlock = topBlock.next; + // We do need to evaluate empty hats + const hatInfo = this.runtime._hats[topBlock.opcode]; + const isHat = !!hatInfo; + if (isHat) { + this.script.stack = this.walkHat(topBlock); } else { - entryBlock = topBlockId; + // We don't evaluate the procedures_definition top block as it never does anything + // We also don't want it to be treated like a hat block + let entryBlock; + if (topBlock.opcode === 'procedures_definition') { + entryBlock = topBlock.next; + } else { + entryBlock = topBlockId; + } + + if (entryBlock) { + this.script.stack = this.walkStack(entryBlock); + } } - if (!entryBlock) { - // This is an empty script. - return this.script; - } - - this.script.stack = this.walkStack(entryBlock); - return this.script; } } diff --git a/src/compiler/jsexecute.js b/src/compiler/jsexecute.js index 40109b33..f4a556ab 100644 --- a/src/compiler/jsexecute.js +++ b/src/compiler/jsexecute.js @@ -578,6 +578,14 @@ const execute = thread => { thread.generator.next(); }; +const threadStack = []; +const saveGlobalState = () => { + threadStack.push(globalState.thread); +}; +const restoreGlobalState = () => { + globalState.thread = threadStack.pop(); +}; + const insertRuntime = source => { let result = baseRuntime; for (const functionName of Object.keys(runtimeFunctions)) { @@ -606,5 +614,7 @@ const scopedEval = source => { execute.scopedEval = scopedEval; execute.runtimeFunctions = runtimeFunctions; +execute.saveGlobalState = saveGlobalState; +execute.restoreGlobalState = restoreGlobalState; module.exports = execute; diff --git a/src/compiler/jsgen.js b/src/compiler/jsgen.js index 219eb4d1..32a0d1d7 100644 --- a/src/compiler/jsgen.js +++ b/src/compiler/jsgen.js @@ -385,6 +385,7 @@ class JSGenerator { this._setupVariables = {}; this.descendedIntoModulo = false; + this.isInHat = false; this.debug = this.target.runtime.debug; } @@ -650,7 +651,8 @@ class JSGenerator { const joinedArgs = args.join(','); const yieldForRecursion = !this.isWarp && procedureCode === this.script.procedureCode; - if (yieldForRecursion) { + const yieldForHat = this.isInHat; + if (yieldForRecursion || yieldForHat) { const runtimeFunction = procedureData.yields ? 'yieldThenCallGenerator' : 'yieldThenCall'; return new TypedInput(`(yield* ${runtimeFunction}(${procedureReference}, ${joinedArgs}))`, TYPE_UNKNOWN); } @@ -848,6 +850,32 @@ class JSGenerator { this.source += `}\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 += `startHats("event_whenbroadcastreceived", { BROADCAST_OPTION: ${this.descendInput(node.broadcast).asString()} });\n`; this.resetVariableInputs(); diff --git a/src/engine/runtime.js b/src/engine/runtime.js index a1589d9f..b2ecb3c9 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -9,6 +9,7 @@ const BlockType = require('../extension-support/block-type'); const Profiler = require('./profiler'); const Sequencer = require('./sequencer'); const execute = require('./execute.js'); +const compilerExecute = require('../compiler/jsexecute'); const ScratchBlocksConstants = require('./scratch-blocks-constants'); const TargetType = require('../extension-support/target-type'); const Thread = require('./thread'); @@ -2116,8 +2117,15 @@ class Runtime extends EventEmitter { // For compatibility with Scratch 2, edge triggered hats need to be processed before // threads are stepped. See ScratchRuntime.as for original implementation newThreads.forEach(thread => { - // tw: do not step compiled threads, the hat block can't be executed - if (!thread.isCompiled) { + if (thread.isCompiled) { + if (thread.executableHat) { + // It is quite likely that we are currently executing a block, so make sure + // that we leave the compiler's state intact at the end. + compilerExecute.saveGlobalState(); + compilerExecute(thread); + compilerExecute.restoreGlobalState(); + } + } else { execute(this.sequencer, thread); thread.goToNextBlock(); } diff --git a/src/engine/thread.js b/src/engine/thread.js index a48b356d..f5672267 100644 --- a/src/engine/thread.js +++ b/src/engine/thread.js @@ -214,6 +214,7 @@ class Thread { * @type {Object.} */ this.procedures = null; + this.executableHat = false; } /** @@ -462,10 +463,15 @@ class Thread { this.triedToCompile = true; + // stackClick === true disables hat block generation + // It would be great to cache these separately, but for now it's easiest to just disable them to avoid + // cached versions of scripts breaking projects. + const canCache = !this.stackClick; + const topBlock = this.topBlock; // Flyout blocks are stored in a special block container. const blocks = this.blockContainer.getBlock(topBlock) ? this.blockContainer : this.target.runtime.flyoutBlocks; - const cachedResult = blocks.getCachedCompileResult(topBlock); + const cachedResult = canCache && blocks.getCachedCompileResult(topBlock); // If there is a cached error, do not attempt to recompile. if (cachedResult && !cachedResult.success) { return; @@ -477,10 +483,14 @@ class Thread { } else { try { result = compile(this); - blocks.cacheCompileResult(topBlock, result); + if (canCache) { + blocks.cacheCompileResult(topBlock, result); + } } catch (error) { log.error('cannot compile script', this.target.getName(), error); - blocks.cacheCompileError(topBlock, error); + if (canCache) { + blocks.cacheCompileError(topBlock, error); + } this.target.runtime.emitCompileError(this.target, error); return; } @@ -493,6 +503,8 @@ class Thread { this.generator = result.startingFunction(this)(); + this.executableHat = result.executableHat; + if (!this.blockContainer.forceNoGlow) { this.blockGlowInFrame = this.topBlock; this.requestScriptGlowInFrame = true; diff --git a/test/snapshot/__snapshots__/tw-preciseProjectTimer-drift-453118719.sb3.tw-snapshot b/test/snapshot/__snapshots__/tw-preciseProjectTimer-drift-453118719.sb3.tw-snapshot index bef98c89..5a2af1e8 100644 --- a/test/snapshot/__snapshots__/tw-preciseProjectTimer-drift-453118719.sb3.tw-snapshot +++ b/test/snapshot/__snapshots__/tw-preciseProjectTimer-drift-453118719.sb3.tw-snapshot @@ -1,9 +1,6 @@ // TW Snapshot // Input SHA-256: 19e0b3f9dcac763041039a8ffe87b6732eb744f9ebb136e8d61a53ccf19118e3 -// Errors: -// Sprite1: Error: Not compiling an edge-activated hat: event_whengreaterthan - // Sprite1 script (function factoryXYZ(thread) { const target = thread.target; const runtime = target.runtime; const stage = runtime.getTargetForStage(); const b0 = stage.variables["PsY$$vp$IVH;dDAr[q2h"]; @@ -28,3 +25,27 @@ b0.value = 1; yield* executeInCompatibilityLayer({"MESSAGE":"end",}, b1, false, false, null); retire(); return; }; }) + +// Sprite1 script +(function factoryXYZ(thread) { const target = thread.target; const runtime = target.runtime; const stage = runtime.getTargetForStage(); +const b0 = stage.variables["l^q!%fq]Bv;72dlGf}^Z"]; +const b1 = runtime.getOpcodeFunction("event_whengreaterthan"); +const b2 = stage.variables["PsY$$vp$IVH;dDAr[q2h"]; +const b3 = runtime.getOpcodeFunction("looks_say"); +return function* genXYZ () { +{ +const resolvedValue = toBoolean((yield* executeInCompatibilityLayer({"VALUE":b0.value,"WHENGREATERTHANMENU":"TIMER",}, b1, false, false, null))); +const id = "iNmua~6veGey6O-_UB9."; +const hasOldEdgeValue = target.hasEdgeActivatedValue(id); +const oldEdgeValue = target.updateEdgeActivatedValue(id, resolvedValue); +const edgeWasActivated = hasOldEdgeValue ? (!oldEdgeValue && resolvedValue) : resolvedValue; +if (!edgeWasActivated) { +retire(); return; +} +yield; +} +if (compareEqual(b2.value, 0)) { +yield* executeInCompatibilityLayer({"MESSAGE":"fail",}, b3, false, false, null); +} +retire(); return; +}; }) diff --git a/test/snapshot/__snapshots__/warp-timer/tw-preciseProjectTimer-drift-453118719.sb3.tw-snapshot b/test/snapshot/__snapshots__/warp-timer/tw-preciseProjectTimer-drift-453118719.sb3.tw-snapshot index bef98c89..5a2af1e8 100644 --- a/test/snapshot/__snapshots__/warp-timer/tw-preciseProjectTimer-drift-453118719.sb3.tw-snapshot +++ b/test/snapshot/__snapshots__/warp-timer/tw-preciseProjectTimer-drift-453118719.sb3.tw-snapshot @@ -1,9 +1,6 @@ // TW Snapshot // Input SHA-256: 19e0b3f9dcac763041039a8ffe87b6732eb744f9ebb136e8d61a53ccf19118e3 -// Errors: -// Sprite1: Error: Not compiling an edge-activated hat: event_whengreaterthan - // Sprite1 script (function factoryXYZ(thread) { const target = thread.target; const runtime = target.runtime; const stage = runtime.getTargetForStage(); const b0 = stage.variables["PsY$$vp$IVH;dDAr[q2h"]; @@ -28,3 +25,27 @@ b0.value = 1; yield* executeInCompatibilityLayer({"MESSAGE":"end",}, b1, false, false, null); retire(); return; }; }) + +// Sprite1 script +(function factoryXYZ(thread) { const target = thread.target; const runtime = target.runtime; const stage = runtime.getTargetForStage(); +const b0 = stage.variables["l^q!%fq]Bv;72dlGf}^Z"]; +const b1 = runtime.getOpcodeFunction("event_whengreaterthan"); +const b2 = stage.variables["PsY$$vp$IVH;dDAr[q2h"]; +const b3 = runtime.getOpcodeFunction("looks_say"); +return function* genXYZ () { +{ +const resolvedValue = toBoolean((yield* executeInCompatibilityLayer({"VALUE":b0.value,"WHENGREATERTHANMENU":"TIMER",}, b1, false, false, null))); +const id = "iNmua~6veGey6O-_UB9."; +const hasOldEdgeValue = target.hasEdgeActivatedValue(id); +const oldEdgeValue = target.updateEdgeActivatedValue(id, resolvedValue); +const edgeWasActivated = hasOldEdgeValue ? (!oldEdgeValue && resolvedValue) : resolvedValue; +if (!edgeWasActivated) { +retire(); return; +} +yield; +} +if (compareEqual(b2.value, 0)) { +yield* executeInCompatibilityLayer({"MESSAGE":"fail",}, b3, false, false, null); +} +retire(); return; +}; })