From 334058b081e3e42c9f6d52a237dedb1027bc12a6 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 27 Mar 2018 13:00:58 -0400 Subject: [PATCH 01/25] Block and variable compression. No need to serialize uid of block and shadow if they are the same, reduce duplication of information. --- src/serialization/sb3.js | 212 ++++++++++++++++++++++++++++++++++++--- src/virtual-machine.js | 8 +- 2 files changed, 204 insertions(+), 16 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 03b871256..d43320050 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -8,6 +8,7 @@ const vmPackage = require('../../package.json'); const Blocks = require('../engine/blocks'); const Sprite = require('../sprites/sprite'); const Variable = require('../engine/variable'); +const log = require('../util/log'); const {loadCostume} = require('../import/load-costume.js'); const {loadSound} = require('../import/load-sound.js'); @@ -25,16 +26,63 @@ const {deserializeCostume, deserializeSound} = require('./deserialize-assets.js' * @property {Map.} extensionURLs - map of ID => URL from project metadata. May not match extensionIDs. */ +const INPUT_SAME_BLOCK_SHADOW = 1; +const INPUT_BLOCK_NO_SHADOW = 2; +const INPUT_DIFF_BLOCK_SHADOW = 3; +// haven't found a case where block = null, but shadow is present... + +const serializeInputs = function (inputs) { + const obj = Object.create(null); + for (const inputName in inputs) { + if (!inputs.hasOwnProperty(inputName)) continue; + // if block and shadow refer to the same block, only serialize one + if (inputs[inputName].block === inputs[inputName].shadow) { + // has block and shadow, and they are the same + obj[inputName] = [ + INPUT_SAME_BLOCK_SHADOW, + inputs[inputName].block + ]; + } else if (inputs[inputName].shadow === null) { + // does not have shadow + obj[inputName] = [ + INPUT_BLOCK_NO_SHADOW, + inputs[inputName].block + ]; + } else { + // block and shadow are both present and are different + obj[inputName] = [ + INPUT_DIFF_BLOCK_SHADOW, + inputs[inputName].block, + inputs[inputName].shadow + ]; + } + } + return obj; +}; + +const serializeFields = function (fields) { + const obj = Object.create(null); + for (const fieldName in fields) { + if (!fields.hasOwnProperty(fieldName)) continue; + obj[fieldName] = [fields[fieldName].value]; + if (fields[fieldName].hasOwnProperty('id')) { + obj[fieldName].push(fields[fieldName].id); + } + } + return obj; +}; + const serializeBlock = function (block) { const obj = Object.create(null); - obj.id = block.id; + // obj.id = block.id; // don't need this, it's the index of this block in its containing object obj.opcode = block.opcode; - obj.next = block.next; + if (block.next) obj.next = block.next; // don't serialize next if null + // obj.next = if (block.next; obj.parent = block.parent; - obj.inputs = block.inputs; - obj.fields = block.fields; + obj.inputs = serializeInputs(block.inputs); + obj.fields = serializeFields(block.fields); obj.topLevel = block.topLevel ? block.topLevel : false; - obj.shadow = block.shadow; + obj.shadow = block.shadow; // I think we don't need this either.. if (block.topLevel) { if (block.x) { obj.x = Math.round(block.x); @@ -92,11 +140,48 @@ const serializeSound = function (sound) { return obj; }; +const serializeVariables = function (variables) { + const obj = Object.create(null); + // separate out variables into types at the top level so we don't have + // keep track of a type for each + obj.variables = Object.create(null); + obj.lists = Object.create(null); + obj.broadcasts = Object.create(null); + for (const varId in variables) { + const v = variables[varId]; + if (v.type === Variable.BROADCAST_MESSAGE_TYPE) { + obj.broadcasts[varId] = [v.name, v.value]; + continue; + } + if (v.type === Variable.LIST_TYPE) { + obj.lists[varId] = [v.name, v.value]; + continue; + } + + // should be a scalar type + obj.variables[varId] = [v.name] + let val = v.value; + if ((typeof val !== 'string') && (typeof val !== 'number')) { + log.info(`Variable: ${v.name} had value ${val} of type: ${typeof val} converting to string`); + val = JSON.stringify(val); + } + obj.variables[varId].push(val); + // Some hacked blocks have booleans as variable values + // (typeof v.value === 'string') || (typeof v.value === 'number') ? + // v.value : JSON.stringify(v.value)]; + if (v.isPersistent) obj.variables[varId].push(true); + } + return obj; +}; + const serializeTarget = function (target) { const obj = Object.create(null); obj.isStage = target.isStage; obj.name = target.name; - obj.variables = target.variables; // This means that uids for variables will persist across saves/loads + const vars = serializeVariables(target.variables); + obj.variables = vars.variables; + obj.lists = vars.lists; + obj.broadcasts = vars.broadcasts; obj.blocks = serializeBlocks(target.blocks); obj.currentCostume = target.currentCostume; obj.costumes = target.costumes.map(serializeCostume); @@ -142,6 +227,59 @@ const serialize = function (runtime) { return obj; }; +const deserializeInputs = function (inputs) { + // Explicitly not using Object.create(null) here + // because we call prototype functions later in the vm + const obj = {}; + for (const inputName in inputs) { + if (!inputs.hasOwnProperty(inputName)) continue; + const inputDescArr = inputs[inputName]; + let block = null; + let shadow = null; + const blockShadowInfo = inputDescArr[0]; + if (blockShadowInfo === INPUT_SAME_BLOCK_SHADOW) { + // block and shadow are the same id, and only one is provided + block = shadow = inputDescArr[1]; + } else if (blockShadowInfo === INPUT_BLOCK_NO_SHADOW) { + block = inputDescArr[1]; + } else { // assume INPUT_DIFF_BLOCK_SHADOW + block = inputDescArr[1]; + shadow = inputDescArr[2]; + } + obj[inputName] = { + name: inputName, + block: block, + shadow: shadow + }; + } + return obj; +}; + +const deserializeFields = function (fields) { + // Explicitly not using Object.create(null) here + // because we call prototype functions later in the vm + const obj = {}; + for (const fieldName in fields) { + if (!fields.hasOwnProperty(fieldName)) continue; + const fieldDescArr = fields[fieldName]; + obj[fieldName] = { + name: fieldName, + value: fieldDescArr[0] + }; + if (fieldDescArr.length > 1) { + obj[fieldName].id = fieldDescArr[1]; + } + if (fieldName === 'BROADCAST_OPTION') { + obj[fieldName].variableType = Variable.BROADCAST_MESSAGE_TYPE; + } else if (fieldName === 'VARIABLE') { + obj[fieldName].variableType = Variable.SCALAR_TYPE; + } else if (fieldName === 'LIST') { + obj[fieldName].variableType = Variable.LIST_TYPE; + } + } + return obj; +}; + /** * Parse a single "Scratch object" and create all its in-memory VM objects. * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. @@ -170,6 +308,13 @@ const parseScratchObject = function (object, runtime, extensions, zip) { for (const blockId in object.blocks) { if (!object.blocks.hasOwnProperty(blockId)) continue; const blockJSON = object.blocks[blockId]; + blockJSON.id = blockId; // add id back to block since it wasn't serialized + const serializedInputs = blockJSON.inputs; + const deserializedInputs = deserializeInputs(serializedInputs); + blockJSON.inputs = deserializedInputs; + const serializedFields = blockJSON.fields; + const deserializedFields = deserializeFields(serializedFields); + blockJSON.fields = deserializedFields; blocks.createBlock(blockJSON); const dotIndex = blockJSON.opcode.indexOf('.'); @@ -238,18 +383,55 @@ const parseScratchObject = function (object, runtime, extensions, zip) { const target = sprite.createClone(); // Load target properties from JSON. if (object.hasOwnProperty('variables')) { - for (const j in object.variables) { - const variable = object.variables[j]; - const newVariable = new Variable( - variable.id, - variable.name, - variable.type, - variable.isPersistent - ); - newVariable.value = variable.value; + for (const varId in object.variables) { + const variable = object.variables[varId]; + let newVariable; + if (Array.isArray(variable)) { + newVariable = new Variable( + varId, // var id is the index of the variable desc array in the variables obj + variable[0], // name of the variable + Variable.SCALAR_TYPE, // type of the variable + (variable.length === 3) ? variable[2] : false // isPersistent/isCloud + ); + newVariable.value = variable[1]; + } else { + newVariable = new Variable( + variable.id, + variable.name, + variable.type, + variable.isPersistent + ); + newVariable.value = variable.value; + } target.variables[newVariable.id] = newVariable; } } + if (object.hasOwnProperty('lists')) { + for (const listId in object.lists) { + const list = object.lists[listId]; + const newList = new Variable( + listId, + list[0], + Variable.LIST_TYPE, + false + ); + newList.value = list[1]; + target.variables[newList.id] = newList; + } + } + if (object.hasOwnProperty('broadcasts')) { + for (const broadcastId in object.broadcasts) { + const broadcast = object.broadcasts[broadcastId]; + const newBroadcast = new Variable( + broadcastId, + broadcast[0], + Variable.BROADCAST_MESSAGE_TYPE, + false + ); + newBroadcast.value = broadcast[1]; + target.variables[newBroadcast.id] = newBroadcast; + } + } if (object.hasOwnProperty('x')) { target.x = object.x; } diff --git a/src/virtual-machine.js b/src/virtual-machine.js index abe4b69ba..79ff6f6f1 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -256,7 +256,13 @@ class VirtualMachine extends EventEmitter { zip.file(currCostume.fileName, currCostume.fileContent); } - return zip.generateAsync({type: 'blob'}); + return zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { + level: 9 // best compression (level 1 would be best speed) + } + }); } /** From 21d60604ac3b555b8f2b7b2cc0465ca45a7236e9 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 28 Mar 2018 09:47:46 -0400 Subject: [PATCH 02/25] Some block serialization compression -- compress primitives. This is a WIP since we don't deserialize blocks serialized in this way. --- src/serialization/sb3.js | 100 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 5 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index d43320050..24181636b 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -31,6 +31,53 @@ const INPUT_BLOCK_NO_SHADOW = 2; const INPUT_DIFF_BLOCK_SHADOW = 3; // haven't found a case where block = null, but shadow is present... +// Constants referring to 'primitive' types, e.g. +// math_number +// text +// event_broadcast_menu +// data_variable +// data_listcontents +const MATH_PRIMITIVE = 4; // there's no reason these constants can't collide +const TEXT_PRIMITIVE = 5; // with the above, but removing duplication for clarity +const BROADCAST_PRIMITIVE = 6; +const VAR_PRIMITIVE = 7; +const LIST_PRIMITIVE = 8; + +const serializePrimitiveBlock = function (block) { + // Returns an array represeting a primitive block or null if not one of + // the primitive types above + if (block.opcode === 'math_number') { + const numField = block.fields.NUM; + // If the primitive block has already been serialized, e.g. serializeFields has run on it + // then the value of its NUM field will be an array with the value we want + // if (Array.isArray(numField)) return [MATH_PRIMITIVE, numField[0]]; + // otherwise get the num out of the unserialized field + return [MATH_PRIMITIVE, numField.value]; + } + if (block.opcode === 'text') { + const textField = block.fields.TEXT; + // if (Array.isArray(textField)) return [TEXT_PRIMITIVE, textField[0]]; + return [TEXT_PRIMITIVE, textField.value]; + } + if (block.opcode === 'event_broadcast_menu') { + const broadcastField = block.fields.BROADCAST_OPTION; + // if (Array.isArray(broadcastField)) return [BROADCAST_PRIMITIVE, broadcastField[0], broadcastField[1]]; + return [BROADCAST_PRIMITIVE, broadcastField.value, broadcastField.id]; + } + if (block.opcode === 'data_variable') { + const variableField = block.fields.VARIABLE; + // if (Array.isArray(variableField)) return [VAR_PRIMITIVE, variableField[0], variableField[1]]; + return [VAR_PRIMITIVE, variableField.value, variableField.id]; + } + if (block.opcode === 'data_listcontents') { + const listField = block.fields.LIST; + // if (Array.isArray(listField)) return [LIST_PRIMITIVE, listField[0], listField[1]]; + return [LIST_PRIMITIVE, listField.value, listField.id]; + } + // If none of the above, return null + return null; +}; + const serializeInputs = function (inputs) { const obj = Object.create(null); for (const inputName in inputs) { @@ -73,6 +120,9 @@ const serializeFields = function (fields) { }; const serializeBlock = function (block) { + const serializedPrimitive = serializePrimitiveBlock(block); + if (serializedPrimitive) return serializedPrimitive; + // If serializedPrimitive is null, proceed with serializing a non-primitive block const obj = Object.create(null); // obj.id = block.id; // don't need this, it's the index of this block in its containing object obj.opcode = block.opcode; @@ -97,11 +147,50 @@ const serializeBlock = function (block) { return obj; }; +// caution this function modifies its inputs directly........... +const compressInputTree = function (block, blocks) { + // const newInputs = Object.create(null); + // second pass on the block + // so the inputs field should be an object of key - array pairs + const serializedInputs = block.inputs; + for (const inputName in serializedInputs) { + // don't need to check for hasOwnProperty because of how we constructed + // inputs + const currInput = serializedInputs[inputName]; + // traverse currInput skipping the first element, which describes whether the block + // and shadow are the same + for (let i = 1; i < currInput.length; i++) { + if (!currInput[i]) continue; // need this check b/c block/shadow can be null + const blockOrShadowID = currInput[i]; + // newInputs[inputName][i] = blocks[blockOrShadowID]; + // replace element of currInput directly + // (modifying input block directly) + const blockOrShadow = blocks[blockOrShadowID]; + if (Array.isArray(blockOrShadow)) { + currInput[i] = blockOrShadow; + delete blocks[blockOrShadowID]; + } + } + } + // block.inputs = newInputs; + return block; +}; + const serializeBlocks = function (blocks) { const obj = Object.create(null); for (const blockID in blocks) { if (!blocks.hasOwnProperty(blockID)) continue; - obj[blockID] = serializeBlock(blocks[blockID]); + obj[blockID] = serializeBlock(blocks[blockID], blocks); + } + // once we have completed a first pass, do a second pass on block inputs + for (const serializedBlockId in obj) { + // don't need to do the hasOwnProperty check here since we + // created an object that doesn't get extra properties/functions + const serializedBlock = obj[serializedBlockId]; + // caution, this function deletes parts of this object in place as + // it's traversing it (we could do a third pass...) + obj[serializedBlockId] = compressInputTree(serializedBlock, obj); + // second pass on connecting primitives to serialized inputs directly } return obj; }; @@ -150,7 +239,7 @@ const serializeVariables = function (variables) { for (const varId in variables) { const v = variables[varId]; if (v.type === Variable.BROADCAST_MESSAGE_TYPE) { - obj.broadcasts[varId] = [v.name, v.value]; + obj.broadcasts[varId] = v.value; // name and value is the same for broadcast msgs continue; } if (v.type === Variable.LIST_TYPE) { @@ -159,7 +248,7 @@ const serializeVariables = function (variables) { } // should be a scalar type - obj.variables[varId] = [v.name] + obj.variables[varId] = [v.name]; let val = v.value; if ((typeof val !== 'string') && (typeof val !== 'number')) { log.info(`Variable: ${v.name} had value ${val} of type: ${typeof val} converting to string`); @@ -424,11 +513,12 @@ const parseScratchObject = function (object, runtime, extensions, zip) { const broadcast = object.broadcasts[broadcastId]; const newBroadcast = new Variable( broadcastId, - broadcast[0], + broadcast, Variable.BROADCAST_MESSAGE_TYPE, false ); - newBroadcast.value = broadcast[1]; + // no need to explicitly set the value, variable constructor + // sets the value to the same as the name for broadcast msgs target.variables[newBroadcast.id] = newBroadcast; } } From d61ea23e1e46f4d182d21ff4a5b736af826ab1fa Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 4 Apr 2018 17:26:30 -0400 Subject: [PATCH 03/25] Deserialize compressed primitives. --- src/serialization/sb3.js | 347 ++++++++++++++++++++++++++++++++------- 1 file changed, 288 insertions(+), 59 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 24181636b..7566d6cfb 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -9,6 +9,8 @@ const Blocks = require('../engine/blocks'); const Sprite = require('../sprites/sprite'); const Variable = require('../engine/variable'); const log = require('../util/log'); +const uid = require('../util/uid'); +// const Cast = require('../util/Cast'); const {loadCostume} = require('../import/load-costume.js'); const {loadSound} = require('../import/load-sound.js'); @@ -26,56 +28,133 @@ const {deserializeCostume, deserializeSound} = require('./deserialize-assets.js' * @property {Map.} extensionURLs - map of ID => URL from project metadata. May not match extensionIDs. */ -const INPUT_SAME_BLOCK_SHADOW = 1; -const INPUT_BLOCK_NO_SHADOW = 2; -const INPUT_DIFF_BLOCK_SHADOW = 3; +const INPUT_SAME_BLOCK_SHADOW = 1; // unobscured shadow +const INPUT_BLOCK_NO_SHADOW = 2; // no shadow +const INPUT_DIFF_BLOCK_SHADOW = 3; // obscured shadow // haven't found a case where block = null, but shadow is present... -// Constants referring to 'primitive' types, e.g. +// Constants referring to 'primitive' blocks that are usually shadows, +// or in the case of variables and lists, appear quite often in projects // math_number +const MATH_NUM_PRIMITIVE = 4; // there's no reason these constants can't collide +// math_positive_number +const POSITIVE_NUM_PRIMITIVE = 5; // with the above, but removing duplication for clarity +// math_whole_number +const WHOLE_NUM_PRIMITIVE = 6; +// math_integer +const INTEGER_NUM_PRIMITIVE = 7; +// math_angle +const ANGLE_NUM_PRIMITIVE = 8; +// colour_picker +const COLOR_PICKER_PRIMITIVE = 9; // text +const TEXT_PRIMITIVE = 10; // event_broadcast_menu +const BROADCAST_PRIMITIVE = 11; // data_variable +const VAR_PRIMITIVE = 12; // data_listcontents -const MATH_PRIMITIVE = 4; // there's no reason these constants can't collide -const TEXT_PRIMITIVE = 5; // with the above, but removing duplication for clarity -const BROADCAST_PRIMITIVE = 6; -const VAR_PRIMITIVE = 7; -const LIST_PRIMITIVE = 8; +const LIST_PRIMITIVE = 13; + +const primitiveOpcodeInfoMap = { + math_number: [MATH_NUM_PRIMITIVE, 'NUM'], + math_positive_number: [POSITIVE_NUM_PRIMITIVE, 'NUM'], + math_whole_number: [WHOLE_NUM_PRIMITIVE, 'NUM'], + math_integer: [INTEGER_NUM_PRIMITIVE, 'NUM'], + math_angle: [ANGLE_NUM_PRIMITIVE, 'NUM'], + colour_picker: [COLOR_PICKER_PRIMITIVE, 'COLOUR'], + text: [TEXT_PRIMITIVE, 'TEXT'], + event_broadcast_menu: [BROADCAST_PRIMITIVE, 'BROADCAST_OPTION'], + data_variable: [VAR_PRIMITIVE, 'VARIABLE'], + data_listcontents: [LIST_PRIMITIVE, 'LIST'] +}; const serializePrimitiveBlock = function (block) { // Returns an array represeting a primitive block or null if not one of // the primitive types above - if (block.opcode === 'math_number') { - const numField = block.fields.NUM; - // If the primitive block has already been serialized, e.g. serializeFields has run on it - // then the value of its NUM field will be an array with the value we want - // if (Array.isArray(numField)) return [MATH_PRIMITIVE, numField[0]]; - // otherwise get the num out of the unserialized field - return [MATH_PRIMITIVE, numField.value]; + if (primitiveOpcodeInfoMap.hasOwnProperty(block.opcode)) { + const primitiveInfo = primitiveOpcodeInfoMap[block.opcode]; + const primitiveConstant = primitiveInfo[0]; + const fieldName = primitiveInfo[1]; + const field = block.fields[fieldName]; + const primitiveDesc = [primitiveConstant, field.value]; + if (block.opcode === 'event_broadcast_menu') { + primitiveDesc.push(field.id); + } else if (block.opcode === 'data_variable' || block.opcode === 'data_listcontents') { + primitiveDesc.push(field.id); + if (block.topLevel) { + primitiveDesc.push(block.x ? Math.round(block.x) : 0); + primitiveDesc.push(block.y ? Math.round(block.y) : 0); + } + } + return primitiveDesc; } - if (block.opcode === 'text') { - const textField = block.fields.TEXT; - // if (Array.isArray(textField)) return [TEXT_PRIMITIVE, textField[0]]; - return [TEXT_PRIMITIVE, textField.value]; - } - if (block.opcode === 'event_broadcast_menu') { - const broadcastField = block.fields.BROADCAST_OPTION; - // if (Array.isArray(broadcastField)) return [BROADCAST_PRIMITIVE, broadcastField[0], broadcastField[1]]; - return [BROADCAST_PRIMITIVE, broadcastField.value, broadcastField.id]; - } - if (block.opcode === 'data_variable') { - const variableField = block.fields.VARIABLE; - // if (Array.isArray(variableField)) return [VAR_PRIMITIVE, variableField[0], variableField[1]]; - return [VAR_PRIMITIVE, variableField.value, variableField.id]; - } - if (block.opcode === 'data_listcontents') { - const listField = block.fields.LIST; - // if (Array.isArray(listField)) return [LIST_PRIMITIVE, listField[0], listField[1]]; - return [LIST_PRIMITIVE, listField.value, listField.id]; - } - // If none of the above, return null return null; + // if (block.opcode === 'math_number') { + // const numField = block.fields.NUM; + // // const numValue = (typeof numField.value === 'number') ? + // // numField.value : Cast.toNumber(numField.value); + // return [MATH_NUM_PRIMITIVE, numField.value]; + // } + // if (block.opcode === 'math_positive_number') { + // const positiveNumField = block.fields.NUM; + // // TODO should I actually be providing more validation here and ensure that the number is positive? + // // const numValue = (typeof positiveNumField.value === 'number') ? + // // positiveNumField.value : Cast.toNumber(positiveNumField.value); + // return [POSITIVE_NUM_PRIMITIVE, positiveNumField.Value]; + // } + // if (block.opcode === 'math_whole_number') { + // const wholeNumField = block.fields.NUM; + // const numValue = (typeof wholeNumField.value === 'number') ? + // wholeNumField.value : JSON.parse(wholeNumField.value); + // return [WHOLE_NUM_PRIMITIVE, numValue]; + // } + // if (block.opcode === 'math_integer') { + // const integerNumField = block.fields.NUM; + // const numValue = (typeof integerNumField.value === 'number') ? + // integerNumField.value : JSON.parse(integerNumField.value); + // return [INTEGER_NUM_PRIMITIVE, numValue]; + // } + // if (block.opcode === 'math_angle') { + // const angleNumField = block.fields.NUM; + // const numValue = (typeof angleNumField.value === 'number') ? + // angleNumField.value : JSON.parse(angleNumField.value); + // return [ANGLE_NUM_PRIMITIVE, numValue]; + // } + // if (block.opcode === 'colour_picker') { + // const colorField = block.fields.COLOUR; // field uses this spelling + // return [COLOR_PICKER_PRIMITIVE, colorField.value]; + // } + // if (block.opcode === 'text') { + // const textField = block.fields.TEXT; + // return [TEXT_PRIMITIVE, textField.value]; + // } + // if (block.opcode === 'event_broadcast_menu') { + // const broadcastField = block.fields.BROADCAST_OPTION; + // return [BROADCAST_PRIMITIVE, broadcastField.value, broadcastField.id]; + // } + // if (block.opcode === 'data_variable') { + // const variableField = block.fields.VARIABLE; + // const varArray = [VAR_PRIMITIVE, variableField.value, variableField.id]; + // if (block.topLevel) { + // varArray.push(block.x ? Math.round(block.x) : 0); + // varArray.push(block.y ? Math.round(block.y) : 0); + // + // } + // return varArray; + // } + // if (block.opcode === 'data_listcontents') { + // const listField = block.fields.LIST; + // const listArray = [LIST_PRIMITIVE, listField.value, listField.id]; + // if (block.topLevel) { + // listArray.push(block.x ? Math.round(block.x) : 0); + // listArray.push(block.y ? Math.round(block.y) : 0); + // + // } + // return listArray; + // } + // // If none of the above, return null + // return null; }; const serializeInputs = function (inputs) { @@ -124,10 +203,10 @@ const serializeBlock = function (block) { if (serializedPrimitive) return serializedPrimitive; // If serializedPrimitive is null, proceed with serializing a non-primitive block const obj = Object.create(null); - // obj.id = block.id; // don't need this, it's the index of this block in its containing object obj.opcode = block.opcode; - if (block.next) obj.next = block.next; // don't serialize next if null - // obj.next = if (block.next; + // NOTE: this is extremely important to serialize even if null; + // not serializing `next: null` results in strange behavior + obj.next = block.next; obj.parent = block.parent; obj.inputs = serializeInputs(block.inputs); obj.fields = serializeFields(block.fields); @@ -172,7 +251,6 @@ const compressInputTree = function (block, blocks) { } } } - // block.inputs = newInputs; return block; }; @@ -247,17 +325,9 @@ const serializeVariables = function (variables) { continue; } - // should be a scalar type - obj.variables[varId] = [v.name]; - let val = v.value; - if ((typeof val !== 'string') && (typeof val !== 'number')) { - log.info(`Variable: ${v.name} had value ${val} of type: ${typeof val} converting to string`); - val = JSON.stringify(val); - } - obj.variables[varId].push(val); - // Some hacked blocks have booleans as variable values - // (typeof v.value === 'string') || (typeof v.value === 'number') ? - // v.value : JSON.stringify(v.value)]; + // otherwise should be a scalar type + obj.variables[varId] = [v.name, v.value]; + // only scalar vars have the potential to be cloud vars if (v.isPersistent) obj.variables[varId].push(true); } return obj; @@ -316,7 +386,154 @@ const serialize = function (runtime) { return obj; }; -const deserializeInputs = function (inputs) { +// Deserializes input descriptors, which is either a block id or a serialized primitive +// (see serializePrimitiveBlock function). +const deserializeInputDesc = function (inputDescOrId, parentId, isShadow, blocks) { + if (!Array.isArray(inputDescOrId)) return inputDescOrId; + const primitiveObj = Object.create(null); + const newId = uid(); + primitiveObj.id = newId; + primitiveObj.next = null; + primitiveObj.parent = parentId; + primitiveObj.shadow = isShadow; + primitiveObj.inputs = Object.create(null); + // need a reference to parent id + switch (inputDescOrId[0]) { + case MATH_NUM_PRIMITIVE: { + primitiveObj.opcode = 'math_number'; + primitiveObj.fields = { + NUM: { + name: 'NUM', + value: inputDescOrId[1] + } + }; + primitiveObj.topLevel = false; + // what should we do about shadows + break; + } + case POSITIVE_NUM_PRIMITIVE: { + primitiveObj.opcode = 'math_positive_number'; + primitiveObj.fields = { + NUM: { + name: 'NUM', + value: inputDescOrId[1] + } + }; + primitiveObj.topLevel = false; + break; + } + case WHOLE_NUM_PRIMITIVE: { + primitiveObj.opcode = 'math_whole_number'; + primitiveObj.fields = { + NUM: { + name: 'NUM', + value: inputDescOrId[1] + } + }; + primitiveObj.topLevel = false; + break; + } + case INTEGER_NUM_PRIMITIVE: { + primitiveObj.opcode = 'math_integer'; + primitiveObj.fields = { + NUM: { + name: 'NUM', + value: inputDescOrId[1] + } + }; + primitiveObj.topLevel = false; + break; + } + case ANGLE_NUM_PRIMITIVE: { + primitiveObj.opcode = 'math_angle'; + primitiveObj.fields = { + NUM: { + name: 'NUM', + value: inputDescOrId[1] + } + }; + primitiveObj.topLevel = false; + break; + } + case COLOR_PICKER_PRIMITIVE: { + primitiveObj.opcode = 'colour_picker'; + primitiveObj.fields = { + COLOUR: { + name: 'COLOUR', + value: inputDescOrId[1] + } + }; + primitiveObj.topLevel = false; + break; + } + case TEXT_PRIMITIVE: { + primitiveObj.opcode = 'text'; + primitiveObj.fields = { + TEXT: { + name: 'TEXT', + value: inputDescOrId[1] + } + }; + primitiveObj.topLevel = false; + break; + } + case BROADCAST_PRIMITIVE: { + primitiveObj.opcode = 'event_broadcast_menu'; + primitiveObj.fields = { + BROADCAST_OPTION: { + name: 'BROADCAST_OPTION', + value: inputDescOrId[1], + id: inputDescOrId[2], + variableType: Variable.BROADCAST_MESSAGE_TYPE + } + }; + primitiveObj.topLevel = false; + break; + } + case VAR_PRIMITIVE: { + primitiveObj.opcode = 'data_variable'; + primitiveObj.fields = { + VARIABLE: { + name: 'VARIABLE', + value: inputDescOrId[1], + id: inputDescOrId[2], + variableType: Variable.SCALAR_TYPE + } + }; + if (inputDescOrId.length > 3) { + primitiveObj.topLevel = true; + primitiveObj.x = inputDescOrId[3]; + primitiveObj.y = inputDescOrId[4]; + } + break; + } + case LIST_PRIMITIVE: { + primitiveObj.opcode = 'data_listcontents'; + primitiveObj.fields = { + LIST: { + name: 'LIST', + value: inputDescOrId[1], + id: inputDescOrId[2], + variableType: Variable.LIST_TYPE + } + }; + if (inputDescOrId.length > 3) { + primitiveObj.topLevel = true; + primitiveObj.x = inputDescOrId[3]; + primitiveObj.y = inputDescOrId[4]; + } + break; + } + default: { + log.error(`Found unknown primitive type during deserialization: ${JSON.stringify(inputDescOrId)}`); + return null; + } + } + blocks[newId] = primitiveObj; + return newId; +}; + +const deserializeInputs = function (inputs, parentId, blocks) { // Explicitly not using Object.create(null) here // because we call prototype functions later in the vm const obj = {}; @@ -328,12 +545,12 @@ const deserializeInputs = function (inputs) { const blockShadowInfo = inputDescArr[0]; if (blockShadowInfo === INPUT_SAME_BLOCK_SHADOW) { // block and shadow are the same id, and only one is provided - block = shadow = inputDescArr[1]; + block = shadow = deserializeInputDesc(inputDescArr[1], parentId, true, blocks); } else if (blockShadowInfo === INPUT_BLOCK_NO_SHADOW) { - block = inputDescArr[1]; + block = deserializeInputDesc(inputDescArr[1], parentId, false, blocks); } else { // assume INPUT_DIFF_BLOCK_SHADOW - block = inputDescArr[1]; - shadow = inputDescArr[2]; + block = deserializeInputDesc(inputDescArr[1], parentId, false, blocks); + shadow = deserializeInputDesc(inputDescArr[2], parentId, true, blocks); } obj[inputName] = { name: inputName, @@ -397,13 +614,26 @@ const parseScratchObject = function (object, runtime, extensions, zip) { for (const blockId in object.blocks) { if (!object.blocks.hasOwnProperty(blockId)) continue; const blockJSON = object.blocks[blockId]; + if (Array.isArray(blockJSON)) { + // this is one of the primitives + // delete the old entry in object.blocks and replace it w/the + // deserialized object + delete object.blocks[blockId]; + deserializeInputDesc(blockJSON, null, false, object.blocks); + continue; + } blockJSON.id = blockId; // add id back to block since it wasn't serialized const serializedInputs = blockJSON.inputs; - const deserializedInputs = deserializeInputs(serializedInputs); + const deserializedInputs = deserializeInputs(serializedInputs, blockId, object.blocks); blockJSON.inputs = deserializedInputs; const serializedFields = blockJSON.fields; const deserializedFields = deserializeFields(serializedFields); blockJSON.fields = deserializedFields; + } + // Take a second pass to create objects and add extensions + for (const blockId in object.blocks) { + if (!object.blocks.hasOwnProperty(blockId)) continue; + const blockJSON = object.blocks[blockId]; blocks.createBlock(blockJSON); const dotIndex = blockJSON.opcode.indexOf('.'); @@ -412,7 +642,6 @@ const parseScratchObject = function (object, runtime, extensions, zip) { extensions.extensionIDs.add(extensionId); } } - // console.log(blocks); } // Costumes from JSON. const costumePromises = (object.costumes || []).map(costumeSource => { From 118c6f03562bbd1664d1b9fb9adba0efede2f206 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 4 Apr 2018 17:31:03 -0400 Subject: [PATCH 04/25] Use bitmap asset type and 'png' for various costume formats. --- src/serialization/deserialize-assets.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/serialization/deserialize-assets.js b/src/serialization/deserialize-assets.js index 37a44e54f..5acbb82ce 100644 --- a/src/serialization/deserialize-assets.js +++ b/src/serialization/deserialize-assets.js @@ -106,14 +106,11 @@ const deserializeCostume = function (costume, runtime, zip, assetFileName) { log.error(`Could not find costume file associated with the ${costume.name} costume.`); return Promise.resolve(null); } - let dataFormat = null; let assetType = null; const costumeFormat = costume.dataFormat.toLowerCase(); if (costumeFormat === 'svg') { - dataFormat = storage.DataFormat.SVG; assetType = storage.AssetType.ImageVector; - } else if (costumeFormat === 'png') { - dataFormat = storage.DataFormat.PNG; + } else if (['png', 'bmp', 'jpeg', 'jpg', 'gif'].indexOf(costumeFormat) >= 0) { assetType = storage.AssetType.ImageBitmap; } else { log.error(`Unexpected file format for costume: ${costumeFormat}`); @@ -126,7 +123,7 @@ const deserializeCostume = function (costume, runtime, zip, assetFileName) { return costumeFile.async('uint8array').then(data => { storage.builtinHelper.cache( assetType, - dataFormat, + costumeFormat, data, assetId ); From 4dd83cf71749796684e5a9d64a060cb31d4108e8 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 4 Apr 2018 17:32:33 -0400 Subject: [PATCH 05/25] Todo comment for clarity... --- src/serialization/deserialize-assets.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/serialization/deserialize-assets.js b/src/serialization/deserialize-assets.js index 5acbb82ce..9fff454b2 100644 --- a/src/serialization/deserialize-assets.js +++ b/src/serialization/deserialize-assets.js @@ -123,6 +123,7 @@ const deserializeCostume = function (costume, runtime, zip, assetFileName) { return costumeFile.async('uint8array').then(data => { storage.builtinHelper.cache( assetType, + // TODO eventually we want to map non-png's to their actual file types? costumeFormat, data, assetId From 80499c9ae5018ac9782203744fefb1a5fdbae7f6 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 4 Apr 2018 18:24:35 -0400 Subject: [PATCH 06/25] Fixing tests --- test/unit/serialization_sb3.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/unit/serialization_sb3.js b/test/unit/serialization_sb3.js index ade46d5b7..42141117d 100644 --- a/test/unit/serialization_sb3.js +++ b/test/unit/serialization_sb3.js @@ -1,11 +1,13 @@ const test = require('tap').test; +const path = require('path'); const VirtualMachine = require('../../src/index'); const sb3 = require('../../src/serialization/sb3'); -const demoSb3 = require('../fixtures/demo.json'); +const extract = require('../fixtures/extract'); +const projectPath = path.resolve(__dirname, '../fixtures/clone-cleanup.sb2'); test('serialize', t => { const vm = new VirtualMachine(); - vm.loadProject(JSON.stringify(demoSb3)) + vm.loadProject(extract(projectPath)) .then(() => { const result = sb3.serialize(vm.runtime); // @todo Analyze From 6c0d257a63e478640ef335889b89f1624faf8c7b Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 4 Apr 2018 18:54:05 -0400 Subject: [PATCH 07/25] Deserialize tempo, volume, and video related state. --- src/serialization/sb3.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 7566d6cfb..24f91c50b 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -345,8 +345,12 @@ const serializeTarget = function (target) { obj.currentCostume = target.currentCostume; obj.costumes = target.costumes.map(serializeCostume); obj.sounds = target.sounds.map(serializeSound); - if (!obj.isStage) { - // Stage does not need the following properties + if (target.hasOwnProperty('volume')) obj.volume = target.volume; + if (obj.isStage) { // Only the stage should have these properties + if (target.hasOwnProperty('tempo')) obj.tempo = target.tempo; + if (target.hasOwnProperty('videoTransparency')) obj.videoTransparency = target.videoTransparency; + if (target.hasOwnProperty('videoState')) obj.videoState = target.videoState; + } else { // The stage does not need the following properties, but sprites should obj.visible = target.visible; obj.x = target.x; obj.y = target.y; @@ -700,6 +704,18 @@ const parseScratchObject = function (object, runtime, extensions, zip) { // Create the first clone, and load its run-state from JSON. const target = sprite.createClone(); // Load target properties from JSON. + if (object.hasOwnProperty('tempo')) { + target.tempo = object.tempo; + } + if (object.hasOwnProperty('volume')) { + target.volume = object.volume; + } + if (object.hasOwnProperty('videoTransparency')) { + target.videoTransparency = object.videoTransparency; + } + if (object.hasOwnProperty('videoState')) { + target.videoState = object.videoState; + } if (object.hasOwnProperty('variables')) { for (const varId in object.variables) { const variable = object.variables[varId]; From b436b5fd82b3ee5f81e846e6b2ed11a28b570622 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 6 Apr 2018 10:35:00 -0400 Subject: [PATCH 08/25] Add a limit to the number of items a list can have. No-op on 'addToList' block if trying to add to a list that is already at the limit. --- src/blocks/scratch3_data.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/blocks/scratch3_data.js b/src/blocks/scratch3_data.js index 0a382e280..ee160281d 100644 --- a/src/blocks/scratch3_data.js +++ b/src/blocks/scratch3_data.js @@ -74,7 +74,7 @@ class Scratch3DataBlocks { addToList (args, util) { const list = util.target.lookupOrCreateList( args.LIST.id, args.LIST.name); - list.value.push(args.ITEM); + if (list.value.length < Scratch3DataBlocks.LIST_ITEM_LIMIT) list.value.push(args.ITEM); } deleteOfList (args, util) { @@ -144,6 +144,14 @@ class Scratch3DataBlocks { } return false; } + + /** + * Type representation for list variables. + * @const {string} + */ + static get LIST_ITEM_LIMIT () { + return 200000; + } } module.exports = Scratch3DataBlocks; From 2dbcdc2244b578bcfccc0001b4b222225ffacbab Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 6 Apr 2018 10:35:42 -0400 Subject: [PATCH 09/25] The stage should always be called 'Stage'. --- src/serialization/sb2.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js index 0fbbf0f10..a0a93edcd 100644 --- a/src/serialization/sb2.js +++ b/src/serialization/sb2.js @@ -215,7 +215,7 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip) const sprite = new Sprite(blocks, runtime); // Sprite/stage name from JSON. if (object.hasOwnProperty('objName')) { - sprite.name = object.objName; + sprite.name = topLevel ? 'Stage' : object.objName; } // Costumes from JSON. const costumePromises = []; From 9892dac904fa153e54d7fb9530e8a73b8cefa171 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 6 Apr 2018 10:37:09 -0400 Subject: [PATCH 10/25] Custom state (e.g. tempo, volume, video related state) should show up in a target that has been 'flattened' via JSON.stringify. --- src/sprites/rendered-target.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/sprites/rendered-target.js b/src/sprites/rendered-target.js index 35204ec93..9bfb1e352 100644 --- a/src/sprites/rendered-target.js +++ b/src/sprites/rendered-target.js @@ -1003,7 +1003,12 @@ class RenderedTarget extends Target { variables: this.variables, lists: this.lists, costumes: costumes, - sounds: this.getSounds() + sounds: this.getSounds(), + tempo: this.tempo, + volume: this.volume, + videoTransparency: this.videoTransparency, + videoState: this.videoState + }; } From 4ec25b32b192524ff791742132599ec397c33104 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 6 Apr 2018 10:37:23 -0400 Subject: [PATCH 11/25] Code cleanup and commenting. --- src/serialization/sb3.js | 196 ++++++++++++++++++++++++--------------- 1 file changed, 119 insertions(+), 77 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 24f91c50b..2ed10984b 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -1,6 +1,6 @@ /** * @fileoverview - * Partial implementation of a SB3 serializer and deserializer. Parses provided + * An SB3 serializer and deserializer. Parses provided * JSON and then generates all needed scratch-vm runtime structures. */ @@ -10,7 +10,6 @@ const Sprite = require('../sprites/sprite'); const Variable = require('../engine/variable'); const log = require('../util/log'); const uid = require('../util/uid'); -// const Cast = require('../util/Cast'); const {loadCostume} = require('../import/load-costume.js'); const {loadSound} = require('../import/load-sound.js'); @@ -28,10 +27,11 @@ const {deserializeCostume, deserializeSound} = require('./deserialize-assets.js' * @property {Map.} extensionURLs - map of ID => URL from project metadata. May not match extensionIDs. */ +// Constants used during serialization and deserialization const INPUT_SAME_BLOCK_SHADOW = 1; // unobscured shadow const INPUT_BLOCK_NO_SHADOW = 2; // no shadow const INPUT_DIFF_BLOCK_SHADOW = 3; // obscured shadow -// haven't found a case where block = null, but shadow is present... +// There shouldn't be a case where block is null, but shadow is present... // Constants referring to 'primitive' blocks that are usually shadows, // or in the case of variables and lists, appear quite often in projects @@ -56,6 +56,8 @@ const VAR_PRIMITIVE = 12; // data_listcontents const LIST_PRIMITIVE = 13; +// Map block opcodes to the above primitives and the name of the field we can use +// to find the value of the field const primitiveOpcodeInfoMap = { math_number: [MATH_NUM_PRIMITIVE, 'NUM'], math_positive_number: [POSITIVE_NUM_PRIMITIVE, 'NUM'], @@ -69,6 +71,12 @@ const primitiveOpcodeInfoMap = { data_listcontents: [LIST_PRIMITIVE, 'LIST'] }; +/** + * Serializes primitives described above into a more compact format + * @param {object} block the block to serialize + * @return {array} An array representing the information in the block, + * or null if the given block is not one of the primitives described above. + */ const serializePrimitiveBlock = function (block) { // Returns an array represeting a primitive block or null if not one of // the primitive types above @@ -90,73 +98,18 @@ const serializePrimitiveBlock = function (block) { return primitiveDesc; } return null; - // if (block.opcode === 'math_number') { - // const numField = block.fields.NUM; - // // const numValue = (typeof numField.value === 'number') ? - // // numField.value : Cast.toNumber(numField.value); - // return [MATH_NUM_PRIMITIVE, numField.value]; - // } - // if (block.opcode === 'math_positive_number') { - // const positiveNumField = block.fields.NUM; - // // TODO should I actually be providing more validation here and ensure that the number is positive? - // // const numValue = (typeof positiveNumField.value === 'number') ? - // // positiveNumField.value : Cast.toNumber(positiveNumField.value); - // return [POSITIVE_NUM_PRIMITIVE, positiveNumField.Value]; - // } - // if (block.opcode === 'math_whole_number') { - // const wholeNumField = block.fields.NUM; - // const numValue = (typeof wholeNumField.value === 'number') ? - // wholeNumField.value : JSON.parse(wholeNumField.value); - // return [WHOLE_NUM_PRIMITIVE, numValue]; - // } - // if (block.opcode === 'math_integer') { - // const integerNumField = block.fields.NUM; - // const numValue = (typeof integerNumField.value === 'number') ? - // integerNumField.value : JSON.parse(integerNumField.value); - // return [INTEGER_NUM_PRIMITIVE, numValue]; - // } - // if (block.opcode === 'math_angle') { - // const angleNumField = block.fields.NUM; - // const numValue = (typeof angleNumField.value === 'number') ? - // angleNumField.value : JSON.parse(angleNumField.value); - // return [ANGLE_NUM_PRIMITIVE, numValue]; - // } - // if (block.opcode === 'colour_picker') { - // const colorField = block.fields.COLOUR; // field uses this spelling - // return [COLOR_PICKER_PRIMITIVE, colorField.value]; - // } - // if (block.opcode === 'text') { - // const textField = block.fields.TEXT; - // return [TEXT_PRIMITIVE, textField.value]; - // } - // if (block.opcode === 'event_broadcast_menu') { - // const broadcastField = block.fields.BROADCAST_OPTION; - // return [BROADCAST_PRIMITIVE, broadcastField.value, broadcastField.id]; - // } - // if (block.opcode === 'data_variable') { - // const variableField = block.fields.VARIABLE; - // const varArray = [VAR_PRIMITIVE, variableField.value, variableField.id]; - // if (block.topLevel) { - // varArray.push(block.x ? Math.round(block.x) : 0); - // varArray.push(block.y ? Math.round(block.y) : 0); - // - // } - // return varArray; - // } - // if (block.opcode === 'data_listcontents') { - // const listField = block.fields.LIST; - // const listArray = [LIST_PRIMITIVE, listField.value, listField.id]; - // if (block.topLevel) { - // listArray.push(block.x ? Math.round(block.x) : 0); - // listArray.push(block.y ? Math.round(block.y) : 0); - // - // } - // return listArray; - // } - // // If none of the above, return null - // return null; }; +/** + * Serializes the inputs field of a block in a compact form using + * constants described above to represent the relationship between the + * inputs of this block (e.g. if there is an unobscured shadow, an obscured shadow + * -- a block plugged into a droppable input -- or, if there is just a block). + * Based on this relationship, serializes the ids of the block and shadow (if present) + * + * @param {object} inputs The inputs to serialize + * @return {object} An object representing the serialized inputs + */ const serializeInputs = function (inputs) { const obj = Object.create(null); for (const inputName in inputs) { @@ -186,6 +139,11 @@ const serializeInputs = function (inputs) { return obj; }; +/** + * Serialize the fields of a block in a more compact form. + * @param {object} fields The fields object to serialize + * @return {object} An object representing the serialized fields + */ const serializeFields = function (fields) { const obj = Object.create(null); for (const fieldName in fields) { @@ -198,6 +156,14 @@ const serializeFields = function (fields) { return obj; }; +/** + * Serialize the given block in the SB3 format with some compression of inputs, + * fields, and primitives. + * @param {object} block The block to serialize + * @return {object | array} A serialized representation of the block. This is an + * array if the block is one of the primitive types described above or an object, + * if not. + */ const serializeBlock = function (block) { const serializedPrimitive = serializePrimitiveBlock(block); if (serializedPrimitive) return serializedPrimitive; @@ -226,10 +192,36 @@ const serializeBlock = function (block) { return obj; }; -// caution this function modifies its inputs directly........... +/** + * Compresses the serialized inputs replacing block/shadow ids that refer to + * one of the primitives with the primitive itself. E.g. + * + * blocks: { + * aUidForMyBlock: { + * inputs: { + * MYINPUT: [1, 'aUidForAnUnobscuredShadowPrimitive'] + * } + * }, + * aUidForAnUnobscuredShadowPrimitive: [4, 10] + * // the above is a primitive representing a 'math_number' with value 10 + * } + * + * becomes: + * + * blocks: { + * aUidForMyBlock: { + * inputs: { + * MYINPUT: [1, [4, 10]] + * } + * } + * } + * Note: this function modifies the given blocks object in place + * @param {object} block The block with inputs to compress + * @param {objec} blocks The object containing all the blocks currently getting serialized + * @return {object} The serialized block with compressed inputs + */ const compressInputTree = function (block, blocks) { - // const newInputs = Object.create(null); - // second pass on the block + // This is the second pass on the block // so the inputs field should be an object of key - array pairs const serializedInputs = block.inputs; for (const inputName in serializedInputs) { @@ -241,12 +233,12 @@ const compressInputTree = function (block, blocks) { for (let i = 1; i < currInput.length; i++) { if (!currInput[i]) continue; // need this check b/c block/shadow can be null const blockOrShadowID = currInput[i]; - // newInputs[inputName][i] = blocks[blockOrShadowID]; // replace element of currInput directly // (modifying input block directly) const blockOrShadow = blocks[blockOrShadowID]; if (Array.isArray(blockOrShadow)) { currInput[i] = blockOrShadow; + // Modifying blocks in place! delete blocks[blockOrShadowID]; } } @@ -254,6 +246,13 @@ const compressInputTree = function (block, blocks) { return block; }; +/** + * Serialize the given blocks object (representing all the blocks for the target + * currently being serialized.) + * @param {object} blocks The blocks to be serialized + * @return {object} The serialized blocks with compressed inputs and compressed + * primitives. + */ const serializeBlocks = function (blocks) { const obj = Object.create(null); for (const blockID in blocks) { @@ -273,6 +272,11 @@ const serializeBlocks = function (blocks) { return obj; }; +/** + * Serialize the given costume. + * @param {object} costume The costume to be serialized. + * @return {object} A serialized representation of the costume. + */ const serializeCostume = function (costume) { const obj = Object.create(null); obj.assetId = costume.assetId; @@ -290,6 +294,11 @@ const serializeCostume = function (costume) { return obj; }; +/** + * Serialize the given sound. + * @param {object} sound The sound to be serialized. + * @return {object} A serialized representation of the sound. + */ const serializeSound = function (sound) { const obj = Object.create(null); obj.assetId = sound.assetId; @@ -307,6 +316,13 @@ const serializeSound = function (sound) { return obj; }; +/** + * Serialize the given variables object. + * @param {object} variables The variables to be serialized. + * @return {object} A serialized representation of the variables. They get + * separated by type to compress the representation of each given variable and + * reduce duplicate information. + */ const serializeVariables = function (variables) { const obj = Object.create(null); // separate out variables into types at the top level so we don't have @@ -333,10 +349,16 @@ const serializeVariables = function (variables) { return obj; }; +/** + * Serialize the given target. Only serialize properties that are necessary + * for saving and loading this target. + * @param {object} target The target to be serialized. + * @return {object} A serialized representation of the given target. + */ const serializeTarget = function (target) { const obj = Object.create(null); obj.isStage = target.isStage; - obj.name = target.name; + obj.name = obj.isStage ? 'Stage' : target.name; const vars = serializeVariables(target.variables); obj.variables = vars.variables; obj.lists = vars.lists; @@ -365,7 +387,7 @@ const serializeTarget = function (target) { /** * Serializes the specified VM runtime. * @param {!Runtime} runtime VM runtime instance to be serialized. - * @return {object} Serialized runtime instance. + * @return {object} Serialized runtime instance. */ const serialize = function (runtime) { // Fetch targets @@ -390,8 +412,16 @@ const serialize = function (runtime) { return obj; }; -// Deserializes input descriptors, which is either a block id or a serialized primitive -// (see serializePrimitiveBlock function). +/** + * Deserialize a block input descriptors. This is either a + * block id or a serialized primitive, e.g. an array + * (see serializePrimitiveBlock function). + * @param {string | array} inputDescOrId The block input descriptor to be serialized. + * @param {string} parentId The id of the parent block for this input block. + * @param {boolean} isShadow Whether or not this input block is a shadow. + * @param {object} blocks The entire blocks object currently in the process of getting serialized. + * @return {object} The deserialized input descriptor. + */ const deserializeInputDesc = function (inputDescOrId, parentId, isShadow, blocks) { if (!Array.isArray(inputDescOrId)) return inputDescOrId; const primitiveObj = Object.create(null); @@ -537,6 +567,14 @@ const deserializeInputDesc = function (inputDescOrId, parentId, isShadow, blocks return newId; }; +/** + * Deserialize the given block inputs. + * @param {object} inputs The inputs to deserialize. + * @param {string} parentId The block id of the parent block + * @param {object} blocks The object representing the entire set of blocks currently + * in the process of getting deserialized. + * @return {object} The deserialized and uncompressed inputs. + */ const deserializeInputs = function (inputs, parentId, blocks) { // Explicitly not using Object.create(null) here // because we call prototype functions later in the vm @@ -565,6 +603,11 @@ const deserializeInputs = function (inputs, parentId, blocks) { return obj; }; +/** + * Deserialize the given block fields. + * @param {object} fields The fields to be deserialized + * @return {object} The deserialized and uncompressed block fields. + */ const deserializeFields = function (fields) { // Explicitly not using Object.create(null) here // because we call prototype functions later in the vm @@ -802,7 +845,6 @@ const parseScratchObject = function (object, runtime, extensions, zip) { /** * Deserialize the specified representation of a VM runtime and loads it into the provided runtime instance. - * TODO: parse extension info (also, design extension info storage...) * @param {object} json - JSON representation of a VM runtime. * @param {Runtime} runtime - Runtime instance * @param {JSZip} zip - Sb3 file describing this project (to load assets from) From 8c1e6095cb88043b04c1be0adae41c0372476944 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 6 Apr 2018 11:31:52 -0400 Subject: [PATCH 12/25] Remove code that was temporarily accomodating for the old version of the default project in gui. --- src/serialization/sb3.js | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 2ed10984b..a80a04649 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -762,24 +762,13 @@ const parseScratchObject = function (object, runtime, extensions, zip) { if (object.hasOwnProperty('variables')) { for (const varId in object.variables) { const variable = object.variables[varId]; - let newVariable; - if (Array.isArray(variable)) { - newVariable = new Variable( - varId, // var id is the index of the variable desc array in the variables obj - variable[0], // name of the variable - Variable.SCALAR_TYPE, // type of the variable - (variable.length === 3) ? variable[2] : false // isPersistent/isCloud - ); - newVariable.value = variable[1]; - } else { - newVariable = new Variable( - variable.id, - variable.name, - variable.type, - variable.isPersistent - ); - newVariable.value = variable.value; - } + const newVariable = new Variable( + varId, // var id is the index of the variable desc array in the variables obj + variable[0], // name of the variable + Variable.SCALAR_TYPE, // type of the variable + (variable.length === 3) ? variable[2] : false // isPersistent/isCloud + ); + newVariable.value = variable[1]; target.variables[newVariable.id] = newVariable; } } From 58c9b11dd3cd887c6ee734b9be8bcacb8cc9daef Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 6 Apr 2018 11:38:41 -0400 Subject: [PATCH 13/25] Update test to use new function. --- test/unit/serialization_sb3.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/serialization_sb3.js b/test/unit/serialization_sb3.js index 42141117d..a3be33a7a 100644 --- a/test/unit/serialization_sb3.js +++ b/test/unit/serialization_sb3.js @@ -2,12 +2,12 @@ const test = require('tap').test; const path = require('path'); const VirtualMachine = require('../../src/index'); const sb3 = require('../../src/serialization/sb3'); -const extract = require('../fixtures/extract'); +const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer; const projectPath = path.resolve(__dirname, '../fixtures/clone-cleanup.sb2'); test('serialize', t => { const vm = new VirtualMachine(); - vm.loadProject(extract(projectPath)) + vm.loadProject(readFileToBuffer(projectPath)) .then(() => { const result = sb3.serialize(vm.runtime); // @todo Analyze From 5d46f8de99daf7f40e1842a39e2caac59d637fe3 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 6 Apr 2018 15:04:03 -0400 Subject: [PATCH 14/25] Not providing a zip to deserialize asset functions is not an error. --- src/serialization/deserialize-assets.js | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/serialization/deserialize-assets.js b/src/serialization/deserialize-assets.js index 9fff454b2..29cae9109 100644 --- a/src/serialization/deserialize-assets.js +++ b/src/serialization/deserialize-assets.js @@ -30,11 +30,7 @@ const deserializeSound = function (sound, runtime, zip, assetFileName) { // This sound has already been cached. return Promise.resolve(null); } - if (!zip) { - // TODO adding this case to make integration tests pass, need to rethink - // the entire structure of saving/loading here (w.r.t. differences between - // loading from local zip file or from server) - log.error('Zipped assets were not provided.'); + if (!zip) { // Zip will not be provided if loading project json from server return Promise.resolve(null); } const soundFile = zip.file(fileName); @@ -93,11 +89,7 @@ const deserializeCostume = function (costume, runtime, zip, assetFileName) { return Promise.resolve(null); } - if (!zip) { - // TODO adding this case to make integration tests pass, need to rethink - // the entire structure of saving/loading here (w.r.t. differences between - // loading from local zip file or from server) - log.error('Zipped assets were not provided.'); + if (!zip) { // Zip will not be provided if loading project json from server return Promise.resolve(null); } From 44b1a2f7981f753f42110147674035bdb1b11d34 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 6 Apr 2018 15:31:10 -0400 Subject: [PATCH 15/25] List limit should apply to insert block as well. --- src/blocks/scratch3_data.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/blocks/scratch3_data.js b/src/blocks/scratch3_data.js index ee160281d..e91fbe943 100644 --- a/src/blocks/scratch3_data.js +++ b/src/blocks/scratch3_data.js @@ -98,7 +98,14 @@ class Scratch3DataBlocks { if (index === Cast.LIST_INVALID) { return; } + const listLimit = Scratch3DataBlocks.LIST_ITEM_LIMIT; + if (index > listLimit) return; list.value.splice(index - 1, 0, item); + if (list.value.length > listLimit) { + // If inserting caused the list to grow larger than the limit, + // remove the last element in the list + list.value.pop(); + } } replaceItemOfList (args, util) { From e92db8fa1e19ab11d372d233c70ffa008560f16e Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 6 Apr 2018 15:47:24 -0400 Subject: [PATCH 16/25] Don't really need level 9 compression. --- src/virtual-machine.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 79ff6f6f1..cafa35ceb 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -245,7 +245,6 @@ class VirtualMachine extends EventEmitter { const zip = new JSZip(); // Put everything in a zip file - // TODO compression? zip.file('project.json', projectJson); for (let i = 0; i < soundDescs.length; i++) { const currSound = soundDescs[i]; @@ -260,7 +259,7 @@ class VirtualMachine extends EventEmitter { type: 'blob', compression: 'DEFLATE', compressionOptions: { - level: 9 // best compression (level 1 would be best speed) + level: 6 // Tradeoff between best speed (1) and best compression (9) } }); } From 1861b5a262b9186ee7dddd8409a0981e33d86ae8 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 6 Apr 2018 16:51:46 -0400 Subject: [PATCH 17/25] Can't have hyphens in identifiers. --- src/sprites/rendered-target.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sprites/rendered-target.js b/src/sprites/rendered-target.js index 9bfb1e352..f648a6bde 100644 --- a/src/sprites/rendered-target.js +++ b/src/sprites/rendered-target.js @@ -217,7 +217,7 @@ class RenderedTarget extends Target { return { 'OFF': 'off', 'ON': 'on', - 'ON-FLIPPED': 'on-flipped' + 'ON_FLIPPED': 'on_flipped' }; } From 9ce6040b0fdfc83babcefbd86305034ad90181b6 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 6 Apr 2018 16:57:52 -0400 Subject: [PATCH 18/25] Fix lint error... --- src/sprites/rendered-target.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sprites/rendered-target.js b/src/sprites/rendered-target.js index f648a6bde..72540c34a 100644 --- a/src/sprites/rendered-target.js +++ b/src/sprites/rendered-target.js @@ -215,9 +215,9 @@ class RenderedTarget extends Target { */ static get VIDEO_STATE () { return { - 'OFF': 'off', - 'ON': 'on', - 'ON_FLIPPED': 'on_flipped' + OFF: 'off', + ON: 'on', + ON_FLIPPED: 'on_flipped' }; } From 2f53996c9c9e25d8e395e819a7d43c0bcfa35d41 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 10 Apr 2018 15:43:29 -0400 Subject: [PATCH 19/25] Switch video flipped string back to hyphen. --- src/sprites/rendered-target.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sprites/rendered-target.js b/src/sprites/rendered-target.js index 72540c34a..a7dc43e69 100644 --- a/src/sprites/rendered-target.js +++ b/src/sprites/rendered-target.js @@ -217,7 +217,7 @@ class RenderedTarget extends Target { return { OFF: 'off', ON: 'on', - ON_FLIPPED: 'on_flipped' + ON_FLIPPED: 'on-flipped' }; } From 18c1a20eaa23a6a29094b8cab1902ca0e4b69301 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 10 Apr 2018 15:44:32 -0400 Subject: [PATCH 20/25] Remove unnecessary comments. --- src/serialization/sb3.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index a80a04649..018ac94e1 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -177,7 +177,7 @@ const serializeBlock = function (block) { obj.inputs = serializeInputs(block.inputs); obj.fields = serializeFields(block.fields); obj.topLevel = block.topLevel ? block.topLevel : false; - obj.shadow = block.shadow; // I think we don't need this either.. + obj.shadow = block.shadow; if (block.topLevel) { if (block.x) { obj.x = Math.round(block.x); @@ -442,7 +442,6 @@ const deserializeInputDesc = function (inputDescOrId, parentId, isShadow, blocks } }; primitiveObj.topLevel = false; - // what should we do about shadows break; } case POSITIVE_NUM_PRIMITIVE: { From 0df937f5c06fd97d3cea2e9750144b20d0743aae Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 10 Apr 2018 16:53:07 -0400 Subject: [PATCH 21/25] Adding a bit more clarification to comment. --- src/serialization/sb3.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 018ac94e1..cb021326e 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -171,7 +171,8 @@ const serializeBlock = function (block) { const obj = Object.create(null); obj.opcode = block.opcode; // NOTE: this is extremely important to serialize even if null; - // not serializing `next: null` results in strange behavior + // not serializing `next: null` results in strange behavior with block + // execution obj.next = block.next; obj.parent = block.parent; obj.inputs = serializeInputs(block.inputs); From 585a46f334b615dc5b71c2d50882fb815585f191 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 10 Apr 2018 16:55:27 -0400 Subject: [PATCH 22/25] Add one last pass to block serialization to remove orphan primitive shadow blocks. --- src/serialization/sb3.js | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index cb021326e..8a786c484 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -261,15 +261,32 @@ const serializeBlocks = function (blocks) { obj[blockID] = serializeBlock(blocks[blockID], blocks); } // once we have completed a first pass, do a second pass on block inputs - for (const serializedBlockId in obj) { + for (const blockID in obj) { // don't need to do the hasOwnProperty check here since we // created an object that doesn't get extra properties/functions - const serializedBlock = obj[serializedBlockId]; + const serializedBlock = obj[blockID]; // caution, this function deletes parts of this object in place as - // it's traversing it (we could do a third pass...) - obj[serializedBlockId] = compressInputTree(serializedBlock, obj); + // it's traversing it + obj[blockID] = compressInputTree(serializedBlock, obj); // second pass on connecting primitives to serialized inputs directly } + // Do one last pass and remove any top level shadows (these are caused by + // a bug: LLK/scratch-vm#1011, and this pass should be removed once that is + // completely fixed) + for (const blockID in obj) { + const serializedBlock = obj[blockID]; + // If the current block is serialized as a primitive (e.g. it's an array + // instead of an object), AND it is not one of the top level primitives + // e.g. variable getter or list getter, then it should be deleted as it's + // a shadow block, and there are no blocks that reference it, otherwise + // they would have been compressed in the last pass) + if (Array.isArray(serializedBlock) && + [VAR_PRIMITIVE, LIST_PRIMITIVE].indexOf(serializedBlock) < 0) { + log.warn(`Found an unexpected top level primitive with block ID: ${ + blockID}; deleting it from serialized blocks.`); + delete obj[blockID]; + } + } return obj; }; From 6f3c84bf77989564c93dc6a3a5e41b66cb04b3e5 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 10 Apr 2018 17:01:31 -0400 Subject: [PATCH 23/25] Ensure we are serializing file extensions for costumes and sounds as lower case. --- src/serialization/sb3.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 8a786c484..7b56c0beb 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -306,7 +306,7 @@ const serializeCostume = function (costume) { // but that change should be made carefully since it is very // pervasive obj.md5ext = costume.md5; - obj.dataFormat = costume.dataFormat; + obj.dataFormat = costume.dataFormat.toLowerCase(); obj.rotationCenterX = costume.rotationCenterX; obj.rotationCenterY = costume.rotationCenterY; return obj; @@ -321,7 +321,7 @@ const serializeSound = function (sound) { const obj = Object.create(null); obj.assetId = sound.assetId; obj.name = sound.name; - obj.dataFormat = sound.dataFormat; + obj.dataFormat = sound.dataFormat.toLowerCase(); obj.format = sound.format; obj.rate = sound.rate; obj.sampleCount = sound.sampleCount; From 6c538d06593841eea224dc080e5983cd9101fdd6 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 11 Apr 2018 10:28:22 -0400 Subject: [PATCH 24/25] Sounds should still get loaded from storage even if there is not an audio engine present. This is analagous to costumes getting loaded even if there is no renderer present. --- src/import/load-sound.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/import/load-sound.js b/src/import/load-sound.js index e4c7009b9..c14e5276d 100644 --- a/src/import/load-sound.js +++ b/src/import/load-sound.js @@ -12,6 +12,10 @@ const log = require('../util/log'); */ const loadSoundFromAsset = function (sound, soundAsset, runtime) { sound.assetId = soundAsset.assetId; + if (!runtime.audioEngine) { + log.error('No audio engine present; cannot load sound asset: ', sound.md5); + return Promise.resolve(sound); + } return runtime.audioEngine.decodeSound(Object.assign( {}, sound, @@ -35,10 +39,6 @@ const loadSound = function (sound, runtime) { log.error('No storage module present; cannot load sound asset: ', sound.md5); return Promise.resolve(sound); } - if (!runtime.audioEngine) { - log.error('No audio engine present; cannot load sound asset: ', sound.md5); - return Promise.resolve(sound); - } const idParts = StringUtil.splitFirst(sound.md5, '.'); const md5 = idParts[0]; const ext = idParts[1].toLowerCase(); From 7fd6dca374db24d6695144f1ee6593fb311fd998 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 11 Apr 2018 19:09:50 -0400 Subject: [PATCH 25/25] Pin scratch-parser to an actual version instead of latest. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cc35511c6..6f71422b2 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "promise": "8.0.1", "scratch-audio": "latest", "scratch-blocks": "latest", - "scratch-parser": "latest", + "scratch-parser": "^3.0.0", "scratch-render": "latest", "scratch-storage": "^0.4.0", "script-loader": "0.7.2",