@fugloy/emu 0.0.0-stage → 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.
@@ -0,0 +1,965 @@
1
+ /**
2
+ * TRS-80 disk images in memory: read JV1, JV3, and DMK exactly, write in place, export DMK.
3
+ *
4
+ * The containers, as Tim Mann's format notes (the xtrs distribution's "TRS-80 Disk Formats")
5
+ * define them:
6
+ *
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.
16
+ *
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).
26
+ *
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.
34
+ *
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).
38
+ */
39
+
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
+ }
69
+ const JV3_HEADER_SIZE = JV3_ENTRIES * 3 + 1;
70
+ const JV3_FREE = 0xff;
71
+
72
+ // JV3 flag bits
73
+ const F_DD = 0x80;
74
+ const F_DAM = 0x60; // data address mark code (0x00 = normal 0xFB)
75
+ const F_SIDE = 0x10;
76
+ const F_CRC_ERROR = 0x08;
77
+
78
+ /** The data size a JV3 header's size code gives: used and free entries number them differently. */
79
+ function jv3SectorSize(flags, free) {
80
+ const code = flags & 0x03;
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];
91
+ }
92
+
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;
98
+ }
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
+
110
+ // TRSDOS-family boot sectors name the directory cylinder in byte 2.
111
+ const DEFAULT_DIR_TRACK = 17;
112
+
113
+ // ---------------------------- DMK ----------------------------------
114
+ const DMK_HEADER = 16;
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 };
124
+
125
+ /** CRC-CCITT, the checksum the WD1793 writes after ID and data fields. */
126
+ function crc16(bytes, init = 0xffff) {
127
+ let crc = init;
128
+ for (const b of bytes) {
129
+ crc ^= b << 8;
130
+ for (let i = 0; i < 8; i++) {
131
+ crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
132
+ }
133
+ }
134
+ return crc;
135
+ }
136
+
137
+ /**
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
141
+ */
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[]} */
159
+ const raw = [];
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
+ }
179
+ for (const s of sectors) {
180
+ const mfm = s.mfm !== false;
181
+ const gap = mfm ? 0x4e : 0xff;
182
+ const size = s.size ?? s.data.length;
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}`);
207
+ }
208
+ return { raw, ptrs, fill: firstMfm ? 0x4e : 0xff };
209
+ }
210
+
211
+ /** Write a laid track into a DMK track region (IDAM table + raw bytes) of `trackLen` bytes. */
212
+ function dmkRegion(laid, trackLen) {
213
+ const region = new Uint8Array(trackLen);
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;
218
+ });
219
+ region.fill(laid.fill, DMK_IDAM_TABLE);
220
+ region.set(laid.raw, DMK_IDAM_TABLE);
221
+ return region;
222
+ }
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
+
238
+ export class DiskImage {
239
+ /**
240
+ * @param {Uint8Array} bytes - raw .dsk contents
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).
250
+ */
251
+ constructor(bytes, name = "disk", { format, dirTrack, jv1SdTracks = JV1_TRACKS } = {}) {
252
+ this.name = name;
253
+ this.bytes = new Uint8Array(bytes); // private working copy
254
+ this.writeProtected = false;
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()) {
271
+ this.format = "dmk";
272
+ } else if (this._tryParseJV3()) {
273
+ this.format = "jv3";
274
+ } else if (this._tryParseJV1(jv1SdTracks)) {
275
+ this.format = "jv1";
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`;
281
+ throw new Error(
282
+ `Unrecognized disk image (${bytes.length} bytes): no DMK header, no JV3 sector table, and not a JV1 size (${sizes})`
283
+ );
284
+ }
285
+ }
286
+
287
+ _tryParseDMK() {
288
+ const b = this.bytes;
289
+ if (b.length < DMK_HEADER) return false;
290
+ const tracks = b[1];
291
+ const trackLen = b[2] | (b[3] << 8);
292
+ const flags = b[4];
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;
297
+ if (trackLen < 0x100 || trackLen > 0x4000) return false;
298
+ if (b[12] | b[13] | b[14] | b[15]) return false; // real-drive marker
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;
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
+ }
314
+
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
+ };
322
+ this.writeProtected = b[0] === 0xff;
323
+ this._dmkCache = new Map();
324
+ return true;
325
+ }
326
+
327
+ _dmkTrackBase(track, side) {
328
+ const { tracks, trackLen, sides } = this.dmk;
329
+ if (track >= tracks || side >= sides) return -1;
330
+ const index = sides === 1 ? track : track * 2 + side;
331
+ return DMK_HEADER + index * trackLen;
332
+ }
333
+
334
+ /** Parse (and cache) one DMK track's sector layout from its IDAMs. */
335
+ _dmkSectors(track, side) {
336
+ const key = `${track}:${side}`;
337
+ if (this._dmkCache.has(key)) return this._dmkCache.get(key);
338
+ const out = [];
339
+ const base = this._dmkTrackBase(track, side);
340
+ if (base >= 0) {
341
+ const { trackLen, sdSingleByte, ignoreDensity } = this.dmk;
342
+ const b = this.bytes;
343
+ for (let i = 0; i < DMK_IDAMS; i++) {
344
+ const ptr = b[base + i * 2] | (b[base + i * 2 + 1] << 8);
345
+ if (!ptr) break; // unused table entries are zero
346
+ const mfm = (ptr & 0x8000) !== 0;
347
+ const off = ptr & 0x3fff;
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);
352
+ if (at(0) !== 0xfe) continue;
353
+ const sector = at(3);
354
+ const size = 128 << (at(4) & 3);
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.
358
+ let dataOff = -1;
359
+ let dam = 0xfb;
360
+ const limit = 6 + (mfm ? 43 : 30);
361
+ for (let k = 7; k <= limit; k++) {
362
+ const byte = at(k);
363
+ if (byte >= 0xf8 && byte <= 0xfb && (!mfm || at(k - 1) === 0xa1)) {
364
+ dataOff = off + (k + 1) * step;
365
+ dam = byte;
366
+ break;
367
+ }
368
+ }
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
+ });
399
+ }
400
+ }
401
+ this._dmkCache.set(key, out);
402
+ return out;
403
+ }
404
+
405
+ _dmkExtend(newTrackCount) {
406
+ const { trackLen, sides } = this.dmk;
407
+ const grown = new Uint8Array(DMK_HEADER + newTrackCount * sides * trackLen);
408
+ grown.set(this.bytes, 0);
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
+ }
414
+ this.bytes = grown;
415
+ this.dmk.tracks = newTrackCount;
416
+ }
417
+
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;
427
+
428
+ const map = [];
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;
453
+ }
454
+ blockAt = dataOffset;
455
+ }
456
+ // A real disk has many sectors; an exactly-header-sized all-free
457
+ // image is a valid BLANK disk (DiskImage.blank, and its export /
458
+ // save-state round trip) waiting for a DOS FORMAT.
459
+ if (detect && map.length < 10 && !(map.length === 0 && b.length === JV3_HEADER_SIZE)) {
460
+ return false;
461
+ }
462
+
463
+ this.jv3Map = map;
464
+ // Last header byte: 0xff = writable, anything else = write-protected
465
+ this.writeProtected = b[JV3_HEADER_SIZE - 1] !== 0xff;
466
+ return true;
467
+ }
468
+
469
+ /** A blank, writable, unformatted disk — FORMAT makes it real. */
470
+ static blank(name = "blank.dsk") {
471
+ return new DiskImage(new Uint8Array(JV3_HEADER_SIZE).fill(0xff), name);
472
+ }
473
+
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.
483
+ const size = this.bytes.length;
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 };
488
+ }
489
+ }
490
+ if (chosen === null) return false;
491
+
492
+ this.sectorsPerTrack = chosen.spt;
493
+ this.tracks = chosen.tracks;
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;
497
+ // JV1 carries no DAM info, but DOSes verify the deleted mark on
498
+ // directory reads — synthesize it for the directory track the boot
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;
509
+ const bootDir = this.bytes[2];
510
+ this.dirTrack =
511
+ bootDir >= 1 && bootDir < this.tracks ? bootDir : DEFAULT_DIR_TRACK;
512
+ return true;
513
+ }
514
+
515
+ /** Highest track number present (for FDC seek bounds). */
516
+ trackCount() {
517
+ if (this.format === "dmk") return this.dmk.tracks;
518
+ if (this.format === "jv1") return this.tracks;
519
+ if (this.jv3Map.length === 0) return 0; // blank disk
520
+ return Math.max(...this.jv3Map.map((e) => e.track)) + 1;
521
+ }
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
+
530
+ /**
531
+ * The ID fields present on one track, in map order — what READ
532
+ * ADDRESS shows a verifying FORMAT. Size codes are the IBM ones the
533
+ * 1793 reports (0=128 ... 3=1024).
534
+ */
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);
564
+ if (this.format === "dmk") {
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,
569
+ sector: s.sector,
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,
581
+ }));
582
+ }
583
+ if (this.format === "jv1") {
584
+ if (side !== 0 || track >= this.tracks) return [];
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,
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,
601
+ }));
602
+ }
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
+ };
639
+ }
640
+
641
+ /**
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}
645
+ */
646
+ _locate(track, side, sector, index) {
647
+ if (this.format === "jv3") {
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);
655
+ return hit ? { offset: hit.offset, size: hit.size, entry: hit } : null;
656
+ }
657
+
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);
673
+ }
674
+
675
+ /**
676
+ * Read a sector with its record type, the way the FDC sees it.
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}
680
+ */
681
+ readSectorEx(track, side, sector, index) {
682
+ if (this.format === "dmk") {
683
+ const hit = this._dmkHit(track, side, sector, index);
684
+ if (!hit || hit.noData) return null;
685
+ const base = this._dmkTrackBase(track, side);
686
+ const { trackLen } = this.dmk;
687
+ const data = new Uint8Array(hit.size);
688
+ for (let k = 0; k < hit.size; k++) {
689
+ const at = hit.dataOff + k * hit.step;
690
+ data[k] = at < trackLen ? this.bytes[base + at] : 0;
691
+ }
692
+ return { data, deleted: hit.deleted, dam: hit.dam };
693
+ }
694
+ const loc = this._locate(track, side, sector, index);
695
+ if (!loc) return null;
696
+ const dam = loc.entry
697
+ ? jv3Dam(this.bytes[loc.entry.flagsAt])
698
+ : track === this.dirTrack
699
+ ? this.dirDam
700
+ : 0xfb;
701
+ return {
702
+ data: this.bytes.slice(loc.offset, loc.offset + loc.size),
703
+ deleted: isDeleted(dam),
704
+ dam,
705
+ };
706
+ }
707
+
708
+ /** @returns {Uint8Array|null} a copy of the sector data */
709
+ readSector(track, side, sector) {
710
+ return this.readSectorEx(track, side, sector)?.data ?? null;
711
+ }
712
+
713
+ /**
714
+ * Replace one track's contents with freshly formatted sectors (the
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.
721
+ *
722
+ * @param {{sector:number, size?:number, deleted?:boolean, dam?:number,
723
+ * data:Uint8Array, mfm?:boolean, track?:number, side?:number}[]} sectors
724
+ * @returns {boolean} true if the track was written
725
+ */
726
+ formatTrack(track, side, sectors) {
727
+ if (this.writeProtected) return false;
728
+ if (this.format === "dmk") {
729
+ if (side >= this.dmk.sides) return false;
730
+ if (track >= this.dmk.tracks) this._dmkExtend(track + 1);
731
+ const doubled = !this.dmk.sdSingleByte && !this.dmk.ignoreDensity;
732
+ const region = buildDmkTrackRegion(track, side, sectors, this.dmk.trackLen, doubled);
733
+ this.bytes.set(region, this._dmkTrackBase(track, side));
734
+ this._dmkCache.delete(`${track}:${side}`);
735
+ return true;
736
+ }
737
+ if (this.format === "jv1") {
738
+ if (this._jv1FormatInPlace(track, side, sectors)) return true;
739
+ this._promoteToJV3();
740
+ }
741
+
742
+ // Keep every other track's entries with their original flags bytes
743
+ const kept = this.jv3Map
744
+ .filter((e) => !(e.track === track && e.side === side))
745
+ .map((e) => ({
746
+ track: e.track,
747
+ sector: e.sector,
748
+ flags: this.bytes[e.flagsAt],
749
+ data: this.bytes.slice(e.offset, e.offset + e.size),
750
+ }));
751
+
752
+ const fresh = sectors.map((s) => {
753
+ const mfm = s.mfm !== false;
754
+ const size = s.size ?? s.data.length;
755
+ let flags = JV3_SIZE_CODE[size] ?? 0x00;
756
+ if (side) flags |= F_SIDE;
757
+ if (mfm) flags |= F_DD;
758
+ flags |= jv3DamBits(s.dam ?? (s.deleted ? 0xf8 : 0xfb), mfm);
759
+ return {
760
+ track,
761
+ sector: s.sector,
762
+ flags,
763
+ data: new Uint8Array(s.data.subarray(0, size)),
764
+ };
765
+ });
766
+
767
+ this._rebuildJV3([...kept, ...fresh]);
768
+ return true;
769
+ }
770
+
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);
774
+ if (!hit) return false;
775
+ const base = this._dmkTrackBase(track, side);
776
+ const { trackLen } = this.dmk;
777
+ const put = (rawOff, v) => {
778
+ if (rawOff < trackLen) this.bytes[base + rawOff] = v;
779
+ if (hit.step === 2 && rawOff + 1 < trackLen) this.bytes[base + rawOff + 1] = v;
780
+ };
781
+
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);
794
+ const payload = new Uint8Array(hit.size);
795
+ for (let k = 0; k < hit.size; k++) {
796
+ payload[k] = data[k] ?? 0x00;
797
+ put(dataOff + k * hit.step, payload[k]);
798
+ }
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}`);
803
+ return true;
804
+ }
805
+
806
+ /** True (and written) when the layout matches this JV1's geometry. */
807
+ _jv1FormatInPlace(track, side, sectors) {
808
+ if (side !== 0 || track >= this.tracks) return false;
809
+ if (sectors.length !== this.sectorsPerTrack) return false;
810
+ const base = this.sectorBase;
811
+ const sorted = [...sectors].sort((a, b) => a.sector - b.sector);
812
+ for (let i = 0; i < sorted.length; i++) {
813
+ const size = sorted[i].size ?? sorted[i].data.length;
814
+ if (sorted[i].sector !== base + i || size !== 256) return false;
815
+ }
816
+ for (const s of sorted) {
817
+ const offset = (track * this.sectorsPerTrack + (s.sector - base)) * 256;
818
+ this.bytes.set(s.data.subarray(0, 256), offset);
819
+ }
820
+ return true;
821
+ }
822
+
823
+ /** Convert this JV1 into an equivalent in-memory JV3. */
824
+ _promoteToJV3() {
825
+ const base = this.sectorBase;
826
+ const dd = this.sectorsPerTrack === 18; // Model III 18-sector = DD
827
+ const entries = [];
828
+ for (let t = 0; t < this.tracks; t++) {
829
+ for (let i = 0; i < this.sectorsPerTrack; i++) {
830
+ let flags = 0x00; // size code 0 = 256 bytes
831
+ if (dd) flags |= F_DD;
832
+ if (t === this.dirTrack) flags |= jv3DamBits(this.dirDam, dd); // keep the DAM synthesis
833
+ const offset = (t * this.sectorsPerTrack + i) * 256;
834
+ entries.push({
835
+ track: t,
836
+ sector: base + i,
837
+ flags,
838
+ data: this.bytes.slice(offset, offset + 256),
839
+ });
840
+ }
841
+ }
842
+ this._rebuildJV3(entries);
843
+ }
844
+
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
+ */
849
+ _rebuildJV3(entries) {
850
+ if (entries.length > 2 * JV3_ENTRIES) {
851
+ throw new Error(`Disk needs ${entries.length} JV3 entries (max ${2 * JV3_ENTRIES})`);
852
+ }
853
+ const blocks = [entries.slice(0, JV3_ENTRIES)];
854
+ if (entries.length > JV3_ENTRIES) blocks.push(entries.slice(JV3_ENTRIES));
855
+ let total = 0;
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);
863
+ const map = [];
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;
882
+ });
883
+ at = offset;
884
+ }
885
+ this.bytes = bytes;
886
+ this.jv3Map = map;
887
+ this.format = "jv3";
888
+ }
889
+
890
+ /**
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`.
898
+ * @returns {boolean} true if written
899
+ */
900
+ writeSector(track, side, sector, data, { deleted = false, dam, index } = {}) {
901
+ if (this.writeProtected) return false;
902
+ const mark = dam ?? (deleted ? 0xf8 : 0xfb);
903
+ if (this.format === "dmk") {
904
+ return this._dmkWriteSector(track, side, sector, data, mark, index);
905
+ }
906
+ const loc = this._locate(track, side, sector, index);
907
+ if (!loc) return false;
908
+ this.bytes.set(data.subarray(0, loc.size), loc.offset);
909
+ const entry = loc.entry;
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);
913
+ }
914
+ return true;
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
+ }
965
+ }