Skip to content

Commit 3a55cf4

Browse files
authored
Merge pull request #470 from scratchfoundation/fix-proc-def-save-load
fix: fix de/serialization for non-shadow proc defs
2 parents 4b67060 + 3343284 commit 3a55cf4

3 files changed

Lines changed: 197 additions & 2 deletions

File tree

packages/scratch-vm/src/serialization/sb2.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,15 +1240,15 @@ const parseBlock = function (sb2block, addBroadcastMsg, getVariableId, extension
12401240
activeBlock.inputs[inputName] = {
12411241
name: inputName,
12421242
block: inputUid,
1243-
shadow: inputUid
1243+
shadow: null
12441244
};
12451245
activeBlock.children = [{
12461246
id: inputUid,
12471247
opcode: 'procedures_prototype',
12481248
inputs: {},
12491249
fields: {},
12501250
next: null,
1251-
shadow: true,
1251+
shadow: false,
12521252
children: [],
12531253
mutation: {
12541254
tagName: 'mutation',

packages/scratch-vm/src/serialization/sb3.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,36 @@ const deserializeBlocks = function (blocks) {
849849
block.comment = `${block.id}_comment`;
850850
}
851851
}
852+
// Second pass: strip argument reporter children from procedures_prototype blocks.
853+
// These blocks are managed by Blockly's `domToMutation` and should not be loaded
854+
// from the SB3 JSON, as loading them would cause duplicates when Blockly's
855+
// `domToMutation` creates them again during workspace initialization.
856+
// Also set `shadow: false` for old-format prototypes (which were saved as shadow blocks).
857+
for (const blockId in blocks) {
858+
if (!Object.prototype.hasOwnProperty.call(blocks, blockId)) continue;
859+
const block = blocks[blockId];
860+
if (Array.isArray(block)) continue;
861+
if (block.opcode !== 'procedures_prototype') continue;
862+
863+
// Convert from shadow (old format) to non-shadow, and clear the stale
864+
// shadow reference in the parent definition block's custom_block input.
865+
block.shadow = false;
866+
const parentBlock = blocks[block.parent];
867+
if (parentBlock && parentBlock.inputs && parentBlock.inputs.custom_block) {
868+
parentBlock.inputs.custom_block.shadow = null;
869+
}
870+
871+
// Delete the argument reporter children from the blocks map.
872+
// They will be recreated by Blockly's domToMutation.
873+
for (const inputName in block.inputs) {
874+
if (!Object.prototype.hasOwnProperty.call(block.inputs, inputName)) continue;
875+
const inputInfo = block.inputs[inputName];
876+
if (inputInfo.block) delete blocks[inputInfo.block];
877+
if (inputInfo.shadow && inputInfo.shadow !== inputInfo.block) delete blocks[inputInfo.shadow];
878+
}
879+
block.inputs = {};
880+
}
881+
852882
return blocks;
853883
};
854884

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
const test = require('tap').test;
2+
const sb3 = require('../../src/serialization/sb3');
3+
4+
/**
5+
* Minimal SB3 blocks for a procedure with one string argument, in old format
6+
* (shadow prototype and shadow argument reporter, as saved by scratch-blocks v1).
7+
* Includes a block attached after the define, matching the conditions from the
8+
* bug report.
9+
*/
10+
const makeOldFormatBlocks = () => ({
11+
define_id: {
12+
opcode: 'procedures_definition',
13+
next: 'attached_id',
14+
parent: null,
15+
inputs: {custom_block: [1, 'proto_id']},
16+
fields: {},
17+
shadow: false,
18+
topLevel: true,
19+
x: 0,
20+
y: 0
21+
},
22+
proto_id: {
23+
opcode: 'procedures_prototype',
24+
next: null,
25+
parent: 'define_id',
26+
inputs: {arg_id: [1, 'reporter_id']},
27+
fields: {},
28+
shadow: true,
29+
topLevel: false,
30+
mutation: {
31+
tagName: 'mutation',
32+
children: [],
33+
proccode: 'my block %s',
34+
argumentids: '["arg_id"]',
35+
argumentnames: '["x"]',
36+
argumentdefaults: '[""]',
37+
warp: 'false'
38+
}
39+
},
40+
reporter_id: {
41+
opcode: 'argument_reporter_string_number',
42+
next: null,
43+
parent: 'proto_id',
44+
inputs: {},
45+
fields: {VALUE: ['x', null]},
46+
shadow: true,
47+
topLevel: false
48+
},
49+
attached_id: {
50+
opcode: 'motion_movesteps',
51+
next: null,
52+
parent: 'define_id',
53+
inputs: {},
54+
fields: {},
55+
shadow: false,
56+
topLevel: false
57+
}
58+
});
59+
60+
/**
61+
* Same procedure in new format (non-shadow prototype, no argument reporter blocks).
62+
*/
63+
const makeNewFormatBlocks = () => ({
64+
define_id: {
65+
opcode: 'procedures_definition',
66+
next: 'attached_id',
67+
parent: null,
68+
inputs: {custom_block: [2, 'proto_id']},
69+
fields: {},
70+
shadow: false,
71+
topLevel: true,
72+
x: 0,
73+
y: 0
74+
},
75+
proto_id: {
76+
opcode: 'procedures_prototype',
77+
next: null,
78+
parent: 'define_id',
79+
inputs: {},
80+
fields: {},
81+
shadow: false,
82+
topLevel: false,
83+
mutation: {
84+
tagName: 'mutation',
85+
children: [],
86+
proccode: 'my block %s',
87+
argumentids: '["arg_id"]',
88+
argumentnames: '["x"]',
89+
argumentdefaults: '[""]',
90+
warp: 'false'
91+
}
92+
},
93+
attached_id: {
94+
opcode: 'motion_movesteps',
95+
next: null,
96+
parent: 'define_id',
97+
inputs: {},
98+
fields: {},
99+
shadow: false,
100+
topLevel: false
101+
}
102+
});
103+
104+
const EXPECTED_OPCODES = [
105+
'motion_movesteps',
106+
'procedures_definition',
107+
'procedures_prototype'
108+
].sort();
109+
110+
const getOpcodes = blocks => Object.values(blocks)
111+
.map(b => b.opcode)
112+
.sort();
113+
114+
test('deserializeBlocks: strips argument reporters from procedures_prototype (old format)', t => {
115+
const result = sb3.deserializeBlocks(makeOldFormatBlocks());
116+
117+
t.same(getOpcodes(result), EXPECTED_OPCODES,
118+
'argument reporter is stripped; definition, prototype, and attached block remain');
119+
120+
t.equal(result.proto_id.shadow, false, 'prototype is not shadow');
121+
t.same(result.proto_id.inputs, {}, 'prototype inputs are empty');
122+
t.equal(result.define_id.inputs.custom_block.shadow, null,
123+
'definition custom_block input shadow reference is cleared');
124+
125+
t.end();
126+
});
127+
128+
test('deserializeBlocks: leaves procedure blocks unchanged when already in new format', t => {
129+
const result = sb3.deserializeBlocks(makeNewFormatBlocks());
130+
131+
t.same(getOpcodes(result), EXPECTED_OPCODES,
132+
'definition, prototype, and attached block are all present');
133+
134+
t.equal(result.proto_id.shadow, false, 'prototype is not shadow');
135+
t.same(result.proto_id.inputs, {}, 'prototype inputs are empty');
136+
137+
t.end();
138+
});
139+
140+
test('procedures block hierarchy is preserved across a serialize/deserialize round-trip (old format)', t => {
141+
const deserialized = sb3.deserializeBlocks(makeOldFormatBlocks());
142+
const [serialized] = sb3.serializeBlocks(deserialized);
143+
const roundTripped = sb3.deserializeBlocks(serialized);
144+
145+
t.same(getOpcodes(roundTripped), EXPECTED_OPCODES,
146+
'same block opcodes after round-trip, no extra blocks accumulated');
147+
148+
t.equal(roundTripped.proto_id.shadow, false, 'prototype is still not shadow after round-trip');
149+
t.same(roundTripped.proto_id.inputs, {}, 'prototype inputs still empty after round-trip');
150+
t.equal(roundTripped.define_id.inputs.custom_block.shadow, null,
151+
'definition input shadow still null after round-trip');
152+
153+
t.end();
154+
});
155+
156+
test('procedures block hierarchy is preserved across a serialize/deserialize round-trip (new format)', t => {
157+
const deserialized = sb3.deserializeBlocks(makeNewFormatBlocks());
158+
const [serialized] = sb3.serializeBlocks(deserialized);
159+
const roundTripped = sb3.deserializeBlocks(serialized);
160+
161+
t.same(getOpcodes(roundTripped), EXPECTED_OPCODES,
162+
'same block opcodes after round-trip');
163+
164+
t.end();
165+
});

0 commit comments

Comments
 (0)