diff --git a/src/serialization/snapshot.js b/src/serialization/snapshot.js new file mode 100644 index 00000000000..07dc0cb0922 --- /dev/null +++ b/src/serialization/snapshot.js @@ -0,0 +1,657 @@ +const {OrderedMap} = require('immutable'); +const sb3 = require('./sb3'); +const MonitorRecord = require('../engine/monitor-record'); +const Thread = require('../engine/thread'); +const Timer = require('../util/timer'); +const log = require('../util/log'); + +const SNAPSHOT_TYPE = 'scratch-vm-snapshot'; +const SNAPSHOT_VERSION = 1; + +const isPlainObject = value => { + if (!value || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +}; + +const serializeAny = value => { + if (value === null) return null; + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'boolean') return value; + if (t === 'undefined') return {__type: 'undefined'}; + + if (Array.isArray(value)) return value.map(serializeAny); + + // immutable.js + if (value && typeof value.toJS === 'function' && typeof value.toJSON === 'function') { + return {__type: 'immutable', value: value.toJS()}; + } + + if (value instanceof Timer) { + const usesDateNow = value.nowObj === Date; + return { + __type: 'Timer', + now: usesDateNow ? 'date' : 'runtime', + elapsed: value.timeElapsed() + }; + } + + if (value instanceof Set) { + return {__type: 'Set', values: Array.from(value, serializeAny)}; + } + + if (value instanceof Map) { + return { + __type: 'Map', + entries: Array.from(value.entries(), ([k, v]) => [serializeAny(k), serializeAny(v)]) + }; + } + + if (isPlainObject(value)) { + const result = {}; + for (const [k, v] of Object.entries(value)) { + result[k] = serializeAny(v); + } + return result; + } + + // Best-effort fallback: serialize enumerable own properties. + const result = {__type: 'object', name: value.constructor ? value.constructor.name : null, value: {}}; + for (const [k, v] of Object.entries(value)) { + result.value[k] = serializeAny(v); + } + return result; +}; + +const deserializeAny = (value, runtime) => { + if (value === null) return null; + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'boolean') return value; + + if (Array.isArray(value)) return value.map(v => deserializeAny(v, runtime)); + + if (!value || typeof value !== 'object') return value; + + if (value.__type === 'undefined') return void 0; + + if (value.__type === 'Timer') { + const useDate = value.now === 'date'; + const timer = useDate ? + new Timer() : + new Timer({ + now: () => runtime.currentMSecs + }); + const now = useDate ? Date.now() : runtime.currentMSecs; + timer.startTime = now - (value.elapsed || 0); + return timer; + } + + if (value.__type === 'Set') { + return new Set((value.values || []).map(v => deserializeAny(v, runtime))); + } + + if (value.__type === 'Map') { + return new Map( + (value.entries || []).map(([k, v]) => [ + deserializeAny(k, runtime), + deserializeAny(v, runtime) + ]) + ); + } + + if (value.__type === 'immutable') { + return value.value; + } + + if (value.__type === 'object') { + return deserializeAny(value.value, runtime); + } + + const result = {}; + for (const [k, v] of Object.entries(value)) { + result[k] = deserializeAny(v, runtime); + } + return result; +}; + +const serializeTargetState = (runtime, target) => { + const isClone = ( + !target.isStage && + !target.isOriginal && + target.sprite && + target.sprite.clones && + target.sprite.clones[0] + ); + const original = isClone ? target.sprite.clones[0] : null; + + const variables = {}; + for (const [id, variable] of Object.entries(target.variables || {})) { + variables[id] = serializeAny(variable.value); + } + + let layerOrder = null; + if ( + runtime.renderer && + target.drawableID !== null && + typeof runtime.renderer.getDrawableOrder === 'function' + ) { + layerOrder = runtime.renderer.getDrawableOrder(target.drawableID); + } + + return { + id: target.id, + isStage: !!target.isStage, + isOriginal: !!target.isOriginal, + spriteName: target.sprite && target.sprite.name, + originalId: original ? original.id : null, + + x: target.x, + y: target.y, + direction: target.direction, + draggable: target.draggable, + visible: target.visible, + size: target.size, + currentCostume: target.currentCostume, + rotationStyle: target.rotationStyle, + volume: target.volume, + tempo: target.tempo, + videoTransparency: target.videoTransparency, + videoState: target.videoState, + textToSpeechLanguage: target.textToSpeechLanguage, + dragging: !!target.dragging, + + effects: target.effects ? Object.assign({}, target.effects) : null, + edgeActivatedHatValues: serializeAny(target._edgeActivatedHatValues), + customState: serializeAny(target._customState), + extensionStorage: serializeAny(target.extensionStorage), + + variables, + layerOrder, + + executableIndex: runtime.executableTargets.indexOf(target) + }; +}; + +const serializeThreadState = thread => ({ + topBlock: thread.topBlock, + targetId: thread.target ? thread.target.id : null, + status: thread.status, + isKilled: !!thread.isKilled, + + stack: thread.stack.slice(), + stackFrames: thread.stackFrames.map(frame => ({ + isLoop: !!frame.isLoop, + warpMode: !!frame.warpMode, + justReported: serializeAny(frame.justReported), + reporting: frame.reporting, + reported: serializeAny(frame.reported), + waitingReporter: frame.waitingReporter, + params: serializeAny(frame.params), + executionContext: serializeAny(frame.executionContext) + })), + + stackClick: !!thread.stackClick, + updateMonitor: !!thread.updateMonitor, + requestScriptGlowInFrame: !!thread.requestScriptGlowInFrame, + blockGlowInFrame: thread.blockGlowInFrame, + + warpTimer: thread.warpTimer ? {elapsed: thread.warpTimer.timeElapsed()} : null, + + isCompiled: !!thread.isCompiled, + triedToCompile: !!thread.triedToCompile, + justReported: serializeAny(thread.justReported) +}); + +const serializeMonitorState = runtime => runtime._monitorState + .valueSeq() + .map(m => m.toJS()) + .toArray(); + +const serializeIOState = runtime => { + const keyboard = runtime.ioDevices.keyboard; + const mouse = runtime.ioDevices.mouse; + const video = runtime.ioDevices.video; + const clock = runtime.ioDevices.clock; + + return { + keyboard: { + keysPressed: keyboard._keysPressed.slice(), + lastKeyPressed: keyboard.lastKeyPressed, + usedKeys: Array.from(keyboard._usedKeys), + numeralKeyCodesToStringKey: Array.from(keyboard._numeralKeyCodesToStringKey.entries()) + }, + mouse: { + clientX: mouse._clientX, + clientY: mouse._clientY, + scratchX: mouse._scratchX, + scratchY: mouse._scratchY, + buttons: Array.from(mouse._buttons), + usesRightClickDown: !!mouse.usesRightClickDown, + isDown: !!mouse._isDown + }, + video: { + skinId: video._skinId, + drawable: video._drawable, + ghost: video._ghost, + forceTransparentPreview: !!video._forceTransparentPreview + }, + clock: { + elapsed: clock._projectTimer.timeElapsed(), + paused: !!clock._paused, + pausedTime: clock._pausedTime + } + }; +}; + +const applyIOState = (runtime, ioState) => { + if (!ioState) return; + + if (ioState.keyboard) { + const keyboard = runtime.ioDevices.keyboard; + keyboard._keysPressed = (ioState.keyboard.keysPressed || []).slice(); + keyboard.lastKeyPressed = ioState.keyboard.lastKeyPressed || ''; + keyboard._usedKeys = new Set(ioState.keyboard.usedKeys || []); + keyboard._numeralKeyCodesToStringKey = new Map(ioState.keyboard.numeralKeyCodesToStringKey || []); + } + + if (ioState.mouse) { + const mouse = runtime.ioDevices.mouse; + mouse._clientX = +ioState.mouse.clientX || 0; + mouse._clientY = +ioState.mouse.clientY || 0; + mouse._scratchX = +ioState.mouse.scratchX || 0; + mouse._scratchY = +ioState.mouse.scratchY || 0; + mouse._buttons = new Set(ioState.mouse.buttons || []); + mouse.usesRightClickDown = !!ioState.mouse.usesRightClickDown; + mouse._isDown = !!ioState.mouse.isDown; + } + + if (ioState.video) { + const video = runtime.ioDevices.video; + video._ghost = +ioState.video.ghost || 0; + video._forceTransparentPreview = !!ioState.video.forceTransparentPreview; + // Do not attempt to set skinId/drawable unless they already exist, + // as these are renderer-specific resources. + if (typeof ioState.video.skinId === 'number' && video._skinId !== -1) { + video._skinId = ioState.video.skinId; + } + if (typeof ioState.video.drawable === 'number' && video._drawable !== -1) { + video._drawable = ioState.video.drawable; + } + if (typeof video.setPreviewGhost === 'function') { + video.setPreviewGhost(video._ghost); + } + } + + if (ioState.clock) { + const clock = runtime.ioDevices.clock; + const elapsed = Math.max(0, +ioState.clock.elapsed || 0); + const paused = !!ioState.clock.paused; + + clock._projectTimer.startTime = runtime.currentMSecs - elapsed; + clock._paused = paused; + clock._pausedTime = paused ? + (typeof ioState.clock.pausedTime === 'number' ? ioState.clock.pausedTime : elapsed) : + null; + } +}; + +const applyMonitorState = (runtime, monitors) => { + if (!Array.isArray(monitors)) return; + let newState = OrderedMap({}); + for (const monitor of monitors) { + if (!monitor || typeof monitor !== 'object') continue; + if (!monitor.id) continue; + newState = newState.set(monitor.id, MonitorRecord(monitor)); + } + runtime._monitorState = newState; + runtime._prevMonitorState = newState; + runtime.emit(runtime.constructor.MONITORS_UPDATE, runtime._monitorState); +}; + +const applyTargetState = (runtime, target, state) => { + if (!target || !state) return; + + if (!target.isStage) { + if (state.dragging && typeof target.startDrag === 'function') { + target.startDrag(); + } else if (!state.dragging && typeof target.stopDrag === 'function') { + target.stopDrag(); + } + + if ( + typeof state.x === 'number' && + typeof state.y === 'number' && + typeof target.setXY === 'function' + ) { + target.setXY(state.x, state.y, true); + } + if (typeof state.direction === 'number' && typeof target.setDirection === 'function') { + target.setDirection(state.direction); + } + if ( + typeof state.draggable !== 'undefined' && + typeof target.setDraggable === 'function' + ) { + target.setDraggable(state.draggable); + } + if ( + typeof state.visible !== 'undefined' && + typeof target.setVisible === 'function' + ) { + target.setVisible(state.visible); + } + if (typeof state.size === 'number' && typeof target.setSize === 'function') { + target.setSize(state.size); + } + if ( + typeof state.currentCostume === 'number' && + typeof target.setCostume === 'function' + ) { + target.setCostume(state.currentCostume); + } + if ( + typeof state.rotationStyle === 'string' && + typeof target.setRotationStyle === 'function' + ) { + target.setRotationStyle(state.rotationStyle); + } + } + + if (state.effects && target.effects && typeof target.setEffect === 'function') { + for (const [effectName, effectValue] of Object.entries(state.effects)) { + target.setEffect(effectName, effectValue); + } + } + + if (typeof state.volume === 'number') { + target.volume = state.volume; + } + if (typeof state.tempo === 'number') { + target.tempo = state.tempo; + } + if (typeof state.videoTransparency === 'number') { + target.videoTransparency = state.videoTransparency; + } + if (typeof state.videoState === 'string') { + target.videoState = state.videoState; + } + if ( + typeof state.textToSpeechLanguage === 'string' || + state.textToSpeechLanguage === null + ) { + target.textToSpeechLanguage = state.textToSpeechLanguage; + } + + if (state.edgeActivatedHatValues) { + target._edgeActivatedHatValues = deserializeAny(state.edgeActivatedHatValues, runtime); + } + if (state.customState) { + target._customState = deserializeAny(state.customState, runtime); + } + if (state.extensionStorage) { + target.extensionStorage = deserializeAny(state.extensionStorage, runtime); + } + + if (state.variables && target.variables) { + for (const [varId, varValue] of Object.entries(state.variables)) { + if (Object.prototype.hasOwnProperty.call(target.variables, varId)) { + target.variables[varId].value = deserializeAny(varValue, runtime); + } + } + } +}; + +const restoreThreads = (runtime, threads) => { + runtime.threads = []; + runtime.threadMap.clear(); + runtime.sequencer.activeThread = null; + + if (!Array.isArray(threads)) return; + + for (const state of threads) { + if (!state || typeof state !== 'object') continue; + const target = state.targetId ? runtime.getTargetById(state.targetId) : null; + if (!target) continue; + + const thread = new Thread(state.topBlock); + thread.target = target; + thread.stackClick = !!state.stackClick; + thread.updateMonitor = !!state.updateMonitor; + thread.blockContainer = thread.updateMonitor ? runtime.monitorBlocks : target.blocks; + + const stack = Array.isArray(state.stack) ? state.stack : []; + for (const blockId of stack) { + thread.pushStack(blockId); + } + + const frameStates = Array.isArray(state.stackFrames) ? state.stackFrames : []; + for (let i = 0; i < thread.stackFrames.length && i < frameStates.length; i++) { + const frame = thread.stackFrames[i]; + const fs = frameStates[i] || {}; + frame.isLoop = !!fs.isLoop; + frame.warpMode = !!fs.warpMode; + frame.justReported = deserializeAny(fs.justReported, runtime); + frame.reporting = fs.reporting || ''; + frame.reported = deserializeAny(fs.reported, runtime); + frame.waitingReporter = fs.waitingReporter || null; + frame.params = deserializeAny(fs.params, runtime); + frame.executionContext = deserializeAny(fs.executionContext, runtime); + frame.op = null; + } + + const ThreadClass = Thread; + if (state.status === ThreadClass.STATUS_PROMISE_WAIT) { + thread.status = ThreadClass.STATUS_YIELD; + } else { + thread.status = typeof state.status === 'number' ? state.status : ThreadClass.STATUS_RUNNING; + } + + thread.isKilled = !!state.isKilled; + thread.requestScriptGlowInFrame = !!state.requestScriptGlowInFrame; + thread.blockGlowInFrame = state.blockGlowInFrame || null; + thread.justReported = deserializeAny(state.justReported, runtime); + + thread.isCompiled = false; + thread.triedToCompile = false; + thread.generator = null; + + if (state.warpTimer && typeof state.warpTimer.elapsed === 'number') { + const timer = new Timer(); + timer.startTime = Date.now() - state.warpTimer.elapsed; + thread.warpTimer = timer; + } else { + thread.warpTimer = null; + } + + runtime.threads.push(thread); + } + + runtime.updateThreadMap(); +}; + +const validateSnapshot = snapshot => { + if (!snapshot || typeof snapshot !== 'object') { + throw new Error('Invalid snapshot: expected an object'); + } + if (snapshot.type !== SNAPSHOT_TYPE) { + throw new Error('Invalid snapshot: unsupported type'); + } + if (snapshot.version !== SNAPSHOT_VERSION) { + throw new Error(`Invalid snapshot: unsupported version ${snapshot.version}`); + } + if (!snapshot.runtimeState || typeof snapshot.runtimeState !== 'object') { + throw new Error('Invalid snapshot: missing runtimeState'); + } +}; + +const serialize = (vm, { + includeProject = true +} = {}) => { + const runtime = vm.runtime; + + const snapshot = { + type: SNAPSHOT_TYPE, + version: SNAPSHOT_VERSION, + timestamp: Date.now(), + project: includeProject ? sb3.serialize(runtime) : null, + runtimeState: { + editingTargetId: vm.editingTarget ? vm.editingTarget.id : null, + + stageWidth: runtime.stageWidth, + stageHeight: runtime.stageHeight, + framerate: runtime.frameLoop ? runtime.frameLoop.framerate : null, + interpolationEnabled: !!runtime.interpolationEnabled, + turboMode: !!runtime.turboMode, + runtimeOptions: runtime.runtimeOptions ? Object.assign({}, runtime.runtimeOptions) : null, + compilerOptions: runtime.compilerOptions ? Object.assign({}, runtime.compilerOptions) : null, + + targets: runtime.targets.map(t => serializeTargetState(runtime, t)), + executableOrder: runtime.executableTargets.map(t => t.id), + + monitors: serializeMonitorState(runtime), + threads: runtime.threads.map(serializeThreadState), + ioDevices: serializeIOState(runtime) + } + }; + + return snapshot; +}; + +const restore = (vm, snapshot, { + stopAll = true, + restoreProject = false +} = {}) => { + validateSnapshot(snapshot); + + const runtime = vm.runtime; + + const restoreProjectPromise = restoreProject && snapshot.project ? + vm.deserializeProject(snapshot.project, null) : + Promise.resolve(); + + return restoreProjectPromise.then(() => { + if (stopAll) { + vm.stopAll(); + } + + const state = snapshot.runtimeState; + + if (typeof state.framerate === 'number') { + runtime.setFramerate(state.framerate); + } + if (typeof state.interpolationEnabled === 'boolean') { + runtime.setInterpolation(state.interpolationEnabled); + } + if (state.runtimeOptions) { + runtime.setRuntimeOptions(state.runtimeOptions); + } + if (state.compilerOptions) { + runtime.setCompilerOptions(state.compilerOptions); + } + if ( + typeof state.stageWidth === 'number' && + typeof state.stageHeight === 'number' + ) { + runtime.setStageSize(state.stageWidth, state.stageHeight); + } + + if (typeof state.turboMode === 'boolean') { + const old = runtime.turboMode; + runtime.turboMode = state.turboMode; + if (old !== runtime.turboMode) { + runtime.emit(runtime.constructor[state.turboMode ? 'TURBO_MODE_ON' : 'TURBO_MODE_OFF']); + } + } + + runtime.updateCurrentMSecs(); + + applyIOState(runtime, state.ioDevices); + + // Re-create clones removed by stopAll + const targetStates = Array.isArray(state.targets) ? state.targets : []; + for (const targetState of targetStates) { + if (!targetState || typeof targetState !== 'object') continue; + if (targetState.isStage || targetState.isOriginal) continue; + + const original = (targetState.originalId && runtime.getTargetById(targetState.originalId)) || + (targetState.spriteName && runtime.getSpriteTargetByName(targetState.spriteName)); + + if (!original || typeof original.makeClone !== 'function') { + log.warn('Could not restore clone: missing original target', targetState); + continue; + } + + const newClone = original.makeClone(); + if (!newClone) { + log.warn('Could not restore clone: makeClone() returned null', targetState); + continue; + } + + runtime.addTarget(newClone); + newClone.id = targetState.id; + } + + // Reorder targets and executables + const byId = new Map(); + for (const t of runtime.targets) { + byId.set(t.id, t); + } + + const reorderedTargets = []; + for (const ts of targetStates) { + const t = ts && ts.id ? byId.get(ts.id) : null; + if (t) reorderedTargets.push(t); + } + for (const t of runtime.targets) { + if (!reorderedTargets.includes(t)) reorderedTargets.push(t); + } + runtime.targets = reorderedTargets; + runtime._stageTarget = runtime.targets.find(t => t.isStage) || runtime._stageTarget; + + if (Array.isArray(state.executableOrder)) { + const execById = new Map(); + for (const t of runtime.executableTargets) execById.set(t.id, t); + const reorderedExec = []; + for (const id of state.executableOrder) { + const t = execById.get(id); + if (t) reorderedExec.push(t); + } + for (const t of runtime.executableTargets) { + if (!reorderedExec.includes(t)) reorderedExec.push(t); + } + runtime.executableTargets = reorderedExec; + } + + // Apply per-target state + for (const targetState of targetStates) { + if (!targetState || typeof targetState !== 'object') continue; + const target = runtime.getTargetById(targetState.id); + if (!target) continue; + + applyTargetState(runtime, target, targetState); + + if (typeof targetState.layerOrder === 'number' && runtime.renderer && typeof runtime.renderer.setDrawableOrder === 'function' && + typeof target.drawableID === 'number' && !target.isStage) { + runtime.renderer.setDrawableOrder(target.drawableID, targetState.layerOrder); + } + } + + applyMonitorState(runtime, state.monitors); + + restoreThreads(runtime, state.threads); + + if (state.editingTargetId) { + vm.setEditingTarget(state.editingTargetId); + } + + runtime.requestRedraw(); + }); +}; + +module.exports = { + SNAPSHOT_TYPE, + SNAPSHOT_VERSION, + serialize, + restore, + validateSnapshot +}; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 693c213042f..1b60c855fcb 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -27,6 +27,7 @@ const {serializeSounds, serializeCostumes} = require('./serialization/serialize- require('canvas-toBlob'); const {exportCostume} = require('./serialization/tw-costume-import-export'); const Base64Util = require('./util/base64-util'); +const snapshotSerializer = require('./serialization/snapshot'); const RESERVED_NAMES = ['_mouse_', '_stage_', '_edge_', '_myself_', '_random_']; @@ -711,6 +712,56 @@ class VirtualMachine extends EventEmitter { return StringUtil.stringify(sb3.serialize(this.runtime, optTargetId, serializationOptions)); } + /** + * Take a VM snapshot which can later be restored with {@link loadSnapshot}. + * + * This is primarily intended for time-travel debugging. + * + * @param {object=} options + * @param {boolean=} options.includeProject Include a serialized SB3 project.json in the snapshot. + * Defaults to true. + * @returns {object} Snapshot data. + */ + takeSnapshot (options) { + const snapshot = snapshotSerializer.serialize(this, options); + this.emit('SNAPSHOT_TAKEN', snapshot); + return snapshot; + } + + /** + * Normalize and validate snapshot data. + * + * @param {string|object|ArrayBuffer|ArrayBufferView} snapshotData Snapshot, or JSON string of a snapshot. + * @returns {object} Parsed snapshot object. + */ + readSnapshot (snapshotData) { + if (typeof snapshotData === 'string') { + snapshotData = JSON.parse(snapshotData); + } else if (snapshotData instanceof ArrayBuffer || ArrayBuffer.isView(snapshotData)) { + snapshotData = JSON.parse(Buffer.from(snapshotData).toString()); + } + + snapshotSerializer.validateSnapshot(snapshotData); + return snapshotData; + } + + /** + * Restore a snapshot created by {@link takeSnapshot}. + * + * @param {string|object|ArrayBuffer|ArrayBufferView} snapshotData Snapshot, or JSON string of a snapshot. + * @param {object=} options + * @param {boolean=} options.stopAll Stop all threads (and delete clones) before restoring. Defaults to true. + * @param {boolean=} options.restoreProject Reload the serialized project before restoring runtime state. + * Defaults to false. + * @returns {Promise} Resolves once restoration is complete. + */ + loadSnapshot (snapshotData, options) { + const snapshot = this.readSnapshot(snapshotData); + return snapshotSerializer.restore(this, snapshot, options).then(() => { + this.emit('SNAPSHOT_LOADED', snapshot); + }); + } + // TODO do we still need this function? Keeping it here so as not to introduce // a breaking change. /** diff --git a/test/unit/vm_snapshot_state.js b/test/unit/vm_snapshot_state.js new file mode 100644 index 00000000000..4a237f13ad3 --- /dev/null +++ b/test/unit/vm_snapshot_state.js @@ -0,0 +1,163 @@ +const tap = require('tap'); + +const VirtualMachine = require('../../src/virtual-machine'); +const Sprite = require('../../src/sprites/sprite'); +const Variable = require('../../src/engine/variable'); +const MonitorRecord = require('../../src/engine/monitor-record'); +const Thread = require('../../src/engine/thread'); +const Timer = require('../../src/util/timer'); + +const test = tap.test; + +test('VM snapshot roundtrip restores targets (including clones), variables, monitors, threads, and IO', async t => { + const vm = new VirtualMachine(); + + // Build a minimal project: stage + one sprite. + const stageSprite = new Sprite(null, vm.runtime); + stageSprite.name = 'Stage'; + const stage = stageSprite.createClone(); + stage.isStage = true; + stage.id = 'stage'; + + const sprite = new Sprite(null, vm.runtime); + sprite.name = 'Sprite1'; + const target = sprite.createClone(); + target.id = 'sprite'; + + vm.runtime.addTarget(stage); + vm.runtime.addTarget(target); + + // Variables + const scalar = new Variable('var1', 'score', Variable.SCALAR_TYPE, false); + scalar.value = 10; + const list = new Variable('list1', 'items', Variable.LIST_TYPE, false); + list.value = ['a', 'b']; + target.variables[scalar.id] = scalar; + target.variables[list.id] = list; + + // Clone + const clone = target.makeClone(); + t.ok(clone, 'clone created'); + vm.runtime.addTarget(clone); + clone.id = 'clone'; + + // Target state + target.setXY(10, 20, true); + target.setDirection(45); + target.setSize(80); + target.setVisible(false); + target.setEffect('ghost', 50); + target._customState = {debug: {step: 1}}; + + clone.setXY(-30, 40, true); + clone.setDirection(-90); + clone.setEffect('color', 25); + + // Monitor state + vm.runtime.requestAddMonitor(MonitorRecord({ + id: 'mon1', + opcode: 'data_variable', + value: 99, + visible: true, + x: 10, + y: 20 + })); + + // IO state + vm.runtime.ioDevices.keyboard._keysPressed = ['A']; + vm.runtime.ioDevices.keyboard.lastKeyPressed = 'a'; + vm.runtime.ioDevices.mouse._clientX = 1; + vm.runtime.ioDevices.mouse._clientY = 2; + vm.runtime.ioDevices.mouse._scratchX = 3; + vm.runtime.ioDevices.mouse._scratchY = 4; + vm.runtime.ioDevices.mouse._buttons = new Set([0, 2]); + vm.runtime.ioDevices.mouse._isDown = true; + + // Thread state + const thread = new Thread('top'); + thread.target = clone; + thread.blockContainer = clone.blocks; + thread.pushStack('top'); + thread.pushStack('next'); + // Add a stack timer-like state to executionContext. + const nowObj = {now: () => vm.runtime.currentMSecs}; + const timer = new Timer(nowObj); + timer.start(); + thread.peekStackFrame().executionContext = { + timer, + duration: 1000 + }; + thread.status = Thread.STATUS_YIELD; + vm.runtime.threads.push(thread); + vm.runtime.updateThreadMap(); + + vm.editingTarget = target; + + const snapshot = vm.takeSnapshot(); + + // Mutate state so we can verify restore. + target.setXY(0, 0, true); + target.setDirection(90); + target.setSize(100); + target.setVisible(true); + target.setEffect('ghost', 0); + target.variables[scalar.id].value = 123; + target.variables[list.id].value = ['x']; + + vm.runtime.ioDevices.keyboard._keysPressed = []; + vm.runtime.ioDevices.keyboard.lastKeyPressed = ''; + vm.runtime.ioDevices.mouse._clientX = 0; + vm.runtime.ioDevices.mouse._clientY = 0; + vm.runtime.ioDevices.mouse._scratchX = 0; + vm.runtime.ioDevices.mouse._scratchY = 0; + vm.runtime.ioDevices.mouse._buttons = new Set(); + vm.runtime.ioDevices.mouse._isDown = false; + + vm.runtime.requestRemoveMonitor('mon1'); + + await vm.loadSnapshot(snapshot); + + // Targets + const restoredSprite = vm.runtime.getTargetById('sprite'); + const restoredClone = vm.runtime.getTargetById('clone'); + t.ok(restoredSprite, 'restored sprite exists'); + t.ok(restoredClone, 'restored clone exists'); + + t.same({x: restoredSprite.x, y: restoredSprite.y}, {x: 10, y: 20}); + t.equal(restoredSprite.direction, 45); + t.equal(restoredSprite.size, 80); + t.equal(restoredSprite.visible, false); + t.equal(restoredSprite.effects.ghost, 50); + t.same(restoredSprite._customState, {debug: {step: 1}}); + + t.same({x: restoredClone.x, y: restoredClone.y}, {x: -30, y: 40}); + t.equal(restoredClone.direction, -90); + t.equal(restoredClone.effects.color, 25); + + // Variables + t.equal(restoredSprite.variables.var1.value, 10); + t.same(restoredSprite.variables.list1.value, ['a', 'b']); + + // Monitors + t.ok(vm.runtime._monitorState.has('mon1'), 'monitor restored'); + t.equal(vm.runtime._monitorState.get('mon1').get('value'), 99); + + // IO + t.same(vm.runtime.ioDevices.keyboard._keysPressed, ['A']); + t.equal(vm.runtime.ioDevices.keyboard.lastKeyPressed, 'a'); + t.equal(vm.runtime.ioDevices.mouse._clientX, 1); + t.equal(vm.runtime.ioDevices.mouse._clientY, 2); + t.equal(vm.runtime.ioDevices.mouse._scratchX, 3); + t.equal(vm.runtime.ioDevices.mouse._scratchY, 4); + t.equal(vm.runtime.ioDevices.mouse._isDown, true); + t.same(Array.from(vm.runtime.ioDevices.mouse._buttons).sort(), [0, 2]); + + // Threads + t.equal(vm.runtime.threads.length, 1); + const restoredThread = vm.runtime.threads[0]; + t.equal(restoredThread.target.id, 'clone'); + t.same(restoredThread.stack, ['top', 'next']); + t.equal(restoredThread.status, Thread.STATUS_YIELD); + t.ok(restoredThread.peekStackFrame().executionContext.timer instanceof Timer, 'timer restored'); + t.equal(restoredThread.peekStackFrame().executionContext.duration, 1000); +});