All files / src snapshot.js

97.5% Statements 78/80
93.47% Branches 43/46
100% Functions 15/15
98.59% Lines 70/71

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207        10x 10x   10x   10x   10x   10x             28x                 53x 22x 22x 22x       10x                                       29x         29x 29x 58x 7x 7x 800x 800x 800x 800x 800x 9x     7x 7x   7x 7x 7x       29x                   29x 29x                     23x 1x   22x 1x   21x 1x   20x 4x 2x                             23x 23x       228x 228x 227x   228x       132x 132x 222904x 132x         132x 132x 1x       131x 131x 131x       228x 228x 228x 95x                 133x 133x     133x 35x 1x   34x                 200x 21575x                   11557x    
import { deflate } from "pako";
import { findModel } from "./models.js";
import { inflate } from "./archive.js";
 
const SnapshotFormat = "jsbeeb-snapshot";
const SnapshotVersion = 4;
 
const CompressedArrayEncoding = "deflate";
// Smaller arrays (registers, palettes) gain too little to pay for a deflate call and its header.
const CompressArraysFromBytes = 1024;
// A session's join waits on this; the default level 6 takes over twice as long to save a sixth of the bytes.
const ArrayCompressionLevel = 1;
// String.fromCharCode takes its bytes as arguments, so a large array goes in chunks.
const Base64ChunkBytes = 8192;
 
/**
 * Whether a snapshot was taken on a machine with a second processor fitted.
 * Nothing before version 3 captured tube state, so those snapshots are always host-only.
 */
export function hasCoProcessor(snapshot) {
    return !!snapshot.coProcessor;
}
 
/**
 * Check if two model names resolve to the same model (accounting for
 * synonyms and old names). Used to decide whether a page reload is
 * needed when loading a snapshot saved under a different model name.
 */
export function isSameModel(nameA, nameB) {
    if (nameA === nameB) return true;
    const a = findModel(nameA);
    const b = findModel(nameB);
    return a !== null && a === b;
}
 
// Map of TypedArray constructor names for deserialization
const TypedArrayConstructors = {
    Uint8Array,
    Uint16Array,
    Uint32Array,
    Int32Array,
    Float32Array,
    Float64Array,
};
 
/**
 * Create a snapshot of the emulator state for save-to-file.
 * Disc track pulse data is stripped — on restore, the discs are reloaded
 * from the source references in the `media` field. (The in-memory rewind
 * path uses cpu.snapshotState() directly, which retains full disc data.)
 * @param {import('./6502.js').Cpu6502} cpu
 * @param {object} model - the model definition object
 * @param {object} [media] - optional media source references (disc1, disc2)
 * @returns {object} snapshot object
 */
export function createSnapshot(cpu, model, media) {
    const state = cpu.snapshotState();
    // Strip clean disc track data from the save-to-file snapshot.
    // The FDC/drive mechanical state is kept; only clean tracks
    // (which can be reloaded from the disc image) are removed.
    // Dirty tracks (written since disc load) are kept as an overlay.
    Eif (state.fdc && state.fdc.drives) {
        for (const drive of state.fdc.drives) {
            if (drive.disc) {
                const dirtyTracks = {};
                for (const key of Object.keys(drive.disc.tracks)) {
                    const [sideStr, trackNumStr] = key.split(":");
                    const isSideUpper = sideStr === "true";
                    const trackNum = parseInt(trackNumStr, 10);
                    const dirtyKey = trackNum | (isSideUpper ? 0x100 : 0);
                    if (drive.disc._everDirtyTracks && drive.disc._everDirtyTracks.has(dirtyKey)) {
                        dirtyTracks[key] = drive.disc.tracks[key];
                    }
                }
                drive.disc.tracks = {};
                drive.disc.dirtyTracks = dirtyTracks;
                // Clean up internal-only fields not needed in serialized state
                delete drive.disc._everDirtyTracks;
                delete drive.disc._originalImageData;
                delete drive.disc._originalImageCrc32;
            }
        }
    }
    const snapshot = {
        format: SnapshotFormat,
        version: SnapshotVersion,
        model: model.name,
        // The model name cannot distinguish a Turbo from a plain Master: the co-processor is
        // emulation config rather than part of the model.
        coProcessor: cpu.hasTube,
        timestamp: new Date().toISOString(),
        state,
    };
    Iif (media) snapshot.media = media;
    return snapshot;
}
 
