Skip to content

Commit 1cb2549

Browse files
authored
Merge pull request #158 from TurboWarp/loops-2
Implement LOOP and CONDITION blocks in interpreter and compiler
2 parents 2027b92 + 7fd9495 commit 1cb2549

File tree

99 files changed

+2053
-1785
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

99 files changed

+2053
-1785
lines changed

src/compiler/compat-block-utility.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
const BlockUtility = require('../engine/block-utility');
22

33
class CompatibilityLayerBlockUtility extends BlockUtility {
4+
constructor () {
5+
super();
6+
this._stackFrame = {};
7+
}
8+
9+
get stackFrame () {
10+
return this._stackFrame;
11+
}
12+
413
// Branching operations are not supported.
514
startBranch () {
615
throw new Error('startBranch is not supported by this BlockUtility');
@@ -20,9 +29,10 @@ class CompatibilityLayerBlockUtility extends BlockUtility {
2029
throw new Error('getParam is not supported by this BlockUtility');
2130
}
2231

23-
init (thread, fakeBlockId) {
32+
init (thread, fakeBlockId, stackFrame) {
2433
this.thread = thread;
2534
this.sequencer = thread.target.runtime.sequencer;
35+
this._stackFrame = stackFrame;
2636
thread.stack[0] = fakeBlockId;
2737
}
2838
}

src/compiler/irgen.js

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,7 +1145,7 @@ class ScriptTreeGenerator {
11451145
const blockInfo = this.getBlockInfo(block.opcode);
11461146
if (blockInfo) {
11471147
const type = blockInfo.info.blockType;
1148-
if (type === BlockType.COMMAND) {
1148+
if (type === BlockType.COMMAND || type === BlockType.CONDITIONAL || type === BlockType.LOOP) {
11491149
return this.descendCompatLayer(block);
11501150
}
11511151
}
@@ -1386,19 +1386,37 @@ class ScriptTreeGenerator {
13861386
*/
13871387
descendCompatLayer (block) {
13881388
this.script.yields = true;
1389+
13891390
const inputs = {};
1390-
const fields = {};
13911391
for (const name of Object.keys(block.inputs)) {
1392-
inputs[name] = this.descendInputOfBlock(block, name);
1392+
if (!name.startsWith('SUBSTACK')) {
1393+
inputs[name] = this.descendInputOfBlock(block, name);
1394+
}
13931395
}
1396+
1397+
const fields = {};
13941398
for (const name of Object.keys(block.fields)) {
13951399
fields[name] = block.fields[name].value;
13961400
}
1401+
1402+
const blockInfo = this.getBlockInfo(block.opcode);
1403+
const blockType = (blockInfo && blockInfo.info && blockInfo.info.blockType) || BlockType.COMMAND;
1404+
const substacks = [];
1405+
if (blockType === BlockType.CONDITIONAL || blockType === BlockType.LOOP) {
1406+
const branchCount = blockInfo.info.branchCount;
1407+
for (let i = 0; i < branchCount; i++) {
1408+
const inputName = i === 0 ? 'SUBSTACK' : `SUBSTACK${i + 1}`;
1409+
substacks.push(this.descendSubstack(block, inputName));
1410+
}
1411+
}
1412+
13971413
return {
13981414
kind: 'compat',
13991415
opcode: block.opcode,
1416+
blockType,
14001417
inputs,
1401-
fields
1418+
fields,
1419+
substacks
14021420
};
14031421
}
14041422

src/compiler/jsexecute.js

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ runtimeFunctions.waitThreads = `const waitThreads = function*(threads) {
101101
* @param {function} blockFunction The primitive's function.
102102
* @param {boolean} useFlags Whether to set flags (hasResumedFromPromise)
103103
* @param {string} blockId Block ID to set on the emulated block utility.
104+
* @param {*|null} stackFrame Object to use as stack frame.
104105
* @returns {*} the value returned by the block, if any.
105106
*/
106107
runtimeFunctions.executeInCompatibilityLayer = `let hasResumedFromPromise = false;
@@ -131,16 +132,13 @@ const isPromise = value => (
131132
typeof value === 'object' &&
132133
typeof value.then === 'function'
133134
);
134-
const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, useFlags, blockId) {
135+
const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, useFlags, blockId, stackFrame) {
135136
const thread = globalState.thread;
136-
137-
// reset the stackframe
138-
// we only ever use one stackframe at a time, so this shouldn't cause issues
139-
thread.stackFrames[thread.stackFrames.length - 1].reuse(isWarp);
137+
const blockUtility = globalState.blockUtility;
138+
if (!stackFrame) stackFrame = {};
140139
141140
const executeBlock = () => {
142-
const blockUtility = globalState.blockUtility;
143-
blockUtility.init(thread, blockId);
141+
blockUtility.init(thread, blockId, stackFrame);
144142
return blockFunction(inputs, blockUtility);
145143
};
146144
@@ -195,6 +193,11 @@ const executeInCompatibilityLayer = function*(inputs, blockFunction, isWarp, use
195193
return returnValue;
196194
}`;
197195

196+
/**
197+
* @returns {unknown} An object to use as a stack frame.
198+
*/
199+
runtimeFunctions.persistentStackFrame = `const persistentStackFrame = () => ({});`;
200+
198201
/**
199202
* End the current script.
200203
*/

src/compiler/jsgen.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const log = require('../util/log');
22
const Cast = require('../util/cast');
3+
const BlockType = require('../extension-support/block-type');
34
const VariablePool = require('./variable-pool');
45
const jsexecute = require('./jsexecute');
56
const environment = require('./environment');
@@ -760,7 +761,27 @@ class JSGenerator {
760761
// If the last command in a loop returns a promise, immediately continue to the next iteration.
761762
// If you don't do this, the loop effectively yields twice per iteration and will run at half-speed.
762763
const isLastInLoop = this.isLastBlockInLoop();
763-
this.source += `${this.generateCompatibilityLayerCall(node, isLastInLoop)};\n`;
764+
765+
const blockType = node.blockType;
766+
if (blockType === BlockType.COMMAND || blockType === BlockType.HAT) {
767+
this.source += `${this.generateCompatibilityLayerCall(node, isLastInLoop)};\n`;
768+
} else if (blockType === BlockType.CONDITIONAL) {
769+
this.source += `switch (Math.round(${this.generateCompatibilityLayerCall(node, isLastInLoop)})) {\n`;
770+
for (let i = 0; i < node.substacks.length; i++) {
771+
this.source += `case ${i + 1}: {\n`;
772+
this.descendStack(node.substacks[i], new Frame(false));
773+
this.source += `break;\n`;
774+
this.source += `}\n`;
775+
}
776+
this.source += `}\n`;
777+
} else if (node.blockType === BlockType.LOOP) {
778+
const stackFrameName = this.localVariables.next();
779+
this.source += `const ${stackFrameName} = persistentStackFrame();\n`;
780+
this.source += `while (toBoolean(${this.generateCompatibilityLayerCall(node, isLastInLoop, stackFrameName)})) {\n`;
781+
this.descendStack(node.substacks[0], new Frame(true));
782+
this.source += '}\n';
783+
}
784+
764785
if (isLastInLoop) {
765786
this.source += 'if (hasResumedFromPromise) {hasResumedFromPromise = false;continue;}\n';
766787
}
@@ -1292,9 +1313,10 @@ class JSGenerator {
12921313
* Generate a call into the compatibility layer.
12931314
* @param {*} node The "compat" kind node to generate from.
12941315
* @param {boolean} setFlags Whether flags should be set describing how this function was processed.
1316+
* @param {string|null} [frameName] Name of the stack frame variable, if any
12951317
* @returns {string} The JS of the call.
12961318
*/
1297-
generateCompatibilityLayerCall (node, setFlags) {
1319+
generateCompatibilityLayerCall (node, setFlags, frameName = null) {
12981320
const opcode = node.opcode;
12991321

13001322
let result = 'yield* executeInCompatibilityLayer({';
@@ -1309,7 +1331,7 @@ class JSGenerator {
13091331
result += `"${sanitize(fieldName)}":"${sanitize(field)}",`;
13101332
}
13111333
const opcodeFunction = this.evaluateOnce(`runtime.getOpcodeFunction("${sanitize(opcode)}")`);
1312-
result += `}, ${opcodeFunction}, ${this.isWarp}, ${setFlags}, null)`;
1334+
result += `}, ${opcodeFunction}, ${this.isWarp}, ${setFlags}, null, ${frameName})`;
13131335

13141336
return result;
13151337
}

src/engine/execute.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ const handleReport = function (resolvedValue, sequencer, thread, blockCached, la
5454
const currentBlockId = blockCached.id;
5555
const opcode = blockCached.opcode;
5656
const isHat = blockCached._isHat;
57+
const isConditional = blockCached._isConditional;
58+
const isLoop = blockCached._isLoop;
5759

5860
thread.pushReportedValue(resolvedValue);
5961
if (isHat) {
@@ -83,6 +85,11 @@ const handleReport = function (resolvedValue, sequencer, thread, blockCached, la
8385
// Predicate returned false: do not allow script to run
8486
sequencer.retireThread(thread);
8587
}
88+
} else if (isConditional) {
89+
const branch = Math.round(resolvedValue);
90+
sequencer.stepToBranch(thread, branch, false);
91+
} else if (isLoop && cast.toBoolean(resolvedValue)) {
92+
sequencer.stepToBranch(thread, 1, true);
8693
} else {
8794
// In a non-hat, report the value visually if necessary if
8895
// at the top of the thread stack.
@@ -292,6 +299,10 @@ class BlockCached {
292299
this._blockFunction = runtime.getOpcodeFunction(opcode);
293300
this._definedBlockFunction = typeof this._blockFunction !== 'undefined';
294301

302+
const flowing = runtime._flowing[opcode];
303+
this._isConditional = !!(flowing && flowing.conditional);
304+
this._isLoop = !!(flowing && flowing.loop);
305+
295306
// Store the current shadow value if there is a shadow value.
296307
const fieldKeys = Object.keys(fields);
297308
this._isShadowBlock = (

src/engine/runtime.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,13 @@ class Runtime extends EventEmitter {
269269
*/
270270
this._hats = {};
271271

272+
/**
273+
* Map of opcode to information about whether the block's return value should be interpreted
274+
* for control flow purposes.
275+
* @type {Record<string, {conditional: boolean}>}
276+
*/
277+
this._flowing = {};
278+
272279
/**
273280
* A list of script block IDs that were glowing during the previous frame.
274281
* @type {!Array.<!string>}
@@ -1120,6 +1127,16 @@ class Runtime extends EventEmitter {
11201127
edgeActivated: blockInfo.isEdgeActivated,
11211128
restartExistingThreads: blockInfo.shouldRestartExistingThreads
11221129
};
1130+
} else if (blockInfo.blockType === BlockType.CONDITIONAL) {
1131+
this._flowing[opcode] = {
1132+
conditional: true,
1133+
loop: false
1134+
};
1135+
} else if (blockInfo.blockType === BlockType.LOOP) {
1136+
this._flowing[opcode] = {
1137+
conditional: false,
1138+
loop: true
1139+
};
11231140
}
11241141
}
11251142
} catch (e) {

test/fixtures/tw-conditional.sb3

2.58 KB
Binary file not shown.

test/fixtures/tw-loop.sb3

2.7 KB
Binary file not shown.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const {test} = require('tap');
4+
const VirtualMachine = require('../../src/virtual-machine');
5+
const Scratch = require('../../src/extension-support/tw-extension-api-common');
6+
7+
// Based on https://github.com/TurboWarp/scratch-vm/pull/141
8+
class LoopsAndThings {
9+
getInfo() {
10+
return {
11+
id: "loopsAndThings",
12+
name: "Loops and things test",
13+
blocks: [
14+
{
15+
opcode: "conditional",
16+
blockType: Scratch.BlockType.CONDITIONAL,
17+
text: "run branch [BRANCH] of",
18+
arguments: {
19+
BRANCH: {
20+
type: Scratch.ArgumentType.NUMBER,
21+
defaultValue: 1
22+
}
23+
},
24+
branchCount: 3
25+
},
26+
{
27+
opcode: "loop",
28+
blockType: Scratch.BlockType.LOOP,
29+
text: "my repeat [TIMES]",
30+
arguments: {
31+
TIMES: {
32+
type: Scratch.ArgumentType.NUMBER,
33+
defaultValue: 10
34+
}
35+
},
36+
},
37+
'---',
38+
{
39+
opcode: "testPromise",
40+
blockType: Scratch.BlockType.REPORTER,
41+
text: "return [VALUE] in a Promise",
42+
arguments: {
43+
VALUE: {
44+
type: Scratch.ArgumentType.STRING,
45+
defaultValue: ''
46+
}
47+
}
48+
}
49+
]
50+
};
51+
}
52+
53+
conditional({BRANCH}, util) {
54+
return Scratch.Cast.toNumber(BRANCH);
55+
}
56+
57+
loop({TIMES}, util) {
58+
const times = Math.round(Scratch.Cast.toNumber(TIMES));
59+
if (typeof util.stackFrame.loopCounter === "undefined") {
60+
util.stackFrame.loopCounter = times;
61+
}
62+
util.stackFrame.loopCounter--;
63+
if (util.stackFrame.loopCounter >= 0) {
64+
return true;
65+
}
66+
}
67+
68+
testPromise({VALUE}) {
69+
return Promise.resolve(VALUE);
70+
}
71+
}
72+
73+
const compilerAndInterpreter = (name, callback) => {
74+
test(`${name} - interpreted`, t => {
75+
callback(t, {
76+
enabled: false
77+
});
78+
});
79+
test(`${name} - compiled`, t => {
80+
callback(t, {
81+
enabled: true
82+
});
83+
});
84+
};
85+
86+
compilerAndInterpreter('CONDITIONAL', (t, co) => {
87+
t.plan(1);
88+
89+
const vm = new VirtualMachine();
90+
vm.setCompilerOptions(co);
91+
vm.extensionManager.addBuiltinExtension('loopsAndThings', LoopsAndThings);
92+
vm.runtime.on('COMPILE_ERROR', () => {
93+
t.fail('Compile error');
94+
});
95+
96+
vm.loadProject(fs.readFileSync(path.join(__dirname, '../fixtures/tw-conditional.sb3'))).then(() => {
97+
let okayCount = 0;
98+
vm.runtime.on('SAY', (target, type, text) => {
99+
if (text === 'OK!') {
100+
okayCount++;
101+
} else if (text === 'end') {
102+
vm.stop();
103+
t.equal(okayCount, 5);
104+
t.end();
105+
}
106+
});
107+
108+
vm.greenFlag();
109+
vm.start();
110+
});
111+
});
112+
113+
compilerAndInterpreter('LOOP', (t, co) => {
114+
t.plan(1);
115+
116+
const vm = new VirtualMachine();
117+
vm.setCompilerOptions(co);
118+
vm.extensionManager.addBuiltinExtension('loopsAndThings', LoopsAndThings);
119+
vm.runtime.on('COMPILE_ERROR', () => {
120+
t.fail('Compile error');
121+
});
122+
123+
vm.loadProject(fs.readFileSync(path.join(__dirname, '../fixtures/tw-loop.sb3'))).then(() => {
124+
vm.runtime.on('SAY', (target, type, text) => {
125+
vm.stop();
126+
t.equal(text, 'a 5 b 50 c 200');
127+
t.end();
128+
});
129+
130+
vm.greenFlag();
131+
vm.start();
132+
});
133+
});

0 commit comments

Comments
 (0)