diff --git a/SabreTools.Data.Extensions.Test/CDROMExtensionsTests.cs b/SabreTools.Data.Extensions.Test/CDROMExtensionsTests.cs index f9fb6aa1..e8269a23 100644 --- a/SabreTools.Data.Extensions.Test/CDROMExtensionsTests.cs +++ b/SabreTools.Data.Extensions.Test/CDROMExtensionsTests.cs @@ -60,6 +60,6 @@ namespace SabreTools.Data.Extensions.Test Assert.Equal(expected, actual); } - // TODO: Figure out how to add ISO9660Stream tests + // TODO: Figure out how to add UserDataStream tests } } diff --git a/SabreTools.Data.Extensions.Test/OperaFSExtensionsTests.cs b/SabreTools.Data.Extensions.Test/OperaFSExtensionsTests.cs new file mode 100644 index 00000000..64c21fa9 --- /dev/null +++ b/SabreTools.Data.Extensions.Test/OperaFSExtensionsTests.cs @@ -0,0 +1,27 @@ +using SabreTools.Data.Models.OperaFS; +using SabreTools.Numerics; +using Xunit; + +namespace SabreTools.Data.Extensions.Test +{ + public class OperaFSExtensionsTests + { + [Fact] + public void DirectoryDescriptor_EqualsExactly_Empty() + { + var dir1 = new DirectoryDescriptor(); + var dir2 = new DirectoryDescriptor(); + bool actual = dir1.EqualsExactly(dir2); + Assert.True(actual); + } + + [Fact] + public void DirectoryRecord_EqualsExactly_Empty() + { + var dr1 = new DirectoryRecord(); + var dr2 = new DirectoryRecord(); + bool actual = dr1.EqualsExactly(dr2); + Assert.True(actual); + } + } +} diff --git a/SabreTools.Data.Extensions/CDROMExtensions.cs b/SabreTools.Data.Extensions/CDROMExtensions.cs index 7b7688ee..65c45c70 100644 --- a/SabreTools.Data.Extensions/CDROMExtensions.cs +++ b/SabreTools.Data.Extensions/CDROMExtensions.cs @@ -107,7 +107,7 @@ namespace SabreTools.Data.Extensions /// /// Creates a stream that provides only the user data of a CDROM stream /// - public class ISO9660Stream : Stream + public class UserDataStream : Stream { // Base CDROM stream (2352-byte sector) private readonly Stream _baseStream; @@ -121,7 +121,7 @@ namespace SabreTools.Data.Extensions private long _isoSectorSize = Constants.Mode1DataSize; #pragma warning restore IDE0044 - public ISO9660Stream(Stream inputStream) + public UserDataStream(Stream inputStream) { if (!inputStream.CanSeek || !inputStream.CanRead) throw new ArgumentException("Stream must be readable and seekable.", nameof(inputStream)); diff --git a/SabreTools.Data.Extensions/OperaFSExtensions.cs b/SabreTools.Data.Extensions/OperaFSExtensions.cs new file mode 100644 index 00000000..1f3b147d --- /dev/null +++ b/SabreTools.Data.Extensions/OperaFSExtensions.cs @@ -0,0 +1,79 @@ +using SabreTools.Data.Models.OperaFS; +using SabreTools.Matching; +using SabreTools.Numerics; + +namespace SabreTools.Data.Extensions +{ + public static class OperaFSExtensions + { + /// + /// Compare two DirectoryDescriptor objects + /// + /// The provided DirectoryDescriptor + /// The DirectoryDescriptor to compare against + /// True if all fields are equal, otherwise false + public static bool EqualsExactly(this DirectoryDescriptor dir1, DirectoryDescriptor dir2) + { + if (dir1.NextBlock != dir2.NextBlock) + return false; + if (dir1.PreviousBlock != dir2.PreviousBlock) + return false; + if (dir1.Flags != dir2.Flags) + return false; + if (dir1.FirstFreeByte != dir2.FirstFreeByte) + return false; + if (dir1.FirstEntryOffset != dir2.FirstEntryOffset) + return false; + if (dir1.DirectoryRecords.Length != dir2.DirectoryRecords.Length) + return false; + + for (int i = 0; i < dir1.DirectoryRecords.Length; i++) + { + if (!dir1.DirectoryRecords[i].EqualsExactly(dir2.DirectoryRecords[i])) + return false; + } + + return true; + } + + /// + /// Compare two DirectoryRecord objects + /// + /// The provided DirectoryRecord + /// The DirectoryRecord to compare against + /// True if all fields are equal, otherwise false + public static bool EqualsExactly(this DirectoryRecord dr1, DirectoryRecord dr2) + { + if ((byte)dr1.DirectoryRecordFlags != (byte)dr2.DirectoryRecordFlags) + return false; + if (!dr1.UniqueIdentifier.EqualsExactly(dr2.UniqueIdentifier)) + return false; + if (!dr1.Type.EqualsExactly(dr2.Type)) + return false; + if (dr1.BlockSize != dr2.BlockSize) + return false; + if (dr1.ByteCount != dr2.ByteCount) + return false; + if (dr1.BlockCount != dr2.BlockCount) + return false; + if (dr1.Burst != dr2.Burst) + return false; + if (dr1.Gap != dr2.Gap) + return false; + if (!dr1.Filename.EqualsExactly(dr2.Filename)) + return false; + if (dr1.LastAvatarIndex != dr2.LastAvatarIndex) + return false; + if (dr1.AvatarList.Length != dr2.AvatarList.Length) + return false; + + for (int i = 0; i < dr1.AvatarList.Length; i++) + { + if (dr1.AvatarList[i] != dr2.AvatarList[i]) + return false; + } + + return true; + } + } +} diff --git a/SabreTools.Data.Models/OperaFS/Constants.cs b/SabreTools.Data.Models/OperaFS/Constants.cs new file mode 100644 index 00000000..4088504c --- /dev/null +++ b/SabreTools.Data.Models/OperaFS/Constants.cs @@ -0,0 +1,23 @@ +namespace SabreTools.Data.Models.OperaFS +{ + /// + /// OperaFS constant values and arrays + /// + public static class Constants + { + /// + /// Standard block size for OperaFS disc images + /// + public static readonly int SectorSize = 2048; + + /// + /// Start of a standard OperaFS image + /// + public static readonly byte[] MagicBytes = [0x01, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x01]; + + /// + /// Padding bytes within a OperaFS FileSystem, "iamaduck" + /// + public static readonly byte[] PaddingBytes = [0x69, 0x61, 0x6D, 0x61, 0x64, 0x75, 0x63, 0x6B]; + } +} diff --git a/SabreTools.Data.Models/OperaFS/DirectoryDescriptor.cs b/SabreTools.Data.Models/OperaFS/DirectoryDescriptor.cs new file mode 100644 index 00000000..f00b30a5 --- /dev/null +++ b/SabreTools.Data.Models/OperaFS/DirectoryDescriptor.cs @@ -0,0 +1,41 @@ +namespace SabreTools.Data.Models.OperaFS +{ + /// + /// OperaFS Directory Descriptor + /// + /// + public class DirectoryDescriptor + { + /// + /// Offset of the next block + /// 0xFFFFFFFF implies this is the last block + /// + public int NextBlock { get; set; } + + /// + /// Offset of the previous block + /// 0xFFFFFFFF implies this is the first block + /// + public int PreviousBlock { get; set; } + + /// + /// Should be zeroed + /// + public uint Flags { get; set; } + + /// + /// First free byte + /// + public uint FirstFreeByte { get; set; } + + /// + /// First entry offset + /// + public uint FirstEntryOffset { get; set; } + + /// + /// Directory records in this directory + /// + public DirectoryRecord[] DirectoryRecords { get; set; } = []; + } +} diff --git a/SabreTools.Data.Models/OperaFS/DirectoryRecord.cs b/SabreTools.Data.Models/OperaFS/DirectoryRecord.cs new file mode 100644 index 00000000..caa3eba4 --- /dev/null +++ b/SabreTools.Data.Models/OperaFS/DirectoryRecord.cs @@ -0,0 +1,66 @@ +namespace SabreTools.Data.Models.OperaFS +{ + /// + /// OperaFS Directory Record + /// + /// + public class DirectoryRecord + { + /// + /// Flags about this directory record + /// + public DirectoryRecordFlags DirectoryRecordFlags { get; set; } = new(); + + /// + /// Hash or random value to identify this record + /// + public byte[] UniqueIdentifier { get; set; } = new byte[4]; + + /// + /// Type of record, ASCII + /// + public byte[] Type { get; set; } = new byte[4]; + + /// + /// Sector size for this record + /// Should be 0x800 + /// + public uint BlockSize { get; set; } + + /// + /// Number of bytes in this record + /// + public uint ByteCount { get; set; } + + /// + /// Number of blocks allocated to this record + /// + public uint BlockCount { get; set; } + + /// + /// Burst, usually 0x1 + /// + public uint Burst { get; set; } + + /// + /// Gap, usually 0x0 + /// + public uint Gap { get; set; } + + /// + /// Filename of record + /// + public byte[] Filename { get; set; } = new byte[32]; + + /// + /// Number of duplicates of the file/directory provided + /// + public uint LastAvatarIndex { get; set; } + + /// + /// Offset of the file/directories provided + /// Length of array is LastAvatarIndex + 1 + /// + public uint[] AvatarList { get; set; } = []; + } +} diff --git a/SabreTools.Data.Models/OperaFS/Enums.cs b/SabreTools.Data.Models/OperaFS/Enums.cs new file mode 100644 index 00000000..7d549c67 --- /dev/null +++ b/SabreTools.Data.Models/OperaFS/Enums.cs @@ -0,0 +1,82 @@ +using System; + +namespace SabreTools.Data.Models.OperaFS +{ + /// + /// Volume descriptor flag values + /// Formerly mapped 0x01 to DATA_DISC but this was not used by 3DO, so was remapped for M2 discs + /// + [Flags] + public enum VolumeFlags : byte + { + /// + /// Indicates it is a data disc, system does not need rebooting + /// Should not be set for any retail 3DO discs + /// + M1_DATA_DISC = 0x01, + + /// + /// Indicates M2 compatible disc? + /// Should be set for all Konami M2 discs + /// + M2 = 0x01, + + /// + /// Indicates disc is only compatible with M2 ? + /// + M2_ONLY = 0x02, + + /// + /// Indicates it is a data disc, system does not need rebooting + /// + M2_DATA_DISC = 0x04, + + /// + /// "Blessed" volume + /// Should be set for all retail Konami M2 discs + /// + M2_SIGNED = 0x08, + + /// + /// Mask for bits that are reserved + /// + RESERVED_MASK = 0xF0, + } + + /// + /// Directory record flag values + /// + [Flags] + public enum DirectoryRecordFlags : uint + { + /// + /// Record is a directory + /// + DIRECTORY = 0x01, + + /// + /// Record is read-only + /// + READ_ONLY = 0x02, + + /// + /// Record is for filesystem use only + /// + SYSTEM = 0x04, + + /// + /// Final record in this block + /// + BLOCK_FINAL = 0x40000000, + + /// + /// Final record in this directory + /// + DIRECTORY_FINAL = 0x80000000, + + /// + /// Mask for bits that are reserved + /// + RESERVED_MASK = 0x3FFFFFF8, + } +} diff --git a/SabreTools.Data.Models/OperaFS/FileSystem.cs b/SabreTools.Data.Models/OperaFS/FileSystem.cs new file mode 100644 index 00000000..2dfc280f --- /dev/null +++ b/SabreTools.Data.Models/OperaFS/FileSystem.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace SabreTools.Data.Models.OperaFS +{ + /// + /// Opera Filesystem (or user-data-only disc image) present on 3DO and M2 discs + /// Usually contained within a CDROM disc image (2352-byte bin file) + /// All fields are Big-Endian + /// + /// + public sealed class FileSystem + { + /// + /// Volume Descriptor + /// + public VolumeDescriptor VolumeDescriptor { get; set; } = new(); + + /// + /// Map of all directories in filesystem, and their offsets + /// Duplicate directories exist at different offsets + /// + public Dictionary Directories { get; set; } = []; + } +} diff --git a/SabreTools.Data.Models/OperaFS/VolumeDescriptor.cs b/SabreTools.Data.Models/OperaFS/VolumeDescriptor.cs new file mode 100644 index 00000000..5c44d0a7 --- /dev/null +++ b/SabreTools.Data.Models/OperaFS/VolumeDescriptor.cs @@ -0,0 +1,112 @@ +namespace SabreTools.Data.Models.OperaFS +{ + /// + /// OperaFS Volume Descriptor, first sector of filesystem + /// + /// + public class VolumeDescriptor + { + /// + /// Should be 0x01 + /// + public byte RecordType { get; set; } + + /// + /// "ZZZZZ" + /// + public byte[] VolumeSyncBytes { get; set; } = new byte[5]; + + /// + /// Should be 0x01 + /// + public byte StructureVersion { get; set; } + + /// + /// Should be 0x00 for all 3DO discs + /// Is used by M2 discs? + /// + public VolumeFlags VolumeFlags { get; set; } + + /// + /// Usually zeroed + /// + public byte[] VolumeCommentary { get; set; } = new byte[32]; + + /// + /// ASCII "CD-ROM" + /// + public byte[] VolumeIdentifier { get; set; } = new byte[32]; + + /// + /// Hash or just a random value to identify disc + /// + public uint VolumeUniqueIdentifier { get; set; } + + /// + /// Sector size in volume + /// Usually 0x800 (2048) + /// + public uint VolumeBlockSize { get; set; } + + /// + /// Number of sectors in volume + /// Usually size of disc image minus 300 + /// + public uint VolumeBlockCount { get; set; } + + /// + /// Hash or just a random value to identify root directory + /// + public uint RootUniqueIdentifier { get; set; } + + /// + /// Number of sectors for root directory + /// Usually 0x01 + /// + public uint RootDirectoryBlockCount { get; set; } + + /// + /// Sector size in root directory + /// Usually 0x800 + /// + public uint RootDirectoryBlockSize { get; set; } + + /// + /// Number of duplicates of the root directory provided + /// Should be between 0 and 7 + /// + public uint RootDirectoryLastAvatarIndex { get; set; } + + /// + /// Array of 8 offsets pointing to the root directory + /// Contents of each root directory should be identical + /// If RootDirectoryLastAvatarIndex is less than 7, remaining values are zeroed + /// + public uint[] RootDirectoryAvatarList { get; set; } = new uint[8]; + + /// + /// Rom tag count + /// + /// Extended volume data, present on M2 discs only + public uint? RomTagCount { get; set; } + + /// + /// Application ID + /// + /// Extended volume data, present on M2 discs only + public uint? ApplicationID { get; set; } + + /// + /// 36 reserved (zeroed) bytes + /// + /// Extended volume data, present on M2 discs only + public byte[]? Reserved { get; set; } + + /// + /// "iamaduck" repeated, aligned to each QWORD (0x0 or 0x8) + /// i.e. if padding starts at offset ending in 0x4 or 0xC, then it begins with "duck" + /// Is 0x77C long for M1 discs, and 0x750 long for M2 discs + /// + public byte[] Padding { get; set; } = []; + } +} diff --git a/SabreTools.Serialization.Readers.Test/OperaFSTests.cs b/SabreTools.Serialization.Readers.Test/OperaFSTests.cs new file mode 100644 index 00000000..570674b3 --- /dev/null +++ b/SabreTools.Serialization.Readers.Test/OperaFSTests.cs @@ -0,0 +1,72 @@ +using System.IO; +using System.Linq; +using Xunit; + +namespace SabreTools.Serialization.Readers.Test +{ + public class OperaFSTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var deserializer = new OperaFS(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var deserializer = new OperaFS(); + + 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 OperaFS(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var deserializer = new OperaFS(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var deserializer = new OperaFS(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var deserializer = new OperaFS(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Serialization.Readers/OperaFS.cs b/SabreTools.Serialization.Readers/OperaFS.cs new file mode 100644 index 00000000..c3cbab00 --- /dev/null +++ b/SabreTools.Serialization.Readers/OperaFS.cs @@ -0,0 +1,237 @@ +using System.Collections.Generic; +using System.IO; +using SabreTools.Data.Extensions; +using SabreTools.Data.Models.OperaFS; +using SabreTools.IO.Extensions; +using SabreTools.Matching; +using SabreTools.Numerics.Extensions; + +namespace SabreTools.Serialization.Readers +{ + public class OperaFS : BaseBinaryReader + { + /// + public override FileSystem? Deserialize(Stream? data) + { + // If the data is invalid + if (data is null || !data.CanRead) + return null; + + // Simple check for a valid stream length + if (data.Length - data.Position < Constants.SectorSize) + return null; + + // Simple check on first bytes + var signature = data.PeekBytes(7); + if (!signature.EqualsExactly(Constants.MagicBytes)) + return null; + + try + { + // Cache the current offset + long initialOffset = data.Position; + + // Create a new FileSystem to fill + var volume = new FileSystem(); + + var volumeDescriptor = ParseVolumeDescriptor(data); + if (volumeDescriptor is null) + return null; + + volume.VolumeDescriptor = volumeDescriptor; + + var directories = ParseRootDirectory(data, volumeDescriptor, initialOffset); + if (directories is null) + return null; + + volume.Directories = directories; + + return volume; + } + catch + { + // Ignore the actual error + return null; + } + } + + /// + /// Parse a Stream into an OperaFS Volume Descriptor + /// + /// Stream to parse + /// Filled VolumeDescriptor on success, null on error + public static VolumeDescriptor ParseVolumeDescriptor(Stream data) + { + var volumeDescriptor = new VolumeDescriptor(); + + volumeDescriptor.RecordType = data.ReadByteValue(); + volumeDescriptor.VolumeSyncBytes = data.ReadBytes(5); + volumeDescriptor.StructureVersion = data.ReadByteValue(); + volumeDescriptor.VolumeFlags = (VolumeFlags)data.ReadByteValue(); + volumeDescriptor.VolumeCommentary = data.ReadBytes(32); + volumeDescriptor.VolumeIdentifier = data.ReadBytes(32); + volumeDescriptor.VolumeUniqueIdentifier = data.ReadUInt32BigEndian(); + volumeDescriptor.VolumeBlockSize = data.ReadUInt32BigEndian(); + volumeDescriptor.VolumeBlockCount = data.ReadUInt32BigEndian(); + volumeDescriptor.RootUniqueIdentifier = data.ReadUInt32BigEndian(); + volumeDescriptor.RootDirectoryBlockCount = data.ReadUInt32BigEndian(); + volumeDescriptor.RootDirectoryBlockSize = data.ReadUInt32BigEndian(); + volumeDescriptor.RootDirectoryLastAvatarIndex = data.ReadUInt32BigEndian(); + + for (int i = 0; i < 8; i++) + { + volumeDescriptor.RootDirectoryAvatarList[i] = data.ReadUInt32BigEndian(); + } + + if ((volumeDescriptor.VolumeFlags & VolumeFlags.M2) == VolumeFlags.M2) + { + volumeDescriptor.RomTagCount = data.ReadUInt32BigEndian(); + volumeDescriptor.ApplicationID = data.ReadUInt32BigEndian(); + volumeDescriptor.Reserved = data.ReadBytes(36); + volumeDescriptor.Padding = data.ReadBytes(0x750); + } + else + { + volumeDescriptor.Padding = data.ReadBytes(0x77C); + } + + return volumeDescriptor; + } + + /// + /// Parse a Stream into an map of OperaFS directories from a volume descriptor + /// + /// Stream to parse + /// Filled map of directories on success, null on error + public static Dictionary ParseRootDirectory(Stream data, VolumeDescriptor volumeDescriptor, long initialOffset) + { + var directories = new Dictionary(); + + data.SeekIfPossible(initialOffset + volumeDescriptor.RootDirectoryAvatarList[0] * Constants.SectorSize, SeekOrigin.Begin); + var rootDirectory = ParseDirectory(data); + for (int i = 0; i <= volumeDescriptor.RootDirectoryLastAvatarIndex; i++) + { + directories.Add(volumeDescriptor.RootDirectoryAvatarList[i], rootDirectory); + } + + var childDirectories = ParseChildDirectories(data, rootDirectory, initialOffset); + foreach (var kvp in childDirectories) + { + if (!directories.ContainsKey(kvp.Key)) + directories.Add(kvp.Key, kvp.Value); + } + + return directories; + } + + /// + /// Parse a Stream into an map of OperaFS directories from a directory + /// + /// Stream to parse + /// Filled map of directories on success, null on error + public static Dictionary ParseChildDirectories(Stream data, DirectoryDescriptor parent, long initialOffset) + { + var directories = new Dictionary(); + + foreach (var dr in parent.DirectoryRecords) + { + // Skip file records + if ((dr.DirectoryRecordFlags & DirectoryRecordFlags.DIRECTORY) == 0) + continue; + + data.SeekIfPossible(initialOffset + dr.AvatarList[0] * Constants.SectorSize, SeekOrigin.Begin); + var directory = ParseDirectory(data); + directories.Add(dr.AvatarList[0], directory); + for (int i = 1; i <= dr.LastAvatarIndex; i++) + { + // Read avatar + data.SeekIfPossible(initialOffset + dr.AvatarList[i] * Constants.SectorSize, SeekOrigin.Begin); + var avatar = ParseDirectory(data); + + // Add reference to original directory if avatar is identical + // TODO: Check equality of all previously added unique avatars too + if (avatar.EqualsExactly(directory)) + directories.Add(dr.AvatarList[i], directory); + else + directories.Add(dr.AvatarList[i], avatar); + } + + var childDirectories = ParseChildDirectories(data, directory, initialOffset); + foreach (var kvp in childDirectories) + { + if (!directories.ContainsKey(kvp.Key)) + directories.Add(kvp.Key, kvp.Value); + } + } + + return directories; + } + + /// + /// Parse a Stream into an OperaFS Directory + /// + /// Stream to parse + /// Filled Directory on success, null on error + public static DirectoryDescriptor ParseDirectory(Stream data) + { + var directory = new DirectoryDescriptor(); + + directory.NextBlock = data.ReadInt32BigEndian(); + directory.PreviousBlock = data.ReadInt32BigEndian(); + directory.Flags = data.ReadUInt32BigEndian(); + directory.FirstFreeByte = data.ReadUInt32BigEndian(); + directory.FirstEntryOffset = data.ReadUInt32BigEndian(); + + var directoryRecords = new List(); + + long startPosition = data.Position; + while (data.Position < startPosition + directory.FirstFreeByte) + { + var directoryRecord = ParseDirectoryRecord(data); + directoryRecords.Add(directoryRecord); + + if ((directoryRecord.DirectoryRecordFlags & DirectoryRecordFlags.DIRECTORY_FINAL) != 0) + break; + + if ((directoryRecord.DirectoryRecordFlags & DirectoryRecordFlags.BLOCK_FINAL) != 0) + { + long nextBlock = Constants.SectorSize - ((data.Position - startPosition) % Constants.SectorSize); + data.SeekIfPossible(nextBlock, SeekOrigin.Current); + } + } + + directory.DirectoryRecords = directoryRecords.ToArray(); + + return directory; + } + + /// + /// Parse a Stream into an OperaFS Directory Record + /// + /// Stream to parse + /// Filled Directory Record on success, null on error + public static DirectoryRecord ParseDirectoryRecord(Stream data) + { + var directoryRecord = new DirectoryRecord(); + + directoryRecord.DirectoryRecordFlags = (DirectoryRecordFlags)data.ReadUInt32BigEndian(); + directoryRecord.UniqueIdentifier = data.ReadBytes(4); + directoryRecord.Type = data.ReadBytes(4); + directoryRecord.BlockSize = data.ReadUInt32BigEndian(); + directoryRecord.ByteCount = data.ReadUInt32BigEndian(); + directoryRecord.BlockCount = data.ReadUInt32BigEndian(); + directoryRecord.Burst = data.ReadUInt32BigEndian(); + directoryRecord.Gap = data.ReadUInt32BigEndian(); + directoryRecord.Filename = data.ReadBytes(32); + directoryRecord.LastAvatarIndex = data.ReadUInt32BigEndian(); + + directoryRecord.AvatarList = new uint[directoryRecord.LastAvatarIndex + 1]; + for (int i = 0; i <= directoryRecord.LastAvatarIndex; i++) + { + directoryRecord.AvatarList[i] = data.ReadUInt32BigEndian(); + } + + return directoryRecord; + } + } +} diff --git a/SabreTools.Wrappers.Test/OperaDiscImageTests.cs b/SabreTools.Wrappers.Test/OperaDiscImageTests.cs new file mode 100644 index 00000000..41ce6fc9 --- /dev/null +++ b/SabreTools.Wrappers.Test/OperaDiscImageTests.cs @@ -0,0 +1,60 @@ +using System.IO; +using System.Linq; +using Xunit; + +namespace SabreTools.Wrappers.Test +{ + public class OperaDiscImageTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var actual = OperaDiscImage.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var actual = OperaDiscImage.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void InvalidArray_Null() + { + byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; + int offset = 0; + var actual = OperaDiscImage.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var actual = OperaDiscImage.Create(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var actual = OperaDiscImage.Create(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var actual = OperaDiscImage.Create(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Wrappers.Test/OperaFSTests.cs b/SabreTools.Wrappers.Test/OperaFSTests.cs new file mode 100644 index 00000000..fc5cdc64 --- /dev/null +++ b/SabreTools.Wrappers.Test/OperaFSTests.cs @@ -0,0 +1,60 @@ +using System.IO; +using System.Linq; +using Xunit; + +namespace SabreTools.Wrappers.Test +{ + public class OperaFSTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var actual = OperaFS.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var actual = OperaFS.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void InvalidArray_Null() + { + byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; + int offset = 0; + var actual = OperaFS.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var actual = OperaFS.Create(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var actual = OperaFS.Create(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var actual = OperaFS.Create(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Wrappers/CDROM.cs b/SabreTools.Wrappers/CDROM.cs index 73eba9c5..1ec5bdd6 100644 --- a/SabreTools.Wrappers/CDROM.cs +++ b/SabreTools.Wrappers/CDROM.cs @@ -71,7 +71,7 @@ namespace SabreTools.Wrappers try { // Create user data sub-stream - var userData = new Data.Extensions.CDROMExtensions.ISO9660Stream(data); + var userData = new Data.Extensions.CDROMExtensions.UserDataStream(data); // Cache the current offset long currentOffset = userData.Position; diff --git a/SabreTools.Wrappers/OperaDiscImage.cs b/SabreTools.Wrappers/OperaDiscImage.cs new file mode 100644 index 00000000..1f01e12f --- /dev/null +++ b/SabreTools.Wrappers/OperaDiscImage.cs @@ -0,0 +1,97 @@ +using System.IO; +using SabreTools.Data.Models.OperaFS; + +namespace SabreTools.Wrappers +{ + public partial class OperaDiscImage : OperaFS + { + #region Descriptive Properties + + /// + public override string DescriptionString => "3DO / M2 (Opera) Disc Image"; + + #endregion + + #region Constructors + + /// + public OperaDiscImage(FileSystem model, byte[] data) : base(model, data) { } + + /// + public OperaDiscImage(FileSystem model, byte[] data, int offset) : base(model, data, offset) { } + + /// + public OperaDiscImage(FileSystem model, byte[] data, int offset, int length) : base(model, data, offset, length) { } + + /// + public OperaDiscImage(FileSystem model, Stream data) : base(model, data) { } + + /// + public OperaDiscImage(FileSystem model, Stream data, long offset) : base(model, data, offset) { } + + /// + public OperaDiscImage(FileSystem model, Stream data, long offset, long length) : base(model, data, offset, length) { } + + #endregion + + #region Static Constructors + + /// + /// Create a OperaDiscImage filesystem from a byte array and offset + /// + /// Byte array representing the OperaDiscImage filesystem + /// Offset within the array to parse + /// A OperaDiscImage filesystem wrapper on success, null on failure + public static new OperaDiscImage? 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 OperaDiscImage filesystem from a Stream + /// + /// Seekable Stream representing the OperaDiscImage filesystem + /// A OperaDiscImage filesystem wrapper on success, null on failure + public static new OperaDiscImage? Create(Stream? data) + { + // If the data is invalid + if (data is null || !data.CanRead || !data.CanSeek) + return null; + + try + { + // Create user data sub-stream + var userData = new Data.Extensions.CDROMExtensions.UserDataStream(data); + + // Cache the current offset + long currentOffset = userData.Position; + + // Deserialize just the sub-stream + var model = new Serialization.Readers.OperaFS().Deserialize(userData); + if (model is null) + return null; + + // Reset stream + userData.Seek(currentOffset, SeekOrigin.Begin); + + return new OperaDiscImage(model, userData, userData.Position); + } + catch + { + return null; + } + } + + #endregion + } +} diff --git a/SabreTools.Wrappers/OperaFS.Extraction.cs b/SabreTools.Wrappers/OperaFS.Extraction.cs new file mode 100644 index 00000000..451d2e1a --- /dev/null +++ b/SabreTools.Wrappers/OperaFS.Extraction.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using SabreTools.Data.Extensions; +using SabreTools.Data.Models.OperaFS; +using SabreTools.IO.Extensions; +using SabreTools.Matching; +using SabreTools.Numerics.Extensions; + +namespace SabreTools.Wrappers +{ + public partial class OperaFS : IExtractable + { + #region Extraction State + + /// + /// List of extracted files by their sector offset + /// + private readonly List extractedFiles = []; + + /// + /// List of extracted directories + /// + private readonly List extractedDirectories = []; + + #endregion + + /// + public virtual bool Extract(string outputDirectory, bool includeDebug) + { + // Clear the extraction state + extractedFiles.Clear(); + extractedDirectories.Clear(); + + bool allExtracted = true; + + for (int i = 0; i <= VolumeDescriptor.RootDirectoryLastAvatarIndex; i++) + { + var rootDirectory = Directories[VolumeDescriptor.RootDirectoryAvatarList[i]]; + if (extractedDirectories.Contains(rootDirectory)) + { + if (includeDebug) Console.WriteLine($"Root directory duplicate at sector {VolumeDescriptor.RootDirectoryAvatarList[i]}"); + continue; + } + + if (includeDebug) Console.WriteLine($"Extracting from root directory at sector {VolumeDescriptor.RootDirectoryAvatarList[i]}"); + allExtracted |= ExtractDirectory(outputDirectory, includeDebug, rootDirectory); + extractedDirectories.Add(rootDirectory); + } + + return allExtracted; + } + + public bool ExtractDirectory(string outputDirectory, bool includeDebug, DirectoryDescriptor dir) + { + // Create output directory if it does not exist + if (!string.IsNullOrEmpty(outputDirectory) && !Directory.Exists(outputDirectory)) + Directory.CreateDirectory(outputDirectory); + + bool allExtracted = true; + foreach (var dr in dir.DirectoryRecords) + { + var filename = Encoding.UTF8.GetString(dr.Filename).TrimEnd('\0'); + if ((dr.DirectoryRecordFlags & DirectoryRecordFlags.DIRECTORY) == 0) + { + // Skip filesystem only files (e.g. Volume Descriptor "Disc Label") + if ((dr.DirectoryRecordFlags & DirectoryRecordFlags.SYSTEM) != 0) + { + if (includeDebug) Console.WriteLine($"Skipping filesystem object {filename}"); + continue; + } + + var filePath = Path.Combine(outputDirectory, filename); + for (int i = 0; i <= dr.LastAvatarIndex; i++) + { + uint fileOffset = (uint)Constants.SectorSize * dr.AvatarList[i]; + + // TODO: Check that all avatars are identical? + if (extractedFiles.Contains(fileOffset)) + { + if (includeDebug) Console.WriteLine($"File duplicate at sector {dr.AvatarList[i]}"); + continue; + } + + try + { + if (File.Exists(filePath)) + continue; + + const uint chunkSize = 2048 * 1024; + lock (_dataSourceLock) + { + _dataSource.SeekIfPossible(fileOffset, SeekOrigin.Begin); + + // Get the length, and make sure it won't EOF + uint length = dr.ByteCount; + if (length > _dataSource.Length - _dataSource.Position) + { + allExtracted = false; + continue; + } + + // Write the output file + if (includeDebug) Console.WriteLine($"Extracting file {filename} at sector {dr.AvatarList[i]}"); + using var fs = File.Open(filePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); + while (length > 0) + { + int bytesToRead = (int)Math.Min(length, chunkSize); + + byte[] buffer = _dataSource.ReadBytes(bytesToRead); + fs.Write(buffer, 0, bytesToRead); + fs.Flush(); + + length -= (uint)bytesToRead; + } + } + + extractedFiles.Add(fileOffset); + } + catch + { + allExtracted = false; + } + } + } + else + { + // Iterate over all avatars, in case they're not identical + for (int i = 0; i <= dr.LastAvatarIndex; i++) + { + // Check whether directory is already extracted + var childDir = Directories[dr.AvatarList[i]]; + if (extractedDirectories.Contains(childDir)) + { + if (includeDebug) Console.WriteLine($"Directory duplicate at sector {dr.AvatarList[i]}"); + continue; + } + + try + { + if (includeDebug) Console.WriteLine($"Extracting directory {filename} at sector {dr.AvatarList[i]}"); + var outputPath = Path.Combine(outputDirectory, filename); + allExtracted |= ExtractDirectory(outputPath, includeDebug, childDir); + extractedDirectories.Add(childDir); + } + catch + { + allExtracted = false; + break; + } + } + } + } + + return allExtracted; + } + } +} diff --git a/SabreTools.Wrappers/OperaFS.Printing.cs b/SabreTools.Wrappers/OperaFS.Printing.cs new file mode 100644 index 00000000..7b28dfa9 --- /dev/null +++ b/SabreTools.Wrappers/OperaFS.Printing.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Text; +using SabreTools.Data.Models.OperaFS; +using SabreTools.Text.Extensions; + +namespace SabreTools.Wrappers +{ + public partial class OperaFS : IPrintable + { +#if NETCOREAPP + /// + public string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions); +#endif + + /// + /// Map of printed directories to their sector offset + /// + private readonly Dictionary printedDirectories = []; + + /// + public void PrintInformation(StringBuilder builder) + { + builder.AppendLine("3DO / M2 (Opera) Filesystem Information:"); + builder.AppendLine("-------------------------"); + builder.AppendLine(); + + Print(builder, VolumeDescriptor); + foreach (var kvp in Directories) + { + if (printedDirectories.ContainsKey(kvp.Value)) + { + builder.AppendLine($" Directory Descriptor (Sector {kvp.Key}):"); + builder.AppendLine(" -------------------------"); + builder.AppendLine($" Duplicate of Descriptor at Sector {printedDirectories[kvp.Value]}"); + builder.AppendLine(); + continue; + } + + Print(builder, kvp.Key, kvp.Value); + printedDirectories.Add(kvp.Value, kvp.Key); + } + } + + internal static void Print(StringBuilder builder, VolumeDescriptor vd) + { + builder.AppendLine(" Volume Descriptor:"); + builder.AppendLine(" -------------------------"); + + builder.AppendLine(vd.RecordType, " Record Type"); + builder.AppendLine(vd.VolumeSyncBytes, " Volume Sync Bytes"); + builder.AppendLine(vd.StructureVersion, " Structure Version"); + + builder.AppendLine((byte)vd.VolumeFlags, " Volume Flags"); + if ((byte)vd.VolumeFlags != 0) + { + builder.AppendLine(" Volume Flags (Parsed)"); + builder.AppendLine((vd.VolumeFlags & VolumeFlags.M2) == VolumeFlags.M2, " M2 Disc (or M1 Data Disc)"); + builder.AppendLine((vd.VolumeFlags & VolumeFlags.M2_ONLY) == VolumeFlags.M2_ONLY, " M2 Only"); + builder.AppendLine((vd.VolumeFlags & VolumeFlags.M2_DATA_DISC) == VolumeFlags.M2_DATA_DISC, " M2 Data Disc"); + builder.AppendLine((vd.VolumeFlags & VolumeFlags.M2_SIGNED) == VolumeFlags.M2_SIGNED, " M2 Signed"); + builder.AppendLine((vd.VolumeFlags & VolumeFlags.RESERVED_MASK) != 0, " Reserved Bits Set"); + } + + builder.AppendLine(Encoding.UTF8.GetString(vd.VolumeCommentary), " Volume Commentary"); + builder.AppendLine(Encoding.UTF8.GetString(vd.VolumeIdentifier), " Volume Identifier"); + builder.AppendLine(vd.VolumeUniqueIdentifier, " Volume Unique Identifier"); + builder.AppendLine(vd.VolumeBlockSize, " Volume Block Size"); + builder.AppendLine(vd.VolumeBlockCount, " Volume Block Count"); + builder.AppendLine(vd.RootUniqueIdentifier, " Root Unique Identifier"); + builder.AppendLine(vd.RootDirectoryBlockCount, " Root Directory Block Count"); + builder.AppendLine(vd.RootDirectoryBlockSize, " Root Directory Block Size"); + builder.AppendLine(vd.RootDirectoryLastAvatarIndex, " Root Directory Last Avatar Index"); + builder.AppendLine(vd.RootDirectoryAvatarList, " Root Directory Avatar List"); + + if ((vd.VolumeFlags & VolumeFlags.M2) == VolumeFlags.M2) + { + builder.AppendLine(vd.RomTagCount, " Rom Tag Count"); + builder.AppendLine(vd.ApplicationID, " Application ID"); + if (vd.Reserved is null) + builder.AppendLine(vd.Reserved, " Reserved Bytes"); + else if (Array.TrueForAll(vd.Reserved, b => b == 0)) + builder.AppendLine($"Zeroed", " Reserved Bytes"); + else + builder.AppendLine($"Not Zeroed", " Reserved Bytes"); + } + + int offset = Array.IndexOf(Constants.PaddingBytes, vd.Padding[0]); + int index = 0; + bool isDuck = (offset >= 0) && Array.TrueForAll(vd.Padding, b => b == Constants.PaddingBytes[(index++ + offset) % Constants.PaddingBytes.Length]); + if (isDuck) + builder.AppendLine("Expected data (iamaduck)", " Padding"); + else + builder.AppendLine("Unexpected data", " Padding"); + + builder.AppendLine(); + } + + internal static void Print(StringBuilder builder, uint sector, DirectoryDescriptor dir) + { + builder.AppendLine($" Directory Descriptor (Sector {sector}):"); + builder.AppendLine(" -------------------------"); + + builder.AppendLine(dir.NextBlock, " Next Block"); + builder.AppendLine(dir.PreviousBlock, " Previous Block"); + builder.AppendLine(dir.Flags, " Flags"); + builder.AppendLine(dir.FirstFreeByte, " First Free Byte"); + builder.AppendLine(dir.FirstEntryOffset, " First Entry Offset"); + + foreach (var dr in dir.DirectoryRecords) + { + Print(builder, dr); + } + + builder.AppendLine(); + } + + internal static void Print(StringBuilder builder, DirectoryRecord dr) + { + builder.AppendLine(" Directory Record:"); + builder.AppendLine(" -------------------------"); + + builder.AppendLine((byte)dr.DirectoryRecordFlags, " Directory Record Flags"); + if ((byte)dr.DirectoryRecordFlags != 0) + { + builder.AppendLine(" Directory Record Flags (Parsed)"); + builder.AppendLine((dr.DirectoryRecordFlags & DirectoryRecordFlags.DIRECTORY) == DirectoryRecordFlags.DIRECTORY, " Directory"); + builder.AppendLine((dr.DirectoryRecordFlags & DirectoryRecordFlags.READ_ONLY) == DirectoryRecordFlags.READ_ONLY, " Read-only"); + builder.AppendLine((dr.DirectoryRecordFlags & DirectoryRecordFlags.SYSTEM) == DirectoryRecordFlags.SYSTEM, " Filesystem Use"); + builder.AppendLine((dr.DirectoryRecordFlags & DirectoryRecordFlags.BLOCK_FINAL) == DirectoryRecordFlags.BLOCK_FINAL, " Final in Block"); + builder.AppendLine((dr.DirectoryRecordFlags & DirectoryRecordFlags.DIRECTORY_FINAL) == DirectoryRecordFlags.DIRECTORY_FINAL, " Final in Directory"); + builder.AppendLine((dr.DirectoryRecordFlags & DirectoryRecordFlags.RESERVED_MASK) != 0, " Reserved Bits Set"); + } + + builder.AppendLine(dr.UniqueIdentifier, " Unique Identifier"); + builder.AppendLine(dr.Type, " Type"); + builder.AppendLine(Encoding.UTF8.GetString(dr.Type), " Type (Parsed)"); + builder.AppendLine(dr.BlockSize, " BlockSize"); + builder.AppendLine(dr.ByteCount, " ByteCount"); + builder.AppendLine(dr.BlockCount, " BlockCount"); + builder.AppendLine(dr.Burst, " Burst"); + builder.AppendLine(dr.Gap, " Gap"); + builder.AppendLine(dr.Filename, " Filename"); + builder.AppendLine(Encoding.UTF8.GetString(dr.Filename), " Filename (Parsed)"); + builder.AppendLine(dr.LastAvatarIndex, " LastAvatarIndex"); + builder.AppendLine(dr.AvatarList, " AvatarList"); + + builder.AppendLine(); + } + } +} diff --git a/SabreTools.Wrappers/OperaFS.cs b/SabreTools.Wrappers/OperaFS.cs new file mode 100644 index 00000000..097c393c --- /dev/null +++ b/SabreTools.Wrappers/OperaFS.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using System.IO; +using SabreTools.Data.Models.OperaFS; + +namespace SabreTools.Wrappers +{ + public partial class OperaFS : WrapperBase + { + #region Descriptive Properties + + /// + public override string DescriptionString => "3DO / M2 (Opera) Filesystem"; + + #endregion + + #region Extension Properties + + /// + public VolumeDescriptor VolumeDescriptor => Model.VolumeDescriptor; + + /// + public Dictionary Directories => Model.Directories; + + #endregion + + #region Constructors + + /// + public OperaFS(FileSystem model, byte[] data) : base(model, data) { } + + /// + public OperaFS(FileSystem model, byte[] data, int offset) : base(model, data, offset) { } + + /// + public OperaFS(FileSystem model, byte[] data, int offset, int length) : base(model, data, offset, length) { } + + /// + public OperaFS(FileSystem model, Stream data) : base(model, data) { } + + /// + public OperaFS(FileSystem model, Stream data, long offset) : base(model, data, offset) { } + + /// + public OperaFS(FileSystem model, Stream data, long offset, long length) : base(model, data, offset, length) { } + + #endregion + + #region Static Constructors + + /// + /// Create an OperaFS FileSystem from a byte array and offset + /// + /// Byte array representing the OperaFS FileSystem + /// Offset within the array to parse + /// An OperaFS FileSystem wrapper on success, null on failure + public static OperaFS? 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 an OperaFS FileSystem from a Stream + /// + /// Stream representing the OperaFS FileSystem + /// An OperaFS FileSystem wrapper on success, null on failure + public static OperaFS? Create(Stream? data) + { + // If the data is invalid + if (data is null || !data.CanRead) + return null; + + try + { + // Cache the current offset + long currentOffset = data.Position; + + var model = new Serialization.Readers.OperaFS().Deserialize(data); + if (model is null) + return null; + + return new OperaFS(model, data, currentOffset); + } + catch + { + return null; + } + } + + #endregion + } +} diff --git a/SabreTools.Wrappers/WrapperFactory.cs b/SabreTools.Wrappers/WrapperFactory.cs index febda9e8..e4015d42 100644 --- a/SabreTools.Wrappers/WrapperFactory.cs +++ b/SabreTools.Wrappers/WrapperFactory.cs @@ -22,7 +22,7 @@ namespace SabreTools.Wrappers WrapperType.BFPK => BFPK.Create(data), WrapperType.BSP => BSP.Create(data), WrapperType.BZip2 => BZip2.Create(data), - WrapperType.CDROM => CDROM.Create(data), + WrapperType.CDROM => CreateCDROMImageWrapper(data), WrapperType.CFB => CFB.Create(data), WrapperType.CHD => CHD.Create(data), WrapperType.CIA => CIA.Create(data), @@ -85,6 +85,36 @@ namespace SabreTools.Wrappers }; } + /// + /// Create an instance of a wrapper based on the CDROM image type + /// + /// Stream data to parse + /// IWrapper representing the CDROM image, null on error + public static IWrapper? CreateCDROMImageWrapper(Stream? stream) + { + // If we have no stream + if (stream is null) + return null; + + // Cache the current offset + long initialOffset = stream.Position; + + // Try to get a 3DO / M2 disc image wrapper + var operaDiscImageWrapper = OperaDiscImage.Create(stream); + if (operaDiscImageWrapper is not null) + return operaDiscImageWrapper; + + // Reset position in stream + stream.SeekIfPossible(initialOffset, SeekOrigin.Begin); + + // Fallback to standard CDROM wrapper + var cdromWrapper = CDROM.Create(stream); + if (cdromWrapper is null) + return null; + + return cdromWrapper; + } + /// /// Create an instance of a wrapper based on the disc image type /// @@ -99,7 +129,7 @@ namespace SabreTools.Wrappers // Cache the current offset long initialOffset = stream.Position; - // Try to get an Xbox ISO wrapper first + // Try to get an Xbox disc image wrapper var xboxWrapper = XboxISO.Create(stream); if (xboxWrapper is not null) return xboxWrapper; @@ -107,6 +137,14 @@ namespace SabreTools.Wrappers // 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) + return operaFSWrapper; + + // Reset position in stream + stream.SeekIfPossible(initialOffset, SeekOrigin.Begin); + // Fallback to standard ISO9660 wrapper var isoWrapper = ISO9660.Create(stream); if (isoWrapper is null) @@ -288,7 +326,9 @@ namespace SabreTools.Wrappers #region CDROM - if (magic.StartsWith([0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) + // Must come before skeleton extension check for ISO9660 + // CDROM skeletons have same extension as ISO9660 skeletons + if (magic.StartsWith([0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00]) && (extension.Equals("bin", StringComparison.OrdinalIgnoreCase) || extension.Equals("skeleton", StringComparison.OrdinalIgnoreCase))) {