Filesystem
How LainDOS turns a FAT12 or FAT16 image into DOS files: BPB validation, cluster math, directory traversal, path parsing, read caching, writes, rollback, and durable flushes.
The FAT path
LainDOS keeps the filesystem deliberately small: one active volume at a time, 8.3 names, FAT12 and FAT16 cluster chains, fixed low-memory buffers, and direct directory-entry writes. The tests around this code inspect serial output and disk images because persistence bugs are often invisible until QEMU exits.
0102030405Mount derives the FAT geometry
BPB fields become the working root, data, and cluster limits.LainDOS rejects BPBs that would make later math ambiguous: zero sectors-per-cluster, non-power-of-two cluster sizes, missing reserved/FAT/root geometry, invalid root-entry alignment, and total sectors that cannot cover the data region.
The mount path defaults to FAT12FAT12The 12-bit FAT format used by 1.44 MB floppy images and the early LainDOS boot path., switches to FAT16FAT16The 16-bit FAT format LainDOS uses for larger hard-disk style images. from the BPBBPBThe FAT boot-sector table that describes sector size, FAT location, root directory size, and cluster layout. type marker, then computes kfat_start, krsta, krsc, kdsta, and kmax_cluster. Those values are what every later FAT, directory, and disk routine trusts.
Tests that pin this
scripts/test_bpb_invalid.pyscripts/test_fat16.pyscripts/test_partitioned_fat16.pyClusters become BIOS sectors
Data-cluster LBAs flow through partition offsets and CHS bounds checks.cluster_lba is the only normal path from a FAT cluster number to a sector address. It rejects cluster zero/one and clusters at or beyond kmax_cluster, then multiplies by sectors-per-cluster and adds the data start.
Sector I/O adds the partition-relative LBA, retries INT 13hINT 13hThe BIOS disk interrupt used by boot code before the DOS filesystem is available. calls, invalidates the read cache on writes, and advances ES across 64 KiB wrap. Geometry errors fail closed instead of letting high-LBA tests read or write the wrong sector.
Tests that pin this
scripts/test_fat16_large.pyscripts/test_fat16_seek.pyscripts/test_highdir.pyFAT chains are bounded and mirrored
FAT12 uses the resident FAT buffer; FAT16 pages sectors through scratch I/O.FAT12FAT12The 12-bit FAT format used by 1.44 MB floppy images and the early LainDOS boot path. entries are packed 12-bit values in FAT_SEG, so odd clusters shift by four and even clusters mask to 0FFFh. FAT16FAT16The 16-bit FAT format LainDOS uses for larger hard-disk style images. entries are sector-addressed because the table can be larger than the resident scratch space.
All chain operations sanitize impossible next-cluster values against reserved and max-cluster bounds. Allocation stores an EOC marker immediately; freeing writes zero entries; flushing writes dirty FAT12FAT12The 12-bit FAT format used by 1.44 MB floppy images and the early LainDOS boot path. tables to every copy, while FAT16FAT16The 16-bit FAT format LainDOS uses for larger hard-disk style images. writes each changed sector to each FAT copy at set time.
Tests that pin this
scripts/test_badfat.pyscripts/test_fat16_bounds.pyscripts/test_dirmut.pyPaths become 8.3 directory keys
Drive prefixes, relative roots, dot segments, and case folding meet in name_buf.A path first activates the requested drive, then chooses a starting directory. Leading separators force root, drive-qualified relative paths keep that drive's current directory, and plain relative paths start from cur_dir_cluster.
The parser fills an eleven-byte DOS name with spaces, uppercases each character, moves to byte eight after a dot, and expands wildcards to question marks for find-first/find-next. Parent resolution temporarily terminates the path at the last separator and calls the same resolver on the parent directory.
Tests that pin this
scripts/test_pathcanon.pyscripts/test_drivepath.pyscripts/test_diredge.pyscripts/test_parsefcb.pyDirectories scan root and cluster chains
Root entries are resident; subdirectory entries stream through the sector buffer.Root scans index directly into ROOT_SEG, derive the backing LBA from krsta, and stop on the DOS zero entry. Subdirectory scans read each cluster sector through SEC_BUF, skip deleted and volume entries, compare against name_buf, then follow fat_next to the next directory cluster.
When a subdirectory is full, find_dir_free extends it by allocating a new cluster, linking the old tail to it, zeroing every new sector, and flushing the FAT. If zeroing or flushing fails, it rolls the chain back before returning failure.
Tests that pin this
scripts/test_findedge.pyscripts/test_dirextfail.pyscripts/test_dirextrollback.pyDirectory slots are loaded and flushed explicitly
Root and subdirectory updates use different buffers but one write contract.load_dir_slot remembers the target LBA and offset before choosing the backing buffer. Root slots already live in ROOT_SEG; subdirectory slots are read into SEC_BUF so callers can modify the entry in place.
flush_handle_dir_entry is the close/commit path for size, date, time, and first-cluster metadata. It reloads the slot, stores handle fields into the directory entry, and flushes the exact sector back to disk.
Tests that pin this
scripts/test_commit.pyscripts/test_termflush.pyscripts/test_savewrite.pyReads cache; writes invalidate
Handle I/O keeps cluster walking fast without hiding dirty sectors.Reads convert the current file position to a cluster index and sector-in-cluster, reuse the handle's last-cluster cache when possible, then cache the sector in READ_CACHE_BUF until another read needs a different LBA.
Writes allocate a first cluster on demand, extend chains with wf_get_cluster, read the target sector into SEC_BUF, patch only the requested bytes, write the sector back, and invalidate the read cache. Sparse writes fill the gap with zeroes through the same sector path.
Tests that pin this
scripts/test_readcache.pyscripts/test_rwedge.pyscripts/test_seekedge.pyMutations flush directory and FAT state
Create, truncate, delete, rename, and commit keep disk images inspectable after exit.Create and create-new share one path: parse the parent, find an existing entry or free slot, reject open/read-only conflicts, truncate old chains when replacing, write a fresh directory entry, and return a handle bound to that slot.
Delete marks the directory entry E5h before freeing its FAT chain; rename is same-directory only and rejects open/read-only entries; commit and close flush handle metadata and FAT state so host-side image checks see durable data.
Tests that pin this
scripts/test_createapi.pyscripts/test_savewrite.pyscripts/test_dirmut.pyscripts/test_rnguard.py