Xbox ISO Support (#81)

* Xbox ISO support

* Fixes

* Print XGDType

* Review
This commit is contained in:
Deterous
2026-04-13 22:39:29 +09:00
committed by GitHub
parent 2494b44647
commit 0e5380ad1a
9 changed files with 379 additions and 9 deletions

View File

@@ -18,6 +18,13 @@ namespace SabreTools.Data.Models.XDVDFS
/// </summary>
public const int MinimumRecordLength = 14;
/// <summary>
/// Volume Descriptor magic bytes at start of sector 32
/// </summary>
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];
/// <summary>
/// Volume Descriptor signature at start of sector 32
/// </summary>

View File

@@ -0,0 +1,37 @@
namespace SabreTools.Data.Models.XboxISO
{
/// <see href="https://github.dev/Deterous/XboxKit/"/>
public static class Constants
{
/// <summary>
/// Known redump ISO lengths
/// 0 = XGD1
/// 1/2/3/4 = XGD2
/// 5 = XGD2-Hybrid
/// 6/7 = XGD3
/// </summary>
/// <see href="https://github.dev/Deterous/XboxKit/"/>
public static readonly long[] RedumpIsoLengths = [0x1D26A8000, 0x1D3301800, 0x1D2FEF800, 0x1D3082000,
0x1D3390000, 0x1D31A0000, 0x208E05800, 0x208E03800];
/// <summary>
/// Known XISO offsets into redump ISOs
/// 0 = XGD1
/// 1 = XGD2
/// 2 = XGD2-Hybrid
/// 3 = XGD3
/// </summary>
/// <see href="https://github.dev/Deterous/XboxKit/"/>
public static readonly long[] XisoOffsets = [0x18300000, 0xFD90000, 0x89D80000, 0x2080000];
/// <summary>
/// Known XISO lengths from redump ISOs
/// 0 = XGD1
/// 1 = XGD2
/// 2 = XGD2-Hybrid
/// 3 = XGD3
/// </summary>
/// <see href="https://github.dev/Deterous/XboxKit/"/>
public static readonly long[] XisoLengths = [0x1A2DB0000, 0x1B3880000, 0xBF8A0000, 0x204510000];
}
}

View File

@@ -0,0 +1,29 @@
namespace SabreTools.Data.Models.XboxISO
{
/// <summary>
/// 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
/// </summary>
public class DiscImage
{
/// <summary>
/// ISO9660 Video parititon, split across start and end of Disc Image
/// </summary>
public SabreTools.Data.Models.ISO9660.Volume VideoPartition { get; set; } = new();
/// <summary>
/// XGD type present in game partition
/// 0 = XGD1
/// 1 = XGD2
/// 2 = XGD2-Hybrid
/// 3 = XGD3
/// </summary>
/// <remarks>This field is not actually present, but is an abstract representation of the offset/length of the game partition</remarks>
public int XGDType { get; set; }
/// <summary>
/// XDVDFS Game partition, present in middle of Disc Image
/// </summary>
public SabreTools.Data.Models.XDVDFS.Volume GamePartition { get; set; } = new();
}
}

View File

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

View File

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

View File

@@ -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;
}
/// <summary>

View File

@@ -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
{
/// <inheritdoc/>
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;
}
}
}

View File

@@ -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
/// <inheritdoc/>
public string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions);
#endif
/// <inheritdoc/>
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);
}
}
}

View File

@@ -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<DiscImage>
{
#region Descriptive Properties
/// <inheritdoc/>
public override string DescriptionString => "Xbox / Xbox 360 Disc Image";
#endregion
#region Extension Properties
/// <inheritdoc cref="DiscImage.VideoPartition"/>
public SabreTools.Data.Models.ISO9660.Volume VideoPartition => Model.VideoPartition;
/// <inheritdoc cref="DiscImage.XGDType"/>
public int XGDType => Model.XGDType;
/// <inheritdoc cref="DiscImage.GamePartition"/>
public SabreTools.Data.Models.XDVDFS.Volume GamePartition => Model.GamePartition;
#endregion
#region Constructors
/// <inheritdoc/>
public XboxISO(DiscImage model, byte[] data) : base(model, data) { }
/// <inheritdoc/>
public XboxISO(DiscImage model, byte[] data, int offset) : base(model, data, offset) { }
/// <inheritdoc/>
public XboxISO(DiscImage model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
/// <inheritdoc/>
public XboxISO(DiscImage model, Stream data) : base(model, data) { }
/// <inheritdoc/>
public XboxISO(DiscImage model, Stream data, long offset) : base(model, data, offset) { }
/// <inheritdoc/>
public XboxISO(DiscImage model, Stream data, long offset, long length) : base(model, data, offset, length) { }
#endregion
#region Static Constructors
/// <summary>
/// Create an XboxISO DiscImage from a byte array and offset
/// </summary>
/// <param name="data">Byte array representing the XboxISO DiscImage</param>
/// <param name="offset">Offset within the array to parse</param>
/// <returns>An XboxISO DiscImage on success, null on failure</returns>
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);
}
/// <summary>
/// Create an XboxISO DiscImage from a Stream
/// </summary>
/// <param name="data">Stream representing the XboxISO DiscImage</param>
/// <returns>An XboxISO DiscImage DiscImage on success, null on failure</returns>
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
}
}