FAT filesystem track

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.

01
Mount
The boot BPBBPBThe FAT boot-sector table that describes sector size, FAT location, root directory size, and cluster layout. is validated, FAT type is selected, and root/data/FAT regions are derived once per active drive.
02
Resolve
Paths are drive-aware, case-folded into 8.3 names, and walked from the current directory or root.
03
Locate
Directory scans read root entries from ROOT_SEG and subdirectories through SEC_BUF while following FAT chains.
04
Transfer
Cluster numbers become LBAs, sector I/O retries BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. calls, and reads use READ_CACHE_BUF.
05
Commit
Creates, truncates, deletes, renames, and directory growth flush directory slots and FAT copies in order.

Mount 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.

src/kernel.asmNASM · 16-bit
430
431
435
437
439
443
469
473
475
480
483
493
513
516
531
537
541
mov al, [bx+0x0D]
test al, al
test al, ah
cmp word [bx+0x0E], 0
cmp byte [bx+0x10], 0
mov ax, [bx+0x11]
mov byte [cs:kfat_bits], 12
cmp byte [bx+0x3A], '6'
mov byte [cs:kfat_bits], 16
mov ax, [bx+22]
mov [cs:kfat_start], ax
mov [cs:krsta], ax
mov [cs:krsc], ax
mov [cs:kdsta], ax
sub ax, [cs:kdsta]
div cx
mov [cs:kmax_cluster], ax

Tests that pin this

scripts/test_bpb_invalid.pyscripts/test_fat16.pyscripts/test_partitioned_fat16.py

Clusters 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.

src/kernel/disk.incNASM · 16-bit
1
3
5
7
9
13
25
30
31
58
63
71
78
80
85
104
107
108
cluster_lba:
cmp ax, 2
cmp ax, [cs:kmax_cluster]
sub ax, 2
mov cl, [cs:kspc]
add ax, [cs:kdsta]
read_sector:
write_sector:
mov byte [cs:rf_cache_valid], 0
setup_sector_io:
mov ax, [cs:kpart_lba]
setup_bios_chs:
cmp dx, [cs:kbio_spt]
div word [cs:kbio_spt]
cmp ax, 1024
finish_sector_io:
mov ax, es
add ax, 0x1000

Tests that pin this

scripts/test_fat16_large.pyscripts/test_fat16_seek.pyscripts/test_highdir.py

FAT 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.

src/kernel/fat.incNASM · 16-bit
1
4
6
13
17
19
22
24
44
55
57
62
67
70
75
79
93
98
128
137
156
171
175
185
199
217
227
247
261
275
278
297
314
fat_next:
cmp si, [cs:kmax_cluster]
cmp byte [cs:kfat_bits], 16
mov ax, FAT_SEG
test si, 1
shr ax, 4
and ax, 0x0FFF
call fat_next_sanitize
fat16_next:
cmp ax, [cs:kfat_secs]
add ax, [cs:kfat_start]
cmp byte [cs:fat16_cache_valid], 1
mov dx, FAT_SEG
call read_sector
.have_sector:
mov ax, [bx]
fat_set:
cmp byte [cs:kfat_bits], 16
mov byte [cs:fat_dirty], 1
fat16_set:
mov byte [cs:fat_copy_idx], 0
call read_sector
mov [es:bx], ax
call write_sector
fat_alloc_cluster:
call fat_next
call fat_set
fat_free_chain:
call fat_set
flush_fat:
cmp byte [cs:kfat_bits], 16
mov ax, [cs:kfat_start]
call write_sector

Tests that pin this

scripts/test_badfat.pyscripts/test_fat16_bounds.pyscripts/test_dirmut.py

Paths 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.

src/kernel/path_dir.incNASM · 16-bit
925
927
931
941
949
954
957
992
1001
1057
1063
1076
1078
1094
1729
1738
1739
1752
1755
1764
1766
1774
1803
1807
1811
1833
1880
1905
1918
resolve_path:
call activate_drive_for_path
cmp byte [ds:si], '\'
cmp byte [ds:si+1], ':'
mov ax, [cs:cur_dir_cluster]
mov ax, ROOT_CLUSTER
mov ax, [cs:cur_dir_cluster]
.rp_parse_name:
mov di, name_buf
call ascii_upper
mov di, name_buf + 8
call find_in_dir
test byte [es:di+11], ATTR_DIR
call find_in_dir
parse_83name:
mov di, name_buf
mov cx, 11
call ascii_upper
cmp di, name_buf + 8
.pl_dot:
mov di, name_buf + 8
mov byte [es:di], '?'
parse_root_path:
call activate_drive_for_path
mov ax, [cs:cur_dir_cluster]
mov [cs:pr_last_sep], bx
mov word [cs:pr_dir_cluster], ROOT_CLUSTER
call resolve_path
call parse_83name

