diff --git a/SabreTools.Data.Extensions.Test/UDFExtensionsTests.cs b/SabreTools.Data.Extensions.Test/UDFExtensionsTests.cs
new file mode 100644
index 00000000..0d8c51df
--- /dev/null
+++ b/SabreTools.Data.Extensions.Test/UDFExtensionsTests.cs
@@ -0,0 +1,199 @@
+using SabreTools.Data.Models.UDF;
+using Xunit;
+
+namespace SabreTools.Data.Extensions.Test
+{
+ public class UDFExtensionsTests
+ {
+ #region DecodeDCharacters
+
+ [Fact]
+ public void DecodeDCharacters_Null_Empty()
+ {
+ byte[]? data = null;
+ Assert.Equal(string.Empty, data.DecodeDCharacters());
+ }
+
+ [Fact]
+ public void DecodeDCharacters_8Bit_Decoded()
+ {
+ byte[] data = [8, 0x41, 0x62, 0xE4];
+ Assert.Equal("Abä", data.DecodeDCharacters());
+ }
+
+ [Fact]
+ public void DecodeDCharacters_16Bit_Decoded()
+ {
+ byte[] data = [16, 0x00, 0x41, 0x30, 0x42];
+ Assert.Equal("Aあ", data.DecodeDCharacters());
+ }
+
+ [Fact]
+ public void DecodeDCharacters_UnknownCompression_Null()
+ {
+ byte[] data = [1, 0x41];
+ Assert.Null(data.DecodeDCharacters());
+ }
+
+ [Fact]
+ public void DecodeDString_UsesLengthByte()
+ {
+ byte[] data = [8, 0x41, 0x42, 0x43, 0x00, 3];
+ Assert.Equal("AB", data.DecodeDString());
+ }
+
+ [Fact]
+ public void DecodeDString_ZeroLength_Empty()
+ {
+ byte[] data = [8, 0x41, 0x00, 0];
+ Assert.Equal(string.Empty, data.DecodeDString());
+ }
+
+ #endregion
+
+ #region TryGetSector
+
+ [Fact]
+ public void TryGetSector_Type1_StartPlusBlock()
+ {
+ var volume = CreateVolume(new Type1PartitionMap { PartitionNumber = 0 });
+ bool actual = volume.TryGetSector(UDFExtensions.GetKey(0, 10), out long sector);
+ Assert.True(actual);
+ Assert.Equal(1010, sector);
+ }
+
+ [Fact]
+ public void TryGetSector_Type1_OutsidePartition_False()
+ {
+ var volume = CreateVolume(new Type1PartitionMap { PartitionNumber = 0 });
+ bool actual = volume.TryGetSector(UDFExtensions.GetKey(0, 100), out _);
+ Assert.False(actual);
+ }
+
+ [Fact]
+ public void TryGetSector_UnknownPartitionReference_False()
+ {
+ var volume = CreateVolume(new Type1PartitionMap { PartitionNumber = 0 });
+ bool actual = volume.TryGetSector(UDFExtensions.GetKey(1, 0), out _);
+ Assert.False(actual);
+ }
+
+ [Fact]
+ public void TryGetSector_Metadata_ThroughMetadataFile()
+ {
+ // Metadata File at block 5 of partition 0: blocks 0-1 of the metadata partition are blocks 20-21,
+ // blocks 2-3 are blocks 40-41
+ var volume = CreateVolume(
+ new Type1PartitionMap { PartitionNumber = 0 },
+ new MetadataPartitionMap { PartitionNumber = 0, MetadataFileLocation = 5, MetadataMirrorFileLocation = 6 });
+ volume.FileEntries[UDFExtensions.GetKey(0, 5)] = CreateShortAdEntry((4096, 20), (4096, 40));
+
+ Assert.True(volume.TryGetSector(UDFExtensions.GetKey(1, 1), out long first));
+ Assert.Equal(1021, first);
+ Assert.True(volume.TryGetSector(UDFExtensions.GetKey(1, 2), out long second));
+ Assert.Equal(1040, second);
+ Assert.False(volume.TryGetSector(UDFExtensions.GetKey(1, 4), out _));
+ }
+
+ [Fact]
+ public void TryGetSector_Metadata_FallsBackToMirror()
+ {
+ var volume = CreateVolume(
+ new Type1PartitionMap { PartitionNumber = 0 },
+ new MetadataPartitionMap { PartitionNumber = 0, MetadataFileLocation = 5, MetadataMirrorFileLocation = 6 });
+ volume.FileEntries[UDFExtensions.GetKey(0, 6)] = CreateShortAdEntry((2048, 30));
+
+ Assert.True(volume.TryGetSector(UDFExtensions.GetKey(1, 0), out long sector));
+ Assert.Equal(1030, sector);
+ }
+
+ #endregion
+
+ #region GetAllocationDescriptors
+
+ [Fact]
+ public void GetAllocationDescriptors_Embedded_Empty()
+ {
+ var entry = new FileEntry();
+ entry.ICBTag.Flags = (ushort)AllocationDescriptorType.EMBEDDED;
+ entry.AllocationDescriptors = [1, 2, 3];
+
+ var actual = new Volume().GetAllocationDescriptors(0, entry);
+ Assert.NotNull(actual);
+ Assert.Empty(actual);
+ }
+
+ [Fact]
+ public void GetAllocationDescriptors_ZeroLengthTerminates()
+ {
+ var entry = CreateShortAdEntry((2048, 20), (0, 99), (2048, 40));
+
+ var actual = new Volume().GetAllocationDescriptors(UDFExtensions.GetKey(2, 7), entry);
+ Assert.NotNull(actual);
+ var ad = Assert.Single(actual);
+ Assert.Equal(20u, ad.ExtentLocation.LogicalBlockNumber);
+ Assert.Equal((ushort)2, ad.ExtentLocation.PartitionReferenceNumber);
+ }
+
+ [Fact]
+ public void GetAllocationDescriptors_MissingAllocationExtent_NullWithKey()
+ {
+ var entry = CreateShortAdEntry((2048, 20), ((3u << 30) | 2048, 50));
+
+ var actual = new Volume().GetAllocationDescriptors(UDFExtensions.GetKey(0, 7), entry, out long missingKey);
+ Assert.Null(actual);
+ Assert.Equal(UDFExtensions.GetKey(0, 50), missingKey);
+ }
+
+ [Fact]
+ public void GetAllocationDescriptors_AllocationExtentLoop_Null()
+ {
+ var volume = new Volume();
+ var entry = CreateShortAdEntry(((3u << 30) | 2048, 50));
+ volume.AllocationExtentDescriptors[UDFExtensions.GetKey(0, 50)] = new AllocationExtentDescriptor
+ {
+ AllocationDescriptors = CreateShortAdEntry(((3u << 30) | 2048, 50)).AllocationDescriptors,
+ };
+
+ var actual = volume.GetAllocationDescriptors(UDFExtensions.GetKey(0, 7), entry);
+ Assert.Null(actual);
+ }
+
+ #endregion
+
+ ///
+ /// Volume with partition 0 at sector 1000 (length 50) and the given partition maps
+ ///
+ private static Volume CreateVolume(params PartitionMap[] maps)
+ {
+ return new Volume
+ {
+ VolumeDescriptorSequence =
+ [
+ new PartitionDescriptor { PartitionNumber = 0, PartitionStartingLocation = 1000, PartitionLength = 50 },
+ new LogicalVolumeDescriptor { LogicalBlockSize = 2048, PartitionMaps = maps },
+ ],
+ };
+ }
+
+ ///
+ /// File Entry with the given short allocation descriptors (extent length, position)
+ ///
+ private static FileEntry CreateShortAdEntry(params (uint Length, uint Position)[] extents)
+ {
+ var data = new byte[extents.Length * 8];
+ for (int i = 0; i < extents.Length; i++)
+ {
+ for (int b = 0; b < 4; b++)
+ {
+ data[(i * 8) + b] = (byte)(extents[i].Length >> (8 * b));
+ data[(i * 8) + 4 + b] = (byte)(extents[i].Position >> (8 * b));
+ }
+ }
+
+ var entry = new FileEntry { AllocationDescriptors = data };
+ entry.ICBTag.Flags = (ushort)AllocationDescriptorType.SHORT;
+ return entry;
+ }
+ }
+}
diff --git a/SabreTools.Data.Extensions/UDFExtensions.cs b/SabreTools.Data.Extensions/UDFExtensions.cs
new file mode 100644
index 00000000..f69a6611
--- /dev/null
+++ b/SabreTools.Data.Extensions/UDFExtensions.cs
@@ -0,0 +1,347 @@
+using System.Collections.Generic;
+using System.Text;
+using SabreTools.Data.Models.UDF;
+
+namespace SabreTools.Data.Extensions
+{
+ public static class UDFExtensions
+ {
+ ///
+ /// Maximum number of Allocation Extent Descriptors followed for one File Entry
+ ///
+ private const int MaximumAllocationExtents = 1024;
+
+ ///
+ /// Get the dictionary key of a logical block address
+ ///
+ /// Logical block address
+ /// ((long)PartitionReferenceNumber << 32) | LogicalBlockNumber
+ public static long GetKey(this LogicalBlockAddress address)
+ => GetKey(address.PartitionReferenceNumber, address.LogicalBlockNumber);
+
+ ///
+ /// Get the dictionary key of a logical block address
+ ///
+ /// Index of the partition map
+ /// Logical block number within the partition
+ /// ((long)partitionReferenceNumber << 32) | logicalBlockNumber
+ public static long GetKey(ushort partitionReferenceNumber, uint logicalBlockNumber)
+ => ((long)partitionReferenceNumber << 32) | logicalBlockNumber;
+
+ ///
+ /// Get the extent type from an allocation descriptor's extent length
+ ///
+ public static ExtentType GetExtentType(uint extentLength)
+ => (ExtentType)(extentLength >> Constants.ExtentTypeShift);
+
+ ///
+ /// Get the extent length in bytes from an allocation descriptor's extent length
+ ///
+ public static uint GetExtentLength(uint extentLength)
+ => extentLength & Constants.ExtentLengthMask;
+
+ ///
+ /// Get the type of the allocation descriptors of an ICB
+ ///
+ public static AllocationDescriptorType GetAllocationDescriptorType(this ICBTag tag)
+ => (AllocationDescriptorType)(tag.Flags & Constants.AllocationDescriptorTypeMask);
+
+ ///
+ /// Get the prevailing Logical Volume Descriptor (highest Volume Descriptor Sequence Number)
+ ///
+ /// Volume to search
+ /// Logical Volume Descriptor on success, null if none was recorded
+ public static LogicalVolumeDescriptor? GetLogicalVolumeDescriptor(this Volume volume)
+ {
+ LogicalVolumeDescriptor? prevailing = null;
+ foreach (var descriptor in volume.VolumeDescriptorSequence)
+ {
+ if (descriptor is LogicalVolumeDescriptor lvd
+ && (prevailing is null || lvd.VolumeDescriptorSequenceNumber > prevailing.VolumeDescriptorSequenceNumber))
+ {
+ prevailing = lvd;
+ }
+ }
+
+ return prevailing;
+ }
+
+ ///
+ /// Get the prevailing Partition Descriptor of a partition (highest Volume Descriptor Sequence Number)
+ ///
+ /// Volume to search
+ /// Partition number
+ /// Partition Descriptor on success, null if none was recorded
+ public static PartitionDescriptor? GetPartitionDescriptor(this Volume volume, ushort partitionNumber)
+ {
+ PartitionDescriptor? prevailing = null;
+ foreach (var descriptor in volume.VolumeDescriptorSequence)
+ {
+ if (descriptor is PartitionDescriptor pd
+ && pd.PartitionNumber == partitionNumber
+ && (prevailing is null || pd.VolumeDescriptorSequenceNumber > prevailing.VolumeDescriptorSequenceNumber))
+ {
+ prevailing = pd;
+ }
+ }
+
+ return prevailing;
+ }
+
+ ///
+ /// Get the partition reference number of the Type 1 partition map for a partition number
+ ///
+ /// Logical Volume Descriptor holding the partition maps
+ /// Partition number
+ /// Partition reference number on success, -1 if there is no such map
+ public static int GetType1PartitionReference(this LogicalVolumeDescriptor lvd, ushort partitionNumber)
+ {
+ for (int i = 0; i < lvd.PartitionMaps.Length; i++)
+ {
+ if (lvd.PartitionMaps[i] is Type1PartitionMap map && map.PartitionNumber == partitionNumber)
+ return i;
+ }
+
+ return -1;
+ }
+
+ ///
+ /// Map a logical block of a partition to a logical sector of the volume
+ /// Supports Type 1 partitions and Metadata Partitions (the Metadata File's File Entry must be in FileEntries)
+ ///
+ /// Volume containing the Volume Descriptor Sequence
+ /// Logical block address key, see GetKey
+ /// Logical sector on success
+ /// True if the block could be mapped, false otherwise
+ public static bool TryGetSector(this Volume volume, long key, out long sector)
+ {
+ sector = -1;
+
+ var lvd = volume.GetLogicalVolumeDescriptor();
+ if (lvd is null)
+ return false;
+
+ int partitionReference = (int)(key >> 32);
+ uint block = (uint)key;
+ if (partitionReference < 0 || partitionReference >= lvd.PartitionMaps.Length)
+ return false;
+
+ var map = lvd.PartitionMaps[partitionReference];
+ if (map is Type1PartitionMap type1)
+ {
+ var pd = volume.GetPartitionDescriptor(type1.PartitionNumber);
+ if (pd is null || block >= pd.PartitionLength)
+ return false;
+
+ sector = (long)pd.PartitionStartingLocation + block;
+ return true;
+ }
+ else if (map is MetadataPartitionMap metadata)
+ {
+ // The Metadata File (or its mirror) is recorded in the Type 1 partition of the same partition number
+ int physical = lvd.GetType1PartitionReference(metadata.PartitionNumber);
+ if (physical < 0)
+ return false;
+
+ if (!TryGetMetadataFileBlock(volume, lvd, GetKey((ushort)physical, metadata.MetadataFileLocation), block, out long physicalKey)
+ && !TryGetMetadataFileBlock(volume, lvd, GetKey((ushort)physical, metadata.MetadataMirrorFileLocation), block, out physicalKey))
+ {
+ return false;
+ }
+
+ return volume.TryGetSector(physicalKey, out sector);
+ }
+
+ // Virtual and Sparable partitions are not supported
+ return false;
+ }
+
+ ///
+ /// Map a logical block of a Metadata Partition through the Metadata File's allocation descriptors
+ ///
+ private static bool TryGetMetadataFileBlock(Volume volume, LogicalVolumeDescriptor lvd, long fileKey, uint block, out long physicalKey)
+ {
+ physicalKey = -1;
+ if (!volume.FileEntries.TryGetValue(fileKey, out var fileEntry))
+ return false;
+
+ var descriptors = volume.GetAllocationDescriptors(fileKey, fileEntry);
+ if (descriptors is null)
+ return false;
+
+ uint blockSize = lvd.LogicalBlockSize;
+ long first = 0;
+ foreach (var ad in descriptors)
+ {
+ long blocks = (GetExtentLength(ad.ExtentLength) + blockSize - 1) / blockSize;
+ if (block < first + blocks)
+ {
+ if (GetExtentType(ad.ExtentLength) != ExtentType.RECORDED_AND_ALLOCATED)
+ return false;
+
+ physicalKey = GetKey(ad.ExtentLocation.PartitionReferenceNumber, (uint)(ad.ExtentLocation.LogicalBlockNumber + (block - first)));
+ return true;
+ }
+
+ first += blocks;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Get the allocation descriptors of a File Entry, including those continued in Allocation Extent Descriptors,
+ /// as long allocation descriptors (short allocation descriptors get the partition of the File Entry)
+ ///
+ /// Volume containing the Allocation Extent Descriptors
+ /// Logical block address key of the File Entry
+ /// File Entry
+ /// Allocation descriptors (empty for embedded data), null if they are not supported or incomplete
+ public static List? GetAllocationDescriptors(this Volume volume, long key, FileEntry fileEntry)
+ => volume.GetAllocationDescriptors(key, fileEntry, out _);
+
+ ///
+ /// Get the allocation descriptors of a File Entry, including those continued in Allocation Extent Descriptors,
+ /// as long allocation descriptors (short allocation descriptors get the partition of the File Entry)
+ ///
+ /// Volume containing the Allocation Extent Descriptors
+ /// Logical block address key of the File Entry
+ /// File Entry
+ /// Key of the first Allocation Extent Descriptor that is not in the volume, -1 if none
+ /// Allocation descriptors (empty for embedded data), null if they are not supported or incomplete
+ public static List? GetAllocationDescriptors(this Volume volume, long key, FileEntry fileEntry, out long missingKey)
+ {
+ missingKey = -1;
+
+ var type = fileEntry.ICBTag.GetAllocationDescriptorType();
+ if (type == AllocationDescriptorType.EMBEDDED)
+ return [];
+
+ // Extended allocation descriptors are not allowed by UDF
+ if (type != AllocationDescriptorType.SHORT && type != AllocationDescriptorType.LONG)
+ return null;
+
+ var result = new List();
+ ushort partition = (ushort)(key >> 32);
+ byte[] descriptors = fileEntry.AllocationDescriptors;
+ int length = descriptors.Length;
+
+ for (int extents = 0; extents <= MaximumAllocationExtents; extents++)
+ {
+ long nextKey = -1;
+ int size = type == AllocationDescriptorType.SHORT ? 8 : 16;
+ for (int offset = 0; offset + size <= length; offset += size)
+ {
+ var ad = new LongAllocationDescriptor();
+ ad.ExtentLength = ReadUInt32(descriptors, offset);
+ if (type == AllocationDescriptorType.SHORT)
+ {
+ ad.ExtentLocation.LogicalBlockNumber = ReadUInt32(descriptors, offset + 4);
+ ad.ExtentLocation.PartitionReferenceNumber = partition;
+ }
+ else
+ {
+ ad.ExtentLocation.LogicalBlockNumber = ReadUInt32(descriptors, offset + 4);
+ ad.ExtentLocation.PartitionReferenceNumber = (ushort)(descriptors[offset + 8] | (descriptors[offset + 9] << 8));
+ for (int i = 0; i < 6; i++)
+ {
+ ad.ImplementationUse[i] = descriptors[offset + 10 + i];
+ }
+ }
+
+ // A zero length terminates the allocation descriptors
+ if (GetExtentLength(ad.ExtentLength) == 0)
+ return result;
+
+ if (GetExtentType(ad.ExtentLength) == ExtentType.NEXT_EXTENT)
+ {
+ nextKey = ad.ExtentLocation.GetKey();
+ break;
+ }
+
+ result.Add(ad);
+ }
+
+ if (nextKey < 0)
+ return result;
+
+ if (!volume.AllocationExtentDescriptors.TryGetValue(nextKey, out var aed))
+ {
+ missingKey = nextKey;
+ return null;
+ }
+
+ partition = (ushort)(nextKey >> 32);
+ descriptors = aed.AllocationDescriptors;
+ length = descriptors.Length;
+ }
+
+ // Too many continuation extents, assume a loop
+ return null;
+ }
+
+ ///
+ /// Decode OSTA Compressed Unicode d-characters (first byte = compression ID)
+ ///
+ /// Encoded characters
+ /// Decoded string, empty for no characters, null for an unknown compression ID
+ public static string? DecodeDCharacters(this byte[]? data)
+ => DecodeDCharacters(data, data?.Length ?? 0);
+
+ ///
+ /// Decode an OSTA Compressed Unicode dstring (last byte = number of used bytes)
+ ///
+ /// Encoded dstring field
+ /// Decoded string, empty for no characters, null for an unknown compression ID
+ public static string? DecodeDString(this byte[]? data)
+ {
+ if (data is null || data.Length == 0)
+ return string.Empty;
+
+ int length = data[data.Length - 1];
+ if (length > data.Length - 1)
+ length = data.Length - 1;
+
+ return DecodeDCharacters(data, length);
+ }
+
+ ///
+ /// Decode the first length bytes of OSTA Compressed Unicode d-characters
+ ///
+ private static string? DecodeDCharacters(byte[]? data, int length)
+ {
+ if (data is null || length == 0)
+ return string.Empty;
+
+ // 254 and 255 are the same encodings, used by UDF for deleted entries
+ byte compressionId = data[0];
+ var builder = new StringBuilder(length);
+ if (compressionId == Constants.CompressionId8 || compressionId == 254)
+ {
+ for (int i = 1; i < length; i++)
+ {
+ builder.Append((char)data[i]);
+ }
+ }
+ else if (compressionId == Constants.CompressionId16 || compressionId == 255)
+ {
+ for (int i = 1; i + 1 < length; i += 2)
+ {
+ builder.Append((char)((data[i] << 8) | data[i + 1]));
+ }
+ }
+ else
+ {
+ return null;
+ }
+
+ return builder.ToString();
+ }
+
+ ///
+ /// Read a little-endian UInt32 from a byte array
+ ///
+ private static uint ReadUInt32(byte[] data, int offset)
+ => (uint)(data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24));
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/AllocationExtentDescriptor.cs b/SabreTools.Data.Models/UDF/AllocationExtentDescriptor.cs
new file mode 100644
index 00000000..2735047b
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/AllocationExtentDescriptor.cs
@@ -0,0 +1,24 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Allocation Extent Descriptor (4/14.5), continues the allocation descriptors of a File Entry
+ ///
+ ///
+ public sealed class AllocationExtentDescriptor : Descriptor
+ {
+ ///
+ /// Logical block of the previous allocation extent
+ ///
+ public uint PreviousAllocationExtentLocation { get; set; }
+
+ ///
+ /// Length of the allocation descriptors in bytes
+ ///
+ public uint LengthOfAllocationDescriptors { get; set; }
+
+ ///
+ /// Allocation descriptors, of the type given in the owning File Entry's ICB tag flags
+ ///
+ public byte[] AllocationDescriptors { get; set; } = [];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/AnchorVolumeDescriptorPointer.cs b/SabreTools.Data.Models/UDF/AnchorVolumeDescriptorPointer.cs
new file mode 100644
index 00000000..0a7ac56a
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/AnchorVolumeDescriptorPointer.cs
@@ -0,0 +1,24 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Anchor Volume Descriptor Pointer (3/10.2), recorded at sector 256 (and at the end of the volume)
+ ///
+ ///
+ public sealed class AnchorVolumeDescriptorPointer : Descriptor
+ {
+ ///
+ /// Extent of the Main Volume Descriptor Sequence
+ ///
+ public ExtentDescriptor MainVolumeDescriptorSequenceExtent { get; set; } = new();
+
+ ///
+ /// Extent of the Reserve Volume Descriptor Sequence
+ ///
+ public ExtentDescriptor ReserveVolumeDescriptorSequenceExtent { get; set; } = new();
+
+ ///
+ /// Reserved, 480 bytes of 0x00
+ ///
+ public byte[] Reserved { get; set; } = new byte[480];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/CharacterSetSpecification.cs b/SabreTools.Data.Models/UDF/CharacterSetSpecification.cs
new file mode 100644
index 00000000..2bc272a2
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/CharacterSetSpecification.cs
@@ -0,0 +1,20 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Character Set Specification (charspec, 1/7.2.1)
+ /// UDF requires CS0 with the information "OSTA Compressed Unicode"
+ ///
+ ///
+ public sealed class CharacterSetSpecification
+ {
+ ///
+ /// Character set type (0 = CS0)
+ ///
+ public byte CharacterSetType { get; set; }
+
+ ///
+ /// Character set information, padded with 0x00
+ ///
+ public byte[] CharacterSetInformation { get; set; } = new byte[63];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/Constants.cs b/SabreTools.Data.Models/UDF/Constants.cs
new file mode 100644
index 00000000..96ad5c40
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/Constants.cs
@@ -0,0 +1,126 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF / ECMA-167 constant values and arrays
+ ///
+ ///
+ ///
+ public static class Constants
+ {
+ #region Volume Recognition Constants
+
+ ///
+ /// Size of a logical sector on optical media
+ ///
+ public const int SectorSize = 2048;
+
+ ///
+ /// Sector of the first Volume Structure Descriptor (after the 32KiB System Area)
+ ///
+ public const int VolumeRecognitionSequenceSector = 16;
+
+ ///
+ /// Sector of the first Anchor Volume Descriptor Pointer
+ ///
+ public const int AnchorVolumeDescriptorPointerSector = 256;
+
+ ///
+ /// Beginning Extended Area Descriptor identifier, "BEA01"
+ ///
+ public static readonly byte[] BeginningExtendedAreaDescriptor = [0x42, 0x45, 0x41, 0x30, 0x31];
+
+ ///
+ /// NSR Descriptor identifier for ECMA-167 2nd edition (UDF 1.02 to 1.50), "NSR02"
+ ///
+ public static readonly byte[] NSR02 = [0x4E, 0x53, 0x52, 0x30, 0x32];
+
+ ///
+ /// NSR Descriptor identifier for ECMA-167 3rd edition (UDF 2.00 and later), "NSR03"
+ ///
+ public static readonly byte[] NSR03 = [0x4E, 0x53, 0x52, 0x30, 0x33];
+
+ ///
+ /// Terminating Extended Area Descriptor identifier, "TEA01"
+ ///
+ public static readonly byte[] TerminatingExtendedAreaDescriptor = [0x54, 0x45, 0x41, 0x30, 0x31];
+
+ ///
+ /// ISO 9660 volume descriptor identifier, "CD001" (bridge discs)
+ ///
+ public static readonly byte[] CD001 = [0x43, 0x44, 0x30, 0x30, 0x31];
+
+ ///
+ /// Boot Descriptor identifier, "BOOT2"
+ ///
+ public static readonly byte[] BOOT2 = [0x42, 0x4F, 0x4F, 0x54, 0x32];
+
+ ///
+ /// ECMA-168 volume descriptor identifier, "CDW02"
+ ///
+ public static readonly byte[] CDW02 = [0x43, 0x44, 0x57, 0x30, 0x32];
+
+ #endregion
+
+ #region Partition Map Constants
+
+ ///
+ /// Type 1 Partition Map type
+ ///
+ public const byte Type1PartitionMap = 1;
+
+ ///
+ /// Type 2 Partition Map type
+ ///
+ public const byte Type2PartitionMap = 2;
+
+ ///
+ /// Partition type identifier of a UDF Metadata Partition (UDF 2.50 and later)
+ ///
+ public const string MetadataPartitionIdentifier = "*UDF Metadata Partition";
+
+ ///
+ /// Partition type identifier of a UDF Sparable Partition
+ ///
+ public const string SparablePartitionIdentifier = "*UDF Sparable Partition";
+
+ ///
+ /// Partition type identifier of a UDF Virtual Partition
+ ///
+ public const string VirtualPartitionIdentifier = "*UDF Virtual Partition";
+
+ #endregion
+
+ #region Character Set Constants
+
+ ///
+ /// OSTA Compressed Unicode, 8 bits per character
+ ///
+ public const byte CompressionId8 = 8;
+
+ ///
+ /// OSTA Compressed Unicode, 16 bits per character (big-endian)
+ ///
+ public const byte CompressionId16 = 16;
+
+ #endregion
+
+ #region Allocation Descriptor Constants
+
+ ///
+ /// Mask of the extent length in an allocation descriptor (the top two bits hold the extent type)
+ ///
+ public const uint ExtentLengthMask = 0x3FFFFFFF;
+
+ ///
+ /// Shift of the extent type in an allocation descriptor's extent length
+ ///
+ public const int ExtentTypeShift = 30;
+
+ ///
+ /// Mask of the allocation descriptor type in an ICB tag's flags
+ ///
+ public const ushort AllocationDescriptorTypeMask = 0x0007;
+
+ #endregion
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/Descriptor.cs b/SabreTools.Data.Models/UDF/Descriptor.cs
new file mode 100644
index 00000000..caf3e508
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/Descriptor.cs
@@ -0,0 +1,14 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// Abstract UDF tagged descriptor, all start with a Descriptor Tag
+ ///
+ ///
+ public abstract class Descriptor
+ {
+ ///
+ /// Descriptor tag
+ ///
+ public DescriptorTag DescriptorTag { get; set; } = new();
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/DescriptorTag.cs b/SabreTools.Data.Models/UDF/DescriptorTag.cs
new file mode 100644
index 00000000..f79e199f
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/DescriptorTag.cs
@@ -0,0 +1,50 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Descriptor Tag (3/7.2), the first 16 bytes of every tagged descriptor
+ ///
+ ///
+ public sealed class DescriptorTag
+ {
+ ///
+ /// Type of the descriptor
+ ///
+ public TagIdentifier TagIdentifier { get; set; }
+
+ ///
+ /// Version of the descriptor, 2 (ECMA-167 2nd edition) or 3 (3rd edition)
+ ///
+ public ushort DescriptorVersion { get; set; }
+
+ ///
+ /// Sum modulo 256 of bytes 0-3 and 5-15 of the tag
+ ///
+ public byte TagChecksum { get; set; }
+
+ ///
+ /// Reserved, 0x00
+ ///
+ public byte Reserved { get; set; }
+
+ ///
+ /// Identification of a set of descriptors
+ ///
+ public ushort TagSerialNumber { get; set; }
+
+ ///
+ /// CRC-CCITT of the DescriptorCRCLength bytes following the tag
+ ///
+ public ushort DescriptorCRC { get; set; }
+
+ ///
+ /// Number of bytes after the tag covered by the DescriptorCRC
+ ///
+ public ushort DescriptorCRCLength { get; set; }
+
+ ///
+ /// Number of the logical sector (volume structures) or logical block (file structures)
+ /// in which the descriptor is recorded
+ ///
+ public uint TagLocation { get; set; }
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/EntityIdentifier.cs b/SabreTools.Data.Models/UDF/EntityIdentifier.cs
new file mode 100644
index 00000000..2f61c2ab
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/EntityIdentifier.cs
@@ -0,0 +1,24 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Entity Identifier (regid, 1/7.4)
+ ///
+ ///
+ public sealed class EntityIdentifier
+ {
+ ///
+ /// Flags (bit 0: dirty, bit 1: protected)
+ ///
+ public byte Flags { get; set; }
+
+ ///
+ /// Identifier, padded with 0x00
+ ///
+ public byte[] Identifier { get; set; } = new byte[23];
+
+ ///
+ /// Identifier suffix (UDF domain identifiers: revision as a little-endian ushort, then domain flags)
+ ///
+ public byte[] IdentifierSuffix { get; set; } = new byte[8];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/Enums.cs b/SabreTools.Data.Models/UDF/Enums.cs
new file mode 100644
index 00000000..f88d0d50
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/Enums.cs
@@ -0,0 +1,306 @@
+using System;
+
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// Enum for DescriptorTag.TagIdentifier
+ ///
+ ///
+ public enum TagIdentifier : ushort
+ {
+ ///
+ /// Primary Volume Descriptor (3/10.1)
+ ///
+ PRIMARY_VOLUME_DESCRIPTOR = 1,
+
+ ///
+ /// Anchor Volume Descriptor Pointer (3/10.2)
+ ///
+ ANCHOR_VOLUME_DESCRIPTOR_POINTER = 2,
+
+ ///
+ /// Volume Descriptor Pointer (3/10.3)
+ ///
+ VOLUME_DESCRIPTOR_POINTER = 3,
+
+ ///
+ /// Implementation Use Volume Descriptor (3/10.4)
+ ///
+ IMPLEMENTATION_USE_VOLUME_DESCRIPTOR = 4,
+
+ ///
+ /// Partition Descriptor (3/10.5)
+ ///
+ PARTITION_DESCRIPTOR = 5,
+
+ ///
+ /// Logical Volume Descriptor (3/10.6)
+ ///
+ LOGICAL_VOLUME_DESCRIPTOR = 6,
+
+ ///
+ /// Unallocated Space Descriptor (3/10.8)
+ ///
+ UNALLOCATED_SPACE_DESCRIPTOR = 7,
+
+ ///
+ /// Terminating Descriptor (3/10.9)
+ ///
+ TERMINATING_DESCRIPTOR = 8,
+
+ ///
+ /// Logical Volume Integrity Descriptor (3/10.10)
+ ///
+ LOGICAL_VOLUME_INTEGRITY_DESCRIPTOR = 9,
+
+ ///
+ /// File Set Descriptor (4/14.1)
+ ///
+ FILE_SET_DESCRIPTOR = 256,
+
+ ///
+ /// File Identifier Descriptor (4/14.4)
+ ///
+ FILE_IDENTIFIER_DESCRIPTOR = 257,
+
+ ///
+ /// Allocation Extent Descriptor (4/14.5)
+ ///
+ ALLOCATION_EXTENT_DESCRIPTOR = 258,
+
+ ///
+ /// Indirect Entry (4/14.7)
+ ///
+ INDIRECT_ENTRY = 259,
+
+ ///
+ /// Terminal Entry (4/14.8)
+ ///
+ TERMINAL_ENTRY = 260,
+
+ ///
+ /// File Entry (4/14.9)
+ ///
+ FILE_ENTRY = 261,
+
+ ///
+ /// Extended Attribute Header Descriptor (4/14.10.1)
+ ///
+ EXTENDED_ATTRIBUTE_HEADER_DESCRIPTOR = 262,
+
+ ///
+ /// Unallocated Space Entry (4/14.11)
+ ///
+ UNALLOCATED_SPACE_ENTRY = 263,
+
+ ///
+ /// Space Bitmap Descriptor (4/14.12)
+ ///
+ SPACE_BITMAP_DESCRIPTOR = 264,
+
+ ///
+ /// Partition Integrity Entry (4/14.13)
+ ///
+ PARTITION_INTEGRITY_ENTRY = 265,
+
+ ///
+ /// Extended File Entry (4/14.17)
+ ///
+ EXTENDED_FILE_ENTRY = 266,
+ }
+
+ ///
+ /// Enum for FileIdentifierDescriptor.FileCharacteristics (4/14.4.3)
+ /// Bits 5-7: Reserved: 0
+ ///
+ ///
+ [Flags]
+ public enum FileCharacteristics : byte
+ {
+ ///
+ /// No characteristics set
+ ///
+ NONE = 0x00,
+
+ ///
+ /// The existence of the file shall not be made known to the user (hidden)
+ ///
+ EXISTENCE = 0x01,
+
+ ///
+ /// The file is a directory
+ ///
+ DIRECTORY = 0x02,
+
+ ///
+ /// The descriptor shall be ignored (deleted)
+ ///
+ DELETED = 0x04,
+
+ ///
+ /// The ICB identifies the parent directory
+ ///
+ PARENT = 0x08,
+
+ ///
+ /// The file contains implementation use data (UDF: stream directory metadata)
+ ///
+ METADATA = 0x10,
+ }
+
+ ///
+ /// Enum for ICBTag.FileType (4/14.6.6, UDF 2.3.5.4)
+ ///
+ ///
+ public enum FileType : byte
+ {
+ ///
+ /// Not specified
+ ///
+ UNSPECIFIED = 0,
+
+ ///
+ /// Unallocated Space Entry
+ ///
+ UNALLOCATED_SPACE_ENTRY = 1,
+
+ ///
+ /// Partition Integrity Entry
+ ///
+ PARTITION_INTEGRITY_ENTRY = 2,
+
+ ///
+ /// Indirect Entry
+ ///
+ INDIRECT_ENTRY = 3,
+
+ ///
+ /// Directory
+ ///
+ DIRECTORY = 4,
+
+ ///
+ /// Sequence of bytes (a regular file)
+ ///
+ FILE = 5,
+
+ ///
+ /// Block special device
+ ///
+ BLOCK_DEVICE = 6,
+
+ ///
+ /// Character special device
+ ///
+ CHARACTER_DEVICE = 7,
+
+ ///
+ /// Extended attributes
+ ///
+ EXTENDED_ATTRIBUTES = 8,
+
+ ///
+ /// FIFO file
+ ///
+ FIFO = 9,
+
+ ///
+ /// Socket
+ ///
+ SOCKET = 10,
+
+ ///
+ /// Terminal Entry
+ ///
+ TERMINAL_ENTRY = 11,
+
+ ///
+ /// Symbolic link
+ ///
+ SYMBOLIC_LINK = 12,
+
+ ///
+ /// Stream directory
+ ///
+ STREAM_DIRECTORY = 13,
+
+ ///
+ /// UDF Virtual Allocation Table
+ ///
+ VIRTUAL_ALLOCATION_TABLE = 248,
+
+ ///
+ /// UDF Real-Time file
+ ///
+ REAL_TIME_FILE = 249,
+
+ ///
+ /// UDF Metadata File
+ ///
+ METADATA_FILE = 250,
+
+ ///
+ /// UDF Metadata Mirror File
+ ///
+ METADATA_MIRROR_FILE = 251,
+
+ ///
+ /// UDF Metadata Bitmap File
+ ///
+ METADATA_BITMAP_FILE = 252,
+ }
+
+ ///
+ /// Enum for the allocation descriptor type in ICBTag.Flags (4/14.6.8)
+ ///
+ ///
+ public enum AllocationDescriptorType : byte
+ {
+ ///
+ /// Short Allocation Descriptors (4/14.14.1)
+ ///
+ SHORT = 0,
+
+ ///
+ /// Long Allocation Descriptors (4/14.14.2)
+ ///
+ LONG = 1,
+
+ ///
+ /// Extended Allocation Descriptors (4/14.14.3)
+ ///
+ EXTENDED = 2,
+
+ ///
+ /// The file data is embedded in the allocation descriptors field
+ ///
+ EMBEDDED = 3,
+ }
+
+ ///
+ /// Enum for the extent type in the top two bits of an allocation descriptor's extent length (4/14.14.1.1)
+ ///
+ ///
+ public enum ExtentType : byte
+ {
+ ///
+ /// Extent recorded and allocated
+ ///
+ RECORDED_AND_ALLOCATED = 0,
+
+ ///
+ /// Extent not recorded but allocated
+ ///
+ NOT_RECORDED_BUT_ALLOCATED = 1,
+
+ ///
+ /// Extent not recorded and not allocated
+ ///
+ NOT_RECORDED_NOT_ALLOCATED = 2,
+
+ ///
+ /// The extent is the next extent of allocation descriptors
+ ///
+ NEXT_EXTENT = 3,
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/ExtendedFileEntry.cs b/SabreTools.Data.Models/UDF/ExtendedFileEntry.cs
new file mode 100644
index 00000000..2aa41f3b
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/ExtendedFileEntry.cs
@@ -0,0 +1,30 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Extended File Entry (4/14.17, UDF 2.00 and later), a File Entry with object size,
+ /// creation time and a stream directory
+ ///
+ ///
+ public sealed class ExtendedFileEntry : FileEntry
+ {
+ ///
+ /// Size of the file and its streams in bytes
+ ///
+ public ulong ObjectSize { get; set; }
+
+ ///
+ /// Creation date and time
+ ///
+ public Timestamp CreationDateAndTime { get; set; } = new();
+
+ ///
+ /// Reserved, 0x00000000
+ ///
+ public uint Reserved { get; set; }
+
+ ///
+ /// ICB of the stream directory, length 0 if none
+ ///
+ public LongAllocationDescriptor StreamDirectoryICB { get; set; } = new();
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/ExtentDescriptor.cs b/SabreTools.Data.Models/UDF/ExtentDescriptor.cs
new file mode 100644
index 00000000..8b496978
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/ExtentDescriptor.cs
@@ -0,0 +1,19 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Extent Descriptor (extent_ad, 3/7.1), an extent of logical sectors
+ ///
+ ///
+ public sealed class ExtentDescriptor
+ {
+ ///
+ /// Length of the extent in bytes
+ ///
+ public uint ExtentLength { get; set; }
+
+ ///
+ /// Logical sector number of the start of the extent
+ ///
+ public uint ExtentLocation { get; set; }
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/FileEntry.cs b/SabreTools.Data.Models/UDF/FileEntry.cs
new file mode 100644
index 00000000..b81a30ea
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/FileEntry.cs
@@ -0,0 +1,115 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF File Entry (4/14.9), the ICB describing a file or directory and where its data is recorded
+ ///
+ ///
+ public class FileEntry : Descriptor
+ {
+ ///
+ /// ICB tag
+ ///
+ public ICBTag ICBTag { get; set; } = new();
+
+ ///
+ /// User ID
+ ///
+ public uint Uid { get; set; }
+
+ ///
+ /// Group ID
+ ///
+ public uint Gid { get; set; }
+
+ ///
+ /// Permissions
+ ///
+ public uint Permissions { get; set; }
+
+ ///
+ /// Number of File Identifier Descriptors identifying this ICB
+ ///
+ public ushort FileLinkCount { get; set; }
+
+ ///
+ /// Record format
+ ///
+ public byte RecordFormat { get; set; }
+
+ ///
+ /// Record display attributes
+ ///
+ public byte RecordDisplayAttributes { get; set; }
+
+ ///
+ /// Record length
+ ///
+ public uint RecordLength { get; set; }
+
+ ///
+ /// Size of the file in bytes
+ ///
+ public ulong InformationLength { get; set; }
+
+ ///
+ /// Number of recorded logical blocks
+ ///
+ public ulong LogicalBlocksRecorded { get; set; }
+
+ ///
+ /// Access date and time
+ ///
+ public Timestamp AccessDateAndTime { get; set; } = new();
+
+ ///
+ /// Modification date and time
+ ///
+ public Timestamp ModificationDateAndTime { get; set; } = new();
+
+ ///
+ /// Attribute date and time
+ ///
+ public Timestamp AttributeDateAndTime { get; set; } = new();
+
+ ///
+ /// Checkpoint
+ ///
+ public uint Checkpoint { get; set; }
+
+ ///
+ /// ICB of the extended attributes, length 0 if none
+ ///
+ public LongAllocationDescriptor ExtendedAttributeICB { get; set; } = new();
+
+ ///
+ /// Implementation identifier
+ ///
+ public EntityIdentifier ImplementationIdentifier { get; set; } = new();
+
+ ///
+ /// Unique ID
+ ///
+ public ulong UniqueID { get; set; }
+
+ ///
+ /// Length of the extended attributes in bytes
+ ///
+ public uint LengthOfExtendedAttributes { get; set; }
+
+ ///
+ /// Length of the allocation descriptors in bytes
+ ///
+ public uint LengthOfAllocationDescriptors { get; set; }
+
+ ///
+ /// Extended attributes
+ ///
+ public byte[] ExtendedAttributes { get; set; } = [];
+
+ ///
+ /// Allocation descriptors of the type given in the ICB tag flags,
+ /// or the file data itself if the type is AllocationDescriptorType.EMBEDDED
+ ///
+ public byte[] AllocationDescriptors { get; set; } = [];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/FileIdentifierDescriptor.cs b/SabreTools.Data.Models/UDF/FileIdentifierDescriptor.cs
new file mode 100644
index 00000000..ec9822a4
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/FileIdentifierDescriptor.cs
@@ -0,0 +1,49 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF File Identifier Descriptor (4/14.4), a directory entry naming a file or directory and pointing to its ICB
+ ///
+ ///
+ public sealed class FileIdentifierDescriptor : Descriptor
+ {
+ ///
+ /// File version number
+ ///
+ public ushort FileVersionNumber { get; set; }
+
+ ///
+ /// File characteristics
+ ///
+ public FileCharacteristics FileCharacteristics { get; set; }
+
+ ///
+ /// Length of the file identifier in bytes
+ ///
+ public byte LengthOfFileIdentifier { get; set; }
+
+ ///
+ /// ICB of the file or directory
+ ///
+ public LongAllocationDescriptor ICB { get; set; } = new();
+
+ ///
+ /// Length of the implementation use in bytes
+ ///
+ public ushort LengthOfImplementationUse { get; set; }
+
+ ///
+ /// Implementation use
+ ///
+ public byte[] ImplementationUse { get; set; } = [];
+
+ ///
+ /// File identifier, d-characters (first byte = compression ID), empty for the parent directory
+ ///
+ public byte[] FileIdentifier { get; set; } = [];
+
+ ///
+ /// Padding to a multiple of 4 bytes
+ ///
+ public byte[] Padding { get; set; } = [];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/FileSetDescriptor.cs b/SabreTools.Data.Models/UDF/FileSetDescriptor.cs
new file mode 100644
index 00000000..129a6a30
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/FileSetDescriptor.cs
@@ -0,0 +1,99 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF File Set Descriptor (4/14.1), the location of the root directory
+ ///
+ ///
+ public sealed class FileSetDescriptor : Descriptor
+ {
+ ///
+ /// Recording date and time
+ ///
+ public Timestamp RecordingDateAndTime { get; set; } = new();
+
+ ///
+ /// Interchange level
+ ///
+ public ushort InterchangeLevel { get; set; }
+
+ ///
+ /// Maximum interchange level
+ ///
+ public ushort MaximumInterchangeLevel { get; set; }
+
+ ///
+ /// Character set list
+ ///
+ public uint CharacterSetList { get; set; }
+
+ ///
+ /// Maximum character set list
+ ///
+ public uint MaximumCharacterSetList { get; set; }
+
+ ///
+ /// File set number
+ ///
+ public uint FileSetNumber { get; set; }
+
+ ///
+ /// File Set Descriptor number
+ ///
+ public uint FileSetDescriptorNumber { get; set; }
+
+ ///
+ /// Logical volume identifier character set
+ ///
+ public CharacterSetSpecification LogicalVolumeIdentifierCharacterSet { get; set; } = new();
+
+ ///
+ /// Logical volume identifier, dstring (last byte = length)
+ ///
+ public byte[] LogicalVolumeIdentifier { get; set; } = new byte[128];
+
+ ///
+ /// File set character set
+ ///
+ public CharacterSetSpecification FileSetCharacterSet { get; set; } = new();
+
+ ///
+ /// File set identifier, dstring (last byte = length)
+ ///
+ public byte[] FileSetIdentifier { get; set; } = new byte[32];
+
+ ///
+ /// Copyright file identifier, dstring (last byte = length)
+ ///
+ public byte[] CopyrightFileIdentifier { get; set; } = new byte[32];
+
+ ///
+ /// Abstract file identifier, dstring (last byte = length)
+ ///
+ public byte[] AbstractFileIdentifier { get; set; } = new byte[32];
+
+ ///
+ /// ICB of the root directory
+ ///
+ public LongAllocationDescriptor RootDirectoryICB { get; set; } = new();
+
+ ///
+ /// Domain identifier
+ ///
+ public EntityIdentifier DomainIdentifier { get; set; } = new();
+
+ ///
+ /// Next extent of File Set Descriptors, length 0 if none
+ ///
+ public LongAllocationDescriptor NextExtent { get; set; } = new();
+
+ ///
+ /// ICB of the system stream directory (ECMA-167 3rd edition), length 0 if none
+ ///
+ public LongAllocationDescriptor SystemStreamDirectoryICB { get; set; } = new();
+
+ ///
+ /// Reserved, 32 bytes of 0x00
+ ///
+ public byte[] Reserved { get; set; } = new byte[32];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/GenericDescriptor.cs b/SabreTools.Data.Models/UDF/GenericDescriptor.cs
new file mode 100644
index 00000000..fcbcb8f4
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/GenericDescriptor.cs
@@ -0,0 +1,15 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF tagged descriptor of a type that is not parsed further
+ /// (e.g. Implementation Use Volume Descriptor, Unallocated Space Descriptor, Terminating Descriptor)
+ ///
+ ///
+ public sealed class GenericDescriptor : Descriptor
+ {
+ ///
+ /// Contents of the descriptor after the tag, up to the end of the logical sector
+ ///
+ public byte[] Data { get; set; } = [];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/ICBTag.cs b/SabreTools.Data.Models/UDF/ICBTag.cs
new file mode 100644
index 00000000..df4634a5
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/ICBTag.cs
@@ -0,0 +1,49 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Information Control Block Tag (icbtag, 4/14.6)
+ ///
+ ///
+ public sealed class ICBTag
+ {
+ ///
+ /// Prior recorded number of direct entries
+ ///
+ public uint PriorRecordedNumberOfDirectEntries { get; set; }
+
+ ///
+ /// Strategy type (UDF: 4, or 4096 for write-once media)
+ ///
+ public ushort StrategyType { get; set; }
+
+ ///
+ /// Strategy parameter, 2 bytes
+ ///
+ public byte[] StrategyParameter { get; set; } = new byte[2];
+
+ ///
+ /// Maximum number of entries
+ ///
+ public ushort MaximumNumberOfEntries { get; set; }
+
+ ///
+ /// Reserved, 0x00
+ ///
+ public byte Reserved { get; set; }
+
+ ///
+ /// File type
+ ///
+ public FileType FileType { get; set; }
+
+ ///
+ /// Parent ICB location
+ ///
+ public LogicalBlockAddress ParentICBLocation { get; set; } = new();
+
+ ///
+ /// Flags, the lower 3 bits hold the AllocationDescriptorType
+ ///
+ public ushort Flags { get; set; }
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/LogicalBlockAddress.cs b/SabreTools.Data.Models/UDF/LogicalBlockAddress.cs
new file mode 100644
index 00000000..797559e8
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/LogicalBlockAddress.cs
@@ -0,0 +1,19 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Recorded Address (lb_addr, 4/7.1), a logical block within a partition
+ ///
+ ///
+ public sealed class LogicalBlockAddress
+ {
+ ///
+ /// Logical block number, relative to the start of the partition
+ ///
+ public uint LogicalBlockNumber { get; set; }
+
+ ///
+ /// Index of the partition map in the Logical Volume Descriptor
+ ///
+ public ushort PartitionReferenceNumber { get; set; }
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/LogicalVolumeDescriptor.cs b/SabreTools.Data.Models/UDF/LogicalVolumeDescriptor.cs
new file mode 100644
index 00000000..8682ac86
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/LogicalVolumeDescriptor.cs
@@ -0,0 +1,69 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Logical Volume Descriptor (3/10.6), block size, partition maps and the location of the File Set Descriptor
+ ///
+ ///
+ public sealed class LogicalVolumeDescriptor : Descriptor
+ {
+ ///
+ /// Volume Descriptor Sequence Number, the highest number prevails
+ ///
+ public uint VolumeDescriptorSequenceNumber { get; set; }
+
+ ///
+ /// Descriptor character set
+ ///
+ public CharacterSetSpecification DescriptorCharacterSet { get; set; } = new();
+
+ ///
+ /// Logical volume identifier, dstring (last byte = length)
+ ///
+ public byte[] LogicalVolumeIdentifier { get; set; } = new byte[128];
+
+ ///
+ /// Size of a logical block in bytes
+ ///
+ public uint LogicalBlockSize { get; set; }
+
+ ///
+ /// Domain identifier ("*OSTA UDF Compliant", suffix holds the UDF revision)
+ ///
+ public EntityIdentifier DomainIdentifier { get; set; } = new();
+
+ ///
+ /// Logical volume contents use (UDF: extent of the File Set Descriptor)
+ ///
+ public LongAllocationDescriptor LogicalVolumeContentsUse { get; set; } = new();
+
+ ///
+ /// Length of the partition maps in bytes
+ ///
+ public uint MapTableLength { get; set; }
+
+ ///
+ /// Number of partition maps
+ ///
+ public uint NumberOfPartitionMaps { get; set; }
+
+ ///
+ /// Implementation identifier
+ ///
+ public EntityIdentifier ImplementationIdentifier { get; set; } = new();
+
+ ///
+ /// Implementation use, 128 bytes
+ ///
+ public byte[] ImplementationUse { get; set; } = new byte[128];
+
+ ///
+ /// Extent of the Logical Volume Integrity Sequence
+ ///
+ public ExtentDescriptor IntegritySequenceExtent { get; set; } = new();
+
+ ///
+ /// Partition maps, a partition reference number is the index into this array
+ ///
+ public PartitionMap[] PartitionMaps { get; set; } = [];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/LongAllocationDescriptor.cs b/SabreTools.Data.Models/UDF/LongAllocationDescriptor.cs
new file mode 100644
index 00000000..04596bb2
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/LongAllocationDescriptor.cs
@@ -0,0 +1,25 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Long Allocation Descriptor (long_ad, 4/14.14.2), an extent within any partition of the logical volume
+ ///
+ ///
+ public sealed class LongAllocationDescriptor
+ {
+ ///
+ /// Length of the extent in bytes (lower 30 bits) and the ExtentType (upper 2 bits)
+ /// A length of 0 terminates a sequence of allocation descriptors
+ ///
+ public uint ExtentLength { get; set; }
+
+ ///
+ /// Logical block address of the start of the extent
+ ///
+ public LogicalBlockAddress ExtentLocation { get; set; } = new();
+
+ ///
+ /// Implementation use (UDF: flags and unique ID bits)
+ ///
+ public byte[] ImplementationUse { get; set; } = new byte[6];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/MetadataPartitionMap.cs b/SabreTools.Data.Models/UDF/MetadataPartitionMap.cs
new file mode 100644
index 00000000..c2591bf0
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/MetadataPartitionMap.cs
@@ -0,0 +1,45 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Metadata Partition Map (UDF 2.2.10, UDF 2.50 and later), a partition whose logical blocks
+ /// are the contents of the Metadata File recorded in the underlying partition
+ ///
+ ///
+ public sealed class MetadataPartitionMap : Type2PartitionMap
+ {
+ ///
+ /// Logical block of the Metadata File's File Entry in the underlying partition
+ ///
+ public uint MetadataFileLocation { get; set; }
+
+ ///
+ /// Logical block of the Metadata Mirror File's File Entry in the underlying partition
+ ///
+ public uint MetadataMirrorFileLocation { get; set; }
+
+ ///
+ /// Logical block of the Metadata Bitmap File's File Entry, 0xFFFFFFFF if not recorded
+ ///
+ public uint MetadataBitmapFileLocation { get; set; }
+
+ ///
+ /// Allocation unit size in logical blocks
+ ///
+ public uint AllocationUnitSize { get; set; }
+
+ ///
+ /// Alignment unit size in logical blocks
+ ///
+ public ushort AlignmentUnitSize { get; set; }
+
+ ///
+ /// Flags (bit 0: duplicate metadata flag)
+ ///
+ public byte Flags { get; set; }
+
+ ///
+ /// Reserved, 5 bytes of 0x00
+ ///
+ public byte[] Reserved2 { get; set; } = new byte[5];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/PartitionDescriptor.cs b/SabreTools.Data.Models/UDF/PartitionDescriptor.cs
new file mode 100644
index 00000000..b4307b55
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/PartitionDescriptor.cs
@@ -0,0 +1,64 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Partition Descriptor (3/10.5), the physical location of a partition
+ ///
+ ///
+ public sealed class PartitionDescriptor : Descriptor
+ {
+ ///
+ /// Volume Descriptor Sequence Number, the highest number prevails
+ ///
+ public uint VolumeDescriptorSequenceNumber { get; set; }
+
+ ///
+ /// Partition flags (bit 0: space is allocated)
+ ///
+ public ushort PartitionFlags { get; set; }
+
+ ///
+ /// Partition number, referenced by the partition maps
+ ///
+ public ushort PartitionNumber { get; set; }
+
+ ///
+ /// Partition contents ("+NSR02" or "+NSR03" for UDF)
+ ///
+ public EntityIdentifier PartitionContents { get; set; } = new();
+
+ ///
+ /// Partition contents use (Partition Header Descriptor for NSR), 128 bytes
+ ///
+ public byte[] PartitionContentsUse { get; set; } = new byte[128];
+
+ ///
+ /// Access type (1 = read-only, 2 = write-once, 3 = rewritable, 4 = overwritable)
+ ///
+ public uint AccessType { get; set; }
+
+ ///
+ /// Logical sector number of the first sector of the partition
+ ///
+ public uint PartitionStartingLocation { get; set; }
+
+ ///
+ /// Length of the partition in logical sectors
+ ///
+ public uint PartitionLength { get; set; }
+
+ ///
+ /// Implementation identifier
+ ///
+ public EntityIdentifier ImplementationIdentifier { get; set; } = new();
+
+ ///
+ /// Implementation use, 128 bytes
+ ///
+ public byte[] ImplementationUse { get; set; } = new byte[128];
+
+ ///
+ /// Reserved, 156 bytes of 0x00
+ ///
+ public byte[] Reserved { get; set; } = new byte[156];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/PartitionMap.cs b/SabreTools.Data.Models/UDF/PartitionMap.cs
new file mode 100644
index 00000000..45f8d752
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/PartitionMap.cs
@@ -0,0 +1,19 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// Abstract UDF Partition Map (3/10.7)
+ ///
+ ///
+ public abstract class PartitionMap
+ {
+ ///
+ /// Partition map type (1 or 2)
+ ///
+ public byte PartitionMapType { get; set; }
+
+ ///
+ /// Length of the partition map in bytes
+ ///
+ public byte PartitionMapLength { get; set; }
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/PrimaryVolumeDescriptor.cs b/SabreTools.Data.Models/UDF/PrimaryVolumeDescriptor.cs
new file mode 100644
index 00000000..668d0ec3
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/PrimaryVolumeDescriptor.cs
@@ -0,0 +1,114 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Primary Volume Descriptor (3/10.1)
+ ///
+ ///
+ public sealed class PrimaryVolumeDescriptor : Descriptor
+ {
+ ///
+ /// Volume Descriptor Sequence Number, the highest number prevails
+ ///
+ public uint VolumeDescriptorSequenceNumber { get; set; }
+
+ ///
+ /// Primary Volume Descriptor Number
+ ///
+ public uint PrimaryVolumeDescriptorNumber { get; set; }
+
+ ///
+ /// Volume identifier, dstring (last byte = length)
+ ///
+ public byte[] VolumeIdentifier { get; set; } = new byte[32];
+
+ ///
+ /// Volume sequence number
+ ///
+ public ushort VolumeSequenceNumber { get; set; }
+
+ ///
+ /// Maximum volume sequence number
+ ///
+ public ushort MaximumVolumeSequenceNumber { get; set; }
+
+ ///
+ /// Interchange level
+ ///
+ public ushort InterchangeLevel { get; set; }
+
+ ///
+ /// Maximum interchange level
+ ///
+ public ushort MaximumInterchangeLevel { get; set; }
+
+ ///
+ /// Character set list
+ ///
+ public uint CharacterSetList { get; set; }
+
+ ///
+ /// Maximum character set list
+ ///
+ public uint MaximumCharacterSetList { get; set; }
+
+ ///
+ /// Volume set identifier, dstring (last byte = length)
+ ///
+ public byte[] VolumeSetIdentifier { get; set; } = new byte[128];
+
+ ///
+ /// Descriptor character set
+ ///
+ public CharacterSetSpecification DescriptorCharacterSet { get; set; } = new();
+
+ ///
+ /// Explanatory character set
+ ///
+ public CharacterSetSpecification ExplanatoryCharacterSet { get; set; } = new();
+
+ ///
+ /// Extent of the volume abstract
+ ///
+ public ExtentDescriptor VolumeAbstract { get; set; } = new();
+
+ ///
+ /// Extent of the volume copyright notice
+ ///
+ public ExtentDescriptor VolumeCopyrightNotice { get; set; } = new();
+
+ ///
+ /// Application identifier
+ ///
+ public EntityIdentifier ApplicationIdentifier { get; set; } = new();
+
+ ///
+ /// Recording date and time
+ ///
+ public Timestamp RecordingDateAndTime { get; set; } = new();
+
+ ///
+ /// Implementation identifier
+ ///
+ public EntityIdentifier ImplementationIdentifier { get; set; } = new();
+
+ ///
+ /// Implementation use, 64 bytes
+ ///
+ public byte[] ImplementationUse { get; set; } = new byte[64];
+
+ ///
+ /// Predecessor Volume Descriptor Sequence location
+ ///
+ public uint PredecessorVolumeDescriptorSequenceLocation { get; set; }
+
+ ///
+ /// Flags (bit 0: volume set identification is common to all volumes of the set)
+ ///
+ public ushort Flags { get; set; }
+
+ ///
+ /// Reserved, 22 bytes of 0x00
+ ///
+ public byte[] Reserved { get; set; } = new byte[22];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/ShortAllocationDescriptor.cs b/SabreTools.Data.Models/UDF/ShortAllocationDescriptor.cs
new file mode 100644
index 00000000..c8778bcf
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/ShortAllocationDescriptor.cs
@@ -0,0 +1,20 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Short Allocation Descriptor (short_ad, 4/14.14.1), an extent within the partition of the descriptor
+ ///
+ ///
+ public sealed class ShortAllocationDescriptor
+ {
+ ///
+ /// Length of the extent in bytes (lower 30 bits) and the ExtentType (upper 2 bits)
+ /// A length of 0 terminates a sequence of allocation descriptors
+ ///
+ public uint ExtentLength { get; set; }
+
+ ///
+ /// Logical block number of the start of the extent
+ ///
+ public uint ExtentPosition { get; set; }
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/Timestamp.cs b/SabreTools.Data.Models/UDF/Timestamp.cs
new file mode 100644
index 00000000..82621b7c
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/Timestamp.cs
@@ -0,0 +1,60 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Timestamp (1/7.3)
+ ///
+ ///
+ public sealed class Timestamp
+ {
+ ///
+ /// Type (upper 4 bits, 1 = local time) and time zone offset from UTC in minutes
+ /// (lower 12 bits, signed, -2047 = not specified)
+ ///
+ public ushort TypeAndTimezone { get; set; }
+
+ ///
+ /// Year, 1 to 9999
+ ///
+ public short Year { get; set; }
+
+ ///
+ /// Month, 1 to 12
+ ///
+ public byte Month { get; set; }
+
+ ///
+ /// Day, 1 to 31
+ ///
+ public byte Day { get; set; }
+
+ ///
+ /// Hour, 0 to 23
+ ///
+ public byte Hour { get; set; }
+
+ ///
+ /// Minute, 0 to 59
+ ///
+ public byte Minute { get; set; }
+
+ ///
+ /// Second, 0 to 59
+ ///
+ public byte Second { get; set; }
+
+ ///
+ /// Centiseconds, 0 to 99
+ ///
+ public byte Centiseconds { get; set; }
+
+ ///
+ /// Hundreds of microseconds, 0 to 99
+ ///
+ public byte HundredsOfMicroseconds { get; set; }
+
+ ///
+ /// Microseconds, 0 to 99
+ ///
+ public byte Microseconds { get; set; }
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/Type1PartitionMap.cs b/SabreTools.Data.Models/UDF/Type1PartitionMap.cs
new file mode 100644
index 00000000..0c0609e3
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/Type1PartitionMap.cs
@@ -0,0 +1,19 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Type 1 Partition Map (3/10.7.2), logical blocks map 1:1 onto a Partition Descriptor's sectors
+ ///
+ ///
+ public sealed class Type1PartitionMap : PartitionMap
+ {
+ ///
+ /// Volume sequence number of the volume containing the partition
+ ///
+ public ushort VolumeSequenceNumber { get; set; }
+
+ ///
+ /// Partition number of the Partition Descriptor
+ ///
+ public ushort PartitionNumber { get; set; }
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/Type2PartitionMap.cs b/SabreTools.Data.Models/UDF/Type2PartitionMap.cs
new file mode 100644
index 00000000..0c28e27b
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/Type2PartitionMap.cs
@@ -0,0 +1,37 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Type 2 Partition Map (3/10.7.3, UDF 2.2.8-2.2.10), a partition identified by its partition type identifier
+ /// (e.g. "*UDF Virtual Partition", "*UDF Sparable Partition", "*UDF Metadata Partition")
+ ///
+ ///
+ ///
+ public class Type2PartitionMap : PartitionMap
+ {
+ ///
+ /// Reserved, 2 bytes of 0x00
+ ///
+ public byte[] Reserved { get; set; } = new byte[2];
+
+ ///
+ /// Partition type identifier
+ ///
+ public EntityIdentifier PartitionTypeIdentifier { get; set; } = new();
+
+ ///
+ /// Volume sequence number of the volume containing the partition
+ ///
+ public ushort VolumeSequenceNumber { get; set; }
+
+ ///
+ /// Partition number of the underlying Partition Descriptor
+ ///
+ public ushort PartitionNumber { get; set; }
+
+ ///
+ /// Partition type specific data after the partition number, 24 bytes
+ /// Empty for partition types that are parsed further (MetadataPartitionMap)
+ ///
+ public byte[] PartitionTypeData { get; set; } = [];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/Volume.cs b/SabreTools.Data.Models/UDF/Volume.cs
new file mode 100644
index 00000000..78de031e
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/Volume.cs
@@ -0,0 +1,50 @@
+using System.Collections.Generic;
+
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// A volume (or disc image) with a UDF / ECMA-167 file system
+ /// Logical sectors and logical blocks are 2048 bytes on optical media
+ /// File system structures are addressed by logical block address (partition reference number + logical block);
+ /// dictionaries are keyed by ((long)PartitionReferenceNumber << 32) | LogicalBlockNumber
+ ///
+ ///
+ ///
+ public sealed class Volume
+ {
+ ///
+ /// Volume Recognition Sequence starting at sector 16
+ ///
+ public VolumeStructureDescriptor[] VolumeRecognitionSequence { get; set; } = [];
+
+ ///
+ /// Anchor Volume Descriptor Pointer
+ ///
+ public AnchorVolumeDescriptorPointer? AnchorVolumeDescriptorPointer { get; set; }
+
+ ///
+ /// Volume Descriptor Sequence (Primary, Partition, Logical Volume, ... up to the Terminating Descriptor)
+ ///
+ public Descriptor[] VolumeDescriptorSequence { get; set; } = [];
+
+ ///
+ /// File Set Descriptor
+ ///
+ public FileSetDescriptor? FileSetDescriptor { get; set; }
+
+ ///
+ /// File Entries (and Extended File Entries) by logical block address
+ ///
+ public Dictionary FileEntries { get; set; } = [];
+
+ ///
+ /// Allocation Extent Descriptors by logical block address
+ ///
+ public Dictionary AllocationExtentDescriptors { get; set; } = [];
+
+ ///
+ /// File Identifier Descriptors of each directory, by logical block address of the directory's File Entry
+ ///
+ public Dictionary Directories { get; set; } = [];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/VolumeDescriptorPointer.cs b/SabreTools.Data.Models/UDF/VolumeDescriptorPointer.cs
new file mode 100644
index 00000000..bfe10afa
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/VolumeDescriptorPointer.cs
@@ -0,0 +1,24 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Volume Descriptor Pointer (3/10.3), continues the Volume Descriptor Sequence in another extent
+ ///
+ ///
+ public sealed class VolumeDescriptorPointer : Descriptor
+ {
+ ///
+ /// Volume Descriptor Sequence Number
+ ///
+ public uint VolumeDescriptorSequenceNumber { get; set; }
+
+ ///
+ /// Extent of the next part of the Volume Descriptor Sequence
+ ///
+ public ExtentDescriptor NextVolumeDescriptorSequenceExtent { get; set; } = new();
+
+ ///
+ /// Reserved, 484 bytes of 0x00
+ ///
+ public byte[] Reserved { get; set; } = new byte[484];
+ }
+}
diff --git a/SabreTools.Data.Models/UDF/VolumeStructureDescriptor.cs b/SabreTools.Data.Models/UDF/VolumeStructureDescriptor.cs
new file mode 100644
index 00000000..acac9308
--- /dev/null
+++ b/SabreTools.Data.Models/UDF/VolumeStructureDescriptor.cs
@@ -0,0 +1,30 @@
+namespace SabreTools.Data.Models.UDF
+{
+ ///
+ /// UDF Volume Structure Descriptor (2/9.1), one logical sector of the Volume Recognition Sequence
+ /// starting at sector 16, e.g. "BEA01", "NSR02"/"NSR03", "TEA01", or "CD001" on bridge discs
+ ///
+ ///
+ public sealed class VolumeStructureDescriptor
+ {
+ ///
+ /// Structure type
+ ///
+ public byte StructureType { get; set; }
+
+ ///
+ /// Standard identifier, 5 bytes
+ ///
+ public byte[] StandardIdentifier { get; set; } = new byte[5];
+
+ ///
+ /// Structure version
+ ///
+ public byte StructureVersion { get; set; }
+
+ ///
+ /// Structure data, the rest of the logical sector
+ ///
+ public byte[] StructureData { get; set; } = [];
+ }
+}
diff --git a/SabreTools.Serialization.Readers.Test/UDFImageBuilder.cs b/SabreTools.Serialization.Readers.Test/UDFImageBuilder.cs
new file mode 100644
index 00000000..c5460fff
--- /dev/null
+++ b/SabreTools.Serialization.Readers.Test/UDFImageBuilder.cs
@@ -0,0 +1,294 @@
+using System;
+using System.Text;
+
+namespace SabreTools.Serialization.Readers.Test
+{
+ ///
+ /// Builds a minimal UDF 1.02 image in memory (2048-byte sectors, one Type 1 partition at sector 260)
+ ///
+ ///
+ /// Partition blocks:
+ /// 0 File Set Descriptor
+ /// 2 root directory File Entry, contents in block 3:
+ /// parent, "README.TXT" (embedded data), "deleted.txt" (deleted), "Sub" (directory),
+ /// "Media" (last entry, no padding)
+ /// 4 "README.TXT" File Entry, data "hello world" embedded
+ /// 5 "Sub" File Entry, contents in block 6: parent, "data.bin" (16-bit name)
+ /// 7 "data.bin" File Entry, long_ad to block 8 (2048 bytes), then an Allocation Extent Descriptor
+ /// in block 10 with a long_ad to block 9 (952 bytes)
+ /// 8-9 "data.bin" data (byte i = i % 251)
+ /// 11 "Media" File Entry, one short_ad of 100 bytes allocated but not recorded
+ ///
+ internal static class UDFImageBuilder
+ {
+ public const int SectorSize = 2048;
+ public const int PartitionStart = 260;
+ public const int PartitionLength = 40;
+ public const int Sectors = 300;
+ public const int DataBinLength = 3000;
+
+ ///
+ /// Build the image
+ ///
+ /// True to break the checksum of the root directory File Entry
+ /// True to change a byte of the root directory File Entry that only the CRC covers
+ /// True to record the partition map with length 0 and a map count of 3
+ /// True to replace the Terminating Descriptor by a Volume Descriptor Pointer back to the start of the sequence
+ /// Name of the "Sub" directory in the root directory
+ /// True to add a directory "loop" to "Sub" that points back to the root directory
+ /// True to record "Media" in a partition block past the end of the image
+ public static byte[] Build(bool corruptRootEntry = false,
+ bool corruptRootEntryCrc = false,
+ bool emptyPartitionMap = false,
+ bool pointerLoop = false,
+ string subName = "Sub",
+ bool subLoop = false,
+ bool mediaPastEnd = false)
+ {
+ var image = new byte[Sectors * SectorSize];
+
+ // Volume Recognition Sequence
+ WriteStructure(image, 16, "BEA01");
+ WriteStructure(image, 17, "NSR02");
+ WriteStructure(image, 18, "TEA01");
+
+ // Anchor Volume Descriptor Pointer: main and reserve sequence at sector 32, 4 sectors
+ int avdp = 256 * SectorSize;
+ WriteUInt32(image, avdp + 16, 4 * SectorSize);
+ WriteUInt32(image, avdp + 20, 32);
+ WriteUInt32(image, avdp + 24, 4 * SectorSize);
+ WriteUInt32(image, avdp + 28, 32);
+ WriteTag(image, avdp, 2, 256, 512);
+
+ // Primary Volume Descriptor
+ int pvd = 32 * SectorSize;
+ WriteDString(image, pvd + 24, 32, "TESTVOLUME");
+ WriteTag(image, pvd, 1, 32, 512);
+
+ // Partition Descriptor: partition 0 at sector 260
+ int pd = 33 * SectorSize;
+ WriteUInt32(image, pd + 16, 1);
+ image[pd + 22] = 0;
+ WriteUInt32(image, pd + 188, PartitionStart);
+ WriteUInt32(image, pd + 192, mediaPastEnd ? PartitionLength + 20 : PartitionLength);
+ WriteTag(image, pd, 5, 33, 512);
+
+ // Logical Volume Descriptor: block size, domain, File Set Descriptor at block 0, one Type 1 map
+ int lvd = 34 * SectorSize;
+ WriteUInt32(image, lvd + 16, 2);
+ WriteDString(image, lvd + 84, 128, "TESTVOLUME");
+ WriteUInt32(image, lvd + 212, SectorSize);
+ WriteAscii(image, lvd + 217, "*OSTA UDF Compliant");
+ image[lvd + 240] = 0x02;
+ image[lvd + 241] = 0x01;
+ WriteLongAd(image, lvd + 248, SectorSize, 0);
+ WriteUInt32(image, lvd + 264, 6);
+ WriteUInt32(image, lvd + 268, emptyPartitionMap ? 3 : 1);
+ image[lvd + 440] = 1;
+ image[lvd + 441] = (byte)(emptyPartitionMap ? 0 : 6);
+ image[lvd + 442] = 1;
+ WriteTag(image, lvd, 6, 34, 446);
+
+ // Terminating Descriptor, or a Volume Descriptor Pointer back to the start of the sequence
+ int td = 35 * SectorSize;
+ if (pointerLoop)
+ {
+ WriteUInt32(image, td + 20, 4 * SectorSize);
+ WriteUInt32(image, td + 24, 32);
+ WriteTag(image, td, 3, 35, 512);
+ }
+ else
+ {
+ WriteTag(image, td, 8, 35, 512);
+ }
+
+ // File Set Descriptor: root directory ICB at block 2
+ int fsd = Block(0);
+ WriteDString(image, fsd + 304, 32, "TESTSET");
+ WriteLongAd(image, fsd + 400, SectorSize, 2);
+ WriteTag(image, fsd, 256, 0, 512);
+
+ // Root directory contents
+ int root = Block(3);
+ int length = 0;
+ length += WriteFid(image, root + length, 3, 0x0A, 2, []);
+ length += WriteFid(image, root + length, 3, 0x00, 4, Name8("README.TXT"));
+ length += WriteFid(image, root + length, 3, 0x04, 4, Name8("deleted.txt"));
+ length += WriteFid(image, root + length, 3, 0x02, 5, Name8(subName));
+ length += WriteFid(image, root + length, 3, 0x00, 11, Name8("Media"));
+ WriteFileEntry(image, 2, 4, 0, length, ShortAd(length, 3));
+ if (corruptRootEntry)
+ image[Block(2) + 4] ^= 0xFF;
+ if (corruptRootEntryCrc)
+ image[Block(2) + 36] ^= 0xFF;
+
+ // "README.TXT", embedded
+ WriteFileEntry(image, 4, 5, 3, 11, Encoding.ASCII.GetBytes("hello world"));
+
+ // "Sub" contents
+ int sub = Block(6);
+ length = 0;
+ length += WriteFid(image, sub + length, 6, 0x0A, 2, []);
+ length += WriteFid(image, sub + length, 6, 0x00, 7, Name16("data.bin"));
+ if (subLoop)
+ length += WriteFid(image, sub + length, 6, 0x02, 2, Name8("loop"));
+
+ WriteFileEntry(image, 5, 4, 0, length, ShortAd(length, 6));
+
+ // "data.bin", long allocation descriptors continued in an Allocation Extent Descriptor
+ var ads = new byte[32];
+ WriteLongAd(ads, 0, SectorSize, 8);
+ WriteLongAd(ads, 16, (3u << 30) | SectorSize, 10);
+ WriteFileEntry(image, 7, 5, 1, DataBinLength, ads);
+ for (int i = 0; i < DataBinLength; i++)
+ {
+ image[Block(8) + i] = (byte)(i % 251);
+ }
+
+ int aed = Block(10);
+ WriteUInt32(image, aed + 20, 16);
+ WriteLongAd(image, aed + 24, DataBinLength - SectorSize, 9);
+ WriteTag(image, aed, 258, 10, 40);
+
+ // "Media", allocated but not recorded, or recorded past the end of the image
+ if (mediaPastEnd)
+ WriteFileEntry(image, 11, 5, 0, 100, ShortAd(100, PartitionLength + 10));
+ else
+ WriteFileEntry(image, 11, 5, 0, 100, ShortAd((1u << 30) | 100, 0));
+
+ return image;
+ }
+
+ ///
+ /// Offset of a partition block in the image
+ ///
+ public static int Block(int block) => (PartitionStart + block) * SectorSize;
+
+ private static void WriteStructure(byte[] image, int sector, string identifier)
+ {
+ int offset = sector * SectorSize;
+ image[offset] = 0;
+ WriteAscii(image, offset + 1, identifier);
+ image[offset + 6] = 1;
+ }
+
+ private static int WriteFid(byte[] image, int offset, uint block, byte characteristics, uint icb, byte[] name)
+ {
+ image[offset + 16] = 1;
+ image[offset + 18] = characteristics;
+ image[offset + 19] = (byte)name.Length;
+ WriteLongAd(image, offset + 20, SectorSize, icb);
+ Array.Copy(name, 0, image, offset + 38, name.Length);
+ int length = (38 + name.Length + 3) & ~3;
+ WriteTag(image, offset, 257, block, length);
+ return length;
+ }
+
+ private static void WriteFileEntry(byte[] image, int block, byte fileType, ushort adType, long informationLength, byte[] ads)
+ {
+ int offset = Block(block);
+ image[offset + 16 + 4] = 4;
+ image[offset + 16 + 11] = fileType;
+ image[offset + 16 + 18] = (byte)adType;
+ WriteUInt32(image, offset + 56, (uint)informationLength);
+ WriteUInt32(image, offset + 172, (uint)ads.Length);
+ Array.Copy(ads, 0, image, offset + 176, ads.Length);
+ WriteTag(image, offset, 261, (uint)block, 176 + ads.Length);
+ }
+
+ private static byte[] ShortAd(uint length, uint position)
+ {
+ var ad = new byte[8];
+ WriteUInt32(ad, 0, length);
+ WriteUInt32(ad, 4, position);
+ return ad;
+ }
+
+ private static byte[] ShortAd(int length, uint position) => ShortAd((uint)length, position);
+
+ private static void WriteLongAd(byte[] data, int offset, uint length, uint block)
+ {
+ WriteUInt32(data, offset, length);
+ WriteUInt32(data, offset + 4, block);
+ }
+
+ private static void WriteLongAd(byte[] data, int offset, int length, uint block)
+ => WriteLongAd(data, offset, (uint)length, block);
+
+ private static byte[] Name8(string name)
+ {
+ var bytes = new byte[name.Length + 1];
+ bytes[0] = 8;
+ Encoding.ASCII.GetBytes(name, 0, name.Length, bytes, 1);
+ return bytes;
+ }
+
+ private static byte[] Name16(string name)
+ {
+ var bytes = new byte[(name.Length * 2) + 1];
+ bytes[0] = 16;
+ Encoding.BigEndianUnicode.GetBytes(name, 0, name.Length, bytes, 1);
+ return bytes;
+ }
+
+ private static void WriteDString(byte[] image, int offset, int fieldLength, string value)
+ {
+ image[offset] = 8;
+ WriteAscii(image, offset + 1, value);
+ image[offset + fieldLength - 1] = (byte)(value.Length + 1);
+ }
+
+ private static void WriteAscii(byte[] image, int offset, string value)
+ => Encoding.ASCII.GetBytes(value, 0, value.Length, image, offset);
+
+ private static void WriteUInt32(byte[] data, int offset, uint value)
+ {
+ data[offset] = (byte)value;
+ data[offset + 1] = (byte)(value >> 8);
+ data[offset + 2] = (byte)(value >> 16);
+ data[offset + 3] = (byte)(value >> 24);
+ }
+
+ private static void WriteUInt32(byte[] data, int offset, int value) => WriteUInt32(data, offset, (uint)value);
+
+ ///
+ /// Write a descriptor tag covering the given descriptor length (version 2, CRC, checksum)
+ ///
+ private static void WriteTag(byte[] image, int offset, ushort identifier, uint location, int length)
+ {
+ image[offset] = (byte)identifier;
+ image[offset + 1] = (byte)(identifier >> 8);
+ image[offset + 2] = 2;
+ ushort crc = Crc(image, offset + 16, length - 16);
+ image[offset + 8] = (byte)crc;
+ image[offset + 9] = (byte)(crc >> 8);
+ image[offset + 10] = (byte)(length - 16);
+ image[offset + 11] = (byte)((length - 16) >> 8);
+ WriteUInt32(image, offset + 12, location);
+
+ byte checksum = 0;
+ for (int i = 0; i < 16; i++)
+ {
+ if (i != 4)
+ checksum += image[offset + i];
+ }
+
+ image[offset + 4] = checksum;
+ }
+
+ private static ushort Crc(byte[] data, int offset, int length)
+ {
+ ushort crc = 0;
+ for (int i = offset; i < offset + length; i++)
+ {
+ crc ^= (ushort)(data[i] << 8);
+ for (int bit = 0; bit < 8; bit++)
+ {
+ crc = (crc & 0x8000) != 0 ? (ushort)((crc << 1) ^ 0x1021) : (ushort)(crc << 1);
+ }
+ }
+
+ return crc;
+ }
+ }
+}
diff --git a/SabreTools.Serialization.Readers.Test/UDFTests.cs b/SabreTools.Serialization.Readers.Test/UDFTests.cs
new file mode 100644
index 00000000..ba07b5e5
--- /dev/null
+++ b/SabreTools.Serialization.Readers.Test/UDFTests.cs
@@ -0,0 +1,289 @@
+using System.IO;
+using System.Linq;
+using System.Text;
+using SabreTools.Data.Extensions;
+using SabreTools.Data.Models.UDF;
+using Xunit;
+
+namespace SabreTools.Serialization.Readers.Test
+{
+ public class UDFTests
+ {
+ [Fact]
+ public void NullArray_Null()
+ {
+ byte[]? data = null;
+ int offset = 0;
+ var deserializer = new UDF();
+
+ var actual = deserializer.Deserialize(data, offset);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void EmptyArray_Null()
+ {
+ byte[]? data = [];
+ int offset = 0;
+ var deserializer = new UDF();
+
+ var actual = deserializer.Deserialize(data, offset);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void InvalidArray_Null()
+ {
+ byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)];
+ int offset = 0;
+ var deserializer = new UDF();
+
+ var actual = deserializer.Deserialize(data, offset);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void NullStream_Null()
+ {
+ Stream? data = null;
+ var deserializer = new UDF();
+
+ var actual = deserializer.Deserialize(data);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void EmptyStream_Null()
+ {
+ Stream? data = new MemoryStream([]);
+ var deserializer = new UDF();
+
+ var actual = deserializer.Deserialize(data);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void InvalidStream_Null()
+ {
+ Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]);
+ var deserializer = new UDF();
+
+ var actual = deserializer.Deserialize(data);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void ValidImage_VolumeStructures()
+ {
+ var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build()));
+
+ Assert.NotNull(volume);
+ Assert.Equal(3, volume.VolumeRecognitionSequence.Length);
+ Assert.NotNull(volume.AnchorVolumeDescriptorPointer);
+ Assert.Equal(4, volume.VolumeDescriptorSequence.Length);
+ var terminator = Assert.IsType(volume.VolumeDescriptorSequence[3]);
+ Assert.Equal(TagIdentifier.TERMINATING_DESCRIPTOR, terminator.DescriptorTag.TagIdentifier);
+
+ var lvd = volume.GetLogicalVolumeDescriptor();
+ Assert.NotNull(lvd);
+ Assert.Equal("TESTVOLUME", lvd.LogicalVolumeIdentifier.DecodeDString());
+ Assert.IsType(Assert.Single(lvd.PartitionMaps));
+
+ Assert.NotNull(volume.FileSetDescriptor);
+ Assert.Equal("TESTSET", volume.FileSetDescriptor.FileSetIdentifier.DecodeDString());
+ Assert.Equal(2u, volume.FileSetDescriptor.RootDirectoryICB.ExtentLocation.LogicalBlockNumber);
+ }
+
+ [Fact]
+ public void ValidImage_DirectoryHierarchy()
+ {
+ var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build()));
+
+ Assert.NotNull(volume);
+
+ // Root, "README.TXT", "Sub", "data.bin", "Media"; the deleted entry is not followed
+ Assert.Equal(5, volume.FileEntries.Count);
+ Assert.Equal(2, volume.Directories.Count);
+ Assert.Single(volume.AllocationExtentDescriptors);
+
+ // The last root entry has no padding and ends exactly at the end of the directory
+ var root = volume.Directories[UDFExtensions.GetKey(0, 2)];
+ Assert.Equal(5, root.Length);
+ Assert.Equal("Media", root[4].FileIdentifier.DecodeDCharacters());
+ Assert.Empty(root[4].Padding);
+
+ var sub = volume.Directories[UDFExtensions.GetKey(0, 5)];
+ Assert.Equal("data.bin", sub[1].FileIdentifier.DecodeDCharacters());
+ }
+
+ [Fact]
+ public void FindFileEntry_EmbeddedData()
+ {
+ var data = new MemoryStream(UDFImageBuilder.Build());
+ var volume = new UDF().Deserialize(data);
+ Assert.NotNull(volume);
+
+ var entry = UDF.FindFileEntry(data, volume, "README.TXT", out long key);
+
+ Assert.NotNull(entry);
+ Assert.Equal(UDFExtensions.GetKey(0, 4), key);
+ Assert.Equal(AllocationDescriptorType.EMBEDDED, entry.ICBTag.GetAllocationDescriptorType());
+ Assert.Equal("hello world", Encoding.ASCII.GetString(entry.AllocationDescriptors, 0, (int)entry.InformationLength));
+ }
+
+ [Fact]
+ public void FindFileEntry_CaseInsensitive()
+ {
+ var data = new MemoryStream(UDFImageBuilder.Build());
+ var volume = new UDF().Deserialize(data);
+ Assert.NotNull(volume);
+
+ var entry = UDF.FindFileEntry(data, volume, "/sub\\DATA.BIN", out long key);
+
+ Assert.NotNull(entry);
+ Assert.Equal(UDFExtensions.GetKey(0, 7), key);
+ }
+
+ [Fact]
+ public void FindFileEntry_DeletedOrMissing_Null()
+ {
+ var data = new MemoryStream(UDFImageBuilder.Build());
+ var volume = new UDF().Deserialize(data);
+ Assert.NotNull(volume);
+
+ Assert.Null(UDF.FindFileEntry(data, volume, "deleted.txt", out long deletedKey));
+ Assert.Equal(-1, deletedKey);
+ Assert.Null(UDF.FindFileEntry(data, volume, "Sub/missing.bin", out _));
+ Assert.Null(UDF.FindFileEntry(data, volume, "README.TXT/data.bin", out _));
+ }
+
+ [Fact]
+ public void FindFileEntry_WithoutDirectories_ReadsOnlyThePath()
+ {
+ var data = new MemoryStream(UDFImageBuilder.Build());
+ var volume = new UDF().Deserialize(data, includeDirectories: false);
+ Assert.NotNull(volume);
+ Assert.Empty(volume.FileEntries);
+ Assert.Empty(volume.Directories);
+
+ var entry = UDF.FindFileEntry(data, volume, "Sub/data.bin", out long key);
+
+ Assert.NotNull(entry);
+ Assert.Equal(UDFExtensions.GetKey(0, 7), key);
+
+ // Root, "Sub" and "data.bin" only
+ Assert.Equal(3, volume.FileEntries.Count);
+ Assert.Equal(2, volume.Directories.Count);
+ Assert.False(volume.FileEntries.ContainsKey(UDFExtensions.GetKey(0, 4)));
+ }
+
+ [Fact]
+ public void AllocationExtent_Followed()
+ {
+ byte[] image = UDFImageBuilder.Build();
+ var data = new MemoryStream(image);
+ var volume = new UDF().Deserialize(data);
+ Assert.NotNull(volume);
+
+ long key = UDFExtensions.GetKey(0, 7);
+ var descriptors = volume.GetAllocationDescriptors(key, volume.FileEntries[key]);
+
+ Assert.NotNull(descriptors);
+ Assert.Equal(2, descriptors.Count);
+ Assert.Equal(8u, descriptors[0].ExtentLocation.LogicalBlockNumber);
+ Assert.Equal(9u, descriptors[1].ExtentLocation.LogicalBlockNumber);
+ Assert.Equal((uint)UDFImageBuilder.DataBinLength, UDFExtensions.GetExtentLength(descriptors[0].ExtentLength) + UDFExtensions.GetExtentLength(descriptors[1].ExtentLength));
+
+ Assert.True(volume.TryGetSector(descriptors[1].ExtentLocation.GetKey(), out long sector));
+ Assert.Equal(UDFImageBuilder.PartitionStart + 9, sector);
+ }
+
+ [Fact]
+ public void NotRecordedExtent_Kept()
+ {
+ var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build()));
+ Assert.NotNull(volume);
+
+ long key = UDFExtensions.GetKey(0, 11);
+ var descriptors = volume.GetAllocationDescriptors(key, volume.FileEntries[key]);
+
+ Assert.NotNull(descriptors);
+ var ad = Assert.Single(descriptors);
+ Assert.Equal(ExtentType.NOT_RECORDED_BUT_ALLOCATED, UDFExtensions.GetExtentType(ad.ExtentLength));
+ Assert.Equal(100u, UDFExtensions.GetExtentLength(ad.ExtentLength));
+ }
+
+ [Fact]
+ public void CorruptRootEntry_NoDirectories()
+ {
+ var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build(corruptRootEntry: true)));
+
+ Assert.NotNull(volume);
+ Assert.NotNull(volume.FileSetDescriptor);
+ Assert.Empty(volume.FileEntries);
+ Assert.Empty(volume.Directories);
+ }
+
+ [Fact]
+ public void CorruptRootEntryCrc_NoDirectories()
+ {
+ var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build(corruptRootEntryCrc: true)));
+
+ Assert.NotNull(volume);
+ Assert.NotNull(volume.FileSetDescriptor);
+ Assert.Empty(volume.FileEntries);
+ Assert.Empty(volume.Directories);
+ }
+
+ [Fact]
+ public void EmptyPartitionMap_EndsMapTable()
+ {
+ var data = new MemoryStream(UDFImageBuilder.Build(emptyPartitionMap: true));
+ var anchor = UDF.ParseAnchorVolumeDescriptorPointer(data, Constants.AnchorVolumeDescriptorPointerSector);
+ Assert.NotNull(anchor);
+
+ var sequence = UDF.ParseVolumeDescriptorSequence(data, anchor.MainVolumeDescriptorSequenceExtent);
+
+ var lvd = Assert.Single(sequence.OfType());
+ Assert.Empty(lvd.PartitionMaps);
+ }
+
+ [Fact]
+ public void VolumeDescriptorPointerLoop_Stops()
+ {
+ var volume = new UDF().Deserialize(new MemoryStream(UDFImageBuilder.Build(pointerLoop: true)));
+
+ Assert.NotNull(volume);
+ Assert.Equal(5, volume.FileEntries.Count);
+ }
+
+ [Fact]
+ public void UnreadableSector_OtherEntriesRead()
+ {
+ // The "Sub" File Entry (block 5) cannot be read; "README.TXT" (block 4) is read in the same request
+ byte[] image = UDFImageBuilder.Build();
+ long bad = UDFImageBuilder.Block(5);
+ var volume = new UDF().Deserialize(new ThrowingStream(image, bad, bad + UDFImageBuilder.SectorSize));
+
+ // Root, "README.TXT" and "Media"
+ long[] expected = [UDFExtensions.GetKey(0, 2), UDFExtensions.GetKey(0, 4), UDFExtensions.GetKey(0, 11)];
+ Assert.NotNull(volume);
+ Assert.Equal(expected, volume.FileEntries.Keys.OrderBy(k => k));
+ Assert.Single(volume.Directories);
+ }
+
+ ///
+ /// Stream that throws an IOException for reads touching a range, like a drive with an unreadable sector
+ ///
+ private sealed class ThrowingStream(byte[] data, long badStart, long badEnd) : MemoryStream(data)
+ {
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ if (Position < badEnd && Position + count > badStart)
+ throw new IOException("Unreadable sector");
+
+ return base.Read(buffer, offset, count);
+ }
+ }
+ }
+}
diff --git a/SabreTools.Serialization.Readers/UDF.cs b/SabreTools.Serialization.Readers/UDF.cs
new file mode 100644
index 00000000..0693579f
--- /dev/null
+++ b/SabreTools.Serialization.Readers/UDF.cs
@@ -0,0 +1,1365 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using SabreTools.Data.Extensions;
+using SabreTools.Data.Models.UDF;
+using SabreTools.IO.Extensions;
+using SabreTools.Matching;
+using SabreTools.Numerics.Extensions;
+
+#pragma warning disable IDE0017 // Simplify object initialization
+namespace SabreTools.Serialization.Readers
+{
+ public class UDF : BaseBinaryReader
+ {
+ ///
+ /// Maximum number of Volume Descriptor Pointers followed in a Volume Descriptor Sequence
+ ///
+ private const int MaximumVolumeDescriptorPointers = 16;
+
+ ///
+ /// CRC-ITU-T (CCITT, polynomial 0x1021) table for descriptor CRCs
+ ///
+ private static readonly ushort[] CrcTable = CreateCrcTable();
+
+ ///
+ public override Volume? Deserialize(Stream? data)
+ => Deserialize(data, includeDirectories: true);
+
+ ///
+ ///
+ /// True to parse the whole directory hierarchy, false to parse only the volume and file set structures
+ /// (use FindFileEntry to look up single paths afterwards)
+ ///
+ /// Logical sectors are addressed from the start of the stream
+ public Volume? Deserialize(Stream? data, bool includeDirectories)
+ {
+ // If the data is invalid
+ if (data is null || !data.CanRead || !data.CanSeek)
+ return null;
+
+ try
+ {
+ // Create a new Volume to fill
+ var volume = new Volume();
+
+ // Read the Volume Recognition Sequence
+ volume.VolumeRecognitionSequence = ParseVolumeRecognitionSequence(data);
+
+ // Read the Anchor Volume Descriptor Pointer at sector 256, then at N - 256 and N (anchor points, ECMA-167 3/8.4.2.1)
+ var anchor = ParseAnchorVolumeDescriptorPointer(data, Constants.AnchorVolumeDescriptorPointerSector);
+ long lastSector = (data.Length / Constants.SectorSize) - 1;
+ if (anchor is null && lastSector > Constants.AnchorVolumeDescriptorPointerSector)
+ anchor = ParseAnchorVolumeDescriptorPointer(data, lastSector - Constants.AnchorVolumeDescriptorPointerSector);
+ if (anchor is null && lastSector > Constants.AnchorVolumeDescriptorPointerSector)
+ anchor = ParseAnchorVolumeDescriptorPointer(data, lastSector);
+ if (anchor is null)
+ return null;
+
+ volume.AnchorVolumeDescriptorPointer = anchor;
+
+ // Read the Main Volume Descriptor Sequence, then the equivalent Reserve one (ECMA-167 3/8.4.2.2) if the main one is unusable
+ volume.VolumeDescriptorSequence = ParseVolumeDescriptorSequence(data, anchor.MainVolumeDescriptorSequenceExtent);
+ if (!HasLogicalVolume(volume))
+ volume.VolumeDescriptorSequence = ParseVolumeDescriptorSequence(data, anchor.ReserveVolumeDescriptorSequenceExtent);
+ if (!HasLogicalVolume(volume))
+ return null;
+
+ // Read the File Entries of Metadata Files, needed to map Metadata Partition blocks
+ var lvd = volume.GetLogicalVolumeDescriptor()!;
+ foreach (var map in lvd.PartitionMaps)
+ {
+ if (map is not MetadataPartitionMap metadata)
+ continue;
+
+ int physical = lvd.GetType1PartitionReference(metadata.PartitionNumber);
+ if (physical < 0)
+ continue;
+
+ var state = new HierarchyState(data, volume, includeDirectories: false);
+ state.QueueEntry(UDFExtensions.GetKey((ushort)physical, metadata.MetadataFileLocation));
+ state.QueueEntry(UDFExtensions.GetKey((ushort)physical, metadata.MetadataMirrorFileLocation));
+ state.Run();
+ }
+
+ // Read the File Set Descriptor
+ var fsd = ParseFileSetDescriptor(data, volume, lvd.LogicalVolumeContentsUse.ExtentLocation);
+ if (fsd is null)
+ return null;
+
+ volume.FileSetDescriptor = fsd;
+
+ // Read the directory hierarchy from the root directory
+ if (includeDirectories)
+ {
+ var state = new HierarchyState(data, volume, includeDirectories: true);
+ state.QueueEntry(fsd.RootDirectoryICB.ExtentLocation.GetKey());
+ state.Run();
+ }
+
+ return volume;
+ }
+ catch
+ {
+ // Ignore the actual error
+ return null;
+ }
+ }
+
+ #region Lookup
+
+ ///
+ /// Find the File Entry of a path by walking the directories on the path only
+ /// Names are compared exactly first, then case-insensitively; deleted entries are ignored
+ /// File Entries and directories read on the way are added to the volume (reused by later lookups)
+ ///
+ /// Stream containing the volume
+ /// Volume parsed by Deserialize (directories may be excluded)
+ /// Path from the root directory, separated by '/' or '\'
+ /// Logical block address key of the File Entry on success, -1 otherwise
+ /// File Entry on success, null if the path does not exist or could not be read
+ public static FileEntry? FindFileEntry(Stream data, Volume volume, string path, out long key)
+ {
+ key = -1;
+ if (volume.FileSetDescriptor is null)
+ return null;
+
+ long current = volume.FileSetDescriptor.RootDirectoryICB.ExtentLocation.GetKey();
+ var entry = LoadEntry(data, volume, current);
+
+ string[] names = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
+ foreach (string name in names)
+ {
+ if (entry is null || entry.ICBTag.FileType != FileType.DIRECTORY)
+ return null;
+
+ if (!volume.Directories.TryGetValue(current, out var fids))
+ return null;
+
+ var fid = FindInDirectory(fids, name);
+ if (fid is null)
+ return null;
+
+ current = fid.ICB.ExtentLocation.GetKey();
+ entry = LoadEntry(data, volume, current);
+ }
+
+ if (entry is null)
+ return null;
+
+ key = current;
+ return entry;
+ }
+
+ ///
+ /// Read a File Entry (with its Allocation Extent Descriptors, and its directory if it is one) unless already read
+ ///
+ private static FileEntry? LoadEntry(Stream data, Volume volume, long key)
+ {
+ if (!volume.FileEntries.TryGetValue(key, out var entry)
+ || (entry.ICBTag.FileType == FileType.DIRECTORY && !volume.Directories.ContainsKey(key)))
+ {
+ var state = new HierarchyState(data, volume, includeDirectories: false);
+ state.QueueEntry(key);
+ state.Run();
+ volume.FileEntries.TryGetValue(key, out entry);
+ }
+
+ return entry;
+ }
+
+ ///
+ /// Find a name in the File Identifier Descriptors of a directory, exact match first, then case-insensitive
+ ///
+ private static FileIdentifierDescriptor? FindInDirectory(FileIdentifierDescriptor[] fids, string name)
+ {
+ FileIdentifierDescriptor? caseInsensitive = null;
+ foreach (var fid in fids)
+ {
+ if ((fid.FileCharacteristics & (FileCharacteristics.DELETED | FileCharacteristics.PARENT)) != 0)
+ continue;
+
+ string? identifier = fid.FileIdentifier.DecodeDCharacters();
+ if (identifier is null)
+ continue;
+
+ if (string.Equals(identifier, name, StringComparison.Ordinal))
+ return fid;
+
+ if (caseInsensitive is null && string.Equals(identifier, name, StringComparison.OrdinalIgnoreCase))
+ caseInsensitive = fid;
+ }
+
+ return caseInsensitive;
+ }
+
+ #endregion
+
+ #region Volume Structure Parsing
+
+ ///
+ /// Parse the Volume Recognition Sequence starting at sector 16
+ ///
+ /// Stream to parse
+ /// Volume Structure Descriptors up to the first unknown one
+ public static VolumeStructureDescriptor[] ParseVolumeRecognitionSequence(Stream data)
+ {
+ var obj = new List();
+
+ long sector = Constants.VolumeRecognitionSequenceSector;
+ while (true)
+ {
+ byte[] block = ReadSectors(data, sector, 1);
+
+ int offset = 0;
+ var vsd = new VolumeStructureDescriptor();
+ vsd.StructureType = block.ReadByteValue(ref offset);
+ vsd.StandardIdentifier = block.ReadBytes(ref offset, 5);
+ vsd.StructureVersion = block.ReadByteValue(ref offset);
+ vsd.StructureData = block.ReadBytes(ref offset, Constants.SectorSize - 7);
+
+ if (!IsKnownStandardIdentifier(vsd.StandardIdentifier))
+ break;
+
+ obj.Add(vsd);
+ sector++;
+
+ // The extended area ends with the Terminating Extended Area Descriptor
+ if (vsd.StandardIdentifier.EqualsExactly(Constants.TerminatingExtendedAreaDescriptor))
+ break;
+ }
+
+ return [.. obj];
+ }
+
+ ///
+ /// Parse an Anchor Volume Descriptor Pointer
+ ///
+ /// Stream to parse
+ /// Logical sector of the anchor
+ /// Filled AnchorVolumeDescriptorPointer on success, null on error
+ public static AnchorVolumeDescriptorPointer? ParseAnchorVolumeDescriptorPointer(Stream data, long sector)
+ {
+ byte[] block = ReadSectors(data, sector, 1);
+ if (!IsValidDescriptor(block, 0, block.Length, (uint)sector))
+ return null;
+
+ int offset = 0;
+ var tag = ParseDescriptorTag(block, ref offset);
+ if (tag.TagIdentifier != TagIdentifier.ANCHOR_VOLUME_DESCRIPTOR_POINTER)
+ return null;
+
+ var obj = new AnchorVolumeDescriptorPointer();
+ obj.DescriptorTag = tag;
+ obj.MainVolumeDescriptorSequenceExtent = ParseExtentDescriptor(block, ref offset);
+ obj.ReserveVolumeDescriptorSequenceExtent = ParseExtentDescriptor(block, ref offset);
+ obj.Reserved = block.ReadBytes(ref offset, 480);
+ return obj;
+ }
+
+ ///
+ /// Parse a Volume Descriptor Sequence, following Volume Descriptor Pointers
+ ///
+ /// Stream to parse
+ /// Extent of the sequence
+ /// Descriptors up to the Terminating Descriptor or the first invalid descriptor
+ public static Descriptor[] ParseVolumeDescriptorSequence(Stream data, ExtentDescriptor extent)
+ {
+ var obj = new List();
+
+ long sector = extent.ExtentLocation;
+ long end = sector + (extent.ExtentLength / Constants.SectorSize);
+ int pointers = 0;
+ while (sector < end)
+ {
+ byte[] block = ReadSectors(data, sector, 1);
+ if (!IsValidDescriptor(block, 0, block.Length, (uint)sector))
+ break;
+
+ var descriptor = ParseVolumeDescriptor(block);
+ obj.Add(descriptor);
+
+ TagIdentifier tagIdentifier = descriptor.DescriptorTag.TagIdentifier;
+ if (tagIdentifier == TagIdentifier.TERMINATING_DESCRIPTOR)
+ break;
+
+ // Continue the sequence in the extent of a Volume Descriptor Pointer
+ if (descriptor is VolumeDescriptorPointer pointer)
+ {
+ if (++pointers > MaximumVolumeDescriptorPointers)
+ break;
+
+ sector = pointer.NextVolumeDescriptorSequenceExtent.ExtentLocation;
+ end = sector + (pointer.NextVolumeDescriptorSequenceExtent.ExtentLength / Constants.SectorSize);
+ continue;
+ }
+
+ sector++;
+ }
+
+ return [.. obj];
+ }
+
+ ///
+ /// Parse a validated descriptor of the Volume Descriptor Sequence
+ ///
+ /// Logical sector holding the descriptor
+ /// Filled Descriptor
+ public static Descriptor ParseVolumeDescriptor(byte[] block)
+ {
+ int offset = 0;
+ var tag = ParseDescriptorTag(block, ref offset);
+ return tag.TagIdentifier switch
+ {
+ TagIdentifier.PRIMARY_VOLUME_DESCRIPTOR => ParsePrimaryVolumeDescriptor(block, ref offset, tag),
+ TagIdentifier.VOLUME_DESCRIPTOR_POINTER => ParseVolumeDescriptorPointer(block, ref offset, tag),
+ TagIdentifier.PARTITION_DESCRIPTOR => ParsePartitionDescriptor(block, ref offset, tag),
+ TagIdentifier.LOGICAL_VOLUME_DESCRIPTOR => ParseLogicalVolumeDescriptor(block, ref offset, tag),
+
+ // Other descriptors are kept as raw data
+ TagIdentifier.ANCHOR_VOLUME_DESCRIPTOR_POINTER
+ or TagIdentifier.IMPLEMENTATION_USE_VOLUME_DESCRIPTOR
+ or TagIdentifier.UNALLOCATED_SPACE_DESCRIPTOR
+ or TagIdentifier.TERMINATING_DESCRIPTOR
+ or TagIdentifier.LOGICAL_VOLUME_INTEGRITY_DESCRIPTOR
+ or TagIdentifier.FILE_SET_DESCRIPTOR
+ or TagIdentifier.FILE_IDENTIFIER_DESCRIPTOR
+ or TagIdentifier.ALLOCATION_EXTENT_DESCRIPTOR
+ or TagIdentifier.INDIRECT_ENTRY
+ or TagIdentifier.TERMINAL_ENTRY
+ or TagIdentifier.FILE_ENTRY
+ or TagIdentifier.EXTENDED_ATTRIBUTE_HEADER_DESCRIPTOR
+ or TagIdentifier.UNALLOCATED_SPACE_ENTRY
+ or TagIdentifier.SPACE_BITMAP_DESCRIPTOR
+ or TagIdentifier.PARTITION_INTEGRITY_ENTRY
+ or TagIdentifier.EXTENDED_FILE_ENTRY => ParseGenericDescriptor(block, ref offset, tag),
+ _ => ParseGenericDescriptor(block, ref offset, tag),
+ };
+ }
+
+ ///
+ /// Parse a descriptor that is not parsed further after its tag
+ ///
+ private static GenericDescriptor ParseGenericDescriptor(byte[] data, ref int offset, DescriptorTag tag)
+ {
+ var obj = new GenericDescriptor();
+
+ obj.DescriptorTag = tag;
+ obj.Data = data.ReadBytes(ref offset, data.Length - offset);
+
+ return obj;
+ }
+
+ ///
+ /// Parse a Primary Volume Descriptor after its tag
+ ///
+ private static PrimaryVolumeDescriptor ParsePrimaryVolumeDescriptor(byte[] data, ref int offset, DescriptorTag tag)
+ {
+ var obj = new PrimaryVolumeDescriptor();
+
+ obj.DescriptorTag = tag;
+ obj.VolumeDescriptorSequenceNumber = data.ReadUInt32LittleEndian(ref offset);
+ obj.PrimaryVolumeDescriptorNumber = data.ReadUInt32LittleEndian(ref offset);
+ obj.VolumeIdentifier = data.ReadBytes(ref offset, 32);
+ obj.VolumeSequenceNumber = data.ReadUInt16LittleEndian(ref offset);
+ obj.MaximumVolumeSequenceNumber = data.ReadUInt16LittleEndian(ref offset);
+ obj.InterchangeLevel = data.ReadUInt16LittleEndian(ref offset);
+ obj.MaximumInterchangeLevel = data.ReadUInt16LittleEndian(ref offset);
+ obj.CharacterSetList = data.ReadUInt32LittleEndian(ref offset);
+ obj.MaximumCharacterSetList = data.ReadUInt32LittleEndian(ref offset);
+ obj.VolumeSetIdentifier = data.ReadBytes(ref offset, 128);
+ obj.DescriptorCharacterSet = ParseCharacterSetSpecification(data, ref offset);
+ obj.ExplanatoryCharacterSet = ParseCharacterSetSpecification(data, ref offset);
+ obj.VolumeAbstract = ParseExtentDescriptor(data, ref offset);
+ obj.VolumeCopyrightNotice = ParseExtentDescriptor(data, ref offset);
+ obj.ApplicationIdentifier = ParseEntityIdentifier(data, ref offset);
+ obj.RecordingDateAndTime = ParseTimestamp(data, ref offset);
+ obj.ImplementationIdentifier = ParseEntityIdentifier(data, ref offset);
+ obj.ImplementationUse = data.ReadBytes(ref offset, 64);
+ obj.PredecessorVolumeDescriptorSequenceLocation = data.ReadUInt32LittleEndian(ref offset);
+ obj.Flags = data.ReadUInt16LittleEndian(ref offset);
+ obj.Reserved = data.ReadBytes(ref offset, 22);
+
+ return obj;
+ }
+
+ ///
+ /// Parse a Volume Descriptor Pointer after its tag
+ ///
+ private static VolumeDescriptorPointer ParseVolumeDescriptorPointer(byte[] data, ref int offset, DescriptorTag tag)
+ {
+ var obj = new VolumeDescriptorPointer();
+
+ obj.DescriptorTag = tag;
+ obj.VolumeDescriptorSequenceNumber = data.ReadUInt32LittleEndian(ref offset);
+ obj.NextVolumeDescriptorSequenceExtent = ParseExtentDescriptor(data, ref offset);
+ obj.Reserved = data.ReadBytes(ref offset, 484);
+
+ return obj;
+ }
+
+ ///
+ /// Parse a Partition Descriptor after its tag
+ ///
+ private static PartitionDescriptor ParsePartitionDescriptor(byte[] data, ref int offset, DescriptorTag tag)
+ {
+ var obj = new PartitionDescriptor();
+
+ obj.DescriptorTag = tag;
+ obj.VolumeDescriptorSequenceNumber = data.ReadUInt32LittleEndian(ref offset);
+ obj.PartitionFlags = data.ReadUInt16LittleEndian(ref offset);
+ obj.PartitionNumber = data.ReadUInt16LittleEndian(ref offset);
+ obj.PartitionContents = ParseEntityIdentifier(data, ref offset);
+ obj.PartitionContentsUse = data.ReadBytes(ref offset, 128);
+ obj.AccessType = data.ReadUInt32LittleEndian(ref offset);
+ obj.PartitionStartingLocation = data.ReadUInt32LittleEndian(ref offset);
+ obj.PartitionLength = data.ReadUInt32LittleEndian(ref offset);
+ obj.ImplementationIdentifier = ParseEntityIdentifier(data, ref offset);
+ obj.ImplementationUse = data.ReadBytes(ref offset, 128);
+ obj.Reserved = data.ReadBytes(ref offset, 156);
+
+ return obj;
+ }
+
+ ///
+ /// Parse a Logical Volume Descriptor after its tag
+ ///
+ private static LogicalVolumeDescriptor ParseLogicalVolumeDescriptor(byte[] data, ref int offset, DescriptorTag tag)
+ {
+ var obj = new LogicalVolumeDescriptor();
+
+ obj.DescriptorTag = tag;
+ obj.VolumeDescriptorSequenceNumber = data.ReadUInt32LittleEndian(ref offset);
+ obj.DescriptorCharacterSet = ParseCharacterSetSpecification(data, ref offset);
+ obj.LogicalVolumeIdentifier = data.ReadBytes(ref offset, 128);
+ obj.LogicalBlockSize = data.ReadUInt32LittleEndian(ref offset);
+ obj.DomainIdentifier = ParseEntityIdentifier(data, ref offset);
+ obj.LogicalVolumeContentsUse = ParseLongAllocationDescriptor(data, ref offset);
+ obj.MapTableLength = data.ReadUInt32LittleEndian(ref offset);
+ obj.NumberOfPartitionMaps = data.ReadUInt32LittleEndian(ref offset);
+ obj.ImplementationIdentifier = ParseEntityIdentifier(data, ref offset);
+ obj.ImplementationUse = data.ReadBytes(ref offset, 128);
+ obj.IntegritySequenceExtent = ParseExtentDescriptor(data, ref offset);
+
+ // Partition maps, bounded by the map table length and the logical sector
+ var maps = new List();
+ int end = Math.Min(data.Length, offset + (int)Math.Min(obj.MapTableLength, (uint)data.Length));
+ for (uint i = 0; i < obj.NumberOfPartitionMaps && offset + 2 <= end; i++)
+ {
+ int start = offset;
+ byte length = data[offset + 1];
+ if (length < 2)
+ break;
+
+ maps.Add(ParsePartitionMap(data, ref offset));
+ offset = start + length;
+ }
+
+ obj.PartitionMaps = [.. maps];
+
+ return obj;
+ }
+
+ ///
+ /// Parse a Partition Map
+ ///
+ private static PartitionMap ParsePartitionMap(byte[] data, ref int offset)
+ {
+ int start = offset;
+ byte type = data[offset];
+ byte length = data[offset + 1];
+
+ if (type == Constants.Type1PartitionMap && length >= 6)
+ {
+ var type1 = new Type1PartitionMap();
+ type1.PartitionMapType = data.ReadByteValue(ref offset);
+ type1.PartitionMapLength = data.ReadByteValue(ref offset);
+ type1.VolumeSequenceNumber = data.ReadUInt16LittleEndian(ref offset);
+ type1.PartitionNumber = data.ReadUInt16LittleEndian(ref offset);
+ return type1;
+ }
+
+ if (type == Constants.Type2PartitionMap && length >= 64)
+ {
+ offset += 4;
+ var identifier = ParseEntityIdentifier(data, ref offset);
+ offset = start;
+
+ string? name = DecodeIdentifier(identifier.Identifier);
+ Type2PartitionMap type2 = name == Constants.MetadataPartitionIdentifier
+ ? new MetadataPartitionMap()
+ : new Type2PartitionMap();
+
+ type2.PartitionMapType = data.ReadByteValue(ref offset);
+ type2.PartitionMapLength = data.ReadByteValue(ref offset);
+ type2.Reserved = data.ReadBytes(ref offset, 2);
+ type2.PartitionTypeIdentifier = ParseEntityIdentifier(data, ref offset);
+ type2.VolumeSequenceNumber = data.ReadUInt16LittleEndian(ref offset);
+ type2.PartitionNumber = data.ReadUInt16LittleEndian(ref offset);
+ if (type2 is MetadataPartitionMap metadata)
+ {
+ metadata.MetadataFileLocation = data.ReadUInt32LittleEndian(ref offset);
+ metadata.MetadataMirrorFileLocation = data.ReadUInt32LittleEndian(ref offset);
+ metadata.MetadataBitmapFileLocation = data.ReadUInt32LittleEndian(ref offset);
+ metadata.AllocationUnitSize = data.ReadUInt32LittleEndian(ref offset);
+ metadata.AlignmentUnitSize = data.ReadUInt16LittleEndian(ref offset);
+ metadata.Flags = data.ReadByteValue(ref offset);
+ metadata.Reserved2 = data.ReadBytes(ref offset, 5);
+ }
+ else
+ {
+ type2.PartitionTypeData = data.ReadBytes(ref offset, 24);
+ }
+
+ return type2;
+ }
+
+ // Unknown partition map type, keep the raw data
+ var unknown = new Type2PartitionMap();
+ unknown.PartitionMapType = data.ReadByteValue(ref offset);
+ unknown.PartitionMapLength = data.ReadByteValue(ref offset);
+ unknown.PartitionTypeData = data.ReadBytes(ref offset, Math.Max(0, length - 2));
+ return unknown;
+ }
+
+ #endregion
+
+ #region File Structure Parsing
+
+ ///
+ /// Parse the File Set Descriptor
+ ///
+ /// Stream to parse
+ /// Volume containing the Volume Descriptor Sequence
+ /// Logical block address of the File Set Descriptor
+ /// Filled FileSetDescriptor on success, null on error
+ public static FileSetDescriptor? ParseFileSetDescriptor(Stream data, Volume volume, LogicalBlockAddress location)
+ {
+ if (!volume.TryGetSector(location.GetKey(), out long sector))
+ return null;
+
+ byte[] block = ReadSectors(data, sector, 1);
+ if (!IsValidDescriptor(block, 0, block.Length, location.LogicalBlockNumber))
+ return null;
+
+ int offset = 0;
+ var tag = ParseDescriptorTag(block, ref offset);
+ if (tag.TagIdentifier != TagIdentifier.FILE_SET_DESCRIPTOR)
+ return null;
+
+ var obj = new FileSetDescriptor();
+
+ obj.DescriptorTag = tag;
+ obj.RecordingDateAndTime = ParseTimestamp(block, ref offset);
+ obj.InterchangeLevel = block.ReadUInt16LittleEndian(ref offset);
+ obj.MaximumInterchangeLevel = block.ReadUInt16LittleEndian(ref offset);
+ obj.CharacterSetList = block.ReadUInt32LittleEndian(ref offset);
+ obj.MaximumCharacterSetList = block.ReadUInt32LittleEndian(ref offset);
+ obj.FileSetNumber = block.ReadUInt32LittleEndian(ref offset);
+ obj.FileSetDescriptorNumber = block.ReadUInt32LittleEndian(ref offset);
+ obj.LogicalVolumeIdentifierCharacterSet = ParseCharacterSetSpecification(block, ref offset);
+ obj.LogicalVolumeIdentifier = block.ReadBytes(ref offset, 128);
+ obj.FileSetCharacterSet = ParseCharacterSetSpecification(block, ref offset);
+ obj.FileSetIdentifier = block.ReadBytes(ref offset, 32);
+ obj.CopyrightFileIdentifier = block.ReadBytes(ref offset, 32);
+ obj.AbstractFileIdentifier = block.ReadBytes(ref offset, 32);
+ obj.RootDirectoryICB = ParseLongAllocationDescriptor(block, ref offset);
+ obj.DomainIdentifier = ParseEntityIdentifier(block, ref offset);
+ obj.NextExtent = ParseLongAllocationDescriptor(block, ref offset);
+ obj.SystemStreamDirectoryICB = ParseLongAllocationDescriptor(block, ref offset);
+ obj.Reserved = block.ReadBytes(ref offset, 32);
+
+ return obj;
+ }
+
+ ///
+ /// Parse a File Entry or Extended File Entry
+ ///
+ /// Logical block holding the entry
+ /// Logical block number the entry was read from
+ /// Filled FileEntry on success, null on error
+ public static FileEntry? ParseFileEntry(byte[] block, uint logicalBlockNumber)
+ {
+ if (!IsValidDescriptor(block, 0, block.Length, logicalBlockNumber))
+ return null;
+
+ int offset = 0;
+ var tag = ParseDescriptorTag(block, ref offset);
+ bool extended = tag.TagIdentifier == TagIdentifier.EXTENDED_FILE_ENTRY;
+ if (!extended && tag.TagIdentifier != TagIdentifier.FILE_ENTRY)
+ return null;
+
+ var obj = extended ? new ExtendedFileEntry() : new FileEntry();
+
+ obj.DescriptorTag = tag;
+ obj.ICBTag = ParseICBTag(block, ref offset);
+ obj.Uid = block.ReadUInt32LittleEndian(ref offset);
+ obj.Gid = block.ReadUInt32LittleEndian(ref offset);
+ obj.Permissions = block.ReadUInt32LittleEndian(ref offset);
+ obj.FileLinkCount = block.ReadUInt16LittleEndian(ref offset);
+ obj.RecordFormat = block.ReadByteValue(ref offset);
+ obj.RecordDisplayAttributes = block.ReadByteValue(ref offset);
+ obj.RecordLength = block.ReadUInt32LittleEndian(ref offset);
+ obj.InformationLength = block.ReadUInt64LittleEndian(ref offset);
+ if (obj is ExtendedFileEntry efe)
+ {
+ efe.ObjectSize = block.ReadUInt64LittleEndian(ref offset);
+ efe.LogicalBlocksRecorded = block.ReadUInt64LittleEndian(ref offset);
+ efe.AccessDateAndTime = ParseTimestamp(block, ref offset);
+ efe.ModificationDateAndTime = ParseTimestamp(block, ref offset);
+ efe.CreationDateAndTime = ParseTimestamp(block, ref offset);
+ efe.AttributeDateAndTime = ParseTimestamp(block, ref offset);
+ efe.Checkpoint = block.ReadUInt32LittleEndian(ref offset);
+ efe.Reserved = block.ReadUInt32LittleEndian(ref offset);
+ efe.ExtendedAttributeICB = ParseLongAllocationDescriptor(block, ref offset);
+ efe.StreamDirectoryICB = ParseLongAllocationDescriptor(block, ref offset);
+ }
+ else
+ {
+ obj.LogicalBlocksRecorded = block.ReadUInt64LittleEndian(ref offset);
+ obj.AccessDateAndTime = ParseTimestamp(block, ref offset);
+ obj.ModificationDateAndTime = ParseTimestamp(block, ref offset);
+ obj.AttributeDateAndTime = ParseTimestamp(block, ref offset);
+ obj.Checkpoint = block.ReadUInt32LittleEndian(ref offset);
+ obj.ExtendedAttributeICB = ParseLongAllocationDescriptor(block, ref offset);
+ }
+
+ obj.ImplementationIdentifier = ParseEntityIdentifier(block, ref offset);
+ obj.UniqueID = block.ReadUInt64LittleEndian(ref offset);
+ obj.LengthOfExtendedAttributes = block.ReadUInt32LittleEndian(ref offset);
+ obj.LengthOfAllocationDescriptors = block.ReadUInt32LittleEndian(ref offset);
+
+ // Both fields must fit into the logical block
+ if (obj.LengthOfExtendedAttributes > block.Length - offset
+ || obj.LengthOfAllocationDescriptors > block.Length - offset - obj.LengthOfExtendedAttributes)
+ {
+ return null;
+ }
+
+ obj.ExtendedAttributes = block.ReadBytes(ref offset, (int)obj.LengthOfExtendedAttributes);
+ obj.AllocationDescriptors = block.ReadBytes(ref offset, (int)obj.LengthOfAllocationDescriptors);
+
+ return obj;
+ }
+
+ ///
+ /// Parse an Allocation Extent Descriptor
+ ///
+ /// Logical block holding the descriptor
+ /// Logical block number the descriptor was read from
+ /// Filled AllocationExtentDescriptor on success, null on error
+ public static AllocationExtentDescriptor? ParseAllocationExtentDescriptor(byte[] block, uint logicalBlockNumber)
+ {
+ if (!IsValidDescriptor(block, 0, block.Length, logicalBlockNumber))
+ return null;
+
+ int offset = 0;
+ var tag = ParseDescriptorTag(block, ref offset);
+ if (tag.TagIdentifier != TagIdentifier.ALLOCATION_EXTENT_DESCRIPTOR)
+ return null;
+
+ var obj = new AllocationExtentDescriptor();
+
+ obj.DescriptorTag = tag;
+ obj.PreviousAllocationExtentLocation = block.ReadUInt32LittleEndian(ref offset);
+ obj.LengthOfAllocationDescriptors = block.ReadUInt32LittleEndian(ref offset);
+ obj.AllocationDescriptors = block.ReadBytes(ref offset, (int)obj.LengthOfAllocationDescriptors);
+
+ return obj;
+ }
+
+ ///
+ /// Parse the File Identifier Descriptors of a directory
+ ///
+ /// Directory contents
+ /// Length of the directory contents in bytes
+ /// File Identifier Descriptors on success, null if the directory is invalid
+ public static FileIdentifierDescriptor[]? ParseDirectory(byte[] data, int length)
+ {
+ var obj = new List();
+
+ int offset = 0;
+ while (offset < length)
+ {
+ var tag = ParseDescriptorTag(data, ref offset);
+ if (tag.TagIdentifier != TagIdentifier.FILE_IDENTIFIER_DESCRIPTOR)
+ return null;
+
+ var fid = new FileIdentifierDescriptor();
+ fid.DescriptorTag = tag;
+ fid.FileVersionNumber = data.ReadUInt16LittleEndian(ref offset);
+ fid.FileCharacteristics = (FileCharacteristics)data.ReadByteValue(ref offset);
+ fid.LengthOfFileIdentifier = data.ReadByteValue(ref offset);
+ fid.ICB = ParseLongAllocationDescriptor(data, ref offset);
+ fid.LengthOfImplementationUse = data.ReadUInt16LittleEndian(ref offset);
+
+ // The descriptor is padded to 4 bytes
+ int used = 38 + fid.LengthOfImplementationUse + fid.LengthOfFileIdentifier;
+ int padded = (used + 3) & ~3;
+ fid.ImplementationUse = data.ReadBytes(ref offset, fid.LengthOfImplementationUse);
+ fid.FileIdentifier = data.ReadBytes(ref offset, fid.LengthOfFileIdentifier);
+ fid.Padding = data.ReadBytes(ref offset, padded - used);
+
+ obj.Add(fid);
+ }
+
+ return [.. obj];
+ }
+
+ #endregion
+
+ #region Common Structures
+
+ ///
+ /// Parse a Descriptor Tag
+ ///
+ public static DescriptorTag ParseDescriptorTag(byte[] data, ref int offset)
+ {
+ var obj = new DescriptorTag();
+
+ obj.TagIdentifier = (TagIdentifier)data.ReadUInt16LittleEndian(ref offset);
+ obj.DescriptorVersion = data.ReadUInt16LittleEndian(ref offset);
+ obj.TagChecksum = data.ReadByteValue(ref offset);
+ obj.Reserved = data.ReadByteValue(ref offset);
+ obj.TagSerialNumber = data.ReadUInt16LittleEndian(ref offset);
+ obj.DescriptorCRC = data.ReadUInt16LittleEndian(ref offset);
+ obj.DescriptorCRCLength = data.ReadUInt16LittleEndian(ref offset);
+ obj.TagLocation = data.ReadUInt32LittleEndian(ref offset);
+
+ return obj;
+ }
+
+ ///
+ /// Parse an Extent Descriptor (extent_ad)
+ ///
+ public static ExtentDescriptor ParseExtentDescriptor(byte[] data, ref int offset)
+ {
+ var obj = new ExtentDescriptor();
+
+ obj.ExtentLength = data.ReadUInt32LittleEndian(ref offset);
+ obj.ExtentLocation = data.ReadUInt32LittleEndian(ref offset);
+
+ return obj;
+ }
+
+ ///
+ /// Parse a Long Allocation Descriptor (long_ad)
+ ///
+ public static LongAllocationDescriptor ParseLongAllocationDescriptor(byte[] data, ref int offset)
+ {
+ var obj = new LongAllocationDescriptor();
+
+ obj.ExtentLength = data.ReadUInt32LittleEndian(ref offset);
+ obj.ExtentLocation.LogicalBlockNumber = data.ReadUInt32LittleEndian(ref offset);
+ obj.ExtentLocation.PartitionReferenceNumber = data.ReadUInt16LittleEndian(ref offset);
+ obj.ImplementationUse = data.ReadBytes(ref offset, 6);
+
+ return obj;
+ }
+
+ ///
+ /// Parse an Entity Identifier (regid)
+ ///
+ public static EntityIdentifier ParseEntityIdentifier(byte[] data, ref int offset)
+ {
+ var obj = new EntityIdentifier();
+
+ obj.Flags = data.ReadByteValue(ref offset);
+ obj.Identifier = data.ReadBytes(ref offset, 23);
+ obj.IdentifierSuffix = data.ReadBytes(ref offset, 8);
+
+ return obj;
+ }
+
+ ///
+ /// Parse a Character Set Specification (charspec)
+ ///
+ public static CharacterSetSpecification ParseCharacterSetSpecification(byte[] data, ref int offset)
+ {
+ var obj = new CharacterSetSpecification();
+
+ obj.CharacterSetType = data.ReadByteValue(ref offset);
+ obj.CharacterSetInformation = data.ReadBytes(ref offset, 63);
+
+ return obj;
+ }
+
+ ///
+ /// Parse a Timestamp
+ ///
+ public static Timestamp ParseTimestamp(byte[] data, ref int offset)
+ {
+ var obj = new Timestamp();
+
+ obj.TypeAndTimezone = data.ReadUInt16LittleEndian(ref offset);
+ obj.Year = data.ReadInt16LittleEndian(ref offset);
+ obj.Month = data.ReadByteValue(ref offset);
+ obj.Day = data.ReadByteValue(ref offset);
+ obj.Hour = data.ReadByteValue(ref offset);
+ obj.Minute = data.ReadByteValue(ref offset);
+ obj.Second = data.ReadByteValue(ref offset);
+ obj.Centiseconds = data.ReadByteValue(ref offset);
+ obj.HundredsOfMicroseconds = data.ReadByteValue(ref offset);
+ obj.Microseconds = data.ReadByteValue(ref offset);
+
+ return obj;
+ }
+
+ ///
+ /// Parse an ICB Tag (icbtag)
+ ///
+ public static ICBTag ParseICBTag(byte[] data, ref int offset)
+ {
+ var obj = new ICBTag();
+
+ obj.PriorRecordedNumberOfDirectEntries = data.ReadUInt32LittleEndian(ref offset);
+ obj.StrategyType = data.ReadUInt16LittleEndian(ref offset);
+ obj.StrategyParameter = data.ReadBytes(ref offset, 2);
+ obj.MaximumNumberOfEntries = data.ReadUInt16LittleEndian(ref offset);
+ obj.Reserved = data.ReadByteValue(ref offset);
+ obj.FileType = (FileType)data.ReadByteValue(ref offset);
+ obj.ParentICBLocation.LogicalBlockNumber = data.ReadUInt32LittleEndian(ref offset);
+ obj.ParentICBLocation.PartitionReferenceNumber = data.ReadUInt16LittleEndian(ref offset);
+ obj.Flags = data.ReadUInt16LittleEndian(ref offset);
+
+ return obj;
+ }
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// Check a descriptor tag: checksum, version, CRC (when it fits into the data) and location
+ ///
+ /// ECMA-167 3/7.2; receiving systems allow descriptor version 2 or 3 (3/7.2.2)
+ /// Data holding the descriptor
+ /// Offset of the descriptor
+ /// Number of bytes of the descriptor available from the offset
+ /// Expected tag location (logical sector or logical block number)
+ /// True if the tag is valid, false otherwise
+ public static bool IsValidDescriptor(byte[] data, int offset, int available, uint location)
+ {
+ byte checksum = 0;
+ for (int i = 0; i < 16; i++)
+ {
+ if (i != 4)
+ checksum += data[offset + i];
+ }
+
+ if (checksum != data[offset + 4])
+ return false;
+
+ int version = data[offset + 2] | (data[offset + 3] << 8);
+ if (version != 2 && version != 3)
+ return false;
+
+ int crcLength = data[offset + 10] | (data[offset + 11] << 8);
+ if (16 + crcLength <= available)
+ {
+ int crc = data[offset + 8] | (data[offset + 9] << 8);
+ if (ComputeCrc(data, offset + 16, crcLength) != crc)
+ return false;
+ }
+
+ uint tagLocation = (uint)(data[offset + 12] | (data[offset + 13] << 8) | (data[offset + 14] << 16) | (data[offset + 15] << 24));
+ return tagLocation == location;
+ }
+
+ ///
+ /// Compute the CRC-ITU-T of a range of bytes
+ ///
+ private static ushort ComputeCrc(byte[] data, int offset, int length)
+ {
+ ushort crc = 0;
+ for (int i = offset; i < offset + length; i++)
+ {
+ crc = (ushort)(CrcTable[((crc >> 8) ^ data[i]) & 0xFF] ^ (crc << 8));
+ }
+
+ return crc;
+ }
+
+ ///
+ /// Create the CRC-ITU-T table
+ ///
+ private static ushort[] CreateCrcTable()
+ {
+ var table = new ushort[256];
+ for (int i = 0; i < 256; i++)
+ {
+ ushort crc = (ushort)(i << 8);
+ for (int bit = 0; bit < 8; bit++)
+ {
+ crc = (crc & 0x8000) != 0 ? (ushort)((crc << 1) ^ 0x1021) : (ushort)(crc << 1);
+ }
+
+ table[i] = crc;
+ }
+
+ return table;
+ }
+
+ ///
+ /// Check whether the volume has a Logical Volume Descriptor with a supported logical block size
+ ///
+ private static bool HasLogicalVolume(Volume volume)
+ {
+ var lvd = volume.GetLogicalVolumeDescriptor();
+ return lvd is not null && lvd.LogicalBlockSize == Constants.SectorSize;
+ }
+
+ ///
+ /// Check whether a standard identifier belongs to the Volume Recognition Sequence
+ ///
+ private static bool IsKnownStandardIdentifier(byte[] identifier)
+ {
+ return identifier.EqualsExactly(Constants.BeginningExtendedAreaDescriptor)
+ || identifier.EqualsExactly(Constants.NSR02)
+ || identifier.EqualsExactly(Constants.NSR03)
+ || identifier.EqualsExactly(Constants.TerminatingExtendedAreaDescriptor)
+ || identifier.EqualsExactly(Constants.CD001)
+ || identifier.EqualsExactly(Constants.BOOT2)
+ || identifier.EqualsExactly(Constants.CDW02);
+ }
+
+ ///
+ /// Decode an Entity Identifier's identifier field (ASCII, padded with 0x00)
+ ///
+ private static string? DecodeIdentifier(byte[] identifier)
+ {
+ int length = Array.IndexOf(identifier, (byte)0);
+ if (length < 0)
+ length = identifier.Length;
+
+ var chars = new char[length];
+ for (int i = 0; i < length; i++)
+ {
+ chars[i] = (char)identifier[i];
+ }
+
+ return new string(chars);
+ }
+
+ ///
+ /// Read whole logical sectors
+ ///
+ private static byte[] ReadSectors(Stream data, long sector, int count)
+ {
+ data.SeekIfPossible(sector * Constants.SectorSize, SeekOrigin.Begin);
+ return data.ReadBytes(count * Constants.SectorSize);
+ }
+
+ #endregion
+
+ #region Directory Hierarchy
+
+ ///
+ /// Kind of a pending read
+ ///
+ private enum ReadKind
+ {
+ ///
+ /// File Entry or Extended File Entry
+ ///
+ Entry,
+
+ ///
+ /// Allocation Extent Descriptor of a File Entry
+ ///
+ AllocationExtent,
+
+ ///
+ /// Part of the contents of a directory
+ ///
+ DirectoryData,
+ }
+
+ ///
+ /// Contents of a directory being assembled from its extents
+ ///
+ private sealed class DirectoryContents
+ {
+ public long Key;
+ public byte[] Data = [];
+ public int Pending;
+ }
+
+ ///
+ /// A read waiting for the elevator
+ ///
+ private sealed class PendingRead
+ {
+ public long Sector;
+ public int Count;
+ public ReadKind Kind;
+ public long Key;
+ public DirectoryContents? Directory;
+ public int DirectoryOffset;
+ public int Length;
+ }
+
+ ///
+ /// Orders pending reads by sector
+ ///
+ private sealed class PendingReadComparer : IComparer
+ {
+ public static readonly PendingReadComparer Instance = new();
+
+ public int Compare(PendingRead? x, PendingRead? y)
+ => (x?.Sector ?? 0).CompareTo(y?.Sector ?? 0);
+ }
+
+ ///
+ /// Reads File Entries, Allocation Extent Descriptors and directories in ascending sector order (C-SCAN),
+ /// merging adjacent sectors into one request; structures found in the last request are served from it
+ ///
+ private sealed class HierarchyState
+ {
+ private readonly Stream _data;
+ private readonly Volume _volume;
+ private readonly bool _includeDirectories;
+
+ ///
+ /// Pending reads ordered by sector
+ ///
+ private readonly List _pending = [];
+
+ ///
+ /// Reads served from the last request, processed before the next request
+ ///
+ private readonly Queue> _ready = new();
+
+ ///
+ /// Keys of Allocation Extent Descriptors already queued, with the File Entries waiting for them
+ ///
+ private readonly Dictionary> _queuedExtents = [];
+
+ ///
+ /// Sector after the last request
+ ///
+ private long _head = 0;
+
+ ///
+ /// Data and first sector of the last request
+ ///
+ private byte[] _lastRun = [];
+ private long _lastRunSector = -1;
+
+ /// Stream containing the volume
+ /// Volume to fill
+ /// True to read the entries of every directory read
+ public HierarchyState(Stream data, Volume volume, bool includeDirectories)
+ {
+ _data = data;
+ _volume = volume;
+ _includeDirectories = includeDirectories;
+ }
+
+ ///
+ /// Queue a File Entry, or resolve it if it was already read
+ ///
+ public void QueueEntry(long key)
+ {
+ if (_volume.FileEntries.TryGetValue(key, out var entry))
+ {
+ ResolveEntry(key, entry);
+ return;
+ }
+
+ if (_volume.TryGetSector(key, out long sector))
+ Queue(new PendingRead { Sector = sector, Count = 1, Kind = ReadKind.Entry, Key = key });
+ }
+
+ ///
+ /// Process pending reads until none are left
+ ///
+ public void Run()
+ {
+ while (true)
+ {
+ while (_ready.Count > 0)
+ {
+ var ready = _ready.Dequeue();
+ Process(ready.Key, ready.Value);
+ }
+
+ if (_pending.Count == 0)
+ return;
+
+ // Next pending read at or after the head, wrapping around to the lowest sector
+ int first = FirstAtOrAfter(_head);
+ if (first >= _pending.Count)
+ first = 0;
+
+ // Merge adjacent or overlapping reads into one request
+ long runStart = _pending[first].Sector;
+ long runEnd = runStart + _pending[first].Count;
+ int last = first;
+ while (last + 1 < _pending.Count && _pending[last + 1].Sector <= runEnd)
+ {
+ last++;
+ runEnd = Math.Max(runEnd, _pending[last].Sector + _pending[last].Count);
+ }
+
+ var run = _pending.GetRange(first, last - first + 1);
+ _pending.RemoveRange(first, last - first + 1);
+ _head = runEnd;
+
+ byte[]? buffer = null;
+ try
+ {
+ buffer = ReadSectors(_data, runStart, (int)(runEnd - runStart));
+ }
+ catch (IOException)
+ {
+ // Unreadable sectors: retry each read alone below
+ }
+
+ if (buffer is not null)
+ {
+ _lastRun = buffer;
+ _lastRunSector = runStart;
+ }
+
+ foreach (var read in run)
+ {
+ byte[]? sectors = buffer is not null
+ ? Slice(buffer, (int)(read.Sector - runStart) * Constants.SectorSize, read.Count * Constants.SectorSize)
+ : TryReadSectors(read.Sector, read.Count);
+ if (sectors is not null)
+ Process(read, sectors);
+ }
+ }
+ }
+
+ ///
+ /// Add a read to the pending reads, or to the ready reads if the last request contains it
+ ///
+ private void Queue(PendingRead read)
+ {
+ long runEnd = _lastRunSector + (_lastRun.Length / Constants.SectorSize);
+ if (_lastRunSector >= 0 && read.Sector >= _lastRunSector && read.Sector + read.Count <= runEnd)
+ {
+ int offset = (int)(read.Sector - _lastRunSector) * Constants.SectorSize;
+ _ready.Enqueue(new KeyValuePair(read, Slice(_lastRun, offset, read.Count * Constants.SectorSize)));
+ return;
+ }
+
+ int index = _pending.BinarySearch(read, PendingReadComparer.Instance);
+ if (index < 0)
+ index = ~index;
+
+ // Keep reads of the same sector in queue order
+ while (index < _pending.Count && _pending[index].Sector == read.Sector)
+ {
+ index++;
+ }
+
+ _pending.Insert(index, read);
+ }
+
+ ///
+ /// Index of the first pending read at or after a sector
+ ///
+ private int FirstAtOrAfter(long sector)
+ {
+ int low = 0, high = _pending.Count;
+ while (low < high)
+ {
+ int middle = low + ((high - low) / 2);
+ if (_pending[middle].Sector < sector)
+ low = middle + 1;
+ else
+ high = middle;
+ }
+
+ return low;
+ }
+
+ ///
+ /// Process the data of a read
+ ///
+ private void Process(PendingRead read, byte[] sectors)
+ {
+ switch (read.Kind)
+ {
+ case ReadKind.Entry:
+ var entry = ParseFileEntry(sectors, (uint)read.Key);
+ if (entry is null)
+ return;
+
+ _volume.FileEntries[read.Key] = entry;
+ ResolveEntry(read.Key, entry);
+ break;
+
+ case ReadKind.AllocationExtent:
+ var aed = ParseAllocationExtentDescriptor(sectors, (uint)read.Key);
+ if (aed is null)
+ return;
+
+ _volume.AllocationExtentDescriptors[read.Key] = aed;
+ if (_queuedExtents.TryGetValue(read.Key, out var owners))
+ {
+ foreach (long owner in owners)
+ {
+ if (_volume.FileEntries.TryGetValue(owner, out var ownerEntry))
+ ResolveEntry(owner, ownerEntry);
+ }
+ }
+
+ break;
+
+ case ReadKind.DirectoryData:
+ var directory = read.Directory!;
+ Array.Copy(sectors, 0, directory.Data, read.DirectoryOffset, Math.Min(read.Length, sectors.Length));
+ if (--directory.Pending == 0)
+ CompleteDirectory(directory.Key, directory.Data);
+
+ break;
+
+ default:
+ // All read kinds are handled above
+ break;
+ }
+ }
+
+ ///
+ /// Queue what a File Entry still needs: its Allocation Extent Descriptors, and its contents if it is a directory
+ ///
+ private void ResolveEntry(long key, FileEntry entry)
+ {
+ var descriptors = _volume.GetAllocationDescriptors(key, entry, out long missingKey);
+ if (descriptors is null)
+ {
+ if (missingKey >= 0)
+ QueueAllocationExtent(missingKey, key);
+
+ return;
+ }
+
+ if (entry.ICBTag.FileType != FileType.DIRECTORY || _volume.Directories.ContainsKey(key))
+ return;
+
+ int length = (int)entry.InformationLength;
+ if (entry.ICBTag.GetAllocationDescriptorType() == AllocationDescriptorType.EMBEDDED)
+ {
+ if (length <= entry.AllocationDescriptors.Length)
+ CompleteDirectory(key, entry.AllocationDescriptors.Length == length ? entry.AllocationDescriptors : Slice(entry.AllocationDescriptors, 0, length));
+
+ return;
+ }
+
+ // Queue the recorded extents; extents that are not recorded read as zeros
+ var directory = new DirectoryContents { Key = key, Data = new byte[length] };
+ var reads = new List();
+ int offset = 0;
+ foreach (var ad in descriptors)
+ {
+ if (offset >= length)
+ break;
+
+ int extentLength = (int)Math.Min(UDFExtensions.GetExtentLength(ad.ExtentLength), (uint)(length - offset));
+ if (UDFExtensions.GetExtentType(ad.ExtentLength) == ExtentType.RECORDED_AND_ALLOCATED)
+ {
+ if (!_volume.TryGetSector(ad.ExtentLocation.GetKey(), out long sector))
+ return;
+
+ int count = (extentLength + Constants.SectorSize - 1) / Constants.SectorSize;
+ reads.Add(new PendingRead
+ {
+ Sector = sector,
+ Count = count,
+ Kind = ReadKind.DirectoryData,
+ Key = key,
+ Directory = directory,
+ DirectoryOffset = offset,
+ Length = extentLength,
+ });
+ }
+
+ offset += extentLength;
+ }
+
+ directory.Pending = reads.Count;
+ if (reads.Count == 0)
+ {
+ CompleteDirectory(key, directory.Data);
+ return;
+ }
+
+ foreach (var read in reads)
+ {
+ Queue(read);
+ }
+ }
+
+ ///
+ /// Queue an Allocation Extent Descriptor needed by a File Entry
+ ///
+ private void QueueAllocationExtent(long key, long owner)
+ {
+ if (_queuedExtents.TryGetValue(key, out var owners))
+ {
+ if (!owners.Contains(owner))
+ owners.Add(owner);
+
+ return;
+ }
+
+ _queuedExtents[key] = [owner];
+ if (_volume.TryGetSector(key, out long sector))
+ Queue(new PendingRead { Sector = sector, Count = 1, Kind = ReadKind.AllocationExtent, Key = key });
+ }
+
+ ///
+ /// Parse the contents of a directory and queue the File Entries it identifies
+ ///
+ private void CompleteDirectory(long key, byte[] contents)
+ {
+ var fids = ParseDirectory(contents, contents.Length);
+ if (fids is null)
+ return;
+
+ _volume.Directories[key] = fids;
+ if (!_includeDirectories)
+ return;
+
+ foreach (var fid in fids)
+ {
+ if ((fid.FileCharacteristics & (FileCharacteristics.DELETED | FileCharacteristics.PARENT)) != 0)
+ continue;
+
+ QueueEntry(fid.ICB.ExtentLocation.GetKey());
+ }
+ }
+
+ ///
+ /// Read sectors alone, null if they cannot be read
+ ///
+ private byte[]? TryReadSectors(long sector, int count)
+ {
+ try
+ {
+ return ReadSectors(_data, sector, count);
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Copy part of an array
+ ///
+ private static byte[] Slice(byte[] data, int offset, int length)
+ {
+ var slice = new byte[length];
+ Array.Copy(data, offset, slice, 0, length);
+ return slice;
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/SabreTools.Wrappers.Test/SabreTools.Wrappers.Test.csproj b/SabreTools.Wrappers.Test/SabreTools.Wrappers.Test.csproj
index 5051ac02..be7825a4 100644
--- a/SabreTools.Wrappers.Test/SabreTools.Wrappers.Test.csproj
+++ b/SabreTools.Wrappers.Test/SabreTools.Wrappers.Test.csproj
@@ -39,6 +39,10 @@
+
+
+
+
diff --git a/SabreTools.Wrappers.Test/UDFTests.cs b/SabreTools.Wrappers.Test/UDFTests.cs
new file mode 100644
index 00000000..91b7c1f6
--- /dev/null
+++ b/SabreTools.Wrappers.Test/UDFTests.cs
@@ -0,0 +1,175 @@
+using System.IO;
+using System.Linq;
+using System.Text;
+using SabreTools.Serialization.Readers.Test;
+using Xunit;
+
+namespace SabreTools.Wrappers.Test
+{
+ public class UDFTests
+ {
+ [Fact]
+ public void NullArray_Null()
+ {
+ byte[]? data = null;
+ int offset = 0;
+ var actual = UDF.Create(data, offset);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void EmptyArray_Null()
+ {
+ byte[]? data = [];
+ int offset = 0;
+ var actual = UDF.Create(data, offset);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void InvalidArray_Null()
+ {
+ byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)];
+ int offset = 0;
+ var actual = UDF.Create(data, offset);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void NullStream_Null()
+ {
+ Stream? data = null;
+ var actual = UDF.Create(data);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void EmptyStream_Null()
+ {
+ Stream? data = new MemoryStream([]);
+ var actual = UDF.Create(data);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void InvalidStream_Null()
+ {
+ Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]);
+ var actual = UDF.Create(data);
+ Assert.Null(actual);
+ }
+
+ [Fact]
+ public void DirectoryLoop_PrintInformation()
+ {
+ var wrapper = UDF.Create(new MemoryStream(UDFImageBuilder.Build(subLoop: true)));
+ Assert.NotNull(wrapper);
+
+ var builder = new StringBuilder();
+ wrapper.PrintInformation(builder, true);
+
+ Assert.Contains("\\Sub\\loop\\", builder.ToString());
+ }
+
+ [Fact]
+ public void DirectoryLoop_Extract()
+ {
+ var wrapper = UDF.Create(new MemoryStream(UDFImageBuilder.Build(subLoop: true)));
+ Assert.NotNull(wrapper);
+
+ string output = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ try
+ {
+ Directory.CreateDirectory(output);
+ Assert.True(wrapper.Extract(output, false));
+ Assert.Equal(UDFImageBuilder.DataBinLength, new FileInfo(Path.Combine(output, "Sub", "data.bin")).Length);
+ Assert.True(Directory.Exists(Path.Combine(output, "Sub", "loop")));
+ }
+ finally
+ {
+ if (Directory.Exists(output))
+ Directory.Delete(output, true);
+ }
+ }
+
+ [Fact]
+ public void ParentDirectoryName_NotExtracted()
+ {
+ // "Sub" is named "..", so "data.bin" would be written next to the output directory
+ var wrapper = UDF.Create(new MemoryStream(UDFImageBuilder.Build(subName: "..")));
+ Assert.NotNull(wrapper);
+
+ string parent = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ string output = Path.Combine(parent, "output");
+ try
+ {
+ Directory.CreateDirectory(output);
+ Assert.False(wrapper.Extract(output, false));
+ Assert.False(File.Exists(Path.Combine(parent, "data.bin")));
+ Assert.True(File.Exists(Path.Combine(output, "README.TXT")));
+ }
+ finally
+ {
+ if (Directory.Exists(parent))
+ Directory.Delete(parent, true);
+ }
+ }
+
+ [Fact]
+ public void RecordedPastEnd_OtherFilesExtracted()
+ {
+ // "Media" is recorded in a partition block past the end of the image
+ var wrapper = UDF.Create(new MemoryStream(UDFImageBuilder.Build(mediaPastEnd: true)));
+ Assert.NotNull(wrapper);
+
+ string output = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ try
+ {
+ Directory.CreateDirectory(output);
+ Assert.False(wrapper.Extract(output, false));
+ Assert.True(File.Exists(Path.Combine(output, "README.TXT")));
+ Assert.Equal(UDFImageBuilder.DataBinLength, new FileInfo(Path.Combine(output, "Sub", "data.bin")).Length);
+ }
+ finally
+ {
+ if (Directory.Exists(output))
+ Directory.Delete(output, true);
+ }
+ }
+
+ [Fact]
+ public void StreamAtOffset_VolumeAtOffsetExtracted()
+ {
+ // Another volume comes first, so reading from the start of the stream would find "Outer" instead of "Inner"
+ var stream = new MemoryStream([.. UDFImageBuilder.Build(subName: "Outer"), .. UDFImageBuilder.Build(subName: "Inner")]);
+ stream.Position = UDFImageBuilder.Sectors * UDFImageBuilder.SectorSize;
+
+ var wrapper = UDF.Create(stream);
+ Assert.NotNull(wrapper);
+
+ string output = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ try
+ {
+ Directory.CreateDirectory(output);
+ Assert.True(wrapper.Extract(output, false));
+ Assert.Equal(UDFImageBuilder.DataBinLength, new FileInfo(Path.Combine(output, "Inner", "data.bin")).Length);
+ Assert.False(Directory.Exists(Path.Combine(output, "Outer")));
+ }
+ finally
+ {
+ if (Directory.Exists(output))
+ Directory.Delete(output, true);
+ }
+ }
+
+ [Fact]
+ public void StreamAtOffset_WrapperFactoryUDF()
+ {
+ var stream = new MemoryStream([.. new byte[3 * UDFImageBuilder.SectorSize], .. UDFImageBuilder.Build()]);
+ stream.Position = 3 * UDFImageBuilder.SectorSize;
+
+ var wrapper = WrapperFactory.CreateDiscImageWrapper(stream);
+ Assert.IsType(wrapper);
+ }
+ }
+}
diff --git a/SabreTools.Wrappers/UDF.Extraction.cs b/SabreTools.Wrappers/UDF.Extraction.cs
new file mode 100644
index 00000000..f28e63f1
--- /dev/null
+++ b/SabreTools.Wrappers/UDF.Extraction.cs
@@ -0,0 +1,172 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using SabreTools.Data.Extensions;
+using SabreTools.Data.Models.UDF;
+using SabreTools.IO.Extensions;
+using SabreTools.Numerics.Extensions;
+
+namespace SabreTools.Wrappers
+{
+ public partial class UDF : IExtractable
+ {
+ ///
+ public virtual bool Extract(string outputDirectory, bool includeDebug)
+ {
+ // If we have no file set or directories, there is nothing to extract
+ if (FileSetDescriptor is null || Directories.Count == 0)
+ return true;
+
+ long root = FileSetDescriptor.RootDirectoryICB.ExtentLocation.GetKey();
+ var visited = new HashSet { root };
+ return ExtractDirectory(root, Path.GetFullPath(outputDirectory), visited, includeDebug);
+ }
+
+ ///
+ /// Extract all files and directories within a directory
+ ///
+ private bool ExtractDirectory(long key, string outputPath, HashSet visited, bool includeDebug)
+ {
+ if (!Directories.TryGetValue(key, out var fids))
+ return false;
+
+ bool succeeded = true;
+ foreach (var fid in fids)
+ {
+ // Skip deleted entries and the parent directory
+ if ((fid.FileCharacteristics & (FileCharacteristics.DELETED | FileCharacteristics.PARENT)) != 0)
+ continue;
+
+ string? name = fid.FileIdentifier.DecodeDCharacters();
+ if (!IsSafeName(name))
+ {
+ if (includeDebug) Console.WriteLine($"Skipping entry with unsafe name in {outputPath}");
+
+ succeeded = false;
+ continue;
+ }
+
+ long childKey = fid.ICB.ExtentLocation.GetKey();
+ if (!FileEntries.TryGetValue(childKey, out var entry))
+ {
+ if (includeDebug) Console.WriteLine($"File Entry missing, cannot extract: {name}");
+
+ succeeded = false;
+ continue;
+ }
+
+ string childPath = Path.Combine(outputPath, name!);
+ if (entry.ICBTag.FileType == FileType.DIRECTORY)
+ {
+ if (includeDebug) Console.WriteLine($"Extracting to directory: {childPath}");
+ if (!Directory.Exists(childPath))
+ Directory.CreateDirectory(childPath);
+
+ // Directories reachable twice (hard links, loops) are extracted once
+ if (visited.Add(childKey))
+ succeeded &= ExtractDirectory(childKey, childPath, visited, includeDebug);
+ }
+ else
+ {
+ succeeded &= ExtractFile(childKey, entry, childPath, includeDebug);
+ }
+ }
+
+ return succeeded;
+ }
+
+ ///
+ /// Extract a file's data
+ ///
+ private bool ExtractFile(long key, FileEntry entry, string filePath, bool includeDebug)
+ {
+ if (File.Exists(filePath) || Directory.Exists(filePath))
+ {
+ if (includeDebug) Console.WriteLine($"File/Folder already exists, cannot extract: {filePath}");
+ return false;
+ }
+
+ if (includeDebug) Console.WriteLine($"Extracting: {filePath}");
+
+ long size = (long)entry.InformationLength;
+ if (entry.ICBTag.GetAllocationDescriptorType() == AllocationDescriptorType.EMBEDDED)
+ {
+ using var embedded = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
+ embedded.Write(entry.AllocationDescriptors, 0, (int)size);
+ return true;
+ }
+
+ var descriptors = Model.GetAllocationDescriptors(key, entry);
+ if (descriptors is null)
+ {
+ if (includeDebug) Console.WriteLine($"Allocation descriptors not supported or incomplete: {filePath}");
+ return false;
+ }
+
+ const int chunkSize = 2048 * 1024;
+ using var fs = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
+ long written = 0;
+ foreach (var ad in descriptors)
+ {
+ if (written >= size)
+ break;
+
+ long length = Math.Min(UDFExtensions.GetExtentLength(ad.ExtentLength), size - written);
+ bool recorded = UDFExtensions.GetExtentType(ad.ExtentLength) == ExtentType.RECORDED_AND_ALLOCATED;
+
+ long sector = 0;
+ if (recorded && !Model.TryGetSector(ad.ExtentLocation.GetKey(), out sector))
+ return false;
+
+ // Make sure the extent won't EOF
+ if (recorded && (sector * Constants.SectorSize) + length > _dataSource.Length)
+ return false;
+
+ long done = 0;
+ while (done < length)
+ {
+ int count = (int)Math.Min(chunkSize, length - done);
+ byte[] buffer;
+ if (recorded)
+ {
+ lock (_dataSourceLock)
+ {
+ _dataSource.SeekIfPossible((sector * Constants.SectorSize) + done, SeekOrigin.Begin);
+ buffer = _dataSource.ReadBytes(count);
+ }
+ }
+ else
+ {
+ // Extents that are not recorded read as zeros
+ buffer = new byte[count];
+ }
+
+ fs.Write(buffer, 0, count);
+ done += count;
+ }
+
+ written += length;
+ }
+
+ return written == size;
+ }
+
+ ///
+ /// Check that a file identifier can be used as a single path component
+ ///
+ private static bool IsSafeName(string? name)
+ {
+ if (string.IsNullOrEmpty(name) || name == "." || name == "..")
+ return false;
+
+ char[] invalid = Path.GetInvalidFileNameChars();
+ foreach (char c in name!)
+ {
+ if (c == '/' || c == '\\' || c == '\0' || Array.IndexOf(invalid, c) >= 0)
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/SabreTools.Wrappers/UDF.Printing.cs b/SabreTools.Wrappers/UDF.Printing.cs
new file mode 100644
index 00000000..b5986775
--- /dev/null
+++ b/SabreTools.Wrappers/UDF.Printing.cs
@@ -0,0 +1,310 @@
+using System.Collections.Generic;
+using System.Text;
+using SabreTools.Data.Extensions;
+using SabreTools.Data.Models.UDF;
+using SabreTools.Text.Extensions;
+
+namespace SabreTools.Wrappers
+{
+ public partial class UDF : IPrintable
+ {
+#if NETCOREAPP
+ ///
+ public string ExportJSON(bool recursive) => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions);
+#else
+ ///
+ public string ExportJSON(bool recursive) => Newtonsoft.Json.JsonConvert.SerializeObject(Model, _jsonSerializerOptions);
+#endif
+
+ ///
+ public void PrintInformation(StringBuilder builder, bool recursive)
+ {
+ builder.AppendLine("UDF Information:");
+ builder.AppendLine("-------------------------");
+ builder.AppendLine();
+
+ Print(builder, Model.VolumeRecognitionSequence);
+ Print(builder, Model.AnchorVolumeDescriptorPointer);
+ Print(builder, Model.VolumeDescriptorSequence);
+ Print(builder, Model.FileSetDescriptor);
+
+ if (Model.FileSetDescriptor is not null)
+ {
+ builder.AppendLine(" Directory Hierarchy:");
+ builder.AppendLine(" -------------------------");
+ long root = Model.FileSetDescriptor.RootDirectoryICB.ExtentLocation.GetKey();
+ PrintDirectory(builder, root, "\\", new HashSet { root });
+ builder.AppendLine();
+ }
+ }
+
+ private static void Print(StringBuilder builder, VolumeStructureDescriptor[] sequence)
+ {
+ builder.AppendLine(" Volume Recognition Sequence:");
+ builder.AppendLine(" -------------------------");
+ if (sequence.Length == 0)
+ {
+ builder.AppendLine(" No volume structure descriptors");
+ builder.AppendLine();
+ return;
+ }
+
+ for (int i = 0; i < sequence.Length; i++)
+ {
+ var vsd = sequence[i];
+ builder.AppendLine($" Volume Structure Descriptor {i}:");
+ builder.AppendLine(vsd.StructureType, " Structure Type");
+ builder.AppendLine(Encoding.ASCII.GetString(vsd.StandardIdentifier), " Standard Identifier");
+ builder.AppendLine(vsd.StructureVersion, " Structure Version");
+ }
+
+ builder.AppendLine();
+ }
+
+ private static void Print(StringBuilder builder, AnchorVolumeDescriptorPointer? anchor)
+ {
+ builder.AppendLine(" Anchor Volume Descriptor Pointer:");
+ builder.AppendLine(" -------------------------");
+ if (anchor is null)
+ {
+ builder.AppendLine(" No anchor volume descriptor pointer");
+ builder.AppendLine();
+ return;
+ }
+
+ Print(builder, anchor.DescriptorTag, " ");
+ Print(builder, anchor.MainVolumeDescriptorSequenceExtent, " Main Volume Descriptor Sequence");
+ Print(builder, anchor.ReserveVolumeDescriptorSequenceExtent, " Reserve Volume Descriptor Sequence");
+ builder.AppendLine();
+ }
+
+ private static void Print(StringBuilder builder, Descriptor[] sequence)
+ {
+ builder.AppendLine(" Volume Descriptor Sequence:");
+ builder.AppendLine(" -------------------------");
+ if (sequence.Length == 0)
+ {
+ builder.AppendLine(" No volume descriptors");
+ builder.AppendLine();
+ return;
+ }
+
+ foreach (var descriptor in sequence)
+ {
+ builder.AppendLine($" {descriptor.DescriptorTag.TagIdentifier} (Sector {descriptor.DescriptorTag.TagLocation}):");
+ Print(builder, descriptor.DescriptorTag, " ");
+ switch (descriptor)
+ {
+ case PrimaryVolumeDescriptor pvd:
+ builder.AppendLine(pvd.VolumeDescriptorSequenceNumber, " Volume Descriptor Sequence Number");
+ builder.AppendLine(pvd.PrimaryVolumeDescriptorNumber, " Primary Volume Descriptor Number");
+ builder.AppendLine(pvd.VolumeIdentifier.DecodeDString(), " Volume Identifier");
+ builder.AppendLine(pvd.VolumeSequenceNumber, " Volume Sequence Number");
+ builder.AppendLine(pvd.MaximumVolumeSequenceNumber, " Maximum Volume Sequence Number");
+ builder.AppendLine(pvd.InterchangeLevel, " Interchange Level");
+ builder.AppendLine(pvd.MaximumInterchangeLevel, " Maximum Interchange Level");
+ builder.AppendLine(pvd.VolumeSetIdentifier.DecodeDString(), " Volume Set Identifier");
+ Print(builder, pvd.ApplicationIdentifier, " Application Identifier");
+ builder.AppendLine(Format(pvd.RecordingDateAndTime), " Recording Date and Time");
+ Print(builder, pvd.ImplementationIdentifier, " Implementation Identifier");
+ builder.AppendLine(pvd.Flags, " Flags");
+ break;
+
+ case PartitionDescriptor pd:
+ builder.AppendLine(pd.VolumeDescriptorSequenceNumber, " Volume Descriptor Sequence Number");
+ builder.AppendLine(pd.PartitionFlags, " Partition Flags");
+ builder.AppendLine(pd.PartitionNumber, " Partition Number");
+ Print(builder, pd.PartitionContents, " Partition Contents");
+ builder.AppendLine(pd.AccessType, " Access Type");
+ builder.AppendLine(pd.PartitionStartingLocation, " Partition Starting Location");
+ builder.AppendLine(pd.PartitionLength, " Partition Length");
+ Print(builder, pd.ImplementationIdentifier, " Implementation Identifier");
+ break;
+
+ case LogicalVolumeDescriptor lvd:
+ builder.AppendLine(lvd.VolumeDescriptorSequenceNumber, " Volume Descriptor Sequence Number");
+ builder.AppendLine(lvd.LogicalVolumeIdentifier.DecodeDString(), " Logical Volume Identifier");
+ builder.AppendLine(lvd.LogicalBlockSize, " Logical Block Size");
+ Print(builder, lvd.DomainIdentifier, " Domain Identifier");
+ Print(builder, lvd.LogicalVolumeContentsUse, " File Set Descriptor Extent");
+ builder.AppendLine(lvd.MapTableLength, " Map Table Length");
+ builder.AppendLine(lvd.NumberOfPartitionMaps, " Number of Partition Maps");
+ Print(builder, lvd.ImplementationIdentifier, " Implementation Identifier");
+ Print(builder, lvd.IntegritySequenceExtent, " Integrity Sequence Extent");
+ for (int i = 0; i < lvd.PartitionMaps.Length; i++)
+ {
+ Print(builder, lvd.PartitionMaps[i], i);
+ }
+
+ break;
+
+ case VolumeDescriptorPointer vdp:
+ builder.AppendLine(vdp.VolumeDescriptorSequenceNumber, " Volume Descriptor Sequence Number");
+ Print(builder, vdp.NextVolumeDescriptorSequenceExtent, " Next Volume Descriptor Sequence");
+ break;
+
+ default:
+ // Other descriptors are not parsed further
+ break;
+ }
+ }
+
+ builder.AppendLine();
+ }
+
+ private static void Print(StringBuilder builder, PartitionMap map, int index)
+ {
+ builder.AppendLine($" Partition Map {index} (Partition Reference Number {index}):");
+ builder.AppendLine(map.PartitionMapType, " Partition Map Type");
+ builder.AppendLine(map.PartitionMapLength, " Partition Map Length");
+ switch (map)
+ {
+ case Type1PartitionMap type1:
+ builder.AppendLine(type1.VolumeSequenceNumber, " Volume Sequence Number");
+ builder.AppendLine(type1.PartitionNumber, " Partition Number");
+ break;
+
+ case MetadataPartitionMap metadata:
+ Print(builder, metadata.PartitionTypeIdentifier, " Partition Type Identifier");
+ builder.AppendLine(metadata.VolumeSequenceNumber, " Volume Sequence Number");
+ builder.AppendLine(metadata.PartitionNumber, " Partition Number");
+ builder.AppendLine(metadata.MetadataFileLocation, " Metadata File Location");
+ builder.AppendLine(metadata.MetadataMirrorFileLocation, " Metadata Mirror File Location");
+ builder.AppendLine(metadata.MetadataBitmapFileLocation, " Metadata Bitmap File Location");
+ builder.AppendLine(metadata.AllocationUnitSize, " Allocation Unit Size");
+ builder.AppendLine(metadata.AlignmentUnitSize, " Alignment Unit Size");
+ builder.AppendLine(metadata.Flags, " Flags");
+ break;
+
+ case Type2PartitionMap type2:
+ Print(builder, type2.PartitionTypeIdentifier, " Partition Type Identifier");
+ builder.AppendLine(type2.VolumeSequenceNumber, " Volume Sequence Number");
+ builder.AppendLine(type2.PartitionNumber, " Partition Number");
+ builder.AppendLine(type2.PartitionTypeData, " Partition Type Data");
+ break;
+
+ default:
+ // Unknown partition map types are not parsed further
+ break;
+ }
+ }
+
+ private static void Print(StringBuilder builder, FileSetDescriptor? fsd)
+ {
+ builder.AppendLine(" File Set Descriptor:");
+ builder.AppendLine(" -------------------------");
+ if (fsd is null)
+ {
+ builder.AppendLine(" No file set descriptor");
+ builder.AppendLine();
+ return;
+ }
+
+ Print(builder, fsd.DescriptorTag, " ");
+ builder.AppendLine(Format(fsd.RecordingDateAndTime), " Recording Date and Time");
+ builder.AppendLine(fsd.InterchangeLevel, " Interchange Level");
+ builder.AppendLine(fsd.MaximumInterchangeLevel, " Maximum Interchange Level");
+ builder.AppendLine(fsd.FileSetNumber, " File Set Number");
+ builder.AppendLine(fsd.FileSetDescriptorNumber, " File Set Descriptor Number");
+ builder.AppendLine(fsd.LogicalVolumeIdentifier.DecodeDString(), " Logical Volume Identifier");
+ builder.AppendLine(fsd.FileSetIdentifier.DecodeDString(), " File Set Identifier");
+ builder.AppendLine(fsd.CopyrightFileIdentifier.DecodeDString(), " Copyright File Identifier");
+ builder.AppendLine(fsd.AbstractFileIdentifier.DecodeDString(), " Abstract File Identifier");
+ Print(builder, fsd.RootDirectoryICB, " Root Directory ICB");
+ Print(builder, fsd.DomainIdentifier, " Domain Identifier");
+ Print(builder, fsd.NextExtent, " Next Extent");
+ Print(builder, fsd.SystemStreamDirectoryICB, " System Stream Directory ICB");
+ builder.AppendLine();
+ }
+
+ private void PrintDirectory(StringBuilder builder, long key, string path, HashSet visited)
+ {
+ if (!Model.Directories.TryGetValue(key, out var fids))
+ {
+ builder.AppendLine($" {path} (directory not read)");
+ return;
+ }
+
+ foreach (var fid in fids)
+ {
+ if ((fid.FileCharacteristics & (FileCharacteristics.DELETED | FileCharacteristics.PARENT)) != 0)
+ continue;
+
+ string name = fid.FileIdentifier.DecodeDCharacters() ?? "(unknown encoding)";
+ string childPath = path + name;
+ long childKey = fid.ICB.ExtentLocation.GetKey();
+ string hidden = (fid.FileCharacteristics & FileCharacteristics.EXISTENCE) != 0 ? ", hidden" : string.Empty;
+ if (!Model.FileEntries.TryGetValue(childKey, out var entry))
+ {
+ builder.AppendLine($" {childPath} (File Entry not read{hidden})");
+ continue;
+ }
+
+ string location = $"partition {fid.ICB.ExtentLocation.PartitionReferenceNumber}, block {fid.ICB.ExtentLocation.LogicalBlockNumber}";
+ if (entry.ICBTag.FileType == FileType.DIRECTORY)
+ {
+ builder.AppendLine($" {childPath}\\ ({location}{hidden})");
+ if (visited.Add(childKey))
+ PrintDirectory(builder, childKey, childPath + "\\", visited);
+ }
+ else
+ {
+ builder.AppendLine($" {childPath} ({entry.InformationLength} bytes, {entry.ICBTag.FileType}, {location}{hidden})");
+ }
+ }
+ }
+
+ private static void Print(StringBuilder builder, DescriptorTag tag, string indent)
+ {
+ builder.AppendLine((ushort)tag.TagIdentifier, $"{indent}Tag Identifier");
+ builder.AppendLine(tag.DescriptorVersion, $"{indent}Descriptor Version");
+ builder.AppendLine(tag.TagSerialNumber, $"{indent}Tag Serial Number");
+ builder.AppendLine(tag.DescriptorCRCLength, $"{indent}Descriptor CRC Length");
+ builder.AppendLine(tag.TagLocation, $"{indent}Tag Location");
+ }
+
+ private static void Print(StringBuilder builder, ExtentDescriptor extent, string label)
+ => builder.AppendLine($"sector {extent.ExtentLocation}, {extent.ExtentLength} bytes", label);
+
+ private static void Print(StringBuilder builder, LongAllocationDescriptor ad, string label)
+ {
+ builder.AppendLine($"partition {ad.ExtentLocation.PartitionReferenceNumber}, block {ad.ExtentLocation.LogicalBlockNumber}, "
+ + $"{UDFExtensions.GetExtentLength(ad.ExtentLength)} bytes", label);
+ }
+
+ private static void Print(StringBuilder builder, EntityIdentifier identifier, string label)
+ {
+ int length = System.Array.IndexOf(identifier.Identifier, (byte)0);
+ if (length < 0)
+ length = identifier.Identifier.Length;
+
+ string text = Encoding.ASCII.GetString(identifier.Identifier, 0, length);
+ builder.AppendLine($"{text} (flags {identifier.Flags}, suffix {BytesToHex(identifier.IdentifierSuffix)})", label);
+ }
+
+ private static string Format(Timestamp timestamp)
+ {
+ if (timestamp.Year == 0 && timestamp.Month == 0 && timestamp.Day == 0)
+ return "Not specified";
+
+ // Lower 12 bits: signed offset from UTC in minutes, -2047 = not specified
+ int offset = timestamp.TypeAndTimezone & 0x0FFF;
+ if ((offset & 0x0800) != 0)
+ offset -= 0x1000;
+
+ string zone = offset == -2047 ? string.Empty : $" UTC{(offset < 0 ? "-" : "+")}{System.Math.Abs(offset) / 60:D2}:{System.Math.Abs(offset) % 60:D2}";
+ return $"{timestamp.Year:D4}-{timestamp.Month:D2}-{timestamp.Day:D2} {timestamp.Hour:D2}:{timestamp.Minute:D2}:{timestamp.Second:D2}.{timestamp.Centiseconds:D2}{zone}";
+ }
+
+ private static string BytesToHex(byte[] data)
+ {
+ var builder = new StringBuilder(data.Length * 2);
+ foreach (byte b in data)
+ {
+ builder.Append(b.ToString("X2"));
+ }
+
+ return builder.ToString();
+ }
+ }
+}
diff --git a/SabreTools.Wrappers/UDF.cs b/SabreTools.Wrappers/UDF.cs
new file mode 100644
index 00000000..d7ec69f0
--- /dev/null
+++ b/SabreTools.Wrappers/UDF.cs
@@ -0,0 +1,112 @@
+using System.Collections.Generic;
+using System.IO;
+using SabreTools.Data.Models.UDF;
+using SabreTools.IO;
+
+namespace SabreTools.Wrappers
+{
+ public partial class UDF : WrapperBase
+ {
+ #region Descriptive Properties
+
+ ///
+ public override string DescriptionString => "Universal Disk Format (UDF) Volume";
+
+ #endregion
+
+ #region Extension Properties
+
+ ///
+ public VolumeStructureDescriptor[] VolumeRecognitionSequence => Model.VolumeRecognitionSequence;
+
+ ///
+ public Descriptor[] VolumeDescriptorSequence => Model.VolumeDescriptorSequence;
+
+ ///
+ public FileSetDescriptor? FileSetDescriptor => Model.FileSetDescriptor;
+
+ ///
+ public Dictionary FileEntries => Model.FileEntries;
+
+ ///
+ public Dictionary Directories => Model.Directories;
+
+ #endregion
+
+ #region Constructors
+
+ ///
+ public UDF(Volume model, byte[] data) : base(model, data) { }
+
+ ///
+ public UDF(Volume model, byte[] data, int offset) : base(model, data, offset) { }
+
+ ///
+ public UDF(Volume model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
+
+ ///
+ public UDF(Volume model, Stream data) : base(model, data) { }
+
+ ///
+ public UDF(Volume model, Stream data, long offset) : base(model, data, offset) { }
+
+ ///
+ public UDF(Volume model, Stream data, long offset, long length) : base(model, data, offset, length) { }
+
+ #endregion
+
+ #region Static Constructors
+
+ ///
+ /// Create a UDF Volume from a byte array and offset
+ ///
+ /// Byte array representing the UDF Volume
+ /// Offset within the array to parse
+ /// A UDF Volume wrapper on success, null on failure
+ public static UDF? Create(byte[]? data, int offset)
+ {
+ // If the data is invalid
+ if (data is null || data.Length == 0)
+ return null;
+
+ // If the offset is out of bounds
+ if (offset < 0 || offset >= data.Length)
+ return null;
+
+ // Create a memory stream and use that
+ var dataStream = new MemoryStream(data, offset, data.Length - offset);
+ return Create(dataStream);
+ }
+
+ ///
+ /// Create a UDF Volume from a Stream
+ ///
+ /// Stream representing the UDF Volume
+ /// A UDF Volume wrapper on success, null on failure
+ public static UDF? Create(Stream? data)
+ {
+ // If the data is invalid
+ if (data is null || !data.CanRead || !data.CanSeek)
+ return null;
+
+ try
+ {
+ // Cache the current offset
+ long currentOffset = data.Position;
+
+ // The reader addresses logical sectors from the start of the stream, so it reads a view that starts here
+ var model = new Serialization.Readers.UDF().Deserialize(new ReadOnlyViewStream(data, currentOffset));
+ if (model is null)
+ return null;
+
+ return new UDF(model, data, currentOffset);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/SabreTools.Wrappers/WrapperFactory.cs b/SabreTools.Wrappers/WrapperFactory.cs
index 44cc24ec..6c72fd26 100644
--- a/SabreTools.Wrappers/WrapperFactory.cs
+++ b/SabreTools.Wrappers/WrapperFactory.cs
@@ -161,6 +161,14 @@ public static class WrapperFactory
// Reset position in stream
stream.SeekIfPossible(initialOffset, SeekOrigin.Begin);
+ // Try to get a UDF wrapper (discs without an ISO9660 file system)
+ var udfWrapper = UDF.Create(stream);
+ if (udfWrapper is not null)
+ return udfWrapper;
+
+ // Reset position in stream
+ stream.SeekIfPossible(initialOffset, SeekOrigin.Begin);
+
// Try to get a 3DO / M2 disc image wrapper
var operaFSWrapper = OperaFS.Create(stream);
if (operaFSWrapper is not null)