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 computes kfat_start, krsta, krsc, kdsta, and the data-area cluster count, then picks FAT12FAT12The 12-bit FAT format used by 1.44 MB floppy images and the early LainDOS boot path. or FAT16FAT16The 16-bit FAT format LainDOS uses for larger hard-disk style images. from the cluster count itself (fewer than 4085 clusters means FAT12FAT12The 12-bit FAT format used by 1.44 MB floppy images and the early LainDOS boot path.) — the BPBBPBThe FAT boot-sector table that describes sector size, FAT location, root directory size, and cluster layout. type string is ignored, matching how real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs. decides. 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, caps multi-sector reads at track and 64 KiB DMA boundaries, invalidates filesystem sector caches 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 keeps the whole table resident; FAT16 caches one sector in a write-back window.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. The whole FAT12FAT12The 12-bit FAT format used by 1.44 MB floppy images and the early LainDOS boot path. table fits in the resident scratch buffer, so reads and writes touch it in place and a single flush mirrors it to every copy.
FAT16FAT16The 16-bit FAT format LainDOS uses for larger hard-disk style images. tables can be far larger than that buffer (an 80 KiB FAT on a 160 MB volume), so both reads and writes go through one cached sector — the write-back window. fat16_window_load keeps the requested FAT sector in FAT_SEG; if a different sector is already cached and dirty it is flushed to every FAT copy first. fat16_set patches the entry in memory and only marks the window dirty, deferring the disk write to the next eviction or to flush_fat.
This matters under real-speed hardware: the old FAT16FAT16The 16-bit FAT format LainDOS uses for larger hard-disk style images. path read-modify-wrote both FAT copies on every single entry, turning a multi-megabyte file's cluster chain into tens of thousands of sector writes (the Red Alert swap-file stall). The window collapses an entire FAT sector's worth of allocations — 256 entries — into one read and one write per copy. All chain operations still sanitize impossible next-cluster values against reserved and max-cluster bounds.
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, including extension wildcards for a name-part * without an explicit dot. Parent resolution temporarily terminates the path at the last separator and calls the same resolver on the parent directory while preserving any explicit drive prefix.
Tests that pin this
scripts/test_pathcanon.pyscripts/test_drivepath.pyscripts/test_diredge.pyscripts/test_parsefcb.pyscripts/test_findstar.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 the subdirectory sector cache into 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.pyscripts/test_subdir_cache.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 through the subdirectory cache 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.
Close, commit, and file-time updates enter through flush_dirty_handle_dir_entry: data is flushed first, then a spare per-handle dirty byte decides whether the directory sector actually needs rewriting.
Tests that pin this
scripts/test_commit.pyscripts/test_termflush.pyscripts/test_savewrite.pyscripts/test_metafail.pyscripts/test_subdir_cache.pyscripts/bench_metadata.pyReads cache; hard-disk writes coalesce
Handle I/O keeps cluster walking fast while hard-disk writes merge small records.Reads convert the current file position to a cluster index and sector-in-cluster, use a shared FAT chain-position cache when possible, and direct-read aligned full-sector requests of at least two sectors. Smaller sequential reads keep a bounded window in READ_CACHE_BUF: non-sequential misses read one sector, while immediate continuation misses prefetch up to four sectors capped by the current FAT cluster and EOF. If the read targets the dirty write-back sector, LainDOS flushes it and fills READ_CACHE_BUF from WRITE_CACHE_BUF rather than rereading stale media data.
Writes allocate a first cluster on demand, extend chains with wf_get_cluster, read partial target sectors into WRITE_CACHE_BUF, patch only the requested bytes, and mark that sector dirty. On hard disks the dirty sector is deferred until the next uncached FAT chain walk, another sector, a read, close/commit, disk reset, or drive switch forces a flush; floppy writes flush immediately. Sparse writes allocate FAT chain gaps without zero-filling every sector.
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. Fault-injection builds can force a post-allocation flush_dir_slot failure and query the handle count through AH=F0h to prove the failed create does not leak an open 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_handleleak.pyscripts/test_savewrite.pyscripts/test_dirmut.pyscripts/test_rnguard.py