diff --git a/test/fixtures/fake-bitmap-adapter.js b/test/fixtures/fake-bitmap-adapter.js
new file mode 100644
index 000000000..a7e5c8aa7
--- /dev/null
+++ b/test/fixtures/fake-bitmap-adapter.js
@@ -0,0 +1,7 @@
+const FakeBitmapAdapter = require('scratch-svg-renderer').BitmapAdapter;
+
+FakeBitmapAdapter.prototype.resize = function (canvas) {
+    return canvas;
+};
+
+module.exports = FakeBitmapAdapter;
diff --git a/test/fixtures/fake-renderer.js b/test/fixtures/fake-renderer.js
index f8343692e..2a4fcfb13 100644
--- a/test/fixtures/fake-renderer.js
+++ b/test/fixtures/fake-renderer.js
@@ -11,6 +11,10 @@ FakeRenderer.prototype.createSVGSkin = function () {
     return this._nextSkinId++;
 };
 
+FakeRenderer.prototype.createBitmapSkin = function () {
+    return this._nextSkinId++;
+};
+
 FakeRenderer.prototype.getSkinSize = function (d) { // eslint-disable-line no-unused-vars
     return [0, 0];
 };
diff --git a/test/fixtures/missing_svg.sb2 b/test/fixtures/missing_svg.sb2
new file mode 100644
index 000000000..45daf2cc8
Binary files /dev/null and b/test/fixtures/missing_svg.sb2 differ
diff --git a/test/fixtures/missing_svg.sprite2 b/test/fixtures/missing_svg.sprite2
new file mode 100644
index 000000000..c8e3732de
Binary files /dev/null and b/test/fixtures/missing_svg.sprite2 differ
diff --git a/test/integration/sb2_missing_svg.js b/test/integration/sb2_missing_svg.js
new file mode 100644
index 000000000..46bbb676b
--- /dev/null
+++ b/test/integration/sb2_missing_svg.js
@@ -0,0 +1,100 @@
+/**
+ * This test ensures that the VM gracefully handles an sb2 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 FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
+const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
+const VirtualMachine = require('../../src/index');
+
+const projectUri = path.resolve(__dirname, '../fixtures/missing_svg.sb2');
+const project = readFileToBuffer(projectUri);
+
+const missingCostumeAssetId = 'beca8009621913e2f5b3111eed2d8210';
+
+global.Image = function () {
+    const image = {
+        width: 1,
+        height: 1
+    };
+    setTimeout(() => image.onload(), 1000);
+    return image;
+};
+
+global.document = {
+    createElement: () => ({
+        // Create mock canvas
+        getContext: () => ({
+            drawImage: () => ({})
+        })
+    })
+};
+
+let vm;
+
+tap.beforeEach(() => {
+    const storage = makeTestStorage();
+
+    vm = new VirtualMachine();
+    vm.attachStorage(storage);
+    vm.attachRenderer(new FakeRenderer());
+    vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
+
+    return vm.loadProject(project);
+});
+
+const test = tap.test;
+
+test('loading sb2 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 Guy');
+    t.equal(blueGuySprite.getCostumes().length, 1);
+    
+    const missingCostume = blueGuySprite.getCostumes()[0];
+    t.equal(missingCostume.name, 'Blue Guy 2');
+    // 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 sb2 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 Guy');
+    t.equal(blueGuySprite.costumes.length, 1);
+    
+    const missingCostume = blueGuySprite.costumes[0];
+    t.equal(missingCostume.name, 'Blue Guy 2');
+    // 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/sprite2_missing_svg.js b/test/integration/sprite2_missing_svg.js
new file mode 100644
index 000000000..e53885f38
--- /dev/null
+++ b/test/integration/sprite2_missing_svg.js
@@ -0,0 +1,107 @@
+/**
+ * This test ensures that the VM gracefully handles a sprite2 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 FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
+const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
+const VirtualMachine = require('../../src/index');
+const {serializeCostumes} = require('../../src/serialization/serialize-assets');
+
+// 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.sprite2');
+const sprite = readFileToBuffer(spriteUri);
+
+const missingCostumeAssetId = 'beca8009621913e2f5b3111eed2d8210';
+
+global.Image = function () {
+    const image = {
+        width: 1,
+        height: 1
+    };
+    setTimeout(() => image.onload(), 1000);
+    return image;
+};
+
+global.document = {
+    createElement: () => ({
+        // Create mock canvas
+        getContext: () => ({
+            drawImage: () => ({})
+        })
+    })
+};
+
+let vm;
+
+tap.beforeEach(() => {
+    const storage = makeTestStorage();
+
+    vm = new VirtualMachine();
+    vm.attachStorage(storage);
+    vm.attachRenderer(new FakeRenderer());
+    vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
+
+    return vm.loadProject(project).then(() => vm.addSprite(sprite));
+});
+
+const test = tap.test;
+
+test('loading sprite2 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 Guy');
+    t.equal(blueGuySprite.getCostumes().length, 1);
+    
+    const missingCostume = blueGuySprite.getCostumes()[0];
+    t.equal(missingCostume.name, 'Blue Guy 2');
+    // 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 sprite2 with missing vector costume file', t => {
+    const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
+
+    t.equal(resavedSprite.name, 'Blue Guy');
+    t.equal(resavedSprite.costumes.length, 1);
+    
+    const missingCostume = resavedSprite.costumes[0];
+    t.equal(missingCostume.name, 'Blue Guy 2');
+    // 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();
+});
+
+test('serializeCostume does not save data for missing costume', t => {
+    const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
+   
+    t.equal(costumeDescs.length, 0);
+    
+    t.end();
+    process.nextTick(process.exit);
+});