All files / src test-machine.js

0% Statements 0/132
0% Branches 0/57
0% Functions 0/40
0% Lines 0/117

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 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 
import { installBasic } from "./basic-loader.js";
import * as fdc from "./fdc.js";
import { fake6502 } from "./fake6502.js";
import { findModel } from "./models.js";
import assert from "assert";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { inspect } from "node:util";
import * as Tokeniser from "./basic-tokenise.js";
import { VduTextCapture } from "./vdu-capture.js";
import { setNodeBasePath } from "./loader.js";
import { Typist } from "./typist.js";
import { keyCodes } from "./keymap.js";
 
const MaxCyclesPerIter = 100 * 1000;
const RepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const HostKeyCodes = new Set(Object.values(keyCodes));
 
function requireKnownKeyCode(method, code) {
    if (typeof code === "number") {
        throw new Error(
            `${method}: ${code} is a numeric key code; since 2.0 keys are named by physical position, ` +
                `as KeyboardEvent.code names them, e.g. "ShiftLeft". See keyCodes in keymap.js`,
        );
    }
    if (!HostKeyCodes.has(code)) {
        const shown = typeof code === "string" ? JSON.stringify(code) : inspect(code);
        throw new Error(
            `${method}: ${shown} is not a key jsbeeb knows; keys are named ` +
                `by physical position, as KeyboardEvent.code names them, e.g. "ShiftLeft" or "KeyA". ` +
                `See keyCodes in keymap.js`,
        );
    }
}
 
export class TestMachine {
    constructor(model, opts) {
        model = model || "B-DFS1.2";
        this.model = findModel(model);
        if (!this.model) throw new Error(`Unknown model "${model}"`);
        this.processor = fake6502(this.model, opts || {});
        this.typist = new Typist(this.processor);
        this._capturedChars = [];
        this._captureHookInstalled = false;
    }
 
    /** The keyboard interface for this machine (SysVia for BBC, PPIA for Atom). */
    get _keyInterface() {
        return this.processor.keyboardInterface;
    }
 
    async initialise() {
        setNodeBasePath(RepoRoot);
        await this.processor.initialise();
    }
 
    /**
     * Calls `listener` with each character the machine sends to the VDU, by
     * watching the write-character vector (WRCHV, $020E on the BBC and $0208 on
     * the Atom) as the OS or a program leaves it. Returns a function that stops
     * listening.
     */
    onVduChar(listener) {
        if (!this._vduListeners) {
            this._vduListeners = [];
            const cpu = this.processor;
            const ram = cpu.ramRomOs;
            const wrchvAddr = this.model.wrchvAddress;
            cpu.debugInstruction.add((addr) => {
                if (addr === (ram[wrchvAddr] | (ram[wrchvAddr + 1] << 8))) {
                    for (const listen of this._vduListeners) listen(cpu.a);
                }
                return false;
            });
        }
        this._vduListeners.push(listener);
        return () => {
            this._vduListeners = this._vduListeners.filter((other) => other !== listener);
        };
    }
 
    /** Accumulates every character sent to the VDU for drainText(); safe to call more than once. */
    startCapture() {
        if (this._captureHookInstalled) return;
        this._captureHookInstalled = true;
        this.onVduChar((c) => this._capturedChars.push(c));
    }
 
    /**
     * Return all captured characters since the last drain (or since
     * startCapture was called), then clear the buffer.
     * @returns {number[]} array of character codes
     */
    drainCapturedChars() {
        const chars = this._capturedChars;
        this._capturedChars = [];
        return chars;
    }
 
    /**
     * Return captured text as a string (printable chars only, with
     * optional newline preservation), then clear the buffer.
     * @param {Object} [opts]
     * @param {boolean} [opts.raw=false] - if true, preserve newlines
     */
    drainText({ raw = false } = {}) {
        const chars = this.drainCapturedChars();
        return chars
            .map((c) => {
                if (raw && c === 10) return "\n";
                if (c === 13) return "";
                if (c >= 0x20 && c < 0x7f) return String.fromCharCode(c);
                return "";
            })
            .join("");
    }
 
    /**
     * Run for `cycles` emulated cycles, or until something stops the CPU.
     * Resolves true if it was stopped short.
     */
    runFor(cycles) {
        let left = cycles;
        let stopped = false;
        const cpu = this.processor;
        return new Promise((resolve) => {
            const runAnIter = () => {
                const todo = Math.max(0, Math.min(left, MaxCyclesPerIter));
                if (todo) {
                    stopped = !cpu.execute(todo);
                    left -= todo;
                }
                // execute() adds each request to a running targetCycles, so budget
                // left unspent by an early stop would silently lengthen the next run.
                if (stopped) cpu.targetCycles = cpu.currentCycles;
                // Not truthiness: a negative or NaN request clamps todo to zero,
                // so left would never move and the loop never end.
                if (left > 0 && !stopped) {
                    setTimeout(runAnIter, 0);
                } else {
                    resolve(stopped);
                }
            };
            runAnIter();
        });
    }
 
    /** Emulated cycles since power-on, undoing the per-second rebasing execute() applies. */
    get elapsedCycles() {
        const cpu = this.processor;
        return cpu.cycleSeconds * this.model.cyclesPerSecond + cpu.currentCycles;
    }
 
