@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,259 @@
1
+ /**
2
+ * TRS-80 Model III Cassette Interface
3
+ *
4
+ * Two personalities share this deck:
5
+ *
6
+ * - The byte-level tape (tapeData/readByte/simulate*) that the fast
7
+ * loaders and legacy phase consoles use.
8
+ *
9
+ * - The pulse-level deck for the REAL port-0xFF path: a pulse tape
10
+ * (transition times from cassette-tape.js) plays against the CPU
11
+ * clock while the ROM holds the motor relay on, setting the input
12
+ * latch on every transition; any port-0xFF write clears the latch
13
+ * (the Model III's latch circuit). Output transitions written
14
+ * while the motor runs are recorded, and recordedCas() decodes
15
+ * them back into a .cas byte stream — a real CSAVE capture.
16
+ */
17
+
18
+ import { pulsesToBytes } from "./cassette-tape.js";
19
+
20
+ export class CassetteSystem {
21
+ constructor() {
22
+ // Where load and save notes go; a no-op unless the host sets one.
23
+ this.log = () => {};
24
+ this.motorOn = false;
25
+ this.playing = false;
26
+ this.recording = false;
27
+
28
+ this.tapeData = null;
29
+ this.tapePosition = 0;
30
+ this.tapeLength = 0;
31
+
32
+ // Pulse deck: playback
33
+ this.pulseTape = null; // transition times (tape-relative T-states)
34
+ this.pulsePos = 0;
35
+ this.playClock = 0; // tape time elapsed while the motor has been on
36
+ this.lastCycle = 0;
37
+ this.inputLevel = false; // the square wave's current polarity (bit 0)
38
+ this.pulseLatch = false; // rising-edge latch (bit 7, OUT-0xFF resets)
39
+ this.risePending = false; // edges since last consume, by direction
40
+ this.fallPending = false;
41
+
42
+ // Pulse deck: recording (port-0xFF output transitions, cycle-stamped)
43
+ this.recordLog = [];
44
+ this.RECORD_LIMIT = 400000;
45
+ this.restoredRecording = null; // decoded bytes carried in by a save state
46
+
47
+ this.BUFFER_ADDRESS = 0x4300;
48
+ this.BUFFER_SIZE = 256;
49
+
50
+ this.onLoadComplete = null;
51
+ this.onSaveComplete = null;
52
+ }
53
+
54
+ /** Advance the playback clock to `cycles`, playing due transitions:
55
+ * each one flips the input level and marks an edge event. */
56
+ _advance(cycles) {
57
+ if (this.motorOn && cycles > this.lastCycle) {
58
+ this.playClock += cycles - this.lastCycle;
59
+ }
60
+ this.lastCycle = cycles;
61
+ if (this.pulseTape && this.motorOn) {
62
+ while (
63
+ this.pulsePos < this.pulseTape.length &&
64
+ this.pulseTape[this.pulsePos] <= this.playClock
65
+ ) {
66
+ this.pulsePos++;
67
+ this.inputLevel = !this.inputLevel;
68
+ if (this.inputLevel) {
69
+ this.risePending = true;
70
+ this.pulseLatch = true;
71
+ } else {
72
+ this.fallPending = true;
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ /** Mount a pulse tape (casToPulses output), rewound. */
79
+ insertPulseTape(times) {
80
+ this.pulseTape = times;
81
+ this.pulsePos = 0;
82
+ this.playClock = 0;
83
+ this.inputLevel = false;
84
+ this.pulseLatch = false;
85
+ this.risePending = false;
86
+ this.fallPending = false;
87
+ }
88
+
89
+ rewindPulseTape() {
90
+ this.pulsePos = 0;
91
+ this.playClock = 0;
92
+ this.inputLevel = false;
93
+ this.pulseLatch = false;
94
+ this.risePending = false;
95
+ this.fallPending = false;
96
+ }
97
+
98
+ /** The tape signal's current level (port 0xFF bit 0). */
99
+ tapeLevel(cycles) {
100
+ this._advance(cycles);
101
+ return this.inputLevel;
102
+ }
103
+
104
+ /** Any OUT (0xFF) resets the pulse latch (the 500-baud reader's ack). */
105
+ clearPulseLatch(cycles) {
106
+ this._advance(cycles);
107
+ this.pulseLatch = false;
108
+ }
109
+
110
+ /** Edge events since the last consume, by direction. */
111
+ consumeEdges(cycles) {
112
+ this._advance(cycles);
113
+ const edges = { rise: this.risePending, fall: this.fallPending };
114
+ this.risePending = false;
115
+ this.fallPending = false;
116
+ return edges;
117
+ }
118
+
119
+ /** Log an output level change while the motor runs (CSAVE's tape). */
120
+ recordTransition(cycles, level) {
121
+ if (!this.motorOn) return;
122
+ if (this.recordLog.length < this.RECORD_LIMIT) {
123
+ this.recordLog.push({ t: cycles, level });
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Decode whatever CSAVE recorded into .cas bytes.
129
+ * @returns {{bytes: Uint8Array, baud: 500|1500|null}}
130
+ */
131
+ recordedCas() {
132
+ if (this.recordLog.length) {
133
+ return pulsesToBytes(this.recordLog.map((r) => r.t));
134
+ }
135
+ if (this.restoredRecording) {
136
+ return { bytes: this.restoredRecording, baud: null };
137
+ }
138
+ return { bytes: new Uint8Array(0), baud: null };
139
+ }
140
+
141
+ /** Playback position 0..1, or null when no pulse tape is mounted. */
142
+ progress() {
143
+ return this.pulseTape?.length ? this.pulsePos / this.pulseTape.length : null;
144
+ }
145
+
146
+ loadTape(programData) {
147
+ if (!programData || programData.length === 0) {
148
+ return false;
149
+ }
150
+
151
+ this.tapeData =
152
+ programData instanceof Uint8Array
153
+ ? programData
154
+ : new Uint8Array(programData);
155
+
156
+ this.tapePosition = 0;
157
+ this.tapeLength = this.tapeData.length;
158
+
159
+ this.log("info", `Cassette loaded: ${this.tapeLength} bytes`);
160
+ return true;
161
+ }
162
+
163
+ simulateCLoad(memory, targetAddress = 0x4200) {
164
+ if (!this.tapeData) {
165
+ this.log("error", "No tape loaded");
166
+ return false;
167
+ }
168
+
169
+ for (let i = 0; i < this.tapeLength; i++) {
170
+ memory.writeByte(targetAddress + i, this.tapeData[i]);
171
+ }
172
+
173
+ this.log("info",
174
+ `CLOAD: ${this.tapeLength} bytes at 0x${targetAddress.toString(16)}`
175
+ );
176
+
177
+ if (this.onLoadComplete) {
178
+ this.onLoadComplete(targetAddress, this.tapeLength);
179
+ }
180
+
181
+ return targetAddress;
182
+ }
183
+
184
+ simulateCSave(memory, startAddress, length) {
185
+ this.tapeData = new Uint8Array(length);
186
+
187
+ for (let i = 0; i < length; i++) {
188
+ this.tapeData[i] = memory.readByte(startAddress + i);
189
+ }
190
+
191
+ this.tapePosition = 0;
192
+ this.tapeLength = length;
193
+
194
+ this.log("info", `CSAVE: ${length} bytes from 0x${startAddress.toString(16)}`);
195
+
196
+ if (this.onSaveComplete) {
197
+ this.onSaveComplete(this.tapeData);
198
+ }
199
+
200
+ return this.tapeData;
201
+ }
202
+
203
+ getStatus() {
204
+ let status = 0x00;
205
+
206
+ if (this.motorOn) status |= 0x01;
207
+ if (this.playing) status |= 0x02;
208
+ if (this.recording) status |= 0x04;
209
+ if (this.tapeData && this.tapePosition < this.tapeLength) {
210
+ status |= 0x08;
211
+ }
212
+
213
+ return status;
214
+ }
215
+
216
+ control(value, cycles = 0) {
217
+ this._advance(cycles); // settle the old motor state's clock first
218
+ this.motorOn = (value & 0x01) !== 0;
219
+
220
+ if (this.motorOn) {
221
+ this.playing = (value & 0x02) !== 0;
222
+ this.recording = (value & 0x04) !== 0;
223
+ } else {
224
+ this.playing = false;
225
+ this.recording = false;
226
+ }
227
+ }
228
+
229
+ readByte() {
230
+ if (!this.tapeData || this.tapePosition >= this.tapeLength) {
231
+ return 0x00;
232
+ }
233
+ return this.tapeData[this.tapePosition++];
234
+ }
235
+
236
+ rewind() {
237
+ this.tapePosition = 0;
238
+ }
239
+
240
+ eject() {
241
+ this.tapeData = null;
242
+ this.tapePosition = 0;
243
+ this.tapeLength = 0;
244
+ this.pulseTape = null;
245
+ this.pulsePos = 0;
246
+ this.playClock = 0;
247
+ this.inputLevel = false;
248
+ this.pulseLatch = false;
249
+ this.risePending = false;
250
+ this.fallPending = false;
251
+ this.motorOn = false;
252
+ this.playing = false;
253
+ this.recording = false;
254
+ // The recording is the recorder's own cassette — eject leaves it
255
+ }
256
+ }
257
+
258
+
259
+