@fugloy/emu 0.2.0 → 0.3.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 +124 -8
- package/package.json +1 -1
- package/src/cpu/z80cpu.js +9 -2
- package/src/debug/debugger.js +20 -9
- package/src/debug/label-file.js +10 -4
- package/src/debug/state.js +69 -10
- package/src/devices/cassette.js +5 -1
- package/src/devices/disk-image.js +635 -248
- package/src/devices/fdc-wd1771.js +180 -0
- package/src/devices/fdc-wd1793.js +838 -239
- package/src/devices/interrupt-latch.js +76 -0
- package/src/devices/key-queue.js +22 -5
- package/src/devices/video.js +6 -3
- package/src/index.js +3 -2
- package/src/loaders/load.js +169 -5
- package/src/machine/drive-select.js +42 -0
- package/src/machine/io.js +238 -15
- package/src/machine/machine.js +53 -15
- package/src/machine/memory.js +107 -24
- package/src/machine/models.js +123 -0
- package/src/machine/rom-hashes.js +14 -7
- package/src/machine/trs80-system.js +90 -33
|
@@ -1,59 +1,126 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TRS-80 disk
|
|
2
|
+
* TRS-80 disk images in memory: read JV1, JV3, and DMK exactly, write in place, export DMK.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* The containers, as Tim Mann's format notes (the xtrs distribution's "TRS-80 Disk Formats")
|
|
5
|
+
* define them:
|
|
5
6
|
*
|
|
6
|
-
* JV1 — headerless
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* JV1 — a headerless dump, single-sided, 256-byte sectors in track order. Jeff Vavasour's
|
|
8
|
+
* format is single density with 10 sectors per track numbered 0-9 (Model I); the
|
|
9
|
+
* Model III images of TRSDOS 1.3 are the same dump with 18 double-density sectors
|
|
10
|
+
* numbered 1-18. The file size is the only evidence, so only 35, 40, 77, or 80 tracks
|
|
11
|
+
* of 10 or 18 sectors count, and the geometry fixes the numbering (a Model I's drives
|
|
12
|
+
* take 10-sector JV1s of 35 to 42 tracks too: `jv1SdTracks`). JV1 records no density,
|
|
13
|
+
* data mark, or CRC: its IDs read in either density, and the directory track's marks
|
|
14
|
+
* are synthesized: FA on a 10-sector disk, the mark Model I TRSDOS writes its
|
|
15
|
+
* directory with for the WD1771, F8 on an 18-sector one.
|
|
10
16
|
*
|
|
11
|
-
* JV3 —
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* JV3 — blocks of 2,901 three-byte sector headers (track, sector, flags) and a write-protect
|
|
18
|
+
* byte (FF writable, anything else protected), each block followed by the data of its
|
|
19
|
+
* sectors in header order. A free header (track and sector FF) still owns its data
|
|
20
|
+
* area, sized by the free entries' size code, so the sectors after it keep their
|
|
21
|
+
* offsets; the untouched FF FF FF entries at the end own nothing that is in the file.
|
|
22
|
+
* A second header block, when there is one, starts after the first block's whole data
|
|
23
|
+
* area. Flags: 80 double density; 60 the data address mark (single density: 00 FB,
|
|
24
|
+
* 20 FA, 40 F9, 60 F8; double density: 00 FB, 20 F8); 10 side 1; 08 CRC error; 04
|
|
25
|
+
* non-IBM; 03 the size (used: 256, 128, 1024, 512; free: 512, 1024, 128, 256).
|
|
14
26
|
*
|
|
15
|
-
* DMK — 16-byte header (write
|
|
16
|
-
* flags
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
27
|
+
* DMK — a 16-byte header (write protect FF or 00, track count, track length little-endian,
|
|
28
|
+
* flags: 10 single-sided, 40 single-density bytes stored once, 80 ignore density;
|
|
29
|
+
* bytes 12-15 nonzero mark a real-drive image, which is refused), then each track
|
|
30
|
+
* side: 64 IDAM pointers (the offset of the FE ID mark from the track start, bit 15
|
|
31
|
+
* set for an MFM ID, zero ending the table) and the raw track bytes. Single-density
|
|
32
|
+
* bytes are stored twice unless flag 40 or 80 says otherwise; a track may mix
|
|
33
|
+
* densities.
|
|
22
34
|
*
|
|
23
|
-
* Writes
|
|
24
|
-
*
|
|
35
|
+
* Writes change the in-memory image only (the session's copy); `bytes` is always the image's
|
|
36
|
+
* truth, what a snapshot saves, and `toDMK()` writes any of the three as a DMK (spec §6: read
|
|
37
|
+
* all three, write DMK only).
|
|
25
38
|
*/
|
|
26
39
|
|
|
27
40
|
const JV3_ENTRIES = 2901;
|
|
41
|
+
|
|
42
|
+
// The JV1 geometries a real disk has: sectors per track (Model III
|
|
43
|
+
// double density, Model I single density) and track counts (35- and
|
|
44
|
+
// 40-track 5.25" drives, 77-track 8", 80-track 5.25").
|
|
45
|
+
const JV1_SPT = [18, 10];
|
|
46
|
+
const JV1_TRACKS = [35, 40, 77, 80];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The track counts a 10-sector JV1 may have on a Model I (`jv1SdTracks`, passed by `m.fdc.mount`
|
|
50
|
+
* on a Model I). Model I archives hold single-density JV1s of 35 tracks (TRSDOS 2.3's 35, Super
|
|
51
|
+
* Utility Tech Manual p. 3, "TRSDOS 2.3") and of up to 40 and beyond, as drives and DOSes that
|
|
52
|
+
* format 36 to 42 tracks wrote them; xtrs takes any whole number of 2,560-byte tracks. Accepted
|
|
53
|
+
* here: 35 to 42, 77, and 80, single-sided. A file of another length still refuses, so a .CMD
|
|
54
|
+
* does not mount as a short JV1 (phase 6 plan, Task 3 ruling; spec §5).
|
|
55
|
+
*/
|
|
56
|
+
export const JV1_MODEL1_SD_TRACKS = Object.freeze([35, 36, 37, 38, 39, 40, 41, 42, 77, 80]);
|
|
57
|
+
|
|
58
|
+
/** "35, 40, 77, 80" or, for a run of consecutive counts, "35-42, 77, 80". */
|
|
59
|
+
function trackList(list) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (let i = 0; i < list.length; ) {
|
|
62
|
+
let j = i;
|
|
63
|
+
while (j + 1 < list.length && list[j + 1] === list[j] + 1) j++;
|
|
64
|
+
out.push(j - i >= 2 ? `${list[i]}-${list[j]}` : list.slice(i, j + 1).join(", "));
|
|
65
|
+
i = j + 1;
|
|
66
|
+
}
|
|
67
|
+
return out.join(", ");
|
|
68
|
+
}
|
|
28
69
|
const JV3_HEADER_SIZE = JV3_ENTRIES * 3 + 1;
|
|
29
70
|
const JV3_FREE = 0xff;
|
|
30
71
|
|
|
31
72
|
// JV3 flag bits
|
|
32
|
-
const
|
|
73
|
+
const F_DD = 0x80;
|
|
33
74
|
const F_DAM = 0x60; // data address mark code (0x00 = normal 0xFB)
|
|
75
|
+
const F_SIDE = 0x10;
|
|
76
|
+
const F_CRC_ERROR = 0x08;
|
|
34
77
|
|
|
78
|
+
/** The data size a JV3 header's size code gives: used and free entries number them differently. */
|
|
35
79
|
function jv3SectorSize(flags, free) {
|
|
36
|
-
// Size code meaning flips between used and free entries; used shown here
|
|
37
80
|
const code = flags & 0x03;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
81
|
+
return free ? [512, 1024, 128, 256][code] : [256, 128, 1024, 512][code];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const JV3_SIZE_CODE = { 256: 0x00, 128: 0x01, 1024: 0x02, 512: 0x03 };
|
|
85
|
+
|
|
86
|
+
/** The data address mark a JV3 flags byte records. */
|
|
87
|
+
function jv3Dam(flags) {
|
|
88
|
+
const code = flags & F_DAM;
|
|
89
|
+
if (flags & F_DD) return code === 0 ? 0xfb : 0xf8;
|
|
90
|
+
return [0xfb, 0xfa, 0xf9, 0xf8][code >> 5];
|
|
42
91
|
}
|
|
43
92
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
93
|
+
/** The JV3 flags bits for a data address mark at a density. */
|
|
94
|
+
function jv3DamBits(dam, dd) {
|
|
95
|
+
if (dam === 0xfb) return 0x00;
|
|
96
|
+
if (dd) return 0x20; // F8; MFM has no code for FA or F9
|
|
97
|
+
return { 0xfa: 0x20, 0xf9: 0x40, 0xf8: 0x60 }[dam] ?? 0x60;
|
|
49
98
|
}
|
|
50
99
|
|
|
100
|
+
// The WD1793 sets Record Type (status bit 5) for a deleted data mark,
|
|
101
|
+
// F8 ("Status Register Summary"); the sheet names only FB and F8. Of the
|
|
102
|
+
// FM marks the WD1771 also writes, the Model III's 1793 "recognizes 'Read
|
|
103
|
+
// Protected' [FA] and 'Standard Data' both as 'Standard Data'" and
|
|
104
|
+
// "'Deleted Data' and 'User Defined' [F8, F9] as 'Read Protected'"
|
|
105
|
+
// (Super Utility Tech Manual, Breeze/QSD 1982, p. 2): FA reads as normal,
|
|
106
|
+
// F9 as deleted (F9 unverified: spec §5). The WD1771 model reads `dam`
|
|
107
|
+
// itself (fdc-wd1771.js), not this.
|
|
108
|
+
const isDeleted = (dam) => dam === 0xf8 || dam === 0xf9;
|
|
109
|
+
|
|
51
110
|
// TRSDOS-family boot sectors name the directory cylinder in byte 2.
|
|
52
111
|
const DEFAULT_DIR_TRACK = 17;
|
|
53
112
|
|
|
54
113
|
// ---------------------------- DMK ----------------------------------
|
|
55
114
|
const DMK_HEADER = 16;
|
|
56
115
|
const DMK_IDAM_TABLE = 128;
|
|
116
|
+
const DMK_IDAMS = 64;
|
|
117
|
+
/** The usual 5.25" double-density DMK track length: the IDAM table and 6,272 raw bytes. */
|
|
118
|
+
const DMK_TRACK_LEN = 0x1900;
|
|
119
|
+
const DMK_SINGLE_SIDED = 0x10;
|
|
120
|
+
const DMK_SD_SINGLE_BYTE = 0x40;
|
|
121
|
+
const DMK_IGNORE_DENSITY = 0x80;
|
|
122
|
+
|
|
123
|
+
const SIZE_CODE = { 128: 0, 256: 1, 512: 2, 1024: 3 };
|
|
57
124
|
|
|
58
125
|
/** CRC-CCITT, the checksum the WD1793 writes after ID and data fields. */
|
|
59
126
|
function crc16(bytes, init = 0xffff) {
|
|
@@ -68,97 +135,190 @@ function crc16(bytes, init = 0xffff) {
|
|
|
68
135
|
}
|
|
69
136
|
|
|
70
137
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
138
|
+
* @typedef {{ sector: number, data: ArrayLike<number>, track?: number, side?: number,
|
|
139
|
+
* size?: number, sizeCode?: number, mfm?: boolean | null, deleted?: boolean, dam?: number,
|
|
140
|
+
* idCrcOk?: boolean, dataCrcOk?: boolean }} TrackSector
|
|
74
141
|
*/
|
|
75
|
-
function buildDmkTrackRegion(cyl, side, sectors, trackLen) {
|
|
76
|
-
const rawCapacity = trackLen - DMK_IDAM_TABLE;
|
|
77
|
-
const perSector = (gap) => gap + 12 + 3 + 7 + 22 + 12 + 3 + 1 + 2;
|
|
78
|
-
const need = (gap) =>
|
|
79
|
-
48 + sectors.reduce((sum, s) => sum + perSector(gap) + (s.size ?? s.data.length), 0);
|
|
80
|
-
let gap = 24;
|
|
81
|
-
if (need(gap) > rawCapacity) gap = 8;
|
|
82
|
-
if (need(gap) > rawCapacity) {
|
|
83
|
-
throw new Error(`Formatted track needs ${need(gap)} bytes; DMK track holds ${rawCapacity}`);
|
|
84
|
-
}
|
|
85
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Lay one DMK track's raw bytes from its sectors, in the FD179X data sheet's track formats ("IBM
|
|
145
|
+
* System 34 Format" for MFM sectors, "IBM 3740 Format" for FM ones) with shortened gaps: an index
|
|
146
|
+
* area in the first sector's density, then per sector its gap, the sync zeros (and A1s in MFM),
|
|
147
|
+
* the ID field and its CRC, Gap II, the sync again, the data mark, the data, and its CRC. FM
|
|
148
|
+
* bytes are stored twice when `doubled`. A sector with `idCrcOk` or `dataCrcOk` false gets that
|
|
149
|
+
* CRC inverted, so a bad CRC exports as bad. `tight` uses the data sheet's "Non-IBM Formats"
|
|
150
|
+
* minimum gaps. Returns the raw bytes and the IDAM pointers (offsets from the raw start).
|
|
151
|
+
* @param {number} cyl
|
|
152
|
+
* @param {number} side
|
|
153
|
+
* @param {TrackSector[]} sectors
|
|
154
|
+
* @param {boolean} doubled
|
|
155
|
+
* @param {boolean} tight
|
|
156
|
+
*/
|
|
157
|
+
function layDmkTrack(cyl, side, sectors, doubled, tight) {
|
|
158
|
+
/** @type {number[]} */
|
|
86
159
|
const raw = [];
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
160
|
+
/** @type {number[]} */
|
|
161
|
+
const ptrs = [];
|
|
162
|
+
const put = (v, mfm) => {
|
|
163
|
+
raw.push(v);
|
|
164
|
+
if (!mfm && doubled) raw.push(v);
|
|
165
|
+
};
|
|
166
|
+
const run = (n, v, mfm) => {
|
|
167
|
+
for (let k = 0; k < n; k++) put(v, mfm);
|
|
168
|
+
};
|
|
169
|
+
const firstMfm = sectors.length === 0 || sectors[0].mfm !== false;
|
|
170
|
+
if (firstMfm) {
|
|
171
|
+
run(tight ? 16 : 32, 0x4e, true);
|
|
172
|
+
run(12, 0x00, true);
|
|
173
|
+
for (const v of [0xc2, 0xc2, 0xc2, 0xfc]) put(v, true);
|
|
174
|
+
} else {
|
|
175
|
+
run(tight ? 8 : 16, 0xff, false);
|
|
176
|
+
run(6, 0x00, false);
|
|
177
|
+
put(0xfc, false);
|
|
178
|
+
}
|
|
92
179
|
for (const s of sectors) {
|
|
180
|
+
const mfm = s.mfm !== false;
|
|
181
|
+
const gap = mfm ? 0x4e : 0xff;
|
|
93
182
|
const size = s.size ?? s.data.length;
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const id = [0xfe, s.track ?? cyl, s.side ?? side, s.sector, sizeCode];
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const mark = s.deleted ? 0xf8 : 0xfb;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
183
|
+
const sync = mfm ? [0xa1, 0xa1, 0xa1] : [];
|
|
184
|
+
run(mfm ? (tight ? 8 : 24) : tight ? 6 : 12, gap, mfm);
|
|
185
|
+
run(mfm ? 12 : 6, 0x00, mfm);
|
|
186
|
+
for (const v of sync) put(v, mfm);
|
|
187
|
+
ptrs.push(raw.length | (mfm ? 0x8000 : 0));
|
|
188
|
+
const id = [0xfe, s.track ?? cyl, s.side ?? side, s.sector, s.sizeCode ?? SIZE_CODE[size] ?? 1];
|
|
189
|
+
let idCrc = crc16([...sync, ...id]);
|
|
190
|
+
if (s.idCrcOk === false) idCrc ^= 0xffff;
|
|
191
|
+
for (const v of [...id, idCrc >> 8, idCrc & 0xff]) put(v, mfm);
|
|
192
|
+
run(mfm ? 22 : 11, gap, mfm);
|
|
193
|
+
run(mfm ? 12 : 6, 0x00, mfm);
|
|
194
|
+
for (const v of sync) put(v, mfm);
|
|
195
|
+
const mark = s.dam ?? (s.deleted ? 0xf8 : 0xfb);
|
|
196
|
+
const data = new Uint8Array(size);
|
|
197
|
+
for (let k = 0; k < size; k++) data[k] = s.data[k] ?? 0;
|
|
198
|
+
let dataCrc = crc16([...sync, mark, ...data]);
|
|
199
|
+
if (s.dataCrcOk === false) dataCrc ^= 0xffff;
|
|
200
|
+
put(mark, mfm);
|
|
201
|
+
for (const v of data) put(v, mfm);
|
|
202
|
+
put(dataCrc >> 8, mfm);
|
|
203
|
+
put(dataCrc & 0xff, mfm);
|
|
204
|
+
}
|
|
205
|
+
if (ptrs.length > DMK_IDAMS) {
|
|
206
|
+
throw new Error(`A DMK track holds at most ${DMK_IDAMS} sectors; this one has ${ptrs.length}`);
|
|
112
207
|
}
|
|
113
|
-
|
|
208
|
+
return { raw, ptrs, fill: firstMfm ? 0x4e : 0xff };
|
|
209
|
+
}
|
|
114
210
|
|
|
211
|
+
/** Write a laid track into a DMK track region (IDAM table + raw bytes) of `trackLen` bytes. */
|
|
212
|
+
function dmkRegion(laid, trackLen) {
|
|
115
213
|
const region = new Uint8Array(trackLen);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
region[i * 2
|
|
214
|
+
laid.ptrs.forEach((p, i) => {
|
|
215
|
+
const ptr = p + DMK_IDAM_TABLE;
|
|
216
|
+
region[i * 2] = ptr & 0xff;
|
|
217
|
+
region[i * 2 + 1] = ptr >> 8;
|
|
119
218
|
});
|
|
120
|
-
region.
|
|
219
|
+
region.fill(laid.fill, DMK_IDAM_TABLE);
|
|
220
|
+
region.set(laid.raw, DMK_IDAM_TABLE);
|
|
121
221
|
return region;
|
|
122
222
|
}
|
|
123
223
|
|
|
224
|
+
/**
|
|
225
|
+
* One DMK track region for freshly formatted sectors, IBM-style gaps, or the minimum gaps when
|
|
226
|
+
* those do not fit the image's track length.
|
|
227
|
+
*/
|
|
228
|
+
function buildDmkTrackRegion(cyl, side, sectors, trackLen, doubled) {
|
|
229
|
+
const capacity = trackLen - DMK_IDAM_TABLE;
|
|
230
|
+
let laid = layDmkTrack(cyl, side, sectors, doubled, false);
|
|
231
|
+
if (laid.raw.length > capacity) laid = layDmkTrack(cyl, side, sectors, doubled, true);
|
|
232
|
+
if (laid.raw.length > capacity) {
|
|
233
|
+
throw new Error(`Formatted track needs ${laid.raw.length} bytes; DMK track holds ${capacity}`);
|
|
234
|
+
}
|
|
235
|
+
return dmkRegion(laid, trackLen);
|
|
236
|
+
}
|
|
237
|
+
|
|
124
238
|
export class DiskImage {
|
|
125
239
|
/**
|
|
126
240
|
* @param {Uint8Array} bytes - raw .dsk contents
|
|
127
241
|
* @param {string} name - display name (file name)
|
|
242
|
+
* @param {{ format?: "dmk" | "jv3" | "jv1", dirTrack?: number, jv1SdTracks?: readonly number[] }} [opts] - `jv1SdTracks`: the
|
|
243
|
+
* track counts a 10-sector JV1 may have (35, 40, 77, 80 by default; a Model I passes
|
|
244
|
+
* JV1_MODEL1_SD_TRACKS); for a save state's
|
|
245
|
+
* disk, whose container is known: `format` parses the bytes as that container only, without
|
|
246
|
+
* the detection's plausibility floor (a JV3 of fewer than ten sectors, which a format of a
|
|
247
|
+
* blank disk can leave, is a valid disk the session made); `dirTrack` restores a JV1's
|
|
248
|
+
* directory track as it was mounted (a guest rewrite of track 0, sector 0 changes byte 2, which
|
|
249
|
+
* is read only at mount).
|
|
128
250
|
*/
|
|
129
|
-
constructor(bytes, name = "disk") {
|
|
251
|
+
constructor(bytes, name = "disk", { format, dirTrack, jv1SdTracks = JV1_TRACKS } = {}) {
|
|
130
252
|
this.name = name;
|
|
131
253
|
this.bytes = new Uint8Array(bytes); // private working copy
|
|
132
254
|
this.writeProtected = false;
|
|
133
|
-
this.format = null; // "jv3" | "jv1"
|
|
134
|
-
this.sectorBase = null; // JV1: 0 or 1,
|
|
135
|
-
|
|
136
|
-
if (
|
|
255
|
+
this.format = null; // "dmk" | "jv3" | "jv1"
|
|
256
|
+
this.sectorBase = null; // JV1: the first sector number, 0 or 1, from the geometry
|
|
257
|
+
|
|
258
|
+
if (format !== undefined) {
|
|
259
|
+
const parsed =
|
|
260
|
+
format === "dmk"
|
|
261
|
+
? this._tryParseDMK()
|
|
262
|
+
: format === "jv3"
|
|
263
|
+
? this._tryParseJV3(false)
|
|
264
|
+
: format === "jv1"
|
|
265
|
+
? this._tryParseJV1(JV1_MODEL1_SD_TRACKS) // a mounted JV1: any geometry a mount took
|
|
266
|
+
: false;
|
|
267
|
+
if (!parsed) throw new Error(`Disk image (${bytes.length} bytes) does not parse as ${String(format)}`);
|
|
268
|
+
this.format = format;
|
|
269
|
+
if (format === "jv1" && dirTrack !== undefined) this.dirTrack = dirTrack;
|
|
270
|
+
} else if (this._tryParseDMK()) {
|
|
137
271
|
this.format = "dmk";
|
|
138
272
|
} else if (this._tryParseJV3()) {
|
|
139
273
|
this.format = "jv3";
|
|
140
|
-
} else if (this._tryParseJV1()) {
|
|
274
|
+
} else if (this._tryParseJV1(jv1SdTracks)) {
|
|
141
275
|
this.format = "jv1";
|
|
142
276
|
} else {
|
|
277
|
+
const sizes =
|
|
278
|
+
trackList(jv1SdTracks) === trackList(JV1_TRACKS)
|
|
279
|
+
? `${trackList(JV1_TRACKS)} tracks of ${JV1_SPT.join(" or ")} 256-byte sectors`
|
|
280
|
+
: `${trackList(JV1_TRACKS)} tracks of 18 256-byte sectors, or ${trackList(jv1SdTracks)} tracks of 10`;
|
|
143
281
|
throw new Error(
|
|
144
|
-
`Unrecognized disk image (${bytes.length} bytes):
|
|
282
|
+
`Unrecognized disk image (${bytes.length} bytes): no DMK header, no JV3 sector table, and not a JV1 size (${sizes})`
|
|
145
283
|
);
|
|
146
284
|
}
|
|
147
285
|
}
|
|
148
286
|
|
|
149
287
|
_tryParseDMK() {
|
|
150
288
|
const b = this.bytes;
|
|
151
|
-
if (b.length < DMK_HEADER
|
|
289
|
+
if (b.length < DMK_HEADER) return false;
|
|
152
290
|
const tracks = b[1];
|
|
153
291
|
const trackLen = b[2] | (b[3] << 8);
|
|
154
292
|
const flags = b[4];
|
|
155
|
-
|
|
293
|
+
// The header's defined values only: write protect FF or 00, flags 10, 40, 80; a track long
|
|
294
|
+
// enough for the IDAM table and short enough for its 14-bit offsets.
|
|
295
|
+
if (b[0] !== 0x00 && b[0] !== 0xff) return false;
|
|
296
|
+
if (flags & ~(DMK_SINGLE_SIDED | DMK_SD_SINGLE_BYTE | DMK_IGNORE_DENSITY)) return false;
|
|
156
297
|
if (trackLen < 0x100 || trackLen > 0x4000) return false;
|
|
157
298
|
if (b[12] | b[13] | b[14] | b[15]) return false; // real-drive marker
|
|
158
|
-
|
|
299
|
+
if (tracks > 96) return false;
|
|
300
|
+
// A DMK with no tracks is an unformatted disk (what toDMK writes for a blank one); it is
|
|
301
|
+
// taken as double-sided so a format of either side fits.
|
|
302
|
+
const sides = flags & DMK_SINGLE_SIDED ? 1 : 2;
|
|
159
303
|
if (b.length !== DMK_HEADER + tracks * sides * trackLen) return false;
|
|
304
|
+
// Every IDAM pointer before the table's end must point into its track.
|
|
305
|
+
for (let i = 0; i < tracks * sides; i++) {
|
|
306
|
+
const base = DMK_HEADER + i * trackLen;
|
|
307
|
+
for (let k = 0; k < DMK_IDAMS; k++) {
|
|
308
|
+
const ptr = b[base + k * 2] | (b[base + k * 2 + 1] << 8);
|
|
309
|
+
if (!ptr) break;
|
|
310
|
+
const off = ptr & 0x3fff;
|
|
311
|
+
if (off < DMK_IDAM_TABLE || off >= trackLen) return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
160
314
|
|
|
161
|
-
this.dmk = {
|
|
315
|
+
this.dmk = {
|
|
316
|
+
tracks,
|
|
317
|
+
trackLen,
|
|
318
|
+
sides,
|
|
319
|
+
sdSingleByte: (flags & DMK_SD_SINGLE_BYTE) !== 0,
|
|
320
|
+
ignoreDensity: (flags & DMK_IGNORE_DENSITY) !== 0,
|
|
321
|
+
};
|
|
162
322
|
this.writeProtected = b[0] === 0xff;
|
|
163
323
|
this._dmkCache = new Map();
|
|
164
324
|
return true;
|
|
@@ -178,33 +338,64 @@ export class DiskImage {
|
|
|
178
338
|
const out = [];
|
|
179
339
|
const base = this._dmkTrackBase(track, side);
|
|
180
340
|
if (base >= 0) {
|
|
181
|
-
const { trackLen, sdSingleByte } = this.dmk;
|
|
341
|
+
const { trackLen, sdSingleByte, ignoreDensity } = this.dmk;
|
|
182
342
|
const b = this.bytes;
|
|
183
|
-
for (let i = 0; i <
|
|
343
|
+
for (let i = 0; i < DMK_IDAMS; i++) {
|
|
184
344
|
const ptr = b[base + i * 2] | (b[base + i * 2 + 1] << 8);
|
|
185
345
|
if (!ptr) break; // unused table entries are zero
|
|
186
346
|
const mfm = (ptr & 0x8000) !== 0;
|
|
187
347
|
const off = ptr & 0x3fff;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
348
|
+
// FM bytes are stored twice unless the header says single bytes (flag 40) or density
|
|
349
|
+
// is ignored (flag 80, where every byte is stored once).
|
|
350
|
+
const step = mfm || sdSingleByte || ignoreDensity ? 1 : 2;
|
|
351
|
+
const at = (k) => (off + k * step < trackLen ? b[base + off + k * step] : -1);
|
|
191
352
|
if (at(0) !== 0xfe) continue;
|
|
192
353
|
const sector = at(3);
|
|
193
354
|
const size = 128 << (at(4) & 3);
|
|
194
|
-
//
|
|
355
|
+
// "Read Sector": the data address mark (F8-FB, "Control Bytes for Initialization") must
|
|
356
|
+
// be found within 30 bytes in FM and 43 in MFM of the last ID CRC byte. In MFM a mark
|
|
357
|
+
// is preceded by its A1 sync bytes.
|
|
195
358
|
let dataOff = -1;
|
|
196
|
-
let
|
|
197
|
-
const limit = mfm ?
|
|
359
|
+
let dam = 0xfb;
|
|
360
|
+
const limit = 6 + (mfm ? 43 : 30);
|
|
198
361
|
for (let k = 7; k <= limit; k++) {
|
|
199
362
|
const byte = at(k);
|
|
200
|
-
if (byte
|
|
363
|
+
if (byte >= 0xf8 && byte <= 0xfb && (!mfm || at(k - 1) === 0xa1)) {
|
|
201
364
|
dataOff = off + (k + 1) * step;
|
|
202
|
-
|
|
365
|
+
dam = byte;
|
|
203
366
|
break;
|
|
204
367
|
}
|
|
205
368
|
}
|
|
206
|
-
|
|
207
|
-
|
|
369
|
+
// An ID with no data mark in the window is still an ID: Read Address and a Type I verify
|
|
370
|
+
// read it, and Write Sector writes its data field after it (`_dmkWriteSector`), but "Read
|
|
371
|
+
// Sector" finds no data there: "the ID field is searched for and verified again", and
|
|
372
|
+
// after the search window, Record Not Found (FD179X data sheet, "Read Sector").
|
|
373
|
+
const sync = mfm ? [0xa1, 0xa1, 0xa1] : [];
|
|
374
|
+
const id = [0xfe, at(1), at(2), sector, at(4)];
|
|
375
|
+
const idCrcOk = crc16([...sync, ...id]) === ((at(5) << 8) | at(6));
|
|
376
|
+
if (dataOff < 0) {
|
|
377
|
+
out.push({
|
|
378
|
+
sector, size, dam: null, deleted: false, mfm: ignoreDensity ? null : mfm, recordedMfm: mfm, step, dataOff: -1,
|
|
379
|
+
off, idTrack: at(1), idSide: at(2), sizeCode: at(4) & 3,
|
|
380
|
+
idCrcOk, dataCrcOk: false, noData: true,
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (dataOff >= trackLen) continue;
|
|
385
|
+
// The ID and data fields' CRCs as the WD1793 checks them (FD179X data sheet, "Read
|
|
386
|
+
// Sector" and "Read Address"): CRC-CCITT preset FFFF over the address mark and the field,
|
|
387
|
+
// and in MFM over the three A1 sync bytes before the mark too.
|
|
388
|
+
// A field cut off by the end of the track reads as bad.
|
|
389
|
+
const dataAt = (k) => (dataOff + k * step < trackLen ? b[base + dataOff + k * step] : -1);
|
|
390
|
+
const field = [dam];
|
|
391
|
+
for (let k = 0; k < size; k++) field.push(dataAt(k));
|
|
392
|
+
const dataCrc = (dataAt(size) << 8) | dataAt(size + 1);
|
|
393
|
+
const dataCrcOk = !field.includes(-1) && dataAt(size + 1) >= 0 && crc16([...sync, ...field]) === dataCrc;
|
|
394
|
+
out.push({
|
|
395
|
+
sector, size, dam, deleted: isDeleted(dam), mfm: ignoreDensity ? null : mfm, recordedMfm: mfm, step, dataOff,
|
|
396
|
+
off, idTrack: at(1), idSide: at(2), sizeCode: at(4) & 3,
|
|
397
|
+
idCrcOk, dataCrcOk, noData: false,
|
|
398
|
+
});
|
|
208
399
|
}
|
|
209
400
|
}
|
|
210
401
|
this._dmkCache.set(key, out);
|
|
@@ -216,49 +407,62 @@ export class DiskImage {
|
|
|
216
407
|
const grown = new Uint8Array(DMK_HEADER + newTrackCount * sides * trackLen);
|
|
217
408
|
grown.set(this.bytes, 0);
|
|
218
409
|
grown[1] = newTrackCount;
|
|
410
|
+
// Tracks not yet formatted: an empty IDAM table and gap bytes.
|
|
411
|
+
for (let i = this.dmk.tracks * sides; i < newTrackCount * sides; i++) {
|
|
412
|
+
grown.fill(0x4e, DMK_HEADER + i * trackLen + DMK_IDAM_TABLE, DMK_HEADER + (i + 1) * trackLen);
|
|
413
|
+
}
|
|
219
414
|
this.bytes = grown;
|
|
220
415
|
this.dmk.tracks = newTrackCount;
|
|
221
416
|
}
|
|
222
417
|
|
|
223
|
-
|
|
224
|
-
|
|
418
|
+
/**
|
|
419
|
+
* Read the JV3 header blocks. Each used entry gets its data's offset; free entries advance the
|
|
420
|
+
* offset by their own size (see the file comment). Garbage is refused: an entry with a track
|
|
421
|
+
* over 96 or a sector over 63, or data running past the end of the file, means it is no JV3.
|
|
422
|
+
* `detect` adds the floor detection needs (ten sectors, or a blank disk); a known JV3 skips it.
|
|
423
|
+
*/
|
|
424
|
+
_tryParseJV3(detect = true) {
|
|
425
|
+
const b = this.bytes;
|
|
426
|
+
if (b.length < JV3_HEADER_SIZE) return false;
|
|
225
427
|
|
|
226
428
|
const map = [];
|
|
227
|
-
let
|
|
228
|
-
let
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
track
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
429
|
+
let blockAt = 0;
|
|
430
|
+
for (let block = 0; block < 2; block++) {
|
|
431
|
+
if (block > 0 && b.length < blockAt + JV3_HEADER_SIZE) break;
|
|
432
|
+
let dataOffset = blockAt + JV3_HEADER_SIZE;
|
|
433
|
+
for (let i = 0; i < JV3_ENTRIES; i++) {
|
|
434
|
+
const at = blockAt + i * 3;
|
|
435
|
+
const track = b[at];
|
|
436
|
+
const sector = b[at + 1];
|
|
437
|
+
const flags = b[at + 2];
|
|
438
|
+
const free = track === JV3_FREE && sector === JV3_FREE;
|
|
439
|
+
const size = jv3SectorSize(flags, free);
|
|
440
|
+
if (!free) {
|
|
441
|
+
if (track > 96 || sector > 63) return false;
|
|
442
|
+
if (dataOffset + size > b.length) return false;
|
|
443
|
+
map.push({
|
|
444
|
+
track,
|
|
445
|
+
sector,
|
|
446
|
+
side: flags & F_SIDE ? 1 : 0,
|
|
447
|
+
offset: dataOffset,
|
|
448
|
+
size,
|
|
449
|
+
flagsAt: at + 2, // to update the flags byte on DAM-changing writes
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
dataOffset += size;
|
|
248
453
|
}
|
|
249
|
-
|
|
250
|
-
if (dataOffset > this.bytes.length) return false;
|
|
454
|
+
blockAt = dataOffset;
|
|
251
455
|
}
|
|
252
456
|
// A real disk has many sectors; an exactly-header-sized all-free
|
|
253
457
|
// image is a valid BLANK disk (DiskImage.blank, and its export /
|
|
254
458
|
// save-state round trip) waiting for a DOS FORMAT.
|
|
255
|
-
if (
|
|
459
|
+
if (detect && map.length < 10 && !(map.length === 0 && b.length === JV3_HEADER_SIZE)) {
|
|
256
460
|
return false;
|
|
257
461
|
}
|
|
258
462
|
|
|
259
463
|
this.jv3Map = map;
|
|
260
464
|
// Last header byte: 0xff = writable, anything else = write-protected
|
|
261
|
-
this.writeProtected =
|
|
465
|
+
this.writeProtected = b[JV3_HEADER_SIZE - 1] !== 0xff;
|
|
262
466
|
return true;
|
|
263
467
|
}
|
|
264
468
|
|
|
@@ -267,32 +471,41 @@ export class DiskImage {
|
|
|
267
471
|
return new DiskImage(new Uint8Array(JV3_HEADER_SIZE).fill(0xff), name);
|
|
268
472
|
}
|
|
269
473
|
|
|
270
|
-
|
|
474
|
+
/** @param {readonly number[]} [sdTracks] the track counts a 10-sector JV1 may have */
|
|
475
|
+
_tryParseJV1(sdTracks = JV1_TRACKS) {
|
|
476
|
+
// JV1 has no header, so its size is the only evidence: accept only
|
|
477
|
+
// a geometry a real disk has, 10 sectors per track (Model I single
|
|
478
|
+
// density) or 18 (Model III double density) on 35, 40, 77, or 80
|
|
479
|
+
// tracks (10-sector: `sdTracks`). Anything else (a .CMD, a text file)
|
|
480
|
+
// would otherwise mount as a short JV1 whose garbage the ROM then
|
|
481
|
+
// boots. No two of these sizes coincide (10-sector tracks 35-42, 77,
|
|
482
|
+
// 80 and 18-sector 35, 40, 77, 80), so the geometry is never ambiguous.
|
|
271
483
|
const size = this.bytes.length;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// Model III 18-sector layout when both fit.
|
|
277
|
-
const candidates = [];
|
|
278
|
-
for (const spt of [18, 10]) {
|
|
279
|
-
if (totalSectors % spt === 0) {
|
|
280
|
-
candidates.push({ spt, tracks: totalSectors / spt });
|
|
484
|
+
let chosen = null;
|
|
485
|
+
for (const spt of JV1_SPT) {
|
|
486
|
+
for (const tracks of spt === 10 ? sdTracks : JV1_TRACKS) {
|
|
487
|
+
if (size === tracks * spt * 256) chosen = { spt, tracks };
|
|
281
488
|
}
|
|
282
489
|
}
|
|
283
|
-
if (
|
|
284
|
-
const classic = candidates.find((c) => [35, 40, 80].includes(c.tracks));
|
|
285
|
-
const chosen = classic || candidates[0];
|
|
490
|
+
if (chosen === null) return false;
|
|
286
491
|
|
|
287
492
|
this.sectorsPerTrack = chosen.spt;
|
|
288
493
|
this.tracks = chosen.tracks;
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
this.preferredBase = chosen.spt === 18 ? 1 : 0;
|
|
494
|
+
// Model III 18-sector disks number sectors 1-18 (TRSDOS 1.3); Model I
|
|
495
|
+
// 10-sector disks number 0-9 (JV1 as Vavasour defined it).
|
|
496
|
+
this.sectorBase = chosen.spt === 18 ? 1 : 0;
|
|
293
497
|
// JV1 carries no DAM info, but DOSes verify the deleted mark on
|
|
294
498
|
// directory reads — synthesize it for the directory track the boot
|
|
295
|
-
// sector names (byte 2), as xtrs does for its fixed track 17.
|
|
499
|
+
// sector names (byte 2), as xtrs does for its fixed track 17. A
|
|
500
|
+
// 10-sector (single-density, Model I) disk's directory is FA: the
|
|
501
|
+
// Model I DOSes write data "STANDARD" and the directory "READ
|
|
502
|
+
// PROTECTED", the WD1771 write command A9H, a1 a0 = 01, FA (Super
|
|
503
|
+
// Utility Tech Manual, Breeze/QSD 1982, p. 20 "@WRTYPE" and p. 61;
|
|
504
|
+
// FD1771-01 data sheet, Table 4). The 1771 reports it as written
|
|
505
|
+
// (record type 01); the WD1793 model reads it as normal (isDeleted,
|
|
506
|
+
// above), as the Model III's 1793 does. An 18-sector
|
|
507
|
+
// disk's is F8, the only MFM deleted mark (Model III TRSDOS: A1H).
|
|
508
|
+
this.dirDam = chosen.spt === 10 ? 0xfa : 0xf8;
|
|
296
509
|
const bootDir = this.bytes[2];
|
|
297
510
|
this.dirTrack =
|
|
298
511
|
bootDir >= 1 && bootDir < this.tracks ? bootDir : DEFAULT_DIR_TRACK;
|
|
@@ -307,87 +520,188 @@ export class DiskImage {
|
|
|
307
520
|
return Math.max(...this.jv3Map.map((e) => e.track)) + 1;
|
|
308
521
|
}
|
|
309
522
|
|
|
523
|
+
/** Sides the image holds: a DMK's header says; a JV3 has two when any sector is on side 1. */
|
|
524
|
+
sideCount() {
|
|
525
|
+
if (this.format === "dmk") return this.dmk.sides;
|
|
526
|
+
if (this.format === "jv3" && this.jv3Map.some((e) => e.side === 1)) return 2;
|
|
527
|
+
return 1;
|
|
528
|
+
}
|
|
529
|
+
|
|
310
530
|
/**
|
|
311
531
|
* The ID fields present on one track, in map order — what READ
|
|
312
532
|
* ADDRESS shows a verifying FORMAT. Size codes are the IBM ones the
|
|
313
533
|
* 1793 reports (0=128 ... 3=1024).
|
|
314
534
|
*/
|
|
315
535
|
sectorsOn(track, side) {
|
|
536
|
+
return this.trackIds(track, side).map((id) => ({ sector: id.sector, sizeCode: id.sizeCode }));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** JV3 entries on one track side, in header order (rotational order). */
|
|
540
|
+
_jv3Entries(track, side) {
|
|
541
|
+
return this.jv3Map.filter((e) => e.track === track && e.side === side);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The ID fields on one track side as the controller meets them in a revolution, for the WD1793's
|
|
546
|
+
* ID search (FD179X data sheet, "Type II Commands"): each with the ID's track, side, sector, and
|
|
547
|
+
* size code, its density (`mfm`: true, false, or null when the container does not record it, as
|
|
548
|
+
* in JV1), where its address mark passes as a fraction of a revolution from the index pulse
|
|
549
|
+
* (`pos`), how many bytes after that mark the first data byte comes (`dataGap`), whether its ID
|
|
550
|
+
* and data CRCs are good, its data address mark (`dam`, F8-FB) and record type, and its place in
|
|
551
|
+
* this list (`index`, which `readSectorEx` and `writeSector` take to reach this very ID when two
|
|
552
|
+
* share a sector number). DMK gives all of these from the raw track. JV1 and JV3 hold only
|
|
553
|
+
* sectors, so their IDs carry the physical track and side, are spaced evenly round the track in
|
|
554
|
+
* image order, and use the IBM gaps of the data sheet's "IBM Track Format" (an ID field of 7
|
|
555
|
+
* bytes, then Gap II, the sync zeros, the A1s in MFM, and the data mark: 45 bytes in MFM, 25 in
|
|
556
|
+
* FM). JV3's flag bit 3 marks a data CRC error. A DMK ID with no data mark within the data
|
|
557
|
+
* sheet's window has `noData` true, `dataGap` and `dam` null, and a bad data CRC.
|
|
558
|
+
* @returns {{track:number, side:number, sector:number, sizeCode:number, size:number,
|
|
559
|
+
* mfm:boolean|null, pos:number, dataGap:number|null, idCrcOk:boolean, dataCrcOk:boolean,
|
|
560
|
+
* dam:number|null, deleted:boolean, index:number, noData?:boolean}[]}
|
|
561
|
+
*/
|
|
562
|
+
trackIds(track, side) {
|
|
563
|
+
const gap = (mfm) => (mfm === false ? 25 : 45);
|
|
316
564
|
if (this.format === "dmk") {
|
|
317
|
-
const
|
|
318
|
-
return this._dmkSectors(track, side).map((s) => ({
|
|
565
|
+
const raw = this.dmk.trackLen - DMK_IDAM_TABLE;
|
|
566
|
+
return this._dmkSectors(track, side).map((s, index) => ({
|
|
567
|
+
track: s.idTrack,
|
|
568
|
+
side: s.idSide,
|
|
319
569
|
sector: s.sector,
|
|
320
|
-
sizeCode:
|
|
570
|
+
sizeCode: s.sizeCode,
|
|
571
|
+
size: s.size,
|
|
572
|
+
mfm: s.mfm,
|
|
573
|
+
pos: (s.off - DMK_IDAM_TABLE) / raw,
|
|
574
|
+
dataGap: s.noData ? null : (s.dataOff - s.off) / s.step,
|
|
575
|
+
idCrcOk: s.idCrcOk,
|
|
576
|
+
dataCrcOk: s.dataCrcOk,
|
|
577
|
+
dam: s.dam,
|
|
578
|
+
deleted: s.deleted,
|
|
579
|
+
index,
|
|
580
|
+
noData: s.noData,
|
|
321
581
|
}));
|
|
322
582
|
}
|
|
323
583
|
if (this.format === "jv1") {
|
|
324
584
|
if (side !== 0 || track >= this.tracks) return [];
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
585
|
+
const n = this.sectorsPerTrack;
|
|
586
|
+
const dam = track === this.dirTrack ? this.dirDam : 0xfb;
|
|
587
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
588
|
+
track,
|
|
589
|
+
side,
|
|
590
|
+
sector: this.sectorBase + i,
|
|
328
591
|
sizeCode: 1,
|
|
592
|
+
size: 256,
|
|
593
|
+
mfm: null,
|
|
594
|
+
pos: (i + 0.5) / n,
|
|
595
|
+
dataGap: 45,
|
|
596
|
+
idCrcOk: true,
|
|
597
|
+
dataCrcOk: true,
|
|
598
|
+
dam,
|
|
599
|
+
deleted: isDeleted(dam),
|
|
600
|
+
index: i,
|
|
329
601
|
}));
|
|
330
602
|
}
|
|
331
|
-
const
|
|
332
|
-
return
|
|
333
|
-
|
|
334
|
-
|
|
603
|
+
const entries = this._jv3Entries(track, side);
|
|
604
|
+
return entries.map((e, i) => {
|
|
605
|
+
const flags = this.bytes[e.flagsAt];
|
|
606
|
+
const mfm = (flags & F_DD) !== 0;
|
|
607
|
+
const dam = jv3Dam(flags);
|
|
608
|
+
return {
|
|
609
|
+
track,
|
|
610
|
+
side,
|
|
611
|
+
sector: e.sector,
|
|
612
|
+
sizeCode: SIZE_CODE[e.size] ?? 1,
|
|
613
|
+
size: e.size,
|
|
614
|
+
mfm,
|
|
615
|
+
pos: (i + 0.5) / entries.length,
|
|
616
|
+
dataGap: gap(mfm),
|
|
617
|
+
idCrcOk: true,
|
|
618
|
+
dataCrcOk: (flags & F_CRC_ERROR) === 0,
|
|
619
|
+
dam,
|
|
620
|
+
deleted: isDeleted(dam),
|
|
621
|
+
index: i,
|
|
622
|
+
};
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* A DMK track side's raw bytes (what READ TRACK returns), or null for the sector containers,
|
|
628
|
+
* which keep no raw track. `doubled` says single-density bytes are stored twice.
|
|
629
|
+
* @returns {{bytes: Uint8Array, doubled: boolean}|null}
|
|
630
|
+
*/
|
|
631
|
+
rawTrack(track, side) {
|
|
632
|
+
if (this.format !== "dmk") return null;
|
|
633
|
+
const base = this._dmkTrackBase(track, side);
|
|
634
|
+
if (base < 0) return null;
|
|
635
|
+
return {
|
|
636
|
+
bytes: this.bytes.slice(base + DMK_IDAM_TABLE, base + this.dmk.trackLen),
|
|
637
|
+
doubled: !this.dmk.sdSingleByte && !this.dmk.ignoreDensity,
|
|
638
|
+
};
|
|
335
639
|
}
|
|
336
640
|
|
|
337
641
|
/**
|
|
338
|
-
* Find a sector's byte range
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
* @returns {{offset:number,size:number}|null}
|
|
642
|
+
* Find a sector's byte range in a JV1 or JV3: by its place in the track's ID list when `index`
|
|
643
|
+
* is given (and its sector number matches), else the first ID with that sector number.
|
|
644
|
+
* @returns {{offset:number, size:number, entry:object|null}|null}
|
|
342
645
|
*/
|
|
343
|
-
_locate(track, side, sector) {
|
|
646
|
+
_locate(track, side, sector, index) {
|
|
344
647
|
if (this.format === "jv3") {
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
648
|
+
const entries = this._jv3Entries(track, side);
|
|
649
|
+
const hit =
|
|
650
|
+
index !== undefined
|
|
651
|
+
? entries[index]?.sector === sector
|
|
652
|
+
? entries[index]
|
|
653
|
+
: undefined
|
|
654
|
+
: entries.find((e) => e.sector === sector);
|
|
348
655
|
return hit ? { offset: hit.offset, size: hit.size, entry: hit } : null;
|
|
349
656
|
}
|
|
350
657
|
|
|
351
|
-
if (side !== 0) return null; // JV1 is single-sided
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
return null;
|
|
658
|
+
if (side !== 0 || track >= this.tracks) return null; // JV1 is single-sided
|
|
659
|
+
const i = sector - this.sectorBase;
|
|
660
|
+
if (i < 0 || i >= this.sectorsPerTrack) return null;
|
|
661
|
+
if (index !== undefined && index !== i) return null;
|
|
662
|
+
return { offset: (track * this.sectorsPerTrack + i) * 256, size: 256, entry: null };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* A DMK track's parsed sector: by list place when `index` is given, else by sector number, an ID
|
|
667
|
+
* with a data field first.
|
|
668
|
+
*/
|
|
669
|
+
_dmkHit(track, side, sector, index) {
|
|
670
|
+
const list = this._dmkSectors(track, side);
|
|
671
|
+
if (index !== undefined) return list[index]?.sector === sector ? list[index] : undefined;
|
|
672
|
+
return list.find((s) => s.sector === sector && !s.noData) ?? list.find((s) => s.sector === sector);
|
|
368
673
|
}
|
|
369
674
|
|
|
370
675
|
/**
|
|
371
676
|
* Read a sector with its record type, the way the FDC sees it.
|
|
372
|
-
* @
|
|
677
|
+
* @param {number} [index] the ID's place in `trackIds(track, side)`, for a track where two IDs
|
|
678
|
+
* carry the same sector number
|
|
679
|
+
* @returns {{data: Uint8Array, deleted: boolean, dam: number}|null}
|
|
373
680
|
*/
|
|
374
|
-
readSectorEx(track, side, sector) {
|
|
681
|
+
readSectorEx(track, side, sector, index) {
|
|
375
682
|
if (this.format === "dmk") {
|
|
376
|
-
const hit = this.
|
|
377
|
-
if (!hit) return null;
|
|
683
|
+
const hit = this._dmkHit(track, side, sector, index);
|
|
684
|
+
if (!hit || hit.noData) return null;
|
|
378
685
|
const base = this._dmkTrackBase(track, side);
|
|
686
|
+
const { trackLen } = this.dmk;
|
|
379
687
|
const data = new Uint8Array(hit.size);
|
|
380
688
|
for (let k = 0; k < hit.size; k++) {
|
|
381
|
-
|
|
689
|
+
const at = hit.dataOff + k * hit.step;
|
|
690
|
+
data[k] = at < trackLen ? this.bytes[base + at] : 0;
|
|
382
691
|
}
|
|
383
|
-
return { data, deleted: hit.deleted };
|
|
692
|
+
return { data, deleted: hit.deleted, dam: hit.dam };
|
|
384
693
|
}
|
|
385
|
-
const loc = this._locate(track, side, sector);
|
|
694
|
+
const loc = this._locate(track, side, sector, index);
|
|
386
695
|
if (!loc) return null;
|
|
387
|
-
const
|
|
696
|
+
const dam = loc.entry
|
|
697
|
+
? jv3Dam(this.bytes[loc.entry.flagsAt])
|
|
698
|
+
: track === this.dirTrack
|
|
699
|
+
? this.dirDam
|
|
700
|
+
: 0xfb;
|
|
388
701
|
return {
|
|
389
702
|
data: this.bytes.slice(loc.offset, loc.offset + loc.size),
|
|
390
|
-
deleted,
|
|
703
|
+
deleted: isDeleted(dam),
|
|
704
|
+
dam,
|
|
391
705
|
};
|
|
392
706
|
}
|
|
393
707
|
|
|
@@ -398,14 +712,15 @@ export class DiskImage {
|
|
|
398
712
|
|
|
399
713
|
/**
|
|
400
714
|
* Replace one track's contents with freshly formatted sectors (the
|
|
401
|
-
* tail end of a WD1793 WRITE TRACK).
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
715
|
+
* tail end of a WD1793 WRITE TRACK). A DMK track is laid out again in
|
|
716
|
+
* each sector's density. JV3 images rebuild their bytes wholesale —
|
|
717
|
+
* `bytes` stays the exportable/serializable truth. A JV1 whose
|
|
718
|
+
* geometry matches writes in place; one that doesn't (new track
|
|
719
|
+
* count, sector numbering, or size) is promoted to JV3 in memory,
|
|
720
|
+
* keeping every existing sector.
|
|
406
721
|
*
|
|
407
|
-
* @param {{sector:number, size?:number, deleted?:boolean,
|
|
408
|
-
* data:Uint8Array, mfm?:boolean}[]} sectors
|
|
722
|
+
* @param {{sector:number, size?:number, deleted?:boolean, dam?:number,
|
|
723
|
+
* data:Uint8Array, mfm?:boolean, track?:number, side?:number}[]} sectors
|
|
409
724
|
* @returns {boolean} true if the track was written
|
|
410
725
|
*/
|
|
411
726
|
formatTrack(track, side, sectors) {
|
|
@@ -413,7 +728,8 @@ export class DiskImage {
|
|
|
413
728
|
if (this.format === "dmk") {
|
|
414
729
|
if (side >= this.dmk.sides) return false;
|
|
415
730
|
if (track >= this.dmk.tracks) this._dmkExtend(track + 1);
|
|
416
|
-
const
|
|
731
|
+
const doubled = !this.dmk.sdSingleByte && !this.dmk.ignoreDensity;
|
|
732
|
+
const region = buildDmkTrackRegion(track, side, sectors, this.dmk.trackLen, doubled);
|
|
417
733
|
this.bytes.set(region, this._dmkTrackBase(track, side));
|
|
418
734
|
this._dmkCache.delete(`${track}:${side}`);
|
|
419
735
|
return true;
|
|
@@ -429,18 +745,17 @@ export class DiskImage {
|
|
|
429
745
|
.map((e) => ({
|
|
430
746
|
track: e.track,
|
|
431
747
|
sector: e.sector,
|
|
432
|
-
flags: this.bytes[e.
|
|
748
|
+
flags: this.bytes[e.flagsAt],
|
|
433
749
|
data: this.bytes.slice(e.offset, e.offset + e.size),
|
|
434
750
|
}));
|
|
435
751
|
|
|
436
|
-
const sizeToCode = { 256: 0x00, 128: 0x01, 1024: 0x02, 512: 0x03 };
|
|
437
752
|
const fresh = sectors.map((s) => {
|
|
438
753
|
const mfm = s.mfm !== false;
|
|
439
754
|
const size = s.size ?? s.data.length;
|
|
440
|
-
let flags =
|
|
755
|
+
let flags = JV3_SIZE_CODE[size] ?? 0x00;
|
|
441
756
|
if (side) flags |= F_SIDE;
|
|
442
|
-
if (mfm) flags |=
|
|
443
|
-
|
|
757
|
+
if (mfm) flags |= F_DD;
|
|
758
|
+
flags |= jv3DamBits(s.dam ?? (s.deleted ? 0xf8 : 0xfb), mfm);
|
|
444
759
|
return {
|
|
445
760
|
track,
|
|
446
761
|
sector: s.sector,
|
|
@@ -453,29 +768,38 @@ export class DiskImage {
|
|
|
453
768
|
return true;
|
|
454
769
|
}
|
|
455
770
|
|
|
456
|
-
/** Write into the raw track bytes in place, CRCs recomputed. */
|
|
457
|
-
_dmkWriteSector(track, side, sector, data,
|
|
458
|
-
const hit = this.
|
|
771
|
+
/** Write into the raw track bytes in place, CRCs recomputed; `mark` is the data mark, F8-FB. */
|
|
772
|
+
_dmkWriteSector(track, side, sector, data, mark, index) {
|
|
773
|
+
const hit = this._dmkHit(track, side, sector, index);
|
|
459
774
|
if (!hit) return false;
|
|
460
775
|
const base = this._dmkTrackBase(track, side);
|
|
776
|
+
const { trackLen } = this.dmk;
|
|
461
777
|
const put = (rawOff, v) => {
|
|
462
|
-
this.bytes[base + rawOff] = v;
|
|
463
|
-
if (hit.step === 2) this.bytes[base + rawOff + 1] = v;
|
|
778
|
+
if (rawOff < trackLen) this.bytes[base + rawOff] = v;
|
|
779
|
+
if (hit.step === 2 && rawOff + 1 < trackLen) this.bytes[base + rawOff + 1] = v;
|
|
464
780
|
};
|
|
465
781
|
|
|
466
|
-
|
|
467
|
-
|
|
782
|
+
let dataOff = hit.dataOff;
|
|
783
|
+
if (hit.noData) {
|
|
784
|
+
// No data field yet: lay it where Write Sector puts it ("Write Sector"): after the ID's
|
|
785
|
+
// seven bytes, 22 bytes (MFM) or 11 (FM) of Gap II, then 12 or 6 zeros, the A1s in MFM, and
|
|
786
|
+
// the mark.
|
|
787
|
+
const mfm = hit.recordedMfm;
|
|
788
|
+
let k = 7 + (mfm ? 22 : 11);
|
|
789
|
+
for (let z = 0; z < (mfm ? 12 : 6); z++) put(hit.off + k++ * hit.step, 0x00);
|
|
790
|
+
if (mfm) for (let z = 0; z < 3; z++) put(hit.off + k++ * hit.step, 0xa1);
|
|
791
|
+
dataOff = hit.off + (k + 1) * hit.step;
|
|
792
|
+
}
|
|
793
|
+
put(dataOff - hit.step, mark);
|
|
468
794
|
const payload = new Uint8Array(hit.size);
|
|
469
795
|
for (let k = 0; k < hit.size; k++) {
|
|
470
796
|
payload[k] = data[k] ?? 0x00;
|
|
471
|
-
put(
|
|
797
|
+
put(dataOff + k * hit.step, payload[k]);
|
|
472
798
|
}
|
|
473
|
-
const crc = crc16(
|
|
474
|
-
|
|
475
|
-
);
|
|
476
|
-
|
|
477
|
-
put(hit.dataOff + (hit.size + 1) * hit.step, crc & 0xff);
|
|
478
|
-
hit.deleted = deleted;
|
|
799
|
+
const crc = crc16(hit.recordedMfm ? [0xa1, 0xa1, 0xa1, mark, ...payload] : [mark, ...payload]);
|
|
800
|
+
put(dataOff + hit.size * hit.step, crc >> 8);
|
|
801
|
+
put(dataOff + (hit.size + 1) * hit.step, crc & 0xff);
|
|
802
|
+
this._dmkCache.delete(`${track}:${side}`);
|
|
479
803
|
return true;
|
|
480
804
|
}
|
|
481
805
|
|
|
@@ -483,7 +807,7 @@ export class DiskImage {
|
|
|
483
807
|
_jv1FormatInPlace(track, side, sectors) {
|
|
484
808
|
if (side !== 0 || track >= this.tracks) return false;
|
|
485
809
|
if (sectors.length !== this.sectorsPerTrack) return false;
|
|
486
|
-
const base = this.sectorBase
|
|
810
|
+
const base = this.sectorBase;
|
|
487
811
|
const sorted = [...sectors].sort((a, b) => a.sector - b.sector);
|
|
488
812
|
for (let i = 0; i < sorted.length; i++) {
|
|
489
813
|
const size = sorted[i].size ?? sorted[i].data.length;
|
|
@@ -498,14 +822,14 @@ export class DiskImage {
|
|
|
498
822
|
|
|
499
823
|
/** Convert this JV1 into an equivalent in-memory JV3. */
|
|
500
824
|
_promoteToJV3() {
|
|
501
|
-
const base = this.sectorBase
|
|
825
|
+
const base = this.sectorBase;
|
|
502
826
|
const dd = this.sectorsPerTrack === 18; // Model III 18-sector = DD
|
|
503
827
|
const entries = [];
|
|
504
828
|
for (let t = 0; t < this.tracks; t++) {
|
|
505
829
|
for (let i = 0; i < this.sectorsPerTrack; i++) {
|
|
506
830
|
let flags = 0x00; // size code 0 = 256 bytes
|
|
507
|
-
if (dd) flags |=
|
|
508
|
-
if (t === this.dirTrack) flags |= dd
|
|
831
|
+
if (dd) flags |= F_DD;
|
|
832
|
+
if (t === this.dirTrack) flags |= jv3DamBits(this.dirDam, dd); // keep the DAM synthesis
|
|
509
833
|
const offset = (t * this.sectorsPerTrack + i) * 256;
|
|
510
834
|
entries.push({
|
|
511
835
|
track: t,
|
|
@@ -518,61 +842,124 @@ export class DiskImage {
|
|
|
518
842
|
this._rebuildJV3(entries);
|
|
519
843
|
}
|
|
520
844
|
|
|
521
|
-
/**
|
|
845
|
+
/**
|
|
846
|
+
* Rebuild bytes + map from {track, sector, flags, data} entries: one header block, or two when
|
|
847
|
+
* there are more than 2,901 sectors.
|
|
848
|
+
*/
|
|
522
849
|
_rebuildJV3(entries) {
|
|
523
|
-
if (entries.length > JV3_ENTRIES) {
|
|
524
|
-
throw new Error(`Disk needs ${entries.length} JV3 entries (max ${JV3_ENTRIES})`);
|
|
850
|
+
if (entries.length > 2 * JV3_ENTRIES) {
|
|
851
|
+
throw new Error(`Disk needs ${entries.length} JV3 entries (max ${2 * JV3_ENTRIES})`);
|
|
525
852
|
}
|
|
853
|
+
const blocks = [entries.slice(0, JV3_ENTRIES)];
|
|
854
|
+
if (entries.length > JV3_ENTRIES) blocks.push(entries.slice(JV3_ENTRIES));
|
|
526
855
|
let total = 0;
|
|
527
|
-
for (const
|
|
528
|
-
|
|
856
|
+
for (const block of blocks) {
|
|
857
|
+
total += JV3_HEADER_SIZE;
|
|
858
|
+
for (const e of block) total += e.data.length;
|
|
859
|
+
}
|
|
860
|
+
// A block that is followed by another must own the data of all its entries, so the next
|
|
861
|
+
// block starts where a reader expects it: the first block is full when there are two.
|
|
862
|
+
const bytes = new Uint8Array(total);
|
|
529
863
|
const map = [];
|
|
530
|
-
let
|
|
531
|
-
|
|
532
|
-
bytes
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
864
|
+
let at = 0;
|
|
865
|
+
for (const block of blocks) {
|
|
866
|
+
bytes.fill(0xff, at, at + JV3_HEADER_SIZE);
|
|
867
|
+
let offset = at + JV3_HEADER_SIZE;
|
|
868
|
+
block.forEach((e, i) => {
|
|
869
|
+
bytes[at + i * 3] = e.track;
|
|
870
|
+
bytes[at + i * 3 + 1] = e.sector;
|
|
871
|
+
bytes[at + i * 3 + 2] = e.flags;
|
|
872
|
+
bytes.set(e.data, offset);
|
|
873
|
+
map.push({
|
|
874
|
+
track: e.track,
|
|
875
|
+
sector: e.sector,
|
|
876
|
+
side: e.flags & F_SIDE ? 1 : 0,
|
|
877
|
+
offset,
|
|
878
|
+
size: e.data.length,
|
|
879
|
+
flagsAt: at + i * 3 + 2,
|
|
880
|
+
});
|
|
881
|
+
offset += e.data.length;
|
|
544
882
|
});
|
|
545
|
-
|
|
546
|
-
}
|
|
883
|
+
at = offset;
|
|
884
|
+
}
|
|
547
885
|
this.bytes = bytes;
|
|
548
886
|
this.jv3Map = map;
|
|
549
887
|
this.format = "jv3";
|
|
550
888
|
}
|
|
551
889
|
|
|
552
890
|
/**
|
|
553
|
-
* @param {{deleted?: boolean}} [opts] - record type to
|
|
554
|
-
* write-sector bit a0
|
|
555
|
-
*
|
|
556
|
-
*
|
|
891
|
+
* @param {{deleted?: boolean, dam?: number, index?: number}} [opts] - record type to
|
|
892
|
+
* write (WD1793 write-sector bit a0: F8 or FB; `dam`, when given, the
|
|
893
|
+
* mark itself, F8-FB, as the WD1771's a1 a0 choose it). JV3 stores it in the
|
|
894
|
+
* header flags — in the image bytes, so exports keep it — and clears
|
|
895
|
+
* the CRC error flag, since the write leaves a good CRC; JV1 has
|
|
896
|
+
* nowhere to put one and keeps synthesizing by directory track.
|
|
897
|
+
* `index` picks the ID by its place in `trackIds`, as `readSectorEx`.
|
|
557
898
|
* @returns {boolean} true if written
|
|
558
899
|
*/
|
|
559
|
-
writeSector(track, side, sector, data, { deleted = false } = {}) {
|
|
900
|
+
writeSector(track, side, sector, data, { deleted = false, dam, index } = {}) {
|
|
560
901
|
if (this.writeProtected) return false;
|
|
902
|
+
const mark = dam ?? (deleted ? 0xf8 : 0xfb);
|
|
561
903
|
if (this.format === "dmk") {
|
|
562
|
-
return this._dmkWriteSector(track, side, sector, data,
|
|
904
|
+
return this._dmkWriteSector(track, side, sector, data, mark, index);
|
|
563
905
|
}
|
|
564
|
-
const loc = this._locate(track, side, sector);
|
|
906
|
+
const loc = this._locate(track, side, sector, index);
|
|
565
907
|
if (!loc) return false;
|
|
566
908
|
this.bytes.set(data.subarray(0, loc.size), loc.offset);
|
|
567
909
|
const entry = loc.entry;
|
|
568
|
-
if (entry
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
// Density decides the code for 0xF8: DD uses 0x20, SD uses 0x60
|
|
572
|
-
const damCode = deleted ? (flags & 0x80 ? 0x20 : 0x60) : 0x00;
|
|
573
|
-
this.bytes[flagsAt] = flags | damCode;
|
|
574
|
-
entry.deleted = deleted;
|
|
910
|
+
if (entry) {
|
|
911
|
+
const flags = this.bytes[entry.flagsAt] & ~(F_DAM | F_CRC_ERROR);
|
|
912
|
+
this.bytes[entry.flagsAt] = flags | jv3DamBits(mark, (flags & F_DD) !== 0);
|
|
575
913
|
}
|
|
576
914
|
return true;
|
|
577
915
|
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* The image as a DMK file (spec §6: write DMK only). A DMK comes back as its own bytes, with
|
|
919
|
+
* the header's write-protect byte following `writeProtected`. A JV1 or JV3 is laid out track by
|
|
920
|
+
* track from `trackIds`: each ID in its order, with its track, side, sector, and size code, its
|
|
921
|
+
* density, its data mark, and its CRCs (a JV3 CRC error stays an error), at the usual 5.25"
|
|
922
|
+
* double-density track length (`0x1900`, longer only if a track needs it). JV1 records no
|
|
923
|
+
* density, so its 10-sector tracks are written in FM and its 18-sector ones in MFM, the
|
|
924
|
+
* densities those geometries are recorded in. A disk with no formatted track (a blank JV3)
|
|
925
|
+
* becomes a DMK with no tracks, which a format extends.
|
|
926
|
+
* @returns {Uint8Array}
|
|
927
|
+
*/
|
|
928
|
+
toDMK() {
|
|
929
|
+
if (this.format === "dmk") {
|
|
930
|
+
const out = this.bytes.slice();
|
|
931
|
+
out[0] = this.writeProtected ? 0xff : 0x00;
|
|
932
|
+
return out;
|
|
933
|
+
}
|
|
934
|
+
const tracks = this.trackCount();
|
|
935
|
+
const sides = tracks === 0 ? 2 : this.sideCount();
|
|
936
|
+
const jv1Mfm = this.format === "jv1" ? this.sectorsPerTrack === 18 : true;
|
|
937
|
+
const laid = [];
|
|
938
|
+
for (let t = 0; t < tracks; t++) {
|
|
939
|
+
for (let side = 0; side < sides; side++) {
|
|
940
|
+
const sectors = this.trackIds(t, side).map((id) => ({
|
|
941
|
+
track: id.track,
|
|
942
|
+
side: id.side,
|
|
943
|
+
sector: id.sector,
|
|
944
|
+
size: id.size,
|
|
945
|
+
sizeCode: id.sizeCode,
|
|
946
|
+
mfm: id.mfm ?? jv1Mfm,
|
|
947
|
+
dam: id.dam,
|
|
948
|
+
idCrcOk: id.idCrcOk,
|
|
949
|
+
dataCrcOk: id.dataCrcOk,
|
|
950
|
+
data: this.readSectorEx(t, side, id.sector, id.index)?.data ?? new Uint8Array(id.size),
|
|
951
|
+
}));
|
|
952
|
+
laid.push(layDmkTrack(t, side, sectors, true, false));
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const trackLen = Math.max(DMK_TRACK_LEN, DMK_IDAM_TABLE + Math.max(0, ...laid.map((l) => l.raw.length)));
|
|
956
|
+
const out = new Uint8Array(DMK_HEADER + laid.length * trackLen);
|
|
957
|
+
out[0] = this.writeProtected ? 0xff : 0x00;
|
|
958
|
+
out[1] = tracks;
|
|
959
|
+
out[2] = trackLen & 0xff;
|
|
960
|
+
out[3] = trackLen >> 8;
|
|
961
|
+
out[4] = sides === 1 ? DMK_SINGLE_SIDED : 0x00;
|
|
962
|
+
laid.forEach((l, i) => out.set(dmkRegion(l, trackLen), DMK_HEADER + i * trackLen));
|
|
963
|
+
return out;
|
|
964
|
+
}
|
|
578
965
|
}
|