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