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/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
- console.log(`Iffy pulse in sector header ${this.description}`);
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
- console.log(`"Sector header without data ${this.description}"`);
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
- console.log(`"Iffy pulse in sector data ${this.description}"`);
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
- let markDetector = 0n;
398
- let markDetectorPrev = 0n;
399
- const all64b = 0xffffffffffffffffn;
400
- const top32of64b = 0xffffffff00000000n;
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
- markDetectorPrev = (markDetectorPrev << 1n) & all64b;
408
- markDetectorPrev |= markDetector >> 63n;
409
- markDetector = (markDetector << 1n) & all64b;
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 ((markDetector & top32of64b) === fmMarker) {
418
- const { clocks, data, iffyPulses } = IbmDiscFormat._2usPulsesToFm(Number(markDetector & 0xffffffffn));
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
- let num0s = 8;
423
- for (let bits = markDetectorPrev; (bits & 0xfn) === 0x8n; bits >>= 4n) {
424
- num0s++;
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 === mfmMarker) {
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
- console.log(
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
- console.log(`Unknown marker byte ${hexbyte(dataByte)} ${this.description}`);
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 < SsdFormat.tracksPerDisc; ++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.setWriteTrackCallback(
716
+ disc.addTrackWriteListener(
584
717
  /** @param {Track} trackObj */
585
- (side, trackNum, trackObj) => {
586
- const trackOffset =
587
- SsdFormat.sectorSize * SsdFormat.sectorsPerTrack * (trackNum * numSides + (side ? 1 : 0));
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, trackNum) {
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 (trackNum >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
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, trackNum);
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
- numSides * SsdFormat.tracksPerDisc * SsdFormat.sectorsPerTrack * SsdFormat.sectorSize,
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, trackNum)) continue;
735
- const sectorOffset = offset + sector.sectorNumber * SsdFormat.sectorSize;
736
- for (let x = 0; x < SsdFormat.sectorSize; ++x) result[sectorOffset + x] = sector.sectorData[x];
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, offset);
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.writeTrackCallback = undefined;
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
- setWriteTrackCallback(callback) {
795
- this.writeTrackCallback = callback;
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 + 1000000) {
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: