jsbeeb 1.16.0 → 1.17.1
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 +16 -2
- package/package.json +9 -1
- package/src/6502.js +2 -1
- package/src/app/app.js +4 -0
- package/src/bbcdiscs.js +84 -0
- package/src/bem-snapshot.js +3 -4
- package/src/canvas.js +9 -2
- package/src/cmos.js +38 -0
- package/src/disc-drive.js +45 -12
- package/src/disc-hfe.js +19 -2
- package/src/disc.js +154 -22
- package/src/fdc.js +97 -12
- package/src/google-drive.js +6 -5
- package/src/intel-fdc.js +8 -1
- package/src/jsbeeb.css +65 -0
- package/src/keyboard.js +14 -15
- package/src/machine-session.js +2 -1
- package/src/main.js +381 -170
- package/src/models.js +2 -0
- package/src/printer.js +59 -0
- package/src/sth.js +5 -5
- package/src/teletext_adaptor.js +6 -4
- package/src/url-params.js +28 -0
- package/src/utils.js +28 -55
- package/src/via.js +7 -1
- package/src/wd-fdc.js +8 -1
- package/src/web/audio-handler.js +27 -8
- package/src/web/toast.js +83 -0
- package/tests/test-machine.js +1 -1
package/src/disc.js
CHANGED
|
@@ -527,6 +527,13 @@ class Side {
|
|
|
527
527
|
}
|
|
528
528
|
}
|
|
529
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
|
+
|
|
530
537
|
export class DiscConfig {
|
|
531
538
|
constructor() {
|
|
532
539
|
// TODO is this even useful?
|
|
@@ -553,6 +560,74 @@ class SsdFormat {
|
|
|
553
560
|
static get tracksPerDisc() {
|
|
554
561
|
return 80;
|
|
555
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` };
|
|
556
631
|
}
|
|
557
632
|
|
|
558
633
|
/**
|
|
@@ -576,10 +651,19 @@ export function loadSsd(disc, data, isDsd, onChange) {
|
|
|
576
651
|
throw new Error("SSD file is too large");
|
|
577
652
|
}
|
|
578
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
|
+
|
|
579
663
|
let offset = 0;
|
|
580
|
-
for (let track = 0; track <
|
|
664
|
+
for (let track = 0; track < numTracks; ++track) {
|
|
581
665
|
for (let side = 0; side < numSides; ++side) {
|
|
582
|
-
const trackBuilder = disc.buildTrack(side === 1, track);
|
|
666
|
+
const trackBuilder = disc.buildTrack(side === 1, track * trackStep);
|
|
583
667
|
// Sync pattern at start of track, as the index pulse starts, aka GAP 5.
|
|
584
668
|
trackBuilder
|
|
585
669
|
.appendRepeatFmByte(0xff, IbmDiscFormat.stdGap1FFs)
|
|
@@ -631,14 +715,12 @@ export function loadSsd(disc, data, isDsd, onChange) {
|
|
|
631
715
|
dataCopy.set(data);
|
|
632
716
|
disc.addTrackWriteListener(
|
|
633
717
|
/** @param {Track} trackObj */
|
|
634
|
-
(side,
|
|
635
|
-
const trackOffset =
|
|
636
|
-
SsdFormat.sectorSize * SsdFormat.sectorsPerTrack * (trackNum * numSides + (side ? 1 : 0));
|
|
718
|
+
(side, _trackNum, trackObj) => {
|
|
637
719
|
for (const sector of trackObj.findSectors())
|
|
638
|
-
if (!sectorShortfall(sector,
|
|
639
|
-
dataCopy.set(sector.sectorData, trackOffset + sector.sectorNumber * SsdFormat.sectorSize);
|
|
720
|
+
if (!sectorShortfall(sector)) dataCopy.set(sector.sectorData, ssdOffsetOf(sector, side, numSides));
|
|
640
721
|
onChange(dataCopy);
|
|
641
722
|
},
|
|
723
|
+
true,
|
|
642
724
|
);
|
|
643
725
|
}
|
|
644
726
|
return disc;
|
|
@@ -720,17 +802,30 @@ export function loadAdf(disc, data, isDsd) {
|
|
|
720
802
|
}
|
|
721
803
|
|
|
722
804
|
/** Why a sector will not fit in an SSD or DSD image, or null if it will. */
|
|
723
|
-
function sectorShortfall(sector
|
|
805
|
+
function sectorShortfall(sector) {
|
|
724
806
|
if (sector.hasDataCrcError || sector.hasHeaderCrcError) return "with a CRC error";
|
|
725
807
|
// A header whose data mark never arrives leaves the sector with nothing to write.
|
|
726
808
|
if (!sector.sectorData) return "with no data";
|
|
727
809
|
if (sector.sectorNumber >= SsdFormat.sectorsPerTrack)
|
|
728
810
|
return `numbered past the ${SsdFormat.sectorsPerTrack} a track holds`;
|
|
729
811
|
if (sector.sectorData.length !== SsdFormat.sectorSize) return `not ${SsdFormat.sectorSize} bytes`;
|
|
730
|
-
if (
|
|
812
|
+
if (sector.trackNumber >= SsdFormat.tracksPerDisc) return `past track ${SsdFormat.tracksPerDisc}`;
|
|
731
813
|
return null;
|
|
732
814
|
}
|
|
733
815
|
|
|
816
|
+
/**
|
|
817
|
+
* Where a sector belongs in an SSD or DSD image, which is the track its own header claims rather
|
|
818
|
+
* than the one it sits on.
|
|
819
|
+
*
|
|
820
|
+
* @param {Sector} sector
|
|
821
|
+
* @param {boolean} isSideUpper
|
|
822
|
+
* @param {Number} numSides
|
|
823
|
+
*/
|
|
824
|
+
function ssdOffsetOf(sector, isSideUpper, numSides) {
|
|
825
|
+
const track = sector.trackNumber * numSides + (isSideUpper ? 1 : 0);
|
|
826
|
+
return track * SsdFormat.trackSize + sector.sectorNumber * SsdFormat.sectorSize;
|
|
827
|
+
}
|
|
828
|
+
|
|
734
829
|
/**
|
|
735
830
|
* SSD and DSD images hold sector contents and nothing else, so anything a DFS sector could not
|
|
736
831
|
* have held is lost. Copy protection usually shows up as one of these.
|
|
@@ -743,7 +838,7 @@ export function ssdOrDsdShortfalls(disc) {
|
|
|
743
838
|
for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
|
|
744
839
|
for (const upper of disc.isDoubleSided ? [false, true] : [false]) {
|
|
745
840
|
for (const sector of disc.getTrack(upper, trackNum).findSectors()) {
|
|
746
|
-
const shortfall = sectorShortfall(sector
|
|
841
|
+
const shortfall = sectorShortfall(sector);
|
|
747
842
|
if (shortfall) counts.set(shortfall, (counts.get(shortfall) ?? 0) + 1);
|
|
748
843
|
}
|
|
749
844
|
}
|
|
@@ -770,22 +865,19 @@ export function toSsdOrDsd(disc, { force = false } = {}) {
|
|
|
770
865
|
);
|
|
771
866
|
}
|
|
772
867
|
const numSides = disc.isDoubleSided ? 2 : 1;
|
|
773
|
-
const result = new Uint8Array(
|
|
774
|
-
|
|
775
|
-
);
|
|
776
|
-
let offset = 0;
|
|
868
|
+
const result = new Uint8Array(numSides * SsdFormat.tracksPerDisc * SsdFormat.trackSize);
|
|
869
|
+
let numTracks = 0;
|
|
777
870
|
for (let trackNum = 0; trackNum < disc.tracksUsed; ++trackNum) {
|
|
778
871
|
for (let side = 0; side < numSides; ++side) {
|
|
779
872
|
const trackObj = disc.getTrack(side === 1, trackNum);
|
|
780
873
|
for (const sector of trackObj.findSectors()) {
|
|
781
|
-
if (sectorShortfall(sector
|
|
782
|
-
|
|
783
|
-
|
|
874
|
+
if (sectorShortfall(sector)) continue;
|
|
875
|
+
result.set(sector.sectorData, ssdOffsetOf(sector, side === 1, numSides));
|
|
876
|
+
numTracks = Math.max(numTracks, sector.trackNumber + 1);
|
|
784
877
|
}
|
|
785
|
-
offset += SsdFormat.sectorsPerTrack * SsdFormat.sectorSize;
|
|
786
878
|
}
|
|
787
879
|
}
|
|
788
|
-
return result.slice(0,
|
|
880
|
+
return result.slice(0, numTracks * numSides * SsdFormat.trackSize);
|
|
789
881
|
}
|
|
790
882
|
|
|
791
883
|
export class Disc {
|
|
@@ -810,8 +902,11 @@ export class Disc {
|
|
|
810
902
|
this.dirtyTrack = -1;
|
|
811
903
|
this.tracksUsed = 0;
|
|
812
904
|
this.isDoubleSided = false;
|
|
905
|
+
// Whether the surface holds a 48 tpi layout, which a drive reads by double stepping.
|
|
906
|
+
this.is40Track = false;
|
|
813
907
|
|
|
814
908
|
this._trackWriteListeners = new Set();
|
|
909
|
+
this._savingListeners = new Set();
|
|
815
910
|
this.isWriteable = isWriteable;
|
|
816
911
|
|
|
817
912
|
// Track which tracks have been written since the last snapshot.
|
|
@@ -838,14 +933,32 @@ export class Disc {
|
|
|
838
933
|
this.initSurface(0);
|
|
839
934
|
}
|
|
840
935
|
|
|
841
|
-
/**
|
|
842
|
-
|
|
936
|
+
/**
|
|
937
|
+
* @param {function(boolean, Number, Track): void} listener called once per flushed track
|
|
938
|
+
* @param {boolean} [savesChanges] whether the listener puts the image somewhere it is read back from
|
|
939
|
+
*/
|
|
940
|
+
addTrackWriteListener(listener, savesChanges = false) {
|
|
843
941
|
this._trackWriteListeners.add(listener);
|
|
942
|
+
if (savesChanges) this._savingListeners.add(listener);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
get savesChanges() {
|
|
946
|
+
return this._savingListeners.size > 0;
|
|
844
947
|
}
|
|
845
948
|
|
|
846
949
|
/** @param {function(boolean, Number, Track): void} listener */
|
|
847
950
|
removeTrackWriteListener(listener) {
|
|
848
951
|
this._trackWriteListeners.delete(listener);
|
|
952
|
+
this._savingListeners.delete(listener);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** @param {function(): void} listener called once, when a track is first written to this disc */
|
|
956
|
+
notifyOnFirstTrackWrite(listener) {
|
|
957
|
+
const onFirstWrite = () => {
|
|
958
|
+
this.removeTrackWriteListener(onFirstWrite);
|
|
959
|
+
listener();
|
|
960
|
+
};
|
|
961
|
+
this.addTrackWriteListener(onFirstWrite);
|
|
849
962
|
}
|
|
850
963
|
|
|
851
964
|
/**
|
|
@@ -944,10 +1057,11 @@ export class Disc {
|
|
|
944
1057
|
// console.log(`wrote to ${track}:${position * 32}`);
|
|
945
1058
|
}
|
|
946
1059
|
|
|
1060
|
+
/** @returns {?{isSideUpper: boolean, trackNum: Number}} the track written, if there was one */
|
|
947
1061
|
flushWrites() {
|
|
948
1062
|
if (!this.isDirty) {
|
|
949
1063
|
if (this.dirtySide !== -1 || this.dirtyTrack !== -1) throw new Error("Bad state in disc dirty tracking");
|
|
950
|
-
return;
|
|
1064
|
+
return null;
|
|
951
1065
|
}
|
|
952
1066
|
|
|
953
1067
|
const dirtySide = this.dirtySide;
|
|
@@ -958,6 +1072,24 @@ export class Disc {
|
|
|
958
1072
|
const trackObj = this.getTrack(dirtySide, dirtyTrack);
|
|
959
1073
|
this.setTrackUsed(dirtySide, dirtyTrack);
|
|
960
1074
|
for (const listener of this._trackWriteListeners) listener(dirtySide, dirtyTrack, trackObj);
|
|
1075
|
+
return { isSideUpper: dirtySide, trackNum: dirtyTrack };
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Leave a track with no flux on it at all, as an erase head does.
|
|
1080
|
+
*
|
|
1081
|
+
* @param {boolean} isSideUpper
|
|
1082
|
+
* @param {Number} trackNum
|
|
1083
|
+
*/
|
|
1084
|
+
eraseTrack(isSideUpper, trackNum) {
|
|
1085
|
+
const trackObj = this.getTrack(isSideUpper, trackNum);
|
|
1086
|
+
trackObj.pulses2Us.fill(0);
|
|
1087
|
+
trackObj.length = IbmDiscFormat.bytesPerTrack;
|
|
1088
|
+
const dirtyKey = trackNum | (isSideUpper ? 0x100 : 0);
|
|
1089
|
+
this._snapshotDirtyTracks.add(dirtyKey);
|
|
1090
|
+
this._everDirtyTracks.add(dirtyKey);
|
|
1091
|
+
this.setTrackUsed(isSideUpper, trackNum);
|
|
1092
|
+
for (const listener of this._trackWriteListeners) listener(isSideUpper, trackNum, trackObj);
|
|
961
1093
|
}
|
|
962
1094
|
|
|
963
1095
|
/**
|
package/src/fdc.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
// Floppy disc assorted utils.
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
Disc,
|
|
4
|
+
DiscConfig,
|
|
5
|
+
DiscLayout,
|
|
6
|
+
loadAdf,
|
|
7
|
+
loadSsd,
|
|
8
|
+
sniffDfsLayout,
|
|
9
|
+
sniffSurfaceLayout,
|
|
10
|
+
toSsdOrDsd,
|
|
11
|
+
} from "./disc.js";
|
|
12
|
+
import { loadHfe, sniffHfeLayout, toHfe } from "./disc-hfe.js";
|
|
4
13
|
import * as utils from "./utils.js";
|
|
5
14
|
|
|
6
15
|
export function load(name) {
|
|
@@ -19,15 +28,29 @@ export class DiscType {
|
|
|
19
28
|
* @param {function(Disc, Uint8Array, function?): Disc} options.loader - Function to load this disc type.
|
|
20
29
|
* @param {function(Disc): Uint8Array} options.saver - Function to save this disc type.
|
|
21
30
|
* @param {function(Uint8Array, string): void|null} [options.nameSetter] - Function to set the name/label in the disc image, or null if not supported.
|
|
31
|
+
* @param {function(Uint8Array): {is40Track: boolean, reason: string}} [options.layoutSniffer] - Function to tell a 40 track image from an 80 track one, for formats that carry the evidence.
|
|
32
|
+
* @param {boolean} [options.isFluxImage] - Whether the image is a picture of the surface rather than a list of the sectors on it.
|
|
22
33
|
* @param {boolean} [options.isDoubleSided] - Whether the disc format is double-sided.
|
|
23
34
|
* @param {boolean} [options.isDoubleDensity] - Whether the disc format is double density.
|
|
24
35
|
* @param {number|undefined} [options.byteSize] - The size in bytes of this disc format, or undefined if variable.
|
|
25
36
|
*/
|
|
26
|
-
constructor({
|
|
37
|
+
constructor({
|
|
38
|
+
extension,
|
|
39
|
+
loader,
|
|
40
|
+
saver,
|
|
41
|
+
nameSetter = null,
|
|
42
|
+
layoutSniffer,
|
|
43
|
+
isFluxImage,
|
|
44
|
+
isDoubleSided,
|
|
45
|
+
isDoubleDensity,
|
|
46
|
+
byteSize,
|
|
47
|
+
} = {}) {
|
|
27
48
|
this._extension = extension;
|
|
28
49
|
this._loader = loader;
|
|
29
50
|
this._saver = saver;
|
|
30
51
|
this._nameSetter = nameSetter;
|
|
52
|
+
this._layoutSniffer = layoutSniffer;
|
|
53
|
+
this._isFluxImage = isFluxImage;
|
|
31
54
|
this._isDoubleSided = isDoubleSided;
|
|
32
55
|
this._isDoubleDensity = isDoubleDensity;
|
|
33
56
|
this._byteSize = byteSize;
|
|
@@ -97,6 +120,24 @@ export class DiscType {
|
|
|
97
120
|
return this._nameSetter !== null;
|
|
98
121
|
}
|
|
99
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Whether the image holds a picture of the surface, so that where its tracks sit is a fact
|
|
125
|
+
* about the disc rather than a decision the loader made.
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
get isFluxImage() {
|
|
129
|
+
return !!this._isFluxImage;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What an image of this type says about its track layout.
|
|
134
|
+
* @param {Uint8Array} data - The disc image data
|
|
135
|
+
* @returns {?{is40Track: boolean, reason: string}} null for formats that cannot say
|
|
136
|
+
*/
|
|
137
|
+
sniffLayout(data) {
|
|
138
|
+
return this._layoutSniffer ? this._layoutSniffer(data) : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
100
141
|
/**
|
|
101
142
|
* Sets the disc name in the disc data using the format-specific setter
|
|
102
143
|
* @param {Uint8Array} data - The disc data to modify
|
|
@@ -114,8 +155,9 @@ export class DiscType {
|
|
|
114
155
|
// Standard sizes
|
|
115
156
|
const SsdByteSize = 80 * 10 * 256; // 80 tracks, 10 sectors, 256 bytes/sector
|
|
116
157
|
const DsdByteSize = SsdByteSize * 2; // Double-sided
|
|
117
|
-
|
|
118
|
-
const
|
|
158
|
+
// ADFS comes in three sizes: S is 40 tracks on one side, M is 80 on one, L is 80 on both.
|
|
159
|
+
const AdfsMediumByteSize = 80 * 16 * 256;
|
|
160
|
+
const AdfsLargeByteSize = AdfsMediumByteSize * 2;
|
|
119
161
|
|
|
120
162
|
/**
|
|
121
163
|
* Set the name in a DFS disc image (SSD/DSD format)
|
|
@@ -133,6 +175,8 @@ const hfeDiscType = new DiscType({
|
|
|
133
175
|
extension: ".hfe",
|
|
134
176
|
loader: loadHfe,
|
|
135
177
|
saver: toHfe,
|
|
178
|
+
layoutSniffer: sniffHfeLayout,
|
|
179
|
+
isFluxImage: true,
|
|
136
180
|
isDoubleSided: true,
|
|
137
181
|
isDoubleDensity: true,
|
|
138
182
|
});
|
|
@@ -152,7 +196,7 @@ const adlDiscType = new DiscType({
|
|
|
152
196
|
byteSize: AdfsLargeByteSize,
|
|
153
197
|
});
|
|
154
198
|
|
|
155
|
-
// ADFS
|
|
199
|
+
// ADFS M and S: single sided, and sized as the larger of the two.
|
|
156
200
|
const adfDiscType = new DiscType({
|
|
157
201
|
extension: ".adf",
|
|
158
202
|
loader: (disc, data, _onChange) => {
|
|
@@ -164,7 +208,7 @@ const adfDiscType = new DiscType({
|
|
|
164
208
|
},
|
|
165
209
|
isDoubleSided: false,
|
|
166
210
|
isDoubleDensity: true,
|
|
167
|
-
byteSize:
|
|
211
|
+
byteSize: AdfsMediumByteSize,
|
|
168
212
|
});
|
|
169
213
|
|
|
170
214
|
// DSD (Double-sided disc)
|
|
@@ -173,6 +217,7 @@ const dsdDiscType = new DiscType({
|
|
|
173
217
|
loader: (disc, data, onChange) => loadSsd(disc, data, true, onChange),
|
|
174
218
|
saver: toSsdOrDsd,
|
|
175
219
|
nameSetter: setDfsDiscName,
|
|
220
|
+
layoutSniffer: (data) => sniffDfsLayout(data, true),
|
|
176
221
|
isDoubleSided: true,
|
|
177
222
|
isDoubleDensity: false,
|
|
178
223
|
byteSize: DsdByteSize,
|
|
@@ -184,6 +229,7 @@ const ssdDiscType = new DiscType({
|
|
|
184
229
|
loader: (disc, data, onChange) => loadSsd(disc, data, false, onChange),
|
|
185
230
|
saver: toSsdOrDsd,
|
|
186
231
|
nameSetter: setDfsDiscName,
|
|
232
|
+
layoutSniffer: (data) => sniffDfsLayout(data, false),
|
|
187
233
|
isDoubleSided: false,
|
|
188
234
|
isDoubleDensity: false,
|
|
189
235
|
byteSize: SsdByteSize,
|
|
@@ -202,22 +248,57 @@ export function guessDiscTypeFromName(name) {
|
|
|
202
248
|
return ssdDiscType;
|
|
203
249
|
}
|
|
204
250
|
|
|
251
|
+
/**
|
|
252
|
+
* @param {DiscType} discType
|
|
253
|
+
* @param {Uint8Array} data
|
|
254
|
+
* @param {string} name
|
|
255
|
+
* @param {string} layout - one of DiscLayout
|
|
256
|
+
* @returns {boolean} whether to lay the image out for a 40 track drive
|
|
257
|
+
*/
|
|
258
|
+
function is40TrackLayout(discType, data, name, layout) {
|
|
259
|
+
if (layout !== DiscLayout.auto) return layout === DiscLayout.expanded40;
|
|
260
|
+
const sniffed = discType.sniffLayout(data);
|
|
261
|
+
if (!sniffed) return false;
|
|
262
|
+
console.log(`${name} loaded as ${sniffed.is40Track ? "40" : "80"} track: ${sniffed.reason}`);
|
|
263
|
+
return sniffed.is40Track;
|
|
264
|
+
}
|
|
265
|
+
|
|
205
266
|
/**
|
|
206
267
|
* Create a disc object of the appropriate type based on the file name
|
|
207
268
|
* @param {Object} fdc - The FDC controller object
|
|
208
269
|
* @param {string} name - The file name with extension
|
|
209
270
|
* @param {string|Uint8Array} stringData - The disc image data as string or Uint8Array
|
|
210
271
|
* @param {function(Uint8Array): void} onChange - Optional callback when disc content changes
|
|
272
|
+
* @param {string} [layout] - one of DiscLayout; by default the image is asked what it is
|
|
211
273
|
* @returns {Disc} The loaded disc object
|
|
212
274
|
*/
|
|
213
|
-
export function discFor(fdc, name, stringData, onChange) {
|
|
275
|
+
export function discFor(fdc, name, stringData, onChange, layout = DiscLayout.auto) {
|
|
214
276
|
const data = typeof stringData !== "string" ? stringData : utils.stringToUint8Array(stringData);
|
|
215
|
-
const
|
|
277
|
+
const discType = guessDiscTypeFromName(name);
|
|
278
|
+
const config = new DiscConfig();
|
|
279
|
+
config.expandTo80 = is40TrackLayout(discType, data, name, layout);
|
|
280
|
+
const disc = discType.loader(new Disc(true, config, name), data, onChange);
|
|
281
|
+
// A flux image of a 40 track disc read in an 80 track drive already holds it spread across the
|
|
282
|
+
// surface, so nothing needs moving and only the drive needs telling to step twice.
|
|
283
|
+
if (layout === DiscLayout.auto && discType.isFluxImage && !disc.is40Track) {
|
|
284
|
+
const sniffed = sniffSurfaceLayout(disc);
|
|
285
|
+
disc.is40Track = sniffed.is40Track;
|
|
286
|
+
console.log(`${name} surface reads as ${sniffed.is40Track ? "40" : "80"} track: ${sniffed.reason}`);
|
|
287
|
+
}
|
|
216
288
|
disc.setOriginalImageCrc32(data instanceof Uint8Array ? data : new Uint8Array(data));
|
|
217
289
|
return disc;
|
|
218
290
|
}
|
|
219
291
|
|
|
220
|
-
|
|
292
|
+
/**
|
|
293
|
+
* Create or open a disc held in the browser's local storage.
|
|
294
|
+
* @param {Object} fdc - The FDC controller object
|
|
295
|
+
* @param {string} name - The file name with extension
|
|
296
|
+
* @param {string} [layout] - one of DiscLayout; by default the image is asked what it is
|
|
297
|
+
* @param {function(*): void} [onSaveError] - called with whatever was thrown, the first time a write
|
|
298
|
+
* cannot be stored
|
|
299
|
+
* @returns {Disc} The loaded disc object
|
|
300
|
+
*/
|
|
301
|
+
export function localDisc(fdc, name, layout = DiscLayout.auto, onSaveError = () => {}) {
|
|
221
302
|
const discName = "disc_" + name;
|
|
222
303
|
let data;
|
|
223
304
|
const dataString = window.localStorage[discName];
|
|
@@ -235,13 +316,17 @@ export function localDisc(fdc, name) {
|
|
|
235
316
|
console.log("Loading browser-local disc " + name);
|
|
236
317
|
data = utils.stringToUint8Array(dataString);
|
|
237
318
|
}
|
|
319
|
+
let reportedSaveError = false;
|
|
238
320
|
const onChange = (data) => {
|
|
239
321
|
try {
|
|
240
322
|
const str = utils.uint8ArrayToString(data);
|
|
241
323
|
window.localStorage.setItem(discName, str);
|
|
242
324
|
} catch (e) {
|
|
243
|
-
|
|
325
|
+
console.log(`Unable to save browser-local disc ${name}: ${e}`);
|
|
326
|
+
if (reportedSaveError) return;
|
|
327
|
+
reportedSaveError = true;
|
|
328
|
+
onSaveError(e);
|
|
244
329
|
}
|
|
245
330
|
};
|
|
246
|
-
return discFor(fdc, name, data, onChange);
|
|
331
|
+
return discFor(fdc, name, data, onChange, layout);
|
|
247
332
|
}
|
package/src/google-drive.js
CHANGED
|
@@ -49,10 +49,11 @@ export class GoogleDriveLoader {
|
|
|
49
49
|
|
|
50
50
|
_loadScript(src) {
|
|
51
51
|
// https://github.com/google/google-api-javascript-client/issues/319
|
|
52
|
-
return new Promise((resolve) => {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
53
|
const script = document.createElement("script");
|
|
54
54
|
script.src = src;
|
|
55
55
|
script.onload = resolve;
|
|
56
|
+
script.onerror = () => reject(new Error(`Failed to fetch ${src}; a browser extension may be blocking it`));
|
|
56
57
|
document.body.appendChild(script);
|
|
57
58
|
});
|
|
58
59
|
}
|
|
@@ -134,7 +135,7 @@ export class GoogleDriveLoader {
|
|
|
134
135
|
return { fileId: meta.id, disc: this.makeDisc(fdc, data, meta) };
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
makeDisc(fdc, data, meta) {
|
|
138
|
+
makeDisc(fdc, data, meta, layout) {
|
|
138
139
|
let flusher = null;
|
|
139
140
|
const name = meta.name;
|
|
140
141
|
const id = meta.id;
|
|
@@ -148,12 +149,12 @@ export class GoogleDriveLoader {
|
|
|
148
149
|
} else {
|
|
149
150
|
console.log("Making read-only disc");
|
|
150
151
|
}
|
|
151
|
-
return discFor(fdc, name, data, flusher);
|
|
152
|
+
return discFor(fdc, name, data, flusher, layout);
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
async load(fdc, fileId) {
|
|
155
|
+
async load(fdc, fileId, layout) {
|
|
155
156
|
const meta = (await this.driveClient.files.get({ fileId, fields: FILE_FIELDS })).result;
|
|
156
157
|
const data = (await this.driveClient.files.get({ fileId, alt: "media" })).body;
|
|
157
|
-
return this.makeDisc(fdc, data, meta);
|
|
158
|
+
return this.makeDisc(fdc, data, meta, layout);
|
|
158
159
|
}
|
|
159
160
|
}
|
package/src/intel-fdc.js
CHANGED
|
@@ -1741,8 +1741,15 @@ export class IntelFdc {
|
|
|
1741
1741
|
* @param {Number} drive
|
|
1742
1742
|
* @param {Disc} disc
|
|
1743
1743
|
*/
|
|
1744
|
-
|
|
1744
|
+
/**
|
|
1745
|
+
* @param {Number} drive
|
|
1746
|
+
* @param {Disc} disc
|
|
1747
|
+
* @param {Number} [tracksPerStep] where to leave the drive's 40/80 switch, which by default
|
|
1748
|
+
* follows the disc, since no drive can tell what pitch the disc in it was written at
|
|
1749
|
+
*/
|
|
1750
|
+
loadDisc(drive, disc, tracksPerStep = disc?.is40Track ? 2 : 1) {
|
|
1745
1751
|
this._drives[drive].setDisc(disc);
|
|
1752
|
+
this._drives[drive].tracksPerStep = tracksPerStep;
|
|
1746
1753
|
}
|
|
1747
1754
|
|
|
1748
1755
|
get motorOn() {
|
package/src/jsbeeb.css
CHANGED
|
@@ -162,6 +162,54 @@ th {
|
|
|
162
162
|
overflow: auto;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
#hfe-list {
|
|
166
|
+
height: 380px;
|
|
167
|
+
overflow: auto;
|
|
168
|
+
list-style: none;
|
|
169
|
+
padding-left: 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#hfe-list li a {
|
|
173
|
+
display: grid;
|
|
174
|
+
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr);
|
|
175
|
+
gap: 0 1rem;
|
|
176
|
+
align-items: baseline;
|
|
177
|
+
padding: 3px 6px;
|
|
178
|
+
border-radius: 3px;
|
|
179
|
+
text-decoration: none;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#hfe-list li a:hover,
|
|
183
|
+
#hfe-list li a:focus-visible {
|
|
184
|
+
background: rgba(255, 255, 255, 0.08);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#hfe-list .name {
|
|
188
|
+
font-weight: 600;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#hfe-list .publisher,
|
|
192
|
+
#hfe-list .detail {
|
|
193
|
+
opacity: 0.65;
|
|
194
|
+
font-size: 0.9em;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#hfe-list .detail {
|
|
198
|
+
font-family: monospace;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.hfe-credit {
|
|
202
|
+
font-size: 0.9em;
|
|
203
|
+
opacity: 0.75;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@media (max-width: 40rem) {
|
|
207
|
+
#hfe-list li a {
|
|
208
|
+
grid-template-columns: minmax(0, 1fr);
|
|
209
|
+
padding-bottom: 6px;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
165
213
|
div.filter {
|
|
166
214
|
margin-top: 8px;
|
|
167
215
|
}
|
|
@@ -576,3 +624,20 @@ div.smoothie-chart-tooltip {
|
|
|
576
624
|
font-size: 10px;
|
|
577
625
|
pointer-events: none;
|
|
578
626
|
}
|
|
627
|
+
|
|
628
|
+
.drive-tracks {
|
|
629
|
+
display: flex;
|
|
630
|
+
align-items: center;
|
|
631
|
+
gap: 6px;
|
|
632
|
+
white-space: nowrap;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
.drive-tracks-label {
|
|
636
|
+
color: var(--bs-dropdown-link-color);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
.drive-tracks .btn {
|
|
640
|
+
/* The stock small button is taller than the menu rows around it. */
|
|
641
|
+
padding: 0 6px;
|
|
642
|
+
line-height: 1.4;
|
|
643
|
+
}
|
package/src/keyboard.js
CHANGED
|
@@ -43,6 +43,7 @@ export class Keyboard extends EventTarget {
|
|
|
43
43
|
this.pauseEmu = false;
|
|
44
44
|
this.stepEmuWhenPaused = false;
|
|
45
45
|
this.keyLayout = keyLayout;
|
|
46
|
+
this.saidCapsLockIsTapped = false;
|
|
46
47
|
|
|
47
48
|
// Modifier key states
|
|
48
49
|
this.lastShiftLocation = 1;
|
|
@@ -315,21 +316,19 @@ export class Keyboard extends EventTarget {
|
|
|
315
316
|
// Simulate a key release after a short delay
|
|
316
317
|
setTimeout(() => this.keyInterface.keyUp(utils.keyCodes.CAPSLOCK), CAPS_LOCK_DELAY);
|
|
317
318
|
|
|
318
|
-
if (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
window.localStorage.setItem("warnedAboutRubbishMacs", "true");
|
|
332
|
-
}
|
|
319
|
+
if (this.saidCapsLockIsTapped) return;
|
|
320
|
+
this.saidCapsLockIsTapped = true;
|
|
321
|
+
this.dispatchEvent(
|
|
322
|
+
new CustomEvent("notice", {
|
|
323
|
+
detail: {
|
|
324
|
+
message:
|
|
325
|
+
"macOS sends no key up for caps lock, so jsbeeb can only tap it. " +
|
|
326
|
+
"For a game that holds caps lock for left or fire, remap that key instead.",
|
|
327
|
+
title: "Keyboard",
|
|
328
|
+
quietKey: "warnedAboutRubbishMacs",
|
|
329
|
+
},
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
333
332
|
}
|
|
334
333
|
|
|
335
334
|
/**
|
package/src/machine-session.js
CHANGED
|
@@ -410,7 +410,8 @@ export class MachineSession {
|
|
|
410
410
|
*/
|
|
411
411
|
loadDisc(imagePath) {
|
|
412
412
|
const data = new Uint8Array(readFileSync(imagePath));
|
|
413
|
-
|
|
413
|
+
const machineFdc = this._machine.processor.fdc;
|
|
414
|
+
machineFdc.loadDisc(0, fdc.discFor(machineFdc, imagePath, data));
|
|
414
415
|
}
|
|
415
416
|
|
|
416
417
|
/**
|