jsbeeb 1.6.0 → 1.8.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.
package/src/wd-fdc.js CHANGED
@@ -1301,9 +1301,106 @@ export class WdFdc {
1301
1301
  this._dataRegister = data;
1302
1302
  }
1303
1303
 
1304
- /// jsbeeb compatibility stuff TODO combine with the noise aware stuff?
1304
+ snapshotState() {
1305
+ const scheduler = this._timerTask.scheduler;
1306
+ return {
1307
+ controlRegister: this._controlRegister,
1308
+ statusRegister: this._statusRegister,
1309
+ trackRegister: this._trackRegister,
1310
+ sectorRegister: this._sectorRegister,
1311
+ dataRegister: this._dataRegister,
1312
+ isIntRq: this._isIntRq,
1313
+ isDrq: this._isDrq,
1314
+ doRaiseIntRq: this._doRaiseIntRq,
1315
+ isIndexPulse: this._isIndexPulse,
1316
+ isInterruptOnIndexPulse: this._isInterruptOnIndexPulse,
1317
+ isWriteTrackCrcSecondByte: this._isWriteTrackCrcSecondByte,
1318
+ command: this._command,
1319
+ commandType: this._commandType,
1320
+ isCommandSettle: this._isCommandSettle,
1321
+ isCommandWrite: this._isCommandWrite,
1322
+ isCommandVerify: this._isCommandVerify,
1323
+ isCommandMulti: this._isCommandMulti,
1324
+ isCommandDeleted: this._isCommandDeleted,
1325
+ commandStepRateMs: this._commandStepRateMs,
1326
+ state: this._state,
1327
+ timerState: this._timerState,
1328
+ stateCount: this._stateCount,
1329
+ indexPulseCount: this._indexPulseCount,
1330
+ // BigInt must be converted to string for JSON serialization
1331
+ markDetector: this._markDetector.toString(),
1332
+ dataShifter: this._dataShifter,
1333
+ dataShiftCount: this._dataShiftCount,
1334
+ deliverData: this._deliverData,
1335
+ deliverIsMarker: this._deliverIsMarker,
1336
+ crc: this._crc,
1337
+ onDiscTrack: this._onDiscTrack,
1338
+ onDiscSector: this._onDiscSector,
1339
+ onDiscLength: this._onDiscLength,
1340
+ onDiscCrc: this._onDiscCrc,
1341
+ lastMfmBit: this._lastMfmBit,
1342
+ timerTaskOffset: this._timerTask.scheduled() ? this._timerTask.expireEpoch - scheduler.epoch : null,
1343
+ drives: this._drives.map((d) => d.snapshotState()),
1344
+ };
1345
+ }
1346
+
1347
+ restoreState(state) {
1348
+ this._controlRegister = state.controlRegister;
1349
+ this._statusRegister = state.statusRegister;
1350
+ this._trackRegister = state.trackRegister;
1351
+ this._sectorRegister = state.sectorRegister;
1352
+ this._dataRegister = state.dataRegister;
1353
+ this._isIntRq = state.isIntRq;
1354
+ this._isDrq = state.isDrq;
1355
+ this._doRaiseIntRq = state.doRaiseIntRq;
1356
+ this._isIndexPulse = state.isIndexPulse;
1357
+ this._isInterruptOnIndexPulse = state.isInterruptOnIndexPulse;
1358
+ this._isWriteTrackCrcSecondByte = state.isWriteTrackCrcSecondByte;
1359
+ this._command = state.command;
1360
+ this._commandType = state.commandType;
1361
+ this._isCommandSettle = state.isCommandSettle;
1362
+ this._isCommandWrite = state.isCommandWrite;
1363
+ this._isCommandVerify = state.isCommandVerify;
1364
+ this._isCommandMulti = state.isCommandMulti;
1365
+ this._isCommandDeleted = state.isCommandDeleted;
1366
+ this._commandStepRateMs = state.commandStepRateMs;
1367
+ this._state = state.state;
1368
+ this._timerState = state.timerState;
1369
+ this._stateCount = state.stateCount;
1370
+ this._indexPulseCount = state.indexPulseCount;
1371
+ this._markDetector = BigInt(state.markDetector);
1372
+ this._dataShifter = state.dataShifter;
1373
+ this._dataShiftCount = state.dataShiftCount;
1374
+ this._deliverData = state.deliverData;
1375
+ this._deliverIsMarker = state.deliverIsMarker;
1376
+ this._crc = state.crc;
1377
+ this._onDiscTrack = state.onDiscTrack;
1378
+ this._onDiscSector = state.onDiscSector;
1379
+ this._onDiscLength = state.onDiscLength;
1380
+ this._onDiscCrc = state.onDiscCrc;
1381
+ this._lastMfmBit = state.lastMfmBit;
1382
+
1383
+ // Restore drives
1384
+ for (let i = 0; i < this._drives.length; i++) {
1385
+ this._drives[i].restoreState(state.drives[i]);
1386
+ }
1387
+
1388
+ // Derive _currentDrive from _controlRegister
1389
+ this._currentDrive = null;
1390
+ if (this._controlRegister & Control.drive0 || this._controlRegister & Control.drive1) {
1391
+ this._currentDrive = this._drives[this._controlRegister & Control.drive0 ? 0 : 1];
1392
+ }
1393
+
1394
+ // Restore timer
1395
+ this._timerTask.cancel();
1396
+ if (state.timerTaskOffset !== null) this._timerTask.schedule(state.timerTaskOffset);
1397
+
1398
+ // NMI level is saved/restored by the CPU snapshot directly,
1399
+ // so we don't reassert it here.
1400
+ }
1401
+
1402
+ /// jsbeeb compatibility stuff
1305
1403
  /**
1306
- *
1307
1404
  * @param {Number} drive
1308
1405
  * @param {Disc} disc
1309
1406
  */
