"""FAT32 + MBR layout for an SD card, as raw byte writes (no OS formatter). Follows the Microsoft FAT specification (fatgen103): BPB at sector 0, FSInfo at 1, backup boot sector at 6, two FATs, root directory in cluster 2 holding the volume label. Cluster sizes follow Windows' defaults. The partition starts at 1 MiB (SD erase-block friendly), MBR type 0x0C (FAT32 LBA), which is what the firmware's FatFs expects. Pure functions: the result is a list of (offset, bytes) writes plus zero ranges relative to the start of the DISK, so it can be tested on a buffer. """ from __future__ import annotations import math import struct from dataclasses import dataclass PART_START = 1024 * 1024 FAT32_MAX_BYTES = 32 * 1024**3 # Windows convention, and all the box needs MIN_CLUSTERS, MAX_CLUSTERS = 65525, 0x0FFFFFF5 RESERVED = 32 NUM_FATS = 2 MEDIA = 0xF8 @dataclass(frozen=True) class Fat32Layout: bytes_per_sector: int part_start: int # bytes from disk start part_sectors: int sectors_per_cluster: int fat_sectors: int clusters: int @property def data_start(self) -> int: # sectors from partition start return RESERVED + NUM_FATS * self.fat_sectors def _spc(sectors: int, bps: int) -> int: size = sectors * bps for limit, cluster in ((260 * 1024**2, 512), (8 * 1024**3, 4096), (16 * 1024**3, 8192), (32 * 1024**3, 16384)): if size <= limit: return max(1, cluster // bps) return max(1, 32768 // bps) def plan(disk_bytes: int, bps: int = 512) -> Fat32Layout: part_bytes = min(disk_bytes - PART_START, FAT32_MAX_BYTES) part_bytes -= part_bytes % (1024 * 1024) # whole MiB sectors = part_bytes // bps if sectors <= 0: raise ValueError("card is too small") spc = _spc(sectors, bps) fat = 1 for _ in range(20): # converge FAT size <-> cluster count clusters = (sectors - RESERVED - NUM_FATS * fat) // spc need = math.ceil((clusters + 2) * 4 / bps) if need == fat: break fat = need clusters = (sectors - RESERVED - NUM_FATS * fat) // spc if not MIN_CLUSTERS <= clusters < MAX_CLUSTERS: raise ValueError(f"card size gives {clusters} clusters - not valid for FAT32") return Fat32Layout(bps, PART_START, sectors, spc, fat, clusters) def _boot_sector(lay: Fat32Layout, label: bytes, vol_id: int) -> bytes: bps = lay.bytes_per_sector b = bytearray(bps) b[0:3] = b"\xEB\x58\x90" b[3:11] = b"MSWIN4.1" struct.pack_into(" bytes: b = bytearray(lay.bytes_per_sector) struct.pack_into(" bytes: b = bytearray(512) struct.pack_into(" bytes: label = text.upper().encode("ascii", "replace")[:11] return label.ljust(11, b" ") def build(lay: Fat32Layout, label: str, vol_id: int, disk_signature: int, disk_bytes: int) -> tuple[list[tuple[int, int]], list[tuple[int, bytes]]]: """(zero ranges, writes) in disk byte offsets; apply zero ranges first.""" bps, p = lay.bytes_per_sector, lay.part_start lab = volume_label(label) fat_bytes = lay.fat_sectors * bps fat1 = p + RESERVED * bps fat2 = fat1 + fat_bytes root = p + lay.data_start * bps cluster = lay.sectors_per_cluster * bps zeros = [ (0, PART_START), # old MBR / GPT header and gap (disk_bytes - PART_START, PART_START), # backup GPT at the end of the disk (p, RESERVED * bps), # reserved region (fat1, NUM_FATS * fat_bytes), # both FATs (root, cluster), # root directory cluster ] boot, info = _boot_sector(lay, lab, vol_id), _fsinfo(lay) first_fat = struct.pack("