Add Opera FileSystem support (#90)

* Add Opera FileSystem support

* Fix tests
This commit is contained in:
Deterous
2026-04-28 02:24:30 +09:00
committed by GitHub
parent b24f96ac0f
commit 6fa3631c80
20 changed files with 1438 additions and 7 deletions

View File

@@ -60,6 +60,6 @@ namespace SabreTools.Data.Extensions.Test
Assert.Equal(expected, actual); Assert.Equal(expected, actual);
} }
// TODO: Figure out how to add ISO9660Stream tests // TODO: Figure out how to add UserDataStream tests
} }
} }

View File

@@ -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);
}
}
}

View File

@@ -107,7 +107,7 @@ namespace SabreTools.Data.Extensions
/// <summary> /// <summary>
/// Creates a stream that provides only the user data of a CDROM stream /// Creates a stream that provides only the user data of a CDROM stream
/// </summary> /// </summary>
public class ISO9660Stream : Stream public class UserDataStream : Stream
{ {
// Base CDROM stream (2352-byte sector) // Base CDROM stream (2352-byte sector)
private readonly Stream _baseStream; private readonly Stream _baseStream;
@@ -121,7 +121,7 @@ namespace SabreTools.Data.Extensions
private long _isoSectorSize = Constants.Mode1DataSize; private long _isoSectorSize = Constants.Mode1DataSize;
#pragma warning restore IDE0044 #pragma warning restore IDE0044
public ISO9660Stream(Stream inputStream) public UserDataStream(Stream inputStream)
{ {
if (!inputStream.CanSeek || !inputStream.CanRead) if (!inputStream.CanSeek || !inputStream.CanRead)
throw new ArgumentException("Stream must be readable and seekable.", nameof(inputStream)); throw new ArgumentException("Stream must be readable and seekable.", nameof(inputStream));

View File

@@ -0,0 +1,79 @@
using SabreTools.Data.Models.OperaFS;
using SabreTools.Matching;
using SabreTools.Numerics;
namespace SabreTools.Data.Extensions
{
public static class OperaFSExtensions
{
/// <summary>
/// Compare two DirectoryDescriptor objects
/// </summary>
/// <param name="dir1">The provided DirectoryDescriptor</param>
/// <param name="dir2">The DirectoryDescriptor to compare against</param>
/// <returns>True if all fields are equal, otherwise false</returns>
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;
}
/// <summary>
/// Compare two DirectoryRecord objects
/// </summary>
/// <param name="dir1">The provided DirectoryRecord</param>
/// <param name="dir2">The DirectoryRecord to compare against</param>
/// <returns>True if all fields are equal, otherwise false</returns>
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;
}
}
}

View File

@@ -0,0 +1,23 @@
namespace SabreTools.Data.Models.OperaFS
{
/// <summary>
/// OperaFS constant values and arrays
/// </summary>
public static class Constants
{
/// <summary>
/// Standard block size for OperaFS disc images
/// </summary>
public static readonly int SectorSize = 2048;
/// <summary>
/// Start of a standard OperaFS image
/// </summary>
public static readonly byte[] MagicBytes = [0x01, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x01];
/// <summary>
/// Padding bytes within a OperaFS FileSystem, "iamaduck"
/// </summary>
public static readonly byte[] PaddingBytes = [0x69, 0x61, 0x6D, 0x61, 0x64, 0x75, 0x63, 0x6B];
}
}

View File

@@ -0,0 +1,41 @@
namespace SabreTools.Data.Models.OperaFS
{
/// <summary>
/// OperaFS Directory Descriptor
/// </summary>
/// <see href="https://groups.google.com/g/rec.games.video.3do/c/1U3qrmLSYMQ"/>
public class DirectoryDescriptor
{
/// <summary>
/// Offset of the next block
/// 0xFFFFFFFF implies this is the last block
/// </summary>
public int NextBlock { get; set; }
/// <summary>
/// Offset of the previous block
/// 0xFFFFFFFF implies this is the first block
/// </summary>
public int PreviousBlock { get; set; }
/// <summary>
/// Should be zeroed
/// </summary>
public uint Flags { get; set; }
/// <summary>
/// First free byte
/// </summary>
public uint FirstFreeByte { get; set; }
/// <summary>
/// First entry offset
/// </summary>
public uint FirstEntryOffset { get; set; }
/// <summary>
/// Directory records in this directory
/// </summary>
public DirectoryRecord[] DirectoryRecords { get; set; } = [];
}
}

View File

@@ -0,0 +1,66 @@
namespace SabreTools.Data.Models.OperaFS
{
/// <summary>
/// OperaFS Directory Record
/// </summary>
/// <see href="https://groups.google.com/g/rec.games.video.3do/c/1U3qrmLSYMQ"/>
public class DirectoryRecord
{
/// <summary>
/// Flags about this directory record
/// </summary>
public DirectoryRecordFlags DirectoryRecordFlags { get; set; } = new();
/// <summary>
/// Hash or random value to identify this record
/// </summary>
public byte[] UniqueIdentifier { get; set; } = new byte[4];
/// <summary>
/// Type of record, ASCII
/// </summary>
public byte[] Type { get; set; } = new byte[4];
/// <summary>
/// Sector size for this record
/// Should be 0x800
/// </summary>
public uint BlockSize { get; set; }
/// <summary>
/// Number of bytes in this record
/// </summary>
public uint ByteCount { get; set; }
/// <summary>
/// Number of blocks allocated to this record
/// </summary>
public uint BlockCount { get; set; }
/// <summary>
/// Burst, usually 0x1
/// </summary>
public uint Burst { get; set; }
/// <summary>
/// Gap, usually 0x0
/// </summary>
public uint Gap { get; set; }
/// <summary>
/// Filename of record
/// </summary>
public byte[] Filename { get; set; } = new byte[32];
/// <summary>
/// Number of duplicates of the file/directory provided
/// </summary>
public uint LastAvatarIndex { get; set; }
/// <summary>
/// Offset of the file/directories provided
/// Length of array is LastAvatarIndex + 1
/// </summary>
public uint[] AvatarList { get; set; } = [];
}
}

View File

@@ -0,0 +1,82 @@
using System;
namespace SabreTools.Data.Models.OperaFS
{
/// <summary>
/// Volume descriptor flag values
/// Formerly mapped 0x01 to DATA_DISC but this was not used by 3DO, so was remapped for M2 discs
/// </summary>
[Flags]
public enum VolumeFlags : byte
{
/// <summary>
/// Indicates it is a data disc, system does not need rebooting
/// Should not be set for any retail 3DO discs
/// </summary>
M1_DATA_DISC = 0x01,
/// <summary>
/// Indicates M2 compatible disc?
/// Should be set for all Konami M2 discs
/// </summary>
M2 = 0x01,
/// <summary>
/// Indicates disc is only compatible with M2 ?
/// </summary>
M2_ONLY = 0x02,
/// <summary>
/// Indicates it is a data disc, system does not need rebooting
/// </summary>
M2_DATA_DISC = 0x04,
/// <summary>
/// "Blessed" volume
/// Should be set for all retail Konami M2 discs
/// </summary>
M2_SIGNED = 0x08,
/// <summary>
/// Mask for bits that are reserved
/// </summary>
RESERVED_MASK = 0xF0,
}
/// <summary>
/// Directory record flag values
/// </summary>
[Flags]
public enum DirectoryRecordFlags : uint
{
/// <summary>
/// Record is a directory
/// </summary>
DIRECTORY = 0x01,
/// <summary>
/// Record is read-only
/// </summary>
READ_ONLY = 0x02,
/// <summary>
/// Record is for filesystem use only
/// </summary>
SYSTEM = 0x04,
/// <summary>
/// Final record in this block
/// </summary>
BLOCK_FINAL = 0x40000000,
/// <summary>
/// Final record in this directory
/// </summary>
DIRECTORY_FINAL = 0x80000000,
/// <summary>
/// Mask for bits that are reserved
/// </summary>
RESERVED_MASK = 0x3FFFFFF8,
}
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
namespace SabreTools.Data.Models.OperaFS
{
/// <summary>
/// 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
/// </summary>
/// <see href="https://groups.google.com/g/rec.games.video.3do/c/1U3qrmLSYMQ"/>
public sealed class FileSystem
{
/// <summary>
/// Volume Descriptor
/// </summary>
public VolumeDescriptor VolumeDescriptor { get; set; } = new();
/// <summary>
/// Map of all directories in filesystem, and their offsets
/// Duplicate directories exist at different offsets
/// </summary>
public Dictionary<uint, DirectoryDescriptor> Directories { get; set; } = [];
}
}

View File

@@ -0,0 +1,112 @@
namespace SabreTools.Data.Models.OperaFS
{
/// <summary>
/// OperaFS Volume Descriptor, first sector of filesystem
/// </summary>
/// <see href="https://groups.google.com/g/rec.games.video.3do/c/1U3qrmLSYMQ"/>
public class VolumeDescriptor
{
/// <summary>
/// Should be 0x01
/// </summary>
public byte RecordType { get; set; }
/// <summary>
/// "ZZZZZ"
/// </summary>
public byte[] VolumeSyncBytes { get; set; } = new byte[5];
/// <summary>
/// Should be 0x01
/// </summary>
public byte StructureVersion { get; set; }
/// <summary>
/// Should be 0x00 for all 3DO discs
/// Is used by M2 discs?
/// </summary>
public VolumeFlags VolumeFlags { get; set; }
/// <summary>
/// Usually zeroed
/// </summary>
public byte[] VolumeCommentary { get; set; } = new byte[32];
/// <summary>
/// ASCII "CD-ROM"
/// </summary>
public byte[] VolumeIdentifier { get; set; } = new byte[32];
/// <summary>
/// Hash or just a random value to identify disc
/// </summary>
public uint VolumeUniqueIdentifier { get; set; }
/// <summary>
/// Sector size in volume
/// Usually 0x800 (2048)
/// </summary>
public uint VolumeBlockSize { get; set; }
/// <summary>
/// Number of sectors in volume
/// Usually size of disc image minus 300
/// </summary>
public uint VolumeBlockCount { get; set; }
/// <summary>
/// Hash or just a random value to identify root directory
/// </summary>
public uint RootUniqueIdentifier { get; set; }
/// <summary>
/// Number of sectors for root directory
/// Usually 0x01
/// </summary>
public uint RootDirectoryBlockCount { get; set; }
/// <summary>
/// Sector size in root directory
/// Usually 0x800
/// </summary>
public uint RootDirectoryBlockSize { get; set; }
/// <summary>
/// Number of duplicates of the root directory provided
/// Should be between 0 and 7
/// </summary>
public uint RootDirectoryLastAvatarIndex { get; set; }
/// <summary>
/// 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
/// </summary>
public uint[] RootDirectoryAvatarList { get; set; } = new uint[8];
/// <summary>
/// Rom tag count
/// </summary>
/// <remarks>Extended volume data, present on M2 discs only</remarks>
public uint? RomTagCount { get; set; }
/// <summary>
/// Application ID
/// </summary>
/// <remarks>Extended volume data, present on M2 discs only</remarks>
public uint? ApplicationID { get; set; }
/// <summary>
/// 36 reserved (zeroed) bytes
/// </summary>
/// <remarks>Extended volume data, present on M2 discs only</remarks>
public byte[]? Reserved { get; set; }
/// <summary>
/// "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
/// </summary>
public byte[] Padding { get; set; } = [];
}
}

View File

@@ -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<byte>(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<byte>(0xFF, 1024)]);
var deserializer = new OperaFS();
var actual = deserializer.Deserialize(data);
Assert.Null(actual);
}
}
}

