@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.
@@ -1,59 +1,126 @@
1
1
  /**
2
- * TRS-80 disk image reader/writer (in-memory)
2
+ * TRS-80 disk images in memory: read JV1, JV3, and DMK exactly, write in place, export DMK.
3
3
  *
4
- * Three containers:
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 linear dump: track 0 sector 0.. then track 1...,
7
- * 256-byte sectors. Model I images use 10 sectors/track (single
8
- * density); Model III TRSDOS 1.3-style images use 18. Geometry is
9
- * inferred from the file size.
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 — 2901 three-byte sector headers (track, sector, flags) + one
12
- * write-protect byte, followed by sector data in header order.
13
- * Free entries are 0xFF-filled.
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-protect, track count, track length LE,
16
- * flags) then raw track bytes behind a 128-byte IDAM pointer
17
- * table per track (pointer = offset of the 0xFE ID byte from
18
- * track start, bit 15 = MFM). Supported here: single- and
19
- * double-sided, DD tracks and doubled-byte SD; freshly
20
- * formatted tracks are always written as DD raw. Real-drive
21
- * passthrough images (nonzero bytes 12-15) are rejected.
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 modify the in-memory image only (session-local); `bytes` is
24
- * always the exportable / serializable truth for every container.
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 F_SIDE = 0x10;
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
- if (free) {
39
- return [512, 1024, 128, 256][code];
40
- }
41
- return [256, 128, 1024, 512][code];
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
- // The WD1793 reports any non-0xFB mark as a deleted record (status bit
45
- // 5). DOSes rely on it: directory sectors are written 0xF8 (or 0xFA on
46
- // Model I TRSDOS) and verified on every directory read.
47
- function jv3Deleted(flags) {
48
- return (flags & F_DAM) !== 0;
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
- * Build one canonical DD raw track (IDAM table + track bytes) from
72
- * formatted sectors. Falls back to minimal gaps when the standard IBM
73
- * layout wouldn't fit the image's track length.
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
- const idamPtrs = [];
88
- const push = (...b) => raw.push(...b);
89
- for (let i = 0; i < 32; i++) push(0x4e);
90
- for (let i = 0; i < 12; i++) push(0x00);
91
- push(0xc2, 0xc2, 0xc2, 0xfc);
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 sizeCode = { 128: 0, 256: 1, 512: 2, 1024: 3 }[size] ?? 1;
95
- for (let i = 0; i < gap; i++) push(0x4e);
96
- for (let i = 0; i < 12; i++) push(0x00);
97
- push(0xa1, 0xa1, 0xa1);
98
- idamPtrs.push((DMK_IDAM_TABLE + raw.length) | 0x8000);
99
- const id = [0xfe, s.track ?? cyl, s.side ?? side, s.sector, sizeCode];
100
- push(...id);
101
- const idCrc = crc16([0xa1, 0xa1, 0xa1, ...id]);
102
- push(idCrc >> 8, idCrc & 0xff);
103
- for (let i = 0; i < 22; i++) push(0x4e);
104
- for (let i = 0; i < 12; i++) push(0x00);
105
- push(0xa1, 0xa1, 0xa1);
106
- const mark = s.deleted ? 0xf8 : 0xfb;
107
- push(mark);
108
- const data = s.data.subarray ? s.data.subarray(0, size) : s.data.slice(0, size);
109
- for (const b of data) push(b);
110
- const dCrc = crc16([0xa1, 0xa1, 0xa1, mark, ...data]);
111
- push(dCrc >> 8, dCrc & 0xff);
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
- while (raw.length < rawCapacity) push(0x4e);
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
- idamPtrs.forEach((p, i) => {
117
- region[i * 2] = p & 0xff;
118
- region[i * 2 + 1] = p >> 8;
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.set(raw.slice(0, rawCapacity), DMK_IDAM_TABLE);
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, learned on first hit
135
-
136
- if (this._tryParseDMK()) {
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): not DMK, not JV3, and not a multiple of 256-byte sectors`
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 + 0x100) return false;
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
- if (tracks < 1 || tracks > 96) return false;
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
- const sides = flags & 0x10 ? 1 : 2;
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 = { tracks, trackLen, sides, sdSingleByte: (flags & 0x40) !== 0 };
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 < 64; 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
- if (off < DMK_IDAM_TABLE || off >= trackLen) continue;
189
- const step = mfm || sdSingleByte ? 1 : 2; // SD bytes usually doubled
190
- const at = (k) => b[base + off + k * step];
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
- // The data mark follows within the WD's address-to-data window
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 deleted = false;
197
- const limit = mfm ? 50 : 37;
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 === 0xfb || byte === 0xf8) {
363
+ if (byte >= 0xf8 && byte <= 0xfb && (!mfm || at(k - 1) === 0xa1)) {
201
364
  dataOff = off + (k + 1) * step;
202
- deleted = byte === 0xf8;
365
+ dam = byte;
203
366
  break;
204
367
  }
205
368
  }
206
- if (dataOff < 0 || dataOff + size * step > trackLen) continue;
207
- out.push({ sector, size, deleted, mfm, step, dataOff });
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
- _tryParseJV3() {
224
- if (this.bytes.length < JV3_HEADER_SIZE) return false;
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 dataOffset = JV3_HEADER_SIZE;
228
- let used = 0;
229
- for (let i = 0; i < JV3_ENTRIES; i++) {
230
- const track = this.bytes[i * 3];
231
- const sector = this.bytes[i * 3 + 1];
232
- const flags = this.bytes[i * 3 + 2];
233
- const free = track === JV3_FREE && sector === JV3_FREE;
234
- const size = jv3SectorSize(flags, free);
235
- if (!free) {
236
- // Sanity: plausible CHS values
237
- if (track > 96 || sector > 63) return false;
238
- map.push({
239
- track,
240
- sector,
241
- side: flags & F_SIDE ? 1 : 0,
242
- offset: dataOffset,
243
- size,
244
- deleted: jv3Deleted(flags),
245
- headerIndex: i, // to update the flags byte on DAM-changing writes
246
- });
247
- used++;
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
- dataOffset += free ? 0 : size;
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 (used < 10 && !(used === 0 && this.bytes.length === JV3_HEADER_SIZE)) {
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 = this.bytes[JV3_HEADER_SIZE - 1] !== 0xff;
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
- _tryParseJV1() {
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
- if (size === 0 || size % 256 !== 0) return false;
273
-
274
- const totalSectors = size / 256;
275
- // Prefer geometries that yield a classic track count; prefer the
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 (candidates.length === 0) return false;
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
- // Convention: Model III 18-sector disks number sectors 1-18 (TRSDOS
290
- // 1.3 style); Model I 10-sector disks number 0-9. Seed the base
291
- // accordingly; _locate still falls back to the other base on a miss.
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 sizeToCode = { 128: 0, 256: 1, 512: 2, 1024: 3 };
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: sizeToCode[s.size] ?? 1,
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 base = this.sectorBase ?? this.preferredBase;
326
- return Array.from({ length: this.sectorsPerTrack }, (_, i) => ({
327
- sector: base + i,
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 sizeToCode = { 128: 0, 256: 1, 512: 2, 1024: 3 };
332
- return this.jv3Map
333
- .filter((e) => e.track === track && e.side === side)
334
- .map((e) => ({ sector: e.sector, sizeCode: sizeToCode[e.size] ?? 1 }));
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. JV1 images may number sectors from 0 or 1
339
- * depending on how they were produced; the first successful lookup
340
- * fixes the convention for the rest of the session.
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 hit = this.jv3Map.find(
346
- (e) => e.track === track && e.sector === sector && e.side === side
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 bases =
353
- this.sectorBase !== null
354
- ? [this.sectorBase]
355
- : [this.preferredBase, 1 - this.preferredBase];
356
- for (const base of bases) {
357
- const index = sector - base;
358
- if (index >= 0 && index < this.sectorsPerTrack && track < this.tracks) {
359
- this.sectorBase = base;
360
- return {
361
- offset: (track * this.sectorsPerTrack + index) * 256,
362
- size: 256,
363
- entry: null,
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
- * @returns {{data: Uint8Array, deleted: boolean}|null}
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._dmkSectors(track, side).find((s) => s.sector === sector);
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
- data[k] = this.bytes[base + hit.dataOff + k * hit.step];
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 deleted = loc.entry ? loc.entry.deleted : track === this.dirTrack;
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). JV3 images rebuild their bytes
402
- * wholesale — `bytes` stays the exportable/serializable truth. A JV1
403
- * whose geometry matches writes in place; one that doesn't (new
404
- * track count, sector numbering, or size) is promoted to JV3 in
405
- * memory, keeping every existing sector.
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 region = buildDmkTrackRegion(track, side, sectors, this.dmk.trackLen);
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.headerIndex * 3 + 2],
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 = sizeToCode[size] ?? 0x00;
755
+ let flags = JV3_SIZE_CODE[size] ?? 0x00;
441
756
  if (side) flags |= F_SIDE;
442
- if (mfm) flags |= 0x80;
443
- if (s.deleted) flags |= mfm ? 0x20 : 0x60;
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, deleted) {
458
- const hit = this._dmkSectors(track, side).find((s) => s.sector === sector);
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
- const mark = deleted ? 0xf8 : 0xfb;
467
- put(hit.dataOff - hit.step, mark);
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(hit.dataOff + k * hit.step, payload[k]);
797
+ put(dataOff + k * hit.step, payload[k]);
472
798
  }
473
- const crc = crc16(
474
- hit.mfm ? [0xa1, 0xa1, 0xa1, mark, ...payload] : [mark, ...payload]
475
- );
476
- put(hit.dataOff + hit.size * hit.step, crc >> 8);
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 ?? this.preferredBase;
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 ?? this.preferredBase;
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 |= 0x80;
508
- if (t === this.dirTrack) flags |= dd ? 0x20 : 0x60; // keep the DAM synthesis
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
- /** Rebuild bytes + map from {track, sector, flags, data} entries. */
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 e of entries) total += e.data.length;
528
- const bytes = new Uint8Array(JV3_HEADER_SIZE + total).fill(0xff, 0, JV3_HEADER_SIZE);
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 offset = JV3_HEADER_SIZE;
531
- entries.forEach((e, i) => {
532
- bytes[i * 3] = e.track;
533
- bytes[i * 3 + 1] = e.sector;
534
- bytes[i * 3 + 2] = e.flags;
535
- bytes.set(e.data, offset);
536
- map.push({
537
- track: e.track,
538
- sector: e.sector,
539
- side: e.flags & F_SIDE ? 1 : 0,
540
- offset,
541
- size: e.data.length,
542
- deleted: jv3Deleted(e.flags),
543
- headerIndex: i,
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
- offset += e.data.length;
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 write (WD1793
554
- * write-sector bit a0). JV3 stores it in the header flags — in the
555
- * image bytes, so exports keep it; JV1 has nowhere to put one and
556
- * keeps synthesizing by directory track.
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, deleted);
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 && entry.deleted !== deleted) {
569
- const flagsAt = entry.headerIndex * 3 + 2;
570
- const flags = this.bytes[flagsAt] & ~F_DAM;
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
  }