From 86a8d93eb6323a02b86e41d6340e55a37ecf9a96 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 19 Jun 2018 08:51:16 -0400 Subject: [PATCH 01/10] Sprite3 Export/Import. --- src/serialization/sb3.js | 18 ++++++++++---- src/serialization/serialize-assets.js | 20 +++++++++------- src/virtual-machine.js | 34 ++++++++++++++++++++------- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 4f129c17..1c02c2ae 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -446,15 +446,25 @@ const serializeTarget = function (target) { /** * Serializes the specified VM runtime. - * @param {!Runtime} runtime VM runtime instance to be serialized. + * @param {!Runtime} runtime VM runtime instance to be serialized. + * @param {string=} targetId Optional target id if serializing only a single target * @return {object} Serialized runtime instance. */ -const serialize = function (runtime) { +const serialize = function (runtime, targetId) { // Fetch targets const obj = Object.create(null); - const flattenedOriginalTargets = JSON.parse(JSON.stringify( + const flattenedOriginalTargets = JSON.parse(JSON.stringify(targetId ? + [runtime.getTargetById(targetId)] : runtime.targets.filter(target => target.isOriginal))); - obj.targets = flattenedOriginalTargets.map(t => serializeTarget(t, runtime)); + + const serializedTargets = flattenedOriginalTargets.map(t => serializeTarget(t, runtime)); + + if (targetId) { + return serializedTargets[0]; + } + + obj.targets = serializedTargets; + // TODO Serialize monitors diff --git a/src/serialization/serialize-assets.js b/src/serialization/serialize-assets.js index 9dc7bf0d..ac444f11 100644 --- a/src/serialization/serialize-assets.js +++ b/src/serialization/serialize-assets.js @@ -5,10 +5,11 @@ * to be written and the contents of the file, the serialized asset. * @param {Runtime} runtime The runtime with the assets to be serialized * @param {string} assetType The type of assets to be serialized: 'sounds' | 'costumes' + * @param {string=} optTargetId Optional target id to serialize assets for * @returns {Array} An array of file descriptors for each asset */ -const serializeAssets = function (runtime, assetType) { - const targets = runtime.targets; +const serializeAssets = function (runtime, assetType, optTargetId) { + const targets = optTargetId ? [runtime.getTargetById(optTargetId)] : runtime.targets; const assetDescs = []; for (let i = 0; i < targets.length; i++) { const currTarget = targets[i]; @@ -27,14 +28,16 @@ const serializeAssets = function (runtime, assetType) { }; /** - * Serialize all the sounds in the provided runtime into an array of file - * descriptors. A file descriptor is an object containing the name of the file + * Serialize all the sounds in the provided runtime or, if a target id is provided, + * in the specified target into an array of file descriptors. + * A file descriptor is an object containing the name of the file * to be written and the contents of the file, the serialized sound. * @param {Runtime} runtime The runtime with the sounds to be serialized + * @param {string=} optTargetId Optional targetid for serializing sounds of a single target * @returns {Array} An array of file descriptors for each sound */ -const serializeSounds = function (runtime) { - return serializeAssets(runtime, 'sounds'); +const serializeSounds = function (runtime, optTargetId) { + return serializeAssets(runtime, 'sounds', optTargetId); }; /** @@ -42,10 +45,11 @@ const serializeSounds = function (runtime) { * descriptors. A file descriptor is an object containing the name of the file * to be written and the contents of the file, the serialized costume. * @param {Runtime} runtime The runtime with the costumes to be serialized + * @param {string} optTargetId Optional targetid for serializing costumes of a single target * @returns {Array} An array of file descriptors for each costume */ -const serializeCostumes = function (runtime) { - return serializeAssets(runtime, 'costumes'); +const serializeCostumes = function (runtime, optTargetId) { + return serializeAssets(runtime, 'costumes', optTargetId); }; module.exports = { diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 4ff368fa..378f9021 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -263,14 +263,7 @@ class VirtualMachine extends EventEmitter { // Put everything in a zip file zip.file('project.json', projectJson); - for (let i = 0; i < soundDescs.length; i++) { - const currSound = soundDescs[i]; - zip.file(currSound.fileName, currSound.fileContent); - } - for (let i = 0; i < costumeDescs.length; i++) { - const currCostume = costumeDescs[i]; - zip.file(currCostume.fileName, currCostume.fileContent); - } + this._addFileDescsToZip(soundDescs.concat(costumeDescs), zip); return zip.generateAsync({ type: 'blob', @@ -281,6 +274,31 @@ class VirtualMachine extends EventEmitter { }); } + _addFileDescsToZip (fileDescs, zip) { + for (let i = 0; i < fileDescs.length; i++) { + const currFileDesc = fileDescs[i]; + zip.file(currFileDesc.fileName, currFileDesc.fileContent); + } + } + + exportSprite (targetId) { + const soundDescs = serializeSounds(this.runtime, targetId); + const costumeDescs = serializeCostumes(this.runtime, targetId); + const spriteJson = JSON.stringify(sb3.serialize(this.runtime, targetId)); + + const zip = new JSZip(); + zip.file('sprite.json', spriteJson); + this._addFileDescsToZip(soundDescs.concat(costumeDescs), zip); + + return zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { + level: 6 + } + }); + } + /** * Export project as a Scratch 3.0 JSON representation. * @return {string} Serialized state of the runtime. From 5cc48b6ef368bcc9f118cd64767e716810c6852a Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 19 Jun 2018 08:52:22 -0400 Subject: [PATCH 02/10] Provide global vars to sb2 import when deserializing a single sprite. --- src/serialization/sb2.js | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js index 9ca378d9..76826768 100644 --- a/src/serialization/sb2.js +++ b/src/serialization/sb2.js @@ -189,14 +189,19 @@ const parseScripts = function (scripts, blocks, addBroadcastMsg, getVariableId, * Create a callback for assigning fixed IDs to imported variables * Generator stores the global variable mapping in a closure * @param {!string} targetId the id of the target to scope the variable to + * @param {object=} initialVars Initial vars in scope if parsing a sprite * @return {string} variable ID */ const generateVariableIdGetter = (function () { let globalVariableNameMap = {}; const namer = (targetId, name) => `${targetId}-${name}`; - return function (targetId, topLevel) { + return function (targetId, topLevel, initialVars) { // Reset the global variable map if topLevel - if (topLevel) globalVariableNameMap = {}; + if (topLevel) { + globalVariableNameMap = {}; + } else if (initialVars) { + globalVariableNameMap = initialVars; + } return function (name) { if (topLevel) { // Store the name/id pair in the globalVariableNameMap globalVariableNameMap[name] = namer(targetId, name); @@ -334,9 +339,10 @@ const parseMonitorObject = (object, runtime, targets, extensions) => { * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. * @param {boolean} topLevel - Whether this is the top-level object (stage). * @param {?object} zip - Optional zipped assets for local file import + * @param {object=} initialVarScope - Initial set of variables in scope if parsing a single sprite * @return {!Promise.>} Promise for the loaded targets when ready, or null for unsupported objects. */ -const parseScratchObject = function (object, runtime, extensions, topLevel, zip) { +const parseScratchObject = function (object, runtime, extensions, topLevel, zip, initialVarScope) { if (!object.hasOwnProperty('objName')) { if (object.hasOwnProperty('listName')) { // Shim these objects so they can be processed as monitors @@ -427,7 +433,7 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip) // Create the first clone, and load its run-state from JSON. const target = sprite.createClone(topLevel ? StageLayering.BACKGROUND_LAYER : StageLayering.SPRITE_LAYER); - const getVariableId = generateVariableIdGetter(target.id, topLevel); + const getVariableId = generateVariableIdGetter(target.id, topLevel, initialVarScope); const globalBroadcastMsgObj = globalBroadcastMsgStateGenerator(topLevel); const addBroadcastMsg = globalBroadcastMsgObj.broadcastMsgMapUpdater; @@ -666,7 +672,19 @@ const sb2import = function (json, runtime, optForceSprite, zip) { extensionIDs: new Set(), extensionURLs: new Map() }; - return parseScratchObject(json, runtime, extensions, !optForceSprite, zip) + + const initialGlobalVars = {}; + if (optForceSprite && runtime.targets && runtime.getTargetForStage()) { + const stageVars = runtime.getTargetForStage().variables; + for (const varId in stageVars) { + const currVar = stageVars[varId]; + initialGlobalVars[currVar.name] = varId; + } + } + + // If parsing a sprite, use the current global vars as parent scope for the sprite + return parseScratchObject(json, runtime, extensions, !optForceSprite, zip, + (optForceSprite ? initialGlobalVars : null)) .then(targets => ({ targets, extensions From f2aacbc79c1f30808d325db06c35ac8d1409d7fa Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 19 Jun 2018 14:02:54 -0400 Subject: [PATCH 03/10] Fix up variable references after deserializing a .sprite2 or .sprite3 to avoid conflicts with pre-existing variables. --- src/engine/blocks.js | 64 +++++++++++++++++++++++++++++++ src/engine/target.js | 81 ++++++++++++++++++++++++++++++++++++---- src/serialization/sb2.js | 34 ++++++----------- src/serialization/sb3.js | 16 ++++++-- 4 files changed, 162 insertions(+), 33 deletions(-) diff --git a/src/engine/blocks.js b/src/engine/blocks.js index 13122c1f..30f43a2a 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -6,6 +6,8 @@ const Clone = require('../util/clone'); const {Map} = require('immutable'); const BlocksExecuteCache = require('./blocks-execute-cache'); const log = require('../util/log'); +const Variable = require('./variable'); +const StringUtil = require('../util/string-util'); /** * @fileoverview @@ -670,6 +672,68 @@ class Blocks { this.resetCache(); } + /** + * Fixes up variable references in this blocks container avoiding conflicts with + * pre-existing variables in the same scope. + * This is used when uploading a new sprite (the given target) + * into an existing project, where the new sprite may contain references + * to variable names that already exist as global variables in the project + * (and thus are in scope for variable references in the given sprite). + * + * If the given target has a block that references an existing global variable and that + * variable *does not* exist in the target itself (e.g. it was a global variable in the + * project the sprite was originally exported from), fix the variable references in this sprite + * to reference the id of the pre-existing global variable. + * If the given target has a block that references an existing global variable and that + * variable does exist in the target itself (e.g. it's a local variable in the sprite being uploaded), + * then the variable is renamed to distinguish itself from the pre-existing variable. + * All blocks that reference the local variable will be updated to use the new name. + * @param {Target} target The new target being uploaded, with potential variable conflicts + * @param {Runtime} runtime The runtime context with any pre-existing variables + */ + fixUpVariableReferences (target, runtime) { + const blocks = this._blocks; + for (const blockId in blocks) { + let varOrListField = null; + let varType = null; + if (blocks[blockId].fields.VARIABLE) { + varOrListField = blocks[blockId].fields.VARIABLE; + varType = Variable.SCALAR_TYPE; + } else if (blocks[blockId].fields.LIST) { + varOrListField = blocks[blockId].fields.LIST; + varType = Variable.LIST_TYPE; + } + if (varOrListField) { + const currVarId = varOrListField.id; + const currVarName = varOrListField.value; + if (target.lookupVariableById(currVarId)) { + // Found a variable with the id in either the target or the stage, + // figure out which one. + if (target.variables.hasOwnProperty(currVarId)) { + // If the target has the variable, then check whether the stage + // has one with the same name and type. If it does, then rename + // this target specific variable so that there is a distinction. + const stage = runtime.getTargetForStage(); + if (stage && stage.variables && + stage.lookupVariableByNameAndType(currVarName, varType)) { + // TODO what should the new name be? + const newName = StringUtil.unusedName( + `${target.getName()}_${currVarName}`, + target.getAllVariableNamesInScopeByType(varType)); + target.renameVariable(currVarId, newName); + this.updateBlocksAfterVarRename(currVarId, newName); + } + } + } else { + const existingVar = target.lookupVariableByNameAndType(currVarName, varType); + if (existingVar) { + varOrListField.id = existingVar.id; + } + } + } + } + } + /** * Keep blocks up to date after a variable gets renamed. * @param {string} varId The id of the variable that was renamed diff --git a/src/engine/target.js b/src/engine/target.js index a8c6828e..c62eb373 100644 --- a/src/engine/target.js +++ b/src/engine/target.js @@ -80,14 +80,40 @@ class Target extends EventEmitter { } /** - * Look up a variable object, and create it if one doesn't exist. + * Get the names of all the variables of the given type that are in scope for this target. + * For targets that are not the stage, this includes any target-specific + * variables as well as any stage variables. + * For the stage, this is all stage variables. + * @param {string} type The variable type to search for; defaults to Variable.SCALAR_TYPE + * @return {Array} A list of variable names + */ + getAllVariableNamesInScopeByType (type) { + if (typeof type !== 'string') type = Variable.SCALAR_TYPE; + const targetVariables = Object.values(this.variables) + .filter(v => v.type === type) + .map(variable => variable.name); + if (this.isStage || !this.runtime) { + return targetVariables; + } + const stage = this.runtime.getTargetForStage(); + const stageVariables = stage.getAllVariableNamesInScopeByType(type); + return targetVariables.concat(stageVariables); + } + + /** + * Look up a variable object, first by id, and then by name if the id is not found. + * Create a new variable if both lookups fail. * @param {string} id Id of the variable. * @param {string} name Name of the variable. * @return {!Variable} Variable object. */ lookupOrCreateVariable (id, name) { - const variable = this.lookupVariableById(id); + let variable = this.lookupVariableById(id); if (variable) return variable; + + variable = this.lookupVariableByNameAndType(name, Variable.SCALAR_TYPE); + if (variable) return variable; + // No variable with this name exists - create it locally. const newVariable = new Variable(id, name, Variable.SCALAR_TYPE, false); this.variables[id] = newVariable; @@ -161,6 +187,40 @@ class Target extends EventEmitter { } } + /** + * Look up a variable object by its name and variable type. + * Search begins with local variables; then global variables if a local one + * was not found. + * @param {string} name Name of the variable. + * @param {string} type Type of the variable. Defaults to Variable.SCALAR_TYPE. + * @return {?Variable} Variable object if found, or null if not. + */ + lookupVariableByNameAndType (name, type) { + if (typeof name !== 'string') return; + if (typeof type !== 'string') type = Variable.SCALAR_TYPE; + + for (const varId in this.variables) { + const currVar = this.variables[varId]; + if (currVar.name === name && currVar.type === type) { + return currVar; + } + } + + if (this.runtime && !this.isStage) { + const stage = this.runtime.getTargetForStage(); + if (stage) { + for (const varId in stage.variables) { + const currVar = stage.variables[varId]; + if (currVar.name === name && currVar.type === type) { + return currVar; + } + } + } + } + + return null; + } + /** * Look up a list object for this target, and create it if one doesn't exist. * Search begins for local lists; then look for globals. @@ -169,8 +229,12 @@ class Target extends EventEmitter { * @return {!Varible} Variable object representing the found/created list. */ lookupOrCreateList (id, name) { - const list = this.lookupVariableById(id); + let list = this.lookupVariableById(id); if (list) return list; + + list = this.lookupVariableByNameAndType(name, Variable.LIST_TYPE); + if (list) return list; + // No variable with this name exists - create it locally. const newList = new Variable(id, name, Variable.LIST_TYPE, false); this.variables[id] = newList; @@ -240,10 +304,13 @@ class Target extends EventEmitter { name: 'VARIABLE', value: id }, this.runtime); - this.runtime.requestUpdateMonitor(Map({ - id: id, - params: blocks._getBlockParams(blocks.getBlock(variable.id)) - })); + const monitorBlock = blocks.getBlock(variable.id); + if (monitorBlock) { + this.runtime.requestUpdateMonitor(Map({ + id: id, + params: blocks._getBlockParams(monitorBlock) + })); + } } } diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js index 76826768..afe615df 100644 --- a/src/serialization/sb2.js +++ b/src/serialization/sb2.js @@ -189,19 +189,14 @@ const parseScripts = function (scripts, blocks, addBroadcastMsg, getVariableId, * Create a callback for assigning fixed IDs to imported variables * Generator stores the global variable mapping in a closure * @param {!string} targetId the id of the target to scope the variable to - * @param {object=} initialVars Initial vars in scope if parsing a sprite * @return {string} variable ID */ const generateVariableIdGetter = (function () { let globalVariableNameMap = {}; const namer = (targetId, name) => `${targetId}-${name}`; - return function (targetId, topLevel, initialVars) { + return function (targetId, topLevel) { // Reset the global variable map if topLevel - if (topLevel) { - globalVariableNameMap = {}; - } else if (initialVars) { - globalVariableNameMap = initialVars; - } + if (topLevel) globalVariableNameMap = {}; return function (name) { if (topLevel) { // Store the name/id pair in the globalVariableNameMap globalVariableNameMap[name] = namer(targetId, name); @@ -339,10 +334,9 @@ const parseMonitorObject = (object, runtime, targets, extensions) => { * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. * @param {boolean} topLevel - Whether this is the top-level object (stage). * @param {?object} zip - Optional zipped assets for local file import - * @param {object=} initialVarScope - Initial set of variables in scope if parsing a single sprite * @return {!Promise.>} Promise for the loaded targets when ready, or null for unsupported objects. */ -const parseScratchObject = function (object, runtime, extensions, topLevel, zip, initialVarScope) { +const parseScratchObject = function (object, runtime, extensions, topLevel, zip) { if (!object.hasOwnProperty('objName')) { if (object.hasOwnProperty('listName')) { // Shim these objects so they can be processed as monitors @@ -433,7 +427,7 @@ const parseScratchObject = function (object, runtime, extensions, topLevel, zip, // Create the first clone, and load its run-state from JSON. const target = sprite.createClone(topLevel ? StageLayering.BACKGROUND_LAYER : StageLayering.SPRITE_LAYER); - const getVariableId = generateVariableIdGetter(target.id, topLevel, initialVarScope); + const getVariableId = generateVariableIdGetter(target.id, topLevel); const globalBroadcastMsgObj = globalBroadcastMsgStateGenerator(topLevel); const addBroadcastMsg = globalBroadcastMsgObj.broadcastMsgMapUpdater; @@ -673,18 +667,14 @@ const sb2import = function (json, runtime, optForceSprite, zip) { extensionURLs: new Map() }; - const initialGlobalVars = {}; - if (optForceSprite && runtime.targets && runtime.getTargetForStage()) { - const stageVars = runtime.getTargetForStage().variables; - for (const varId in stageVars) { - const currVar = stageVars[varId]; - initialGlobalVars[currVar.name] = varId; - } - } - - // If parsing a sprite, use the current global vars as parent scope for the sprite - return parseScratchObject(json, runtime, extensions, !optForceSprite, zip, - (optForceSprite ? initialGlobalVars : null)) + return parseScratchObject(json, runtime, extensions, !optForceSprite, zip) + .then(targets => { + if (optForceSprite && targets.length === 1) { + const target = targets[0]; + target.blocks.fixUpVariableReferences(target, runtime); + } + return targets; + }) .then(targets => ({ targets, extensions diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 1c02c2ae..d63fa204 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -936,10 +936,18 @@ const deserialize = function (json, runtime, zip, isSingleSprite) { return Promise.all( ((isSingleSprite ? [json] : json.targets) || []).map(target => parseScratchObject(target, runtime, extensions, zip)) - ).then(targets => ({ - targets, - extensions - })); + ) + .then(targets => { + if (isSingleSprite && targets.length === 1) { + const target = targets[0]; + target.blocks.fixUpVariableReferences(target, runtime); + } + return targets; + }) + .then(targets => ({ + targets, + extensions + })); }; module.exports = { From 9646e3d11e363e5db08777dc4ce14f62ddbe87f8 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 19 Jun 2018 15:31:08 -0400 Subject: [PATCH 04/10] Refactor and move variable reference fixup function into target. --- src/engine/blocks.js | 59 +++++++++++------------------------- src/engine/target.js | 65 ++++++++++++++++++++++++++++++++++++++++ src/serialization/sb2.js | 2 +- src/serialization/sb3.js | 2 +- 4 files changed, 84 insertions(+), 44 deletions(-) diff --git a/src/engine/blocks.js b/src/engine/blocks.js index 30f43a2a..0a675022 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -7,7 +7,6 @@ const {Map} = require('immutable'); const BlocksExecuteCache = require('./blocks-execute-cache'); const log = require('../util/log'); const Variable = require('./variable'); -const StringUtil = require('../util/string-util'); /** * @fileoverview @@ -673,26 +672,15 @@ class Blocks { } /** - * Fixes up variable references in this blocks container avoiding conflicts with - * pre-existing variables in the same scope. - * This is used when uploading a new sprite (the given target) - * into an existing project, where the new sprite may contain references - * to variable names that already exist as global variables in the project - * (and thus are in scope for variable references in the given sprite). - * - * If the given target has a block that references an existing global variable and that - * variable *does not* exist in the target itself (e.g. it was a global variable in the - * project the sprite was originally exported from), fix the variable references in this sprite - * to reference the id of the pre-existing global variable. - * If the given target has a block that references an existing global variable and that - * variable does exist in the target itself (e.g. it's a local variable in the sprite being uploaded), - * then the variable is renamed to distinguish itself from the pre-existing variable. - * All blocks that reference the local variable will be updated to use the new name. - * @param {Target} target The new target being uploaded, with potential variable conflicts - * @param {Runtime} runtime The runtime context with any pre-existing variables + * Returns a map of all references to variables or lists from blocks + * in this block container. + * @return {object} A map of variable ID to a list of all variable references + * for that ID. A variable reference contains the field referencing that variable + * and also the type of the variable being referenced. */ - fixUpVariableReferences (target, runtime) { + getAllVariableAndListReferences () { const blocks = this._blocks; + const allReferences = Object.create(null); for (const blockId in blocks) { let varOrListField = null; let varType = null; @@ -705,33 +693,20 @@ class Blocks { } if (varOrListField) { const currVarId = varOrListField.id; - const currVarName = varOrListField.value; - if (target.lookupVariableById(currVarId)) { - // Found a variable with the id in either the target or the stage, - // figure out which one. - if (target.variables.hasOwnProperty(currVarId)) { - // If the target has the variable, then check whether the stage - // has one with the same name and type. If it does, then rename - // this target specific variable so that there is a distinction. - const stage = runtime.getTargetForStage(); - if (stage && stage.variables && - stage.lookupVariableByNameAndType(currVarName, varType)) { - // TODO what should the new name be? - const newName = StringUtil.unusedName( - `${target.getName()}_${currVarName}`, - target.getAllVariableNamesInScopeByType(varType)); - target.renameVariable(currVarId, newName); - this.updateBlocksAfterVarRename(currVarId, newName); - } - } + if (allReferences[currVarId]) { + allReferences[currVarId].push({ + referencingField: varOrListField, + type: varType + }); } else { - const existingVar = target.lookupVariableByNameAndType(currVarName, varType); - if (existingVar) { - varOrListField.id = existingVar.id; - } + allReferences[currVarId] = [{ + referencingField: varOrListField, + type: varType + }]; } } } + return allReferences; } /** diff --git a/src/engine/target.js b/src/engine/target.js index c62eb373..e456a88b 100644 --- a/src/engine/target.js +++ b/src/engine/target.js @@ -6,6 +6,7 @@ const Comment = require('../engine/comment'); const uid = require('../util/uid'); const {Map} = require('immutable'); const log = require('../util/log'); +const StringUtil = require('../util/string-util'); /** * @fileoverview @@ -331,6 +332,70 @@ class Target extends EventEmitter { } } + /** + * Fixes up variable references in this target avoiding conflicts with + * pre-existing variables in the same scope. + * This is used when uploading this target as a new sprite into an existing + * project, where the new sprite may contain references + * to variable names that already exist as global variables in the project + * (and thus are in scope for variable references in the given sprite). + * + * If the given target has a block that references an existing global variable and that + * variable *does not* exist in the target itself (e.g. it was a global variable in the + * project the sprite was originally exported from), fix the variable references in this sprite + * to reference the id of the pre-existing global variable. + * If the given target has a block that references an existing global variable and that + * variable does exist in the target itself (e.g. it's a local variable in the sprite being uploaded), + * then the variable is renamed to distinguish itself from the pre-existing variable. + * All blocks that reference the local variable will be updated to use the new name. + // * @param {Target} target The new target being uploaded, with potential variable conflicts + // * @param {Runtime} runtime The runtime context with any pre-existing variables + */ + fixUpVariableReferences () { + if (!this.runtime) return; // There's no runtime context to conflict with + if (this.isStage) return; // Stage can't have variable conflicts with itself (and also can't be uploaded) + + const allReferences = this.blocks.getAllVariableAndListReferences(); + const conflictIdsToReplace = Object.create(null); + for (const varId in allReferences) { + // We don't care about which var ref we get, they should all have the same var info + const varRef = allReferences[varId][0]; + const varName = varRef.referencingField.value; + const varType = varRef.type; + if (this.lookupVariableById(varId)) { + // Found a variable with the id in either the target or the stage, + // figure out which one. + if (this.variables.hasOwnProperty(varId)) { + // If the target has the variable, then check whether the stage + // has one with the same name and type. If it does, then rename + // this target specific variable so that there is a distinction. + const stage = this.runtime.getTargetForStage(); + if (stage && stage.variables && + stage.lookupVariableByNameAndType(varName, varType)) { + // TODO what should the new name be? + const newName = StringUtil.unusedName( + `${this.getName()}_${varName}`, + this.getAllVariableNamesInScopeByType(varType)); + this.renameVariable(varId, newName); + this.blocks.updateBlocksAfterVarRename(varId, newName); + } + } + } else { + const existingVar = this.lookupVariableByNameAndType(varName, varType); + if (existingVar && !conflictIdsToReplace[varId]) { + conflictIdsToReplace[varId] = existingVar.id; + } + } + } + for (const conflictId in conflictIdsToReplace) { + const existingId = conflictIdsToReplace[conflictId]; + allReferences[conflictId].map(varRef => { + varRef.referencingField.id = existingId; + return varRef; + }); + } + } + /** * Post/edit sprite info. * @param {object} data An object with sprite info data to set. diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js index afe615df..60978eea 100644 --- a/src/serialization/sb2.js +++ b/src/serialization/sb2.js @@ -671,7 +671,7 @@ const sb2import = function (json, runtime, optForceSprite, zip) { .then(targets => { if (optForceSprite && targets.length === 1) { const target = targets[0]; - target.blocks.fixUpVariableReferences(target, runtime); + target.fixUpVariableReferences(); } return targets; }) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index d63fa204..fb4cbeff 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -940,7 +940,7 @@ const deserialize = function (json, runtime, zip, isSingleSprite) { .then(targets => { if (isSingleSprite && targets.length === 1) { const target = targets[0]; - target.blocks.fixUpVariableReferences(target, runtime); + target.fixUpVariableReferences(); } return targets; }) From 073bb37c30ac13b655a3dbe449a4565f789d9695 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 20 Jun 2018 10:32:36 -0400 Subject: [PATCH 05/10] Add a test for 'getAllVariableAndListReferences' function. --- test/fixtures/events.json | 12 ++++++++++++ test/unit/engine_blocks.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/test/fixtures/events.json b/test/fixtures/events.json index a5a126f0..dbddd277 100644 --- a/test/fixtures/events.json +++ b/test/fixtures/events.json @@ -90,5 +90,17 @@ "type": "comment_create", "commentId": "a comment", "xy": {"x": 10, "y": 20} + }, + "mockVariableBlock": { + "name": "block", + "xml": { + "outerHTML": "a mock variable" + } + }, + "mockListBlock": { + "name": "block", + "xml": { + "outerHTML": "a mock list" + } } } diff --git a/test/unit/engine_blocks.js b/test/unit/engine_blocks.js index 08b0b414..5659b687 100644 --- a/test/unit/engine_blocks.js +++ b/test/unit/engine_blocks.js @@ -1,5 +1,8 @@ const test = require('tap').test; const Blocks = require('../../src/engine/blocks'); +const Variable = require('../../src/engine/variable'); +const adapter = require('../../src/engine/adapter'); +const events = require('../fixtures/events.json'); test('spec', t => { const b = new Blocks(); @@ -776,3 +779,32 @@ test('updateTargetSpecificBlocks changes sprite clicked hat to stage clicked for t.end(); }); + +test('getAllVariableAndListReferences returns an empty map references when variable blocks do not exist', t => { + const b = new Blocks(); + t.equal(Object.keys(b.getAllVariableAndListReferences()).length, 0); + t.end(); +}); + +test('getAllVariableAndListReferences returns references when variable blocks exist', t => { + const b = new Blocks(); + + let varListRefs = b.getAllVariableAndListReferences(); + t.equal(Object.keys(varListRefs).length, 0); + + b.createBlock(adapter(events.mockVariableBlock)[0]); + b.createBlock(adapter(events.mockListBlock)[0]); + + varListRefs = b.getAllVariableAndListReferences(); + t.equal(Object.keys(varListRefs).length, 2); + t.equal(Array.isArray(varListRefs['mock var id']), true); + t.equal(varListRefs['mock var id'].length, 1); + t.equal(varListRefs['mock var id'][0].type, Variable.SCALAR_TYPE); + t.equal(varListRefs['mock var id'][0].referencingField.value, 'a mock variable'); + t.equal(Array.isArray(varListRefs['mock list id']), true); + t.equal(varListRefs['mock list id'].length, 1); + t.equal(varListRefs['mock list id'][0].type, Variable.LIST_TYPE); + t.equal(varListRefs['mock list id'][0].referencingField.value, 'a mock list'); + + t.end(); +}); From 889443fcef313f4dfa053085f403b3f28024fb2a Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 20 Jun 2018 12:07:42 -0400 Subject: [PATCH 06/10] fixUpVariableReferences should handle conflicting local variables that don't have blocks referencing them. Add unit tests for the function. --- src/engine/target.js | 51 ++++++++++-- test/unit/engine_target.js | 165 ++++++++++++++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 10 deletions(-) diff --git a/src/engine/target.js b/src/engine/target.js index e456a88b..b5d94eb5 100644 --- a/src/engine/target.js +++ b/src/engine/target.js @@ -354,8 +354,29 @@ class Target extends EventEmitter { fixUpVariableReferences () { if (!this.runtime) return; // There's no runtime context to conflict with if (this.isStage) return; // Stage can't have variable conflicts with itself (and also can't be uploaded) + const stage = this.runtime.getTargetForStage(); + if (!stage || !stage.variables) return; + + const renameConflictingLocalVar = (id, name, type) => { + const conflict = stage.lookupVariableByNameAndType(name, type); + if (conflict) { + const newName = StringUtil.unusedName( + `${this.getName()}: ${name}`, + this.getAllVariableNamesInScopeByType(type)); + this.renameVariable(id, newName); + return newName; + } + return null; + }; const allReferences = this.blocks.getAllVariableAndListReferences(); + const unreferencedLocalVarIds = []; + if (Object.keys(this.variables).length > 0) { + for (const localVarId in this.variables) { + if (!this.variables.hasOwnProperty(localVarId)) continue; + if (!allReferences[localVarId]) unreferencedLocalVarIds.push(localVarId); + } + } const conflictIdsToReplace = Object.create(null); for (const varId in allReferences) { // We don't care about which var ref we get, they should all have the same var info @@ -369,15 +390,16 @@ class Target extends EventEmitter { // If the target has the variable, then check whether the stage // has one with the same name and type. If it does, then rename // this target specific variable so that there is a distinction. - const stage = this.runtime.getTargetForStage(); - if (stage && stage.variables && - stage.lookupVariableByNameAndType(varName, varType)) { - // TODO what should the new name be? - const newName = StringUtil.unusedName( - `${this.getName()}_${varName}`, - this.getAllVariableNamesInScopeByType(varType)); - this.renameVariable(varId, newName); - this.blocks.updateBlocksAfterVarRename(varId, newName); + const newVarName = renameConflictingLocalVar(varId, varName, varType); + + if (newVarName) { + // We are not calling this.blocks.updateBlocksAfterVarRename + // here because it will search through all the blocks. We already + // have access to all the references for this var id. + allReferences[varId].map(ref => { + ref.referencingField.value = newVarName; + return ref; + }); } } } else { @@ -387,6 +409,17 @@ class Target extends EventEmitter { } } } + // Rename any local variables that were missed above because they aren't + // referenced by any blocks + for (const id in unreferencedLocalVarIds) { + const varId = unreferencedLocalVarIds[id]; + const name = this.variables[varId].name; + const type = this.variables[varId].type; + renameConflictingLocalVar(varId, name, type); + } + // Finally, handle global var conflicts (e.g. a sprite is uploaded, and has + // blocks referencing some variable that the sprite does not own, and this + // variable conflicts with a global var) for (const conflictId in conflictIdsToReplace) { const existingId = conflictIdsToReplace[conflictId]; allReferences[conflictId].map(varRef => { diff --git a/test/unit/engine_target.js b/test/unit/engine_target.js index a92dd5e6..c39154cd 100644 --- a/test/unit/engine_target.js +++ b/test/unit/engine_target.js @@ -1,6 +1,9 @@ const test = require('tap').test; const Target = require('../../src/engine/target'); const Variable = require('../../src/engine/variable'); +const adapter = require('../../src/engine/adapter'); +const Runtime = require('../../src/engine/runtime'); +const events = require('../fixtures/events.json'); test('spec', t => { const target = new Target(); @@ -145,7 +148,7 @@ test('deleteVariable2', t => { t.end(); }); -test('lookupOrCreateList creates a list if var with given id does not exist', t => { +test('lookupOrCreateList creates a list if var with given id or var with given name does not exist', t => { const target = new Target(); const variables = target.variables; @@ -174,6 +177,22 @@ test('lookupOrCreateList returns list if one with given id exists', t => { t.end(); }); +test('lookupOrCreateList succeeds in finding list if id is incorrect but name matches', t => { + const target = new Target(); + const variables = target.variables; + + t.equal(Object.keys(variables).length, 0); + target.createVariable('foo', 'bar', Variable.LIST_TYPE); + t.equal(Object.keys(variables).length, 1); + + const listVar = target.lookupOrCreateList('not foo', 'bar'); + t.equal(Object.keys(variables).length, 1); + t.equal(listVar.id, 'foo'); + t.equal(listVar.name, 'bar'); + + t.end(); +}); + test('lookupBroadcastMsg returns the var with given id if exists', t => { const target = new Target(); const variables = target.variables; @@ -263,3 +282,147 @@ test('creating a comment with a blockId also updates the comment property on the t.end(); }); + +test('fixUpVariableReferences fixes sprite global var conflicting with project global var', t => { + const runtime = new Runtime(); + + const stage = new Target(runtime); + stage.isStage = true; + + const target = new Target(runtime); + target.isStage = false; + + runtime.targets = [stage, target]; + + // Create a global variable + stage.createVariable('pre-existing global var id', 'a mock variable', Variable.SCALAR_TYPE); + + target.blocks.createBlock(adapter(events.mockVariableBlock)[0]); + + t.equal(Object.keys(target.variables).length, 0); + t.equal(Object.keys(stage.variables).length, 1); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); + + target.fixUpVariableReferences(target, runtime); + + t.equal(Object.keys(target.variables).length, 0); + t.equal(Object.keys(stage.variables).length, 1); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'pre-existing global var id'); + + t.end(); +}); + +test('fixUpVariableReferences fixes sprite local var conflicting with project global var', t => { + const runtime = new Runtime(); + + const stage = new Target(runtime); + stage.isStage = true; + + const target = new Target(runtime); + target.isStage = false; + target.getName = () => 'Target'; + + runtime.targets = [stage, target]; + + // Create a global variable + stage.createVariable('pre-existing global var id', 'a mock variable', Variable.SCALAR_TYPE); + target.createVariable('mock var id', 'a mock variable', Variable.SCALAR_TYPE); + + target.blocks.createBlock(adapter(events.mockVariableBlock)[0]); + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 1); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); + t.equal(target.variables['mock var id'].name, 'a mock variable'); + + target.fixUpVariableReferences(target, runtime); + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 1); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); + t.equal(target.variables['mock var id'].name, 'Target: a mock variable'); + + t.end(); +}); + +test('fixUpVariableReferences fixes conflicting sprite local var without blocks referencing var', t => { + const runtime = new Runtime(); + + const stage = new Target(runtime); + stage.isStage = true; + + const target = new Target(runtime); + target.isStage = false; + target.getName = () => 'Target'; + + runtime.targets = [stage, target]; + + // Create a global variable + stage.createVariable('pre-existing global var id', 'a mock variable', Variable.SCALAR_TYPE); + target.createVariable('mock var id', 'a mock variable', Variable.SCALAR_TYPE); + + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 1); + t.equal(target.variables['mock var id'].name, 'a mock variable'); + + target.fixUpVariableReferences(target, runtime); + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 1); + t.equal(target.variables['mock var id'].name, 'Target: a mock variable'); + + t.end(); +}); + +test('fixUpVariableReferences does not change variable name if there is no variable conflict', t => { + const runtime = new Runtime(); + + const stage = new Target(runtime); + stage.isStage = true; + + const target = new Target(runtime); + target.isStage = false; + target.getName = () => 'Target'; + + runtime.targets = [stage, target]; + + // Create a global variable + stage.createVariable('pre-existing global var id', 'a variable', Variable.SCALAR_TYPE); + stage.createVariable('pre-existing global list id', 'a mock variable', Variable.LIST_TYPE); + target.createVariable('mock var id', 'a mock variable', Variable.SCALAR_TYPE); + + target.blocks.createBlock(adapter(events.mockVariableBlock)[0]); + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 2); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); + t.equal(target.variables['mock var id'].name, 'a mock variable'); + + target.fixUpVariableReferences(target, runtime); + + t.equal(Object.keys(target.variables).length, 1); + t.equal(Object.keys(stage.variables).length, 2); + t.type(target.blocks.getBlock('a block'), 'object'); + t.type(target.blocks.getBlock('a block').fields, 'object'); + t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); + t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); + t.equal(target.variables['mock var id'].name, 'a mock variable'); + + t.end(); +}); From b75a77954040b24e99acfe1f9938b479fdaaba5f Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 20 Jun 2018 12:12:33 -0400 Subject: [PATCH 07/10] Call fixUpVariableReferences in installTargets, before emitting the workspace update. --- src/serialization/sb2.js | 7 ------- src/serialization/sb3.js | 7 ------- src/virtual-machine.js | 4 ++++ 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js index 60978eea..a1533b67 100644 --- a/src/serialization/sb2.js +++ b/src/serialization/sb2.js @@ -668,13 +668,6 @@ const sb2import = function (json, runtime, optForceSprite, zip) { }; return parseScratchObject(json, runtime, extensions, !optForceSprite, zip) - .then(targets => { - if (optForceSprite && targets.length === 1) { - const target = targets[0]; - target.fixUpVariableReferences(); - } - return targets; - }) .then(targets => ({ targets, extensions diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index fb4cbeff..0ecff4ed 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -937,13 +937,6 @@ const deserialize = function (json, runtime, zip, isSingleSprite) { ((isSingleSprite ? [json] : json.targets) || []).map(target => parseScratchObject(target, runtime, extensions, zip)) ) - .then(targets => { - if (isSingleSprite && targets.length === 1) { - const target = targets[0]; - target.fixUpVariableReferences(); - } - return targets; - }) .then(targets => ({ targets, extensions diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 378f9021..b0571e52 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -386,6 +386,10 @@ class VirtualMachine extends EventEmitter { this.editingTarget = targets[0]; } + if (!wholeProject) { + this.editingTarget.fixUpVariableReferences(); + } + // Update the VM user's knowledge of targets and blocks on the workspace. this.emitTargetsUpdate(); this.emitWorkspaceUpdate(); From b40ac54fdd5b0f862fade0c5741b8d5276b6638e Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 20 Jun 2018 13:37:31 -0400 Subject: [PATCH 08/10] Remove stray newline. --- src/serialization/sb2.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js index a1533b67..9ca378d9 100644 --- a/src/serialization/sb2.js +++ b/src/serialization/sb2.js @@ -666,7 +666,6 @@ const sb2import = function (json, runtime, optForceSprite, zip) { extensionIDs: new Set(), extensionURLs: new Map() }; - return parseScratchObject(json, runtime, extensions, !optForceSprite, zip) .then(targets => ({ targets, From f06b8a39432f2181c0a23950d757dbc68c458f84 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 25 Jun 2018 09:11:48 -0400 Subject: [PATCH 09/10] Clean up calls to fixUpVariableReferences --- src/engine/target.js | 2 -- test/unit/engine_target.js | 8 ++++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/engine/target.js b/src/engine/target.js index b5d94eb5..69620c91 100644 --- a/src/engine/target.js +++ b/src/engine/target.js @@ -348,8 +348,6 @@ class Target extends EventEmitter { * variable does exist in the target itself (e.g. it's a local variable in the sprite being uploaded), * then the variable is renamed to distinguish itself from the pre-existing variable. * All blocks that reference the local variable will be updated to use the new name. - // * @param {Target} target The new target being uploaded, with potential variable conflicts - // * @param {Runtime} runtime The runtime context with any pre-existing variables */ fixUpVariableReferences () { if (!this.runtime) return; // There's no runtime context to conflict with diff --git a/test/unit/engine_target.js b/test/unit/engine_target.js index c39154cd..098908e1 100644 --- a/test/unit/engine_target.js +++ b/test/unit/engine_target.js @@ -306,7 +306,7 @@ test('fixUpVariableReferences fixes sprite global var conflicting with project g t.type(target.blocks.getBlock('a block').fields.VARIABLE, 'object'); t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); - target.fixUpVariableReferences(target, runtime); + target.fixUpVariableReferences(); t.equal(Object.keys(target.variables).length, 0); t.equal(Object.keys(stage.variables).length, 1); @@ -344,7 +344,7 @@ test('fixUpVariableReferences fixes sprite local var conflicting with project gl t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); t.equal(target.variables['mock var id'].name, 'a mock variable'); - target.fixUpVariableReferences(target, runtime); + target.fixUpVariableReferences(); t.equal(Object.keys(target.variables).length, 1); t.equal(Object.keys(stage.variables).length, 1); @@ -378,7 +378,7 @@ test('fixUpVariableReferences fixes conflicting sprite local var without blocks t.equal(Object.keys(stage.variables).length, 1); t.equal(target.variables['mock var id'].name, 'a mock variable'); - target.fixUpVariableReferences(target, runtime); + target.fixUpVariableReferences(); t.equal(Object.keys(target.variables).length, 1); t.equal(Object.keys(stage.variables).length, 1); @@ -414,7 +414,7 @@ test('fixUpVariableReferences does not change variable name if there is no varia t.equal(target.blocks.getBlock('a block').fields.VARIABLE.id, 'mock var id'); t.equal(target.variables['mock var id'].name, 'a mock variable'); - target.fixUpVariableReferences(target, runtime); + target.fixUpVariableReferences(); t.equal(Object.keys(target.variables).length, 1); t.equal(Object.keys(stage.variables).length, 2); From 55a5592ab6fda4f3c769d6173f69ad9101e6550e Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 25 Jun 2018 09:23:04 -0400 Subject: [PATCH 10/10] Add optional type param to export sprite API. --- src/virtual-machine.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/virtual-machine.js b/src/virtual-machine.js index b0571e52..7218d443 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -281,7 +281,19 @@ class VirtualMachine extends EventEmitter { } } - exportSprite (targetId) { + /** + * Exports a sprite in the sprite3 format. + * @param {string} targetId ID of the target to export + * @param {string=} optZipType Optional type that the resulting + * zip should be outputted in. Options are: base64, binarystring, + * array, uint8array, arraybuffer, blob, or nodebuffer. Defaults to + * blob if argument not provided. + * See https://stuk.github.io/jszip/documentation/api_jszip/generate_async.html#type-option + * for more information about these options. + * @return {object} A generated zip of the sprite and its assets in the format + * specified by optZipType or blob by default. + */ + exportSprite (targetId, optZipType) { const soundDescs = serializeSounds(this.runtime, targetId); const costumeDescs = serializeCostumes(this.runtime, targetId); const spriteJson = JSON.stringify(sb3.serialize(this.runtime, targetId)); @@ -291,7 +303,7 @@ class VirtualMachine extends EventEmitter { this._addFileDescsToZip(soundDescs.concat(costumeDescs), zip); return zip.generateAsync({ - type: 'blob', + type: typeof optZipType === 'string' ? optZipType : 'blob', compression: 'DEFLATE', compressionOptions: { level: 6