@@ -30,13 +30,28 @@ export class TestMachine {
30
30
  if (left && !stopped) {
31
31
  setTimeout(runAnIter, 0);
32
32
  } else {
33
- resolve();
33
+ resolve(stopped);
34
34
  }
35
35
  };
36
36
  runAnIter();
37
37
  });
38
38
  }
39
39
 
40
+ /**
41
+ * Run until the cursor blink reaches the desired state.
42
+ * This ensures deterministic screenshots regardless of how many
43
+ * cycles were consumed by prior type() or runFor() calls.
44
+ * @param {boolean} on - true for cursor visible, false for hidden
45
+ */
46
+ async runToCursorState(on) {
47
+ const video = this.processor.video;
48
+ for (let i = 0; i < 100; i++) {
49
+ if (video.cursorOnThisFrame === on) return;
50
+ await this.runFor(40000);
51
+ }
52
+ throw new Error(`Cursor did not reach state ${on} in time (cursorOnThisFrame=${video.cursorOnThisFrame})`);
53
+ }
54
+
40
55
  async runUntilVblank() {
41
56
  let hit = false;
42
57
  if (this.processor.isMaster) throw new Error("Not yet implemented");
@@ -87,6 +102,22 @@ export class TestMachine {
87
102
  this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc, "", data));
88
103
  }
89
104
 
