All files / src disc-drive.js

83.75% Statements 165/197
86.9% Branches 73/84
63.63% Functions 42/66
84.35% Lines 151/179

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 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524                38x           38x 38x 38x 38x               252x 252x 16x 13x   252x 504x 8x 8x   504x 8x 8x   504x 504x             42x 42x 42x             18x 18x                                                                                                                                                                                                                                                                                     927939x         166791x                   692x 692x   692x     692x   692x 692x   692x   692x 692x   692x   692x 692x 692x   692x 692x             2610366x       185571x 185571x 185571x       1559358x 1559358x 1425207x       463968x 463968x 463968x 148446x 148446x 148446x             463968x   463968x 205631x     463968x     463968x 148446x 74249x   74197x 74197x     315522x     463968x   463968x 121x 121x   463968x         464176x 464176x     464176x       106x 106x       96x       8850x               928267x       331x 331x       927936x             663x           155377x       91x 91x 91x 91x 91x         19x 15x 15x 15x   19x       85x 85x 85x 85x             88x         6x       31x 2x 29x         1x         166893x   166791x       3x   3x 3x           3x       6x       316x                 246x 246x 246x                   49x 49x 49x 49x 49x 42x         24x 18x 18x 18x             246x 246x 246x 3x 3x 243x 43x 43x   246x 246x 246x       452x 374x     374x       94x                             56x 56x 56x 56x 56x 56x 56x     56x 56x 56x 56x 56x     56x 8x        
// Translated from beebjit by Chris Evans.
// https://github.com/scarybeasts/beebjit
// eslint-disable-next-line no-unused-vars
import { Scheduler } from "./scheduler.js";
// eslint-disable-next-line no-unused-vars
import { Disc } from "./disc.js";
import { IbmDiscFormat } from "./disc.js";
 
const SpinDebounceMs = 2;
/**
 * The spindle motor of a 5.25" drive reaches speed about half a second after it starts (drive
 * sheets give 400 to 500 ms) and coasts to a stop over a second or so: time constants of the
 * exponentials below, in cycles. The disc never quite stands still here, or the pulses would.
 */
const SpinUpTicks = 150 * 2000;
const SpinDownTicks = 1000 * 2000;
const LeastSpeed = 0.02;
const AtSpeed = 0.9999;
 
/**
 * Plays the spin and seek noises for a set of drives.
 * @param {BaseDiscDrive[]} drives
 * @param {import("./ddnoise.js").DdNoise|import("./ddnoise.js").FakeDdNoise} ddNoise
 */
export function attachDriveNoise(drives, ddNoise) {
    let numSpinning = 0;
    const updateSpinStatus = () => {
        if (numSpinning) ddNoise.spinUp();
        else ddNoise.spinDown();
    };
    for (const drive of drives) {
        drive.addEventListener("startSpinning", () => {
            numSpinning++;
            setTimeout(updateSpinStatus, SpinDebounceMs);
        });
        drive.addEventListener("stopSpinning", () => {
            numSpinning--;
            setTimeout(updateSpinStatus, SpinDebounceMs);
        });
        drive.addEventListener("seekStart", (evt) => ddNoise.seekStart(evt.steps, evt.stepMs));
        drive.addEventListener("seekEnd", (evt) => ddNoise.seekEnd(evt.steps));
    }
}
 
/** The head is about to take `steps` steps, one every `stepMs`. */
class SeekEvent extends Event {
    constructor(steps, stepMs) {
        super("seekStart");
        this.steps = steps;
        this.stepMs = stepMs;
    }
}
 
/** The head has stopped after `steps` of the steps it was about to take. */
class SeekEndEvent extends Event {
    constructor(steps) {
        super("seekEnd");
        this.steps = steps;
    }
}
 
/**
 * Abstract base class defining the interface for disc drives.
 * All disc drive implementations must extend this class.
 */
export class BaseDiscDrive extends EventTarget {
    /** @returns {Disc|undefined} */
    get disc() {
        throw new Error("Not implemented: disc getter");
    }
 
    /** @returns {number} */
    get track() {
        throw new Error("Not implemented: track getter");
    }
 
    /** @returns {number} */
    get headPosition() {
        throw new Error("Not implemented: headPosition getter");
    }
 
    /** @returns {boolean} */
    get isSideUpper() {
        throw new Error("Not implemented: isSideUpper getter");
    }
 
    /** @returns {Number} */
    get tracksPerStep() {
        throw new Error("Not implemented: tracksPerStep getter");
    }
 
