From 79ca9f617eeae134a38fcc5c70ce19b24006791c Mon Sep 17 00:00:00 2001 From: HeroponRikiBestest <50224630+HeroponRikiBestest@users.noreply.github.com> Date: Mon, 6 Jul 2026 06:21:17 -0700 Subject: [PATCH] Add sim parsing for Steam2 sim/sid installers (#95) * Add model for sim file * Add sim parsing for steam2 installer set * First round of fixes --- .../Steam2Installer/Constants.cs | 10 + .../Steam2Installer/FileEntry.cs | 71 ++++++++ .../Steam2Installer/Header.cs | 30 +++ .../Steam2Installer/SimFile.cs | 31 ++++ .../Steam2Installer/Steam2InstallerSet.cs | 15 ++ .../Steam2InstallerTests.cs | 72 ++++++++ .../Steam2Installer.cs | 172 ++++++++++++++++++ .../Steam2InstallerTests.cs | 60 ++++++ SabreTools.Wrappers/Steam2Installer.cs | 97 ++++++++++ SabreTools.Wrappers/WrapperFactory.cs | 8 + SabreTools.Wrappers/WrapperType.cs | 5 + 11 files changed, 571 insertions(+) create mode 100644 SabreTools.Data.Models/Steam2Installer/Constants.cs create mode 100644 SabreTools.Data.Models/Steam2Installer/FileEntry.cs create mode 100644 SabreTools.Data.Models/Steam2Installer/Header.cs create mode 100644 SabreTools.Data.Models/Steam2Installer/SimFile.cs create mode 100644 SabreTools.Data.Models/Steam2Installer/Steam2InstallerSet.cs create mode 100644 SabreTools.Serialization.Readers.Test/Steam2InstallerTests.cs create mode 100644 SabreTools.Serialization.Readers/Steam2Installer.cs create mode 100644 SabreTools.Wrappers.Test/Steam2InstallerTests.cs create mode 100644 SabreTools.Wrappers/Steam2Installer.cs diff --git a/SabreTools.Data.Models/Steam2Installer/Constants.cs b/SabreTools.Data.Models/Steam2Installer/Constants.cs new file mode 100644 index 00000000..852975fc --- /dev/null +++ b/SabreTools.Data.Models/Steam2Installer/Constants.cs @@ -0,0 +1,10 @@ +namespace SabreTools.Data.Models.Steam2Installer +{ + public static class Constants + { + public static readonly byte[] SimSignatureBytes = [0x1F, 0x4C, 0xD0, 0x3F]; + + /// All other values in the structure are little endian, assuming this is too + public const uint SimSignatureUInt32 = 0x3FD04C1F; + } +} diff --git a/SabreTools.Data.Models/Steam2Installer/FileEntry.cs b/SabreTools.Data.Models/Steam2Installer/FileEntry.cs new file mode 100644 index 00000000..3c80e5c8 --- /dev/null +++ b/SabreTools.Data.Models/Steam2Installer/FileEntry.cs @@ -0,0 +1,71 @@ +using System.Runtime.InteropServices; + +namespace SabreTools.Data.Models.Steam2Installer +{ + + public sealed class FileEntry + { + /// + /// Offset of the filename in the string bytes array + /// + public uint NameOffset { get; set; } + + /// + /// Offset of the path name in the string bytes array + /// + public uint PathOffset { get; set; } + + /// + /// Steam depot id of the depot the file is a part of + /// + public uint DepotId { get; set; } + + /// + /// Offset of the file within its sid volume + /// + public ulong Offset { get; set; } + + /// + /// Size of the file, in bytes + /// + public ulong Size { get; set; } + + /// + /// Which # installer disc the file is on + /// + public byte DiscNumber { get; set; } + + /// + /// Which # sid volume the file is in + /// + public byte VolumeNumber { get; set; } + + /// + /// Whether the file is encrypted or not + /// + /// + /// This flag isn't entirely reliable, as sometimes the file is encrypted in the sid without this + /// flag being set in the sid. The reverse is not currently known to be true, though, so there at + /// least shouldn't be any false positives when using this flag. + /// + public bool Encrypted { get; set; } + + /// + /// Unknown 1-byte value, likely just padding + /// + public byte Unknown { get; set; } + + // The filename and path aren't stored in the fileentry structure in the .sim, but the offsets are, + // so it just makes sense to also parse and store the strings for the file here too. + + /// + /// The filename + /// + public string Name { get; set; } = string.Empty; + + /// + /// The file path + /// + public string Path { get; set; } = string.Empty; + } +} diff --git a/SabreTools.Data.Models/Steam2Installer/Header.cs b/SabreTools.Data.Models/Steam2Installer/Header.cs new file mode 100644 index 00000000..fa70fc74 --- /dev/null +++ b/SabreTools.Data.Models/Steam2Installer/Header.cs @@ -0,0 +1,30 @@ +using System.Runtime.InteropServices; + +namespace SabreTools.Data.Models.Steam2Installer +{ + /// + /// Header from the .sim file. + /// + public sealed class Header + { + /// + /// .sim file magic value + /// + public uint Magic { get; set; } + + /// + /// Version (0x00000001) + /// + public uint Version { get; set; } + + /// + /// Number of discs + /// + public uint Discs { get; set; } + + /// + /// Size of the section containing file and path strings. + /// + public uint StringsSize { get; set; } + } +} diff --git a/SabreTools.Data.Models/Steam2Installer/SimFile.cs b/SabreTools.Data.Models/Steam2Installer/SimFile.cs new file mode 100644 index 00000000..d54d103c --- /dev/null +++ b/SabreTools.Data.Models/Steam2Installer/SimFile.cs @@ -0,0 +1,31 @@ +namespace SabreTools.Data.Models.Steam2Installer +{ + /// + /// .sim file for a .sim/.sid archive + /// + /// + /// While it is not known for sure what .sim stands for, it's assumed to mean Steam Installer Manifest. + /// This contains information about the .sid data volumes. It's used for retail installer discs for + /// Steam2 (Steam pre-Steam3/SteamPipe). Preceded by GCF (GCF and NCF are still used for actual game + /// installs on Steam2, sim/sid just replaced GCF for retail discs. NCF was never directly stored on + /// retail discs since sim/sid had already come out). Succeeded by csm/csd for Steam3/SteamPipe. + /// Code is based on NickNine's python-based sim/sid extractor at https://pastebin.com/sj6F64XP + /// While this was not referenced for any of the code in SabreTools, https://codeberg.org/CYBERDEV/SIDEx + /// provides additional documentation if it is needed. + /// + public sealed class SimFile + { + /// + /// .sim file header + /// + public Header Header { get; set; } = new(); + + // The string bytes array would go here, but it gets parsed out into FileEntries during deserialization, + // so there's no point storing it in the model. Same with the file entries size and count. + + /// + /// Array of file entries + /// + public FileEntry[] FileEntries { get; set; } = []; + } +} diff --git a/SabreTools.Data.Models/Steam2Installer/Steam2InstallerSet.cs b/SabreTools.Data.Models/Steam2Installer/Steam2InstallerSet.cs new file mode 100644 index 00000000..947a5ea2 --- /dev/null +++ b/SabreTools.Data.Models/Steam2Installer/Steam2InstallerSet.cs @@ -0,0 +1,15 @@ +namespace SabreTools.Data.Models.Steam2Installer +{ + public class Steam2InstallerSet + { + /// + /// The sim file + /// + public SimFile Sim { get; set; } = new(); + + /// + /// The set of sid file volumes + /// + // public SidFile[] Sid { get; set; } = []; + } +} diff --git a/SabreTools.Serialization.Readers.Test/Steam2InstallerTests.cs b/SabreTools.Serialization.Readers.Test/Steam2InstallerTests.cs new file mode 100644 index 00000000..cce21761 --- /dev/null +++ b/SabreTools.Serialization.Readers.Test/Steam2InstallerTests.cs @@ -0,0 +1,72 @@ +using System.IO; +using System.Linq; +using Xunit; + +namespace SabreTools.Serialization.Readers.Test +{ + public class Steam2InstallerTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var deserializer = new Steam2Installer(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var deserializer = new Steam2Installer(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void InvalidArray_Null() + { + byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; + int offset = 0; + var deserializer = new Steam2Installer(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var deserializer = new Steam2Installer(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var deserializer = new Steam2Installer(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var deserializer = new Steam2Installer(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Serialization.Readers/Steam2Installer.cs b/SabreTools.Serialization.Readers/Steam2Installer.cs new file mode 100644 index 00000000..e92671f4 --- /dev/null +++ b/SabreTools.Serialization.Readers/Steam2Installer.cs @@ -0,0 +1,172 @@ +using System.IO; +using System.Text; +using SabreTools.Data.Models.Steam2Installer; +using SabreTools.IO.Extensions; +using SabreTools.Numerics.Extensions; +using SabreTools.Text.Extensions; + +namespace SabreTools.Serialization.Readers +{ + public class Steam2Installer : BaseBinaryReader + { + /// + public override Steam2InstallerSet? Deserialize(Stream? data) + { + // If the data is invalid + if (data is null || !data.CanRead) + return null; + + try + { + // Cache the current offset + long initialOffset = data.Position; + + // Create a new Steam2 Installer set to populate + var si = new Steam2InstallerSet(); + + // Try to parse the .sim file + var sim = ParseSim(data); + + // Make sure the sim was parsed properly + if (sim == null) + return null; + + // Set the sim file + si.Sim = sim; + + return si; + } + catch + { + // Ignore the actual error + return null; + } + } + + /// + /// Parse a Sim file + /// + /// Stream to parse + /// Filled Sim on success, null on error + public static SimFile? ParseSim(Stream data) + { + // Create a new Sim file to populate + var si = new SimFile(); + + // Try to parse the .sim file header + var header = ParseHeader(data); + + // Make sure the magic is correct + if (header.Magic != Constants.SimSignatureUInt32) + return null; + + // Make sure the version is correct + if (header.Version != 1) + return null; + + // Make sure string bytes section doesn't go beyond EOF + if (header.StringsSize > data.Length - data.Position) + return null; + + // Set the header + si.Header = header; + + // Read the string section bytes + byte[] stringsBytes = data.ReadBytes((int)header.StringsSize); + + // Read the size and number of file entries + uint fileEntriesSize = data.ReadUInt32LittleEndian(); + uint fileEntriesCount = data.ReadUInt32LittleEndian(); + + // Make sure reading the file entries won't go beyond EOF + if (fileEntriesSize > data.Length - data.Position) + return null; + + // Try to parse the entry table + var table = ParseTable(data, fileEntriesCount, stringsBytes); + if (table is null || table.Length != fileEntriesCount) + return null; + + // Set the entry table + si.FileEntries = table; + + return si; + } + + /// + /// 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.Magic = data.ReadUInt32LittleEndian(); + obj.Version = data.ReadUInt32LittleEndian(); + obj.Discs = data.ReadUInt32LittleEndian(); + obj.StringsSize = data.ReadUInt32LittleEndian(); + + return obj; + } + + /// + /// Parse a Stream into an entry table + /// + /// Stream to parse + /// Number of entries to parse + /// Filled entry table on success, null on error + public static FileEntry[]? ParseTable(Stream data, uint count, byte[] stringsBytes) + { + if (count == 0) + return []; + + var obj = new FileEntry[count]; + for (uint i = 0; i < count; i++) + { + var fileEntry = ParseFileEntry(data, stringsBytes); + if (fileEntry is null) + return null; + + obj[i] = fileEntry; + } + + return obj; + } + + /// + /// Parse a Stream into a FileEntry + /// + /// Stream to parse + /// Filled FileEntry on success, null on error + public static FileEntry? ParseFileEntry(Stream data, byte[] stringsBytes) + { + var obj = new FileEntry(); + + obj.NameOffset = data.ReadUInt32LittleEndian(); + obj.PathOffset = data.ReadUInt32LittleEndian(); + obj.DepotId = data.ReadUInt32LittleEndian(); + obj.Offset = data.ReadUInt64LittleEndian(); + obj.Size = data.ReadUInt64LittleEndian(); + obj.DiscNumber = data.ReadByteValue(); + obj.VolumeNumber = data.ReadByteValue(); + obj.Encrypted = data.ReadByte() != 0; + obj.Unknown = data.ReadByteValue(); + + // Read file path and name strings + int nameOffset = (int)obj.NameOffset; + string? name = stringsBytes.ReadNullTerminatedAnsiString(ref nameOffset); // Unsure on encoding, assumed ascii/ansi + if (name is null) + return null; + + obj.Name = name; + int pathOffset = (int)obj.PathOffset; + string? path = stringsBytes.ReadNullTerminatedAnsiString(ref pathOffset); // Unsure on encoding, assumed ascii/ansi + if (path is null) + return null; + + obj.Path = path; + return obj; + } + } +} diff --git a/SabreTools.Wrappers.Test/Steam2InstallerTests.cs b/SabreTools.Wrappers.Test/Steam2InstallerTests.cs new file mode 100644 index 00000000..11600d62 --- /dev/null +++ b/SabreTools.Wrappers.Test/Steam2InstallerTests.cs @@ -0,0 +1,60 @@ +using System.IO; +using System.Linq; +using Xunit; + +namespace SabreTools.Wrappers.Test +{ + public class Steam2InstallerTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var actual = Steam2Installer.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var actual = Steam2Installer.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void InvalidArray_Null() + { + byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; + int offset = 0; + var actual = Steam2Installer.Create(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var actual = Steam2Installer.Create(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var actual = Steam2Installer.Create(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var actual = Steam2Installer.Create(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Wrappers/Steam2Installer.cs b/SabreTools.Wrappers/Steam2Installer.cs new file mode 100644 index 00000000..9e235454 --- /dev/null +++ b/SabreTools.Wrappers/Steam2Installer.cs @@ -0,0 +1,97 @@ +using System.IO; +using SabreTools.Data.Models.Steam2Installer; + +namespace SabreTools.Wrappers +{ + public partial class Steam2Installer : WrapperBase + { + #region Descriptive Properties + + /// + public override string DescriptionString => "Steam2 Installer Set"; + + #endregion + + #region Extension Properties + + /// + public SimFile Sim => Model.Sim; + + #endregion + + #region Constructors + + /// + public Steam2Installer(Steam2InstallerSet model, byte[] data) : base(model, data) { } + + /// + public Steam2Installer(Steam2InstallerSet model, byte[] data, int offset) : base(model, data, offset) { } + + /// + public Steam2Installer(Steam2InstallerSet model, byte[] data, int offset, int length) : base(model, data, offset, length) { } + + /// + public Steam2Installer(Steam2InstallerSet model, Stream data) : base(model, data) { } + + /// + public Steam2Installer(Steam2InstallerSet model, Stream data, long offset) : base(model, data, offset) { } + + /// + public Steam2Installer(Steam2InstallerSet model, Stream data, long offset, long length) : base(model, data, offset, length) { } + + #endregion + + #region Static Constructors + + /// + /// Create a Steam2 Installer Set from a byte array and offset + /// + /// Byte array representing the sim file + /// Offset within the array to parse + /// A Steam2 Installer Set wrapper on success, null on failure + public static Steam2Installer? 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 a Steam2 Installer Set from a Stream + /// + /// Stream representing the sim file + /// A Steam2 Installer Set wrapper on success, null on failure + public static Steam2Installer? 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; + + var model = new Serialization.Readers.Steam2Installer().Deserialize(data); + if (model is null) + return null; + + return new Steam2Installer(model, data, currentOffset); + } + catch + { + return null; + } + } + + #endregion + } +} diff --git a/SabreTools.Wrappers/WrapperFactory.cs b/SabreTools.Wrappers/WrapperFactory.cs index 14c1fe12..44cc24ec 100644 --- a/SabreTools.Wrappers/WrapperFactory.cs +++ b/SabreTools.Wrappers/WrapperFactory.cs @@ -64,6 +64,7 @@ namespace SabreTools.Wrappers WrapperType.SkuSis => SkuSis.Create(data), WrapperType.SFFS => SFFS.Create(data), WrapperType.SGA => SGA.Create(data), + WrapperType.Steam2Installer => Steam2Installer.Create(data), WrapperType.STFS => STFS.Create(data), WrapperType.TapeArchive => TapeArchive.Create(data), WrapperType.VBSP => VBSP.Create(data), @@ -938,6 +939,13 @@ namespace SabreTools.Wrappers #endregion + #region Steam2Installer + + if (magic.StartsWith(Data.Models.Steam2Installer.Constants.SimSignatureBytes)) + return WrapperType.Steam2Installer; + + #endregion + #region STFS // LIVE diff --git a/SabreTools.Wrappers/WrapperType.cs b/SabreTools.Wrappers/WrapperType.cs index 23b52a2b..02041fbe 100644 --- a/SabreTools.Wrappers/WrapperType.cs +++ b/SabreTools.Wrappers/WrapperType.cs @@ -257,6 +257,11 @@ namespace SabreTools.Wrappers /// SkuSis, + /// + /// Steam2 Installer set (sim/sid) + /// + Steam2Installer, + /// /// Secure Transacted File System ///