Files
evan b22934fe0d Replace vvfat SD with browser-built raw FAT32 from OPFS
Browser SD files are persisted in OPFS (web/sdstore.js) and edited in the
Files tab. At boot the browser builds a deterministic 64 MiB FAT32 image
(web/fat32.js) with nested directories and VFAT long names, then mounts
it as a raw block device, bypassing QEMU's broken populated-vvfat WASM
coroutine path. Guest writes mutate only the ephemeral image.

Exposes a hidden #frame-generation indicator that increments on every
e-ink flush, so automation can wait on real display updates instead of
fixed sleeps. Adds tests/ with a Makefile runner: 'make test' for JS +
FAT32/mtools checks, 'make browser-test' for a headless-Firefox boot of
the official CrossPoint 1.4.1 firmware that navigates to
Test/example.txt. Bumps third_party/qemu to the SPI-SD/CMD13 fixes and
updates README/ROADMAP/ISSUES for the new architecture.
2026-07-21 07:43:07 -04:00

294 lines
10 KiB
JavaScript

export const FAT32_IMAGE_SIZE = 64 * 1024 * 1024;
const SECTOR_SIZE = 512;
const RESERVED_SECTORS = 32;
const FAT_COUNT = 2;
const ROOT_CLUSTER = 2;
const FAT_EOC = 0x0fffffff;
function setAscii(bytes, offset, value, length) {
for (let i = 0; i < length; i++) {
bytes[offset + i] = i < value.length ? value.charCodeAt(i) : 0x20;
}
}
function fatGeometry(imageSize) {
const totalSectors = imageSize / SECTOR_SIZE;
let fatSectors = 1;
while (true) {
const clusters = totalSectors - RESERVED_SECTORS - FAT_COUNT * fatSectors;
const required = Math.ceil((clusters + 2) * 4 / SECTOR_SIZE);
if (required <= fatSectors) {
return { totalSectors, fatSectors, clusters };
}
fatSectors = required;
}
}
function makeNode(name, directory, parent = null) {
return { name, directory, parent, children: new Map(), bytes: null, shortName: null, firstCluster: 0, clusters: [] };
}
function normalizeFiles(files) {
const root = makeNode('', true);
for (const { path, bytes } of files) {
const parts = path.replaceAll('\\', '/').split('/').filter(Boolean);
if (!parts.length || parts.some(part => part === '.' || part === '..' || part.includes('\0'))) {
throw new Error(`Invalid SD path: ${path}`);
}
let directory = root;
for (const part of parts.slice(0, -1)) {
const existing = directory.children.get(part);
if (existing && !existing.directory) {
throw new Error(`SD path conflicts with a file: ${path}`);
}
if (!existing) {
directory.children.set(part, makeNode(part, true, directory));
}
directory = directory.children.get(part);
}
const name = parts.at(-1);
if (directory.children.has(name)) {
throw new Error(`Duplicate SD path: ${path}`);
}
const file = makeNode(name, false, directory);
file.bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
directory.children.set(name, file);
}
return root;
}
function sanitizeShortPart(value) {
return value.toUpperCase().replace(/[^A-Z0-9$%'-_@~`!(){}^#&]/g, '_');
}
function assignShortNames(directory) {
const used = new Set();
for (const child of directory.children.values()) {
const dot = child.directory ? -1 : child.name.lastIndexOf('.');
const rawBase = dot > 0 ? child.name.slice(0, dot) : child.name;
const rawExt = dot > 0 ? child.name.slice(dot + 1) : '';
const base = sanitizeShortPart(rawBase);
const ext = sanitizeShortPart(rawExt).slice(0, 3);
const direct = base.length >= 1 && base.length <= 8 && rawExt.length <= 3 && `${base}${ext ? `.${ext}` : ''}` === child.name.toUpperCase();
let short;
if (direct) {
short = `${base.padEnd(8)}${ext.padEnd(3)}`;
} else {
for (let n = 1; ; n++) {
const suffix = `~${n}`;
short = `${base.slice(0, 8 - suffix.length).padEnd(8 - suffix.length, '_')}${suffix}${ext.padEnd(3)}`;
if (!used.has(short)) {
break;
}
}
}
if (used.has(short)) {
throw new Error(`Duplicate FAT short name for ${child.name}`);
}
used.add(short);
child.shortName = short;
if (child.directory) {
assignShortNames(child);
}
}
}
function lfnChecksum(shortName) {
let sum = 0;
for (const char of shortName) {
sum = ((sum & 1) << 7) + (sum >> 1) + char.charCodeAt(0);
sum &= 0xff;
}
return sum;
}
function lfnEntries(node) {
const shortDisplay = `${node.shortName.slice(0, 8).trimEnd()}${node.shortName.slice(8).trimEnd() ? `.${node.shortName.slice(8).trimEnd()}` : ''}`;
if (node.name === shortDisplay) {
return [];
}
const units = Array.from(node.name).flatMap(char => {
const code = char.codePointAt(0);
return code <= 0xffff ? [code] : [0xd800 + ((code - 0x10000) >> 10), 0xdc00 + ((code - 0x10000) & 0x3ff)];
});
if (units.length > 255) {
throw new Error(`FAT filename is too long: ${node.name}`);
}
units.push(0);
while (units.length % 13) {
units.push(0xffff);
}
const count = units.length / 13;
const checksum = lfnChecksum(node.shortName);
const entries = [];
const positions = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30];
for (let sequence = count; sequence >= 1; sequence--) {
const entry = new Uint8Array(32).fill(0xff);
entry[0] = sequence | (sequence === count ? 0x40 : 0);
entry[11] = 0x0f;
entry[12] = 0;
entry[13] = checksum;
entry[26] = 0;
entry[27] = 0;
const chunk = units.slice((sequence - 1) * 13, sequence * 13);
const view = new DataView(entry.buffer);
positions.forEach((position, index) => view.setUint16(position, chunk[index], true));
entries.push(entry);
}
return entries;
}
function shortEntry(node) {
const entry = new Uint8Array(32);
setAscii(entry, 0, node.shortName, 11);
entry[11] = node.directory ? 0x10 : 0x20;
const view = new DataView(entry.buffer);
const fatDate = ((2024 - 1980) << 9) | (1 << 5) | 1;
view.setUint16(14, 0, true);
view.setUint16(16, fatDate, true);
view.setUint16(18, fatDate, true);
view.setUint16(22, 0, true);
view.setUint16(24, fatDate, true);
view.setUint16(20, node.firstCluster >>> 16, true);
view.setUint16(26, node.firstCluster & 0xffff, true);
view.setUint32(28, node.directory ? 0 : node.bytes.length, true);
return entry;
}
function directoryEntryCount(directory) {
let count = directory.parent ? 2 : 0;
for (const child of directory.children.values()) {
count += lfnEntries(child).length + 1;
}
return count + 1;
}
function allNodes(root) {
const nodes = [];
const visit = node => {
nodes.push(node);
for (const child of node.children.values()) {
visit(child);
}
};
visit(root);
return nodes;
}
function allocateClusters(root, availableClusters) {
let next = ROOT_CLUSTER;
const allocate = (node, count) => {
if (next + count - ROOT_CLUSTER > availableClusters) {
throw new Error('SD card contents exceed the 64 MiB image capacity.');
}
node.clusters = Array.from({ length: count }, (_, i) => next + i);
node.firstCluster = count ? next : 0;
next += count;
};
for (const node of allNodes(root).filter(node => node.directory)) {
allocate(node, Math.max(1, Math.ceil(directoryEntryCount(node) * 32 / SECTOR_SIZE)));
}
for (const node of allNodes(root).filter(node => !node.directory)) {
allocate(node, Math.ceil(node.bytes.length / SECTOR_SIZE));
}
return next;
}
function writeBootSector(image, geometry) {
const boot = image.subarray(0, SECTOR_SIZE);
const view = new DataView(image.buffer);
boot.set([0xeb, 0x58, 0x90]);
setAscii(boot, 3, 'XTEINK ', 8);
view.setUint16(11, SECTOR_SIZE, true);
boot[13] = 1;
view.setUint16(14, RESERVED_SECTORS, true);
boot[16] = FAT_COUNT;
view.setUint16(17, 0, true);
view.setUint16(19, 0, true);
boot[21] = 0xf8;
view.setUint16(22, 0, true);
view.setUint16(24, 63, true);
view.setUint16(26, 255, true);
view.setUint32(28, 0, true);
view.setUint32(32, geometry.totalSectors, true);
view.setUint32(36, geometry.fatSectors, true);
view.setUint16(40, 0, true);
view.setUint16(42, 0, true);
view.setUint32(44, ROOT_CLUSTER, true);
view.setUint16(48, 1, true);
view.setUint16(50, 6, true);
boot[64] = 0x80;
boot[66] = 0x29;
view.setUint32(67, 0x58544549, true);
setAscii(boot, 71, 'XTEINK', 11);
setAscii(boot, 82, 'FAT32', 8);
boot[510] = 0x55;
boot[511] = 0xaa;
image.set(boot, 6 * SECTOR_SIZE);
const fsInfo = new DataView(image.buffer, SECTOR_SIZE, SECTOR_SIZE);
fsInfo.setUint32(0, 0x41615252, true);
fsInfo.setUint32(484, 0x61417272, true);
fsInfo.setUint32(488, 0xffffffff, true);
fsInfo.setUint32(492, ROOT_CLUSTER + 1, true);
fsInfo.setUint32(508, 0xaa550000, true);
image.copyWithin(7 * SECTOR_SIZE, SECTOR_SIZE, 2 * SECTOR_SIZE);
}
function dotEntry(name, cluster) {
const entry = new Uint8Array(32);
setAscii(entry, 0, name, 11);
entry[11] = 0x10;
const view = new DataView(entry.buffer);
view.setUint16(20, cluster >>> 16, true);
view.setUint16(26, cluster & 0xffff, true);
return entry;
}
export function buildFat32Image(files, imageSize = FAT32_IMAGE_SIZE) {
if (imageSize < 64 * 1024 * 1024 || imageSize % SECTOR_SIZE || (imageSize & (imageSize - 1))) {
throw new Error('FAT32 SD image size must be a power of two and at least 64 MiB.');
}
const geometry = fatGeometry(imageSize);
const root = normalizeFiles(files);
assignShortNames(root);
const nextCluster = allocateClusters(root, geometry.clusters);
const image = new Uint8Array(imageSize);
writeBootSector(image, geometry);
const fatOffset = RESERVED_SECTORS * SECTOR_SIZE;
const fatView = new DataView(image.buffer, fatOffset, geometry.fatSectors * SECTOR_SIZE);
fatView.setUint32(0, 0x0ffffff8, true);
fatView.setUint32(4, FAT_EOC, true);
for (const node of allNodes(root)) {
node.clusters.forEach((cluster, index) => fatView.setUint32(cluster * 4, node.clusters[index + 1] ?? FAT_EOC, true));
}
image.copyWithin(fatOffset + geometry.fatSectors * SECTOR_SIZE, fatOffset, fatOffset + geometry.fatSectors * SECTOR_SIZE);
const dataOffset = (RESERVED_SECTORS + FAT_COUNT * geometry.fatSectors) * SECTOR_SIZE;
const clusterOffset = cluster => dataOffset + (cluster - ROOT_CLUSTER) * SECTOR_SIZE;
for (const directory of allNodes(root).filter(node => node.directory)) {
const entries = [];
if (directory.parent) {
entries.push(dotEntry('. ', directory.firstCluster));
entries.push(dotEntry('.. ', directory.parent.parent ? directory.parent.firstCluster : ROOT_CLUSTER));
}
for (const child of directory.children.values()) {
entries.push(...lfnEntries(child), shortEntry(child));
}
entries.push(new Uint8Array(32));
const bytes = new Uint8Array(directory.clusters.length * SECTOR_SIZE);
entries.forEach((entry, index) => bytes.set(entry, index * 32));
directory.clusters.forEach((cluster, index) => image.set(bytes.subarray(index * SECTOR_SIZE, (index + 1) * SECTOR_SIZE), clusterOffset(cluster)));
}
for (const file of allNodes(root).filter(node => !node.directory)) {
file.clusters.forEach((cluster, index) => image.set(file.bytes.subarray(index * SECTOR_SIZE, (index + 1) * SECTOR_SIZE), clusterOffset(cluster)));
}
const freeClusters = geometry.clusters - (nextCluster - ROOT_CLUSTER);
new DataView(image.buffer).setUint32(SECTOR_SIZE + 488, freeClusters, true);
image.copyWithin(7 * SECTOR_SIZE, SECTOR_SIZE, 2 * SECTOR_SIZE);
return image;
}