View File

@@ -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<FileSystem>
{
/// <inheritdoc cref="Deserialize(Stream?)" />
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;
}
}
/// <summary>
/// Parse a Stream into an OperaFS Volume Descriptor
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled VolumeDescriptor on success, null on error</returns>
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;
}
/// <summary>
/// Parse a Stream into an map of OperaFS directories from a volume descriptor
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled map of directories on success, null on error</returns>
public static Dictionary<uint, DirectoryDescriptor> ParseRootDirectory(Stream data, VolumeDescriptor volumeDescriptor, long initialOffset)
{
var directories = new Dictionary<uint, DirectoryDescriptor>();
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;
}
/// <summary>
/// Parse a Stream into an map of OperaFS directories from a directory
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled map of directories on success, null on error</returns>
public static Dictionary<uint, DirectoryDescriptor> ParseChildDirectories(Stream data, DirectoryDescriptor parent, long initialOffset)
{
var directories = new Dictionary<uint, DirectoryDescriptor>();
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;
}
/// <summary>
/// Parse a Stream into an OperaFS Directory
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled Directory on success, null on error</returns>
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<DirectoryRecord>();
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;
}
/// <summary>
/// Parse a Stream into an OperaFS Directory Record
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled Directory Record on success, null on error</returns>
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;
}
}
}

