jsbeeb 1.5.0 → 1.7.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/README.md +22 -2
- package/package.json +3 -2
- package/src/6502.js +119 -6
- package/src/6502.opcodes.js +21 -8
- package/src/acia.js +42 -0
- package/src/adc.js +22 -0
- package/src/app/app.js +29 -0
- package/src/app/electron.js +10 -1
- package/src/app/preload.js +1 -0
- package/src/bem-snapshot.js +681 -0
- package/src/disc-drive.js +33 -0
- package/src/disc.js +176 -22
- package/src/fdc.js +3 -1
- package/src/intel-fdc.js +65 -0
- package/src/jsbeeb.css +66 -0
- package/src/machine-session.js +91 -0
- package/src/main.js +231 -6
- package/src/rewind-thumbnail.js +118 -0
- package/src/rewind-ui.js +230 -0
- package/src/rewind.js +84 -0
- package/src/scheduler.js +28 -0
- package/src/snapshot.js +143 -0
- package/src/soundchip.js +40 -5
- package/src/state-utils.js +66 -0
- package/src/teletext.js +69 -0
- package/src/via.js +91 -0
- package/src/video.js +143 -0
- package/src/wd-fdc.js +99 -2
- package/tests/test-machine.js +134 -146
package/src/disc.js
CHANGED
|
@@ -3,30 +3,23 @@
|
|
|
3
3
|
|
|
4
4
|
import * as utils from "./utils.js";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
this._crc = this._crc >>> 1;
|
|
20
|
-
if (doEor) this._crc ^= 0xedb88320;
|
|
21
|
-
}
|
|
6
|
+
/**
|
|
7
|
+
* Compute CRC32 of a Uint8Array.
|
|
8
|
+
* @param {Uint8Array} data
|
|
9
|
+
* @returns {number} CRC32 as a signed 32-bit integer
|
|
10
|
+
*/
|
|
11
|
+
export function crc32(data) {
|
|
12
|
+
let crc = 0xffffffff;
|
|
13
|
+
for (let i = 0; i < data.length; ++i) {
|
|
14
|
+
crc ^= data[i];
|
|
15
|
+
for (let j = 0; j < 8; ++j) {
|
|
16
|
+
const doEor = crc & 1;
|
|
17
|
+
crc = crc >>> 1;
|
|
18
|
+
if (doEor) crc ^= 0xedb88320;
|
|
22
19
|
}
|
|
23
20
|
}
|
|
24
|
-
|
|
25
|
-
get crc() {
|
|
26
|
-
return ~this._crc;
|
|
27
|
-
}
|
|
21
|
+
return ~crc;
|
|
28
22
|
}
|
|
29
|
-
*/
|
|
30
23
|
class TrackBuilder {
|
|
31
24
|
/**
|
|
32
25
|
* @param {Track} track
|
|
@@ -747,6 +740,27 @@ export class Disc {
|
|
|
747
740
|
this.writeTrackCallback = undefined;
|
|
748
741
|
this.isWriteable = isWriteable;
|
|
749
742
|
|
|
743
|
+
// Track which tracks have been written since the last snapshot.
|
|
744
|
+
// Keys are numeric: (track | (isSideUpper ? 0x100 : 0)).
|
|
745
|
+
this._snapshotDirtyTracks = new Set();
|
|
746
|
+
// Cumulative set of all tracks ever written since disc load (never cleared by snapshots).
|
|
747
|
+
// Same key encoding as _snapshotDirtyTracks.
|
|
748
|
+
this._everDirtyTracks = new Set();
|
|
749
|
+
// Cache of the previous snapshot's track data for structural sharing.
|
|
750
|
+
// Keys are "side:trackNum" strings, values are {pulses2Us, length} objects.
|
|
751
|
+
this._lastTrackSnapshots = Object.create(null);
|
|
752
|
+
|
|
753
|
+
// Original disc image data, stored for embedding in save-to-file snapshots
|
|
754
|
+
// and CRC32 verification on restore. _originalImageData is only set for
|
|
755
|
+
// local-file discs (not URL-sourced ones). _originalImageCrc32 is the CRC
|
|
756
|
+
// of the image bytes at load time — it is not updated if an onChange handler
|
|
757
|
+
// mutates the backing store. This is fine because the CRC is compared against
|
|
758
|
+
// the same source that would be reloaded (the original image or URL), not the
|
|
759
|
+
// mutated copy. If a mutable source URL were added in future, CRC verification
|
|
760
|
+
// should be skipped or the CRC updated accordingly.
|
|
761
|
+
this._originalImageData = null;
|
|
762
|
+
this._originalImageCrc32 = null;
|
|
763
|
+
|
|
750
764
|
this.initSurface(0);
|
|
751
765
|
}
|
|
752
766
|
|
|
@@ -754,6 +768,39 @@ export class Disc {
|
|
|
754
768
|
this.writeTrackCallback = callback;
|
|
755
769
|
}
|
|
756
770
|
|
|
771
|
+
/**
|
|
772
|
+
* Record the CRC32 of the original disc image for verification on restore.
|
|
773
|
+
* Called by discFor() after loading. Does not retain the image bytes.
|
|
774
|
+
* @param {Uint8Array} data - the raw disc image bytes (used only to compute CRC32)
|
|
775
|
+
*/
|
|
776
|
+
setOriginalImageCrc32(data) {
|
|
777
|
+
this._originalImageCrc32 = crc32(data);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Store the original disc image bytes for embedding in snapshots.
|
|
782
|
+
* Only call this for local-file discs — URL-sourced discs should use
|
|
783
|
+
* setOriginalImageCrc32() instead to avoid retaining the full image.
|
|
784
|
+
* Computes CRC32 only if not already set (e.g. by discFor).
|
|
785
|
+
* @param {Uint8Array} data - the raw disc image bytes
|
|
786
|
+
*/
|
|
787
|
+
setOriginalImage(data) {
|
|
788
|
+
this._originalImageData = data;
|
|
789
|
+
if (this._originalImageCrc32 == null) {
|
|
790
|
+
this._originalImageCrc32 = crc32(data);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** @returns {Uint8Array|null} the original disc image bytes, or null if not set */
|
|
795
|
+
get originalImageData() {
|
|
796
|
+
return this._originalImageData;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** @returns {number|null} CRC32 of the original disc image, or null if not set */
|
|
800
|
+
get originalImageCrc32() {
|
|
801
|
+
return this._originalImageCrc32;
|
|
802
|
+
}
|
|
803
|
+
|
|
757
804
|
get writeProtected() {
|
|
758
805
|
return !this.isWriteable;
|
|
759
806
|
}
|
|
@@ -797,7 +844,9 @@ export class Disc {
|
|
|
797
844
|
writePulses(isSideUpper, track, position, pulses) {
|
|
798
845
|
const trackObj = this.getTrack(isSideUpper, track);
|
|
799
846
|
if (position >= trackObj.length)
|
|
800
|
-
throw new Error(
|
|
847
|
+
throw new Error(
|
|
848
|
+
`Attempt to write off end of track ${track}: position ${position} >= length ${trackObj.length}`,
|
|
849
|
+
);
|
|
801
850
|
if (this.isDirty) {
|
|
802
851
|
if (isSideUpper !== this.dirtySide || track !== this.dirtyTrack)
|
|
803
852
|
throw new Error("Switched dirty track or side");
|
|
@@ -805,6 +854,11 @@ export class Disc {
|
|
|
805
854
|
this.isDirty = true;
|
|
806
855
|
this.dirtySide = isSideUpper;
|
|
807
856
|
this.dirtyTrack = track;
|
|
857
|
+
// Numeric key avoids string allocation on every pulse write.
|
|
858
|
+
// Upper bit encodes side, lower 8 bits encode track number.
|
|
859
|
+
const dirtyKey = track | (isSideUpper ? 0x100 : 0);
|
|
860
|
+
this._snapshotDirtyTracks.add(dirtyKey);
|
|
861
|
+
this._everDirtyTracks.add(dirtyKey);
|
|
808
862
|
trackObj.pulses2Us[position] = pulses;
|
|
809
863
|
// TODO a debug log flag for this
|
|
810
864
|
// console.log(`wrote to ${track}:${position * 32}`);
|
|
@@ -827,6 +881,106 @@ export class Disc {
|
|
|
827
881
|
this.setTrackUsed(dirtySide, dirtyTrack);
|
|
828
882
|
}
|
|
829
883
|
|
|
884
|
+
/**
|
|
885
|
+
* Create a snapshot of all track data with structural sharing.
|
|
886
|
+
* Clean tracks reuse references from the previous snapshot;
|
|
887
|
+
* dirty tracks get fresh copies.
|
|
888
|
+
*/
|
|
889
|
+
snapshotState() {
|
|
890
|
+
const numSides = this.isDoubleSided ? 2 : 1;
|
|
891
|
+
// Use a plain object for JSON serialization compatibility.
|
|
892
|
+
// Keys are "side:trackNum" strings.
|
|
893
|
+
const tracks = Object.create(null);
|
|
894
|
+
for (let side = 0; side < numSides; ++side) {
|
|
895
|
+
for (let trackNum = 0; trackNum < this.tracksUsed; ++trackNum) {
|
|
896
|
+
const key = `${side === 1}:${trackNum}`;
|
|
897
|
+
const dirtyKey = trackNum | (side === 1 ? 0x100 : 0);
|
|
898
|
+
const trackObj = this.getTrack(side === 1, trackNum);
|
|
899
|
+
if (this._snapshotDirtyTracks.has(dirtyKey) || !this._lastTrackSnapshots[key]) {
|
|
900
|
+
// Dirty or first snapshot: copy the track data
|
|
901
|
+
tracks[key] = {
|
|
902
|
+
pulses2Us: trackObj.pulses2Us.slice(),
|
|
903
|
+
length: trackObj.length,
|
|
904
|
+
};
|
|
905
|
+
} else {
|
|
906
|
+
// Clean: reuse the previous snapshot's reference
|
|
907
|
+
tracks[key] = this._lastTrackSnapshots[key];
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
this._snapshotDirtyTracks.clear();
|
|
913
|
+
this._lastTrackSnapshots = tracks;
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
tracksUsed: this.tracksUsed,
|
|
917
|
+
isDoubleSided: this.isDoubleSided,
|
|
918
|
+
isWriteable: this.isWriteable,
|
|
919
|
+
name: this.name,
|
|
920
|
+
tracks,
|
|
921
|
+
// Expose for save-to-file snapshots to identify dirty tracks.
|
|
922
|
+
// This is a Set and won't survive JSON serialization — it's only
|
|
923
|
+
// used by createSnapshot() before the snapshot is serialized.
|
|
924
|
+
_everDirtyTracks: new Set(this._everDirtyTracks),
|
|
925
|
+
_originalImageData: this._originalImageData,
|
|
926
|
+
_originalImageCrc32: this._originalImageCrc32,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Restore disc track data from a snapshot.
|
|
932
|
+
* For save-to-file snapshots, `state.tracks` is empty and `state.dirtyTracks`
|
|
933
|
+
* contains only the tracks modified since disc load. The base disc data must
|
|
934
|
+
* already be loaded before calling this method.
|
|
935
|
+
*/
|
|
936
|
+
restoreState(state) {
|
|
937
|
+
this.tracksUsed = state.tracksUsed;
|
|
938
|
+
this.isDoubleSided = state.isDoubleSided;
|
|
939
|
+
this.isWriteable = state.isWriteable;
|
|
940
|
+
this.name = state.name;
|
|
941
|
+
|
|
942
|
+
// Reset write-in-progress state
|
|
943
|
+
this.isDirty = false;
|
|
944
|
+
this.dirtySide = -1;
|
|
945
|
+
this.dirtyTrack = -1;
|
|
946
|
+
this._snapshotDirtyTracks.clear();
|
|
947
|
+
// Restore _everDirtyTracks from the snapshot if present (rewind path —
|
|
948
|
+
// the Set is carried in-memory but won't survive JSON serialization).
|
|
949
|
+
// For the save-to-file path, _everDirtyTracks is rebuilt from dirtyTracks below.
|
|
950
|
+
if (state._everDirtyTracks instanceof Set) {
|
|
951
|
+
this._everDirtyTracks = new Set(state._everDirtyTracks);
|
|
952
|
+
} else {
|
|
953
|
+
this._everDirtyTracks.clear();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Restore full track data (rewind path — tracks contains all data)
|
|
957
|
+
this._lastTrackSnapshots = state.tracks;
|
|
958
|
+
for (const key of Object.keys(state.tracks)) {
|
|
959
|
+
const trackData = state.tracks[key];
|
|
960
|
+
const [sideStr, trackNumStr] = key.split(":");
|
|
961
|
+
const isSideUpper = sideStr === "true";
|
|
962
|
+
const trackNum = parseInt(trackNumStr, 10);
|
|
963
|
+
const trackObj = this.getTrack(isSideUpper, trackNum);
|
|
964
|
+
trackObj.pulses2Us.set(trackData.pulses2Us);
|
|
965
|
+
trackObj.length = trackData.length;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Apply dirty track overlays (save-to-file path — base disc already loaded,
|
|
969
|
+
// overlay only the tracks that were written since disc load)
|
|
970
|
+
if (state.dirtyTracks) {
|
|
971
|
+
for (const key of Object.keys(state.dirtyTracks)) {
|
|
972
|
+
const trackData = state.dirtyTracks[key];
|
|
973
|
+
const [sideStr, trackNumStr] = key.split(":");
|
|
974
|
+
const isSideUpper = sideStr === "true";
|
|
975
|
+
const trackNum = parseInt(trackNumStr, 10);
|
|
976
|
+
const trackObj = this.getTrack(isSideUpper, trackNum);
|
|
977
|
+
trackObj.pulses2Us.set(trackData.pulses2Us);
|
|
978
|
+
trackObj.length = trackData.length;
|
|
979
|
+
this._everDirtyTracks.add(trackNum | (isSideUpper ? 0x100 : 0));
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
830
984
|
logSummary() {
|
|
831
985
|
const maxTrack = this.tracksUsed;
|
|
832
986
|
const numSides = this.isDoubleSided ? 2 : 1;
|
package/src/fdc.js
CHANGED
|
@@ -212,7 +212,9 @@ export function guessDiscTypeFromName(name) {
|
|
|
212
212
|
*/
|
|
213
213
|
export function discFor(fdc, name, stringData, onChange) {
|
|
214
214
|
const data = typeof stringData !== "string" ? stringData : utils.stringToUint8Array(stringData);
|
|
215
|
-
|
|
215
|
+
const disc = guessDiscTypeFromName(name).loader(new Disc(true, new DiscConfig(), name), data, onChange);
|
|
216
|
+
disc.setOriginalImageCrc32(data instanceof Uint8Array ? data : new Uint8Array(data));
|
|
217
|
+
return disc;
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
export function localDisc(fdc, name) {
|
package/src/intel-fdc.js
CHANGED
|
@@ -1670,6 +1670,71 @@ export class IntelFdc {
|
|
|
1670
1670
|
this._doWriteRun(callContext, 0xff);
|
|
1671
1671
|
}
|
|
1672
1672
|
|
|
1673
|
+
snapshotState() {
|
|
1674
|
+
const scheduler = this._timerTask.scheduler;
|
|
1675
|
+
return {
|
|
1676
|
+
regs: this._regs.slice(),
|
|
1677
|
+
status: this._status,
|
|
1678
|
+
isResultReady: this._isResultReady,
|
|
1679
|
+
mmioData: this._mmioData,
|
|
1680
|
+
mmioClocks: this._mmioClocks,
|
|
1681
|
+
driveOut: this._driveOut,
|
|
1682
|
+
shiftRegister: this._shiftRegister,
|
|
1683
|
+
numShifts: this._numShifts,
|
|
1684
|
+
state: this._state,
|
|
1685
|
+
stateCount: this._stateCount,
|
|
1686
|
+
stateIsIndexPulse: this._stateIsIndexPulse,
|
|
1687
|
+
crc: this._crc,
|
|
1688
|
+
onDiscCrc: this._onDiscCrc,
|
|
1689
|
+
paramCallback: this._paramCallback,
|
|
1690
|
+
indexPulseCallback: this._indexPulseCallback,
|
|
1691
|
+
timerState: this._timerState,
|
|
1692
|
+
callContext: this._callContext,
|
|
1693
|
+
didSeekStep: this._didSeekStep,
|
|
1694
|
+
timerTaskOffset: this._timerTask.scheduled() ? this._timerTask.expireEpoch - scheduler.epoch : null,
|
|
1695
|
+
drives: this._drives.map((d) => d.snapshotState()),
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
restoreState(state) {
|
|
1700
|
+
this._regs.set(state.regs);
|
|
1701
|
+
this._status = state.status;
|
|
1702
|
+
this._isResultReady = state.isResultReady;
|
|
1703
|
+
this._mmioData = state.mmioData;
|
|
1704
|
+
this._mmioClocks = state.mmioClocks;
|
|
1705
|
+
this._driveOut = state.driveOut;
|
|
1706
|
+
this._shiftRegister = state.shiftRegister;
|
|
1707
|
+
this._numShifts = state.numShifts;
|
|
1708
|
+
this._state = state.state;
|
|
1709
|
+
this._stateCount = state.stateCount;
|
|
1710
|
+
this._stateIsIndexPulse = state.stateIsIndexPulse;
|
|
1711
|
+
this._crc = state.crc;
|
|
1712
|
+
this._onDiscCrc = state.onDiscCrc;
|
|
1713
|
+
this._paramCallback = state.paramCallback;
|
|
1714
|
+
this._indexPulseCallback = state.indexPulseCallback;
|
|
1715
|
+
this._timerState = state.timerState;
|
|
1716
|
+
this._callContext = state.callContext;
|
|
1717
|
+
this._didSeekStep = state.didSeekStep;
|
|
1718
|
+
|
|
1719
|
+
// Restore drives
|
|
1720
|
+
for (let i = 0; i < this._drives.length; i++) {
|
|
1721
|
+
this._drives[i].restoreState(state.drives[i]);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Derive _currentDrive from _driveOut
|
|
1725
|
+
this._currentDrive = null;
|
|
1726
|
+
const selectBits = this._driveOut & DriveOut.selectFlags;
|
|
1727
|
+
if (selectBits === DriveOut.select_0) this._currentDrive = this._drives[0];
|
|
1728
|
+
else if (selectBits === DriveOut.select_1) this._currentDrive = this._drives[1];
|
|
1729
|
+
|
|
1730
|
+
// Restore timer
|
|
1731
|
+
this._timerTask.cancel();
|
|
1732
|
+
if (state.timerTaskOffset !== null) this._timerTask.schedule(state.timerTaskOffset);
|
|
1733
|
+
|
|
1734
|
+
// NMI level is saved/restored by the CPU snapshot directly,
|
|
1735
|
+
// so we don't reassert it here.
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1673
1738
|
/// jsbeeb compatibility stuff
|
|
1674
1739
|
/**
|
|
1675
1740
|
*
|
package/src/jsbeeb.css
CHANGED
|
@@ -352,6 +352,72 @@ small {
|
|
|
352
352
|
pointer-events: auto;
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
/* Rewind scrubber panel */
|
|
356
|
+
#rewind-panel {
|
|
357
|
+
position: fixed;
|
|
358
|
+
bottom: 40px;
|
|
359
|
+
left: 0;
|
|
360
|
+
right: 0;
|
|
361
|
+
background: rgba(0, 0, 0, 0.85);
|
|
362
|
+
border-top: 1px solid #555;
|
|
363
|
+
z-index: 10;
|
|
364
|
+
padding: 8px 12px;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.rewind-header {
|
|
368
|
+
display: flex;
|
|
369
|
+
align-items: center;
|
|
370
|
+
gap: 12px;
|
|
371
|
+
margin-bottom: 6px;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.rewind-title {
|
|
375
|
+
color: #ccc;
|
|
376
|
+
font-size: 12px;
|
|
377
|
+
font-weight: bold;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.rewind-filmstrip {
|
|
381
|
+
display: flex;
|
|
382
|
+
gap: 6px;
|
|
383
|
+
overflow-x: auto;
|
|
384
|
+
padding: 4px 0;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.rewind-thumb {
|
|
388
|
+
flex: 0 0 auto;
|
|
389
|
+
cursor: pointer;
|
|
390
|
+
border: 2px solid transparent;
|
|
391
|
+
border-radius: 3px;
|
|
392
|
+
position: relative;
|
|
393
|
+
transition: border-color 0.15s;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.rewind-thumb:hover {
|
|
397
|
+
border-color: #aaa;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.rewind-thumb.selected {
|
|
401
|
+
border-color: #4af;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.rewind-thumb canvas {
|
|
405
|
+
display: block;
|
|
406
|
+
border-radius: 2px;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.rewind-thumb-label {
|
|
410
|
+
position: absolute;
|
|
411
|
+
bottom: 2px;
|
|
412
|
+
right: 4px;
|
|
413
|
+
font-size: 10px;
|
|
414
|
+
color: #fff;
|
|
415
|
+
text-shadow:
|
|
416
|
+
0 0 3px #000,
|
|
417
|
+
0 0 6px #000;
|
|
418
|
+
pointer-events: none;
|
|
419
|
+
}
|
|
420
|
+
|
|
355
421
|
div.smoothie-chart-tooltip {
|
|
356
422
|
background: #444;
|
|
357
423
|
padding: 1em;
|
package/src/machine-session.js
CHANGED
|
@@ -62,6 +62,10 @@ export class MachineSession {
|
|
|
62
62
|
|
|
63
63
|
// Accumulated VDU text output — drained by callers
|
|
64
64
|
this._pendingOutput = [];
|
|
65
|
+
|
|
66
|
+
// Breakpoint management — persistent hooks that survive across run calls
|
|
67
|
+
this._breakpoints = new Map(); // id → { hook, type, address, hit }
|
|
68
|
+
this._nextBreakpointId = 1;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
/** Load ROMs and hardware — call once before anything else */
|
|
@@ -277,6 +281,93 @@ export class MachineSession {
|
|
|
277
281
|
await this._machine.runUntilAddress(addr, timeoutSecs);
|
|
278
282
|
}
|
|
279
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Add a persistent breakpoint. Returns the breakpoint id.
|
|
286
|
+
* The hook stays active across run calls until removed.
|
|
287
|
+
* When the hook fires, cpu.stop() halts the current runFor.
|
|
288
|
+
* @param {"execute"|"read"|"write"} type
|
|
289
|
+
* @param {number} address
|
|
290
|
+
* @returns {number} breakpoint id
|
|
291
|
+
*/
|
|
292
|
+
addBreakpoint(type, address) {
|
|
293
|
+
const id = this._nextBreakpointId++;
|
|
294
|
+
const cpu = this._machine.processor;
|
|
295
|
+
const bp = { type, address, hit: false, id, value: undefined };
|
|
296
|
+
|
|
297
|
+
if (type === "execute") {
|
|
298
|
+
bp.hook = cpu.debugInstruction.add((pc) => {
|
|
299
|
+
if (pc === address) {
|
|
300
|
+
bp.hit = true;
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
} else if (type === "read") {
|
|
305
|
+
bp.hook = cpu.debugRead.add((addr, val) => {
|
|
306
|
+
if (addr === address) {
|
|
307
|
+
bp.hit = true;
|
|
308
|
+
bp.value = val;
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
} else if (type === "write") {
|
|
313
|
+
bp.hook = cpu.debugWrite.add((addr, val) => {
|
|
314
|
+
if (addr === address) {
|
|
315
|
+
bp.hit = true;
|
|
316
|
+
bp.value = val;
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
} else {
|
|
321
|
+
throw new Error(`Unknown breakpoint type: ${type}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this._breakpoints.set(id, bp);
|
|
325
|
+
return id;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Remove a breakpoint by id.
|
|
330
|
+
*/
|
|
331
|
+
removeBreakpoint(id) {
|
|
332
|
+
const bp = this._breakpoints.get(id);
|
|
333
|
+
if (!bp) throw new Error(`No breakpoint with id ${id}`);
|
|
334
|
+
bp.hook.remove();
|
|
335
|
+
this._breakpoints.delete(id);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Remove all breakpoints.
|
|
340
|
+
*/
|
|
341
|
+
clearBreakpoints() {
|
|
342
|
+
for (const bp of this._breakpoints.values()) {
|
|
343
|
+
bp.hook.remove();
|
|
344
|
+
}
|
|
345
|
+
this._breakpoints.clear();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Return the first breakpoint that was hit since the last reset, or null.
|
|
350
|
+
*/
|
|
351
|
+
hitBreakpoint() {
|
|
352
|
+
for (const bp of this._breakpoints.values()) {
|
|
353
|
+
if (bp.hit) {
|
|
354
|
+
const result = { id: bp.id, type: bp.type, address: bp.address };
|
|
355
|
+
if (bp.value !== undefined) result.value = bp.value;
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Reset all hit flags (call before starting a new run).
|
|
364
|
+
*/
|
|
365
|
+
resetBreakpointHits() {
|
|
366
|
+
for (const bp of this._breakpoints.values()) {
|
|
367
|
+
bp.hit = false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
280
371
|
/**
|
|
281
372
|
* Load a disc image (absolute or relative path to an .ssd or .dsd file).
|
|
282
373
|
*
|