    /** @param {Number} _tracksPerStep */
    set tracksPerStep(_tracksPerStep) {
        throw new Error("Not implemented: tracksPerStep setter");
    }
 
    /** @returns {boolean} */
    get indexPulse() {
        throw new Error("Not implemented: indexPulse getter");
    }
 
    /** @returns {boolean} */
    get spinning() {
        throw new Error("Not implemented: spinning getter");
    }
 
    /** @returns {boolean} */
    get writeProtect() {
        throw new Error("Not implemented: writeProtect getter");
    }
 
    /** @returns {number} */
    get trackLength() {
        throw new Error("Not implemented: trackLength getter");
    }
 
    /** @returns {number} */
    get positionFraction() {
        throw new Error("Not implemented: positionFraction getter");
    }
 
    /** @returns {number} */
    get positionTime() {
        throw new Error("Not implemented: positionTime getter");
    }
 
    /**
     * @param {Disc|undefined} _disc
     */
    setDisc(_disc) {
        throw new Error("Not implemented: setDisc");
    }
 
    /**
     * @param {function(number, number): void} _callback
     */
    setPulsesCallback(_callback) {
        throw new Error("Not implemented: setPulsesCallback");
    }
 
    startSpinning() {
        throw new Error("Not implemented: startSpinning");
    }
 
    stopSpinning() {
        throw new Error("Not implemented: stopSpinning");
    }
 
    /**
     * @param {boolean} _isSideUpper
     */
    selectSide(_isSideUpper) {
        throw new Error("Not implemented: selectSide");
    }
 
    /**
     * @param {number} _delta
     */
    seekOneTrack(_delta) {
        throw new Error("Not implemented: seekOneTrack");
    }
 
    /**
     * @param {number} _delta
     */
    notifySeekAmount(_delta, _stepMs) {
        throw new Error("Not implemented: notifySeekAmount");
    }
 
    notifySeekEnd() {
        throw new Error("Not implemented: notifySeekEnd");
    }
 
    /**
     * @param {boolean} _isDoubleDensity
     */
    set32usMode(_isDoubleDensity) {
        throw new Error("Not implemented: set32usMode");
    }
 
    /**
     * @param {number} _pulses
     */
    writePulses(_pulses) {
        throw new Error("Not implemented: writePulses");
    }
 
    /** @returns {number} */
    getQuasiRandomPulses() {
        throw new Error("Not implemented: getQuasiRandomPulses");
    }
}
 
export class DiscDrive extends BaseDiscDrive {
    static get TicksPerRevolution() {
        // 300 rpm
        return 400000;
    }
 
    // scarybeast's Chinon drive holds the index pulse low for about 4ms. */
    static get DiscIndexMs() {
        return 4;
    }
 
    /**
     * Create a new DiscDrive.
     *
     * @param {Number} id which drive id this is (0 or 1)
     * @param {Scheduler} scheduler scheduler to register callbacks etc
     */
    constructor(id, scheduler) {
        super();
        this._scheduler = scheduler;
        /** @type {Disc|undefined} */
        this._disc = undefined;
        // Two for a drive whose 40/80 switch is set to 40, which reaches a 48 tpi format by
        // stepping twice for each track the controller counts.
        this._tracksPerStep = 1;
        // Where the head is over the 96 tpi surface, whatever the controller believes.
        this._track = 0;
        this._isSideUpper = false;
        // In units where 3125 is a normal track length.
        this._headPosition = 0;
        // Extra precision for head position, needed for MFM.
        this._pulsePosition = 0;
        this._in32usMode = false;
        /** @type {function(number, number): void} */
        this._pulsesCallback = null;
 
        this._timer = this._scheduler.newTask(this._onTimer.bind(this));
        this._spinning = false;
        this._seekSteps = null;
        // Speed as a fraction of 300 rpm when the motor last started or stopped, and when.
        this._speedThen = 0;
        this._speedEpoch = 0;
    }
 
    /**
     * @returns {Disc|undefined}
     */
    get disc() {
        return this._disc;
    }
 
    getQuasiRandomPulses() {
        const ticks = this._scheduler.epoch;
        const fmData = (ticks ^ (ticks >>> 8) ^ (ticks >>> 16) ^ (ticks >>> 24)) & 0xff;
        return IbmDiscFormat.fmTo2usPulses(0xff, fmData);
    }
 
    get trackLength() {
        const disc = this.disc;
        if (!disc) return IbmDiscFormat.bytesPerTrack;
        return disc.getTrack(this._isSideUpper, this._track).length;
    }
 
