All files / src/web snapshot-ui.js

89.74% Statements 105/117
90.36% Branches 75/83
81.81% Functions 9/11
92.38% Lines 97/105

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 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228                                  1x     1x     5x 5x 3x   2x 1x         7x 7x 7x 7x                 8x 8x 12x 12x 5x     12x 5x 5x 5x     5x 12x 1x 1x     6x           25x 25x 25x 25x 25x 25x 25x 25x   25x 25x         25x                 2x 2x 2x 2x 2x 2x 2x       2x   2x         7x 7x 7x 7x 7x 5x 3x 3x 3x   2x   2x 2x   2x 2x   7x           4x 4x 3x 3x 2x   1x             4x 4x 4x   4x 4x         17x 11x 18x 18x 18x     18x   18x 18x   5x 13x     2x     2x 2x   2x   18x 11x       4x 4x 4x         1x 3x     4x         7x         7x           1x           6x              
import * as disc from "../fdc.js";
import { DiscLayout } from "../disc.js";
import { downloadBlob } from "./dom-utils.js";
import { gzipCodec, isGzip } from "../archive.js";
import {
    createSnapshot,
    restoreSnapshot,
    snapshotToJSON,
    snapshotFromJSON,
    isSameModel,
    hasCoProcessor,
} from "../snapshot.js";
import { isBemSnapshot, parseBemSnapshot } from "../bem-snapshot.js";
import { isUefSnapshot, parseUefSnapshot } from "../uef-snapshot.js";
import { leaveForNextPage, reloadAsMachine, takeFromLastPage } from "./machine-switch.js";
import { noteFeature } from "./analytics.js";
 
const PendingStateKey = "jsbeeb-pending-state";
 
/** Enough for the restored OS to settle before the user sees the screen. */
const PostRestoreCycles = 40000;
 
export function isSnapshotFile(filename, arrayBuffer) {
    const lower = filename.toLowerCase();
    if (lower.endsWith(".snp") || lower.endsWith(".json") || lower.endsWith(".json.gz") || lower.endsWith(".gz"))
        return true;
    // .uef can be either a BeebEm save state or a regular tape image - check content
    if (lower.endsWith(".uef") && arrayBuffer) return isUefSnapshot(arrayBuffer);
    return false;
}
 
/** The saved snapshot in whichever of the formats we read the buffer holds. */
async function readSnapshot(arrayBuffer) {
    Iif (isBemSnapshot(arrayBuffer)) return await parseBemSnapshot(arrayBuffer);
    Iif (isUefSnapshot(arrayBuffer)) return parseUefSnapshot(arrayBuffer);
    const bytes = new Uint8Array(arrayBuffer);
    return snapshotFromJSON(isGzip(bytes) ? await gzipCodec.decompress(bytes) : new TextDecoder().decode(bytes));
}
 
/**
 * What a snapshot needs to put the same discs back: their URL references
 * where they have one, the image bytes where they do not, and CRCs for
 * saying when a source has changed underneath a state.
 */
export function snapshotMedia(slots) {
    const manifest = {};
    for (const slot of slots.driveSlots) {
        const driveDisc = slot.media;
        if (!driveDisc) continue;
        const discKey = slot.index === 0 ? "disc1" : "disc2";
        // A disc with its bytes to hand is embedded below; anything else is known by its reference,
        // which the page's own boot disc has even though the URL does not name it.
        if (!driveDisc.originalImageData && slot.ref) manifest[discKey] = slot.ref;
        Iif (driveDisc.originalImageCrc32 == null) continue;
        const crcKey = discKey + "Crc32";
        manifest[crcKey] = driveDisc.originalImageCrc32;
        // The snapshot's dirty tracks are indexed by physical track, so restoring has to lay
        // the disc out the way this one was rather than work it out again.
        manifest[discKey + "Layout"] = driveDisc.is40Track ? DiscLayout.expanded40 : DiscLayout.contiguous;
        if (!manifest[discKey] && driveDisc.originalImageData) {
            manifest[discKey + "ImageData"] = driveDisc.originalImageData;
            manifest[discKey + "Name"] = driveDisc.name;
        }
    }
    return Object.keys(manifest).length > 0 ? manifest : undefined;
}
 
/** Saving and restoring states: the menu item, the file input and the reload across a model change. */
export class SnapshotUI {
    constructor({ processor, model, video, media, urlState, modals, loop }) {
        this.processor = processor;
        this.model = model;
        this.video = video;
        this.media = media;
        this.slots = media.slots;
        this.urlState = urlState;
        this.modals = modals;
        this.loop = loop;
 
        this.saveItem = document.getElementById("save-state");
        this.saveItem.addEventListener("click", async (event) => {
            event.preventDefault();
            await this.saveState();
        });
 
        document.getElementById("load-state").addEventListener("change", async (event) => {
            const file = event.target.files[0];
            if (!file) return;
            event.target.value = "";
            await this.loadStateFromFile(file);
        });
    }
 
