jsbeeb 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/package.json +15 -6
- package/src/6502.js +14 -7
- package/src/6847.js +50 -8
- package/src/acia.js +2 -1
- package/src/app/app.js +4 -0
- package/src/bbcdiscs.js +84 -0
- package/src/canvas.js +85 -19
- package/src/disc-drive.js +54 -12
- package/src/disc-hfe.js +19 -3
- package/src/disc-surface.js +350 -0
- package/src/disc-visualiser.js +569 -0
- package/src/disc.js +226 -62
- package/src/econet.js +6 -2
- package/src/fdc.js +83 -11
- package/src/google-drive.js +4 -4
- package/src/intel-fdc.js +8 -1
- package/src/jsbeeb.css +191 -0
- package/src/keyboard.js +14 -15
- package/src/machine-session.js +2 -1
- package/src/main.js +389 -152
- package/src/models.js +15 -3
- package/src/sth.js +25 -22
- package/src/tapes.js +10 -12
- package/src/teletext_adaptor.js +6 -4
- package/src/touchscreen.js +6 -6
- package/src/tube.js +89 -37
- package/src/url-params.js +28 -0
- package/src/utils.js +3 -0
- package/src/video-filters/pal-composite.js +14 -48
- package/src/video-filters/passthrough-filter.js +11 -39
- package/src/video-filters/pixel-grid.js +55 -0
- package/src/video-filters/shader-program.js +53 -0
- package/src/video-filters/shaders/xbr.frag.glsl +278 -0
- package/src/video-filters/shaders/xbr.vert.glsl +7 -0
- package/src/video-filters/xbr-filter.js +107 -0
- package/src/video.js +56 -16
- package/src/wd-fdc.js +25 -8
- package/src/web/audio-handler.js +4 -2
- package/src/web/toast.js +79 -0
- package/tests/test-machine.js +8 -6
package/src/disc.js
CHANGED
|
@@ -257,10 +257,12 @@ class Sector {
|
|
|
257
257
|
* @param {Track} track
|
|
258
258
|
* @param {boolean} isMfm
|
|
259
259
|
* @param {Number} idPosBitOffset
|
|
260
|
+
* @param {function(string): void} [warn] where to report anomalies; the console by default
|
|
260
261
|
*/
|
|
261
|
-
constructor(track, isMfm, idPosBitOffset) {
|
|
262
|
+
constructor(track, isMfm, idPosBitOffset, warn = console.log) {
|
|
262
263
|
this.track = track;
|
|
263
264
|
this.isMfm = isMfm;
|
|
265
|
+
this._warn = warn;
|
|
264
266
|
this.idPosBitOffset = idPosBitOffset;
|
|
265
267
|
this.dataPosBitOffset = null;
|
|
266
268
|
this.isDeleted = false;
|
|
@@ -271,7 +273,7 @@ class Sector {
|
|
|
271
273
|
const idReader = this._readerAt(this.idPosBitOffset);
|
|
272
274
|
const { data: headerData, iffyPulses } = idReader.read(6);
|
|
273
275
|
if (iffyPulses) {
|
|
274
|
-
|
|
276
|
+
this._warn(`Iffy pulse in sector header ${this.description}`);
|
|
275
277
|
}
|
|
276
278
|
this.header = headerData;
|
|
277
279
|
let crc = idReader.initialCrc;
|
|
@@ -304,7 +306,7 @@ class Sector {
|
|
|
304
306
|
read(nextSector) {
|
|
305
307
|
const pulsesPerByte = this.isMfm ? 16 : 32; // todo put in reader
|
|
306
308
|
if (this.dataPosBitOffset === null) {
|
|
307
|
-
|
|
309
|
+
this._warn(`Sector header without data ${this.description}`);
|
|
308
310
|
return;
|
|
309
311
|
}
|
|
310
312
|
|
|
@@ -331,7 +333,7 @@ class Sector {
|
|
|
331
333
|
sectorSize = sectorSize >>> 1;
|
|
332
334
|
} while (sectorSize >= 128);
|
|
333
335
|
if (seenIffyData) {
|
|
334
|
-
|
|
336
|
+
this._warn(`Iffy pulse in sector data ${this.description}`);
|
|
335
337
|
}
|
|
336
338
|
}
|
|
337
339
|
|
|
@@ -355,6 +357,61 @@ class Sector {
|
|
|
355
357
|
}
|
|
356
358
|
}
|
|
357
359
|
|
|
360
|
+
/**
|
|
361
|
+
* A 64 bit shift register, held as a pair of unsigned 32 bit halves. BigInt would say this more
|
|
362
|
+
* directly but costs an order of magnitude more per bit shifted.
|
|
363
|
+
*/
|
|
364
|
+
export class BitWindow64 {
|
|
365
|
+
constructor() {
|
|
366
|
+
this.hi = 0;
|
|
367
|
+
this.lo = 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* @param {Number} bit 0 or 1, shifted into bit 0
|
|
372
|
+
* @returns {Number} the bit shifted out of bit 63
|
|
373
|
+
*/
|
|
374
|
+
shiftIn(bit) {
|
|
375
|
+
const shiftedOut = this.hi >>> 31;
|
|
376
|
+
this.hi = ((this.hi << 1) | (this.lo >>> 31)) >>> 0;
|
|
377
|
+
this.lo = ((this.lo << 1) | bit) >>> 0;
|
|
378
|
+
return shiftedOut;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @returns {boolean} whether all 64 bits match the halves given
|
|
383
|
+
*/
|
|
384
|
+
equals(hi, lo) {
|
|
385
|
+
return this.hi === hi && this.lo === lo;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* @param {Number} nibble
|
|
390
|
+
* @returns {Number} the length of the run of `nibble` ending at bit 0
|
|
391
|
+
*/
|
|
392
|
+
countTrailingNibbles(nibble) {
|
|
393
|
+
const nibblesPerHalf = 8;
|
|
394
|
+
let count = 0;
|
|
395
|
+
for (let bits = this.lo; (bits & 0xf) === nibble; bits >>>= 4) count++;
|
|
396
|
+
// Only a low half that matched all the way up can have a run continuing into the high half.
|
|
397
|
+
if (count === nibblesPerHalf) for (let bits = this.hi; (bits & 0xf) === nibble; bits >>>= 4) count++;
|
|
398
|
+
return count;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// What the mark detector holds at an address mark. The FM case only pins down the high half,
|
|
403
|
+
// the tail of the zero sync run; the low half is the marker byte itself, decoded separately.
|
|
404
|
+
const FmSyncHi = 0x88888888;
|
|
405
|
+
const MfmMarkerHi = 0xaaaa4489;
|
|
406
|
+
const MfmMarkerLo = 0x44894489;
|
|
407
|
+
// One data bit of zero, FM encoded with its clock, is the pulse nibble 0x8.
|
|
408
|
+
const FmZeroBitPulses = 0x8;
|
|
409
|
+
// FmSyncHi is eight of those, so a match has already seen this much of the sync run.
|
|
410
|
+
const FmSyncHiZeroBits = 8;
|
|
411
|
+
// Sync is counted in zero data bits, so a run no longer than two bytes' worth is short enough
|
|
412
|
+
// to be worth logging.
|
|
413
|
+
const ShortSyncZeros = 16;
|
|
414
|
+
|
|
358
415
|
class Track {
|
|
359
416
|
constructor(upper, trackNum, initialByte) {
|
|
360
417
|
this.length = IbmDiscFormat.bytesPerTrack; // Default size, will be updated when track is populated
|
|
@@ -371,10 +428,11 @@ class Track {
|
|
|
371
428
|
|
|
372
429
|
/**
|
|
373
430
|
* Debug functionality to try and interpret the track.
|
|
431
|
+
* @param {function(string): void} [warn] where to report anomalies; the console by default
|
|
374
432
|
* @returns {Sector[]}
|
|
375
433
|
*/
|
|
376
|
-
findSectors() {
|
|
377
|
-
const sectors = this.findSectorIds();
|
|
434
|
+
findSectors(warn = console.log) {
|
|
435
|
+
const sectors = this.findSectorIds(warn);
|
|
378
436
|
for (let sectorIndex = 0; sectorIndex !== sectors.length; ++sectorIndex) {
|
|
379
437
|
const nextSector = sectors[sectorIndex + 1]; // Will be unset for last
|
|
380
438
|
sectors[sectorIndex].read(nextSector);
|
|
@@ -383,9 +441,10 @@ class Track {
|
|
|
383
441
|
}
|
|
384
442
|
|
|
385
443
|
/**
|
|
444
|
+
* @param {function(string): void} [warn] where to report anomalies; the console by default
|
|
386
445
|
* @returns {Sector[]}
|
|
387
446
|
*/
|
|
388
|
-
findSectorIds() {
|
|
447
|
+
findSectorIds(warn = console.log) {
|
|
389
448
|
const sectors = [];
|
|
390
449
|
// Pass 1: walk the track and find header and data markers.
|
|
391
450
|
const bitLength = this.length * 32;
|
|
@@ -394,40 +453,30 @@ class Track {
|
|
|
394
453
|
let doMfmMarkerByte = false;
|
|
395
454
|
let isMfm = false;
|
|
396
455
|
let pulses = 0;
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
const
|
|
401
|
-
const fmMarker = 0x8888888800000000n;
|
|
402
|
-
const mfmMarker = 0xaaaa448944894489n;
|
|
456
|
+
// The mark detector is a 64 bit sliding window over the pulse stream; the bits leaving it
|
|
457
|
+
// spill into a second window, which the sync run length is counted from.
|
|
458
|
+
const markDetector = new BitWindow64();
|
|
459
|
+
const markDetectorPrev = new BitWindow64();
|
|
403
460
|
let dataByte;
|
|
404
461
|
let sector = null;
|
|
405
462
|
for (let pulseIndex = 0; pulseIndex < bitLength; ++pulseIndex) {
|
|
406
463
|
if ((pulseIndex & 31) === 0) pulses = this.pulses2Us[pulseIndex >>> 5];
|
|
407
|
-
|
|
408
|
-
markDetectorPrev
|
|
409
|
-
|
|
410
|
-
shiftRegister = (shiftRegister << 1) & 0xffffffff;
|
|
464
|
+
const pulseBit = pulses >>> 31;
|
|
465
|
+
markDetectorPrev.shiftIn(markDetector.shiftIn(pulseBit));
|
|
466
|
+
shiftRegister = ((shiftRegister << 1) | pulseBit) & 0xffffffff;
|
|
411
467
|
numShifts++;
|
|
412
|
-
if (pulses & 0x80000000) {
|
|
413
|
-
markDetector |= 1n;
|
|
414
|
-
shiftRegister |= 1;
|
|
415
|
-
}
|
|
416
468
|
pulses = (pulses << 1) & 0xffffffff;
|
|
417
|
-
if (
|
|
418
|
-
const { clocks, data, iffyPulses } = IbmDiscFormat._2usPulsesToFm(
|
|
469
|
+
if (markDetector.hi === FmSyncHi) {
|
|
470
|
+
const { clocks, data, iffyPulses } = IbmDiscFormat._2usPulsesToFm(markDetector.lo);
|
|
419
471
|
if (iffyPulses || clocks !== IbmDiscFormat.markClockPattern) continue;
|
|
420
472
|
isMfm = false;
|
|
421
473
|
doMfmMarkerByte = false;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
if (num0s <= 16) {
|
|
427
|
-
console.log(`Short zeros sync ${this.description}`);
|
|
474
|
+
const num0s = FmSyncHiZeroBits + markDetectorPrev.countTrailingNibbles(FmZeroBitPulses);
|
|
475
|
+
if (num0s <= ShortSyncZeros) {
|
|
476
|
+
warn(`Short zeros sync ${this.description}`);
|
|
428
477
|
}
|
|
429
478
|
dataByte = data;
|
|
430
|
-
} else if (markDetector
|
|
479
|
+
} else if (markDetector.equals(MfmMarkerHi, MfmMarkerLo)) {
|
|
431
480
|
// Next byte is MFM marker.
|
|
432
481
|
isMfm = true;
|
|
433
482
|
doMfmMarkerByte = true;
|
|
@@ -442,7 +491,7 @@ class Track {
|
|
|
442
491
|
}
|
|
443
492
|
switch (dataByte) {
|
|
444
493
|
case IbmDiscFormat.idMarkDataPattern: {
|
|
445
|
-
sector = new Sector(this, isMfm, pulseIndex + 1);
|
|
494
|
+
sector = new Sector(this, isMfm, pulseIndex + 1, warn);
|
|
446
495
|
sectors.push(sector);
|
|
447
496
|
shiftRegister = 0;
|
|
448
497
|
numShifts = 0;
|
|
@@ -451,7 +500,7 @@ class Track {
|
|
|
451
500
|
case IbmDiscFormat.dataMarkDataPattern:
|
|
452
501
|
case IbmDiscFormat.deletedDataMarkDataPattern:
|
|
453
502
|
if (!sector || sector.dataPosBitOffset) {
|
|
454
|
-
|
|
503
|
+
warn(
|
|
455
504
|
`Sector data without header ${this.description}; mark bitpos ${pulseIndex}; previous good sector ${sector ? sector.description : "none"}`,
|
|
456
505
|
);
|
|
457
506
|
} else {
|
|
@@ -464,7 +513,7 @@ class Track {
|
|
|
464
513
|
}
|
|
465
514
|
break;
|
|
466
515
|
default:
|
|
467
|
-
|
|
516
|
+
warn(`Unknown marker byte ${hexbyte(dataByte)} ${this.description}`);
|
|
468
517
|
}
|
|
469
518
|
}
|
|
470
519
|
return sectors;
|
|
@@ -478,6 +527,13 @@ class Side {
|
|
|
478
527
|
}
|
|
479
528
|
}
|
|
480
529
|
|
|
530
|
+
/** How an image's tracks are laid out on the surface, and whether that is for the loader to decide. */
|
|
531
|
+
export const DiscLayout = Object.freeze({
|
|
532
|
+
auto: "auto",
|
|
533
|
+
contiguous: "contiguous",
|
|
534
|
+
expanded40: "expanded40",
|
|
535
|
+
});
|
|
536
|
+
|
|
481
537
|
export class DiscConfig {
|
|
482
538
|
constructor() {
|
|
483
539
|
// TODO is this even useful?
|
|
@@ -504,6 +560,74 @@ class SsdFormat {
|
|
|
504
560
|
static get tracksPerDisc() {
|
|
505
561
|
return 80;
|
|
506
562
|
}
|
|
563
|
+
|
|
564
|
+
static get trackSize() {
|
|
565
|
+
return SsdFormat.sectorSize * SsdFormat.sectorsPerTrack;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** @returns {Number} how many logical tracks of `data` hold anything, ignoring a run of trailing zeros */
|
|
570
|
+
function tracksWithData(data, numSides) {
|
|
571
|
+
let lastUsed = data.length - 1;
|
|
572
|
+
while (lastUsed >= 0 && data[lastUsed] === 0) lastUsed--;
|
|
573
|
+
return Math.floor(lastUsed / (SsdFormat.trackSize * numSides)) + 1;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// The DFS catalogue is the first two sectors of a side. Sector 1 holds how many files there are and
|
|
577
|
+
// how many sectors the disc has.
|
|
578
|
+
const DfsEntryCountOffset = 0x105;
|
|
579
|
+
const DfsSectorCountOffset = 0x106;
|
|
580
|
+
const DfsEntrySize = 8;
|
|
581
|
+
const DfsMaxEntries = 31;
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Whether an SSD or DSD image was written by a 40 track drive. Only its catalogue can say: images
|
|
585
|
+
* are routinely padded out or cut short, so their size means nothing.
|
|
586
|
+
*
|
|
587
|
+
* @param {Uint8Array} data
|
|
588
|
+
* @param {boolean} isDsd
|
|
589
|
+
* @returns {{is40Track: boolean, reason: string}}
|
|
590
|
+
*/
|
|
591
|
+
export function sniffDfsLayout(data, isDsd) {
|
|
592
|
+
const contiguous = (reason) => ({ is40Track: false, reason });
|
|
593
|
+
if (data.length < 2 * SsdFormat.sectorSize) return contiguous("it is smaller than a catalogue");
|
|
594
|
+
const entryBytes = data[DfsEntryCountOffset];
|
|
595
|
+
if (entryBytes % DfsEntrySize !== 0 || entryBytes > DfsMaxEntries * DfsEntrySize)
|
|
596
|
+
return contiguous(`its catalogue claims ${entryBytes} bytes of file entries`);
|
|
597
|
+
const sectors = ((data[DfsSectorCountOffset] & 3) << 8) | data[DfsSectorCountOffset + 1];
|
|
598
|
+
const reason = `its catalogue claims ${sectors} sectors`;
|
|
599
|
+
const fortyTrackSectors = (SsdFormat.tracksPerDisc / 2) * SsdFormat.sectorsPerTrack;
|
|
600
|
+
if (sectors === 0 || sectors > fortyTrackSectors) return contiguous(reason);
|
|
601
|
+
const tracks = tracksWithData(data, isDsd ? 2 : 1);
|
|
602
|
+
if (tracks > IbmDiscFormat.tracksPerDisc / 2) return contiguous(`it holds data as far as track ${tracks - 1}`);
|
|
603
|
+
return { is40Track: true, reason };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// One track could match by luck; a disc's worth of them could not.
|
|
607
|
+
const MinDoubleSteppedTracks = 4;
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Whether a surface holds a 40 track format, going by where its sectors say they are: a track
|
|
611
|
+
* written by a 48 tpi head sits at twice the number its own headers claim, with nothing readable
|
|
612
|
+
* on the tracks between. Only a flux image can be asked this, since for any other format the
|
|
613
|
+
* layout is the loader's own doing.
|
|
614
|
+
*
|
|
615
|
+
* @param {Disc} disc
|
|
616
|
+
* @returns {{is40Track: boolean, reason: string}}
|
|
617
|
+
*/
|
|
618
|
+
export function sniffSurfaceLayout(disc) {
|
|
619
|
+
let doubleStepped = 0;
|
|
620
|
+
// Both heads move together, so a disc has one pitch and the side that boots can speak for it.
|
|
621
|
+
// Track 0 is where it claims to be at either pitch, so it says nothing.
|
|
622
|
+
for (let trackNum = 1; trackNum < disc.tracksUsed; ++trackNum) {
|
|
623
|
+
const sectors = disc.getTrack(false, trackNum).findSectorIds(() => {});
|
|
624
|
+
if (!sectors.length) continue;
|
|
625
|
+
if (trackNum & 1) return { is40Track: false, reason: `track ${trackNum} holds sectors of its own` };
|
|
626
|
+
if (sectors.some((sector) => sector.trackNumber === trackNum / 2)) doubleStepped++;
|
|
627
|
+
}
|
|
628
|
+
if (doubleStepped < MinDoubleSteppedTracks)
|
|
629
|
+
return { is40Track: false, reason: `only ${doubleStepped} tracks sit at twice their own number` };
|
|
630
|
+
return { is40Track: true, reason: `${doubleStepped} tracks sit at twice the number their sectors claim` };
|
|
507
631
|
}
|
|
508
632
|
|
|
509
633
|
/**
|
|
@@ -527,10 +651,19 @@ export function loadSsd(disc, data, isDsd, onChange) {
|
|
|
527
651
|
throw new Error("SSD file is too large");
|
|
528
652
|
}
|
|
529
653
|
|
|
654
|
+
disc.is40Track = disc.config.expandTo80;
|
|
655
|
+
const trackStep = disc.is40Track ? 2 : 1;
|
|
656
|
+
// Tracks twice as far apart are half as many, and an image can run past the last of them as
|
|
657
|
+
// far as the surface has room for.
|
|
658
|
+
const numTracks = Math.min(
|
|
659
|
+
IbmDiscFormat.tracksPerDisc / trackStep,
|
|
660
|
+
Math.max(SsdFormat.tracksPerDisc / trackStep, tracksWithData(data, numSides)),
|
|
661
|
+
);
|
|
662
|
+
|
|
530
663
|
let offset = 0;
|
|
531
|
-
for (let track = 0; track <
|
|
664
|
+
for (let track = 0; track < numTracks; ++track) {
|
|
532
665
|
for (let side = 0; side < numSides; ++side) {
|
|
533
|
-
const trackBuilder = disc.buildTrack(side === 1, track);
|
|
666
|
+
const trackBuilder = disc.buildTrack(side === 1, track * trackStep);
|
|
534
667
|
// Sync pattern at start of track, as the index pulse starts, aka GAP 5.
|
|
535
668
|
trackBuilder
|
|
536
669
|
.appendRepeatFmByte(0xff, IbmDiscFormat.stdGap1FFs)
|
|
@@ -580,16 +713,11 @@ export function loadSsd(disc, data, isDsd, onChange) {
|
|
|
580
713
|
// Create a dataCopy large enough for all the sectors and tracks.
|
|
581
714
|
const dataCopy = new Uint8Array(maxSize);
|
|
582
715
|
dataCopy.set(data);
|
|
583
|
-
disc.
|
|
716
|
+
disc.addTrackWriteListener(
|
|
584
717
|
/** @param {Track} trackObj */
|
|
585
|
-
(side,
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
for (const sector of trackObj.findSectors()) {
|
|
589
|
-
const sectorOffset = sector.sectorNumber * SsdFormat.sectorSize;
|
|
590
|
-
for (let x = 0; x < SsdFormat.sectorSize; ++x)
|
|
591
|
-
dataCopy[trackOffset + sectorOffset + x] = sector.sectorData[x];
|
|
592
|
-
}
|
|
718
|
+
(side, _trackNum, trackObj) => {
|
|
719
|
+
for (const sector of trackObj.findSectors())
|
|
720
|
+
if (!sectorShortfall(sector)) dataCopy.set(sector.sectorData, ssdOffsetOf(sector, side, numSides));
|
|
593
721
|
onChange(dataCopy);
|
|
594
722
|
},
|
|
595
723
|
);
|
|
@@ -673,17 +801,30 @@ export function loadAdf(disc, data, isDsd) {
|
|
|
673
801
|
}
|
|
674
802
|
|
|
675
803
|
/** Why a sector will not fit in an SSD or DSD image, or null if it will. */
|
|
676
|
-
function sectorShortfall(sector
|
|
804
|
+
function sectorShortfall(sector) {
|
|
677
805
|
if (sector.hasDataCrcError || sector.hasHeaderCrcError) return "with a CRC error";
|
|
678
806
|
// A header whose data mark never arrives leaves the sector with nothing to write.
|
|
679
807
|
if (!sector.sectorData) return "with no data";
|
|
680
808
|
if (sector.sectorNumber >= SsdFormat.sectorsPerTrack)
|
|
681
809
|
return `numbered past the ${SsdFormat.sectorsPerTrack} a track holds`;
|
|
682
810
|
if (sector.sectorData.length !== SsdFormat.sectorSize) return `not ${SsdFormat.sectorSize} bytes`;
|
|
683
|
-
if (
|
|
811
|
+
if (sector.trackNumber >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
|
|
684
812
|
return null;
|
|
685
813
|
}
|
|
686
814
|
|
|
815
|
+
/**
|
|
816
|
+
* Where a sector belongs in an SSD or DSD image, which is the track its own header claims rather
|
|
817
|
+
* than the one it sits on.
|
|
818
|
+
*
|
|
819
|
+
* @param {Sector} sector
|
|
820
|
+
* @param {boolean} isSideUpper
|
|
821
|
+
* @param {Number} numSides
|
|
822
|
+
*/
|
|
823
|
+
function ssdOffsetOf(sector, isSideUpper, numSides) {
|
|
824
|
+
const track = sector.trackNumber * numSides + (isSideUpper ? 1 : 0);
|
|
825
|
+
return track * SsdFormat.trackSize + sector.sectorNumber * SsdFormat.sectorSize;
|
|
826
|
+
}
|
|
827
|
+
|
|
687
828
|
/**
|
|
688
829
|
* SSD and DSD images hold sector contents and nothing else, so anything a DFS sector could not
|
|
689
830
|
* have held is lost. Copy protection usually shows up as one of these.
|
|
@@ -696,7 +837,7 @@ export function ssdOrDsdShortfalls(disc) {
|
|
|
696
837
|
for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
|
|
697
838
|
for (const upper of disc.isDoubleSided ? [false, true] : [false]) {
|
|
698
839
|
for (const sector of disc.getTrack(upper, trackNum).findSectors()) {
|
|
699
|
-
const shortfall = sectorShortfall(sector
|
|
840
|
+
const shortfall = sectorShortfall(sector);
|
|
700
841
|
if (shortfall) counts.set(shortfall, (counts.get(shortfall) ?? 0) + 1);
|
|
701
842
|
}
|
|
702
843
|
}
|
|
@@ -723,22 +864,19 @@ export function toSsdOrDsd(disc, { force = false } = {}) {
|
|
|
723
864
|
);
|
|
724
865
|
}
|
|
725
866
|
const numSides = disc.isDoubleSided ? 2 : 1;
|
|
726
|
-
const result = new Uint8Array(
|
|
727
|
-
|
|
728
|
-
);
|
|
729
|
-
let offset = 0;
|
|
867
|
+
const result = new Uint8Array(numSides * SsdFormat.tracksPerDisc * SsdFormat.trackSize);
|
|
868
|
+
let numTracks = 0;
|
|
730
869
|
for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
|
|
731
870
|
for (let side = 0; side < numSides; ++side) {
|
|
732
871
|
const trackObj = disc.getTrack(side === 1, trackNum);
|
|
733
872
|
for (const sector of trackObj.findSectors()) {
|
|
734
|
-
if (sectorShortfall(sector
|
|
735
|
-
|
|
736
|
-
|
|
873
|
+
if (sectorShortfall(sector)) continue;
|
|
874
|
+
result.set(sector.sectorData, ssdOffsetOf(sector, side === 1, numSides));
|
|
875
|
+
numTracks = Math.max(numTracks, sector.trackNumber + 1);
|
|
737
876
|
}
|
|
738
|
-
offset += SsdFormat.sectorsPerTrack * SsdFormat.sectorSize;
|
|
739
877
|
}
|
|
740
878
|
}
|
|
741
|
-
return result.slice(0,
|
|
879
|
+
return result.slice(0, numTracks * numSides * SsdFormat.trackSize);
|
|
742
880
|
}
|
|
743
881
|
|
|
744
882
|
export class Disc {
|
|
@@ -763,8 +901,10 @@ export class Disc {
|
|
|
763
901
|
this.dirtyTrack = -1;
|
|
764
902
|
this.tracksUsed = 0;
|
|
765
903
|
this.isDoubleSided = false;
|
|
904
|
+
// Whether the surface holds a 48 tpi layout, which a drive reads by double stepping.
|
|
905
|
+
this.is40Track = false;
|
|
766
906
|
|
|
767
|
-
this.
|
|
907
|
+
this._trackWriteListeners = new Set();
|
|
768
908
|
this.isWriteable = isWriteable;
|
|
769
909
|
|
|
770
910
|
// Track which tracks have been written since the last snapshot.
|
|
@@ -791,8 +931,14 @@ export class Disc {
|
|
|
791
931
|
this.initSurface(0);
|
|
792
932
|
}
|
|
793
933
|
|
|
794
|
-
|
|
795
|
-
|
|
934
|
+
/** @param {function(boolean, Number, Track): void} listener called once per flushed track */
|
|
935
|
+
addTrackWriteListener(listener) {
|
|
936
|
+
this._trackWriteListeners.add(listener);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** @param {function(boolean, Number, Track): void} listener */
|
|
940
|
+
removeTrackWriteListener(listener) {
|
|
941
|
+
this._trackWriteListeners.delete(listener);
|
|
796
942
|
}
|
|
797
943
|
|
|
798
944
|
/**
|
|
@@ -891,10 +1037,11 @@ export class Disc {
|
|
|
891
1037
|
// console.log(`wrote to ${track}:${position * 32}`);
|
|
892
1038
|
}
|
|
893
1039
|
|
|
1040
|
+
/** @returns {?{isSideUpper: boolean, trackNum: Number}} the track written, if there was one */
|
|
894
1041
|
flushWrites() {
|
|
895
1042
|
if (!this.isDirty) {
|
|
896
1043
|
if (this.dirtySide !== -1 || this.dirtyTrack !== -1) throw new Error("Bad state in disc dirty tracking");
|
|
897
|
-
return;
|
|
1044
|
+
return null;
|
|
898
1045
|
}
|
|
899
1046
|
|
|
900
1047
|
const dirtySide = this.dirtySide;
|
|
@@ -902,10 +1049,27 @@ export class Disc {
|
|
|
902
1049
|
this.isDirty = false;
|
|
903
1050
|
this.dirtySide = -1;
|
|
904
1051
|
this.dirtyTrack = -1;
|
|
905
|
-
if (!this.writeTrackCallback) return;
|
|
906
1052
|
const trackObj = this.getTrack(dirtySide, dirtyTrack);
|
|
907
|
-
this.writeTrackCallback(dirtySide, dirtyTrack, trackObj);
|
|
908
1053
|
this.setTrackUsed(dirtySide, dirtyTrack);
|
|
1054
|
+
for (const listener of this._trackWriteListeners) listener(dirtySide, dirtyTrack, trackObj);
|
|
1055
|
+
return { isSideUpper: dirtySide, trackNum: dirtyTrack };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Leave a track with no flux on it at all, as an erase head does.
|
|
1060
|
+
*
|
|
1061
|
+
* @param {boolean} isSideUpper
|
|
1062
|
+
* @param {Number} trackNum
|
|
1063
|
+
*/
|
|
1064
|
+
eraseTrack(isSideUpper, trackNum) {
|
|
1065
|
+
const trackObj = this.getTrack(isSideUpper, trackNum);
|
|
1066
|
+
trackObj.pulses2Us.fill(0);
|
|
1067
|
+
trackObj.length = IbmDiscFormat.bytesPerTrack;
|
|
1068
|
+
const dirtyKey = trackNum | (isSideUpper ? 0x100 : 0);
|
|
1069
|
+
this._snapshotDirtyTracks.add(dirtyKey);
|
|
1070
|
+
this._everDirtyTracks.add(dirtyKey);
|
|
1071
|
+
this.setTrackUsed(isSideUpper, trackNum);
|
|
1072
|
+
for (const listener of this._trackWriteListeners) listener(isSideUpper, trackNum, trackObj);
|
|
909
1073
|
}
|
|
910
1074
|
|
|
911
1075
|
/**
|
package/src/econet.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Code ported from Beebem (C to .js) by Jason Robson
|
|
2
2
|
// The majority of the commentary here is also from Beebem
|
|
3
3
|
|
|
4
|
+
// How long a four-way handshake may stall before we resend.
|
|
5
|
+
const RetryTimeoutSecs = 0.5;
|
|
6
|
+
|
|
4
7
|
// Econet support classes
|
|
5
8
|
class ADLC {
|
|
6
9
|
constructor() {
|
|
@@ -57,10 +60,11 @@ export class ReceiveBlock {
|
|
|
57
60
|
|
|
58
61
|
// Econet class definition
|
|
59
62
|
export class Econet {
|
|
60
|
-
constructor(stationId_) {
|
|
63
|
+
constructor(stationId_, cyclesPerSecond) {
|
|
61
64
|
// Config parameters
|
|
62
65
|
this.TIME_BETWEEN_BYTES = 128;
|
|
63
66
|
this.SERVER_STATION_ID = 254;
|
|
67
|
+
this.retryCycles = (cyclesPerSecond * RetryTimeoutSecs) | 0;
|
|
64
68
|
|
|
65
69
|
// 4-way handshake states
|
|
66
70
|
this.FWH_Idle = 0;
|
|
@@ -156,7 +160,7 @@ export class Econet {
|
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
// Re-tries
|
|
159
|
-
if (this.pollTotalCycles > this.wireStateEntryTimer +
|
|
163
|
+
if (this.pollTotalCycles > this.wireStateEntryTimer + this.retryCycles) {
|
|
160
164
|
if (this.wireState !== this.FWH_Idle) {
|
|
161
165
|
switch (this.wireState) {
|
|
162
166
|
case this.FWH_RX_Scout_Received:
|