@fugloy/emu 0.0.0-stage → 0.2.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,578 @@
1
+ /**
2
+ * TRS-80 disk image reader/writer (in-memory)
3
+ *
4
+ * Three containers:
5
+ *
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.
10
+ *
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.
14
+ *
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.
22
+ *
23
+ * Writes modify the in-memory image only (session-local); `bytes` is
24
+ * always the exportable / serializable truth for every container.
25
+ */
26
+
27
+ const JV3_ENTRIES = 2901;
28
+ const JV3_HEADER_SIZE = JV3_ENTRIES * 3 + 1;
29
+ const JV3_FREE = 0xff;
30
+
31
+ // JV3 flag bits
32
+ const F_SIDE = 0x10;
33
+ const F_DAM = 0x60; // data address mark code (0x00 = normal 0xFB)
34
+
35
+ function jv3SectorSize(flags, free) {
36
+ // Size code meaning flips between used and free entries; used shown here
37
+ const code = flags & 0x03;
38
+ if (free) {
39
+ return [512, 1024, 128, 256][code];
40
+ }
41
+ return [256, 128, 1024, 512][code];
42
+ }
43
+
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;
49
+ }
50
+
51
+ // TRSDOS-family boot sectors name the directory cylinder in byte 2.
52
+ const DEFAULT_DIR_TRACK = 17;
53
+
54
+ // ---------------------------- DMK ----------------------------------
55
+ const DMK_HEADER = 16;
56
+ const DMK_IDAM_TABLE = 128;
57
+
58
+ /** CRC-CCITT, the checksum the WD1793 writes after ID and data fields. */
59
+ function crc16(bytes, init = 0xffff) {
60
+ let crc = init;
61
+ for (const b of bytes) {
62
+ crc ^= b << 8;
63
+ for (let i = 0; i < 8; i++) {
64
+ crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
65
+ }
66
+ }
67
+ return crc;
68
+ }
69
+
70
+ /**
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.
74
+ */
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
+
86
+ 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);
92
+ for (const s of sectors) {
93
+ 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);
112
+ }
113
+ while (raw.length < rawCapacity) push(0x4e);
114
+
115
+ const region = new Uint8Array(trackLen);
116
+ idamPtrs.forEach((p, i) => {
117
+ region[i * 2] = p & 0xff;
118
+ region[i * 2 + 1] = p >> 8;
119
+ });
120
+ region.set(raw.slice(0, rawCapacity), DMK_IDAM_TABLE);
121
+ return region;
122
+ }
123
+
124
+ export class DiskImage {
125
+ /**
126
+ * @param {Uint8Array} bytes - raw .dsk contents
127
+ * @param {string} name - display name (file name)
128
+ */
129
+ constructor(bytes, name = "disk") {
130
+ this.name = name;
131
+ this.bytes = new Uint8Array(bytes); // private working copy
132
+ 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()) {
137
+ this.format = "dmk";
138
+ } else if (this._tryParseJV3()) {
139
+ this.format = "jv3";
140
+ } else if (this._tryParseJV1()) {
141
+ this.format = "jv1";
142
+ } else {
143
+ throw new Error(
144
+ `Unrecognized disk image (${bytes.length} bytes): not DMK, not JV3, and not a multiple of 256-byte sectors`
145
+ );
146
+ }
147
+ }
148
+
149
+ _tryParseDMK() {
150
+ const b = this.bytes;
151
+ if (b.length < DMK_HEADER + 0x100) return false;
152
+ const tracks = b[1];
153
+ const trackLen = b[2] | (b[3] << 8);
154
+ const flags = b[4];
155
+ if (tracks < 1 || tracks > 96) return false;
156
+ if (trackLen < 0x100 || trackLen > 0x4000) return false;
157
+ if (b[12] | b[13] | b[14] | b[15]) return false; // real-drive marker
158
+ const sides = flags & 0x10 ? 1 : 2;
159
+ if (b.length !== DMK_HEADER + tracks * sides * trackLen) return false;
160
+
161
+ this.dmk = { tracks, trackLen, sides, sdSingleByte: (flags & 0x40) !== 0 };
162
+ this.writeProtected = b[0] === 0xff;
163
+ this._dmkCache = new Map();
164
+ return true;
165
+ }
166
+
167
+ _dmkTrackBase(track, side) {
168
+ const { tracks, trackLen, sides } = this.dmk;
169
+ if (track >= tracks || side >= sides) return -1;
170
+ const index = sides === 1 ? track : track * 2 + side;
171
+ return DMK_HEADER + index * trackLen;
172
+ }
173
+
174
+ /** Parse (and cache) one DMK track's sector layout from its IDAMs. */
175
+ _dmkSectors(track, side) {
176
+ const key = `${track}:${side}`;
177
+ if (this._dmkCache.has(key)) return this._dmkCache.get(key);
178
+ const out = [];
179
+ const base = this._dmkTrackBase(track, side);
180
+ if (base >= 0) {
181
+ const { trackLen, sdSingleByte } = this.dmk;
182
+ const b = this.bytes;
183
+ for (let i = 0; i < 64; i++) {
184
+ const ptr = b[base + i * 2] | (b[base + i * 2 + 1] << 8);
185
+ if (!ptr) break; // unused table entries are zero
186
+ const mfm = (ptr & 0x8000) !== 0;
187
+ 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];
191
+ if (at(0) !== 0xfe) continue;
192
+ const sector = at(3);
193
+ const size = 128 << (at(4) & 3);
194
+ // The data mark follows within the WD's address-to-data window
195
+ let dataOff = -1;
196
+ let deleted = false;
197
+ const limit = mfm ? 50 : 37;
198
+ for (let k = 7; k <= limit; k++) {
199
+ const byte = at(k);
200
+ if (byte === 0xfb || byte === 0xf8) {
201
+ dataOff = off + (k + 1) * step;
202
+ deleted = byte === 0xf8;
203
+ break;
204
+ }
205
+ }
206
+ if (dataOff < 0 || dataOff + size * step > trackLen) continue;
207
+ out.push({ sector, size, deleted, mfm, step, dataOff });
208
+ }
209
+ }
210
+ this._dmkCache.set(key, out);
211
+ return out;
212
+ }
213
+
214
+ _dmkExtend(newTrackCount) {
215
+ const { trackLen, sides } = this.dmk;
216
+ const grown = new Uint8Array(DMK_HEADER + newTrackCount * sides * trackLen);
217
+ grown.set(this.bytes, 0);
218
+ grown[1] = newTrackCount;
219
+ this.bytes = grown;
220
+ this.dmk.tracks = newTrackCount;
221
+ }
222
+
223
+ _tryParseJV3() {
224
+ if (this.bytes.length < JV3_HEADER_SIZE) return false;
225
+
226
+ 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++;
248
+ }
249
+ dataOffset += free ? 0 : size;
250
+ if (dataOffset > this.bytes.length) return false;
251
+ }
252
+ // A real disk has many sectors; an exactly-header-sized all-free
253
+ // image is a valid BLANK disk (DiskImage.blank, and its export /
254
+ // save-state round trip) waiting for a DOS FORMAT.
255
+ if (used < 10 && !(used === 0 && this.bytes.length === JV3_HEADER_SIZE)) {
256
+ return false;
257
+ }
258
+
259
+ this.jv3Map = map;
260
+ // Last header byte: 0xff = writable, anything else = write-protected
261
+ this.writeProtected = this.bytes[JV3_HEADER_SIZE - 1] !== 0xff;
262
+ return true;
263
+ }
264
+
265
+ /** A blank, writable, unformatted disk — FORMAT makes it real. */
266
+ static blank(name = "blank.dsk") {
267
+ return new DiskImage(new Uint8Array(JV3_HEADER_SIZE).fill(0xff), name);
268
+ }
269
+
270
+ _tryParseJV1() {
271
+ 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 });
281
+ }
282
+ }
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];
286
+
287
+ this.sectorsPerTrack = chosen.spt;
288
+ 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;
293
+ // JV1 carries no DAM info, but DOSes verify the deleted mark on
294
+ // directory reads — synthesize it for the directory track the boot
295
+ // sector names (byte 2), as xtrs does for its fixed track 17.
296
+ const bootDir = this.bytes[2];
297
+ this.dirTrack =
298
+ bootDir >= 1 && bootDir < this.tracks ? bootDir : DEFAULT_DIR_TRACK;
299
+ return true;
300
+ }
301
+
302
+ /** Highest track number present (for FDC seek bounds). */
303
+ trackCount() {
304
+ if (this.format === "dmk") return this.dmk.tracks;
305
+ if (this.format === "jv1") return this.tracks;
306
+ if (this.jv3Map.length === 0) return 0; // blank disk
307
+ return Math.max(...this.jv3Map.map((e) => e.track)) + 1;
308
+ }
309
+
310
+ /**
311
+ * The ID fields present on one track, in map order — what READ
312
+ * ADDRESS shows a verifying FORMAT. Size codes are the IBM ones the
313
+ * 1793 reports (0=128 ... 3=1024).
314
+ */
315
+ sectorsOn(track, side) {
316
+ if (this.format === "dmk") {
317
+ const sizeToCode = { 128: 0, 256: 1, 512: 2, 1024: 3 };
318
+ return this._dmkSectors(track, side).map((s) => ({
319
+ sector: s.sector,
320
+ sizeCode: sizeToCode[s.size] ?? 1,
321
+ }));
322
+ }
323
+ if (this.format === "jv1") {
324
+ 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,
328
+ sizeCode: 1,
329
+ }));
330
+ }
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 }));
335
+ }
336
+
337
+ /**
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}
342
+ */
343
+ _locate(track, side, sector) {
344
+ if (this.format === "jv3") {
345
+ const hit = this.jv3Map.find(
346
+ (e) => e.track === track && e.sector === sector && e.side === side
347
+ );
348
+ return hit ? { offset: hit.offset, size: hit.size, entry: hit } : null;
349
+ }
350
+
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;
368
+ }
369
+
370
+ /**
371
+ * Read a sector with its record type, the way the FDC sees it.
372
+ * @returns {{data: Uint8Array, deleted: boolean}|null}
373
+ */
374
+ readSectorEx(track, side, sector) {
375
+ if (this.format === "dmk") {
376
+ const hit = this._dmkSectors(track, side).find((s) => s.sector === sector);
377
+ if (!hit) return null;
378
+ const base = this._dmkTrackBase(track, side);
379
+ const data = new Uint8Array(hit.size);
380
+ for (let k = 0; k < hit.size; k++) {
381
+ data[k] = this.bytes[base + hit.dataOff + k * hit.step];
382
+ }
383
+ return { data, deleted: hit.deleted };
384
+ }
385
+ const loc = this._locate(track, side, sector);
386
+ if (!loc) return null;
387
+ const deleted = loc.entry ? loc.entry.deleted : track === this.dirTrack;
388
+ return {
389
+ data: this.bytes.slice(loc.offset, loc.offset + loc.size),
390
+ deleted,
391
+ };
392
+ }
393
+
394
+ /** @returns {Uint8Array|null} a copy of the sector data */
395
+ readSector(track, side, sector) {
396
+ return this.readSectorEx(track, side, sector)?.data ?? null;
397
+ }
398
+
399
+ /**
400
+ * 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.
406
+ *
407
+ * @param {{sector:number, size?:number, deleted?:boolean,
408
+ * data:Uint8Array, mfm?:boolean}[]} sectors
409
+ * @returns {boolean} true if the track was written
410
+ */
411
+ formatTrack(track, side, sectors) {
412
+ if (this.writeProtected) return false;
413
+ if (this.format === "dmk") {
414
+ if (side >= this.dmk.sides) return false;
415
+ if (track >= this.dmk.tracks) this._dmkExtend(track + 1);
416
+ const region = buildDmkTrackRegion(track, side, sectors, this.dmk.trackLen);
417
+ this.bytes.set(region, this._dmkTrackBase(track, side));
418
+ this._dmkCache.delete(`${track}:${side}`);
419
+ return true;
420
+ }
421
+ if (this.format === "jv1") {
422
+ if (this._jv1FormatInPlace(track, side, sectors)) return true;
423
+ this._promoteToJV3();
424
+ }
425
+
426
+ // Keep every other track's entries with their original flags bytes
427
+ const kept = this.jv3Map
428
+ .filter((e) => !(e.track === track && e.side === side))
429
+ .map((e) => ({
430
+ track: e.track,
431
+ sector: e.sector,
432
+ flags: this.bytes[e.headerIndex * 3 + 2],
433
+ data: this.bytes.slice(e.offset, e.offset + e.size),
434
+ }));
435
+
436
+ const sizeToCode = { 256: 0x00, 128: 0x01, 1024: 0x02, 512: 0x03 };
437
+ const fresh = sectors.map((s) => {
438
+ const mfm = s.mfm !== false;
439
+ const size = s.size ?? s.data.length;
440
+ let flags = sizeToCode[size] ?? 0x00;
441
+ if (side) flags |= F_SIDE;
442
+ if (mfm) flags |= 0x80;
443
+ if (s.deleted) flags |= mfm ? 0x20 : 0x60;
444
+ return {
445
+ track,
446
+ sector: s.sector,
447
+ flags,
448
+ data: new Uint8Array(s.data.subarray(0, size)),
449
+ };
450
+ });
451
+
452
+ this._rebuildJV3([...kept, ...fresh]);
453
+ return true;
454
+ }
455
+
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);
459
+ if (!hit) return false;
460
+ const base = this._dmkTrackBase(track, side);
461
+ const put = (rawOff, v) => {
462
+ this.bytes[base + rawOff] = v;
463
+ if (hit.step === 2) this.bytes[base + rawOff + 1] = v;
464
+ };
465
+
466
+ const mark = deleted ? 0xf8 : 0xfb;
467
+ put(hit.dataOff - hit.step, mark);
468
+ const payload = new Uint8Array(hit.size);
469
+ for (let k = 0; k < hit.size; k++) {
470
+ payload[k] = data[k] ?? 0x00;
471
+ put(hit.dataOff + k * hit.step, payload[k]);
472
+ }
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;
479
+ return true;
480
+ }
481
+
482
+ /** True (and written) when the layout matches this JV1's geometry. */
483
+ _jv1FormatInPlace(track, side, sectors) {
484
+ if (side !== 0 || track >= this.tracks) return false;
485
+ if (sectors.length !== this.sectorsPerTrack) return false;
486
+ const base = this.sectorBase ?? this.preferredBase;
487
+ const sorted = [...sectors].sort((a, b) => a.sector - b.sector);
488
+ for (let i = 0; i < sorted.length; i++) {
489
+ const size = sorted[i].size ?? sorted[i].data.length;
490
+ if (sorted[i].sector !== base + i || size !== 256) return false;
491
+ }
492
+ for (const s of sorted) {
493
+ const offset = (track * this.sectorsPerTrack + (s.sector - base)) * 256;
494
+ this.bytes.set(s.data.subarray(0, 256), offset);
495
+ }
496
+ return true;
497
+ }
498
+
499
+ /** Convert this JV1 into an equivalent in-memory JV3. */
500
+ _promoteToJV3() {
501
+ const base = this.sectorBase ?? this.preferredBase;
502
+ const dd = this.sectorsPerTrack === 18; // Model III 18-sector = DD
503
+ const entries = [];
504
+ for (let t = 0; t < this.tracks; t++) {
505
+ for (let i = 0; i < this.sectorsPerTrack; i++) {
506
+ 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
509
+ const offset = (t * this.sectorsPerTrack + i) * 256;
510
+ entries.push({
511
+ track: t,
512
+ sector: base + i,
513
+ flags,
514
+ data: this.bytes.slice(offset, offset + 256),
515
+ });
516
+ }
517
+ }
518
+ this._rebuildJV3(entries);
519
+ }
520
+
521
+ /** Rebuild bytes + map from {track, sector, flags, data} entries. */
522
+ _rebuildJV3(entries) {
523
+ if (entries.length > JV3_ENTRIES) {
524
+ throw new Error(`Disk needs ${entries.length} JV3 entries (max ${JV3_ENTRIES})`);
525
+ }
526
+ 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);
529
+ 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,
544
+ });
545
+ offset += e.data.length;
546
+ });
547
+ this.bytes = bytes;
548
+ this.jv3Map = map;
549
+ this.format = "jv3";
550
+ }
551
+
552
+ /**
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.
557
+ * @returns {boolean} true if written
558
+ */
559
+ writeSector(track, side, sector, data, { deleted = false } = {}) {
560
+ if (this.writeProtected) return false;
561
+ if (this.format === "dmk") {
562
+ return this._dmkWriteSector(track, side, sector, data, deleted);
563
+ }
564
+ const loc = this._locate(track, side, sector);
565
+ if (!loc) return false;
566
+ this.bytes.set(data.subarray(0, loc.size), loc.offset);
567
+ 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;
575
+ }
576
+ return true;
577
+ }
578
+ }