View File

@@ -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<byte>(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<byte>(0xFF, 1024)]);
var actual = OperaDiscImage.Create(data);
Assert.Null(actual);
}
}
}

View File

@@ -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<byte>(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<byte>(0xFF, 1024)]);
var actual = OperaFS.Create(data);
Assert.Null(actual);
}
}
}

View File

@@ -71,7 +71,7 @@ namespace SabreTools.Wrappers
try try
{ {
// Create user data sub-stream // 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 // Cache the current offset
long currentOffset = userData.Position; long currentOffset = userData.Position;

View File

@@ -0,0 +1,97 @@
using System.IO;
using SabreTools.Data.Models.OperaFS;
namespace SabreTools.Wrappers
{
public partial class OperaDiscImage : OperaFS
{
#region Descriptive Properties
/// <inheritdoc/>
public override string DescriptionString => "3DO / M2 (Opera) Disc Image";
#endregion
#region Constructors
/// <inheritdoc/>
public OperaDiscImage(FileSystem model, byte[] data) : base(model, data) { }
/// <inheritdoc/>
public OperaDiscImage(FileSystem model, byte[] data, int offset) : base(model, data, offset) { }
/// <inheritdoc/>
public OperaDiscImage(FileSystem model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
/// <inheritdoc/>
public OperaDiscImage(FileSystem model, Stream data) : base(model, data) { }
/// <inheritdoc/>
public OperaDiscImage(FileSystem model, Stream data, long offset) : base(model, data, offset) { }
/// <inheritdoc/>
public OperaDiscImage(FileSystem model, Stream data, long offset, long length) : base(model, data, offset, length) { }
#endregion
#region Static Constructors
/// <summary>
/// Create a OperaDiscImage filesystem from a byte array and offset
/// </summary>
/// <param name="data">Byte array representing the OperaDiscImage filesystem</param>
/// <param name="offset">Offset within the array to parse</param>
/// <returns>A OperaDiscImage filesystem wrapper on success, null on failure</returns>
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);
}
/// <summary>
/// Create a OperaDiscImage filesystem from a Stream
/// </summary>
/// <param name="data">Seekable Stream representing the OperaDiscImage filesystem</param>
/// <returns>A OperaDiscImage filesystem wrapper on success, null on failure</returns>
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
}
}

View File

@@ -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
/// <summary>
/// List of extracted files by their sector offset
/// </summary>
private readonly List<uint> extractedFiles = [];
/// <summary>
/// List of extracted directories
/// </summary>
private readonly List<DirectoryDescriptor> extractedDirectories = [];
#endregion
/// <inheritdoc/>
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;
}
}
}

