From 6fa3631c8025d4a7f8501abcaac98c3a0f4db253 Mon Sep 17 00:00:00 2001
From: Deterous <138427222+Deterous@users.noreply.github.com>
Date: Tue, 28 Apr 2026 02:24:30 +0900
Subject: [PATCH] Add Opera FileSystem support (#90)
* Add Opera FileSystem support
* Fix tests
---
.../CDROMExtensionsTests.cs | 2 +-
.../OperaFSExtensionsTests.cs | 27 ++
SabreTools.Data.Extensions/CDROMExtensions.cs | 4 +-
.../OperaFSExtensions.cs | 79 ++++++
SabreTools.Data.Models/OperaFS/Constants.cs | 23 ++
.../OperaFS/DirectoryDescriptor.cs | 41 +++
.../OperaFS/DirectoryRecord.cs | 66 +++++
SabreTools.Data.Models/OperaFS/Enums.cs | 82 ++++++
SabreTools.Data.Models/OperaFS/FileSystem.cs | 24 ++
.../OperaFS/VolumeDescriptor.cs | 112 +++++++++
.../OperaFSTests.cs | 72 ++++++
SabreTools.Serialization.Readers/OperaFS.cs | 237 ++++++++++++++++++
.../OperaDiscImageTests.cs | 60 +++++
SabreTools.Wrappers.Test/OperaFSTests.cs | 60 +++++
SabreTools.Wrappers/CDROM.cs | 2 +-
SabreTools.Wrappers/OperaDiscImage.cs | 97 +++++++
SabreTools.Wrappers/OperaFS.Extraction.cs | 159 ++++++++++++
SabreTools.Wrappers/OperaFS.Printing.cs | 151 +++++++++++
SabreTools.Wrappers/OperaFS.cs | 101 ++++++++
SabreTools.Wrappers/WrapperFactory.cs | 46 +++-
20 files changed, 1438 insertions(+), 7 deletions(-)
create mode 100644 SabreTools.Data.Extensions.Test/OperaFSExtensionsTests.cs
create mode 100644 SabreTools.Data.Extensions/OperaFSExtensions.cs
create mode 100644 SabreTools.Data.Models/OperaFS/Constants.cs
create mode 100644 SabreTools.Data.Models/OperaFS/DirectoryDescriptor.cs
create mode 100644 SabreTools.Data.Models/OperaFS/DirectoryRecord.cs
create mode 100644 SabreTools.Data.Models/OperaFS/Enums.cs
create mode 100644 SabreTools.Data.Models/OperaFS/FileSystem.cs
create mode 100644 SabreTools.Data.Models/OperaFS/VolumeDescriptor.cs
create mode 100644 SabreTools.Serialization.Readers.Test/OperaFSTests.cs
create mode 100644 SabreTools.Serialization.Readers/OperaFS.cs
create mode 100644 SabreTools.Wrappers.Test/OperaDiscImageTests.cs
create mode 100644 SabreTools.Wrappers.Test/OperaFSTests.cs
create mode 100644 SabreTools.Wrappers/OperaDiscImage.cs
create mode 100644 SabreTools.Wrappers/OperaFS.Extraction.cs
create mode 100644 SabreTools.Wrappers/OperaFS.Printing.cs
create mode 100644 SabreTools.Wrappers/OperaFS.cs
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)))
{