@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,466 @@
1
+ /**
2
+ * WD1793 Floppy Disk Controller (Model III disk subsystem)
3
+ *
4
+ * Ports (handled by IOSystem):
5
+ * 0xF0 status (R) / command (W) 0xF2 sector register
6
+ * 0xF1 track register 0xF3 data register
7
+ * 0xF4 (W) drive select: bits 0-3 drive, bit 4 side, bit 7 MFM
8
+ *
9
+ * Implements what the boot ROM and LDOS/TRSDOS-class DOSes use: Type I
10
+ * RESTORE/SEEK/STEP, Type II READ/WRITE SECTOR with a DRQ byte pump,
11
+ * Type III READ ADDRESS, Type IV FORCE INTERRUPT. Command completion is
12
+ * delayed in emulated T-states via tick(); INTRQ is exposed for the
13
+ * 0xE4 NMI latch. Sector data comes from DiskImage objects attached per
14
+ * drive.
15
+ */
16
+
17
+ // Status bits (Type I / common)
18
+ const ST_BUSY = 0x01;
19
+ const ST_INDEX = 0x02; // Type I: index pulse
20
+ const ST_DRQ = 0x02; // Type II/III: data request
21
+ const ST_TRACK0 = 0x04;
22
+ const ST_CRC_ERROR = 0x08;
23
+ const ST_RNF = 0x10; // record not found / seek error
24
+ const ST_HEAD_LOADED = 0x20;
25
+ const ST_RECORD_TYPE = 0x20; // Type II read: deleted data mark (0xF8)
26
+ const ST_WRITE_PROTECT = 0x40;
27
+ const ST_NOT_READY = 0x80;
28
+
29
+ // Index hole: 300 rpm = one pulse per 405,504 T-states, ~6k wide
30
+ const INDEX_PERIOD = 405504;
31
+ const INDEX_WIDTH = 8000;
32
+
33
+ const COMPLETION_DELAY = 12000; // T-states for Type I/II command spin-up
34
+
35
+ // WRITE TRACK capture cap: a DD track is ~6250 bytes; an unthrottled
36
+ // DOS can shovel more filler than that in one emulated revolution.
37
+ const TRACK_WRITE_CAP = 20000;
38
+
39
+ /**
40
+ * Turn the byte stream a formatting DOS sends during WRITE TRACK into
41
+ * sectors. MFM control bytes: 0xF5 writes an A1 sync byte, 0xFE starts
42
+ * an ID field (track, side, sector, size code), 0xFB/0xF8 start a
43
+ * normal/deleted data field, 0xF7 writes the CRC. FM (single density)
44
+ * uses the bare marks after a run of 0x00 sync bytes. The density latch
45
+ * is only a guess about what the DOS meant, so a parse that finds no
46
+ * sectors retries as the other density.
47
+ *
48
+ * @returns {{track:number, side:number, sector:number, size:number,
49
+ * deleted:boolean, data:Uint8Array, mfm:boolean}[]}
50
+ */
51
+ export function parseWriteTrackStream(bytes, mfmGuess = true) {
52
+ const first = mfmGuess ? scanMfm(bytes) : scanFm(bytes);
53
+ if (first.length) return first;
54
+ return mfmGuess ? scanFm(bytes) : scanMfm(bytes);
55
+ }
56
+
57
+ function scanMfm(bytes) {
58
+ const sectors = [];
59
+ let i = 0;
60
+ let id = null;
61
+ while (i < bytes.length) {
62
+ if (bytes[i] !== 0xf5) {
63
+ i++;
64
+ continue;
65
+ }
66
+ let j = i;
67
+ while (j < bytes.length && bytes[j] === 0xf5) j++;
68
+ const mark = bytes[j];
69
+ if (mark === 0xfe && j + 4 < bytes.length) {
70
+ id = {
71
+ track: bytes[j + 1],
72
+ side: bytes[j + 2],
73
+ sector: bytes[j + 3],
74
+ size: 128 << (bytes[j + 4] & 3),
75
+ };
76
+ i = j + 5;
77
+ } else if ((mark === 0xfb || mark === 0xf8) && id) {
78
+ sectors.push({
79
+ ...id,
80
+ deleted: mark === 0xf8,
81
+ data: bytes.slice(j + 1, j + 1 + id.size),
82
+ mfm: true,
83
+ });
84
+ i = j + 1 + id.size;
85
+ id = null;
86
+ } else {
87
+ i = j + 1;
88
+ }
89
+ }
90
+ return sectors;
91
+ }
92
+
93
+ function scanFm(bytes) {
94
+ const sectors = [];
95
+ let i = 0;
96
+ let id = null;
97
+ let zeros = 0;
98
+ while (i < bytes.length) {
99
+ const b = bytes[i];
100
+ if (b === 0x00) {
101
+ zeros++;
102
+ i++;
103
+ continue;
104
+ }
105
+ if (zeros >= 2) {
106
+ if (b === 0xfe && i + 4 < bytes.length) {
107
+ id = {
108
+ track: bytes[i + 1],
109
+ side: bytes[i + 2],
110
+ sector: bytes[i + 3],
111
+ size: 128 << (bytes[i + 4] & 3),
112
+ };
113
+ zeros = 0;
114
+ i += 5;
115
+ continue;
116
+ }
117
+ if ((b === 0xfb || b === 0xf8) && id) {
118
+ sectors.push({
119
+ ...id,
120
+ deleted: b === 0xf8,
121
+ data: bytes.slice(i + 1, i + 1 + id.size),
122
+ mfm: false,
123
+ });
124
+ zeros = 0;
125
+ i += 1 + id.size;
126
+ id = null;
127
+ continue;
128
+ }
129
+ }
130
+ zeros = 0;
131
+ i++;
132
+ }
133
+ return sectors;
134
+ }
135
+
136
+ export class FDC1793 {
137
+ constructor() {
138
+ this.drives = [null, null, null, null]; // DiskImage or null
139
+ this.selected = 0;
140
+ this.side = 0;
141
+
142
+ this.trackReg = 0;
143
+ this.sectorReg = 0;
144
+ this.dataReg = 0;
145
+ this.physicalTrack = [0, 0, 0, 0];
146
+
147
+ this.status = 0;
148
+ this.intrq = false;
149
+ this.commandType = 1;
150
+
151
+ // In-flight operation
152
+ this.pending = null; // { kind, delay }
153
+ this.buffer = null; // Uint8Array being pumped
154
+ this.bufferPos = 0;
155
+ this.writing = false;
156
+ this.writeDeleted = false; // WRITE SECTOR bit a0: write a 0xF8 DAM
157
+ this.trackWrite = null; // WRITE TRACK capture (array of bytes)
158
+ this.mfm = true; // port 0xF4 bit 7 density latch (Model III default DD)
159
+ this._readAddrIndex = 0; // READ ADDRESS round-robins the track's IDs
160
+
161
+ this.indexClock = 0;
162
+ }
163
+
164
+ attachDrive(n, image) {
165
+ this.drives[n & 3] = image;
166
+ }
167
+
168
+ ejectDrive(n) {
169
+ this.drives[n & 3] = null;
170
+ }
171
+
172
+ anyDiskMounted() {
173
+ return this.drives.some((d) => d !== null);
174
+ }
175
+
176
+ currentDisk() {
177
+ return this.drives[this.selected];
178
+ }
179
+
180
+ /** Port 0xF4 write */
181
+ selectDrive(value) {
182
+ if (value & 0x01) this.selected = 0;
183
+ else if (value & 0x02) this.selected = 1;
184
+ else if (value & 0x04) this.selected = 2;
185
+ else if (value & 0x08) this.selected = 3;
186
+ this.side = value & 0x10 ? 1 : 0;
187
+ this.mfm = (value & 0x80) !== 0;
188
+ }
189
+
190
+ /** Advance emulated time; completes in-flight commands. */
191
+ tick(cycles) {
192
+ this.indexClock = (this.indexClock + cycles) % INDEX_PERIOD;
193
+ if (this.pending) {
194
+ this.pending.delay -= cycles;
195
+ if (this.pending.delay <= 0) {
196
+ const kind = this.pending.kind;
197
+ this.pending = null;
198
+ this._complete(kind);
199
+ }
200
+ }
201
+ }
202
+
203
+ _complete(kind) {
204
+ const disk = this.currentDisk();
205
+ switch (kind) {
206
+ case "type1":
207
+ this.status = this._type1Status();
208
+ this._finish();
209
+ break;
210
+
211
+ case "read-sector": {
212
+ const read = disk
213
+ ? disk.readSectorEx(this.physicalTrack[this.selected], this.side, this.sectorReg)
214
+ : null;
215
+ if (!read) {
216
+ this.status = ST_RNF;
217
+ this._finish();
218
+ } else {
219
+ this.buffer = read.data;
220
+ this.bufferPos = 0;
221
+ this.writing = false;
222
+ // The 179x knows the DAM before the first data byte, and DOSes
223
+ // check bit 5 after completion — _finish only drops BUSY|DRQ,
224
+ // so the record type survives until the next command.
225
+ this.status = ST_BUSY | ST_DRQ | (read.deleted ? ST_RECORD_TYPE : 0);
226
+ }
227
+ break;
228
+ }
229
+
230
+ case "write-sector": {
231
+ if (!disk) {
232
+ this.status = ST_RNF;
233
+ this._finish();
234
+ } else if (disk.writeProtected) {
235
+ this.status = ST_WRITE_PROTECT;
236
+ this._finish();
237
+ } else {
238
+ // Probe the sector exists before accepting data
239
+ const probe = disk.readSector(this.physicalTrack[this.selected], this.side, this.sectorReg);
240
+ if (!probe) {
241
+ this.status = ST_RNF;
242
+ this._finish();
243
+ } else {
244
+ this.buffer = new Uint8Array(probe.length);
245
+ this.bufferPos = 0;
246
+ this.writing = true;
247
+ this.status = ST_BUSY | ST_DRQ;
248
+ }
249
+ }
250
+ break;
251
+ }
252
+
253
+ case "read-address": {
254
+ // 6 ID bytes: track, side, sector, size code, CRC1, CRC2 — from
255
+ // the real IDs on the track, round-robin like passing IDAMs
256
+ // (FORMAT verify passes read these to check the seek landed).
257
+ const track = this.physicalTrack[this.selected];
258
+ const ids = disk ? disk.sectorsOn(track, this.side) : [];
259
+ if (ids.length === 0) {
260
+ this.status = ST_RNF;
261
+ this._finish();
262
+ break;
263
+ }
264
+ const id = ids[this._readAddrIndex++ % ids.length];
265
+ this.buffer = new Uint8Array([track, this.side, id.sector, id.sizeCode, 0, 0]);
266
+ // The 1793 copies the ID's track address into the sector register
267
+ this.sectorReg = track;
268
+ this.bufferPos = 0;
269
+ this.writing = false;
270
+ this.status = ST_BUSY | ST_DRQ;
271
+ break;
272
+ }
273
+
274
+ case "write-track": {
275
+ const stream = new Uint8Array(this.trackWrite || []);
276
+ this.trackWrite = null;
277
+ const sectors = parseWriteTrackStream(stream, this.mfm);
278
+ if (disk && sectors.length) {
279
+ disk.formatTrack(this.physicalTrack[this.selected], this.side, sectors);
280
+ }
281
+ this.status = 0;
282
+ this._finish();
283
+ break;
284
+ }
285
+
286
+ default:
287
+ this._finish();
288
+ }
289
+ }
290
+
291
+ _finish() {
292
+ // Completion drops BUSY and DRQ together (type I status is
293
+ // recomputed live on the next read, so losing bit 1 there is fine)
294
+ this.status &= ~(ST_BUSY | ST_DRQ);
295
+ this.intrq = true;
296
+ this.buffer = null;
297
+ this.writing = false;
298
+ this.trackWrite = null;
299
+ }
300
+
301
+ _type1Status() {
302
+ const disk = this.currentDisk();
303
+ let s = ST_HEAD_LOADED;
304
+ if (!disk) s |= ST_NOT_READY;
305
+ if (this.physicalTrack[this.selected] === 0) s |= ST_TRACK0;
306
+ if (disk && disk.writeProtected) s |= ST_WRITE_PROTECT;
307
+ if (this.indexClock < INDEX_WIDTH) s |= ST_INDEX;
308
+ return s;
309
+ }
310
+
311
+ /** Port 0xF0 read */
312
+ readStatus() {
313
+ this.intrq = false; // reading status clears INTRQ
314
+ if (this.commandType === 1) {
315
+ // Recompute live bits (index pulse, track 0)
316
+ if (!(this.status & ST_BUSY)) {
317
+ this.status = this._type1Status();
318
+ }
319
+ return this.status;
320
+ }
321
+ return this.status;
322
+ }
323
+
324
+ /** Port 0xF0 write */
325
+ writeCommand(value) {
326
+ this.intrq = false;
327
+ const high = value >> 4;
328
+
329
+ // Type IV: force interrupt
330
+ if (high === 0x0d) {
331
+ this.pending = null;
332
+ this.buffer = null;
333
+ this.trackWrite = null;
334
+ this.status &= ~ST_BUSY;
335
+ this.commandType = 1;
336
+ this.status = this._type1Status();
337
+ if (value & 0x08) this.intrq = true;
338
+ return;
339
+ }
340
+
341
+ if (this.status & ST_BUSY && this.pending) {
342
+ return; // busy: ignore new commands (except force interrupt above)
343
+ }
344
+
345
+ if (high <= 0x07) {
346
+ // Type I: restore/seek/step family
347
+ this.commandType = 1;
348
+ const drive = this.selected;
349
+ if (high === 0x00) {
350
+ this.physicalTrack[drive] = 0;
351
+ this.trackReg = 0;
352
+ } else if (high === 0x01) {
353
+ this.physicalTrack[drive] = this.dataReg;
354
+ this.trackReg = this.dataReg;
355
+ } else {
356
+ // STEP/STEP-IN/STEP-OUT with optional track update (bit 4)
357
+ const dir = high >= 0x06 ? -1 : high >= 0x04 ? 1 : this._lastStepDir || 1;
358
+ if (high >= 0x04) this._lastStepDir = dir;
359
+ this.physicalTrack[drive] = Math.max(0, this.physicalTrack[drive] + dir);
360
+ if (value & 0x10) this.trackReg = this.physicalTrack[drive];
361
+ }
362
+ this.status = ST_BUSY;
363
+ this.pending = { kind: "type1", delay: COMPLETION_DELAY };
364
+ return;
365
+ }
366
+
367
+ if (high === 0x08 || high === 0x09) {
368
+ // READ SECTOR (multi-sector treated as single)
369
+ this.commandType = 2;
370
+ this.status = ST_BUSY;
371
+ this.pending = { kind: "read-sector", delay: COMPLETION_DELAY };
372
+ return;
373
+ }
374
+ if (high === 0x0a || high === 0x0b) {
375
+ // WRITE SECTOR (bit 0 selects the DAM: 1 = deleted/0xF8)
376
+ this.commandType = 2;
377
+ this.writeDeleted = (value & 0x01) !== 0;
378
+ this.status = ST_BUSY;
379
+ this.pending = { kind: "write-sector", delay: COMPLETION_DELAY };
380
+ return;
381
+ }
382
+ if (high === 0x0c) {
383
+ // READ ADDRESS
384
+ this.commandType = 3;
385
+ this.status = ST_BUSY;
386
+ this.pending = { kind: "read-address", delay: COMPLETION_DELAY };
387
+ return;
388
+ }
389
+ if (high === 0x0e) {
390
+ // READ TRACK: rarely used; accept and complete with no data
391
+ this.commandType = 3;
392
+ this.status = ST_BUSY;
393
+ this.pending = { kind: "noop", delay: COMPLETION_DELAY };
394
+ return;
395
+ }
396
+
397
+ // WRITE TRACK (0xF): a formatting DOS streams a whole track image
398
+ // through the data register; the pass ends at the next index pulse
399
+ // (one revolution), when the stream is parsed into sectors and the
400
+ // track is rebuilt on the mounted image.
401
+ this.commandType = 3;
402
+ const disk = this.currentDisk();
403
+ if (!disk) {
404
+ this.status = ST_NOT_READY;
405
+ this.intrq = true;
406
+ return;
407
+ }
408
+ if (disk.writeProtected) {
409
+ this.status = ST_WRITE_PROTECT;
410
+ this.intrq = true;
411
+ return;
412
+ }
413
+ this.trackWrite = [];
414
+ this.status = ST_BUSY | ST_DRQ;
415
+ this.pending = { kind: "write-track", delay: INDEX_PERIOD };
416
+ }
417
+
418
+ readTrackReg() {
419
+ return this.trackReg;
420
+ }
421
+ writeTrackReg(v) {
422
+ this.trackReg = v & 0xff;
423
+ }
424
+ readSectorReg() {
425
+ return this.sectorReg;
426
+ }
427
+ writeSectorReg(v) {
428
+ this.sectorReg = v & 0xff;
429
+ }
430
+
431
+ /** Port 0xF3 read: pump read buffer */
432
+ readData() {
433
+ if (this.buffer && !this.writing) {
434
+ this.dataReg = this.buffer[this.bufferPos++];
435
+ if (this.bufferPos >= this.buffer.length) {
436
+ this._finish();
437
+ }
438
+ }
439
+ return this.dataReg;
440
+ }
441
+
442
+ /** Port 0xF3 write: pump write buffer */
443
+ writeData(v) {
444
+ this.dataReg = v & 0xff;
445
+ if (this.trackWrite) {
446
+ if (this.trackWrite.length < TRACK_WRITE_CAP) {
447
+ this.trackWrite.push(this.dataReg);
448
+ }
449
+ return;
450
+ }
451
+ if (this.buffer && this.writing) {
452
+ this.buffer[this.bufferPos++] = this.dataReg;
453
+ if (this.bufferPos >= this.buffer.length) {
454
+ const disk = this.currentDisk();
455
+ disk.writeSector(
456
+ this.physicalTrack[this.selected],
457
+ this.side,
458
+ this.sectorReg,
459
+ this.buffer,
460
+ { deleted: this.writeDeleted }
461
+ );
462
+ this._finish();
463
+ }
464
+ }
465
+ }
466
+ }