    _onTimer() {
        let pulses = this.disc ? this.disc.readPulses(this._isSideUpper, this._track, this._headPosition) : 0;
        let numPulses = 32;
        if (this._pulsePosition === 16 || this._in32usMode) {
            numPulses = 16;
            if (this._pulsePosition === 0) pulses >>>= 16;
            pulses &= 0xffff;
        }
 
        // If there's an empty patch on the disc surface, the disc drive's head amplifier will typically desperately
        // seek for a signal in the noise, resulting in "weak bits". @scarybeasts verified this with an oscilloscope on
        // his Chinon F-051MD drive, which has a Motorola MC3470AP head amplifier. We need to return an inconsistent yet
        // deterministic set of weak bits.
        if (pulses === 0) pulses = this.getQuasiRandomPulses();
 
        if (this._pulsesCallback) {
            this._pulsesCallback(pulses, numPulses);
        }
 
        const thisTicks = this.positionTime;
 
        // Advance head position.
        if (numPulses === 16) {
            if (this._pulsePosition === 0) {
                this._pulsePosition = 16;
            } else {
                this._pulsePosition = 0;
                this._headPosition++;
            }
        } else {
            this._headPosition++;
        }
 
        const nextTicks = this.positionTime;
 
        if (this._headPosition === this.trackLength) {
            this._headPosition = 0;
            this._checkTrackNeedsWrite();
        }
        Eif (this._spinning) this._timer.reschedule(Math.round((nextTicks - thisTicks) / this.speed));
    }
 
    /** How fast the disc is turning, as a fraction of its rated speed. */
    get speed() {
        const elapsed = this._scheduler.epoch - this._speedEpoch;
        const speed = this._spinning
            ? 1 - (1 - this._speedThen) * Math.exp(-elapsed / SpinUpTicks)
            : this._speedThen * Math.exp(-elapsed / SpinDownTicks);
        return speed > AtSpeed ? 1 : Math.max(LeastSpeed, speed);
    }
 
    _noteSpeed() {
        this._speedThen = this.speed;
        this._speedEpoch = this._scheduler.epoch;
    }
 
    get headPosition() {
        return this._headPosition;
    }
 
    get track() {
        return this._track;
    }
 
    get isSideUpper() {
        return this._isSideUpper;
    }
 
    get positionFraction() {
        return (this._headPosition + this._pulsePosition / 32) / this.trackLength;
    }
 
    set positionFraction(fraction) {
        this._headPosition = (this.trackLength * fraction) | 0;
        this._pulsePosition = 0;
    }
 
    get positionTime() {
        return (this.positionFraction * DiscDrive.TicksPerRevolution) | 0;
    }
 
    /**
     * @param {function(number, number): void} callback
     */
    setPulsesCallback(callback) {
        this._pulsesCallback = callback;
    }
 
    get spinning() {
        // beebjit uses the timer's scheduledness here, but our schedule system deschedules timers
        // during callbacks, which makes this briefly "false" and disturbs things.
        return this._spinning;
    }
 
    startSpinning() {
        Eif (!this._spinning) {
            this._noteSpeed();
            this._spinning = true;
            this.dispatchEvent(new Event("startSpinning"));
            this._timer.schedule(1);
        }
    }
 
    stopSpinning() {
        if (this._spinning) {
            this._noteSpeed();
            this._spinning = false;
            this.dispatchEvent(new Event("stopSpinning"));
        }
        this._timer.cancel();
    }
 
    selectSide(isSideUpper) {
        const fraction = this.positionFraction;
        this._checkTrackNeedsWrite();
        this._isSideUpper = isSideUpper;
        this.positionFraction = fraction;
    }
 
    /**
     * @param {Disc} disc
     */
    setDisc(disc) {
        this._disc = disc;
    }
 
    /** @returns {Number} how many of the surface's tracks the head crosses for one of the format's */
    get tracksPerStep() {
        return this._tracksPerStep;
    }
 
    set tracksPerStep(tracksPerStep) {
        if (tracksPerStep !== 1 && tracksPerStep !== 2)
            throw new Error(`Drives step over one or two tracks at a time, not ${tracksPerStep}`);
        this._tracksPerStep = tracksPerStep;
    }
 
    /** @returns {Number} the track the controller believes the head is on */
    get logicalTrack() {
        return (this._track / this._tracksPerStep) | 0;
    }
 
    get indexPulse() {
        // With no disc loaded the drive asserts the index all the time.
        if (!this.disc) return true;
        // The 8271 datasheet says that the index pulse must be held for over 0.5us. Most drives are in the millisecond range.
        return this._headPosition < (this.trackLength * DiscDrive.DiscIndexMs) / 200;
    }
 
