jsbeeb 1.8.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 +65 -0
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;
|
|
@@ -118,6 +166,23 @@ export class TestMachine {
|
|
|
118
166
|
this.processor.reset(hard);
|
|
119
167
|
}
|
|
120
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
|
+
|
|
121
186
|
async loadBasic(source) {
|
|
122
187
|
const tokeniser = await Tokeniser.create();
|
|
123
188
|
const tokenised = tokeniser.tokenise(source);
|