    async runUntilVblank() {
        let hit = false;
        if (this.processor.isMaster) throw new Error("Not yet implemented");
        const hook = this.processor.debugInstruction.add((addr) => {
            if (addr === 0xdd15) {
                hit = true;
                return true;
            }
        });
        await this.runFor(10 * 1000 * 1000);
        hook.remove();
        assert(hit, "did not hit appropriate breakpoint in time");
    }
 
    async runUntilInput(secs) {
        if (!secs) secs = 120;
        console.log("Running until keyboard input requested");
        const idleAddr = this.model.idleAddress;
        let hit = false;
        const hook = this.processor.debugInstruction.add((addr) => {
            if (addr === idleAddr) {
                hit = true;
                return true;
            }
        });
        await this.runFor(secs * this.model.cyclesPerSecond);
        hook.remove();
        assert(hit, "did not hit appropriate breakpoint in time");
        return this.runFor(10 * 1000);
    }
 
    async runUntilAddress(targetAddr, secs) {
        if (!secs) secs = 120;
        let hit = false;
        const hook = this.processor.debugInstruction.add((addr) => {
            if (addr === targetAddr) {
                hit = true;
                return true;
            }
        });
        await this.runFor(secs * this.model.cyclesPerSecond);
        hook.remove();
        assert(hit, "did not hit appropriate breakpoint in time");
    }
 
    async loadDisc(image) {
        const data = await fdc.load(image);
        this.processor.fdc.loadDisc(0, fdc.discFor(image, data));
    }
 
    /**
     * Load a disc image from raw data (Uint8Array or Buffer).
     * @param {Uint8Array|Buffer} data - raw disc image bytes
     */
    loadDiscData(data) {
        this.processor.fdc.loadDisc(0, fdc.discFor("", data));
    }
 
    /**
     * Reset the machine.
     * @param {boolean} hard - true for power-on reset, false for soft reset
     */
    reset(hard) {
        this.processor.reset(hard);
    }
 
    /**
     * Take a snapshot of the entire machine state (CPU, RAM, SWRAM,
     * VIAs, video, FDC, etc). Returns an opaque state object that
     * can be passed to restore().
     */
    snapshot({ includeRoms = true } = {}) {
        return this.processor.snapshotState({ includeRoms });
    }
 
    /**
     * Restore a previously saved snapshot. The machine will be in
     * exactly the state it was when snapshot() was called.
     */
    restore(state) {
        this.processor.restoreState(state);
    }
 
    async loadBasic(source) {
        const tokeniser = await Tokeniser.create();
        const tokenised = tokeniser.tokenise(source);
        installBasic(tokenised, {
            readByte: (addr) => this.readbyte(addr),
            writeByte: (addr, value) => this.writebyte(addr, value),
        });
    }
 
    /**
     * Types the text and a RETURN at the machine, running the CPU until the
     * last key is up. The keys arrive from the scheduler, so a breakpoint that
     * stops the CPU part way leaves the rest to be typed when it runs again.
     */
    async type(text) {
        const lines = text.replace(/\r\n?/g, "\n");
        this.typist.type(this.model.stringToKeys(lines + "\n"), true);
        while (this.typist.isTyping) {
            const stopped = await this.runFor(MaxCyclesPerIter);
            if (stopped) break;
        }
    }
 
    /**
     * Press a key on the keyboard. Throws on a code that is not in `keyCodes`.
     * @param {string} code - the host key by physical position, as `keyCodes` in keymap.js names it
     * @param {boolean} [shiftDown] - whether the host's shift is held, which only a natural layout maps on
     */
    keyDown(code, shiftDown = false) {
        requireKnownKeyCode("keyDown", code);
        this._keyInterface.keyDown(code, shiftDown);
    }
 
    /**
     * Release a key on the keyboard. Throws on a code that is not in `keyCodes`.
     * @param {string} code - the host key by physical position
     */
    keyUp(code) {
        requireKnownKeyCode("keyUp", code);
        this._keyInterface.keyUp(code);
    }
 
    /**
     * Load a ROM image directly into a sideways RAM slot.
     * @param {number} slot - slot number (0-15, typically 4-7 for SWRAM)
     * @param {Uint8Array|Buffer} data - ROM data (up to 16384 bytes)
     */
    loadSidewaysRam(slot, data) {
        const offset = this.processor.romOffset + slot * 16384;
        for (let i = 0; i < data.length && i < 16384; i++) {
            this.processor.ramRomOs[offset + i] = data[i];
        }
    }
 
    writebyte(addr, val) {
        this.processor.writemem(addr, val);
    }
 
    readbyte(addr) {
        return this.processor.readmem(addr);
    }
 
    readword(addr) {
        return this.readbyte(addr) | (this.readbyte(addr + 1) << 8);
    }
 
    /**
     * Decodes the machine's VDU output into text elements for `onElement`
     * until the returned capture's `stop()` is called; see VduTextCapture.
     */
    captureText(onElement) {
        const capture = new VduTextCapture(onElement, { isAtom: this.model.isAtom });
        capture.stop = this.onVduChar((c) => capture.onChar(c));
        return capture;
    }
}