    writePulses(pulses) {
        Iif (!this.disc) return;
        // All drives seen have a write-protect failsafe on the drive itself.
        Iif (this.disc.writeProtected) return;
        Iif (this._in32usMode) {
            if (pulses & 0xffff0000) throw new Error(`Unable to write 32us pulses for ${pulses}`);
            const existingPulses = this.disc.readPulses(this._isSideUpper, this.track, this.headPosition);
            if (this._pulsePosition === 0) pulses = (existingPulses & 0x0000ffff) | (pulses << 16);
            else pulses = (existingPulses & 0xffff0000) | pulses;
        }
        this.disc.writePulses(this._isSideUpper, this.track, this.headPosition, pulses);
    }
 
    get writeProtect() {
        return this.disc ? this.disc.writeProtected : false;
    }
 
    set32usMode(isDoubleDensity) {
        this._in32usMode = isDoubleDensity;
    }
 
    /**
     * Seek a relative track.
     *
     * @param {Number} delta track step delta, either 1 or -1
     */
    seekOneTrack(delta) {
        const from = this._track;
        this._selectTrack(this._track + delta * this._tracksPerStep);
        if (this._seekSteps !== null && this._track !== from) ++this._seekSteps;
    }
 
    /**
     * The controller is about to seek `delta` of its tracks, a step every `stepMs`. Purely
     * informational: the noise follows it.
     */
    notifySeekAmount(delta, stepMs) {
        // The noise counts the steps the head takes: none past either end of the surface,
        // whatever the controller asked for, and a last one onto the edge though it falls short.
        const lastTrack = IbmDiscFormat.tracksPerDisc - this._tracksPerStep;
        const target = Math.min(lastTrack, Math.max(0, this._track + delta * this._tracksPerStep));
        const steps = Math.sign(delta) * Math.ceil(Math.abs(target - this._track) / this._tracksPerStep);
        this._seekSteps = steps ? 0 : null;
        if (!steps) return;
        this.dispatchEvent(new SeekEvent(steps, stepMs));
    }
 
    /** The controller has finished stepping, whether or not it got as far as it said. */
    notifySeekEnd() {
        if (this._seekSteps === null) return;
        const steps = this._seekSteps;
        this._seekSteps = null;
        this.dispatchEvent(new SeekEndEvent(steps));
    }
 
    /**
     * @param {Number} track
     */
    _selectTrack(track) {
        this._checkTrackNeedsWrite();
        const lastTrack = IbmDiscFormat.tracksPerDisc - this._tracksPerStep;
        if (track < 0) {
            track = 0;
            console.log("Clang! disc head stopped at track 0");
        } else if (track > lastTrack) {
            track = lastTrack;
            console.log("Clang! disc head stopper at track max");
        }
        const fraction = this.positionFraction;
        this._track = track;
        this.positionFraction = fraction;
    }
 
    _checkTrackNeedsWrite() {
        if (!this.disc) return;
        const written = this.disc.flushWrites();
        // A 48 tpi head writes across most of its band but not as far as the neighbouring 96 tpi
        // track, which is left in the guard band with nothing readable on it.
        if (written && this._tracksPerStep === 2) this.disc.eraseTrack(written.isSideUpper, written.trackNum ^ 1);
    }
 
    snapshotState() {
        return {
            track: this._track,
            isSideUpper: this._isSideUpper,
            headPosition: this._headPosition,
            pulsePosition: this._pulsePosition,
            in32usMode: this._in32usMode,
            spinning: this._spinning,
            speed: this.speed,
            is40Track: this._tracksPerStep === 2,
            timerTaskOffset: this._timer.scheduled() ? this._timer.expireEpoch - this._scheduler.epoch : null,
            disc: this._disc ? this._disc.snapshotState() : null,
        };
    }
 
    restoreState(state) {
        this._seekSteps = null;
        this._track = state.track;
        this._isSideUpper = state.isSideUpper;
        this._headPosition = state.headPosition;
        this._pulsePosition = state.pulsePosition;
        this._in32usMode = state.in32usMode;
        this._tracksPerStep = state.is40Track ? 2 : 1;
 
        // Restore spinning state and timer
        this._timer.cancel();
        this._spinning = state.spinning;
        this._speedThen = state.speed ?? 1;
        this._speedEpoch = this._scheduler.epoch;
        if (state.timerTaskOffset !== null) this._timer.schedule(state.timerTaskOffset);
 
        // Restore disc data if present
        if (state.disc && this._disc) {
            this._disc.restoreState(state.disc);
        }
    }
}