Tests that pin this

scripts/test_pathcanon.pyscripts/test_drivepath.pyscripts/test_diredge.pyscripts/test_parsefcb.py

Directories 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.

src/kernel/path_dir.incNASM · 16-bit
1107
1125
1128
1131
1142
1150
1152
1163
1188
1194
1202
1236
1247
1261
1352
1374
1379
1392
1408
1419
1423
1455
1465
1477
1480
1484
find_in_dir:
mov ax, [cs:fid_cluster]
mov ax, ROOT_SEG
cmp cx, [cs:kroot_entries]
add ax, [cs:krsta]
cmp byte [es:di], 0
cmp byte [es:di], 0xE5
call name_matches
mov dx, SEC_BUF
call read_sector
cmp byte [es:di], 0
call fat_next
mov [cs:ff_entry_lba], ax
mov ax, [es:di+26]
find_dir_free:
call cluster_lba
mov dx, SEC_BUF
cmp byte [es:di], 0
call fat_next
call fat_alloc_cluster
call fat_set
call write_sector
call flush_fat
.sd_rollback:
call fat_set
call flush_fat

Tests that pin this

scripts/test_findedge.pyscripts/test_dirextfail.pyscripts/test_dirextrollback.py

Directory 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.

src/kernel/fs.incNASM · 16-bit
1
4
6
25
29
31
38
44
53
79
85
88
94
100
106
115
126
129
132
141
147
149
dir_lba_root_base:
cmp ax, [cs:krsta]
cmp ax, [cs:kdsta]
load_dir_slot:
call dir_lba_root_base
mov ax, ROOT_SEG
mov ax, SEC_BUF
call read_sector
flush_dir_slot:
flush_dir_sector:
call dir_lba_root_base
call flush_root_sector
mov ax, SEC_BUF
call write_sector
flush_handle_dir_entry:
mov ax, [cs:si+handles+H_DIR_LBA]
call load_dir_slot
call store_handle_dir_fields
call flush_dir_slot
store_handle_dir_fields:
mov ax, [cs:si+handles+H_CLUSTER]
mov ax, [cs:si+handles+H_SIZE_LO]

Tests that pin this

scripts/test_commit.pyscripts/test_termflush.pyscripts/test_savewrite.py

Reads 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.

src/kernel/int21.incNASM · 16-bit
2851
2853
2854
2861
2868
2870
2874
2900
3035
3054
3088
3107
3114
3120
3148
3151
3166
3186
3191
3197
3200
3217
3235
3240
3262
mov ax, si
call cluster_lba
cmp byte [cs:rf_cache_valid], 1
mov byte [cs:rf_cache_valid], 0
mov dx, READ_CACHE_BUF
call read_sector
mov byte [cs:rf_cache_valid], 1
mov dx, READ_CACHE_BUF
.wf_file:
call activate_drive_for_handle
call fat_alloc_cluster
call wf_get_cluster
mov ax, SEC_BUF
call read_sector
call write_sector
mov byte [cs:rf_cache_valid], 0
call fat_alloc_cluster
call wf_get_cluster
call cluster_lba
mov dx, SEC_BUF
call read_sector
mov ax, SEC_BUF
call write_sector
mov byte [cs:rf_cache_valid], 0
mov ax, [cs:wf_written]

Tests that pin this

scripts/test_readcache.pyscripts/test_rwedge.pyscripts/test_seekedge.py

Mutations 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.

src/kernel/int21.incNASM · 16-bit
2271
2288
2292
2295
2308
2325
2326
2327
2333
2346
2360
3330
3338
3342
3352
3355
3358
3359
4400
4411
4417
4423
4437
4451
4456
4526
4556
4558
.create_file:
call parse_root_path
call find_in_dir
call find_dir_free
call entry_has_open_handle
mov si, [cs:cf_first_cluster]
call fat_free_chain
call flush_fat
call load_dir_slot
mov si, name_buf
call flush_dir_slot
.delete_file:
call parse_root_path
call find_in_dir
mov byte [es:di], 0xE5
call flush_dir_slot
call fat_free_chain
call flush_fat
.rename_file:
call parse_root_path
call find_in_dir
call entry_has_open_handle
cmp ax, [cs:rn_dir_cluster]
mov si, name_buf
call flush_dir_slot
.commit_file:
call flush_handle_dir_entry
call flush_fat

Tests that pin this

scripts/test_createapi.pyscripts/test_savewrite.pyscripts/test_dirmut.pyscripts/test_rnguard.py