349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
/**
|
|
* fatImage.ts — build a FAT16 filesystem image from a set of files, in the
|
|
* browser, with no dependencies.
|
|
*
|
|
* Used by the microSD card feature: the auto-copy of project files (free) and
|
|
* the "SD Card" upload (paid) both produce a `{ name, data }[]` list that this
|
|
* turns into a `Uint8Array` disk image. That image is handed to the
|
|
* `microsd-card` simulation part via `element.sdImageData`, where the firmware
|
|
* mounts it over SD-over-SPI and reads the files with `SD.open(...)`.
|
|
*
|
|
* Layout: "super-floppy" FAT16 (BPB at sector 0, no MBR) — SdFat / the Arduino
|
|
* SD library mount this directly. 512-byte sectors, 1 sector per cluster, 2
|
|
* FATs, 512 root entries. Short (8.3) names are emitted directly; names that do
|
|
* not fit 8.3 get a generated 8.3 alias plus VFAT long-name (LFN) entries so the
|
|
* real filename is preserved.
|
|
*
|
|
* Scope: root directory only (flat). Subdirectories (folder trees) are a later
|
|
* enhancement.
|
|
*/
|
|
|
|
export interface SdFile {
|
|
/** File name (no leading slash). Long names are preserved via LFN. */
|
|
name: string;
|
|
data: Uint8Array;
|
|
}
|
|
|
|
const SEC = 512;
|
|
|
|
function writeU16(buf: Uint8Array, off: number, v: number): void {
|
|
buf[off] = v & 0xff;
|
|
buf[off + 1] = (v >> 8) & 0xff;
|
|
}
|
|
function writeU32(buf: Uint8Array, off: number, v: number): void {
|
|
buf[off] = v & 0xff;
|
|
buf[off + 1] = (v >> 8) & 0xff;
|
|
buf[off + 2] = (v >> 16) & 0xff;
|
|
buf[off + 3] = (v >>> 24) & 0xff;
|
|
}
|
|
function writeAscii(buf: Uint8Array, off: number, s: string, len: number, pad = 0x20): void {
|
|
for (let i = 0; i < len; i++) buf[off + i] = i < s.length ? s.charCodeAt(i) & 0xff : pad;
|
|
}
|
|
|
|
/** Is `name` a valid uppercase-able 8.3 short name (no LFN needed)? */
|
|
function fitsShort(name: string): boolean {
|
|
const dot = name.lastIndexOf('.');
|
|
const base = dot < 0 ? name : name.slice(0, dot);
|
|
const ext = dot < 0 ? '' : name.slice(dot + 1);
|
|
if (base.length === 0 || base.length > 8 || ext.length > 3) return false;
|
|
return /^[A-Za-z0-9_~!#$%&'()@^{}-]+$/.test(base) && /^[A-Za-z0-9_~!#$%&'()@^{}-]*$/.test(ext);
|
|
}
|
|
|
|
/** Build the 11-byte padded 8.3 representation ("HELLO TXT"). */
|
|
function pad83(base: string, ext: string): string {
|
|
const b = (base.toUpperCase() + ' ').slice(0, 8);
|
|
const e = (ext.toUpperCase() + ' ').slice(0, 3);
|
|
return b + e;
|
|
}
|
|
|
|
function sanitize83(s: string): string {
|
|
return s.toUpperCase().replace(/[^A-Z0-9_~!#$%&'()@^{}-]/g, '_');
|
|
}
|
|
|
|
/** Generate a unique 11-char 8.3 name for `name`, tracking collisions. */
|
|
function shortNameFor(name: string, used: Set<string>): string {
|
|
const dot = name.lastIndexOf('.');
|
|
const baseRaw = dot < 0 ? name : name.slice(0, dot);
|
|
const extRaw = dot < 0 ? '' : name.slice(dot + 1);
|
|
const ext = sanitize83(extRaw).slice(0, 3);
|
|
|
|
if (fitsShort(name)) {
|
|
const s = pad83(sanitize83(baseRaw), ext);
|
|
if (!used.has(s)) {
|
|
used.add(s);
|
|
return s;
|
|
}
|
|
}
|
|
// Generated alias: BBBBBB~N.EXT
|
|
const baseSan = sanitize83(baseRaw).replace(/~/g, '_') || 'FILE';
|
|
for (let n = 1; n < 1_000_000; n++) {
|
|
const suffix = '~' + n;
|
|
const stem = (baseSan.slice(0, 8 - suffix.length) + suffix).slice(0, 8);
|
|
const s = pad83(stem, ext);
|
|
if (!used.has(s)) {
|
|
used.add(s);
|
|
return s;
|
|
}
|
|
}
|
|
throw new Error('fatImage: could not allocate a unique short name for ' + name);
|
|
}
|
|
|
|
/** LFN checksum of the 11-byte short name. */
|
|
function lfnChecksum(short11: string): number {
|
|
let sum = 0;
|
|
for (let i = 0; i < 11; i++) sum = (((sum & 1) << 7) + (sum >> 1) + short11.charCodeAt(i)) & 0xff;
|
|
return sum;
|
|
}
|
|
|
|
export interface BuildFatOptions {
|
|
/** Volume size in bytes. Default 8 MB (mirrors Wokwi). */
|
|
volumeBytes?: number;
|
|
/** 11-char volume label. */
|
|
label?: string;
|
|
}
|
|
|
|
/** A file listed out of a FAT16 image by `readFat16Image`. */
|
|
export interface FatDirFile {
|
|
/** Long name when LFN entries were present, else the 8.3 name. */
|
|
name: string;
|
|
size: number;
|
|
data: Uint8Array;
|
|
}
|
|
|
|
function readU16(buf: Uint8Array, off: number): number {
|
|
return buf[off] | (buf[off + 1] << 8);
|
|
}
|
|
function readU32(buf: Uint8Array, off: number): number {
|
|
return (buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24)) >>> 0;
|
|
}
|
|
|
|
/**
|
|
* List the ROOT directory of a FAT16 "super-floppy" image and extract each
|
|
* file's bytes — the inverse of `buildFat16Image`, but general enough for a
|
|
* card a guest OS (FatFs / Arduino SD) has written to: it follows real
|
|
* cluster chains, honours sectors-per-cluster from the BPB, decodes VFAT
|
|
* long names and skips deleted/volume/directory entries. Subdirectories are
|
|
* not descended (the builder is root-only too).
|
|
*
|
|
* Returns [] for anything that does not look like a mountable FAT16 volume —
|
|
* the caller treats that as "no readable card", never as an error.
|
|
*/
|
|
export function readFat16Image(img: Uint8Array): FatDirFile[] {
|
|
try {
|
|
if (img.length < SEC || img[510] !== 0x55 || img[511] !== 0xaa) return [];
|
|
const bytesPerSec = readU16(img, 11);
|
|
const spc = img[13];
|
|
const reserved = readU16(img, 14);
|
|
const numFats = img[16];
|
|
const rootEntries = readU16(img, 17);
|
|
const fatSz = readU16(img, 22);
|
|
if (!bytesPerSec || !spc || !reserved || !numFats || !fatSz || !rootEntries) return [];
|
|
|
|
const fatStart = reserved * bytesPerSec;
|
|
const rootStart = (reserved + numFats * fatSz) * bytesPerSec;
|
|
const rootBytes = rootEntries * 32;
|
|
const dataStart = rootStart + Math.ceil(rootBytes / bytesPerSec) * bytesPerSec;
|
|
const clusterBytes = spc * bytesPerSec;
|
|
if (dataStart >= img.length) return [];
|
|
|
|
const out: FatDirFile[] = [];
|
|
// LFN entries accumulate (in on-disk reverse order) until their 8.3 entry.
|
|
let lfnParts: Array<{ seq: number; chars: string }> = [];
|
|
|
|
for (let slot = 0; slot < rootEntries; slot++) {
|
|
const d = rootStart + slot * 32;
|
|
if (d + 32 > img.length) break;
|
|
const first = img[d];
|
|
if (first === 0x00) break; // end of directory
|
|
if (first === 0xe5) {
|
|
lfnParts = []; // deleted entry
|
|
continue;
|
|
}
|
|
const attr = img[d + 11];
|
|
if ((attr & 0x0f) === 0x0f) {
|
|
// VFAT long-name entry: 13 UTF-16 chars in fixed slots.
|
|
const slots = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30];
|
|
let chars = '';
|
|
for (const s of slots) {
|
|
const code = img[d + s] | (img[d + s + 1] << 8);
|
|
if (code === 0x0000 || code === 0xffff) break;
|
|
chars += String.fromCharCode(code);
|
|
}
|
|
lfnParts.push({ seq: img[d] & 0x1f, chars });
|
|
continue;
|
|
}
|
|
if (attr & 0x08 || attr & 0x10) {
|
|
// volume label / directory
|
|
lfnParts = [];
|
|
continue;
|
|
}
|
|
|
|
let name: string;
|
|
if (lfnParts.length) {
|
|
name = lfnParts
|
|
.sort((a, b) => a.seq - b.seq)
|
|
.map((p) => p.chars)
|
|
.join('');
|
|
} else {
|
|
const base = String.fromCharCode(...img.subarray(d, d + 8)).trimEnd();
|
|
const ext = String.fromCharCode(...img.subarray(d + 8, d + 11)).trimEnd();
|
|
name = ext ? `${base}.${ext}` : base;
|
|
}
|
|
lfnParts = [];
|
|
|
|
const size = readU32(img, d + 28);
|
|
let cluster = readU16(img, d + 26);
|
|
const data = new Uint8Array(size);
|
|
let got = 0;
|
|
// Follow the chain; guard against loops with a step cap.
|
|
for (let steps = 0; got < size && cluster >= 2 && cluster < 0xfff8; steps++) {
|
|
if (steps > 1_000_000) break;
|
|
const at = dataStart + (cluster - 2) * clusterBytes;
|
|
if (at >= img.length) break;
|
|
const take = Math.min(clusterBytes, size - got, img.length - at);
|
|
data.set(img.subarray(at, at + take), got);
|
|
got += take;
|
|
cluster = readU16(img, fatStart + cluster * 2);
|
|
}
|
|
out.push({ name, size, data: got === size ? data : data.subarray(0, got) });
|
|
}
|
|
return out;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a FAT16 image containing `files` in the root directory.
|
|
* Throws if the files don't fit the volume or the root directory.
|
|
*/
|
|
export function buildFat16Image(files: SdFile[], opts: BuildFatOptions = {}): Uint8Array {
|
|
const volumeBytes = opts.volumeBytes ?? 8 * 1024 * 1024;
|
|
const totalSectors = Math.floor(volumeBytes / SEC);
|
|
const spc = 1; // sectors per cluster
|
|
const reserved = 1;
|
|
const numFats = 2;
|
|
const rootEntries = 512;
|
|
const rootDirSectors = Math.ceil((rootEntries * 32) / SEC);
|
|
|
|
// Iteratively size the FAT so it can map every data cluster.
|
|
let fatSz = 1;
|
|
for (;;) {
|
|
const dataSectors = totalSectors - reserved - numFats * fatSz - rootDirSectors;
|
|
const clusters = Math.floor(dataSectors / spc);
|
|
const needed = Math.ceil(((clusters + 2) * 2) / SEC);
|
|
if (needed <= fatSz) break;
|
|
fatSz = needed;
|
|
}
|
|
const dataSectors = totalSectors - reserved - numFats * fatSz - rootDirSectors;
|
|
const totalClusters = Math.floor(dataSectors / spc);
|
|
if (totalClusters < 4085 || totalClusters > 65524) {
|
|
throw new Error(`fatImage: cluster count ${totalClusters} outside FAT16 range — adjust volumeBytes`);
|
|
}
|
|
|
|
const img = new Uint8Array(totalSectors * SEC);
|
|
|
|
// ── Boot sector / BPB (FAT16) ───────────────────────────────────────────
|
|
img[0] = 0xeb;
|
|
img[1] = 0x3c;
|
|
img[2] = 0x90;
|
|
writeAscii(img, 3, 'VELXIO ', 8); // OEM name
|
|
writeU16(img, 11, SEC); // bytes per sector
|
|
img[13] = spc; // sectors per cluster
|
|
writeU16(img, 14, reserved); // reserved sectors
|
|
img[16] = numFats;
|
|
writeU16(img, 17, rootEntries);
|
|
writeU16(img, 19, totalSectors < 0x10000 ? totalSectors : 0); // total sectors (16)
|
|
img[21] = 0xf8; // media descriptor (fixed disk)
|
|
writeU16(img, 22, fatSz); // sectors per FAT
|
|
writeU16(img, 24, 0x3f); // sectors per track
|
|
writeU16(img, 26, 0xff); // num heads
|
|
writeU32(img, 28, 0); // hidden sectors
|
|
writeU32(img, 32, totalSectors < 0x10000 ? 0 : totalSectors); // total sectors (32)
|
|
img[36] = 0x80; // drive number
|
|
img[38] = 0x29; // extended boot signature
|
|
writeU32(img, 39, 0x564c5849); // volume id ("VLXI")
|
|
writeAscii(img, 43, (opts.label ?? 'VELXIO SD').toUpperCase(), 11);
|
|
writeAscii(img, 54, 'FAT16 ', 8);
|
|
img[510] = 0x55;
|
|
img[511] = 0xaa;
|
|
|
|
// ── FAT regions ─────────────────────────────────────────────────────────
|
|
const fat1 = reserved * SEC;
|
|
const fat2 = (reserved + fatSz) * SEC;
|
|
const setFat = (cluster: number, value: number): void => {
|
|
writeU16(img, fat1 + cluster * 2, value);
|
|
writeU16(img, fat2 + cluster * 2, value);
|
|
};
|
|
setFat(0, 0xfff8); // media descriptor in entry 0
|
|
setFat(1, 0xffff); // end-of-chain marker in entry 1
|
|
|
|
const rootStart = (reserved + numFats * fatSz) * SEC;
|
|
const dataStart = (reserved + numFats * fatSz + rootDirSectors) * SEC;
|
|
const clusterBytes = spc * SEC;
|
|
|
|
let nextCluster = 2;
|
|
let rootSlot = 0;
|
|
const used = new Set<string>(); // short-name uniqueness across files
|
|
|
|
for (const f of files) {
|
|
const short = shortNameFor(f.name, used);
|
|
const need83 = !fitsShort(f.name);
|
|
|
|
// Long-name (VFAT) entries, stored in reverse before the 8.3 entry.
|
|
const lfnEntries: Uint8Array[] = [];
|
|
if (need83) {
|
|
const cksum = lfnChecksum(short);
|
|
const chars = f.name + '\u0000'; // name + NUL terminator; rest padded 0xFFFF
|
|
const count = Math.ceil(chars.length / 13);
|
|
for (let seq = 1; seq <= count; seq++) {
|
|
const e = new Uint8Array(32);
|
|
e[0] = seq | (seq === count ? 0x40 : 0x00);
|
|
e[11] = 0x0f; // LFN attribute
|
|
e[13] = cksum;
|
|
const slots = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30];
|
|
for (let k = 0; k < 13; k++) {
|
|
const ci = (seq - 1) * 13 + k;
|
|
const code = ci < chars.length ? chars.charCodeAt(ci) : 0xffff;
|
|
e[slots[k]] = code & 0xff;
|
|
e[slots[k] + 1] = (code >> 8) & 0xff;
|
|
}
|
|
lfnEntries.unshift(e); // reverse order
|
|
}
|
|
}
|
|
|
|
// Allocate clusters and write data.
|
|
const len = f.data.length;
|
|
const fileClusters = Math.max(1, Math.ceil(len / clusterBytes));
|
|
if (nextCluster + fileClusters - 1 > totalClusters + 1) {
|
|
throw new Error(`fatImage: files exceed volume capacity (${volumeBytes} bytes)`);
|
|
}
|
|
const startCluster = nextCluster;
|
|
for (let i = 0; i < fileClusters; i++) {
|
|
const cl = nextCluster++;
|
|
const at = dataStart + (cl - 2) * clusterBytes;
|
|
img.set(f.data.subarray(i * clusterBytes, (i + 1) * clusterBytes), at);
|
|
setFat(cl, i === fileClusters - 1 ? 0xffff : cl + 1);
|
|
}
|
|
|
|
// Root directory: LFN entries (if any) then the 8.3 entry.
|
|
const entriesNeeded = lfnEntries.length + 1;
|
|
if (rootSlot + entriesNeeded > rootEntries) {
|
|
throw new Error('fatImage: too many files for the root directory (max 512 entries)');
|
|
}
|
|
for (const e of lfnEntries) {
|
|
img.set(e, rootStart + rootSlot * 32);
|
|
rootSlot++;
|
|
}
|
|
const d = rootStart + rootSlot * 32;
|
|
rootSlot++;
|
|
writeAscii(img, d, short, 11); // 8.3 name
|
|
img[d + 11] = 0x20; // attr = archive
|
|
writeU16(img, d + 26, startCluster); // first cluster (low word)
|
|
writeU16(img, d + 20, 0); // first cluster (high word) — 0 for FAT16
|
|
writeU32(img, d + 28, len); // file size
|
|
}
|
|
|
|
return img;
|
|
}
|