View File

@@ -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
/// <inheritdoc/>
public string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions);
#endif
/// <summary>
/// Map of printed directories to their sector offset
/// </summary>
private readonly Dictionary<DirectoryDescriptor, uint> printedDirectories = [];
/// <inheritdoc/>
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();
}
}
}

View File

@@ -0,0 +1,101 @@
using System.Collections.Generic;
using System.IO;
using SabreTools.Data.Models.OperaFS;
namespace SabreTools.Wrappers
{
public partial class OperaFS : WrapperBase<FileSystem>
{
#region Descriptive Properties
/// <inheritdoc/>
public override string DescriptionString => "3DO / M2 (Opera) Filesystem";
#endregion
#region Extension Properties
/// <inheritdoc cref="FileSystem.VolumeDescriptor"/>
public VolumeDescriptor VolumeDescriptor => Model.VolumeDescriptor;
/// <inheritdoc cref="FileSystem.Directories"/>
public Dictionary<uint, DirectoryDescriptor> Directories => Model.Directories;
#endregion
#region Constructors
/// <inheritdoc/>
public OperaFS(FileSystem model, byte[] data) : base(model, data) { }
/// <inheritdoc/>
public OperaFS(FileSystem model, byte[] data, int offset) : base(model, data, offset) { }
/// <inheritdoc/>
public OperaFS(FileSystem model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
/// <inheritdoc/>
public OperaFS(FileSystem model, Stream data) : base(model, data) { }
/// <inheritdoc/>
public OperaFS(FileSystem model, Stream data, long offset) : base(model, data, offset) { }
/// <inheritdoc/>
public OperaFS(FileSystem model, Stream data, long offset, long length) : base(model, data, offset, length) { }
#endregion
#region Static Constructors
/// <summary>
/// Create an OperaFS FileSystem from a byte array and offset
/// </summary>
/// <param name="data">Byte array representing the OperaFS FileSystem</param>
/// <param name="offset">Offset within the array to parse</param>
/// <returns>An OperaFS FileSystem wrapper on success, null on failure</returns>
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);
}
/// <summary>
/// Create an OperaFS FileSystem from a Stream
/// </summary>
/// <param name="data">Stream representing the OperaFS FileSystem</param>
/// <returns>An OperaFS FileSystem wrapper on success, null on failure</returns>
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
}
}

