jsbeeb 1.12.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/src/mmc.js ADDED
@@ -0,0 +1,1053 @@
1
+ import { loadData, unzip, createZipBlob } from "./utils.js";
2
+
3
+ /*
4
+ highly adapted from:
5
+ https://github.com/hoglet67/Atomulator
6
+ in the src/atommc folder
7
+
8
+ Simulate the AtoMMC2 device, which is a MMC/SD card reader for the Acorn Atom.
9
+
10
+ First create a dictionary of the MMC data from a zipped file, into this.allfiles.
11
+
12
+ The Acorn Atom MMC2 eprom will communicate with the MMC via 0xb400-0xb40c, which is the Atom side of the MMC2 device.
13
+ The write() is called when 0xb400-0xb40c is written, read() is called when 0xb400-0xb40c is read.
14
+
15
+ the low byte determines which register is being accessed.
16
+ The registers are:
17
+ CMD_REG: 0xb400
18
+ LATCH_REG: 0xb401
19
+ READ_DATA_REG: 0xb402
20
+ WRITE_DATA_REG: 0xb403
21
+ STATUS_REG: 0xb404
22
+
23
+ */
24
+
25
+ // Access an MMC file (zipped)
26
+ const CMD_REG = 0x0;
27
+ const LATCH_REG = 0x1;
28
+ const READ_DATA_REG = 0x2;
29
+ const WRITE_DATA_REG = 0x3;
30
+ const STATUS_REG = 0x4;
31
+
32
+ const CMD_DIR_OPEN = 0x0;
33
+ const CMD_DIR_READ = 0x1;
34
+ const CMD_DIR_CWD = 0x2;
35
+ // const CMD_DIR_GETCWD = 0x3;
36
+ const CMD_DIR_MKDIR = 0x4;
37
+ const CMD_DIR_RMDIR = 0x5;
38
+
39
+ const CMD_RENAME = 0x8;
40
+
41
+ const CMD_FILE_CLOSE = 0x10;
42
+ const CMD_FILE_OPEN_READ = 0x11;
43
+ // const CMD_FILE_OPEN_IMG = 0x12;
44
+ const CMD_FILE_OPEN_WRITE = 0x13;
45
+ const CMD_FILE_DELETE = 0x14;
46
+ const CMD_FILE_GETINFO = 0x15;
47
+ const CMD_FILE_SEEK = 0x16;
48
+ const CMD_FILE_OPEN_RAF = 0x17;
49
+
50
+ const CMD_INIT_READ = 0x20;
51
+ const CMD_INIT_WRITE = 0x21;
52
+ const CMD_READ_BYTES = 0x22;
53
+ const CMD_WRITE_BYTES = 0x23;
54
+
55
+ // EXEC_PACKET_REG "commands"
56
+ const CMD_EXEC_PACKET = 0x3f;
57
+
58
+ // UTIL_CMD_REG commands
59
+ const CMD_GET_CARD_TYPE = 0x80;
60
+ const CMD_GET_PORT_DDR = 0xa0;
61
+ const CMD_SET_PORT_DDR = 0xa1;
62
+ const CMD_READ_PORT = 0xa2;
63
+ const CMD_WRITE_PORT = 0xa3;
64
+ const CMD_GET_FW_VER = 0xe0;
65
+ const CMD_GET_BL_VER = 0xe1;
66
+ const CMD_GET_CFG_BYTE = 0xf0;
67
+ const CMD_SET_CFG_BYTE = 0xf1;
68
+ const CMD_READ_AUX = 0xfd;
69
+ const CMD_GET_HEARTBEAT = 0xfe;
70
+
71
+ // Status codes
72
+ const STATUS_OK = 0x3f;
73
+ const STATUS_COMPLETE = 0x40;
74
+ const STATUS_EOF = 0x60;
75
+ const STATUS_BUSY = 0x80;
76
+
77
+ // STATUS_REG bit masks
78
+ const MMC_MCU_BUSY = 0x01;
79
+ const MMC_MCU_READ = 0x02;
80
+ const MMC_MCU_WROTE = 0x04;
81
+
82
+ const VSN_MAJ = 2;
83
+ const VSN_MIN = 10;
84
+ const FA_OPEN_EXISTING = 0;
85
+ const FA_READ = 1;
86
+ const FA_WRITE = 2;
87
+ const FA_CREATE_NEW = 4;
88
+
89
+ // Simulate constants
90
+ const FR_OK = 0,
91
+ FR_EXIST = 8,
92
+ FR_NO_FILE = 4,
93
+ FR_NO_PATH = 5,
94
+ FR_INVALID_NAME = 6;
95
+ const FA_CREATE_ALWAYS = 8,
96
+ FA_OPEN_ALWAYS = 16,
97
+ ERROR_TOO_MANY_OPEN = 0x12,
98
+ FILENUM_OFFSET = 0x20;
99
+ // Simulate open modes
100
+ const O_CREAT = 0x100,
101
+ O_RDWR = 0x2,
102
+ O_RDONLY = 0x0,
103
+ O_BINARY = 0x8000;
104
+
105
+ // Reference: https://github.com/hoglet67/AtoMMC2Firmware (atmmmc2def.h)
106
+
107
+ /** WFN functions
108
+ *
109
+ */
110
+ /**
111
+ * Represents a file with a path and data.
112
+ */
113
+ export class WFNFile {
114
+ /**
115
+ * @param {string} path - The file path. prepended with § if deleted
116
+ * @param {Uint8Array} data - The file data.
117
+ */
118
+ constructor(path, data) {
119
+ this.path = path;
120
+ this.data = data instanceof Uint8Array ? data : new Uint8Array();
121
+ }
122
+ }
123
+
124
+ // Sanitize a WFNFile path for use as a ZIP entry name.
125
+ function sanitizeZipEntryName(path) {
126
+ const name = path.startsWith("/") ? path.slice(1) : path;
127
+ if (!name || name.includes("\\") || name.split("/").some((seg) => seg === "..")) return null;
128
+ return name;
129
+ }
130
+
131
+ // Create a ZIP blob from an array of WFNFile entries (stored, no compression).
132
+ export async function toMMCZipAsync(data) {
133
+ const files = [];
134
+ for (const entry of data) {
135
+ if (entry.path.startsWith("§")) continue; // skip unlinked files
136
+ const name = sanitizeZipEntryName(entry.path);
137
+ if (!name) continue;
138
+ files.push({ name, data: entry.data });
139
+ }
140
+ return createZipBlob(files);
141
+ }
142
+
143
+ // Extract all files from a ZIP archive into WFNFile entries.
144
+ export async function extractSDFiles(data) {
145
+ const files = await unzip(new Uint8Array(data));
146
+ const result = [];
147
+ for (const [name, fileData] of Object.entries(files)) {
148
+ if (!/^[a-z0-9./_ -]+$/i.test(name)) continue;
149
+ if (name.endsWith("/")) continue; // skip directory entries
150
+ if (name.split("/").some((seg) => seg === "..")) continue;
151
+ result.push(new WFNFile("/" + name, fileData));
152
+ }
153
+ return result;
154
+ }
155
+
156
+ export async function LoadSD(file) {
157
+ const data = await loadData(file);
158
+ return extractSDFiles(data);
159
+ }
160
+
161
+ class WFN {
162
+ /**
163
+ * Create a new WFN instance.
164
+ * @param {Object} cpu - The CPU instance.
165
+ */
166
+ constructor(mmc, cpu) {
167
+ this.mmc = mmc;
168
+ this.cpu = cpu;
169
+
170
+ this.globalData = new Uint8Array(256);
171
+ this.globalIndex = 0;
172
+ this.globalDataPresent = 0;
173
+ this.globalAmount = 0;
174
+
175
+ this.fildata = new Array(4).fill(null);
176
+ this.fildataIndex = 0;
177
+
178
+ this.openeddir = ""; // Directory requested when reading directories
179
+ this.foldersSeen = []; // Folders seen when reading directories
180
+ this.dfn = 0; // Directory file number, used to track the current file in the zip file when reading directories
181
+
182
+ this.CWD = "";
183
+
184
+ this.filenum = -1;
185
+ this.seekpos = 0;
186
+ this.WildPattern = ".*"; // Default wildcard pattern (for regex not ATOM)
187
+
188
+ /**
189
+ * @type {WFNFile[]}
190
+ */
191
+ this.allfiles = [];
192
+ }
193
+
194
+ WFN_WorkerTest() {}
195
+
196
+ WFN_FileOpenRead() {
197
+ const res = this.fileOpen(FA_OPEN_EXISTING | FA_READ);
198
+ // if (filenum < 4) {
199
+ // FILINFO *filinfo = &filinfodata[filenum];
200
+ // get_fileinfo_special(filinfo);
201
+ // }
202
+ this.mmc.WriteDataPort(STATUS_COMPLETE | res);
203
+ }
204
+
205
+ WFN_FileOpenWrite() {
206
+ const res = this.fileOpen(FA_CREATE_NEW | FA_WRITE);
207
+ this.mmc.WriteDataPort(STATUS_COMPLETE | res);
208
+ }
209
+
210
+ WFN_FileClose() {
211
+ this.seekpos = 0;
212
+ this.mmc.WriteDataPort(STATUS_COMPLETE);
213
+ }
214
+
215
+ // Convert a glob pattern (with * and ?) to an anchored regex string.
216
+ static wildcardToRegex(pattern) {
217
+ // Escape regex metacharacters except * and ?, then convert glob wildcards.
218
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
219
+ return "^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$";
220
+ }
221
+
222
+ GetWildcard() {
223
+ // Extract the path from this.globalData (Uint8Array) until the first null byte
224
+ const path = this.trimToFilename(this.globalData).replace(/\\/g, "/");
225
+ const hasWildcard = path.includes("*") || path.includes("?");
226
+
227
+ if (hasWildcard) {
228
+ const lastSlashIndex = path.lastIndexOf("/");
229
+ if (lastSlashIndex !== -1) {
230
+ this.globalData = new TextEncoder().encode(path.slice(0, lastSlashIndex + 1) + "\0");
231
+ this.WildPattern = WFN.wildcardToRegex(path.slice(lastSlashIndex + 1));
232
+ } else {
233
+ this.WildPattern = WFN.wildcardToRegex(path);
234
+ this.globalData = new TextEncoder().encode("\0");
235
+ }
236
+ } else {
237
+ this.WildPattern = ".*";
238
+ }
239
+ }
240
+
241
+ // Strip trailing slash from a path string
242
+ stripTrailingSlash(path) {
243
+ if (path.endsWith("/")) {
244
+ return path.slice(0, -1);
245
+ }
246
+ return path;
247
+ }
248
+
249
+ /*
250
+ MMCData file contains only files with paths, and no directories.
251
+ To simulate directories, a basepath with multiple entries is a directory
252
+ For example, if the MMCData file contains:
253
+ /dir1/file1.txt
254
+ /dir1/file2.txt
255
+ /dir2/file3.txt
256
+ then /dir1/ is a directory, and /dir2/ is a directory.
257
+ The findfirst and findnext functions will simulate directory traversal by checking the paths in allfiles.
258
+ The findfirst function will set this.dfn to the index of the first file in the directory, and this.openeddir to the directory path.
259
+ The findnext function will iterate through the files in the directory, checking if the file path starts with this.openeddir.
260
+ If a file path starts with this.openeddir, it is considered to be in the directory.
261
+ If a file path does not start with this.openeddir, it is considered to be outside the directory.
262
+ If a file path has a subdirectory (i.e., it contains a slash after the directory path), it is considered to be in a subdirectory and is returned
263
+ once and subsequent calls to findnext will skip it.
264
+
265
+
266
+
267
+ */
268
+
269
+ // Simulate findfirst: check if a directory exists in allfiles
270
+ findfirst(path) {
271
+ const dirPrefix = this.stripTrailingSlash(path) + "/"; // Ensure path ends with a slash
272
+
273
+ // Simulate directory open: check if any file starts with path + "/"
274
+ const f_index = this.allfiles.findIndex((file) => file.path.startsWith(dirPrefix));
275
+
276
+ // if index is found, set dfn to the index of the first file in the directory
277
+ if (f_index !== -1) {
278
+ this.dfn = f_index + 1; // Set dfn to the next entry after the first one in this directory
279
+ this.openeddir = dirPrefix; // record the opened directory
280
+ }
281
+
282
+ this.foldersSeen = [];
283
+
284
+ return f_index !== -1;
285
+ }
286
+
287
+ // this.dfn is the index of the next file in the zip file
288
+
289
+ // Simulate findnext: iterate through directory entries in allfiles[].path
290
+ findnext() {
291
+ let foundDirEntry = "";
292
+ do {
293
+ // entry = readdir()
294
+
295
+ if (this.dfn >= this.allfiles.length) break;
296
+
297
+ // Check if the next file starts with the opened directory
298
+ const nextFullName = this.allfiles[this.dfn].path;
299
+ //
300
+
301
+ if (!nextFullName.startsWith(this.openeddir)) {
302
+ this.dfn += 1;
303
+ continue; // Skip to the next entry if it doesn't match the opened directory
304
+ }
305
+
306
+ let relativeName = nextFullName.slice(this.openeddir.length);
307
+
308
+ // Skip empty or root entries
309
+ if (relativeName === "" && relativeName !== "/") {
310
+ this.dfn += 1;
311
+ continue; // Skip to the next entry if it doesn't match the opened directory
312
+ }
313
+
314
+ //
315
+ let folders = relativeName.split("/");
316
+
317
+ relativeName = folders[0];
318
+
319
+ // skip files in subdirectories if the subdirecty has been seen before
320
+ if (folders.length > 1) {
321
+ if (this.foldersSeen.includes(folders[0])) {
322
+ // If this is seen subfolder, skip this entry
323
+ this.dfn += 1;
324
+ continue;
325
+ }
326
+ this.foldersSeen.push(folders[0]);
327
+ relativeName = folders[0] + "/"; // relative name is the folder + slash
328
+ }
329
+ foundDirEntry = relativeName;
330
+ } while (foundDirEntry == "");
331
+
332
+ if (foundDirEntry) {
333
+ // Optionally, check for valid 8.3 filename here if needed
334
+ // For now, just return the entry
335
+ this.dfn += 1;
336
+ return foundDirEntry;
337
+ }
338
+
339
+ // No more entries
340
+ return "";
341
+ }
342
+
343
+ // Simulate f_opendir: open a directory
344
+ f_opendir(path) {
345
+ // Build absolute path and check validity
346
+ const xpath = this.trimToFilename(path);
347
+ const absResult = this.buildAbsolutePath(xpath, false);
348
+ if (absResult.error) {
349
+ return absResult.error;
350
+ }
351
+ const newpath = absResult.path;
352
+
353
+ if (this.findfirst(newpath)) {
354
+ return FR_OK;
355
+ } else {
356
+ return FR_NO_PATH;
357
+ }
358
+ }
359
+
360
+ WFN_DirectoryOpen() {
361
+ // Separate wildcard and path
362
+ this.GetWildcard();
363
+
364
+ let res = this.f_opendir(this.globalData);
365
+ this.dfn = 0;
366
+ if (res !== 0) {
367
+ this.mmc.WriteDataPort(STATUS_COMPLETE | res);
368
+ return;
369
+ }
370
+ this.mmc.WriteDataPort(STATUS_OK);
371
+ }
372
+
373
+ f_readdir() {
374
+ let fno = { fname: "", fsize: 0, fattrib: 0 };
375
+ // If a file found copy it's details, else set size to 0 and filename to ''
376
+ const nextentry = this.findnext();
377
+ fno.fname = nextentry;
378
+
379
+ return { error: FR_OK, fno: fno };
380
+ }
381
+
382
+ WFN_DirectoryRead() {
383
+ while (true) {
384
+ let result = this.f_readdir();
385
+ let res = result.error;
386
+
387
+ if (res !== FR_OK || result.fno.fname === "") {
388
+ this.mmc.WriteDataPort(STATUS_COMPLETE | res);
389
+ return;
390
+ }
391
+
392
+ const fname = result.fno.fname;
393
+
394
+ // Check to see if filename matches current wildcard
395
+ // const Match = wildcmp(WildPattern, longname);
396
+ const Match = fname.match(new RegExp(this.WildPattern));
397
+
398
+ if (Match) {
399
+ // if is a directory, str will be <fname>
400
+
401
+ const isdir = fname.endsWith("/");
402
+ let str = fname;
403
+ if (isdir) {
404
+ str = str.slice(0, -1); // Remove trailing slash for directory names
405
+ str = `<${str}>`; // Enclose directory names in <>
406
+ }
407
+
408
+ // Convert the string to a Uint8Array
409
+ this.globalData = new TextEncoder().encode(str + "\0");
410
+
411
+ this.mmc.WriteDataPort(STATUS_OK);
412
+ return;
413
+ }
414
+ }
415
+ }
416
+
417
+ dir_exists(path) {
418
+ // ensure path ends in slash
419
+ if (!path.endsWith("/")) path += "/";
420
+
421
+ // Check if the path exists in the names array
422
+ if (this.allfiles.some((file) => file.path === path || file.path.startsWith(path))) {
423
+ // Check if the path is a directory (ends with '/')
424
+ return FR_OK;
425
+ }
426
+ return FR_NO_PATH;
427
+ }
428
+
429
+ realpath(path) {
430
+ // In C, realpath resolves all symbolic links, relative paths, and returns the absolute path.
431
+ // In JS, we just normalize the path (remove redundant slashes, resolve '.' and '..').
432
+ // This is a simplified version and does not handle symlinks.
433
+ const parts = [];
434
+ for (const part of path.split("/")) {
435
+ if (part === "" || part === ".") continue;
436
+ if (part === "..") {
437
+ if (parts.length > 0) parts.pop();
438
+ } else {
439
+ parts.push(part);
440
+ }
441
+ }
442
+ path = "/" + parts.join("/");
443
+
444
+ while (path.length > 0 && path.endsWith("/")) {
445
+ path = path.slice(0, -1);
446
+ }
447
+ return path;
448
+ }
449
+
450
+ f_chdir(path) {
451
+ // ensure newpath is an absolute path (relative paths appended to CWD,
452
+ // absolute paths are used as is)
453
+ const newpath = this.buildAbsolutePath(path, false);
454
+ if (newpath.error) {
455
+ return newpath.error; // Return error if path is invalid
456
+ }
457
+
458
+ // Resolve the newpath
459
+ const fullpath = this.realpath(newpath.path);
460
+ if (fullpath !== undefined) {
461
+ // Path exists and is a directory
462
+ if (this.dir_exists(fullpath) == FR_OK) {
463
+ this.CWD = fullpath; // Update the base path
464
+ return FR_OK; // Success
465
+ }
466
+ }
467
+ return FR_NO_PATH;
468
+ }
469
+
470
+ f_unlink(path) {
471
+ // ensure newpath is an absolute path (relative paths appended to CWD,
472
+ // absolute paths are used as is)
473
+ const newpath = this.buildAbsolutePath(path, true);
474
+ if (newpath.error) {
475
+ return newpath.error; // Return error if path is invalid
476
+ }
477
+
478
+ // remove all in a folder, but this doesn't seem to be
479
+ // used for *DELETE as files are deleted one by one
480
+ if (this.dir_exists(newpath.path) == FR_OK) {
481
+ // delete all the files within the folder
482
+ const dirPrefix = newpath.path.endsWith("/") ? newpath.path : newpath.path + "/";
483
+ this.allfiles
484
+ .filter((file) => file.path.startsWith(dirPrefix))
485
+ .forEach((file) => (file.path = "§" + file.path));
486
+
487
+ return FR_OK;
488
+ }
489
+
490
+ if (this.file_exists(newpath.path) == FR_OK) {
491
+ // remove the file from the list
492
+ this.allfiles.filter((file) => file.path == newpath.path).forEach((file) => (file.path = "§" + file.path));
493
+
494
+ return FR_OK;
495
+ }
496
+
497
+ return FR_NO_PATH;
498
+ }
499
+
500
+ trimToFilename(globaldata) {
501
+ let path = String.fromCharCode(...globaldata.slice(0, -1)).split("\0")[0];
502
+ // when deleting folders, the pathname is echoed
503
+ // back by the ATOM which means they have <...> around the
504
+ // name
505
+ if (path.startsWith("<") && path.endsWith(">")) path = path.slice(1, -1);
506
+ return path;
507
+ }
508
+
509
+ WFN_SetCWDirectory() {
510
+ const dirname = this.trimToFilename(this.globalData);
511
+ let ret = this.f_chdir(dirname);
512
+ this.mmc.WriteDataPort(STATUS_COMPLETE | ret);
513
+ }
514
+
515
+ WFN_FileSeek() {
516
+ this.seekpos =
517
+ this.globalData[0] | (this.globalData[1] << 8) | (this.globalData[2] << 16) | (this.globalData[3] << 24);
518
+ }
519
+
520
+ WFN_FileRead() {
521
+ if (this.globalAmount === 0) {
522
+ this.globalAmount = 256;
523
+ }
524
+ const read = Math.min(this.fildata[0].data.length, this.globalAmount);
525
+ const fildataEnd = this.fildataIndex + read;
526
+ const data = this.fildata[0].data.slice(this.fildataIndex, fildataEnd);
527
+ let ret = 0;
528
+ this.globalData = data;
529
+ this.fildataIndex = fildataEnd;
530
+ if (this.filenum > 0 && ret === 0 && this.globalAmount !== read) {
531
+ this.mmc.WriteDataPort(STATUS_EOF);
532
+ } else {
533
+ this.mmc.WriteDataPort(STATUS_COMPLETE | ret);
534
+ }
535
+ }
536
+
537
+ WFN_FileWrite() {
538
+ if (this.globalAmount === 0) {
539
+ this.globalAmount = 256;
540
+ }
541
+ const wrote = this.globalAmount;
542
+ const fildataEnd = this.fildataIndex + wrote;
543
+
544
+ // append this.globalData to this.fildata[0] at this.fildataIndex
545
+ let oldData = this.fildata[0].data;
546
+ let newLength = Math.max(oldData.length, fildataEnd);
547
+ let newData = new Uint8Array(newLength);
548
+ newData.set(oldData, 0);
549
+ newData.set(this.globalData.slice(0, wrote), this.fildataIndex);
550
+ this.fildata[0].data = newData;
551
+
552
+ this.fildataIndex = fildataEnd;
553
+
554
+ // const res = this.f_write(fil, (void*)globalData, globalAmount, &written);
555
+
556
+ let res = 0; // always works !
557
+ this.mmc.WriteDataPort(STATUS_COMPLETE | res);
558
+ }
559
+
560
+ WFN_ExecuteArbitrary() {}
561
+
562
+ WFN_FileOpenRAF() {}
563
+ WFN_FileDelete() {
564
+ const pathname = this.trimToFilename(this.globalData);
565
+ const ret = this.f_unlink(pathname);
566
+ this.mmc.WriteDataPort(STATUS_COMPLETE | ret);
567
+ }
568
+ WFN_FileGetInfo() {
569
+ this.mmc.WriteDataPort(STATUS_EOF);
570
+ }
571
+
572
+ WFN_DirectoryCreate() {
573
+ // Not implemented — return EOF to signal failure to the Atom
574
+ this.mmc.WriteDataPort(STATUS_EOF);
575
+ }
576
+ WFN_DirectoryDelete() {
577
+ this.mmc.WriteDataPort(STATUS_EOF);
578
+ }
579
+ WFN_Rename() {
580
+ this.mmc.WriteDataPort(STATUS_EOF);
581
+ }
582
+
583
+ reset() {
584
+ this.CWD = "";
585
+ }
586
+
587
+ clearData() {
588
+ this.globalData = new Uint8Array(256);
589
+ this.globalIndex = 0;
590
+ this.globalDataPresent = 0;
591
+ }
592
+
593
+ addData(data) {
594
+ this.globalData[this.globalIndex] = data;
595
+ ++this.globalIndex;
596
+ this.globalDataPresent = 1;
597
+ }
598
+
599
+ getData(restart = false) {
600
+ if (restart) {
601
+ this.globalIndex = 0;
602
+ this.globalDataPresent = 0;
603
+ }
604
+ let val = 0;
605
+ if (this.globalIndex < this.globalData.length) val = this.globalData[this.globalIndex] | 0;
606
+ this.globalIndex++;
607
+ return val;
608
+ }
609
+
610
+ setTransferLength(length) {
611
+ this.globalAmount = length;
612
+ }
613
+
614
+ fileOpen(mode) {
615
+ if (!this.allfiles) return 4; // no file
616
+ let ret = 0;
617
+ let fname = this.trimToFilename(this.globalData);
618
+
619
+ if (this.filenum === 0) {
620
+ this.fildataIndex = 0; // FIXME : should be one for each fildata if going down this line!
621
+ // The scratch file is fixed, so we are backwards compatible with 2.9 firmware
622
+ let fopen = this.f_open(fname, mode);
623
+ if (fopen.error == FR_OK) this.fildata[0] = this.allfiles[fopen.fp];
624
+ ret = fopen.error;
625
+ } else {
626
+ this.filenum = 0;
627
+ if (this.fildata[1] == null) {
628
+ this.filenum = 1;
629
+ } else if (this.fildata[2] == null) {
630
+ this.filenum = 2;
631
+ } else if (this.fildata[3] == null) {
632
+ this.filenum = 3;
633
+ }
634
+ if (this.filenum > 0) {
635
+ let fopen = this.f_open(fname, mode);
636
+ if (fopen.error == FR_OK) {
637
+ this.fildata[this.filenum] = this.allfiles[fopen.fp];
638
+ // No error, so update the return value to indicate the file num
639
+ ret = FILENUM_OFFSET | this.filenum;
640
+ }
641
+ } else {
642
+ // All files are open, return too many open files
643
+ ret = ERROR_TOO_MANY_OPEN;
644
+ }
645
+ }
646
+ return STATUS_COMPLETE | ret;
647
+ }
648
+
649
+ // Convert a path to an absolute, normalized path and optionally validate 8.3 filename
650
+ buildAbsolutePath(xpath, validateName = true) {
651
+ // Normalize path separators
652
+ let path = String(xpath).replace(/\\/g, "/");
653
+
654
+ // Optionally validate 8.3 filename rules
655
+ if (validateName) {
656
+ // Find the last element of the path
657
+ let nameIdx = path.lastIndexOf("/");
658
+ let name = nameIdx === -1 ? path : path.slice(nameIdx + 1);
659
+
660
+ // Find the suffix
661
+ let suffixIdx = name.indexOf(".");
662
+ let namePart = suffixIdx !== -1 ? name.slice(0, suffixIdx) : name;
663
+ let suffixPart = suffixIdx !== -1 ? name.slice(suffixIdx + 1) : "";
664
+
665
+ // Validate the name part
666
+ if (namePart.length < 1 || namePart.length > 8) {
667
+ // Name not between 1 and 8 characters
668
+ return { error: FR_INVALID_NAME };
669
+ }
670
+
671
+ // Validate the optional suffix part
672
+ if (suffixIdx !== -1) {
673
+ // Reject multiple suffixes
674
+ if (suffixPart.includes(".")) {
675
+ return { error: FR_INVALID_NAME };
676
+ }
677
+ if (suffixPart.length === 0) {
678
+ // Remove a dangling suffix
679
+ path = path.slice(0, path.length - 1);
680
+ } else if (suffixPart.length > 3) {
681
+ // Suffix too long
682
+ return { error: FR_INVALID_NAME };
683
+ }
684
+ }
685
+ }
686
+
687
+ // Make the path absolute
688
+ let absPath;
689
+ if (path.startsWith("/")) {
690
+ // absolute: append the path to the root directory path
691
+ absPath = path;
692
+ } else {
693
+ // relative: append the path to current directory path
694
+ absPath = this.CWD + "/" + path; // CWD should be MMCPath
695
+ }
696
+ return { path: absPath };
697
+ }
698
+
699
+ // Check if a file exists in allfiles by name (returns FR_OK if exists, FR_NO_PATH if not)
700
+ file_exists(name) {
701
+ // if (!this.allfiles || !Array.isArray(this.allfiles)) return FR_NO_PATH;
702
+ // Normalize name to absolute path
703
+ const absResult = this.buildAbsolutePath(name, false);
704
+ if (absResult.error) return FR_NO_PATH;
705
+ const absName = absResult.path;
706
+ // Search for a WFNFile with matching path (not a directory)
707
+ const file = this.allfiles.find((f) => f.path === absName && !f.path.startsWith("§"));
708
+ return file ? FR_OK : FR_NO_PATH;
709
+ }
710
+
711
+ open(path, mode) {
712
+ let fileIndex = -1;
713
+ if (this.allfiles) {
714
+ fileIndex = this.allfiles.findIndex((f) => f.path === path);
715
+
716
+ if (fileIndex === -1 && mode & O_CREAT) {
717
+ // Create new file
718
+ this.allfiles.push(new WFNFile(path, new Uint8Array()));
719
+ fileIndex = this.allfiles.length - 1;
720
+ } else if (fileIndex !== -1) {
721
+ //cannot find file and not creating it.
722
+ }
723
+ }
724
+
725
+ return fileIndex;
726
+ }
727
+
728
+ // Simulate f_open in JavaScript
729
+ // Returns FR_OK (0) on success, or error code
730
+ // fp: file object (JS object), path: string, mode: integer flags
731
+ f_open(path, mode) {
732
+ // Build absolute path and check validity
733
+ const absResult = this.buildAbsolutePath(path, true);
734
+ if (absResult.error) {
735
+ return { error: FR_INVALID_NAME };
736
+ }
737
+ const open_path = absResult.path;
738
+
739
+ // Check if file exists
740
+ let exists = this.file_exists(open_path);
741
+
742
+ // Mask mode flags
743
+ mode &= FA_READ | FA_WRITE | FA_CREATE_ALWAYS | FA_OPEN_ALWAYS | FA_CREATE_NEW;
744
+
745
+ let open_mode = 0;
746
+ if (exists === FR_OK) {
747
+ if (mode & FA_CREATE_NEW) return { error: FR_EXIST };
748
+ if (mode & FA_CREATE_ALWAYS) open_mode = O_CREAT;
749
+ if (mode & (FA_READ | FA_WRITE)) {
750
+ if (mode & FA_WRITE) open_mode |= O_RDWR;
751
+ else open_mode |= O_RDONLY;
752
+ }
753
+ } else {
754
+ if (mode & (FA_OPEN_ALWAYS | FA_CREATE_NEW | FA_CREATE_ALWAYS)) open_mode = O_CREAT | O_RDWR;
755
+ else return { error: FR_NO_FILE };
756
+ }
757
+
758
+ // Simulate file open/create
759
+ let fileIndex = this.open(open_path, open_mode | O_BINARY);
760
+
761
+ if (fileIndex >= 0) {
762
+ return { fp: fileIndex, error: FR_OK };
763
+ } else {
764
+ return { error: FR_INVALID_NAME };
765
+ }
766
+ }
767
+ }
768
+
769
+ /**
770
+ * AtomMMC2 emulates the AtoMMC2 device for the Acorn Atom.
771
+ * Handles SD/MMC file operations and communication with the Atom.
772
+ */
773
+ export class AtomMMC2 {
774
+ /**
775
+ * Attach a gamepad object for joystick support.
776
+ * @param {Object} gamepad
777
+ */
778
+ attachGamepad(gamepad) {
779
+ this.gamepad = gamepad;
780
+ }
781
+
782
+ /**
783
+ * Reset the MMC state.
784
+ * @param {boolean} hard - If true, perform a hard reset.
785
+ */
786
+ reset(hard) {
787
+ if (hard) {
788
+ this.configByte = 0xff;
789
+ }
790
+ this.WFN.reset();
791
+ this.seekpos = 0;
792
+ }
793
+
794
+ /**
795
+ * Set the MMC data (unzipped files).
796
+ * @param {Object} data
797
+ */
798
+ SetMMCData(data) {
799
+ this.WFN.allfiles = data;
800
+ }
801
+
802
+ /**
803
+ * Get the MMC data.
804
+ * @returns {Object}
805
+ */
806
+ GetMMCData() {
807
+ return this.WFN.allfiles;
808
+ }
809
+
810
+ /**
811
+ * clear
812
+ * @returns
813
+ */
814
+ ClearMMCData() {
815
+ const fname = "README".padEnd(16, "\0");
816
+ const loadaddr = 0x2900;
817
+ const basicstart = 0xb2c2;
818
+ const flen = 0x003e;
819
+ const basicfile = "\r\0\n REM created by jsatom\r\0\x14 REM \x19commandercoder.com\r\0\x1E END\r";
820
+ const fend = 0xc3;
821
+
822
+ // Build the byte array for the README file
823
+ // Format: [fname (16 bytes), loadaddr (2 bytes LE), basicstart (2 bytes LE), flen (2 bytes LE), basicfile (flen bytes), fend (1 byte)]
824
+ const readmeBytes = new Uint8Array(16 + 2 + 2 + 2 + basicfile.length + 1);
825
+ let offset = 0;
826
+ // fname (16 bytes)
827
+ for (let i = 0; i < 16; i++) {
828
+ readmeBytes[offset++] = fname.charCodeAt(i);
829
+ }
830
+ // loadaddr (2 bytes, little endian)
831
+ readmeBytes[offset++] = loadaddr & 0xff;
832
+ readmeBytes[offset++] = (loadaddr >> 8) & 0xff;
833
+ // basicstart (2 bytes, little endian)
834
+ readmeBytes[offset++] = basicstart & 0xff;
835
+ readmeBytes[offset++] = (basicstart >> 8) & 0xff;
836
+ // flen (2 bytes, little endian)
837
+ readmeBytes[offset++] = flen & 0xff;
838
+ readmeBytes[offset++] = (flen >> 8) & 0xff;
839
+ // basicfile (flen bytes)
840
+ for (let i = 0; i < basicfile.length; i++) {
841
+ readmeBytes[offset++] = basicfile.charCodeAt(i);
842
+ }
843
+ // fend (1 byte)
844
+ readmeBytes[offset] = fend;
845
+
846
+ this.WFN.allfiles = [new WFNFile("/README", readmeBytes)];
847
+ }
848
+
849
+ /**
850
+ * @param {Object} cpu - The CPU instance.
851
+ */
852
+ constructor(cpu) {
853
+ this.cpu = cpu;
854
+ this.gamepad = null;
855
+ this.MMCtoAtom = STATUS_BUSY;
856
+ this.heartbeat = 0x55;
857
+ this.MCUStatus = MMC_MCU_BUSY;
858
+ this.configByte = 0;
859
+ this.lastaddr = CMD_REG; // address latch for reads
860
+ this.byteValueLatch = 0;
861
+ this.worker = null;
862
+ this.seekpos = 0;
863
+ this.WildPattern = ".*";
864
+ this.foldersSeen = [];
865
+ this.dfn = 0;
866
+ this.TRISB = 0;
867
+ this.PORTB = 0;
868
+ this.LATB = 0;
869
+
870
+ this.WFN = new WFN(this, cpu);
871
+ this.reset(true);
872
+ }
873
+
874
+ // MMCtoAtom and MCUStatus are used to transfer data
875
+ // from the MMC to the Atom and vice versa.
876
+
877
+ // Set the WROTE bit and write data to the Atom
878
+ WriteDataPort(b) {
879
+ this.MMCtoAtom = b;
880
+ this.MCUStatus &= ~MMC_MCU_BUSY;
881
+ this.MCUStatus |= MMC_MCU_WROTE;
882
+ }
883
+
884
+ // Set the WROTE bit and return the data from the Atom
885
+ ReadDataPort() {
886
+ this.MCUStatus &= ~MMC_MCU_READ;
887
+ this.MCUStatus |= MMC_MCU_WROTE;
888
+
889
+ return this.MMCtoAtom;
890
+ }
891
+
892
+ // CPU is writing to 0xb400-0xb40c
893
+ write(addr, val) {
894
+ this.lastaddr = addr;
895
+ this.at_process(addr, val, true);
896
+ }
897
+
898
+ // CPU is reading from 0xb400-0xb40c
899
+ read(addr) {
900
+ const Current = this.MMCtoAtom;
901
+ const val = Current & 0xff;
902
+ const reg = addr & 0x0f;
903
+ const stat = this.MCUStatus;
904
+ this.MCUStatus &= ~MMC_MCU_READ;
905
+ addr = this.lastaddr;
906
+ if (reg === STATUS_REG) {
907
+ return stat;
908
+ }
909
+ this.at_process(addr, val, false);
910
+ return Current;
911
+ }
912
+ /**
913
+ * Process Atom MMC register access.
914
+ * @param {number} addr
915
+ * @param {number} val
916
+ * @param {boolean} write
917
+ */
918
+ at_process(addr, val, write) {
919
+ const LatchedAddress = addr & 0x0f;
920
+ this.latchedAddress = LatchedAddress;
921
+ const ADDRESS_MASK = 0x07;
922
+ this.worker = null;
923
+ this.MCUStatus |= MMC_MCU_READ;
924
+ if (write === false) {
925
+ this.MCUStatus &= ~MMC_MCU_READ;
926
+ switch (LatchedAddress) {
927
+ case READ_DATA_REG: {
928
+ let data = this.WFN.getData();
929
+ this.WriteDataPort(data);
930
+ break;
931
+ }
932
+ }
933
+ } else {
934
+ switch (LatchedAddress & ADDRESS_MASK) {
935
+ case CMD_REG: {
936
+ let received = val & 0xff;
937
+ if ((received & 0x98) === 0x10) {
938
+ this.WFN.filenum = (received >> 5) & 3;
939
+ received &= 0x9f;
940
+ }
941
+ if ((received & 0xf0) === 0x20) {
942
+ this.WFN.filenum = (received >> 2) & 3;
943
+ received &= 0xf3;
944
+ }
945
+ this.WriteDataPort(STATUS_BUSY);
946
+ this.MCUStatus |= MMC_MCU_BUSY;
947
+ if (received === CMD_DIR_OPEN) {
948
+ this.worker = () => this.WFN.WFN_DirectoryOpen();
949
+ } else if (received === CMD_DIR_READ) {
950
+ this.worker = () => this.WFN.WFN_DirectoryRead();
951
+ } else if (received === CMD_DIR_CWD) {
952
+ this.worker = () => this.WFN.WFN_SetCWDirectory();
953
+ } else if (received == CMD_DIR_MKDIR) {
954
+ // create directory
955
+ this.worker = () => this.WFN.WFN_DirectoryCreate();
956
+ } else if (received == CMD_DIR_RMDIR) {
957
+ // delete directory
958
+ this.worker = () => this.WFN.WFN_DirectoryDelete();
959
+ } else if (received == CMD_RENAME) {
960
+ // rename
961
+ this.worker = () => this.WFN.WFN_Rename();
962
+ } else if (received === CMD_FILE_CLOSE) {
963
+ this.worker = () => this.WFN.WFN_FileClose();
964
+ } else if (received === CMD_FILE_OPEN_READ) {
965
+ this.worker = () => this.WFN.WFN_FileOpenRead();
966
+ } else if (received === CMD_FILE_OPEN_WRITE) {
967
+ this.worker = () => this.WFN.WFN_FileOpenWrite();
968
+ } else if (received === CMD_FILE_OPEN_RAF) {
969
+ this.worker = () => this.WFN.WFN_FileOpenRAF();
970
+ } else if (received === CMD_FILE_DELETE) {
971
+ this.worker = () => this.WFN.WFN_FileDelete();
972
+ } else if (received === CMD_FILE_GETINFO) {
973
+ this.worker = () => this.WFN.WFN_FileGetInfo();
974
+ } else if (received === CMD_FILE_SEEK) {
975
+ this.worker = () => this.WFN.WFN_FileSeek();
976
+ } else if (received === CMD_INIT_READ) {
977
+ let data = this.WFN.getData(true);
978
+ this.WriteDataPort(data);
979
+ this.lastaddr = READ_DATA_REG;
980
+ } else if (received === CMD_INIT_WRITE) {
981
+ this.WFN.clearData();
982
+ } else if (received === CMD_READ_BYTES) {
983
+ this.WFN.setTransferLength(this.byteValueLatch);
984
+ this.worker = () => this.WFN.WFN_FileRead();
985
+ } else if (received === CMD_WRITE_BYTES) {
986
+ this.WFN.setTransferLength(this.byteValueLatch);
987
+ this.worker = () => this.WFN.WFN_FileWrite();
988
+ } else if (received === CMD_EXEC_PACKET) {
989
+ this.worker = () => this.WFN.WFN_ExecuteArbitrary();
990
+ } else if (received === CMD_GET_FW_VER) {
991
+ this.WriteDataPort((VSN_MAJ << 4) | VSN_MIN);
992
+ } else if (received === CMD_GET_BL_VER) {
993
+ this.WriteDataPort(1);
994
+ } else if (received === CMD_GET_CFG_BYTE) {
995
+ this.WriteDataPort(this.configByte);
996
+ } else if (received === CMD_SET_CFG_BYTE) {
997
+ this.configByte = this.byteValueLatch;
998
+ this.WriteDataPort(STATUS_OK);
999
+ } else if (received === CMD_READ_AUX) {
1000
+ this.WriteDataPort(this.latchedAddress);
1001
+ } else if (received === CMD_GET_HEARTBEAT) {
1002
+ this.WriteDataPort(this.heartbeat);
1003
+ this.heartbeat ^= 0xff;
1004
+ } else if (received === CMD_GET_CARD_TYPE) {
1005
+ this.WriteDataPort(0x01);
1006
+ } else if (received === CMD_GET_PORT_DDR) {
1007
+ this.WriteDataPort(this.TRISB);
1008
+ } else if (received === CMD_SET_PORT_DDR) {
1009
+ this.TRISB = this.byteValueLatch;
1010
+ this.WriteDataPort(STATUS_OK);
1011
+ } else if (received === CMD_READ_PORT) {
1012
+ let JOYSTICK = 0xff;
1013
+ const joyst = true;
1014
+ if (joyst && this.gamepad && this.gamepad.gamepadButtons !== undefined) {
1015
+ if (this.gamepad.gamepadButtons[15]) JOYSTICK ^= 1;
1016
+ if (this.gamepad.gamepadButtons[14]) JOYSTICK ^= 2;
1017
+ if (this.gamepad.gamepadButtons[13]) JOYSTICK ^= 4;
1018
+ if (this.gamepad.gamepadButtons[12]) JOYSTICK ^= 8;
1019
+ if (this.gamepad.gamepadButtons[0]) JOYSTICK ^= 0x10;
1020
+ this.WriteDataPort(JOYSTICK);
1021
+ } else {
1022
+ this.WriteDataPort(this.PORTB);
1023
+ }
1024
+ } else if (received === CMD_WRITE_PORT) {
1025
+ this.LATB = this.byteValueLatch;
1026
+ this.WriteDataPort(STATUS_OK);
1027
+ } else {
1028
+ console.warn(`MMC: unrecognised CMD: 0x${received.toString(16)}`);
1029
+ }
1030
+ break;
1031
+ }
1032
+ case WRITE_DATA_REG: {
1033
+ const received = val & 0xff;
1034
+ this.WFN.addData(received);
1035
+ break;
1036
+ }
1037
+ case LATCH_REG: {
1038
+ const received = val & 0xff;
1039
+ this.byteValueLatch = received;
1040
+ this.WriteDataPort(this.byteValueLatch);
1041
+ break;
1042
+ }
1043
+ case STATUS_REG: {
1044
+ // does nothing
1045
+ break;
1046
+ }
1047
+ }
1048
+ if (this.worker) {
1049
+ this.worker();
1050
+ }
1051
+ }
1052
+ }
1053
+ }