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
///