diff --git a/package.json b/package.json
index 136318cec..68d1ba16d 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,7 @@
     "gh-pages": "1.2.0",
     "in-publish": "2.0.1",
     "jsdoc": "3.6.6",
+    "js-md5": "0.7.3",
     "json": "^9.0.4",
     "lodash.defaultsdeep": "4.6.1",
     "pngjs": "3.3.3",
diff --git a/src/import/load-costume.js b/src/import/load-costume.js
index b3b73c591..58f3cacf9 100644
--- a/src/import/load-costume.js
+++ b/src/import/load-costume.js
@@ -250,9 +250,9 @@ const loadBitmap_ = function (costume, runtime, _rotationCenter) {
 // Handle all manner of costume errors with a Gray Question Mark (default costume)
 // and preserve as much of the original costume data as possible
 // Returns a promise of a costume
-const handleCostumeLoadError = function (costume, runtime) {       
-    // Keep track of the old assetId until we're done loading the default costume
-    // const oldAsset = costume.asset; // could be null
+const handleCostumeLoadError = function (costume, runtime) {
+    // Keep track of the old asset information until we're done loading the default costume
+    const oldAsset = costume.asset; // could be null
     const oldAssetId = costume.assetId;
     const oldRotationX = costume.rotationCenterX;
     const oldRotationY = costume.rotationCenterY;
@@ -270,8 +270,10 @@ const handleCostumeLoadError = function (costume, runtime) {
         loadedCostume.broken = {};
         loadedCostume.broken.assetId = oldAssetId;
         loadedCostume.broken.md5 = `${oldAssetId}.${costume.dataFormat}`;
+
         // Should be null if we got here because the costume was missing
-        loadedCostume.broken.asset = runtime.storage.get(oldAssetId);
+        loadedCostume.broken.asset = oldAsset;
+        
         loadedCostume.broken.rotationCenterX = oldRotationX;
         loadedCostume.broken.rotationCenterY = oldRotationY;
         return loadedCostume;
@@ -310,7 +312,7 @@ const loadCostumeFromAsset = function (costume, runtime, optVersion) {
     if (costume.asset.assetType.runtimeFormat === AssetType.ImageVector.runtimeFormat) {
         return loadVector_(costume, runtime, rotationCenter, optVersion)
             .catch(error => {
-                log.warn(`Error loading vector image: ${error.name}: ${error.message}`);
+                log.warn(`Error loading vector image: ${error}`);
                 return handleCostumeLoadError(costume, runtime);
                 
             });
diff --git a/src/virtual-machine.js b/src/virtual-machine.js
index 9abbf6791..3e67ec91e 100644
--- a/src/virtual-machine.js
+++ b/src/virtual-machine.js
@@ -427,11 +427,9 @@ class VirtualMachine extends EventEmitter {
      * specified by optZipType or blob by default.
      */
     exportSprite (targetId, optZipType) {
-        const sb3 = require('./serialization/sb3');
-
         const soundDescs = serializeSounds(this.runtime, targetId);
         const costumeDescs = serializeCostumes(this.runtime, targetId);
-        const spriteJson = StringUtil.stringify(sb3.serialize(this.runtime, targetId));
+        const spriteJson = this.toJSON(targetId);
 
         const zip = new JSZip();
         zip.file('sprite.json', spriteJson);
@@ -448,12 +446,13 @@ class VirtualMachine extends EventEmitter {
     }
 
     /**
-     * Export project as a Scratch 3.0 JSON representation.
+     * Export project or sprite as a Scratch 3.0 JSON representation.
+     * @param {string=} optTargetId - Optional id of a sprite to serialize
      * @return {string} Serialized state of the runtime.
      */
-    toJSON () {
+    toJSON (optTargetId) {
         const sb3 = require('./serialization/sb3');
-        return StringUtil.stringify(sb3.serialize(this.runtime));
+        return StringUtil.stringify(sb3.serialize(this.runtime, optTargetId));
     }
 
     // TODO do we still need this function? Keeping it here so as not to introduce
diff --git a/test/fixtures/corrupt_svg.sb3 b/test/fixtures/corrupt_svg.sb3
new file mode 100644
index 000000000..52fb93096
Binary files /dev/null and b/test/fixtures/corrupt_svg.sb3 differ
diff --git a/test/fixtures/corrupt_svg.sprite3 b/test/fixtures/corrupt_svg.sprite3
new file mode 100644
index 000000000..1d9dd0b3d
Binary files /dev/null and b/test/fixtures/corrupt_svg.sprite3 differ
diff --git a/test/fixtures/default.sb3 b/test/fixtures/default.sb3
new file mode 100644
index 000000000..61b718389
Binary files /dev/null and b/test/fixtures/default.sb3 differ
diff --git a/test/fixtures/fake-renderer.js b/test/fixtures/fake-renderer.js
index dd0868767..f8343692e 100644
--- a/test/fixtures/fake-renderer.js
+++ b/test/fixtures/fake-renderer.js
@@ -4,6 +4,19 @@ const FakeRenderer = function () {
     this.y = 0;
     this.order = 0;
     this.spriteCount = 5;
+    this._nextSkinId = -1;
+};
+
+FakeRenderer.prototype.createSVGSkin = function () {
+    return this._nextSkinId++;
+};
+
+FakeRenderer.prototype.getSkinSize = function (d) { // eslint-disable-line no-unused-vars
+    return [0, 0];
+};
+
+FakeRenderer.prototype.getSkinRotationCenter = function (d) { // eslint-disable-line no-unused-vars
+    return [0, 0];
 };
 
 FakeRenderer.prototype.createDrawable = function () {
diff --git a/test/fixtures/make-test-storage.js b/test/fixtures/make-test-storage.js
index 0179ca34a..1401a2c1e 100644
--- a/test/fixtures/make-test-storage.js
+++ b/test/fixtures/make-test-storage.js
@@ -39,8 +39,8 @@ const getAssetUrl = function (asset) {
 const makeTestStorage = function () {
     const storage = new ScratchStorage();
     const AssetType = storage.AssetType;
-    storage.addWebSource([AssetType.Project], getProjectUrl);
-    storage.addWebSource([AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], getAssetUrl);
+    storage.addWebStore([AssetType.Project], getProjectUrl);
+    storage.addWebStore([AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], getAssetUrl);
     return storage;
 };
 
diff --git a/test/fixtures/missing_svg.sb3 b/test/fixtures/missing_svg.sb3
new file mode 100644
index 000000000..e0956c36f
Binary files /dev/null and b/test/fixtures/missing_svg.sb3 differ
diff --git a/test/fixtures/missing_svg.sprite3 b/test/fixtures/missing_svg.sprite3
new file mode 100644
index 000000000..5cc098689
Binary files /dev/null and b/test/fixtures/missing_svg.sprite3 differ
diff --git a/test/fixtures/readProjectFile.js b/test/fixtures/readProjectFile.js
index 8340ba228..254862cea 100644
--- a/test/fixtures/readProjectFile.js
+++ b/test/fixtures/readProjectFile.js
@@ -12,5 +12,10 @@ module.exports = {
             return JSON.parse(zip.readAsText(projectEntry.entryName, 'utf8'));
         }
         return null;
+    },
+    extractAsset: function (path, assetFileName) {
+        const zip = new AdmZip(path);
+        const assetEntry = zip.getEntries().filter(item => item.entryName.match(assetFileName))[0];
+        return assetEntry.getData();
     }
 };
diff --git a/test/integration/sb3_corrupted_svg.js b/test/integration/sb3_corrupted_svg.js
new file mode 100644
index 000000000..281c1c604
--- /dev/null
+++ b/test/integration/sb3_corrupted_svg.js
@@ -0,0 +1,107 @@
+/**
+ * This test mocks render breaking on loading a corrupted vector costume.
+ * The VM should handle this safely by displaying a Gray Question Mark,
+ * but keeping track of the original costume data and serializing the
+ * original costume data back out. The saved project.json should not
+ * reflect that the costume is broken and should therefore re-attempt
+ * to load the costume if the saved project is re-loaded.
+ */
+const path = require('path');
+const tap = require('tap');
+const md5 = require('js-md5');
+const makeTestStorage = require('../fixtures/make-test-storage');
+const FakeRenderer = require('../fixtures/fake-renderer');
+const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
+const VirtualMachine = require('../../src/index');
+const {serializeCostumes} = require('../../src/serialization/serialize-assets');
+
+const projectUri = path.resolve(__dirname, '../fixtures/corrupt_svg.sb3');
+const project = readFileToBuffer(projectUri);
+const costumeFileName = 'a267f8b97ee9cf8aa9832aa0b4cfd9eb.svg';
+const originalCostume = extractAsset(projectUri, costumeFileName);
+// We need to get the actual md5 because we hand modified the svg to corrupt it
+// after we downloaded the project from Scratch
+// Loading the project back into the VM will correct the assetId and md5
+const brokenCostumeMd5 = md5(originalCostume);
+
+let vm;
+let defaultVectorAssetId;
+
+tap.beforeEach(() => {
+    const storage = makeTestStorage();
+
+    vm = new VirtualMachine();
+    vm.attachStorage(storage);
+    defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
+
+    // Mock renderer breaking on loading a corrupt costume
+    FakeRenderer.prototype.createSVGSkin = function (svgString) {
+        // Look for text added to costume to make it a corrupt svg
+        if (svgString.includes('<here is some')) {
+            throw new Error('mock createSVGSkin broke');
+        }
+        return FakeRenderer._nextSkinId++;
+    };
+
+    vm.attachRenderer(new FakeRenderer());
+
+    return vm.loadProject(project);
+});
+
+const test = tap.test;
+
+test('load sb3 project with corrupted vector costume file', t => {
+    t.equal(vm.runtime.targets.length, 2);
+    
+    const stage = vm.runtime.targets[0];
+    t.ok(stage.isStage);
+
+    const blueGuySprite = vm.runtime.targets[1];
+    t.equal(blueGuySprite.getName(), 'Blue Square Guy');
+    t.equal(blueGuySprite.getCostumes().length, 1);
+    
+    const corruptedCostume = blueGuySprite.getCostumes()[0];
+    t.equal(corruptedCostume.name, 'costume1');
+    t.equal(corruptedCostume.assetId, defaultVectorAssetId);
+    t.equal(corruptedCostume.dataFormat, 'svg');
+    // Runtime should have info about broken asset
+    t.ok(corruptedCostume.broken);
+    t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
+    // Verify that we saved the original asset data
+    t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
+
+    t.end();
+});
+
+test('load and then save project with corrupted vector costume file', t => {
+    const resavedProject = JSON.parse(vm.toJSON());
+
+    t.equal(resavedProject.targets.length, 2);
+    
+    const stage = resavedProject.targets[0];
+    t.ok(stage.isStage);
+
+    const blueGuySprite = resavedProject.targets[1];
+    t.equal(blueGuySprite.name, 'Blue Square Guy');
+    t.equal(blueGuySprite.costumes.length, 1);
+    
+    const corruptedCostume = blueGuySprite.costumes[0];
+    t.equal(corruptedCostume.name, 'costume1');
+    // Resaved project costume should have the metadata that corresponds to the original broken costume
+    t.equal(corruptedCostume.assetId, brokenCostumeMd5);
+    t.equal(corruptedCostume.dataFormat, 'svg');
+    // Test that we didn't save any data about the costume being broken
+    t.notOk(corruptedCostume.broken);
+
+    t.end();
+});
+
+test('serializeCostume saves orignal broken costume', t => {
+    const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[1].id);
+    t.equal(costumeDescs.length, 1);
+    const costume = costumeDescs[0];
+    t.equal(costume.fileName, `${brokenCostumeMd5}.svg`);
+    t.equal(md5(costume.fileContent), brokenCostumeMd5);
+    t.end();
+    process.nextTick(process.exit);
+});
diff --git a/test/integration/sb3_missing_svg.js b/test/integration/sb3_missing_svg.js
new file mode 100644
index 000000000..9e1445f5a
--- /dev/null
+++ b/test/integration/sb3_missing_svg.js
@@ -0,0 +1,87 @@
+/**
+ * This test ensures that the VM gracefully handles an sb3 project with
+ * a missing vector costume. The VM should handle this safely by displaying
+ * a Gray Question Mark, but keeping track of the original costume data
+ * and serializing the original costume data back out. The saved project.json
+ * should not reflect that the costume is broken and should therefore re-attempt
+ * to load the costume if the saved project is re-loaded.
+ */
+const path = require('path');
+const tap = require('tap');
+const makeTestStorage = require('../fixtures/make-test-storage');
+const FakeRenderer = require('../fixtures/fake-renderer');
+const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
+const VirtualMachine = require('../../src/index');
+
+const projectUri = path.resolve(__dirname, '../fixtures/missing_svg.sb3');
+const project = readFileToBuffer(projectUri);
+
+const missingCostumeAssetId = 'a267f8b97ee9cf8aa9832aa0b4cfd9eb';
+
+let vm;
+
+tap.beforeEach(() => {
+    const storage = makeTestStorage();
+    
+    // This line removes the webhelper from the list of available helpers.
+    // W/o the following line, this fails because storage doesn't handle the case
+    // where none of the tools have isGetSupported: true
+    // TODO: Remove this line when the related storage bug is resolved so that
+    // storage gracefully handles non-browser situations where assets are missing.
+    storage._helpers = [storage._helpers[0]];
+
+    vm = new VirtualMachine();
+    vm.attachStorage(storage);
+    vm.attachRenderer(new FakeRenderer());
+
+    return vm.loadProject(project);
+});
+
+const test = tap.test;
+
+test('loading sb3 project with missing vector costume file', t => {
+    t.equal(vm.runtime.targets.length, 2);
+    
+    const stage = vm.runtime.targets[0];
+    t.ok(stage.isStage);
+
+    const blueGuySprite = vm.runtime.targets[1];
+    t.equal(blueGuySprite.getName(), 'Blue Square Guy');
+    t.equal(blueGuySprite.getCostumes().length, 1);
+    
+    const missingCostume = blueGuySprite.getCostumes()[0];
+    t.equal(missingCostume.name, 'costume1');
+    // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
+    const defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
+    t.equal(missingCostume.assetId, defaultVectorAssetId);
+    t.equal(missingCostume.dataFormat, 'svg');
+    // Runtime should have info about broken asset
+    t.ok(missingCostume.broken);
+    t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
+
+    t.end();
+});
+
+test('load and then save sb3 project with missing costume file', t => {
+    const resavedProject = JSON.parse(vm.toJSON());
+
+    t.equal(resavedProject.targets.length, 2);
+    
+    const stage = resavedProject.targets[0];
+    t.ok(stage.isStage);
+
+    const blueGuySprite = resavedProject.targets[1];
+    t.equal(blueGuySprite.name, 'Blue Square Guy');
+    t.equal(blueGuySprite.costumes.length, 1);
+    
+    const missingCostume = blueGuySprite.costumes[0];
+    t.equal(missingCostume.name, 'costume1');
+    // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
+    t.equal(missingCostume.assetId, missingCostumeAssetId);
+    t.equal(missingCostume.dataFormat, 'svg');
+    // Test that we didn't save any data about the costume being broken
+    t.notOk(missingCostume.broken);
+
+    t.end();
+    process.nextTick(process.exit);
+});
diff --git a/test/integration/sprite3_corrupted_svg.js b/test/integration/sprite3_corrupted_svg.js
new file mode 100644
index 000000000..9bbe96bc9
--- /dev/null
+++ b/test/integration/sprite3_corrupted_svg.js
@@ -0,0 +1,106 @@
+/**
+ * This test mocks render breaking on loading a sprite with a
+ * corrupted vector costume.
+ * The VM should handle this safely by displaying a Gray Question Mark,
+ * but keeping track of the original costume data and serializing the
+ * original costume data back out. The saved project.json should not
+ * reflect that the costume is broken and should therefore re-attempt
+ * to load the costume if the saved project is re-loaded.
+ */
+const path = require('path');
+const tap = require('tap');
+const md5 = require('js-md5');
+const makeTestStorage = require('../fixtures/make-test-storage');
+const FakeRenderer = require('../fixtures/fake-renderer');
+const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
+const VirtualMachine = require('../../src/index');
+const {serializeCostumes} = require('../../src/serialization/serialize-assets');
+
+const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
+const project = readFileToBuffer(projectUri);
+
+const spriteUri = path.resolve(__dirname, '../fixtures/corrupt_svg.sprite3');
+const sprite = readFileToBuffer(spriteUri);
+
+const costumeFileName = 'a267f8b97ee9cf8aa9832aa0b4cfd9eb.svg';
+const originalCostume = extractAsset(spriteUri, costumeFileName);
+// We need to get the actual md5 because we hand modified the svg to corrupt it
+// after we downloaded the project from Scratch
+// Loading the project back into the VM will correct the assetId and md5
+const brokenCostumeMd5 = md5(originalCostume);
+
+let vm;
+let defaultVectorAssetId;
+
+tap.beforeEach(() => {
+    const storage = makeTestStorage();
+
+    vm = new VirtualMachine();
+    vm.attachStorage(storage);
+    defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
+
+    // Mock renderer breaking on loading a corrupt costume
+    FakeRenderer.prototype.createSVGSkin = function (svgString) {
+        // Look for text added to costume to make it a corrupt svg
+        if (svgString.includes('<here is some')) {
+            throw new Error('mock createSVGSkin broke');
+        }
+        return FakeRenderer.prototype._nextSkinId++;
+    };
+
+    vm.attachRenderer(new FakeRenderer());
+
+    return vm.loadProject(project).then(() => vm.addSprite(sprite));
+});
+
+const test = tap.test;
+
+test('load sprite3 with corrupted vector costume file', t => {
+    t.equal(vm.runtime.targets.length, 3);
+    
+    const stage = vm.runtime.targets[0];
+    t.ok(stage.isStage);
+
+    const blueGuySprite = vm.runtime.targets[2];
+    t.equal(blueGuySprite.getName(), 'Blue Square Guy');
+    t.equal(blueGuySprite.getCostumes().length, 1);
+    
+    const corruptedCostume = blueGuySprite.getCostumes()[0];
+    t.equal(corruptedCostume.name, 'costume1');
+    t.equal(corruptedCostume.assetId, defaultVectorAssetId);
+    t.equal(corruptedCostume.dataFormat, 'svg');
+    // Runtime should have info about broken asset
+    t.ok(corruptedCostume.broken);
+    t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
+    // Verify that we saved the original asset data
+    t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
+
+    t.end();
+});
+
+test('load and then save sprite with corrupted costume file', t => {
+    const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
+
+    t.equal(resavedSprite.name, 'Blue Square Guy');
+    t.equal(resavedSprite.costumes.length, 1);
+    
+    const corruptedCostume = resavedSprite.costumes[0];
+    t.equal(corruptedCostume.name, 'costume1');
+    // Resaved project costume should have the metadata that corresponds to the original broken costume
+    t.equal(corruptedCostume.assetId, brokenCostumeMd5);
+    t.equal(corruptedCostume.dataFormat, 'svg');
+    // Test that we didn't save any data about the costume being broken
+    t.notOk(corruptedCostume.broken);
+
+    t.end();
+});
+
+test('serializeCostume saves orignal broken costume', t => {
+    const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
+    t.equal(costumeDescs.length, 1);
+    const costume = costumeDescs[0];
+    t.equal(costume.fileName, `${brokenCostumeMd5}.svg`);
+    t.equal(md5(costume.fileContent), brokenCostumeMd5);
+    t.end();
+    process.nextTick(process.exit);
+});
diff --git a/test/integration/sprite3_missing_svg.js b/test/integration/sprite3_missing_svg.js
new file mode 100644
index 000000000..70e3ab046
--- /dev/null
+++ b/test/integration/sprite3_missing_svg.js
@@ -0,0 +1,85 @@
+/**
+ * This test ensures that the VM gracefully handles a sprite3 file with
+ * a missing vector costume. The VM should handle this safely by displaying
+ * a Gray Question Mark, but keeping track of the original costume data
+ * and serializing the original costume data back out. The saved project.json
+ * should not reflect that the costume is broken and should therefore re-attempt
+ * to load the costume if the saved project is re-loaded.
+ */
+const path = require('path');
+const tap = require('tap');
+const makeTestStorage = require('../fixtures/make-test-storage');
+const FakeRenderer = require('../fixtures/fake-renderer');
+const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
+const VirtualMachine = require('../../src/index');
+
+// The particular project that we're loading doesn't matter for this test
+const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
+const project = readFileToBuffer(projectUri);
+
+const spriteUri = path.resolve(__dirname, '../fixtures/missing_svg.sprite3');
+const sprite = readFileToBuffer(spriteUri);
+
+const missingCostumeAssetId = 'a267f8b97ee9cf8aa9832aa0b4cfd9eb';
+
+let vm;
+
+tap.beforeEach(() => {
+    const storage = makeTestStorage();
+    
+    // This line removes the webhelper from the list of available helpers.
+    // W/o the following line, this fails because storage doesn't handle the case
+    // where none of the tools have isGetSupported: true
+    // TODO: Remove this line when the related storage bug is resolved so that
+    // storage gracefully handles non-browser situations where assets are missing.
+    storage._helpers = [storage._helpers[0]];
+
+    vm = new VirtualMachine();
+    vm.attachStorage(storage);
+    vm.attachRenderer(new FakeRenderer());
+
+    return vm.loadProject(project).then(() => vm.addSprite(sprite));
+});
+
+const test = tap.test;
+
+test('loading sprite3 with missing vector costume file', t => {
+    t.equal(vm.runtime.targets.length, 3);
+    
+    const stage = vm.runtime.targets[0];
+    t.ok(stage.isStage);
+
+    const blueGuySprite = vm.runtime.targets[2];
+    t.equal(blueGuySprite.getName(), 'Blue Square Guy');
+    t.equal(blueGuySprite.getCostumes().length, 1);
+    
+    const missingCostume = blueGuySprite.getCostumes()[0];
+    t.equal(missingCostume.name, 'costume1');
+    // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
+    const defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
+    t.equal(missingCostume.assetId, defaultVectorAssetId);
+    t.equal(missingCostume.dataFormat, 'svg');
+    // Runtime should have info about broken asset
+    t.ok(missingCostume.broken);
+    t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
+
+    t.end();
+});
+
+test('load and then save sprite3 with missing vector costume file', t => {
+    const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
+
+    t.equal(resavedSprite.name, 'Blue Square Guy');
+    t.equal(resavedSprite.costumes.length, 1);
+    
+    const missingCostume = resavedSprite.costumes[0];
+    t.equal(missingCostume.name, 'costume1');
+    // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
+    t.equal(missingCostume.assetId, missingCostumeAssetId);
+    t.equal(missingCostume.dataFormat, 'svg');
+    // Test that we didn't save any data about the costume being broken
+    t.notOk(missingCostume.broken);
+
+    t.end();
+    process.nextTick(process.exit);
+});