View File

@@ -22,7 +22,7 @@ namespace SabreTools.Wrappers
WrapperType.BFPK => BFPK.Create(data), WrapperType.BFPK => BFPK.Create(data),
WrapperType.BSP => BSP.Create(data), WrapperType.BSP => BSP.Create(data),
WrapperType.BZip2 => BZip2.Create(data), WrapperType.BZip2 => BZip2.Create(data),
WrapperType.CDROM => CDROM.Create(data), WrapperType.CDROM => CreateCDROMImageWrapper(data),
WrapperType.CFB => CFB.Create(data), WrapperType.CFB => CFB.Create(data),
WrapperType.CHD => CHD.Create(data), WrapperType.CHD => CHD.Create(data),
WrapperType.CIA => CIA.Create(data), WrapperType.CIA => CIA.Create(data),
@@ -85,6 +85,36 @@ namespace SabreTools.Wrappers
}; };
} }
/// <summary>
/// Create an instance of a wrapper based on the CDROM image type
/// </summary>
/// <param name="stream">Stream data to parse</param>
/// <returns>IWrapper representing the CDROM image, null on error</returns>
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;
}
/// <summary> /// <summary>
/// Create an instance of a wrapper based on the disc image type /// Create an instance of a wrapper based on the disc image type
/// </summary> /// </summary>
@@ -99,7 +129,7 @@ namespace SabreTools.Wrappers
// Cache the current offset // Cache the current offset
long initialOffset = stream.Position; 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); var xboxWrapper = XboxISO.Create(stream);
if (xboxWrapper is not null) if (xboxWrapper is not null)
return xboxWrapper; return xboxWrapper;
@@ -107,6 +137,14 @@ namespace SabreTools.Wrappers
// Reset position in stream // Reset position in stream
stream.SeekIfPossible(initialOffset, SeekOrigin.Begin); 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 // Fallback to standard ISO9660 wrapper
var isoWrapper = ISO9660.Create(stream); var isoWrapper = ISO9660.Create(stream);
if (isoWrapper is null) if (isoWrapper is null)
@@ -288,7 +326,9 @@ namespace SabreTools.Wrappers
#region CDROM #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("bin", StringComparison.OrdinalIgnoreCase)
|| extension.Equals("skeleton", StringComparison.OrdinalIgnoreCase))) || extension.Equals("skeleton", StringComparison.OrdinalIgnoreCase)))
{ {