jsbeeb 1.15.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/package.json +8 -6
- package/src/6502.js +14 -7
- package/src/6847.js +50 -8
- package/src/acia.js +2 -1
- package/src/canvas.js +77 -18
- 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 +96 -44
- package/src/econet.js +6 -2
- package/src/jsbeeb.css +126 -0
- package/src/main.js +70 -18
- package/src/models.js +13 -3
- package/src/sth.js +21 -18
- package/src/tapes.js +10 -12
- package/src/touchscreen.js +6 -6
- package/src/tube.js +89 -37
- 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
|
);
|
|
@@ -764,7 +811,7 @@ export class Disc {
|
|
|
764
811
|
this.tracksUsed = 0;
|
|
765
812
|
this.isDoubleSided = false;
|
|
766
813
|
|
|
767
|
-
this.
|
|
814
|
+
this._trackWriteListeners = new Set();
|
|
768
815
|
this.isWriteable = isWriteable;
|
|
769
816
|
|
|
770
817
|
// Track which tracks have been written since the last snapshot.
|
|
@@ -791,8 +838,14 @@ export class Disc {
|
|
|
791
838
|
this.initSurface(0);
|
|
792
839
|
}
|
|
793
840
|
|
|
794
|
-
|
|
795
|
-
|
|
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);
|
|
796
849
|
}
|
|
797
850
|
|
|
798
851
|
/**
|
|
@@ -902,10 +955,9 @@ export class Disc {
|
|
|
902
955
|
this.isDirty = false;
|
|
903
956
|
this.dirtySide = -1;
|
|
904
957
|
this.dirtyTrack = -1;
|
|
905
|
-
if (!this.writeTrackCallback) return;
|
|
906
958
|
const trackObj = this.getTrack(dirtySide, dirtyTrack);
|
|
907
|
-
this.writeTrackCallback(dirtySide, dirtyTrack, trackObj);
|
|
908
959
|
this.setTrackUsed(dirtySide, dirtyTrack);
|
|
960
|
+
for (const listener of this._trackWriteListeners) listener(dirtySide, dirtyTrack, trackObj);
|
|
909
961
|
}
|
|
910
962
|
|
|
911
963
|
/**
|
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/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;
|
package/src/main.js
CHANGED
|
@@ -44,6 +44,7 @@ import { isBemSnapshot, parseBemSnapshot } from "./bem-snapshot.js";
|
|
|
44
44
|
import { isUefSnapshot, parseUefSnapshot } from "./uef-snapshot.js";
|
|
45
45
|
import { RewindBuffer } from "./rewind.js";
|
|
46
46
|
import { RewindUI } from "./rewind-ui.js";
|
|
47
|
+
import { DiscVisualiser } from "./disc-visualiser.js";
|
|
47
48
|
import { downloadBlob } from "./dom-utils.js";
|
|
48
49
|
import {
|
|
49
50
|
buildUrlFromParams,
|
|
@@ -232,9 +233,10 @@ speechOutput.enabled = !!parsedQuery.speechOutput;
|
|
|
232
233
|
const config = new Config(
|
|
233
234
|
function onChange(changed) {
|
|
234
235
|
if (changed.displayMode) {
|
|
235
|
-
displayModeFilter
|
|
236
|
+
// swapCanvas settles displayModeFilter on whatever was really
|
|
237
|
+
// built, so take the picture from that rather than the request.
|
|
238
|
+
swapCanvas(getFilterForMode(changed.displayMode));
|
|
236
239
|
setCrtPic(displayModeFilter);
|
|
237
|
-
swapCanvas(displayModeFilter);
|
|
238
240
|
// Trigger window resize to recalculate layout with new dimensions
|
|
239
241
|
window.dispatchEvent(new Event("resize"));
|
|
240
242
|
}
|
|
@@ -352,7 +354,7 @@ sbBind(document.querySelector(".sidebar.bottom"), parsedQuery.sbBottom, function
|
|
|
352
354
|
});
|
|
353
355
|
|
|
354
356
|
if (cpuMultiplier !== 1) console.log(`CPU multiplier set to ${cpuMultiplier}`);
|
|
355
|
-
const cpuSpeed = model.
|
|
357
|
+
const cpuSpeed = model.cyclesPerSecond;
|
|
356
358
|
const clocksPerSecond = (cpuMultiplier * cpuSpeed) | 0;
|
|
357
359
|
const MaxCyclesPerFrame = clocksPerSecond / 10;
|
|
358
360
|
|
|
@@ -386,11 +388,24 @@ if (keyMappingWarnings.length) {
|
|
|
386
388
|
}
|
|
387
389
|
|
|
388
390
|
function createCanvasForFilter(filterClass) {
|
|
391
|
+
// Not `config`: that is the emulator's live configuration object, declared
|
|
392
|
+
// at module scope and used throughout this file.
|
|
393
|
+
const displayConfig = filterClass.getDisplayConfig();
|
|
394
|
+
// Each mode says how many pixels it wants to draw into. Set this before
|
|
395
|
+
// creating the context, which fixes its initial viewport.
|
|
396
|
+
screenCanvas.width = displayConfig.canvasWidth;
|
|
397
|
+
screenCanvas.height = displayConfig.canvasHeight;
|
|
398
|
+
|
|
389
399
|
const newCanvas = tryGl ? canvasLib.bestCanvas(screenCanvas, filterClass) : new canvasLib.Canvas(screenCanvas);
|
|
390
400
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
401
|
+
// Test which filter was actually built, not merely whether we got WebGL: a
|
|
402
|
+
// filter can decline a context that works perfectly well for other modes,
|
|
403
|
+
// in which case bestCanvas quietly gives us an unfiltered GL canvas.
|
|
404
|
+
if (newCanvas.filterClass !== filterClass) {
|
|
405
|
+
showError(
|
|
406
|
+
`enabling ${displayConfig.name} mode`,
|
|
407
|
+
`${displayConfig.name} is not available on this device. Using standard display instead.`,
|
|
408
|
+
);
|
|
394
409
|
}
|
|
395
410
|
|
|
396
411
|
return newCanvas;
|
|
@@ -398,20 +413,33 @@ function createCanvasForFilter(filterClass) {
|
|
|
398
413
|
|
|
399
414
|
let displayModeFilter = canvasLib.getFilterForMode(parsedQuery.displayMode || "rgb");
|
|
400
415
|
function swapCanvas(newFilterClass) {
|
|
416
|
+
const oldCanvas = canvas;
|
|
401
417
|
const newCanvas = createCanvasForFilter(newFilterClass);
|
|
418
|
+
// Carry the picture over; the buffers differ in height, so copy what fits.
|
|
419
|
+
newCanvas.fb32.set(oldCanvas.fb32.subarray(0, newCanvas.fb32.length));
|
|
420
|
+
// Only once the replacement exists, so a failure to build it leaves the
|
|
421
|
+
// display we already had. The two share a GL context but no GL objects.
|
|
422
|
+
oldCanvas.dispose();
|
|
402
423
|
video.fb32 = newCanvas.fb32;
|
|
403
424
|
video.paint_ext = function paint(minx, miny, maxx, maxy) {
|
|
404
425
|
frames++;
|
|
405
426
|
if (frames < frameSkip) return;
|
|
406
427
|
frames = 0;
|
|
407
|
-
newCanvas.paint(minx, miny, maxx, maxy, this.frameCount);
|
|
428
|
+
newCanvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
|
|
408
429
|
};
|
|
409
430
|
canvas = newCanvas;
|
|
410
|
-
|
|
431
|
+
// Follow the filter we ended up with, not the one we asked for: everything
|
|
432
|
+
// downstream — the monitor picture, the canvas geometry, how large a
|
|
433
|
+
// drawing buffer to ask for — comes from its display config.
|
|
434
|
+
displayModeFilter = newCanvas.filterClass;
|
|
435
|
+
// Nothing else will redraw: the mode is changed from a modal, which stops
|
|
436
|
+
// the emulator.
|
|
437
|
+
video.paint();
|
|
411
438
|
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
|
|
412
439
|
}
|
|
413
440
|
|
|
414
441
|
let canvas = createCanvasForFilter(displayModeFilter);
|
|
442
|
+
displayModeFilter = canvas.filterClass;
|
|
415
443
|
|
|
416
444
|
video = new Video(
|
|
417
445
|
model.isMaster,
|
|
@@ -420,7 +448,7 @@ video = new Video(
|
|
|
420
448
|
frames++;
|
|
421
449
|
if (frames < frameSkip) return;
|
|
422
450
|
frames = 0;
|
|
423
|
-
canvas.paint(minx, miny, maxx, maxy, this.frameCount);
|
|
451
|
+
canvas.paint(minx, miny, maxx, maxy, { frameCount: this.frameCount, lineGrid: this.lineGrid });
|
|
424
452
|
},
|
|
425
453
|
{ isAtom: model.isAtom },
|
|
426
454
|
);
|
|
@@ -533,7 +561,7 @@ pastetext.addEventListener("drop", async function (event) {
|
|
|
533
561
|
await loadStateFromFile(file, arrayBuffer);
|
|
534
562
|
} else if (file.name.toLowerCase().endsWith(".uef")) {
|
|
535
563
|
// Regular UEF tape image (not a BeebEm save state)
|
|
536
|
-
setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model
|
|
564
|
+
setProcessorTape(await loadTapeFromData(file.name, new Uint8Array(arrayBuffer), model));
|
|
537
565
|
} else {
|
|
538
566
|
await loadHTMLFile(file);
|
|
539
567
|
}
|
|
@@ -619,7 +647,7 @@ window.addEventListener("beforeunload", function (event) {
|
|
|
619
647
|
});
|
|
620
648
|
|
|
621
649
|
if (config.hasEconet) {
|
|
622
|
-
econet = new Econet(stationId);
|
|
650
|
+
econet = new Econet(stationId, model.cyclesPerSecond);
|
|
623
651
|
} else {
|
|
624
652
|
document.getElementById("fsmenuitem").style.display = "none";
|
|
625
653
|
}
|
|
@@ -1207,17 +1235,16 @@ async function loadTapeImage(tapeImage) {
|
|
|
1207
1235
|
const split = splitImage(tapeImage);
|
|
1208
1236
|
tapeImage = split.image;
|
|
1209
1237
|
const schema = split.schema;
|
|
1210
|
-
const isAtom = model.isAtom;
|
|
1211
1238
|
|
|
1212
1239
|
switch (schema) {
|
|
1213
1240
|
case "|":
|
|
1214
1241
|
case "sth":
|
|
1215
|
-
return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage),
|
|
1242
|
+
return await loadTapeFromData(tapeImage, await tapeSth.fetch(tapeImage), model);
|
|
1216
1243
|
|
|
1217
1244
|
case "data": {
|
|
1218
1245
|
const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
|
|
1219
1246
|
const { name, data } = await utils.unzipDiscImage(arr);
|
|
1220
|
-
return await loadTapeFromData(name, data,
|
|
1247
|
+
return await loadTapeFromData(name, data, model);
|
|
1221
1248
|
}
|
|
1222
1249
|
|
|
1223
1250
|
case "http":
|
|
@@ -1232,7 +1259,7 @@ async function loadTapeImage(tapeImage) {
|
|
|
1232
1259
|
tapeData = unzipped.data;
|
|
1233
1260
|
tapeImage = unzipped.name;
|
|
1234
1261
|
}
|
|
1235
|
-
return await loadTapeFromData(tapeImage, tapeData,
|
|
1262
|
+
return await loadTapeFromData(tapeImage, tapeData, model);
|
|
1236
1263
|
}
|
|
1237
1264
|
|
|
1238
1265
|
default: {
|
|
@@ -1244,7 +1271,7 @@ async function loadTapeImage(tapeImage) {
|
|
|
1244
1271
|
tapeData = unzipped.data;
|
|
1245
1272
|
tapeName = unzipped.name;
|
|
1246
1273
|
}
|
|
1247
|
-
return await loadTapeFromData(tapeName, tapeData,
|
|
1274
|
+
return await loadTapeFromData(tapeName, tapeData, model);
|
|
1248
1275
|
}
|
|
1249
1276
|
}
|
|
1250
1277
|
}
|
|
@@ -1277,7 +1304,7 @@ document.getElementById("tape_load").addEventListener("change", async function (
|
|
|
1277
1304
|
tapeData = unzipped.data;
|
|
1278
1305
|
tapeName = unzipped.name;
|
|
1279
1306
|
}
|
|
1280
|
-
setProcessorTape(await loadTapeFromData(tapeName, tapeData, model
|
|
1307
|
+
setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
|
|
1281
1308
|
delete parsedQuery.tape;
|
|
1282
1309
|
updateUrl();
|
|
1283
1310
|
bootstrap.Modal.getInstance(document.getElementById("tapes"))?.hide();
|
|
@@ -1940,7 +1967,7 @@ function VirtualSpeedUpdater() {
|
|
|
1940
1967
|
if (this.cycles) {
|
|
1941
1968
|
const thisMHz = this.cycles / this.time / 1000;
|
|
1942
1969
|
this.v.textContent = thisMHz.toFixed(1);
|
|
1943
|
-
if (this.cycles >= 10 *
|
|
1970
|
+
if (this.cycles >= 10 * cpuSpeed) {
|
|
1944
1971
|
this.cycles = this.time = 0;
|
|
1945
1972
|
}
|
|
1946
1973
|
this.header.style.color = this.speedy ? "red" : "white";
|
|
@@ -1968,6 +1995,9 @@ rewindUI = new RewindUI({
|
|
|
1968
1995
|
});
|
|
1969
1996
|
rewindUI.updateButtonState();
|
|
1970
1997
|
|
|
1998
|
+
if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
|
|
1999
|
+
else document.getElementById("disc-visualiser-open").classList.add("disabled");
|
|
2000
|
+
|
|
1971
2001
|
function draw(now) {
|
|
1972
2002
|
if (!running) {
|
|
1973
2003
|
last = 0;
|
|
@@ -2083,6 +2113,9 @@ function stop(debug) {
|
|
|
2083
2113
|
updateDebugButtons();
|
|
2084
2114
|
}
|
|
2085
2115
|
|
|
2116
|
+
/** Steps the drawing buffer grows in, as a multiple of the base canvas size. */
|
|
2117
|
+
const CanvasScaleStep = 0.25;
|
|
2118
|
+
|
|
2086
2119
|
(function () {
|
|
2087
2120
|
const resizeCubMonitor = document.getElementById("cub-monitor");
|
|
2088
2121
|
const resizeCubMonitorPic = document.getElementById("cub-monitor-pic");
|
|
@@ -2135,6 +2168,25 @@ function stop(debug) {
|
|
|
2135
2168
|
resizeCubMonitor.style.width = width + "px";
|
|
2136
2169
|
resizeCubMonitorPic.style.height = height + "px";
|
|
2137
2170
|
resizeCubMonitorPic.style.width = width + "px";
|
|
2171
|
+
// A mode that reconstructs detail wants to draw at the size it will be
|
|
2172
|
+
// seen at, up to the limit it asks for. Drawing more than the display
|
|
2173
|
+
// can show costs fragments and buys nothing, and for an expensive
|
|
2174
|
+
// shader that is the difference between comfortable and not.
|
|
2175
|
+
if (displayConfig.maxCanvasScale) {
|
|
2176
|
+
const wanted = (finalCanvasWidth * (window.devicePixelRatio || 1)) / displayConfig.canvasWidth;
|
|
2177
|
+
// Quantised, because resize fires continuously while a window is
|
|
2178
|
+
// dragged and every distinct value reallocates the drawing buffer.
|
|
2179
|
+
const quantised = Math.round(wanted / CanvasScaleStep) * CanvasScaleStep;
|
|
2180
|
+
const scale = Math.min(displayConfig.maxCanvasScale, Math.max(1, quantised));
|
|
2181
|
+
const backingWidth = Math.round(displayConfig.canvasWidth * scale);
|
|
2182
|
+
if (screenCanvas.width !== backingWidth) {
|
|
2183
|
+
screenCanvas.width = backingWidth;
|
|
2184
|
+
screenCanvas.height = Math.round(displayConfig.canvasHeight * scale);
|
|
2185
|
+
// Resizing threw the drawing buffer away.
|
|
2186
|
+
video.paint();
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2138
2190
|
screenCanvas.style.width = finalCanvasWidth + "px";
|
|
2139
2191
|
screenCanvas.style.height = finalCanvasHeight + "px";
|
|
2140
2192
|
screenCanvas.style.left = canvasOrigLeft * containerScale + "px";
|
package/src/models.js
CHANGED
|
@@ -35,6 +35,15 @@ class Model {
|
|
|
35
35
|
this.cmosOverride = cmosOverride;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* How many CPU cycles this machine runs in a second. Everything that
|
|
40
|
+
* converts between real time and emulated cycles should ask here rather
|
|
41
|
+
* than assuming a clock speed.
|
|
42
|
+
*/
|
|
43
|
+
get cyclesPerSecond() {
|
|
44
|
+
return this.clockMhz * 1000 * 1000;
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
get nmos() {
|
|
39
48
|
return this._cpuModel === CpuModel.MOS6502;
|
|
40
49
|
}
|
|
@@ -225,8 +234,8 @@ export const allModels = [
|
|
|
225
234
|
name: "Tube65C02",
|
|
226
235
|
synonyms: [],
|
|
227
236
|
os: ["tube/6502Tube.rom"],
|
|
228
|
-
//
|
|
229
|
-
cpuModel: CpuModel.
|
|
237
|
+
// The production wedge's GTE 65SC02 has no Rockwell bit instructions.
|
|
238
|
+
cpuModel: CpuModel.CMOS65C12,
|
|
230
239
|
isMaster: false,
|
|
231
240
|
clockMhz: 3,
|
|
232
241
|
}),
|
|
@@ -234,7 +243,8 @@ export const allModels = [
|
|
|
234
243
|
name: "Tube65C102",
|
|
235
244
|
synonyms: [],
|
|
236
245
|
os: ["tube/65C102Tube.rom"],
|
|
237
|
-
//
|
|
246
|
+
// Boards are reported with both Rockwell and GTE parts, so keep the superset of the two
|
|
247
|
+
// until #756 lets the fitted co-processor be chosen.
|
|
238
248
|
cpuModel: CpuModel.CMOS65C02,
|
|
239
249
|
isMaster: false,
|
|
240
250
|
clockMhz: 4,
|