jsbeeb 1.7.0 → 1.9.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/package.json +1 -1
- package/src/6502.js +4 -2
- package/src/disc.js +4 -1
- package/src/utils.js +10 -1
- package/src/via.js +39 -7
- package/tests/test-machine.js +123 -3
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"name": "jsbeeb",
|
|
8
8
|
"description": "Emulate a BBC Micro",
|
|
9
9
|
"repository": "git@github.com:mattgodbolt/jsbeeb.git",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.9.0",
|
|
11
11
|
"//": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": "22"
|
package/src/6502.js
CHANGED
|
@@ -1131,7 +1131,7 @@ export class Cpu6502 extends Base6502 {
|
|
|
1131
1131
|
this.resetLine = !resetOn;
|
|
1132
1132
|
}
|
|
1133
1133
|
|
|
1134
|
-
snapshotState() {
|
|
1134
|
+
snapshotState({ includeRoms = false } = {}) {
|
|
1135
1135
|
return {
|
|
1136
1136
|
// CPU registers
|
|
1137
1137
|
a: this.a,
|
|
@@ -1155,8 +1155,10 @@ export class Cpu6502 extends Base6502 {
|
|
|
1155
1155
|
peripheralCycles: this.peripheralCycles,
|
|
1156
1156
|
videoCycles: this.videoCycles,
|
|
1157
1157
|
music5000PageSel: this.music5000PageSel,
|
|
1158
|
-
// RAM only
|
|
1158
|
+
// RAM only by default; pass includeRoms:true to also capture
|
|
1159
|
+
// sideways ROM/RAM slots (adds ~256KB to the snapshot).
|
|
1159
1160
|
ram: this.ramRomOs.slice(0, this.romOffset),
|
|
1161
|
+
roms: includeRoms ? this.ramRomOs.slice(this.romOffset, this.romOffset + 16 * 16384) : undefined,
|
|
1160
1162
|
// Sub-component state
|
|
1161
1163
|
scheduler: this.scheduler.snapshotState(),
|
|
1162
1164
|
sysvia: this.sysvia.snapshotState(),
|
package/src/disc.js
CHANGED
|
@@ -535,7 +535,10 @@ export function loadSsd(disc, data, isDsd, onChange) {
|
|
|
535
535
|
const blankSector = new Uint8Array(SsdFormat.sectorSize);
|
|
536
536
|
const numSides = isDsd ? 2 : 1;
|
|
537
537
|
if (data.length % SsdFormat.sectorSize !== 0) {
|
|
538
|
-
|
|
538
|
+
const paddedLength = Math.ceil(data.length / SsdFormat.sectorSize) * SsdFormat.sectorSize;
|
|
539
|
+
const padded = new Uint8Array(paddedLength);
|
|
540
|
+
padded.set(data);
|
|
541
|
+
data = padded;
|
|
539
542
|
}
|
|
540
543
|
const maxSize = SsdFormat.sectorSize * SsdFormat.sectorsPerTrack * SsdFormat.tracksPerDisc * numSides;
|
|
541
544
|
if (data.length > maxSize) {
|
package/src/utils.js
CHANGED
|
@@ -437,6 +437,14 @@ export function getKeyMap(keyLayout) {
|
|
|
437
437
|
// shift not pressed
|
|
438
438
|
keys2[false] = {};
|
|
439
439
|
|
|
440
|
+
// Create a key map entry that overrides the BBC SHIFT state while held.
|
|
441
|
+
// Used in natural keyboard for keys where the PC and BBC shift states
|
|
442
|
+
// differ for the same character (e.g. US Shift+6 = ^ needs BBC HAT_TILDE
|
|
443
|
+
// without shift, even though the physical shift key is held).
|
|
444
|
+
function withShiftOverride(bbcKey, bbcShift) {
|
|
445
|
+
return [bbcKey[0], bbcKey[1], bbcShift];
|
|
446
|
+
}
|
|
447
|
+
|
|
440
448
|
// shiftDown MUST be true or false (not undefined)
|
|
441
449
|
function doMap(s, colRow, shiftDown) {
|
|
442
450
|
if (keys2[shiftDown][s] && keys2[shiftDown][s] !== colRow) {
|
|
@@ -554,7 +562,8 @@ export function getKeyMap(keyLayout) {
|
|
|
554
562
|
map(keyCodes.K1, BBC.K1);
|
|
555
563
|
map(keyCodes.K4, BBC.K4);
|
|
556
564
|
map(keyCodes.K5, BBC.K5);
|
|
557
|
-
map(keyCodes.K6, BBC.K6);
|
|
565
|
+
map(keyCodes.K6, BBC.K6, false);
|
|
566
|
+
map(keyCodes.K6, withShiftOverride(BBC.HAT_TILDE, false), true);
|
|
558
567
|
|
|
559
568
|
map(keyCodes.MINUS, BBC.MINUS);
|
|
560
569
|
|
package/src/via.js
CHANGED
|
@@ -541,6 +541,9 @@ export class SysVia extends Via {
|
|
|
541
541
|
this.mouseButton1 = false;
|
|
542
542
|
this.mouseButton2 = false;
|
|
543
543
|
this.keyboardEnabled = true;
|
|
544
|
+
this._physicalShiftDown = false;
|
|
545
|
+
this._shiftOverrideActive = false;
|
|
546
|
+
this._shiftOverrideDesiredShift = false;
|
|
544
547
|
this.setKeyLayout(initialLayout);
|
|
545
548
|
this.video = video;
|
|
546
549
|
this.soundChip = soundChip;
|
|
@@ -589,6 +592,9 @@ export class SysVia extends Via {
|
|
|
589
592
|
this.keys[i][j] = false;
|
|
590
593
|
}
|
|
591
594
|
}
|
|
595
|
+
this._physicalShiftDown = false;
|
|
596
|
+
this._shiftOverrideActive = false;
|
|
597
|
+
this._shiftOverrideDesiredShift = false;
|
|
592
598
|
this.updateKeys();
|
|
593
599
|
}
|
|
594
600
|
|
|
@@ -604,9 +610,34 @@ export class SysVia extends Via {
|
|
|
604
610
|
|
|
605
611
|
set(key, val, shiftDown) {
|
|
606
612
|
if (!this.keyboardEnabled) return;
|
|
607
|
-
const
|
|
608
|
-
if (!
|
|
609
|
-
|
|
613
|
+
const mapping = this.keycodeToRowCol[!!shiftDown][key];
|
|
614
|
+
if (!mapping) return;
|
|
615
|
+
|
|
616
|
+
const [col, row, bbcShiftOverride] = mapping;
|
|
617
|
+
const [shiftCol, shiftRow] = utils.BBC.SHIFT;
|
|
618
|
+
|
|
619
|
+
this.keys[col][row] = val;
|
|
620
|
+
|
|
621
|
+
const isPhysicalShiftKey = col === shiftCol && row === shiftRow && bbcShiftOverride === undefined;
|
|
622
|
+
|
|
623
|
+
if (isPhysicalShiftKey) {
|
|
624
|
+
this._physicalShiftDown = !!val;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (bbcShiftOverride !== undefined) {
|
|
628
|
+
this._shiftOverrideActive = !!val;
|
|
629
|
+
this._shiftOverrideDesiredShift = bbcShiftOverride;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// When a shift override is active, force BBC SHIFT to the desired
|
|
633
|
+
// state; otherwise follow the physical shift key.
|
|
634
|
+
// Note: keyDownRaw/keyUpRaw bypass this logic intentionally as they
|
|
635
|
+
// are used by gamepad/paste code that manages shift independently.
|
|
636
|
+
if (this._shiftOverrideActive || isPhysicalShiftKey || bbcShiftOverride !== undefined) {
|
|
637
|
+
const shiftPressed = this._shiftOverrideActive ? this._shiftOverrideDesiredShift : this._physicalShiftDown;
|
|
638
|
+
this.keys[shiftCol][shiftRow] = shiftPressed ? 1 : 0;
|
|
639
|
+
}
|
|
640
|
+
|
|
610
641
|
this.updateKeys();
|
|
611
642
|
}
|
|
612
643
|
|
|
@@ -765,10 +796,11 @@ export class SysVia extends Via {
|
|
|
765
796
|
const pad2 = pads[1];
|
|
766
797
|
|
|
767
798
|
// Combine gamepad and mouse button states (OR logic)
|
|
768
|
-
button1 = button1 || pad.buttons[10]
|
|
769
|
-
//
|
|
770
|
-
|
|
771
|
-
|
|
799
|
+
button1 = button1 || !!pad.buttons[10]?.pressed;
|
|
800
|
+
// FIRE2 on first gamepad always maps to button2
|
|
801
|
+
button2 = button2 || !!pad.buttons[11]?.pressed;
|
|
802
|
+
// If two gamepads, FIRE1 on second gamepad also maps to button2
|
|
803
|
+
if (pad2) button2 = button2 || !!pad2.buttons[10]?.pressed;
|
|
772
804
|
}
|
|
773
805
|
|
|
774
806
|
return { button1: button1, button2: button2 };
|
package/tests/test-machine.js
CHANGED
|
@@ -11,12 +11,60 @@ export class TestMachine {
|
|
|
11
11
|
constructor(model, opts) {
|
|
12
12
|
model = model || "B-DFS1.2";
|
|
13
13
|
this.processor = fake6502(findModel(model), opts || {});
|
|
14
|
+
this._capturedChars = [];
|
|
15
|
+
this._captureHookInstalled = false;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
async initialise() {
|
|
17
19
|
await this.processor.initialise();
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Install the character capture hook (once). All characters sent
|
|
24
|
+
* through WRCHV are accumulated and can be read with drainText().
|
|
25
|
+
* Safe to call multiple times — only installs one hook.
|
|
26
|
+
*/
|
|
27
|
+
startCapture() {
|
|
28
|
+
if (this._captureHookInstalled) return;
|
|
29
|
+
this._captureHookInstalled = true;
|
|
30
|
+
this.processor.debugInstruction.add((addr) => {
|
|
31
|
+
const wrchv = this.readword(0x20e);
|
|
32
|
+
if (addr === wrchv) {
|
|
33
|
+
this._capturedChars.push(this.processor.a);
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Return all captured characters since the last drain (or since
|
|
41
|
+
* startCapture was called), then clear the buffer.
|
|
42
|
+
* @returns {number[]} array of character codes
|
|
43
|
+
*/
|
|
44
|
+
drainCapturedChars() {
|
|
45
|
+
const chars = this._capturedChars;
|
|
46
|
+
this._capturedChars = [];
|
|
47
|
+
return chars;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Return captured text as a string (printable chars only, with
|
|
52
|
+
* optional newline preservation), then clear the buffer.
|
|
53
|
+
* @param {Object} [opts]
|
|
54
|
+
* @param {boolean} [opts.raw=false] - if true, preserve newlines
|
|
55
|
+
*/
|
|
56
|
+
drainText({ raw = false } = {}) {
|
|
57
|
+
const chars = this.drainCapturedChars();
|
|
58
|
+
return chars
|
|
59
|
+
.map((c) => {
|
|
60
|
+
if (raw && c === 10) return "\n";
|
|
61
|
+
if (c === 13) return "";
|
|
62
|
+
if (c >= 0x20 && c < 0x7f) return String.fromCharCode(c);
|
|
63
|
+
return "";
|
|
64
|
+
})
|
|
65
|
+
.join("");
|
|
66
|
+
}
|
|
67
|
+
|
|
20
68
|
runFor(cycles) {
|
|
21
69
|
let left = cycles;
|
|
22
70
|
let stopped = false;
|
|
@@ -102,6 +150,39 @@ export class TestMachine {
|
|
|
102
150
|
this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc, "", data));
|
|
103
151
|
}
|
|
104
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Load a disc image from raw data (Uint8Array or Buffer).
|
|
155
|
+
* @param {Uint8Array|Buffer} data - raw disc image bytes
|
|
156
|
+
*/
|
|
157
|
+
loadDiscData(data) {
|
|
158
|
+
this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc, "", data));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Reset the machine.
|
|
163
|
+
* @param {boolean} hard - true for power-on reset, false for soft reset
|
|
164
|
+
*/
|
|
165
|
+
reset(hard) {
|
|
166
|
+
this.processor.reset(hard);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Take a snapshot of the entire machine state (CPU, RAM, SWRAM,
|
|
171
|
+
* VIAs, video, FDC, etc). Returns an opaque state object that
|
|
172
|
+
* can be passed to restore().
|
|
173
|
+
*/
|
|
174
|
+
snapshot({ includeRoms = true } = {}) {
|
|
175
|
+
return this.processor.snapshotState({ includeRoms });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Restore a previously saved snapshot. The machine will be in
|
|
180
|
+
* exactly the state it was when snapshot() was called.
|
|
181
|
+
*/
|
|
182
|
+
restore(state) {
|
|
183
|
+
this.processor.restoreState(state);
|
|
184
|
+
}
|
|
185
|
+
|
|
105
186
|
async loadBasic(source) {
|
|
106
187
|
const tokeniser = await Tokeniser.create();
|
|
107
188
|
const tokenised = tokeniser.tokenise(source);
|
|
@@ -186,8 +267,19 @@ export class TestMachine {
|
|
|
186
267
|
return { code: utils.keyCodes.K4, shift: true };
|
|
187
268
|
case "%":
|
|
188
269
|
return { code: utils.keyCodes.K5, shift: true };
|
|
189
|
-
default:
|
|
190
|
-
|
|
270
|
+
default: {
|
|
271
|
+
const upper = ch.toUpperCase();
|
|
272
|
+
const isLetter = (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z");
|
|
273
|
+
if (isLetter) {
|
|
274
|
+
const wantUpper = ch >= "A" && ch <= "Z";
|
|
275
|
+
const capsOn = this.processor.sysvia.capsLockLight;
|
|
276
|
+
// CAPS LOCK on: unshifted = upper, shifted = lower
|
|
277
|
+
// CAPS LOCK off: unshifted = lower, shifted = upper
|
|
278
|
+
const needShift = capsOn ? !wantUpper : wantUpper;
|
|
279
|
+
return { code: upper.charCodeAt(0), shift: needShift };
|
|
280
|
+
}
|
|
281
|
+
return { code: ch.charCodeAt(0), shift: false };
|
|
282
|
+
}
|
|
191
283
|
}
|
|
192
284
|
}
|
|
193
285
|
|
|
@@ -201,7 +293,7 @@ export class TestMachine {
|
|
|
201
293
|
async type(text) {
|
|
202
294
|
const fullText = text + "\n"; // append RETURN
|
|
203
295
|
const keys = fullText.split("").map((ch) => this._charToKey(ch));
|
|
204
|
-
const holdCycles = 40000;
|
|
296
|
+
const holdCycles = 40000;
|
|
205
297
|
let index = 0;
|
|
206
298
|
let phase = "idle"; // "idle" → "down" → "idle"
|
|
207
299
|
let nextEventCycle = 0;
|
|
@@ -246,6 +338,34 @@ export class TestMachine {
|
|
|
246
338
|
}
|
|
247
339
|
}
|
|
248
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Press a key on the BBC keyboard.
|
|
343
|
+
* @param {number} code - BBC key code (e.g. utils.keyCodes.SPACE)
|
|
344
|
+
*/
|
|
345
|
+
keyDown(code) {
|
|
346
|
+
this.processor.sysvia.keyDown(code);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Release a key on the BBC keyboard.
|
|
351
|
+
* @param {number} code - BBC key code
|
|
352
|
+
*/
|
|
353
|
+
keyUp(code) {
|
|
354
|
+
this.processor.sysvia.keyUp(code);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Load a ROM image directly into a sideways RAM slot.
|
|
359
|
+
* @param {number} slot - slot number (0-15, typically 4-7 for SWRAM)
|
|
360
|
+
* @param {Uint8Array|Buffer} data - ROM data (up to 16384 bytes)
|
|
361
|
+
*/
|
|
362
|
+
loadSidewaysRam(slot, data) {
|
|
363
|
+
const offset = this.processor.romOffset + slot * 16384;
|
|
364
|
+
for (let i = 0; i < data.length && i < 16384; i++) {
|
|
365
|
+
this.processor.ramRomOs[offset + i] = data[i];
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
249
369
|
writebyte(addr, val) {
|
|
250
370
|
this.processor.writemem(addr, val);
|
|
251
371
|
}
|