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
This commit is contained in:
HeroponRikiBestest
2026-07-06 06:21:17 -07:00
committed by GitHub
parent a602686551
commit 79ca9f617e
11 changed files with 571 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
namespace SabreTools.Data.Models.Steam2Installer
{
public static class Constants
{
public static readonly byte[] SimSignatureBytes = [0x1F, 0x4C, 0xD0, 0x3F];
/// <remarks>All other values in the structure are little endian, assuming this is too</remarks>
public const uint SimSignatureUInt32 = 0x3FD04C1F;
}
}

View File

@@ -0,0 +1,71 @@
using System.Runtime.InteropServices;
namespace SabreTools.Data.Models.Steam2Installer
{
public sealed class FileEntry
{
/// <summary>
/// Offset of the filename in the string bytes array
/// </summary>
public uint NameOffset { get; set; }
/// <summary>
/// Offset of the path name in the string bytes array
/// </summary>
public uint PathOffset { get; set; }
/// <summary>
/// Steam depot id of the depot the file is a part of
/// </summary>
public uint DepotId { get; set; }
/// <summary>
/// Offset of the file within its sid volume
/// </summary>
public ulong Offset { get; set; }
/// <summary>
/// Size of the file, in bytes
/// </summary>
public ulong Size { get; set; }
/// <summary>
/// Which # installer disc the file is on
/// </summary>
public byte DiscNumber { get; set; }
/// <summary>
/// Which # sid volume the file is in
/// </summary>
public byte VolumeNumber { get; set; }
/// <summary>
/// Whether the file is encrypted or not
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public bool Encrypted { get; set; }
/// <summary>
/// Unknown 1-byte value, likely just padding
/// </summary>
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.
/// <summary>
/// The filename
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// The file path
/// </summary>
public string Path { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,30 @@
using System.Runtime.InteropServices;
namespace SabreTools.Data.Models.Steam2Installer
{
/// <summary>
/// Header from the .sim file.
/// </summary>
public sealed class Header
{
/// <summary>
/// .sim file magic value
/// </summary>
public uint Magic { get; set; }
/// <summary>
/// Version (0x00000001)
/// </summary>
public uint Version { get; set; }
/// <summary>
/// Number of discs
/// </summary>
public uint Discs { get; set; }
/// <summary>
/// Size of the section containing file and path strings.
/// </summary>
public uint StringsSize { get; set; }
}
}

View File

@@ -0,0 +1,31 @@
namespace SabreTools.Data.Models.Steam2Installer
{
/// <summary>
/// .sim file for a .sim/.sid archive
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class SimFile
{
/// <summary>
/// .sim file header
/// </summary>
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.
/// <summary>
/// Array of file entries
/// </summary>
public FileEntry[] FileEntries { get; set; } = [];
}
}

View File

@@ -0,0 +1,15 @@
namespace SabreTools.Data.Models.Steam2Installer
{
public class Steam2InstallerSet
{
/// <summary>
/// The sim file
/// </summary>
public SimFile Sim { get; set; } = new();
/// <summary>
/// The set of sid file volumes
/// </summary>
// public SidFile[] Sid { get; set; } = [];
}
}

View File

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

View File

@@ -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<Steam2InstallerSet>
{
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Parse a Sim file
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled Sim on success, null on error</returns>
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;
}
/// <summary>
/// Parse a Stream into a Header
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled Header on success, null on error</returns>
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;
}
/// <summary>
/// Parse a Stream into an entry table
/// </summary>
/// <param name="data">Stream to parse</param>
/// <param name="count">Number of entries to parse</param>
/// <returns>Filled entry table on success, null on error</returns>
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;
}
/// <summary>
/// Parse a Stream into a FileEntry
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled FileEntry on success, null on error</returns>
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;
}
}
}

View File

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

View File

@@ -0,0 +1,97 @@
using System.IO;
using SabreTools.Data.Models.Steam2Installer;
namespace SabreTools.Wrappers
{
public partial class Steam2Installer : WrapperBase<Steam2InstallerSet>
{
#region Descriptive Properties
/// <inheritdoc/>
public override string DescriptionString => "Steam2 Installer Set";
#endregion
#region Extension Properties
/// <inheritdoc cref="Steam2InstallerSet.Sim"/>
public SimFile Sim => Model.Sim;
#endregion
#region Constructors
/// <inheritdoc/>
public Steam2Installer(Steam2InstallerSet model, byte[] data) : base(model, data) { }
/// <inheritdoc/>
public Steam2Installer(Steam2InstallerSet model, byte[] data, int offset) : base(model, data, offset) { }
/// <inheritdoc/>
public Steam2Installer(Steam2InstallerSet model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
/// <inheritdoc/>
public Steam2Installer(Steam2InstallerSet model, Stream data) : base(model, data) { }
/// <inheritdoc/>
public Steam2Installer(Steam2InstallerSet model, Stream data, long offset) : base(model, data, offset) { }
/// <inheritdoc/>
public Steam2Installer(Steam2InstallerSet model, Stream data, long offset, long length) : base(model, data, offset, length) { }
#endregion
#region Static Constructors
/// <summary>
/// Create a Steam2 Installer Set from a byte array and offset
/// </summary>
/// <param name="data">Byte array representing the sim file</param>
/// <param name="offset">Offset within the array to parse</param>
/// <returns>A Steam2 Installer Set wrapper on success, null on failure</returns>
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);
}
/// <summary>
/// Create a Steam2 Installer Set from a Stream
/// </summary>
/// <param name="data">Stream representing the sim file</param>
/// <returns>A Steam2 Installer Set wrapper on success, null on failure</returns>
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
}
}

View File

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

View File

@@ -257,6 +257,11 @@ namespace SabreTools.Wrappers
/// </summary>
SkuSis,
/// <summary>
/// Steam2 Installer set (sim/sid)
/// </summary>
Steam2Installer,
/// <summary>
/// Secure Transacted File System
/// </summary>