    async saveState() {
        noteFeature(this.saveItem, "save");
        const resume = this.loop.hold("saving state");
        try {
            const manifest = snapshotMedia(this.slots);
            const snapshot = createSnapshot(this.processor, this.model, manifest);
            const json = snapshotToJSON(snapshot);
            const blob = new Blob([await gzipCodec.compress(json)]);
            const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
            downloadBlob(blob, `jsbeeb-${this.model.name}-${timestamp}.json.gz`);
        } catch (e) {
            this.modals.showError("saving state", e);
        } finally {
            resume();
        }
    }
 
    async loadStateFromFile(file, preReadBuffer) {
        noteFeature(this.saveItem, "load");
        const resume = this.loop.hold("loading state");
        try {
            const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
            const snapshot = await readSnapshot(arrayBuffer);
            if (!isSameModel(snapshot.model, this.model.name) || hasCoProcessor(snapshot) !== this.processor.hasTube) {
                leaveForNextPage(PendingStateKey, snapshotToJSON(snapshot));
                reloadAsMachine(this.urlState, { model: snapshot.model, coProcessor: hasCoProcessor(snapshot) });
                return true;
            }
            await this.restore(snapshot);
            // Force a repaint so the display updates even while paused
            this.video.paint();
            return true;
        } catch (e) {
            this.modals.showError("loading state", e);
            return false;
        } finally {
            resume();
        }
    }
 
    /** Picks up the state a cross-model reload stashed, once the matching machine is up. */
    async restorePendingState() {
        const pendingState = takeFromLastPage(PendingStateKey);
        if (pendingState === null) return;
        try {
            await this.restore(snapshotFromJSON(pendingState));
            if (!this.processor.debugInstruction.stopped) this.processor.execute(PostRestoreCycles);
        } catch (e) {
            this.modals.showError("restoring saved state", e);
        }
    }
 
    async restore(snapshot) {
        // Order matters: reload disc media first so the base disc is in the
        // drive before restoreSnapshot applies dirty track overlays on top.
        await this.reloadSnapshotMedia(snapshot.media);
        this.loop.replacingState();
        restoreSnapshot(this.processor, this.model, snapshot);
        // The drives' pitch and the recorder's latch came back with the state, behind the slots' backs.
        this.slots.restored();
        if (this.processor.debugInstruction.stopped) this.loop.stop(true);
    }
 
    async reloadSnapshotMedia(savedMedia) {
        // A state saved by a drive-less Atom can name the page's default disc in its hidden drive 0.
        if (!savedMedia || !this.model.hasDiscDrives) return;
        for (let driveIndex = 0; driveIndex < 2; driveIndex++) {
            const discKey = driveIndex === 0 ? "disc1" : "disc2";
            const imageDataKey = discKey + "ImageData";
            const crcKey = discKey + "Crc32";
 
            // A snapshot from before layout detection has no field, and was contiguous.
            const layout = savedMedia[discKey + "Layout"] ?? DiscLayout.contiguous;
 
            let loadedDisc = null;
            if (savedMedia[discKey]) {
                // URL-based disc — reload from source
                loadedDisc = await this.media.loadDiscImage(savedMedia[discKey], layout);
            } else if (savedMedia[imageDataKey]) {
                // Locally-loaded disc — reconstruct from embedded image data
                const imageData =
                    savedMedia[imageDataKey] instanceof Uint8Array
                        ? savedMedia[imageDataKey]
                        : new Uint8Array(Object.values(savedMedia[imageDataKey]));
                const discName = savedMedia[discKey + "Name"] || "snapshot.ssd";
                loadedDisc = disc.discFor(discName, imageData, undefined, layout);
                // Retain the image bytes so subsequent saves can re-embed them.
                loadedDisc.setOriginalImage(imageData);
            }
            if (!loadedDisc) {
                if (savedMedia[crcKey] != null) {
                    // A state may name no source (older default-boot saves); the disc
                    // already in the drive can still satisfy the CRC, but only laid out
                    // the way the state's dirty tracks expect.
                    const currentDisc = this.processor.fdc.drives[driveIndex].disc;
                    const currentLayout = currentDisc?.is40Track ? DiscLayout.expanded40 : DiscLayout.contiguous;
                    if (
                        currentDisc &&
                        currentDisc.originalImageCrc32 === savedMedia[crcKey] &&
                        currentLayout === layout
                    )
                        continue;
                    const problem = savedMedia[discKey]
                        ? `The disc for drive ${driveIndex} (${savedMedia[discKey]}) could not be reloaded`
                        : `This state does not record where the disc in drive ${driveIndex} came from`;
                    throw new Error(
                        `${problem}, and the drive does not hold a matching disc. ` +
                            `Load the right disc, then load the state again.`,
                    );
                }
                continue;
            }
 
            // A disc that keeps its changes at its source has moved on by design; the state's own
            // dirty tracks go back over it.
            if (
                !loadedDisc.savesChanges &&
                savedMedia[crcKey] != null &&
                loadedDisc.originalImageCrc32 != null &&
                loadedDisc.originalImageCrc32 !== savedMedia[crcKey]
            ) {
                throw new Error(
                    `${loadedDisc.name} has changed since this state was saved, so the state cannot be restored over it.`,
                );
            }
 
            // An embedded disc has no reference, which takes whatever the URL named before out of it.
            this.slots.put(this.slots.drive(driveIndex), loadedDisc, savedMedia[discKey], {
                recent: false,
                entry: "restore",
            });
        }
    }
}