From 0e5380ad1a982841b038114e83ff659b3cbf239f Mon Sep 17 00:00:00 2001 From: Deterous <138427222+Deterous@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:39:29 +0900 Subject: [PATCH] Xbox ISO Support (#81) * Xbox ISO support * Fixes * Print XGDType * Review --- SabreTools.Data.Models/XDVDFS/Constants.cs | 7 + SabreTools.Data.Models/XboxISO/Constants.cs | 37 +++++ SabreTools.Data.Models/XboxISO/XboxISO.cs | 29 ++++ SabreTools.Serialization.Readers/XDVDFS.cs | 6 +- SabreTools.Wrappers.Test/XboxISOTests.cs | 60 ++++++++ SabreTools.Wrappers/WrapperFactory.cs | 19 ++- SabreTools.Wrappers/XboxISO.Extraction.cs | 27 ++++ SabreTools.Wrappers/XboxISO.Printing.cs | 49 +++++++ SabreTools.Wrappers/XboxISO.cs | 154 ++++++++++++++++++++ 9 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 SabreTools.Data.Models/XboxISO/Constants.cs create mode 100644 SabreTools.Data.Models/XboxISO/XboxISO.cs create mode 100644 SabreTools.Wrappers.Test/XboxISOTests.cs create mode 100644 SabreTools.Wrappers/XboxISO.Extraction.cs create mode 100644 SabreTools.Wrappers/XboxISO.Printing.cs create mode 100644 SabreTools.Wrappers/XboxISO.cs diff --git a/SabreTools.Data.Models/XDVDFS/Constants.cs b/SabreTools.Data.Models/XDVDFS/Constants.cs index 5ac4601e..e8dcc903 100644 --- a/SabreTools.Data.Models/XDVDFS/Constants.cs +++ b/SabreTools.Data.Models/XDVDFS/Constants.cs @@ -18,6 +18,13 @@ namespace SabreTools.Data.Models.XDVDFS /// public const int MinimumRecordLength = 14; + /// + /// Volume Descriptor magic bytes at start of sector 32 + /// + public static readonly byte[] VolumeDescriptorMagic = [0x4D, 0x49, 0x43, 0x52, 0x4F, 0x53, 0x4F, 0x46, + 0x54, 0x2A, 0x58, 0x42, 0x4F, 0x58, 0x2A, 0x4D, + 0x45, 0x44, 0x49, 0x41]; + /// /// Volume Descriptor signature at start of sector 32 /// diff --git a/SabreTools.Data.Models/XboxISO/Constants.cs b/SabreTools.Data.Models/XboxISO/Constants.cs new file mode 100644 index 00000000..f99cab0d --- /dev/null +++ b/SabreTools.Data.Models/XboxISO/Constants.cs @@ -0,0 +1,37 @@ +namespace SabreTools.Data.Models.XboxISO +{ + /// + public static class Constants + { + /// + /// Known redump ISO lengths + /// 0 = XGD1 + /// 1/2/3/4 = XGD2 + /// 5 = XGD2-Hybrid + /// 6/7 = XGD3 + /// + /// + public static readonly long[] RedumpIsoLengths = [0x1D26A8000, 0x1D3301800, 0x1D2FEF800, 0x1D3082000, + 0x1D3390000, 0x1D31A0000, 0x208E05800, 0x208E03800]; + + /// + /// Known XISO offsets into redump ISOs + /// 0 = XGD1 + /// 1 = XGD2 + /// 2 = XGD2-Hybrid + /// 3 = XGD3 + /// + /// + public static readonly long[] XisoOffsets = [0x18300000, 0xFD90000, 0x89D80000, 0x2080000]; + + /// + /// Known XISO lengths from redump ISOs + /// 0 = XGD1 + /// 1 = XGD2 + /// 2 = XGD2-Hybrid + /// 3 = XGD3 + /// + /// + public static readonly long[] XisoLengths = [0x1A2DB0000, 0x1B3880000, 0xBF8A0000, 0x204510000]; + } +} diff --git a/SabreTools.Data.Models/XboxISO/XboxISO.cs b/SabreTools.Data.Models/XboxISO/XboxISO.cs new file mode 100644 index 00000000..3c7505b0 --- /dev/null +++ b/SabreTools.Data.Models/XboxISO/XboxISO.cs @@ -0,0 +1,29 @@ +namespace SabreTools.Data.Models.XboxISO +{ + /// + /// Xbox / Xbox 360 disc image with a video partition (ISO9660) and a game partition (XDVDFS) + /// There exists zeroed gaps before/after the start/end of the game partition + /// + public class DiscImage + { + /// + /// ISO9660 Video parititon, split across start and end of Disc Image + /// + public SabreTools.Data.Models.ISO9660.Volume VideoPartition { get; set; } = new(); + + /// + /// XGD type present in game partition + /// 0 = XGD1 + /// 1 = XGD2 + /// 2 = XGD2-Hybrid + /// 3 = XGD3 + /// + /// This field is not actually present, but is an abstract representation of the offset/length of the game partition + public int XGDType { get; set; } + + /// + /// XDVDFS Game partition, present in middle of Disc Image + /// + public SabreTools.Data.Models.XDVDFS.Volume GamePartition { get; set; } = new(); + } +} diff --git a/SabreTools.Serialization.Readers/XDVDFS.cs b/SabreTools.Serialization.Readers/XDVDFS.cs index 163e2dfd..b13681bb 100644 --- a/SabreTools.Serialization.Readers/XDVDFS.cs +++ b/SabreTools.Serialization.Readers/XDVDFS.cs @@ -198,7 +198,7 @@ namespace SabreTools.Serialization.Readers data.SeekIfPossible(initialOffset + (((long)offset) * Constants.SectorSize), SeekOrigin.Begin); long curPosition; - while (size > data.Position - (((long)offset) * Constants.SectorSize)) + while (size > data.Position - (initialOffset + ((long)offset) * Constants.SectorSize)) { curPosition = data.Position; var dr = ParseDirectoryRecord(data); @@ -206,9 +206,9 @@ namespace SabreTools.Serialization.Readers records.Add(dr); // If invalid record read or next descriptor cannot fit in the current sector, skip ahead - if (dr is null || data.Position % Constants.SectorSize > (Constants.SectorSize - Constants.MinimumRecordLength)) + if (dr is null || (data.Position - initialOffset) % Constants.SectorSize > (Constants.SectorSize - Constants.MinimumRecordLength)) { - data.Position += Constants.SectorSize - (int)(data.Position % Constants.SectorSize); + data.SeekIfPossible(Constants.SectorSize - (int)((data.Position - initialOffset) % Constants.SectorSize), SeekOrigin.Current); continue; } diff --git a/SabreTools.Wrappers.Test/XboxISOTests.cs b/SabreTools.Wrappers.Test/XboxISOTests.cs new file mode 100644 index 00000000..fbc065a4 --- /dev/null +++ b/SabreTools.Wrappers.Test/XboxISOTests.cs @@ -0,0 +1,60 @@ +using System.IO; +using System.Linq; +using Xunit; + +namespace SabreTools.Wrappers.Test +{ + public class XboxISOTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var actual = XboxISO.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var actual = XboxISO.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void InvalidArray_Null() + { + byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; + int offset = 0; + var actual = XboxISO.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var actual = XboxISO.Create(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var actual = XboxISO.Create(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var actual = XboxISO.Create(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Wrappers/WrapperFactory.cs b/SabreTools.Wrappers/WrapperFactory.cs index c45ed7dc..4caa043e 100644 --- a/SabreTools.Wrappers/WrapperFactory.cs +++ b/SabreTools.Wrappers/WrapperFactory.cs @@ -33,7 +33,7 @@ namespace SabreTools.Wrappers WrapperType.InstallShieldArchiveV3 => InstallShieldArchiveV3.Create(data), WrapperType.InstallShieldCAB => InstallShieldCabinet.Create(data), WrapperType.IRD => IRD.Create(data), - WrapperType.ISO9660 => ISO9660.Create(data), + WrapperType.ISO9660 => CreateDiscImageWrapper(data), WrapperType.LDSCRYPT => LDSCRYPT.Create(data), WrapperType.LZKWAJ => LZKWAJ.Create(data), WrapperType.LZQBasic => LZQBasic.Create(data), @@ -99,13 +99,20 @@ namespace SabreTools.Wrappers // Cache the current offset long initialOffset = stream.Position; - // Try to get an ISO-9660 wrapper first - var wrapper = ISO9660.Create(stream); - if (wrapper is null || wrapper is not ISO9660 iso) + // Try to get an Xbox ISO wrapper first + var xboxWrapper = XboxISO.Create(stream); + if (xboxWrapper is not null && xboxWrapper is XboxISO xboxISO) + return xboxWrapper; + + // Reset position in stream + stream.SeekIfPossible(initialOffset, SeekOrigin.Begin); + + // Fallback to standard ISO9660 wrapper + var isoWrapper = ISO9660.Create(stream); + if (isoWrapper is null || isoWrapper is not ISO9660 iso) return null; - // TODO: Fill in the rest - return wrapper; + return isoWrapper; } /// diff --git a/SabreTools.Wrappers/XboxISO.Extraction.cs b/SabreTools.Wrappers/XboxISO.Extraction.cs new file mode 100644 index 00000000..dffe7854 --- /dev/null +++ b/SabreTools.Wrappers/XboxISO.Extraction.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using SabreTools.Data.Models.XboxISO; + +namespace SabreTools.Wrappers +{ + public partial class XboxISO : IExtractable + { + /// + public bool Extract(string outputDirectory, bool includeDebug) + { + long initialOffset = _dataSource.Position; + + // Extract all files from the video partition + var videoWrapper = new SabreTools.Wrappers.ISO9660(VideoPartition, _dataSource, initialOffset, _dataSource.Length); + bool success = videoWrapper?.Extract(outputDirectory, includeDebug) ?? false; + + // Extract all files from the game partition + var gameWrapper = new SabreTools.Wrappers.XDVDFS(GamePartition, _dataSource, initialOffset + Constants.XisoOffsets[XGDType], Constants.XisoLengths[XGDType]); + success |= gameWrapper?.Extract(outputDirectory, includeDebug) ?? false; + + return success; + } + } +} diff --git a/SabreTools.Wrappers/XboxISO.Printing.cs b/SabreTools.Wrappers/XboxISO.Printing.cs new file mode 100644 index 00000000..638ee32b --- /dev/null +++ b/SabreTools.Wrappers/XboxISO.Printing.cs @@ -0,0 +1,49 @@ +using System; +using System.Text; +using SabreTools.Data.Models.XboxISO; +using SabreTools.Text.Extensions; + +namespace SabreTools.Wrappers +{ + public partial class XboxISO : IPrintable + { +#if NETCOREAPP + /// + public string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions); +#endif + + /// + public void PrintInformation(StringBuilder builder) + { + builder.AppendLine("Xbox / Xbox 360 Disc Image Information:"); + builder.AppendLine("-------------------------"); + builder.AppendLine(); + + // Custom XGD type string + string xgdType = "XGD?"; + if (XGDType == 0) + xgdType = "XGD1 (Xbox)"; + else if (XGDType == 1) + xgdType = "XGD2 (Xbox 360)"; + else if (XGDType == 2) + xgdType = "XGD2 (Xbox 360 / DVD-Video Hybrid)"; + else if (XGDType == 3) + xgdType = "XGD3 (Xbox 360)"; + + builder.AppendLine(xgdType, "XGD Type"); + builder.AppendLine(); + + long initialOffset = _dataSource.Position; + + // Print all information of video partition model + var videoWrapper = new SabreTools.Wrappers.ISO9660(VideoPartition, _dataSource, initialOffset, _dataSource.Length); + if (videoWrapper is not null) + videoWrapper.PrintInformation(builder); + + // Print all information of game partition model + var gameWrapper = new SabreTools.Wrappers.XDVDFS(GamePartition, _dataSource, initialOffset + Constants.XisoOffsets[XGDType], Constants.XisoLengths[XGDType]); + if (gameWrapper is not null) + gameWrapper.PrintInformation(builder); + } + } +} diff --git a/SabreTools.Wrappers/XboxISO.cs b/SabreTools.Wrappers/XboxISO.cs new file mode 100644 index 00000000..638f0783 --- /dev/null +++ b/SabreTools.Wrappers/XboxISO.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.IO; +using SabreTools.Data.Models.XboxISO; +using SabreTools.IO; +using SabreTools.IO.Extensions; +using SabreTools.Matching; +using SabreTools.Numerics.Extensions; + +namespace SabreTools.Wrappers +{ + public partial class XboxISO : WrapperBase + { + #region Descriptive Properties + + /// + public override string DescriptionString => "Xbox / Xbox 360 Disc Image"; + + #endregion + + #region Extension Properties + + /// + public SabreTools.Data.Models.ISO9660.Volume VideoPartition => Model.VideoPartition; + + /// + public int XGDType => Model.XGDType; + + /// + public SabreTools.Data.Models.XDVDFS.Volume GamePartition => Model.GamePartition; + + #endregion + + #region Constructors + + /// + public XboxISO(DiscImage model, byte[] data) : base(model, data) { } + + /// + public XboxISO(DiscImage model, byte[] data, int offset) : base(model, data, offset) { } + + /// + public XboxISO(DiscImage model, byte[] data, int offset, int length) : base(model, data, offset, length) { } + + /// + public XboxISO(DiscImage model, Stream data) : base(model, data) { } + + /// + public XboxISO(DiscImage model, Stream data, long offset) : base(model, data, offset) { } + + /// + public XboxISO(DiscImage model, Stream data, long offset, long length) : base(model, data, offset, length) { } + + #endregion + + #region Static Constructors + + /// + /// Create an XboxISO DiscImage from a byte array and offset + /// + /// Byte array representing the XboxISO DiscImage + /// Offset within the array to parse + /// An XboxISO DiscImage on success, null on failure + public static XboxISO? 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 XboxISO DiscImage from a Stream + /// + /// Stream representing the XboxISO DiscImage + /// An XboxISO DiscImage DiscImage on success, null on failure + public static XboxISO? 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; + + // Create new model to fill in + var model = new DiscImage(); + + // Try to detect XDVDFS partition + int redumpType = Array.IndexOf(Constants.RedumpIsoLengths, data.Length); + if (redumpType < 0) + return null; + + // Determine location/size of game partition based on total ISO size + model.XGDType = redumpType switch + { + 0 => 0, // XGD1 + 1 or 2 or 3 or 4 => 1, // XGD2 + 5 => 2, // XGD2 (Hybrid) + 6 or 7 => 3, // XGD3 + _ => -1, + }; + + // Validate XGDType + if (model.XGDType < 0) + return null; + + // Verify that XDVDFS game parition exists + long magicOffset = Constants.XisoOffsets[model.XGDType] + Data.Models.XDVDFS.Constants.SectorSize * Data.Models.XDVDFS.Constants.ReservedSectors; + data.SeekIfPossible(currentOffset + magicOffset, SeekOrigin.Begin); + var magic = data.ReadBytes(20); + if (magic is null) + return null; + if (!magic.EqualsExactly(Data.Models.XDVDFS.Constants.VolumeDescriptorMagic)) + return null; + + // Parse the game partition first, more likely to fail + data.SeekIfPossible(currentOffset + Constants.XisoOffsets[model.XGDType], SeekOrigin.Begin); + var gamePartition = new Serialization.Readers.XDVDFS().Deserialize(data); + if (gamePartition is null) + return null; + + model.GamePartition = gamePartition; + + // Reset stream position + data.SeekIfPossible(currentOffset, SeekOrigin.Begin); + + // Parse the video partition last, more likely to pass + var videoPartition = new Serialization.Readers.ISO9660().Deserialize(data); + if (videoPartition is null) + return null; + + model.VideoPartition = videoPartition; + + return new XboxISO(model, data, currentOffset); + } + catch + { + return null; + } + } + + #endregion + } +}