diff --git a/ExtractionTool/Features/MainFeature.cs b/ExtractionTool/Features/MainFeature.cs index 36dc8eaf..114a9ec8 100644 --- a/ExtractionTool/Features/MainFeature.cs +++ b/ExtractionTool/Features/MainFeature.cs @@ -289,6 +289,11 @@ namespace ExtractionTool.Features case XZP xzp: xzp.Extract(OutputPath, Debug); break; + + // ZSTD + case ZSTD zstd: + zstd.Extract(OutputPath, Debug); + break; // Everything else default: diff --git a/SabreTools.Serialization/Models/ZSTD/Constants.cs b/SabreTools.Serialization/Models/ZSTD/Constants.cs new file mode 100644 index 00000000..4b9a01d5 --- /dev/null +++ b/SabreTools.Serialization/Models/ZSTD/Constants.cs @@ -0,0 +1,7 @@ +namespace SabreTools.Data.Models.ZSTD +{ + public static class Constants + { + public static readonly byte[] SignatureBytes = [0xB5, 0x2F, 0xFD]; + } +} \ No newline at end of file diff --git a/SabreTools.Serialization/Models/ZSTD/Header.cs b/SabreTools.Serialization/Models/ZSTD/Header.cs new file mode 100644 index 00000000..ac45d0cc --- /dev/null +++ b/SabreTools.Serialization/Models/ZSTD/Header.cs @@ -0,0 +1,22 @@ +namespace SabreTools.Data.Models.ZSTD +{ + /// + /// Header + /// + /// + public sealed class Header + { + /// + /// Despite never being referred to as such, and being hard to find in the documentation, the least significant + /// byte changed with versions until 0.8. + /// 0.1 used 0x1E, then it was 0.2(0x22)-0.8(0x28) + /// + /// + public byte VersionByte; + + /// + /// "0x?? 0xB5 0x2F 0xFD" + /// + public byte[]? Magic; + } +} \ No newline at end of file diff --git a/SabreTools.Serialization/Readers/ZSTD.cs b/SabreTools.Serialization/Readers/ZSTD.cs new file mode 100644 index 00000000..b4baef4f --- /dev/null +++ b/SabreTools.Serialization/Readers/ZSTD.cs @@ -0,0 +1,62 @@ +using System.IO; +using SabreTools.Data.Models.ZSTD; +using SabreTools.IO.Extensions; +using static SabreTools.Data.Models.ZSTD.Constants; + +namespace SabreTools.Serialization.Readers +{ + public class ZSTD : BaseBinaryReader
+ { + /// + public override Header? Deserialize(Stream? data) + { + // If the data is invalid + if (data == null || !data.CanRead) + return null; + + try + { + // Cache the current offset + long initialOffset = data.Position; + + #region Header + + var header = ParseHeader(data); + if (header == null) + return null; + + // Valid versions are 0x1E and 0x22-0x28. + // According to RFC-8878, the current version is still 0x28, and it should stay that way now that + // it's a stable format. + if ((header.VersionByte < 0x22 || header.VersionByte > 0x28) && header.VersionByte != 0x1E) + return null; + + #endregion + + return header; + } + catch + { + // Ignore the actual error + return null; + } + } + + /// + /// Parse a Stream into a Header + /// + /// Stream to parse + /// Filled Header on success, null on error + public static Header? ParseHeader(Stream data) + { + var obj = new Header(); + + obj.VersionByte = data.ReadByteValue(); + obj.Magic = data.ReadBytes(3); + if (!obj.Magic.EqualsExactly(SignatureBytes)) + return null; + + return obj; + } + } +} \ No newline at end of file diff --git a/SabreTools.Serialization/WrapperFactory.cs b/SabreTools.Serialization/WrapperFactory.cs index 1f6d2936..6e5c2c85 100644 --- a/SabreTools.Serialization/WrapperFactory.cs +++ b/SabreTools.Serialization/WrapperFactory.cs @@ -60,6 +60,7 @@ namespace SabreTools.Serialization WrapperType.WAD => WAD3.Create(data), WrapperType.XZ => XZ.Create(data), WrapperType.XZP => XZP.Create(data), + WrapperType.ZSTD => ZSTD.Create(data), _ => null, }; } @@ -824,6 +825,13 @@ namespace SabreTools.Serialization return WrapperType.XZP; #endregion + + #region ZSTD + + if (magic.StartsWith([null, 0xB5, 0x2F, 0xFD])) + return WrapperType.ZSTD; + + #endregion // We couldn't find a supported match return WrapperType.UNKNOWN; diff --git a/SabreTools.Serialization/Wrappers/WrapperType.cs b/SabreTools.Serialization/Wrappers/WrapperType.cs index f943eeae..4dcbeac3 100644 --- a/SabreTools.Serialization/Wrappers/WrapperType.cs +++ b/SabreTools.Serialization/Wrappers/WrapperType.cs @@ -252,5 +252,10 @@ namespace SabreTools.Serialization.Wrappers /// Xbox Package File /// XZP, + + /// + /// ZStandard compressed file + /// + ZSTD, } } diff --git a/SabreTools.Serialization/Wrappers/ZSTD.Extraction.cs b/SabreTools.Serialization/Wrappers/ZSTD.Extraction.cs new file mode 100644 index 00000000..ce65597e --- /dev/null +++ b/SabreTools.Serialization/Wrappers/ZSTD.Extraction.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +#if NET462_OR_GREATER || NETCOREAPP +using SharpCompress.Compressors.ZStandard; +#endif + +namespace SabreTools.Serialization.Wrappers +{ + /// + /// This is a shell wrapper; one that does not contain + /// any actual parsing. It is used as a placeholder for + /// types that typically do not have models. + /// + public partial class ZSTD : IExtractable + { + /// + public bool Extract(string outputDirectory, bool includeDebug) + { + // Ensure there is data to extract + if (Magic == null) + { + if (includeDebug) Console.Error.WriteLine("Invalid archive detected, skipping..."); + return false; + } + +#if NET462_OR_GREATER || NETCOREAPP + try + { + // Ensure directory separators are consistent + string filename = (Filename != null ? Path.GetFileName(Filename).Replace(".zstd", string.Empty) : null) + ?? (Filename != null ? Path.GetFileName(Filename).Replace(".zst", string.Empty) : null) + ?? $"extracted_file"; + + if (Path.DirectorySeparatorChar == '\\') + filename = filename.Replace('/', '\\'); + else if (Path.DirectorySeparatorChar == '/') + filename = filename.Replace('\\', '/'); + + // Ensure the full output directory exists + filename = Path.Combine(outputDirectory, filename); + var directoryName = Path.GetDirectoryName(filename); + if (directoryName != null && !Directory.Exists(directoryName)) + Directory.CreateDirectory(directoryName); + + // Open the source as a zStandard stream + var zstdStream = new ZStandardStream(_dataSource, false); + + // Write the file + using var fs = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.None); + zstdStream.CopyTo(fs); + fs.Flush(); + + return true; + } + catch (Exception ex) + { + if (includeDebug) Console.Error.WriteLine(ex); + return false; + } +#else + Console.WriteLine("Extraction is not supported for this framework!"); + Console.WriteLine(); + return false; +#endif + } + } +} diff --git a/SabreTools.Serialization/Wrappers/ZSTD.cs b/SabreTools.Serialization/Wrappers/ZSTD.cs new file mode 100644 index 00000000..67a619fe --- /dev/null +++ b/SabreTools.Serialization/Wrappers/ZSTD.cs @@ -0,0 +1,101 @@ +using System.IO; +using SabreTools.Data.Models.ZSTD; +using SabreTools.IO.Extensions; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class ZSTD : WrapperBase
+ { + #region Descriptive Properties + + /// + public override string DescriptionString => "ZSTD file"; + + #endregion + + #region Extension Properties + + /// + public byte VersionByte => Model.VersionByte; + + /// + public byte[]? Magic => Model.Magic; + + #endregion + + #region Constructors + + /// + public ZSTD(Data.Models.ZSTD.Header model, byte[] data) : base(model, data) { } + + /// + public ZSTD(Data.Models.ZSTD.Header model, byte[] data, int offset) : base(model, data, offset) { } + + /// + public ZSTD(Data.Models.ZSTD.Header model, byte[] data, int offset, int length) : base(model, data, offset, length) { } + + /// + public ZSTD(Data.Models.ZSTD.Header model, Stream data) : base(model, data) { } + + /// + public ZSTD(Data.Models.ZSTD.Header model, Stream data, long offset) : base(model, data, offset) { } + + /// + public ZSTD(Data.Models.ZSTD.Header model, Stream data, long offset, long length) : base(model, data, offset, length) { } + + #endregion + + #region Static Constructors + + /// + /// Create a ZSTD file from a byte array and offset + /// + /// Byte array representing the ZSTD file + /// Offset within the array to parse + /// A ZSTD wrapper on success, null on failure + public static ZSTD? Create(byte[]? data, int offset) + { + // If the data is invalid + if (data == 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 ZSTD file from a Stream + /// + /// Stream representing the ZSTD file + /// A ZSTD wrapper on success, null on failure + public static ZSTD? Create(Stream? data) + { + // If the data is invalid + if (data == null || !data.CanRead) + return null; + + try + { + // Cache the current offset + long currentOffset = data.Position; + + var model = new Readers.ZSTD().Deserialize(data); + if (model == null) + return null; + + return new ZSTD(model, data, currentOffset); + } + catch + { + return null; + } + } + + #endregion + } +}