105
+ /**
106
+ * Load a disc image from raw data (Uint8Array or Buffer).
107
+ * @param {Uint8Array|Buffer} data - raw disc image bytes
108
+ */
109
+ loadDiscData(data) {
110
+ this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc, "", data));
111
+ }
112
+
113
+ /**
114
+ * Reset the machine.
115
+ * @param {boolean} hard - true for power-on reset, false for soft reset
116
+ */
117
+ reset(hard) {
118
+ this.processor.reset(hard);
119
+ }
120
+
90
121
  async loadBasic(source) {
91
122
  const tokeniser = await Tokeniser.create();
92
123
  const tokenised = tokeniser.tokenise(source);
@@ -105,157 +136,169 @@ export class TestMachine {
105
136
  this.writebyte(0x13, endHigh);
106
137
  }
107
138
 
139
+ /**
140
+ * Convert an ASCII character to a {code, shift} pair for the BBC keyboard.
141
+ */
142
+ _charToKey(ch) {
143
+ switch (ch) {
144
+ case "\n":
145
+ case "\r":
146
+ return { code: 13, shift: false };
147
+ case '"':
148
+ return { code: utils.keyCodes.K2, shift: true };
149
+ case "*":
150
+ return { code: utils.keyCodes.APOSTROPHE, shift: true };
151
+ case "!":
152
+ return { code: utils.keyCodes.K1, shift: true };
153
+ case ".":
154
+ return { code: utils.keyCodes.PERIOD, shift: false };
155
+ case ";":
156
+ return { code: utils.keyCodes.SEMICOLON, shift: false };
157
+ case ":":
158
+ return { code: utils.keyCodes.APOSTROPHE, shift: false };
159
+ case ",":
160
+ return { code: utils.keyCodes.COMMA, shift: false };
161
+ case "&":
162
+ return { code: utils.keyCodes.K6, shift: true };
163
+ case " ":
164
+ return { code: utils.keyCodes.SPACE, shift: false };
165
+ case "-":
166
+ return { code: utils.keyCodes.MINUS, shift: false };
167
+ case "=":
168
+ return { code: utils.keyCodes.MINUS, shift: true };
169
+ case "+":
170
+ return { code: utils.keyCodes.SEMICOLON, shift: true };
171
+ case "^":
172
+ return { code: utils.keyCodes.EQUALS, shift: false };
173
+ case "~":
174
+ return { code: utils.keyCodes.EQUALS, shift: true };
175
+ case "[":
176
+ return { code: utils.keyCodes.LEFT_SQUARE_BRACKET, shift: false };
177
+ case "]":
178
+ return { code: utils.keyCodes.RIGHT_SQUARE_BRACKET, shift: false };
179
+ case "{":
180
+ return { code: utils.keyCodes.LEFT_SQUARE_BRACKET, shift: true };
181
+ case "}":
182
+ return { code: utils.keyCodes.HASH, shift: true };
183
+ case "\\":
184
+ return { code: utils.keyCodes.BACKSLASH, shift: false };
185
+ case "/":
186
+ return { code: utils.keyCodes.SLASH, shift: false };
187
+ case "?":
188
+ return { code: utils.keyCodes.SLASH, shift: true };
189
+ case "<":
190
+ return { code: utils.keyCodes.COMMA, shift: true };
191
+ case ">":
192
+ return { code: utils.keyCodes.PERIOD, shift: true };
193
+ case "(":
194
+ return { code: utils.keyCodes.K8, shift: true };
195
+ case ")":
196
+ return { code: utils.keyCodes.K9, shift: true };
197
+ case "@":
198
+ return { code: utils.keyCodes.BACK_QUOTE, shift: false };
199
+ case "#":
200
+ return { code: utils.keyCodes.K3, shift: true };
201
+ case "$":
202
+ return { code: utils.keyCodes.K4, shift: true };
203
+ case "%":
204
+ return { code: utils.keyCodes.K5, shift: true };
205
+ default: {
206
+ const upper = ch.toUpperCase();
207
+ const isLetter = (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z");
208
+ if (isLetter) {
209
+ const wantUpper = ch >= "A" && ch <= "Z";
210
+ const capsOn = this.processor.sysvia.capsLockLight;
211
+ // CAPS LOCK on: unshifted = upper, shifted = lower
212
+ // CAPS LOCK off: unshifted = lower, shifted = upper
213
+ const needShift = capsOn ? !wantUpper : wantUpper;
214
+ return { code: upper.charCodeAt(0), shift: needShift };
215
+ }
216
+ return { code: ch.charCodeAt(0), shift: false };
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Type text by installing a debugInstruction hook that presses/releases
223
+ * keys at timed intervals during CPU execution. The hook persists across
224
+ * runFor calls, so breakpoints naturally coexist: if a breakpoint halts
225
+ * execution mid-typing, the remaining characters are typed when execution
226
+ * resumes.
227
+ */
108
228
  async type(text) {
109
- console.log("Typing '" + text + "'");
110
- const cycles = 40 * 1000;
229
+ const fullText = text + "\n"; // append RETURN
230
+ const keys = fullText.split("").map((ch) => this._charToKey(ch));
231
+ const holdCycles = 40000;
232
+ let index = 0;
233
+ let phase = "idle"; // "idle" → "down" → "idle"
234
+ let nextEventCycle = 0;
235
+ let done = false;
111
236
 
112
- const kd = (ch) => {
113
- this.processor.sysvia.keyDown(ch);
114
- return this.runFor(cycles).then(() => {
115
- this.processor.sysvia.keyUp(ch);
116
- return this.runFor(cycles);
117
- });
118
- };
237
+ const currentCycle = () => this.processor.cycleSeconds * 2000000 + this.processor.currentCycles;
119
238
 
120
- const typeChar = (ch) => {
121
- let shift = false;
122
- // Map printable ASCII characters to BBC Micro key codes (UK layout).
123
- // Characters not listed here fall through to the default case which
124
- // uses toUpperCase().charCodeAt(0) — valid for A–Z and 0–9 only.
125
- switch (ch) {
126
- case '"':
127
- ch = utils.keyCodes.K2;
128
- shift = true;
129
- break;
130
- case "*":
131
- ch = utils.keyCodes.APOSTROPHE;
132
- shift = true;
133
- break;
134
- case "!":
135
- ch = utils.keyCodes.K1;
136
- shift = true;
137
- break;
138
- case ".":
139
- ch = utils.keyCodes.PERIOD;
140
- break;
141
- case ";":
142
- ch = utils.keyCodes.SEMICOLON;
143
- break;
144
- case ":":
145
- ch = utils.keyCodes.APOSTROPHE;
146
- break;
147
- case ",":
148
- ch = utils.keyCodes.COMMA;
149
- break;
150
- case "&":
151
- ch = utils.keyCodes.K6;
152
- shift = true;
153
- break;
154
- case " ":
155
- ch = utils.keyCodes.SPACE;
156
- break;
157
- case "-":
158
- ch = utils.keyCodes.MINUS;
159
- break;
160
- case "=":
161
- ch = utils.keyCodes.MINUS;
162
- shift = true;
163
- break;
164
- case "+":
165
- ch = utils.keyCodes.SEMICOLON;
166
- shift = true;
167
- break;
168
- case "^":
169
- ch = utils.keyCodes.EQUALS;
170
- break;
171
- case "~":
172
- ch = utils.keyCodes.EQUALS;
173
- shift = true;
174
- break;
175
- case "[":
176
- ch = utils.keyCodes.LEFT_SQUARE_BRACKET;
177
- break;
178
- case "]":
179
- ch = utils.keyCodes.RIGHT_SQUARE_BRACKET;
180
- break;
181
- case "{":
182
- ch = utils.keyCodes.LEFT_SQUARE_BRACKET;
183
- shift = true;
184
- break;
185
- case "}":
186
- ch = utils.keyCodes.HASH;
187
- shift = true;
188
- break;
189
- case "\\":
190
- ch = utils.keyCodes.BACKSLASH;
191
- break;
192
- case "/":
193
- ch = utils.keyCodes.SLASH;
194
- break;
195
- case "?":
196
- ch = utils.keyCodes.SLASH;
197
- shift = true;
198
- break;
199
- case "<":
200
- ch = utils.keyCodes.COMMA;
201
- shift = true;
202
- break;
203
- case ">":
204
- ch = utils.keyCodes.PERIOD;
205
- shift = true;
206
- break;
207
- case "(":
208
- ch = utils.keyCodes.K8;
209
- shift = true;
210
- break;
211
- case ")":
212
- ch = utils.keyCodes.K9;
213
- shift = true;
214
- break;
215
- case "@":
216
- ch = utils.keyCodes.BACK_QUOTE;
217
- break;
218
- case "#":
219
- ch = utils.keyCodes.K3;
220
- shift = true;
221
- break;
222
- case "$":
223
- ch = utils.keyCodes.K4;
224
- shift = true;
225
- break;
226
- case "%":
227
- ch = utils.keyCodes.K5;
228
- shift = true;
229
- break;
230
- default:
231
- // A-Z and 0-9: ASCII value == DOM keyCode — works as-is.
232
- // Anything else (e.g. '_', '£', '`') is not yet mapped.
233
- ch = ch.toUpperCase().charCodeAt(0);
234
- break;
239
+ const hook = this.processor.debugInstruction.add(() => {
240
+ if (currentCycle() < nextEventCycle) return;
241
+
242
+ if (phase === "down") {
243
+ // Release current key
244
+ const key = keys[index];
245
+ this.processor.sysvia.keyUp(key.code);
246
+ if (key.shift) this.processor.sysvia.keyUp(16);
247
+ index++;
248
+ phase = "idle";
249
+ nextEventCycle = currentCycle() + holdCycles;
250
+ return;
235
251
  }
236
- if (shift) {
237
- this.processor.sysvia.keyDown(16);
238
- return this.runFor(cycles).then(() => {
239
- return kd(ch).then(() => {
240
- this.processor.sysvia.keyUp(16);
241
- return this.runFor(cycles);
242
- });
243
- });
244
- } else {
245
- return kd(ch);
252
+
253
+ // phase === "idle"
254
+ if (index >= keys.length) {
255
+ hook.remove();
256
+ done = true;
257
+ return;
246
258
  }
247
- };
248
259
 
249
- return text
250
- .split("")
251
- .reduce(function (p, char) {
252
- return p.then(function () {
253
- return typeChar(char);
254
- });
255
- }, Promise.resolve())
256
- .then(function () {
257
- return kd(13);
258
- });
260
+ // Press next key
261
+ const key = keys[index];
262
+ if (key.shift) this.processor.sysvia.keyDown(16);
263
+ this.processor.sysvia.keyDown(key.code);
264
+ phase = "down";
265
+ nextEventCycle = currentCycle() + holdCycles;
266
+ });
267
+
268
+ // Drive execution in chunks until all characters are typed or
269
+ // a breakpoint halts the CPU.
270
+ while (!done) {
271
+ const stopped = await this.runFor(holdCycles);
272
+ if (stopped) break;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Press a key on the BBC keyboard.
278
+ * @param {number} code - BBC key code (e.g. utils.keyCodes.SPACE)
279
+ */
280
+ keyDown(code) {
281
+ this.processor.sysvia.keyDown(code);
282
+ }
283
+
284
+ /**
285
+ * Release a key on the BBC keyboard.
286
+ * @param {number} code - BBC key code
287
+ */
288
+ keyUp(code) {
289
+ this.processor.sysvia.keyUp(code);
290
+ }
291
+
292
+ /**
293
+ * Load a ROM image directly into a sideways RAM slot.
294
+ * @param {number} slot - slot number (0-15, typically 4-7 for SWRAM)
295
+ * @param {Uint8Array|Buffer} data - ROM data (up to 16384 bytes)
296
+ */
297
+ loadSidewaysRam(slot, data) {
298
+ const offset = this.processor.romOffset + slot * 16384;
299
+ for (let i = 0; i < data.length && i < 16384; i++) {
300
+ this.processor.ramRomOs[offset + i] = data[i];
301
+ }
259
302
  }
260
303
 
261
304
  writebyte(addr, val) {