238 lines
9.0 KiB
TypeScript
238 lines
9.0 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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;
|
||
|
|
}
|