/**
 * Throws unless the snapshot is one restoreSnapshot can put into this machine: a known format and version,
 * the same model and the same co-processor.
 * @param {import('./6502.js').Cpu6502} cpu
 * @param {object} model - the current model definition
 * @param {object} snapshot
 */
export function assertSnapshotFits(cpu, model, snapshot) {
    if (snapshot.format !== SnapshotFormat) {
        throw new Error(`Unknown snapshot format: ${snapshot.format}`);
    }
    if (snapshot.version > SnapshotVersion) {
        throw new Error(`Snapshot version ${snapshot.version} is newer than supported version ${SnapshotVersion}`);
    }
    if (!isSameModel(snapshot.model, model.name)) {
        throw new Error(`Model mismatch: snapshot is for "${snapshot.model}" but current model is "${model.name}"`);
    }
    if (hasCoProcessor(snapshot) !== cpu.hasTube) {
        const fitted = (yes) => (yes ? "with a second processor" : "without a second processor");
        throw new Error(
            `Co-processor mismatch: snapshot was taken ${fitted(hasCoProcessor(snapshot))} ` +
                `but this machine is ${fitted(cpu.hasTube)}`,
        );
    }
}
 
/**
 * Restore emulator state from a snapshot.
 * @param {import('./6502.js').Cpu6502} cpu
 * @param {object} model - the current model definition
 * @param {object} snapshot
 * @throws {Error} if the snapshot does not fit the machine (see assertSnapshotFits)
 */
export function restoreSnapshot(cpu, model, snapshot) {
    assertSnapshotFits(cpu, model, snapshot);
    cpu.restoreState(snapshot.state);
}
 
function bytesToBase64(bytes) {
    const parts = [];
    for (let i = 0; i < bytes.length; i += Base64ChunkBytes) {
        parts.push(String.fromCharCode(...bytes.subarray(i, i + Base64ChunkBytes)));
    }
    return btoa(parts.join(""));
}
 
function base64ToBytes(base64) {
    const binary = atob(base64);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
    return bytes;
}
 
// A copy, because a view straight onto the bytes' buffer could start at an offset the element size does not divide.
function bytesToTypedArray(bytes, Constructor) {
    const elementSize = Constructor.BYTES_PER_ELEMENT;
    if (bytes.length % elementSize !== 0) {
        throw new Error(
            `Data length (${bytes.length} bytes) is not a multiple of ${Constructor.name} element size (${elementSize})`,
        );
    }
    const result = new Constructor(bytes.length / elementSize);
    new Uint8Array(result.buffer).set(bytes);
    return result;
}
 
function encodeTypedArray(array) {
    const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
    const type = array.constructor.name;
    if (bytes.length < CompressArraysFromBytes) return { __typedArray: true, type, data: bytesToBase64(bytes) };
    return {
        __typedArray: true,
        type,
        encoding: CompressedArrayEncoding,
        data: bytesToBase64(deflate(bytes, { level: ArrayCompressionLevel })),
    };
}
 
function decodeTypedArray({ type, encoding, data }) {
    const Constructor = TypedArrayConstructors[type];
    Iif (!Constructor) {
        throw new Error(`Unknown TypedArray type: ${type}`);
    }
    if (encoding === undefined) return bytesToTypedArray(base64ToBytes(data), Constructor);
    if (encoding !== CompressedArrayEncoding) {
        throw new Error(`Unknown TypedArray encoding: ${encoding}`);
    }
    return bytesToTypedArray(inflate(base64ToBytes(data)), Constructor);
}
 
/**
 * Serialize a snapshot to a JSON string, converting TypedArrays to base64, deflating the large ones first.
 * @param {object} snapshot
 * @returns {string} JSON string
 */
export function snapshotToJSON(snapshot) {
    return JSON.stringify(snapshot, (key, value) =>
        ArrayBuffer.isView(value) && !(value instanceof DataView) ? encodeTypedArray(value) : value,
    );
}
 
/**
 * Deserialize a snapshot from a JSON string, converting base64 back to TypedArrays.
 * @param {string} json
 * @returns {object} snapshot object
 */
export function snapshotFromJSON(json) {
    return JSON.parse(json, (key, value) => (value && value.__typedArray ? decodeTypedArray(value) : value));
}