jsbeeb 1.11.0 → 1.13.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 +17 -3
- package/package.json +8 -9
- package/public/roms/atom/ATMMC3E.rom +0 -0
- package/public/roms/atom/Atom_Basic.rom +0 -0
- package/public/roms/atom/Atom_DOS.rom +0 -0
- package/public/roms/atom/Atom_FloatingPoint.rom +0 -0
- package/public/roms/atom/Atom_Kernel.rom +0 -0
- package/public/roms/atom/Atom_Kernel_E.rom +0 -0
- package/public/roms/atom/PCHARME.ROM +0 -0
- package/public/roms/atom/gags.rom +0 -0
- package/public/roms/atom/werom.rom +0 -0
- package/src/6502.js +351 -44
- package/src/6847.js +724 -0
- package/src/6847_fontdata.js +124 -0
- package/src/app/app.js +1 -1
- package/src/app/electron.js +4 -4
- package/src/bem-snapshot.js +5 -184
- package/src/config.js +20 -9
- package/src/disc.js +2 -20
- package/src/fake6502.js +3 -2
- package/src/jsbeeb.css +23 -0
- package/src/keyboard.js +62 -37
- package/src/machine-session.js +85 -59
- package/src/main.js +188 -75
- package/src/mmc.js +1053 -0
- package/src/models.js +46 -5
- package/src/ppia.js +477 -0
- package/src/snapshot-helpers.js +208 -0
- package/src/snapshot.js +9 -18
- package/src/soundchip.js +99 -1
- package/src/tapes.js +73 -16
- package/src/uef-snapshot.js +402 -0
- package/src/url-params.js +7 -2
- package/src/utils.js +81 -2
- package/src/utils_atom.js +508 -0
- package/src/video.js +12 -1
- package/src/web/audio-handler.js +39 -17
- package/tests/test-machine.js +133 -8
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// BeebEm UEF save state parser.
|
|
4
|
+
// Converts a BeebEm UEF save state file into a jsbeeb snapshot object, in the same way
|
|
5
|
+
// that bem-snapshot.js converts B-em snapshots.
|
|
6
|
+
//
|
|
7
|
+
// BeebEm extends the UEF (Universal Emulator Format) with save-state chunks in the
|
|
8
|
+
// 0x0460-0x047F range, identified by an initial 0x046C (BeebEm ID) chunk.
|
|
9
|
+
// Reference: stardot/beebem-windows Src/UefState.cpp
|
|
10
|
+
|
|
11
|
+
import { volumeTable, buildVideoState, buildSnapshot } from "./snapshot-helpers.js";
|
|
12
|
+
|
|
13
|
+
// Chunk IDs used in BeebEm UEF save states
|
|
14
|
+
const ChunkId = {
|
|
15
|
+
BeebEmID: 0x046c, // presence of this chunk identifies a BeebEm save state
|
|
16
|
+
EmuState: 0x046a, // machine type, FDC type, tube type
|
|
17
|
+
Cpu6502: 0x0460, // 6502 CPU registers and status
|
|
18
|
+
RomRegs: 0x0461, // paged ROM register (FE30) and ACCCON (FE34)
|
|
19
|
+
MainRam: 0x0462, // main RAM (32 KB)
|
|
20
|
+
ShadowRam: 0x0463, // shadow RAM (BBC B+/Master)
|
|
21
|
+
PrivateRam: 0x0464, // private RAM (BBC B+/Master)
|
|
22
|
+
SwRam: 0x0466, // sideways RAM bank (one chunk per bank)
|
|
23
|
+
Via: 0x0467, // VIA state (one chunk per VIA: sys VIA first, then user VIA)
|
|
24
|
+
Video: 0x0468, // video state (CRTC + ULA)
|
|
25
|
+
Sound: 0x046b, // SN76489 sound chip state
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check whether an ArrayBuffer looks like a BeebEm UEF save state.
|
|
30
|
+
* A save state has the "UEF File!" header and its first chunk is the BeebEm ID (0x046C).
|
|
31
|
+
* @param {ArrayBuffer} buffer
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isUefSnapshot(buffer) {
|
|
35
|
+
if (buffer.byteLength < 14) return false;
|
|
36
|
+
const bytes = new Uint8Array(buffer, 0, 14);
|
|
37
|
+
// Bytes 0-9: "UEF File!\0"
|
|
38
|
+
const header = "UEF File!";
|
|
39
|
+
for (let i = 0; i < 9; i++) {
|
|
40
|
+
if (bytes[i] !== header.charCodeAt(i)) return false;
|
|
41
|
+
}
|
|
42
|
+
if (bytes[9] !== 0) return false;
|
|
43
|
+
// Bytes 12-13: first chunk ID (little-endian uint16) must be 0x046C
|
|
44
|
+
const chunkId = bytes[12] | (bytes[13] << 8);
|
|
45
|
+
return chunkId === ChunkId.BeebEmID;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse all UEF chunks from the buffer into a Map<chunkId, Uint8Array[]>.
|
|
50
|
+
* Starts after the 12-byte UEF header (10 bytes signature + 2 bytes version).
|
|
51
|
+
*/
|
|
52
|
+
function parseChunks(bytes, view) {
|
|
53
|
+
const chunks = new Map();
|
|
54
|
+
let offset = 12;
|
|
55
|
+
while (offset + 6 <= bytes.length) {
|
|
56
|
+
const chunkId = view.getUint16(offset, true);
|
|
57
|
+
const chunkLen = view.getUint32(offset + 2, true);
|
|
58
|
+
offset += 6;
|
|
59
|
+
if (offset + chunkLen > bytes.length) break;
|
|
60
|
+
const chunkData = bytes.slice(offset, offset + chunkLen);
|
|
61
|
+
if (!chunks.has(chunkId)) chunks.set(chunkId, []);
|
|
62
|
+
chunks.get(chunkId).push(chunkData);
|
|
63
|
+
offset += chunkLen;
|
|
64
|
+
}
|
|
65
|
+
return chunks;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert a BeebEm UEF VIA state (from chunk 0x0467) to jsbeeb VIA state.
|
|
70
|
+
* The UEF chunk layout (with the leading VIAType byte) is:
|
|
71
|
+
* [0] VIAType (0=sys, 1=user)
|
|
72
|
+
* [1] ORB
|
|
73
|
+
* [2] IRB
|
|
74
|
+
* [3] ORA
|
|
75
|
+
* [4] IRA
|
|
76
|
+
* [5] DDRB
|
|
77
|
+
* [6] DDRA
|
|
78
|
+
* [7-8] timer1c / 2 (uint16 LE; BeebEm saves count/2, jsbeeb needs count*2 from file)
|
|
79
|
+
* [9-10] timer1l (uint16 LE; raw 16-bit latch; jsbeeb needs latch*2)
|
|
80
|
+
* [11-12] timer2c / 2 (uint16 LE)
|
|
81
|
+
* [13-14] timer2l (uint16 LE)
|
|
82
|
+
* [15] ACR
|
|
83
|
+
* [16] PCR
|
|
84
|
+
* [17] IFR
|
|
85
|
+
* [18] IER
|
|
86
|
+
* [19] timer1hasshot
|
|
87
|
+
* [20] timer2hasshot
|
|
88
|
+
* [21] IC32State (sys VIA only, absent for user VIA)
|
|
89
|
+
*/
|
|
90
|
+
function convertViaChunk(data) {
|
|
91
|
+
const viaType = data[0];
|
|
92
|
+
if (viaType !== 0 && viaType !== 1) throw new Error(`Unknown VIA type: ${viaType} (expected 0=sys or 1=user)`);
|
|
93
|
+
const minLen = viaType === 0 ? 22 : 21; // sys VIA needs IC32 byte
|
|
94
|
+
if (data.length < minLen) throw new Error(`VIA chunk too short: expected >= ${minLen} bytes, got ${data.length}`);
|
|
95
|
+
|
|
96
|
+
const view = new DataView(data.buffer, data.byteOffset);
|
|
97
|
+
|
|
98
|
+
// BeebEm saves timer counters as counter/2 and loads them as file*2.
|
|
99
|
+
// jsbeeb stores timer values in 2x peripheral cycles (same as BeebEm's internal),
|
|
100
|
+
// so: jsbeeb_t1c = file_t1c * 2, jsbeeb_t1l = file_t1l * 2.
|
|
101
|
+
const t1c = view.getUint16(7, true) * 2;
|
|
102
|
+
const t1l = view.getUint16(9, true) * 2;
|
|
103
|
+
const t2c = view.getUint16(11, true) * 2;
|
|
104
|
+
const t2l = view.getUint16(13, true) * 2;
|
|
105
|
+
|
|
106
|
+
const acr = data[15];
|
|
107
|
+
const pcr = data[16];
|
|
108
|
+
const ifr = data[17];
|
|
109
|
+
const ier = data[18];
|
|
110
|
+
|
|
111
|
+
// Derive CA2/CB2 from PCR, matching BeebEm's LoadViaUEF
|
|
112
|
+
const ca2 = (pcr & 0x0e) === 0x0e;
|
|
113
|
+
const cb2 = (pcr & 0xe0) === 0xe0;
|
|
114
|
+
// Sys VIA (type 0) always needs IC32; default to 0xff if missing
|
|
115
|
+
const ic32 = viaType === 0 ? (data.length > 21 ? data[21] : 0xff) : undefined;
|
|
116
|
+
|
|
117
|
+
const result = {
|
|
118
|
+
ora: data[3],
|
|
119
|
+
orb: data[1],
|
|
120
|
+
ira: data[4],
|
|
121
|
+
irb: data[2],
|
|
122
|
+
ddra: data[6],
|
|
123
|
+
ddrb: data[5],
|
|
124
|
+
sr: 0,
|
|
125
|
+
acr,
|
|
126
|
+
pcr,
|
|
127
|
+
ifr,
|
|
128
|
+
ier,
|
|
129
|
+
t1l,
|
|
130
|
+
t2l,
|
|
131
|
+
t1c,
|
|
132
|
+
t2c,
|
|
133
|
+
// In free-running mode (ACR bit 6), t1hit must be false or jsbeeb
|
|
134
|
+
// will never generate timer 1 interrupts (blocking the 100Hz tick).
|
|
135
|
+
t1hit: acr & 0x40 ? false : !!data[19],
|
|
136
|
+
t2hit: !!data[20],
|
|
137
|
+
portapins: 0xff,
|
|
138
|
+
portbpins: 0xff,
|
|
139
|
+
ca1: false,
|
|
140
|
+
ca2,
|
|
141
|
+
cb1: false,
|
|
142
|
+
cb2,
|
|
143
|
+
justhit: 0,
|
|
144
|
+
t1_pb7: (data[1] >> 7) & 1,
|
|
145
|
+
lastPolltime: 0,
|
|
146
|
+
taskOffset: 1,
|
|
147
|
+
};
|
|
148
|
+
if (ic32 !== undefined) {
|
|
149
|
+
result.IC32 = ic32;
|
|
150
|
+
result.capsLockLight = !(ic32 & 0x40);
|
|
151
|
+
result.shiftLockLight = !(ic32 & 0x80);
|
|
152
|
+
}
|
|
153
|
+
return { viaType, state: result };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Convert BeebEm UEF sound chunk (0x046B) to jsbeeb sound chip state.
|
|
158
|
+
*
|
|
159
|
+
* SaveSoundUEF layout (byte offsets within the chunk):
|
|
160
|
+
* [0-1] ToneFreq[2] (uint16 LE) → SN76489 tone channel 0 period
|
|
161
|
+
* [2-3] ToneFreq[1] (uint16 LE) → SN76489 tone channel 1 period
|
|
162
|
+
* [4-5] ToneFreq[0] (uint16 LE) → SN76489 tone channel 2 period
|
|
163
|
+
* [6] RealVolumes[3] → tone channel 0 volume register (0=loud, 15=silent)
|
|
164
|
+
* [7] RealVolumes[2] → tone channel 1 volume register
|
|
165
|
+
* [8] RealVolumes[1] → tone channel 2 volume register
|
|
166
|
+
* [9] Noise = (Noise.Freq | (Noise.FB << 2)) → noise register bits 0-2
|
|
167
|
+
* [10] RealVolumes[0] → noise channel volume register
|
|
168
|
+
* [11] LastToneFreqSet (ignored)
|
|
169
|
+
* [12+] GenIndex[0..3] (ignored)
|
|
170
|
+
*/
|
|
171
|
+
function convertSoundChunk(data) {
|
|
172
|
+
const registers = new Uint16Array(4);
|
|
173
|
+
const counter = new Float32Array(4);
|
|
174
|
+
const outputBit = [false, false, false, false];
|
|
175
|
+
const volume = new Float32Array(4);
|
|
176
|
+
|
|
177
|
+
if (data && data.length >= 11) {
|
|
178
|
+
const view = new DataView(data.buffer, data.byteOffset);
|
|
179
|
+
// BeebEm's ToneFreq array is indexed in reverse relative to SN76489 channels:
|
|
180
|
+
// ToneFreq[2] → SN76489 channel 0, ToneFreq[1] → channel 1, ToneFreq[0] → channel 2.
|
|
181
|
+
// Similarly, RealVolumes[3,2,1,0] map to channels [0,1,2,noise].
|
|
182
|
+
registers[0] = view.getUint16(0, true); // ToneFreq[2] → ch 0
|
|
183
|
+
registers[1] = view.getUint16(2, true); // ToneFreq[1] → ch 1
|
|
184
|
+
registers[2] = view.getUint16(4, true); // ToneFreq[0] → ch 2
|
|
185
|
+
registers[3] = data[9] & 0x07; // noise register
|
|
186
|
+
|
|
187
|
+
const vol0 = data[6] & 0x0f; // channel 0 volume
|
|
188
|
+
const vol1 = data[7] & 0x0f; // channel 1 volume
|
|
189
|
+
const vol2 = data[8] & 0x0f; // channel 2 volume
|
|
190
|
+
const vol3 = data[10] & 0x0f; // noise volume
|
|
191
|
+
|
|
192
|
+
volume[0] = volumeTable[vol0];
|
|
193
|
+
volume[1] = volumeTable[vol1];
|
|
194
|
+
volume[2] = volumeTable[vol2];
|
|
195
|
+
volume[3] = volumeTable[vol3];
|
|
196
|
+
|
|
197
|
+
// Approximate outputBit from volume: if a channel is silent (volume register = 15),
|
|
198
|
+
// its output bit is off. Not cycle-accurate but produces a reasonable initial state.
|
|
199
|
+
outputBit[0] = vol0 !== 15;
|
|
200
|
+
outputBit[1] = vol1 !== 15;
|
|
201
|
+
outputBit[2] = vol2 !== 15;
|
|
202
|
+
outputBit[3] = vol3 !== 15;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
registers,
|
|
207
|
+
counter,
|
|
208
|
+
outputBit,
|
|
209
|
+
volume,
|
|
210
|
+
lfsr: 1 << 14,
|
|
211
|
+
latchedRegister: 0,
|
|
212
|
+
residual: 0,
|
|
213
|
+
sineOn: false,
|
|
214
|
+
sineStep: 0,
|
|
215
|
+
sineTime: 0,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Default jsbeeb VIA state used when no VIA chunk is present.
|
|
221
|
+
* @param {number|undefined} ic32 - IC32 value (sys VIA only)
|
|
222
|
+
*/
|
|
223
|
+
function defaultViaState(ic32) {
|
|
224
|
+
const result = {
|
|
225
|
+
ora: 0xff,
|
|
226
|
+
orb: 0xff,
|
|
227
|
+
ira: 0xff,
|
|
228
|
+
irb: 0xff,
|
|
229
|
+
ddra: 0x00,
|
|
230
|
+
ddrb: 0x00,
|
|
231
|
+
sr: 0,
|
|
232
|
+
acr: 0x00,
|
|
233
|
+
pcr: 0x00,
|
|
234
|
+
ifr: 0x00,
|
|
235
|
+
ier: 0x80,
|
|
236
|
+
t1l: 0x1fffe,
|
|
237
|
+
t2l: 0x1fffe,
|
|
238
|
+
t1c: 0x1fffe,
|
|
239
|
+
t2c: 0x1fffe,
|
|
240
|
+
t1hit: true,
|
|
241
|
+
t2hit: true,
|
|
242
|
+
portapins: 0xff,
|
|
243
|
+
portbpins: 0xff,
|
|
244
|
+
ca1: false,
|
|
245
|
+
ca2: false,
|
|
246
|
+
cb1: false,
|
|
247
|
+
cb2: false,
|
|
248
|
+
justhit: 0,
|
|
249
|
+
t1_pb7: 1,
|
|
250
|
+
lastPolltime: 0,
|
|
251
|
+
taskOffset: 1,
|
|
252
|
+
};
|
|
253
|
+
if (ic32 !== undefined) {
|
|
254
|
+
result.IC32 = ic32;
|
|
255
|
+
result.capsLockLight = !(ic32 & 0x40);
|
|
256
|
+
result.shiftLockLight = !(ic32 & 0x80);
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Parse a BeebEm UEF save state into a jsbeeb snapshot object.
|
|
263
|
+
* @param {ArrayBuffer} buffer
|
|
264
|
+
* @returns {object} jsbeeb snapshot
|
|
265
|
+
*/
|
|
266
|
+
export function parseUefSnapshot(buffer) {
|
|
267
|
+
if (buffer.byteLength < 14) throw new Error("File too small to be a BeebEm UEF save state");
|
|
268
|
+
if (!isUefSnapshot(buffer)) throw new Error("Not a BeebEm UEF save state (missing 0x046C chunk)");
|
|
269
|
+
|
|
270
|
+
const bytes = new Uint8Array(buffer);
|
|
271
|
+
const view = new DataView(buffer);
|
|
272
|
+
const chunks = parseChunks(bytes, view);
|
|
273
|
+
|
|
274
|
+
// Validate that required chunks are present (BeebEmID is guaranteed by isUefSnapshot above)
|
|
275
|
+
if (!chunks.has(ChunkId.Cpu6502)) throw new Error("BeebEm UEF save state missing CPU chunk (0x0460)");
|
|
276
|
+
if (!chunks.has(ChunkId.MainRam)) throw new Error("BeebEm UEF save state missing main RAM chunk (0x0462)");
|
|
277
|
+
|
|
278
|
+
// ── Model (from EmuState chunk 0x046A) ──────────────────────────────
|
|
279
|
+
// BeebEm Model enum: 0=B, 1=IntegraB, 2=BPlus, 3=Master128, 4=MasterET
|
|
280
|
+
let modelName = "B";
|
|
281
|
+
if (chunks.has(ChunkId.EmuState)) {
|
|
282
|
+
const emuData = chunks.get(ChunkId.EmuState)[0];
|
|
283
|
+
const machineType = emuData[0];
|
|
284
|
+
if (machineType === 3 || machineType === 4) {
|
|
285
|
+
modelName = "Master";
|
|
286
|
+
} else if (machineType === 2) {
|
|
287
|
+
modelName = "B"; // jsbeeb has no B+ model; BBC B is the closest match
|
|
288
|
+
}
|
|
289
|
+
// 0=B, 1=IntegraB → both treated as "B"
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── CPU (chunk 0x0460) ──────────────────────────────────────────────
|
|
293
|
+
// Layout: uint16 PC, uint8 A, X, Y, SP, PSR, uint32 TotalCycles(ignored),
|
|
294
|
+
// uint8 intStatus, uint8 NMIStatus, uint8 NMILock(ignored)
|
|
295
|
+
const d0460 = chunks.get(ChunkId.Cpu6502)[0];
|
|
296
|
+
if (d0460.length < 13) throw new Error(`CPU chunk too short: expected >= 13 bytes, got ${d0460.length}`);
|
|
297
|
+
const v0460 = new DataView(d0460.buffer, d0460.byteOffset);
|
|
298
|
+
const cpuState = {
|
|
299
|
+
pc: v0460.getUint16(0, true),
|
|
300
|
+
a: d0460[2],
|
|
301
|
+
x: d0460[3],
|
|
302
|
+
y: d0460[4],
|
|
303
|
+
s: d0460[5],
|
|
304
|
+
flags: d0460[6],
|
|
305
|
+
nmi: d0460[12],
|
|
306
|
+
fe30: 0,
|
|
307
|
+
fe34: 0,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// ── ROM registers (chunk 0x0461) ────────────────────────────────────
|
|
311
|
+
// Layout: uint8 PagedRomReg, uint8 ACCCON (and more for non-B models)
|
|
312
|
+
if (chunks.has(ChunkId.RomRegs)) {
|
|
313
|
+
const d = chunks.get(ChunkId.RomRegs)[0];
|
|
314
|
+
cpuState.fe30 = d[0] & 0x0f; // PagedRomReg: low nibble = ROM bank select
|
|
315
|
+
cpuState.fe34 = d[1]; // ACCCON
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── RAM ─────────────────────────────────────────────────────────────
|
|
319
|
+
// jsbeeb snapshot.state.ram is 128 KB (ramRomOs up to romOffset).
|
|
320
|
+
// Main RAM (chunk 0x0462): 32 KB at offset 0.
|
|
321
|
+
const ram = new Uint8Array(128 * 1024);
|
|
322
|
+
if (chunks.has(ChunkId.MainRam)) {
|
|
323
|
+
const mainRam = chunks.get(ChunkId.MainRam)[0];
|
|
324
|
+
// Defensive: chunk is defined as exactly 32 KB; clamp in case of a malformed file
|
|
325
|
+
ram.set(mainRam.slice(0, Math.min(32768, mainRam.length)));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Shadow RAM (chunk 0x0463, Master/B+) ─────────────────────────────
|
|
329
|
+
// BeebEm saves 32 KB (full shadow bank). jsbeeb's LYNNE region is 20 KB
|
|
330
|
+
// at ram[0xB000-0xFFFF], covering addresses 0x3000-0x7FFF when ACCCON X bit is set.
|
|
331
|
+
// See 6502.js writeAcccon: memLook[i] = bitX ? 0x8000 : 0 for pages 0x30-0x7F.
|
|
332
|
+
if (chunks.has(ChunkId.ShadowRam)) {
|
|
333
|
+
const shadowData = chunks.get(ChunkId.ShadowRam)[0];
|
|
334
|
+
if (shadowData.length >= 0x8000) {
|
|
335
|
+
// Full 32 KB shadow bank - extract LYNNE region (file offsets 0x3000-0x7FFF)
|
|
336
|
+
ram.set(shadowData.slice(0x3000, 0x8000), 0xb000);
|
|
337
|
+
} else if (shadowData.length >= 0x5000) {
|
|
338
|
+
// 20 KB LYNNE-only dump
|
|
339
|
+
ram.set(shadowData.slice(0, 0x5000), 0xb000);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Private RAM (chunk 0x0464, Master) ──────────────────────────────
|
|
344
|
+
// 12 KB: 4 KB ANDY (ram[0x8000-0x8FFF]) + 8 KB HAZEL (ram[0x9000-0xAFFF]).
|
|
345
|
+
if (chunks.has(ChunkId.PrivateRam)) {
|
|
346
|
+
const privData = chunks.get(ChunkId.PrivateRam)[0];
|
|
347
|
+
ram.set(privData.slice(0, Math.min(0x3000, privData.length)), 0x8000);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Sideways RAM (chunk 0x0466) ─────────────────────────────────────
|
|
351
|
+
// Each chunk: uint8 bank_number + 16384 bytes of data.
|
|
352
|
+
// BeebEm only saves sideways RAM banks, not the actual ROMs (BASIC, DFS, OS).
|
|
353
|
+
// We must NOT pass a roms array to restoreState because that would overwrite
|
|
354
|
+
// all 16 ROM banks (including the ROMs jsbeeb has already loaded) with zeros.
|
|
355
|
+
// Instead, we record which banks have sideways RAM data so restoreState can
|
|
356
|
+
// selectively overwrite only those banks.
|
|
357
|
+
let swRamBanks = null;
|
|
358
|
+
if (chunks.has(ChunkId.SwRam)) {
|
|
359
|
+
swRamBanks = {};
|
|
360
|
+
for (const d of chunks.get(ChunkId.SwRam)) {
|
|
361
|
+
if (d.length >= 1 + 16384) {
|
|
362
|
+
const bank = d[0] & 0x0f;
|
|
363
|
+
swRamBanks[bank] = d.slice(1, 1 + 16384);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── VIA (chunk 0x0467, one per VIA) ─────────────────────────────────
|
|
369
|
+
// sysvia default: IC32 = 0xff (all outputs open), keyboard not scanning
|
|
370
|
+
let sysvia = defaultViaState(0xff);
|
|
371
|
+
let uservia = defaultViaState(undefined);
|
|
372
|
+
if (chunks.has(ChunkId.Via)) {
|
|
373
|
+
for (const d of chunks.get(ChunkId.Via)) {
|
|
374
|
+
const { viaType, state } = convertViaChunk(d);
|
|
375
|
+
if (viaType === 0) sysvia = state;
|
|
376
|
+
else uservia = state;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── Video (chunk 0x0468) ─────────────────────────────────────────────
|
|
381
|
+
// Layout: 18 CRTC regs, 1 ULA ctrl, 16 ULA palette
|
|
382
|
+
let ulaControl = 0x9c; // mode 7 (sensible default)
|
|
383
|
+
let ulaPalette = new Uint8Array(16);
|
|
384
|
+
let crtcRegs = new Uint8Array(18);
|
|
385
|
+
if (chunks.has(ChunkId.Video)) {
|
|
386
|
+
const d = chunks.get(ChunkId.Video)[0];
|
|
387
|
+
if (d.length >= 35) {
|
|
388
|
+
crtcRegs = d.slice(0, 18);
|
|
389
|
+
ulaControl = d[18];
|
|
390
|
+
for (let i = 0; i < 16; i++) ulaPalette[i] = d[19 + i];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const video = buildVideoState(ulaControl, ulaPalette, crtcRegs);
|
|
394
|
+
|
|
395
|
+
// ── Sound (chunk 0x046B) ─────────────────────────────────────────────
|
|
396
|
+
const soundData = chunks.has(ChunkId.Sound) ? chunks.get(ChunkId.Sound)[0] : null;
|
|
397
|
+
const soundChip = convertSoundChunk(soundData);
|
|
398
|
+
|
|
399
|
+
const snapshot = buildSnapshot("beebem-uef", modelName, cpuState, ram, null, sysvia, uservia, video, soundChip);
|
|
400
|
+
if (swRamBanks) snapshot.state.swRamBanks = swRamBanks;
|
|
401
|
+
return snapshot;
|
|
402
|
+
}
|
package/src/url-params.js
CHANGED
|
@@ -240,6 +240,7 @@ export function processAutobootParams(parsedQuery) {
|
|
|
240
240
|
export function guessModelFromHostname(hostname) {
|
|
241
241
|
if (hostname.startsWith("bbc")) return "B-DFS1.2";
|
|
242
242
|
if (hostname.startsWith("master")) return "Master";
|
|
243
|
+
if (hostname.startsWith("atom")) return "Atom";
|
|
243
244
|
return "B-DFS1.2";
|
|
244
245
|
}
|
|
245
246
|
|
|
@@ -247,10 +248,14 @@ export function guessModelFromHostname(hostname) {
|
|
|
247
248
|
* Parse disc or tape images from the query parameters
|
|
248
249
|
* @param {Object} parsedQuery - The query parameters
|
|
249
250
|
* @returns {Object} Object containing disc and tape information
|
|
251
|
+
* - discImage: disc image URL (?disc= or ?disc1=)
|
|
252
|
+
* - secondDiscImage: second disc URL (?disc2=)
|
|
253
|
+
* - tapeImage: tape image URL (?tape=)
|
|
254
|
+
* - mmcImage: MMC/SD card image URL (?mmc=, Atom only)
|
|
250
255
|
*/
|
|
251
256
|
export function parseMediaParams(parsedQuery) {
|
|
252
|
-
const { disc, disc1, disc2, tape } = parsedQuery;
|
|
257
|
+
const { disc, disc1, disc2, tape, mmc } = parsedQuery;
|
|
253
258
|
const discImage = disc || disc1;
|
|
254
259
|
|
|
255
|
-
return { discImage, secondDiscImage: disc2, tapeImage: tape };
|
|
260
|
+
return { discImage, secondDiscImage: disc2, tapeImage: tape, mmcImage: mmc };
|
|
256
261
|
}
|
package/src/utils.js
CHANGED
|
@@ -55,7 +55,13 @@ export async function decompress(data, format) {
|
|
|
55
55
|
const writePromise = (async () => {
|
|
56
56
|
await writer.write(data);
|
|
57
57
|
await writer.close();
|
|
58
|
-
})().catch(() => {
|
|
58
|
+
})().catch(() => {
|
|
59
|
+
// Intentionally empty.
|
|
60
|
+
});
|
|
61
|
+
// Intentionally empty: Node's DecompressionStream rejects multiple
|
|
62
|
+
// promises on error (write, close, closed). The read side surfaces
|
|
63
|
+
// the same error with proper context — catching these just prevents
|
|
64
|
+
// unhandled rejections from the write-side promises.
|
|
59
65
|
writer.closed.catch(() => {});
|
|
60
66
|
await readPromise;
|
|
61
67
|
await writePromise;
|
|
@@ -71,7 +77,7 @@ export async function decompress(data, format) {
|
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
// Extract all files from a ZIP archive. Returns {filename: Uint8Array}.
|
|
74
|
-
async function unzip(buf) {
|
|
80
|
+
export async function unzip(buf) {
|
|
75
81
|
if (!(buf instanceof Uint8Array)) buf = new Uint8Array(buf);
|
|
76
82
|
const eocdOff = findEocd(buf);
|
|
77
83
|
const cdOff = readU32(buf, eocdOff + 16);
|
|
@@ -114,6 +120,79 @@ async function unzip(buf) {
|
|
|
114
120
|
return files;
|
|
115
121
|
}
|
|
116
122
|
|
|
123
|
+
// Standard CRC-32/ISO-HDLC.
|
|
124
|
+
export function crc32(data) {
|
|
125
|
+
let crc = 0xffffffff;
|
|
126
|
+
for (let i = 0; i < data.length; ++i) {
|
|
127
|
+
crc ^= data[i];
|
|
128
|
+
for (let j = 0; j < 8; ++j) {
|
|
129
|
+
const doEor = crc & 1;
|
|
130
|
+
crc = crc >>> 1;
|
|
131
|
+
if (doEor) crc ^= 0xedb88320;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return ~crc;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Create a ZIP blob from an array of {name: string, data: Uint8Array} entries.
|
|
138
|
+
// Uses stored method (no compression) with CRC-32 for maximum compatibility.
|
|
139
|
+
export function createZipBlob(files) {
|
|
140
|
+
const encoder = new TextEncoder();
|
|
141
|
+
const localHeaders = [];
|
|
142
|
+
const centralHeaders = [];
|
|
143
|
+
let offset = 0;
|
|
144
|
+
|
|
145
|
+
for (const { name, data } of files) {
|
|
146
|
+
const nameBytes = encoder.encode(name);
|
|
147
|
+
const crc = crc32(data);
|
|
148
|
+
// Local file header (30 + nameLen + data)
|
|
149
|
+
const local = new Uint8Array(30 + nameBytes.length + data.length);
|
|
150
|
+
const lv = new DataView(local.buffer);
|
|
151
|
+
lv.setUint32(0, 0x04034b50, true); // signature
|
|
152
|
+
lv.setUint16(4, 20, true); // version needed
|
|
153
|
+
lv.setUint16(8, 0, true); // method: stored
|
|
154
|
+
lv.setUint32(14, crc, true); // CRC-32
|
|
155
|
+
lv.setUint32(18, data.length, true); // compressed size
|
|
156
|
+
lv.setUint32(22, data.length, true); // uncompressed size
|
|
157
|
+
lv.setUint16(26, nameBytes.length, true); // name length
|
|
158
|
+
local.set(nameBytes, 30);
|
|
159
|
+
local.set(data, 30 + nameBytes.length);
|
|
160
|
+
localHeaders.push(local);
|
|
161
|
+
|
|
162
|
+
// Central directory entry (46 + nameLen)
|
|
163
|
+
const central = new Uint8Array(46 + nameBytes.length);
|
|
164
|
+
const cv = new DataView(central.buffer);
|
|
165
|
+
cv.setUint32(0, 0x02014b50, true); // signature
|
|
166
|
+
cv.setUint16(4, 20, true); // version made by
|
|
167
|
+
cv.setUint16(6, 20, true); // version needed
|
|
168
|
+
cv.setUint16(10, 0, true); // method: stored
|
|
169
|
+
cv.setUint32(16, crc, true); // CRC-32
|
|
170
|
+
cv.setUint32(20, data.length, true); // compressed size
|
|
171
|
+
cv.setUint32(24, data.length, true); // uncompressed size
|
|
172
|
+
cv.setUint16(28, nameBytes.length, true); // name length
|
|
173
|
+
cv.setUint32(42, offset, true); // local header offset
|
|
174
|
+
central.set(nameBytes, 46);
|
|
175
|
+
centralHeaders.push(central);
|
|
176
|
+
|
|
177
|
+
offset += local.length;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const cdOffset = offset;
|
|
181
|
+
let cdSize = 0;
|
|
182
|
+
for (const c of centralHeaders) cdSize += c.length;
|
|
183
|
+
|
|
184
|
+
// End of central directory (22 bytes)
|
|
185
|
+
const eocd = new Uint8Array(22);
|
|
186
|
+
const ev = new DataView(eocd.buffer);
|
|
187
|
+
ev.setUint32(0, 0x06054b50, true); // signature
|
|
188
|
+
ev.setUint16(8, files.length, true); // entries on this disc
|
|
189
|
+
ev.setUint16(10, files.length, true); // total entries
|
|
190
|
+
ev.setUint32(12, cdSize, true); // central directory size
|
|
191
|
+
ev.setUint32(16, cdOffset, true); // central directory offset
|
|
192
|
+
|
|
193
|
+
return new Blob([...localHeaders, ...centralHeaders, eocd], { type: "application/zip" });
|
|
194
|
+
}
|
|
195
|
+
|
|
117
196
|
export function debounce(fn, wait) {
|
|
118
197
|
let timeout;
|
|
119
198
|
return function (...args) {
|