jsbeeb 1.14.0 → 1.16.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 +94 -0
- package/package.json +8 -6
- package/public/roms/tube/65C102Tube.rom +0 -0
- package/src/6502.js +22 -16
- package/src/6847.js +50 -8
- package/src/acia.js +31 -12
- package/src/canvas.js +77 -18
- package/src/config.js +16 -4
- package/src/disc-drive.js +9 -0
- package/src/disc-hfe.js +1 -2
- package/src/disc-surface.js +350 -0
- package/src/disc-visualiser.js +569 -0
- package/src/disc.js +143 -49
- package/src/dom-utils.js +16 -0
- package/src/econet.js +6 -2
- package/src/fake6502.js +2 -2
- package/src/gamepads.js +11 -4
- package/src/jsbeeb.css +126 -0
- package/src/main.js +112 -55
- package/src/models.js +47 -5
- package/src/serial.js +1 -0
- package/src/snapshot-helpers.js +1 -1
- package/src/sth.js +21 -18
- package/src/tapes.js +10 -12
- package/src/teletext_adaptor.js +38 -13
- package/src/touchscreen.js +6 -6
- package/src/tube.js +89 -37
- package/src/url-params.js +23 -19
- package/src/utils.js +10 -5
- package/src/utils_atom.js +9 -5
- 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 +17 -7
- package/tests/test-machine.js +7 -5
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;
|
|
@@ -580,16 +629,14 @@ export function loadSsd(disc, data, isDsd, onChange) {
|
|
|
580
629
|
// Create a dataCopy large enough for all the sectors and tracks.
|
|
581
630
|
const dataCopy = new Uint8Array(maxSize);
|
|
582
631
|
dataCopy.set(data);
|
|
583
|
-
disc.
|
|
632
|
+
disc.addTrackWriteListener(
|
|
584
633
|
/** @param {Track} trackObj */
|
|
585
634
|
(side, trackNum, trackObj) => {
|
|
586
635
|
const trackOffset =
|
|
587
636
|
SsdFormat.sectorSize * SsdFormat.sectorsPerTrack * (trackNum * numSides + (side ? 1 : 0));
|
|
588
|
-
for (const sector of trackObj.findSectors())
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
dataCopy[trackOffset + sectorOffset + x] = sector.sectorData[x];
|
|
592
|
-
}
|
|
637
|
+
for (const sector of trackObj.findSectors())
|
|
638
|
+
if (!sectorShortfall(sector, trackNum))
|
|
639
|
+
dataCopy.set(sector.sectorData, trackOffset + sector.sectorNumber * SsdFormat.sectorSize);
|
|
593
640
|
onChange(dataCopy);
|
|
594
641
|
},
|
|
595
642
|
);
|
|
@@ -672,11 +719,56 @@ export function loadAdf(disc, data, isDsd) {
|
|
|
672
719
|
return disc;
|
|
673
720
|
}
|
|
674
721
|
|
|
722
|
+
/** Why a sector will not fit in an SSD or DSD image, or null if it will. */
|
|
723
|
+
function sectorShortfall(sector, trackNum) {
|
|
724
|
+
if (sector.hasDataCrcError || sector.hasHeaderCrcError) return "with a CRC error";
|
|
725
|
+
// A header whose data mark never arrives leaves the sector with nothing to write.
|
|
726
|
+
if (!sector.sectorData) return "with no data";
|
|
727
|
+
if (sector.sectorNumber >= SsdFormat.sectorsPerTrack)
|
|
728
|
+
return `numbered past the ${SsdFormat.sectorsPerTrack} a track holds`;
|
|
729
|
+
if (sector.sectorData.length !== SsdFormat.sectorSize) return `not ${SsdFormat.sectorSize} bytes`;
|
|
730
|
+
if (trackNum >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* SSD and DSD images hold sector contents and nothing else, so anything a DFS sector could not
|
|
736
|
+
* have held is lost. Copy protection usually shows up as one of these.
|
|
737
|
+
*
|
|
738
|
+
* @returns {string[]} what `disc` holds that an SSD or DSD cannot, worst first
|
|
739
|
+
* @param {Disc} disc
|
|
740
|
+
*/
|
|
741
|
+
export function ssdOrDsdShortfalls(disc) {
|
|
742
|
+
const counts = new Map();
|
|
743
|
+
for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
|
|
744
|
+
for (const upper of disc.isDoubleSided ? [false, true] : [false]) {
|
|
745
|
+
for (const sector of disc.getTrack(upper, trackNum).findSectors()) {
|
|
746
|
+
const shortfall = sectorShortfall(sector, trackNum);
|
|
747
|
+
if (shortfall) counts.set(shortfall, (counts.get(shortfall) ?? 0) + 1);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return [...counts]
|
|
752
|
+
.sort(([, a], [, b]) => b - a)
|
|
753
|
+
.map(([shortfall, count]) => `${count} sector${count === 1 ? "" : "s"} ${shortfall}`);
|
|
754
|
+
}
|
|
755
|
+
|
|
675
756
|
/**
|
|
676
757
|
* @returns {Uint8Array}
|
|
677
758
|
* @param {Disc} disc
|
|
759
|
+
* @param {object} [options]
|
|
760
|
+
* @param {boolean} [options.force] save what fits instead of refusing a disc that will not fit
|
|
761
|
+
* @throws if the disc holds anything an SSD or DSD cannot, and `force` is not set
|
|
678
762
|
*/
|
|
679
|
-
export function toSsdOrDsd(disc) {
|
|
763
|
+
export function toSsdOrDsd(disc, { force = false } = {}) {
|
|
764
|
+
if (!force) {
|
|
765
|
+
const shortfalls = ssdOrDsdShortfalls(disc);
|
|
766
|
+
if (shortfalls.length)
|
|
767
|
+
throw new Error(
|
|
768
|
+
`This disc cannot be saved as SSD or DSD: it has ${shortfalls.join(", ")}. ` +
|
|
769
|
+
`Save it as HFE to keep everything.`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
680
772
|
const numSides = disc.isDoubleSided ? 2 : 1;
|
|
681
773
|
const result = new Uint8Array(
|
|
682
774
|
numSides * SsdFormat.tracksPerDisc * SsdFormat.sectorsPerTrack * SsdFormat.sectorSize,
|
|
@@ -686,11 +778,8 @@ export function toSsdOrDsd(disc) {
|
|
|
686
778
|
for (let side = 0; side < numSides; ++side) {
|
|
687
779
|
const trackObj = disc.getTrack(side === 1, trackNum);
|
|
688
780
|
for (const sector of trackObj.findSectors()) {
|
|
781
|
+
if (sectorShortfall(sector, trackNum)) continue;
|
|
689
782
|
const sectorOffset = offset + sector.sectorNumber * SsdFormat.sectorSize;
|
|
690
|
-
if (sector.hasDataCrcError || sector.hasHeaderCrcError) {
|
|
691
|
-
console.log(`Skipping sector ${sector.description} with bad CRC`);
|
|
692
|
-
continue;
|
|
693
|
-
}
|
|
694
783
|
for (let x = 0; x < SsdFormat.sectorSize; ++x) result[sectorOffset + x] = sector.sectorData[x];
|
|
695
784
|
}
|
|
696
785
|
offset += SsdFormat.sectorsPerTrack * SsdFormat.sectorSize;
|
|
@@ -722,7 +811,7 @@ export class Disc {
|
|
|
722
811
|
this.tracksUsed = 0;
|
|
723
812
|
this.isDoubleSided = false;
|
|
724
813
|
|
|
725
|
-
this.
|
|
814
|
+
this._trackWriteListeners = new Set();
|
|
726
815
|
this.isWriteable = isWriteable;
|
|
727
816
|
|
|
728
817
|
// Track which tracks have been written since the last snapshot.
|
|
@@ -749,8 +838,14 @@ export class Disc {
|
|
|
749
838
|
this.initSurface(0);
|
|
750
839
|
}
|
|
751
840
|
|
|
752
|
-
|
|
753
|
-
|
|
841
|
+
/** @param {function(boolean, Number, Track): void} listener called once per flushed track */
|
|
842
|
+
addTrackWriteListener(listener) {
|
|
843
|
+
this._trackWriteListeners.add(listener);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** @param {function(boolean, Number, Track): void} listener */
|
|
847
|
+
removeTrackWriteListener(listener) {
|
|
848
|
+
this._trackWriteListeners.delete(listener);
|
|
754
849
|
}
|
|
755
850
|
|
|
756
851
|
/**
|
|
@@ -860,10 +955,9 @@ export class Disc {
|
|
|
860
955
|
this.isDirty = false;
|
|
861
956
|
this.dirtySide = -1;
|
|
862
957
|
this.dirtyTrack = -1;
|
|
863
|
-
if (!this.writeTrackCallback) return;
|
|
864
958
|
const trackObj = this.getTrack(dirtySide, dirtyTrack);
|
|
865
|
-
this.writeTrackCallback(dirtySide, dirtyTrack, trackObj);
|
|
866
959
|
this.setTrackUsed(dirtySide, dirtyTrack);
|
|
960
|
+
for (const listener of this._trackWriteListeners) listener(dirtySide, dirtyTrack, trackObj);
|
|
867
961
|
}
|
|
868
962
|
|
|
869
963
|
/**
|
package/src/dom-utils.js
CHANGED
|
@@ -30,3 +30,19 @@ export function fadeOut(el, duration = 400) {
|
|
|
30
30
|
if (el.style.opacity === "0") el.style.display = "none";
|
|
31
31
|
}, duration);
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
// Safari fetches the blob a task or more after the click, so the URL must outlive it.
|
|
35
|
+
// 40s matches FileSaver.js.
|
|
36
|
+
const BlobUrlLifetimeMs = 40000;
|
|
37
|
+
|
|
38
|
+
/** Save a blob to the user's downloads under the given file name. */
|
|
39
|
+
export function downloadBlob(blob, fileName) {
|
|
40
|
+
const url = URL.createObjectURL(blob);
|
|
41
|
+
const a = document.createElement("a");
|
|
42
|
+
a.href = url;
|
|
43
|
+
a.download = fileName;
|
|
44
|
+
document.body.appendChild(a);
|
|
45
|
+
a.click();
|
|
46
|
+
a.remove();
|
|
47
|
+
setTimeout(() => URL.revokeObjectURL(url), BlobUrlLifetimeMs);
|
|
48
|
+
}
|
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:
|
package/src/fake6502.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { FakeVideo } from "./video.js";
|
|
5
5
|
import { FakeSoundChip } from "./soundchip.js";
|
|
6
|
-
import { TEST_6502, TEST_65C02, TEST_65C12,
|
|
6
|
+
import { TEST_6502, TEST_65C02, TEST_65C12, tubeModelFor } from "./models.js";
|
|
7
7
|
import { FakeDdNoise } from "./ddnoise.js";
|
|
8
8
|
import { FakeRelayNoise } from "./relaynoise.js";
|
|
9
9
|
import { Cpu6502, AtomCpu6502 } from "./6502.js";
|
|
@@ -30,7 +30,7 @@ export function fake6502(model, opts) {
|
|
|
30
30
|
cmos: new Cmos(),
|
|
31
31
|
cycleAccurate: opts.cycleAccurate,
|
|
32
32
|
config: {
|
|
33
|
-
tube: opts.tube ?
|
|
33
|
+
tube: opts.tube ? tubeModelFor(model) : null,
|
|
34
34
|
tubeCpuMultiplier: opts.tubeCpuMultiplier,
|
|
35
35
|
cpuMultiplier: opts.cpuMultiplier,
|
|
36
36
|
hasTeletextAdaptor: opts.hasTeletextAdaptor,
|
package/src/gamepads.js
CHANGED
|
@@ -39,16 +39,21 @@ export class GamePad {
|
|
|
39
39
|
this.gamepadAxisMapping[3][-1] = BBC.COLON_STAR; // up
|
|
40
40
|
this.gamepadAxisMapping[3][1] = BBC.SLASH; // down
|
|
41
41
|
*/
|
|
42
|
+
/**
|
|
43
|
+
* Maps a gamepad button or stick direction to a BBC key.
|
|
44
|
+
* @param {string} gamepadKey - the gamepad control, eg `FIRE2`
|
|
45
|
+
* @param {string} bbcKey - the BBC key to press, eg `RETURN`
|
|
46
|
+
* @returns {?string} a description of the problem, or null if the mapping was applied
|
|
47
|
+
*/
|
|
42
48
|
remap(gamepadKey, bbcKey) {
|
|
43
49
|
// convert "1" into "K1"
|
|
44
|
-
if ("
|
|
50
|
+
if (bbcKey.length === 1 && bbcKey >= "0" && bbcKey <= "9") {
|
|
45
51
|
bbcKey = "K" + bbcKey;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
const mappedBbcKey = BBC[bbcKey];
|
|
49
55
|
if (!mappedBbcKey) {
|
|
50
|
-
|
|
51
|
-
return;
|
|
56
|
+
return `unknown BBC key "${bbcKey}".`;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
switch (gamepadKey) {
|
|
@@ -152,8 +157,10 @@ export class GamePad {
|
|
|
152
157
|
this.gamepadMapping[6] = mappedBbcKey;
|
|
153
158
|
break;
|
|
154
159
|
default:
|
|
155
|
-
|
|
160
|
+
return `unknown gamepad control "${gamepadKey}".`;
|
|
156
161
|
}
|
|
162
|
+
|
|
163
|
+
return null;
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
update(sysvia) {
|
package/src/jsbeeb.css
CHANGED
|
@@ -441,6 +441,132 @@ small {
|
|
|
441
441
|
pointer-events: none;
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
#disc-panel {
|
|
445
|
+
position: fixed;
|
|
446
|
+
top: 56px;
|
|
447
|
+
right: 8px;
|
|
448
|
+
width: min(420px, 46vw);
|
|
449
|
+
max-height: calc(100vh - 104px);
|
|
450
|
+
overflow-y: auto;
|
|
451
|
+
background: rgba(0, 0, 0, 0.9);
|
|
452
|
+
border: 1px solid #555;
|
|
453
|
+
border-radius: 4px;
|
|
454
|
+
z-index: 10;
|
|
455
|
+
padding: 8px;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.disc-header {
|
|
459
|
+
display: flex;
|
|
460
|
+
align-items: baseline;
|
|
461
|
+
gap: 8px;
|
|
462
|
+
margin-bottom: 8px;
|
|
463
|
+
cursor: move;
|
|
464
|
+
user-select: none;
|
|
465
|
+
/* Claim touch gestures, or a drag scrolls the page instead. */
|
|
466
|
+
touch-action: none;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
.disc-controls {
|
|
470
|
+
display: flex;
|
|
471
|
+
flex-wrap: wrap;
|
|
472
|
+
gap: 6px;
|
|
473
|
+
margin-bottom: 8px;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.disc-header button {
|
|
477
|
+
cursor: pointer;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.disc-title {
|
|
481
|
+
color: #ccc;
|
|
482
|
+
font-size: 12px;
|
|
483
|
+
font-weight: bold;
|
|
484
|
+
flex: none;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/* The only item in the header with no bound on its width, so it is the one that yields. */
|
|
488
|
+
#disc-name {
|
|
489
|
+
flex: 1;
|
|
490
|
+
min-width: 0;
|
|
491
|
+
overflow: hidden;
|
|
492
|
+
text-overflow: ellipsis;
|
|
493
|
+
white-space: nowrap;
|
|
494
|
+
color: #888;
|
|
495
|
+
font-family: consolas, monospace;
|
|
496
|
+
font-size: 11px;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#disc-close {
|
|
500
|
+
flex: none;
|
|
501
|
+
margin-left: auto;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.disc-stack {
|
|
505
|
+
position: relative;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.disc-stack canvas {
|
|
509
|
+
display: block;
|
|
510
|
+
width: 100%;
|
|
511
|
+
aspect-ratio: 1;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
#disc-overlay {
|
|
515
|
+
position: absolute;
|
|
516
|
+
inset: 0;
|
|
517
|
+
cursor: crosshair;
|
|
518
|
+
/* Claim wheel and drag gestures, or the page scrolls instead. */
|
|
519
|
+
touch-action: none;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.disc-legend {
|
|
523
|
+
display: flex;
|
|
524
|
+
align-items: center;
|
|
525
|
+
flex-wrap: wrap;
|
|
526
|
+
gap: 4px 8px;
|
|
527
|
+
margin-top: 8px;
|
|
528
|
+
color: #aaa;
|
|
529
|
+
font-size: 10px;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.disc-legend-item {
|
|
533
|
+
display: inline-flex;
|
|
534
|
+
align-items: center;
|
|
535
|
+
gap: 4px;
|
|
536
|
+
white-space: nowrap;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.disc-legend-grow {
|
|
540
|
+
flex: 1;
|
|
541
|
+
min-width: 150px;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.disc-legend-ramp {
|
|
545
|
+
flex: 1;
|
|
546
|
+
min-width: 40px;
|
|
547
|
+
height: 8px;
|
|
548
|
+
border-radius: 2px;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.disc-legend-swatch {
|
|
552
|
+
width: 12px;
|
|
553
|
+
height: 8px;
|
|
554
|
+
border-radius: 2px;
|
|
555
|
+
flex: none;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
.disc-status {
|
|
559
|
+
margin-top: 4px;
|
|
560
|
+
color: #ccc;
|
|
561
|
+
font-family: consolas, monospace;
|
|
562
|
+
font-size: 11px;
|
|
563
|
+
/* Rewritten every frame, so the height must not depend on the content. */
|
|
564
|
+
min-height: 14px;
|
|
565
|
+
overflow: hidden;
|
|
566
|
+
text-overflow: ellipsis;
|
|
567
|
+
white-space: nowrap;
|
|
568
|
+
}
|
|
569
|
+
|
|
444
570
|
div.smoothie-chart-tooltip {
|
|
445
571
|
background: #444;
|
|
446
572
|
padding: 1em;
|