336 lines
15 KiB
C#
336 lines
15 KiB
C#
// Fat16ImageLib.cs
|
|
// Minimal C# library for creating a FAT16 disk image and adding files (root directory only)
|
|
// Target: .NET 6+
|
|
// Limitations: 8.3 filenames, root directory only (no subdirectories), no long file names, no deletions/compaction.
|
|
|
|
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
|
|
namespace Fat16Lib
|
|
{
|
|
public class Fat16ImageOptions
|
|
{
|
|
// Total size of the image in bytes. Common sizes: 16 MiB, 32 MiB, 64 MiB, 128 MiB, 256 MiB, etc.
|
|
public long TotalSizeBytes { get; set; } = 32L * 1024 * 1024; // 32 MiB default
|
|
public ushort BytesPerSector { get; set; } = 512;
|
|
public byte SectorsPerCluster { get; set; } = 0; // 0 = auto-select based on size
|
|
public byte NumFats { get; set; } = 2;
|
|
public ushort ReservedSectors { get; set; } = 1; // boot sector
|
|
public ushort MaxRootDirEntries { get; set; } = 512; // typical for FAT16
|
|
public byte MediaDescriptor { get; set; } = 0xF8; // fixed disk
|
|
public ushort SectorsPerTrack { get; set; } = 32; // geometry hints (not critical)
|
|
public ushort NumberOfHeads { get; set; } = 64; // geometry hints (not critical)
|
|
public uint HiddenSectors { get; set; } = 0;
|
|
public string OemId { get; set; } = "MSDOS5.0";
|
|
public string VolumeLabel { get; set; } = "NO NAME "; // exactly 11 chars; will be padded/trimmed
|
|
public string FsTypeLabel { get; set; } = "FAT16 "; // 8 chars in boot sector label
|
|
public ushort? VolumeId { get; set; } = null; // null => random
|
|
}
|
|
|
|
public class Fat16Image
|
|
{
|
|
private readonly Fat16ImageOptions _opt;
|
|
|
|
// Computed layout
|
|
private uint _totalSectors; // 16-bit or 32-bit depending on size
|
|
private ushort _rootDirSectors;
|
|
private ushort _fatSectors; // sectors per FAT
|
|
private uint _firstFatSectorLba;
|
|
private uint _firstRootDirSectorLba;
|
|
private uint _firstDataSectorLba;
|
|
private uint _clusterCount; // data clusters (not counting 0 and 1)
|
|
private byte _sectorsPerCluster;
|
|
|
|
// Runtime buffers
|
|
private byte[] _bootSector = Array.Empty<byte>();
|
|
private byte[] _fat; // a single FAT table; will be duplicated NumFats times
|
|
private byte[] _rootDir; // root directory area (fixed size)
|
|
private byte[] _data; // data region sized to contain all clusters (clusterCount * spc * bps)
|
|
|
|
// Allocation state
|
|
private uint _nextFreeCluster = 2; // FAT clusters start at 2
|
|
private int _nextFreeRootDirEntry = 0;
|
|
|
|
public Fat16Image(Fat16ImageOptions options)
|
|
{
|
|
_opt = options;
|
|
if (_opt.BytesPerSector != 512)
|
|
throw new NotSupportedException("Only 512-byte sectors supported in this minimal implementation.");
|
|
if (_opt.TotalSizeBytes % _opt.BytesPerSector != 0)
|
|
throw new ArgumentException("TotalSizeBytes must be a multiple of BytesPerSector.");
|
|
|
|
InitializeLayout();
|
|
BuildBootSector();
|
|
InitializeFatAndRoot();
|
|
}
|
|
|
|
// Adds a file into the root directory. Uses 8.3 name rules.
|
|
public void AddFile(string fileName, byte[] content, DateTime? timestamp = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("fileName");
|
|
if (content == null) throw new ArgumentNullException(nameof(content));
|
|
|
|
var (name83, ext83) = ToShortName(fileName);
|
|
if (_nextFreeRootDirEntry >= _opt.MaxRootDirEntries)
|
|
throw new IOException("Root directory is full.");
|
|
|
|
// Compute how many clusters needed
|
|
uint bytesPerCluster = (uint)(_opt.BytesPerSector * _sectorsPerCluster);
|
|
uint clustersNeeded = (uint)((content.Length + bytesPerCluster - 1) / bytesPerCluster);
|
|
if (clustersNeeded == 0) clustersNeeded = 1; // zero-length file occupies 0 clusters in FAT16 spec? Practically some tools allow 0. We'll allocate 1 for simplicity.
|
|
|
|
if ((_nextFreeCluster - 2) + clustersNeeded > _clusterCount)
|
|
throw new IOException("Not enough free space for file.");
|
|
|
|
// Allocate cluster chain
|
|
List<uint> chain = new();
|
|
for (int i = 0; i < clustersNeeded; i++)
|
|
{
|
|
uint c = _nextFreeCluster++;
|
|
chain.Add(c);
|
|
}
|
|
|
|
// Write data to clusters
|
|
int srcOffset = 0;
|
|
foreach (var c in chain)
|
|
{
|
|
uint lba = ClusterToLba(c);
|
|
uint offset = (lba - _firstDataSectorLba) * _opt.BytesPerSector;
|
|
int toCopy = (int)Math.Min(bytesPerCluster, (uint)(content.Length - srcOffset));
|
|
if (toCopy > 0)
|
|
Buffer.BlockCopy(content, srcOffset, _data, (int)offset, toCopy);
|
|
// Zero the remainder of the cluster (already zeroed by default construction)
|
|
srcOffset += toCopy;
|
|
}
|
|
|
|
// Link FAT entries
|
|
for (int i = 0; i < chain.Count - 1; i++)
|
|
SetFatEntry(chain[i], (ushort)chain[i + 1]);
|
|
SetFatEntry(chain[^1], 0xFFFF); // EOF
|
|
|
|
// Build directory entry
|
|
var dt = timestamp ?? DateTime.Now;
|
|
ushort time = ToFatTime(dt);
|
|
ushort date = ToFatDate(dt);
|
|
|
|
Span<byte> entry = stackalloc byte[32];
|
|
entry.Clear();
|
|
Encoding.ASCII.GetBytes(name83, entry);
|
|
Encoding.ASCII.GetBytes(ext83, entry.Slice(8));
|
|
entry[11] = 0x20; // Archive
|
|
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(14), time); // Create time
|
|
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(16), date); // Create date
|
|
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(18), date); // Last access date (approx)
|
|
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(22), time); // Write time
|
|
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(24), date); // Write date
|
|
BinaryPrimitives.WriteUInt16LittleEndian(entry.Slice(26), (ushort)chain[0]); // First cluster
|
|
BinaryPrimitives.WriteUInt32LittleEndian(entry.Slice(28), (uint)content.Length); // File size
|
|
|
|
// Write entry into root dir
|
|
int rootOffset = _nextFreeRootDirEntry * 32;
|
|
_rootDir.AsSpan(rootOffset, 32).Clear();
|
|
entry.CopyTo(_rootDir.AsSpan(rootOffset));
|
|
_nextFreeRootDirEntry++;
|
|
}
|
|
|
|
public byte[] GetImage()
|
|
{
|
|
using var ms = new MemoryStream((int)(_opt.TotalSizeBytes));
|
|
|
|
// Boot sector
|
|
ms.Write(_bootSector, 0, _bootSector.Length);
|
|
|
|
// FATs
|
|
for (int f = 0; f < _opt.NumFats; f++)
|
|
ms.Write(_fat, 0, _fat.Length);
|
|
|
|
// Root directory
|
|
ms.Write(_rootDir, 0, _rootDir.Length);
|
|
|
|
// Data region
|
|
ms.Write(_data, 0, _data.Length);
|
|
|
|
// Pad to exact size if necessary (should be exact)
|
|
if (ms.Length < _opt.TotalSizeBytes)
|
|
ms.Write(new byte[_opt.TotalSizeBytes - ms.Length], 0, (int)(_opt.TotalSizeBytes - ms.Length));
|
|
|
|
return ms.ToArray();
|
|
}
|
|
|
|
public void SaveToFile(string path)
|
|
{
|
|
File.WriteAllBytes(path, GetImage());
|
|
}
|
|
|
|
private void InitializeLayout()
|
|
{
|
|
_totalSectors = (uint)(_opt.TotalSizeBytes / _opt.BytesPerSector);
|
|
|
|
// Decide sectors per cluster if not specified, per rough guidance for FAT16
|
|
_sectorsPerCluster = _opt.SectorsPerCluster != 0 ? _opt.SectorsPerCluster : SelectSectorsPerCluster(_totalSectors, _opt.BytesPerSector);
|
|
|
|
_rootDirSectors = (ushort)(((uint)_opt.MaxRootDirEntries * 32 + (_opt.BytesPerSector - 1)) / _opt.BytesPerSector);
|
|
|
|
// We need to iteratively solve for FAT size and cluster count
|
|
ushort fatSectors = 1;
|
|
while (true)
|
|
{
|
|
uint dataSectors = _totalSectors - _opt.ReservedSectors - (uint)(_opt.NumFats * fatSectors) - _rootDirSectors;
|
|
uint clusterCount = dataSectors / _sectorsPerCluster;
|
|
if (clusterCount < 4085 || clusterCount >= 65525)
|
|
throw new NotSupportedException($"Cluster count {clusterCount} out of FAT16 bounds (needs 4,085..65,524). Adjust size or sectors/cluster.");
|
|
|
|
// FAT16 entry size 2 bytes, need (clusterCount + 2) entries
|
|
ushort requiredFatSectors = (ushort)(((clusterCount + 2) * 2 + (_opt.BytesPerSector - 1)) / _opt.BytesPerSector);
|
|
if (requiredFatSectors == fatSectors)
|
|
{
|
|
_fatSectors = fatSectors;
|
|
_clusterCount = clusterCount;
|
|
break;
|
|
}
|
|
fatSectors = requiredFatSectors;
|
|
}
|
|
|
|
_firstFatSectorLba = _opt.ReservedSectors;
|
|
_firstRootDirSectorLba = _firstFatSectorLba + (uint)(_opt.NumFats * _fatSectors);
|
|
_firstDataSectorLba = _firstRootDirSectorLba + _rootDirSectors;
|
|
|
|
// Buffers
|
|
_bootSector = new byte[_opt.BytesPerSector];
|
|
_fat = new byte[_fatSectors * _opt.BytesPerSector];
|
|
_rootDir = new byte[_rootDirSectors * _opt.BytesPerSector];
|
|
uint dataSectorsFinal = _totalSectors - _opt.ReservedSectors - (uint)(_opt.NumFats * _fatSectors) - _rootDirSectors;
|
|
_data = new byte[dataSectorsFinal * _opt.BytesPerSector];
|
|
}
|
|
|
|
private void BuildBootSector()
|
|
{
|
|
Span<byte> bs = _bootSector;
|
|
bs.Clear();
|
|
|
|
// Jump + OEM
|
|
bs[0] = 0xEB; bs[1] = 0x3C; bs[2] = 0x90; // JMP short
|
|
Encoding.ASCII.GetBytes((_opt.OemId ?? "MSDOS5.0").PadRight(8).Substring(0,8), bs.Slice(3, 8));
|
|
|
|
// BIOS Parameter Block (BPB)
|
|
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(11), _opt.BytesPerSector);
|
|
bs[13] = _sectorsPerCluster;
|
|
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(14), _opt.ReservedSectors);
|
|
bs[16] = _opt.NumFats;
|
|
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(17), _opt.MaxRootDirEntries);
|
|
ushort totalSectors16 = _totalSectors <= 0xFFFF ? (ushort)_totalSectors : (ushort)0;
|
|
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(19), totalSectors16);
|
|
bs[21] = _opt.MediaDescriptor;
|
|
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(22), _fatSectors);
|
|
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(24), _opt.SectorsPerTrack);
|
|
BinaryPrimitives.WriteUInt16LittleEndian(bs.Slice(26), _opt.NumberOfHeads);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(bs.Slice(28), _opt.HiddenSectors);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(bs.Slice(32), _totalSectors);
|
|
|
|
// Extended BPB for FAT16
|
|
ushort volId = _opt.VolumeId ?? (ushort)Random.Shared.Next(1, 0xFFFF);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(bs.Slice(39), volId);
|
|
string label = (_opt.VolumeLabel ?? "NO NAME").ToUpperInvariant();
|
|
label = new string(label.Where(ch => ch >= 0x20 && ch <= 0x7E).ToArray());
|
|
label = label.PadRight(11).Substring(0, 11);
|
|
Encoding.ASCII.GetBytes(label, bs.Slice(43, 11));
|
|
string fstype = (_opt.FsTypeLabel ?? "FAT16").PadRight(8).Substring(0, 8);
|
|
Encoding.ASCII.GetBytes(fstype, bs.Slice(54, 8));
|
|
|
|
// Minimal bootstrap code area (ignored for data disks)
|
|
bs[_opt.BytesPerSector - 2] = 0x55; // signature
|
|
bs[_opt.BytesPerSector - 1] = 0xAA;
|
|
}
|
|
|
|
private void InitializeFatAndRoot()
|
|
{
|
|
// FAT initial entries
|
|
// Entry 0: media descriptor in low byte, 0xFF in high byte
|
|
SetFatEntryRaw(0, (ushort)(_opt.MediaDescriptor | 0xFF00));
|
|
// Entry 1: EOF
|
|
SetFatEntryRaw(1, 0xFFFF);
|
|
// Zero rest (already zero)
|
|
}
|
|
|
|
private uint ClusterToLba(uint cluster)
|
|
{
|
|
if (cluster < 2) throw new ArgumentOutOfRangeException(nameof(cluster));
|
|
return _firstDataSectorLba + (cluster - 2) * _sectorsPerCluster;
|
|
}
|
|
|
|
private void SetFatEntry(uint cluster, ushort value)
|
|
{
|
|
if (cluster >= _clusterCount + 2) throw new ArgumentOutOfRangeException(nameof(cluster));
|
|
SetFatEntryRaw(cluster, value);
|
|
}
|
|
|
|
private void SetFatEntryRaw(uint cluster, ushort value)
|
|
{
|
|
int index = (int)(cluster * 2);
|
|
BinaryPrimitives.WriteUInt16LittleEndian(_fat.AsSpan(index, 2), value);
|
|
}
|
|
|
|
private static (string name, string ext) ToShortName(string fileName)
|
|
{
|
|
// Extract base and extension, sanitize to 8.3 uppercase ASCII
|
|
string name = Path.GetFileNameWithoutExtension(fileName) ?? "FILE";
|
|
string ext = Path.GetExtension(fileName);
|
|
if (ext.StartsWith('.')) ext = ext.Substring(1);
|
|
|
|
string Sanitize(string s) => new string(s.ToUpperInvariant()
|
|
.Select(c => (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || "!#$%&'()-@^_`{}~".Contains(c) ? c : '_')
|
|
.ToArray());
|
|
|
|
string n = Sanitize(name);
|
|
string e = Sanitize(ext);
|
|
if (n.Length > 8) n = n.Substring(0, 8);
|
|
if (e.Length > 3) e = e.Substring(0, 3);
|
|
n = n.PadRight(8);
|
|
e = e.PadRight(3);
|
|
return (n, e);
|
|
}
|
|
|
|
private static ushort ToFatDate(DateTime dt)
|
|
{
|
|
int year = Math.Clamp(dt.Year - 1980, 0, 127);
|
|
int month = Math.Clamp(dt.Month, 1, 12);
|
|
int day = Math.Clamp(dt.Day, 1, 31);
|
|
return (ushort)((year << 9) | (month << 5) | day);
|
|
}
|
|
|
|
private static ushort ToFatTime(DateTime dt)
|
|
{
|
|
int hour = Math.Clamp(dt.Hour, 0, 23);
|
|
int minute = Math.Clamp(dt.Minute, 0, 59);
|
|
int second = Math.Clamp(dt.Second / 2, 0, 29); // 2-second resolution
|
|
return (ushort)((hour << 11) | (minute << 5) | second);
|
|
}
|
|
|
|
private static byte SelectSectorsPerCluster(uint totalSectors, ushort bytesPerSector)
|
|
{
|
|
// Very rough mapping consistent with FAT16 recommendations for 512-byte sectors
|
|
// Values chosen to keep cluster count in FAT16 range and cluster size reasonable
|
|
long sizeMiB = (long)totalSectors * bytesPerSector / (1024 * 1024);
|
|
return sizeMiB switch
|
|
{
|
|
<= 16 => 2, // 1 KiB clusters
|
|
<= 32 => 4, // 2 KiB
|
|
<= 64 => 8, // 4 KiB
|
|
<= 128 => 16, // 8 KiB
|
|
<= 256 => 32, // 16 KiB
|
|
<= 512 => 64, // 32 KiB
|
|
_ => 64, // 32 KiB clusters for larger images in this minimal impl
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// --------- Example usage (not part of the library) ---------
|
|
// var img = new Fat16Lib.Fat16Image(new Fat16Lib.Fat16ImageOptions { TotalSizeBytes = 32L * 1024 * 1024, VolumeLabel = "MYDISK" });
|
|
// img.AddFile("HELLO.TXT", Encoding.ASCII.GetBytes("Hello FAT16!\r\n"));
|
|
// img.SaveToFile("fat16.img");
|