/** * 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 { 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; } /** * 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(); // 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; }