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
+ }
+}