diff --git a/TACTLib/Client/ClientCreateArgs.cs b/TACTLib/Client/ClientCreateArgs.cs index a198d8a..ab97aca 100644 --- a/TACTLib/Client/ClientCreateArgs.cs +++ b/TACTLib/Client/ClientCreateArgs.cs @@ -116,13 +116,14 @@ public bool Online { public ICDNClient? CustomCDNClient { get; set; } = null; public NGDPClientBase? CustomNGDPClient { get; set; } = null; - + public bool LoadCDNIndices { get; set; } = true; public ClientHandler? TryShareCDNIndexWithHandler { get; set; } = null; public bool ParallelCDNIndexLoading { get; set; } = false; public int MaxCDNIndexLoadingParallelism { get; set; } = 4; - + public bool LoadRoot { get; set; } = true; public bool LoadVFS { get; set; } = true; + public bool LoadAllVFS { get; set; } = false; } -} \ No newline at end of file +} diff --git a/TACTLib/Client/ClientHandler.cs b/TACTLib/Client/ClientHandler.cs index 12cd818..b4c7b35 100644 --- a/TACTLib/Client/ClientHandler.cs +++ b/TACTLib/Client/ClientHandler.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.IO.Compression; using System.Linq; using TACTLib.Agent; using TACTLib.Agent.Protobuf; @@ -110,6 +111,11 @@ public ClientHandler(string? basePath, ClientCreateArgs createArgs) { var staticBuildConfigPath = Path.Combine(BasePath, "data", ".build.config"); // todo: um IsStaticContainer = File.Exists(staticBuildConfigPath); + if (!IsStaticContainer) { + staticBuildConfigPath = Path.Combine(BasePath, "Data", ".build.config"); // todo: um x2 (thx fenris.) + IsStaticContainer = File.Exists(staticBuildConfigPath); + } + if (IsStaticContainer) { if (CreateArgs.VersionSource != ClientCreateArgs.InstallMode.Local) throw new Exception("only local version sources are supported for static containers (steam)"); CreateArgs.Online = false; @@ -162,7 +168,7 @@ public ClientHandler(string? basePath, ClientCreateArgs createArgs) { if (CreateArgs.OverrideVersionName != null) { InstallationInfo.Values["Version"] = CreateArgs.OverrideVersionName; } - + if (CreateArgs.Online) { CDNClient = createArgs.CustomCDNClient ?? new HttpCDNClient(null); CDNClient.SetClientHandler(this); @@ -199,7 +205,7 @@ public ClientHandler(string? basePath, ClientCreateArgs createArgs) { if (CreateArgs.UseContainer) { Logger.Info("CASC", "Initializing..."); if (IsStaticContainer) { - ContainerHandler = new StaticContainerHandler(this); + ContainerHandler = CreateStaticContainerHandler() ?? throw new NotImplementedException($"Product \"{Product}\" as static container is not supported."); } else { using var _ = new PerfCounter("ContainerHandler::ctor`ClientHandler"); ContainerHandler = new ContainerHandler(this); @@ -224,13 +230,28 @@ public ClientHandler(string? basePath, ClientCreateArgs createArgs) { // for testing local cdn index init but remote data: //ContainerHandler = null; - using (var _ = new PerfCounter("EncodingHandler::ctor`ClientHandler")) + if (ConfigHandler.BuildConfig.Encoding != null) { + using var _ = new PerfCounter("EncodingHandler::ctor`ClientHandler"); EncodingHandler = new EncodingHandler(this); + } if (ConfigHandler.BuildConfig.VFSRoot != null && CreateArgs.LoadVFS) { - using var _ = new PerfCounter("VFSFileTree::ctor`ClientHandler"); - using var vfsStream = OpenCKey(ConfigHandler.BuildConfig.VFSRoot!.ContentKey)!; - VFS = new VFSFileTree(this, vfsStream); + using (new PerfCounter("ClientHandler::ctor``VFS")) { + VFS = new VFSFileTree(this); + foreach (var vfs in ConfigHandler.BuildConfig.VFS) { + using var _ = new PerfCounter("VFSFileTree::ctor`ClientHandler"); + using var vfsStream = + OpenCKey(vfs.File.ContentKey) ?? + OpenEKey(vfs.File.EncodingKey, vfs.Size?.EncodedSize ?? 0, vfs.Spec); + if (vfsStream != null) { + VFS.Load(vfsStream); + } + + if (!CreateArgs.LoadAllVFS) { + break; + } + } + } } if (CreateArgs.LoadRoot) @@ -243,7 +264,12 @@ public ClientHandler(string? basePath, ClientCreateArgs createArgs) { public IProductHandler? CreateProductHandler() { using var _ = new PerfCounter("ProductHandlerFactory::GetHandler`TACTProduct`ClientHandler`Stream"); - return ProductHandlerFactory.GetHandler(Product, this, OpenCKey(ConfigHandler.BuildConfig.Root.ContentKey)); + return ProductHandlerFactory.GetHandler(Product, this, ConfigHandler.BuildConfig.Root is {} root ? OpenCKey(root.ContentKey) : null); + } + + public IContainerHandler? CreateStaticContainerHandler() { + using var _ = new PerfCounter("StaticContainerHandlerFactory::GetHandler`TACTProduct`ClientHandler"); + return StaticContainerHandlerFactory.GetHandler(Product, this); } private bool CanShareCDNData([NotNullWhen(true)] ClientHandler? other) { @@ -270,8 +296,8 @@ private static bool CanShareCDNData(CDNConfig cdnConfig, CDNConfig otherCDNConfi return true; } - public Stream? OpenEKey(FullEKey fullEKey, int eSize) { // ekey = value of ckey in encoding table - var fromContainer = TryOpenEKeyFromContainer(fullEKey, eSize); + public Stream? OpenEKey(FullEKey fullEKey, int eSize, string? espec) { // ekey = value of ckey in encoding table + var fromContainer = TryOpenEKeyFromContainer(fullEKey, eSize, espec); if (fromContainer != null) return fromContainer; return TryOpenEKeyFromRemote(fullEKey, eSize); @@ -301,17 +327,17 @@ private static bool CanShareCDNData(CDNConfig cdnConfig, CDNConfig otherCDNConfi if (EncodingHandler == null) return null; // cant get here but okay foreach (var ekey in eKeys) { - var fromContainer = TryOpenEKeyFromContainer(ekey, EncodingHandler.GetEncodedSize(ekey)); + var fromContainer = TryOpenEKeyFromContainer(ekey, EncodingHandler.GetEncodedSize(ekey), null); if (fromContainer != null) return fromContainer; } return null; } - private Stream? TryOpenEKeyFromContainer(FullEKey fullEKey, int eSize) { + private Stream? TryOpenEKeyFromContainer(FullEKey fullEKey, int eSize, string? espec) { if (ContainerHandler == null) return null; if (!ContainerHandler.CheckResidency(fullEKey)) return null; try { - var cascBlte = OpenEKeyFromContainer(fullEKey, eSize); + var cascBlte = OpenEKeyFromContainer(fullEKey, eSize, espec); if (cascBlte != null) return cascBlte; } catch (Exception e) { if (e is BLTEKeyException) throw; @@ -321,11 +347,11 @@ private static bool CanShareCDNData(CDNConfig cdnConfig, CDNConfig otherCDNConfi return null; } - private Stream? OpenEKeyFromContainer(FullEKey fullEKey, int eSize) { // ekey = value of ckey in encoding table + private Stream? OpenEKeyFromContainer(FullEKey fullEKey, int eSize, string? espec) { // ekey = value of ckey in encoding table if (ContainerHandler == null) return null; var fromContainer = ContainerHandler.OpenEKey(fullEKey, eSize); if (fromContainer == null) throw new Exception($"failed to load local file {fullEKey.ToHexString()} (it was marked resident)"); - return TryDecodeToStream(fromContainer); + return TryDecodeToStream(fromContainer, espec); } private Stream? TryOpenEKeyFromRemote(FullEKey fullEKey, int eSize) { @@ -346,7 +372,7 @@ private static bool CanShareCDNData(CDNConfig cdnConfig, CDNConfig otherCDNConfi if (!CDNIndex.TryGetIndexEntry(fullEKey, out var cdnIdx)) return null; var encodedData = CDNIndex.OpenIndexEntry(cdnIdx); if (encodedData == null) throw new Exception($"failed to fetch archived cdn file {fullEKey.ToHexString()}"); - return TryDecodeToStream(encodedData); + return TryDecodeToStream(encodedData, null); } private Stream? TryOpenRemoteLooseFile(FullEKey fullKey) { @@ -354,12 +380,33 @@ private static bool CanShareCDNData(CDNConfig cdnConfig, CDNConfig otherCDNConfi if (!CDNIndex.IsLooseFile(fullKey)) return null; var encodedData = CDNClient!.FetchLooseData(fullKey); if (encodedData == null) throw new Exception($"failed to fetch loose cdn file {fullKey.ToHexString()}"); - return TryDecodeToStream(encodedData); + return TryDecodeToStream(encodedData, null); } - private MemoryStream? TryDecodeToStream(ArraySegment? data) { - if (data == null) return null; - return new MemoryStream(BLTEDecoder.Decode(this, data.Value.AsSpan()), false); + private MemoryStream? TryDecodeToStream(ArraySegment? data, string? espec) { + if (data == null) { + return null; + } + + if (espec is null or "b" || espec.StartsWith("b:")) { + return new MemoryStream(BLTEDecoder.Decode(this, data.Value.AsSpan()), false); + } + + if (espec == "z" || espec.StartsWith("z:")) { + var mem = data.Value.AsMemory(); + using var pin = mem.Pin(); + unsafe { + using var ums = new UnmanagedMemoryStream((byte*) pin.Pointer, mem.Length); + using var z = new ZLibStream(ums, CompressionMode.Decompress); + var stream = new MemoryStream(); + z.CopyTo(stream); + stream.Position = 0; + return stream; + } + } + + // todo: whag + return null; } public Stream? OpenConfigKey(string key) { diff --git a/TACTLib/Config/BuildConfig.cs b/TACTLib/Config/BuildConfig.cs index e11a857..628a6e2 100644 --- a/TACTLib/Config/BuildConfig.cs +++ b/TACTLib/Config/BuildConfig.cs @@ -1,76 +1,105 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO; +using System.Linq; namespace TACTLib.Config { + public class BuildConfig : Config { - public FileRecord Root; + public FileRecord? Root; public FileRecord? Install; public FileRecord? Patch; public FileRecord? Download; - public FileRecord Encoding; + public FileRecord? Encoding; public SizeRecord? EncodingSize; - public FileRecord? VFSRoot; - - public string GetBuildName() => Values["build-name"][0]; - + public VFSRecord? VFSRoot; + public List VFS; + + public string GetBuildName() => (Values.TryGetValue("build-name", out var buildName) ? buildName.FirstOrDefault() : null) ?? "Unknown"; + public BuildConfig(Stream? stream) : base(stream) { - GetFileRecord("root", out var root); - GetFileRecord("install", out Install); - GetFileRecord("patch", out Patch); - GetFileRecord("download", out Download); - GetFileRecord("encoding", out var encoding); - GetSizeRecord("encoding-size", out EncodingSize); - GetFileRecord("vfs-root", out VFSRoot); - - if (root == null) throw new NullReferenceException(nameof(root)); - Root = root; - - if (encoding == null) throw new NullReferenceException(nameof(encoding)); - Encoding = encoding; + TryGetFileRecord("root", out Root); + TryGetFileRecord("install", out Install); + TryGetFileRecord("patch", out Patch); + TryGetFileRecord("download", out Download); + TryGetFileRecord("encoding", out Encoding); + TryGetSizeRecord("encoding-size", out EncodingSize); + BuildVFS(); } - private void GetFileRecord(string key, out FileRecord? @out) { - if (!Values.TryGetValue(key, out var list)) { - @out = null; + private void BuildVFS() { + VFS = []; + + if (!TryGetVFSRecord("vfs-root", out VFSRoot)) { return; } - @out = GetFileRecord(list); + + VFS.Add(VFSRoot); + var index = 1; + while (TryGetVFSRecord("vfs-" + index++, out var vfs)) { + VFS.Add(vfs); + } } - - private void GetSizeRecord(string key, out SizeRecord? @out) { + + private bool TryGetFileRecord(string key, [MaybeNullWhen(false)] out FileRecord record) { if (!Values.TryGetValue(key, out var list)) { - @out = null; - return; + record = null; + return false; } - @out = new SizeRecord { - ContentSize = int.Parse(list[0]), - EncodedSize = int.Parse(list[1]) - }; - } - private static FileRecord GetFileRecord(IReadOnlyList vals) { - FileRecord record = new FileRecord(); + record = GetFileRecord(list); + return true; + } - if (vals.Count > 0) { - record.ContentKey = CKey.FromString(vals[0]); + private bool TryGetSizeRecord(string key, [MaybeNullWhen(false)] out SizeRecord record) { + if (!Values.TryGetValue(key, out var list)) { + record = null; + return false; } - if (vals.Count > 1) { - record.EncodingKey = FullEKey.FromString(vals[1]); + record = GetSizeRecord(list); + return true; + } + + private bool TryGetVFSRecord(string key, [MaybeNullWhen(false)] out VFSRecord record) { + if (!TryGetFileRecord(key, out var file)) { + record = null; + return false; } - - return record; + + record = new VFSRecord { + File = file, + Size = TryGetSizeRecord(key + "-size", out var size) ? size : default, + Spec = Values.TryGetValue(key + "-espec", out var list) ? string.Join(" ", list) : default, + }; + return true; + } + + private static FileRecord GetFileRecord(List vals) => new() { + ContentKey = vals.Count > 0 ? CKey.FromString(vals[0]) : default, + EncodingKey = vals.Count > 1 ? CKey.FromString(vals[1]) : default, + }; + + private static SizeRecord GetSizeRecord(List vals) => new() { + ContentSize = vals.Count > 0 ? int.Parse(vals[0], NumberStyles.Integer, CultureInfo.InvariantCulture) : default, + EncodedSize = vals.Count > 1 ? int.Parse(vals[1], NumberStyles.Integer, CultureInfo.InvariantCulture) : default, + }; + + public record FileRecord { + public CKey ContentKey { get; init; } + public FullEKey EncodingKey { get; init; } } - public class FileRecord { - public CKey ContentKey; - public FullEKey EncodingKey; + public record SizeRecord { + public int ContentSize { get; init; } + public int EncodedSize { get; init; } } - public class SizeRecord { - public int ContentSize; - public int EncodedSize; + public record VFSRecord { + public required FileRecord File { get; init; } + public SizeRecord? Size { get; init; } + public string? Spec { get; init; } } } -} \ No newline at end of file +} diff --git a/TACTLib/Container/ContainerHandler.cs b/TACTLib/Container/ContainerHandler.cs index 8f43eed..035a929 100644 --- a/TACTLib/Container/ContainerHandler.cs +++ b/TACTLib/Container/ContainerHandler.cs @@ -22,7 +22,7 @@ public class ContainerHandler : IContainerHandler { /// Container directory. Where the data, config, indices etc subdirectories are located. /// public readonly string ContainerDirectory; - + public const string DataDirectory = "data"; public const string ConfigDirectory = "config"; public const string CDNIndicesDirectory = "indices"; @@ -130,7 +130,7 @@ private bool TryFindIndexEntry(TruncatedKey key, out EKeyEntry entry) { if (!CASCKeyComparer.Instance.Equals(entry.EKey, key)) { throw new Exception("key failed Equals check"); // todo: temp } - return true; + return true; } public IEnumerable> GetIndexEntries() { @@ -196,17 +196,17 @@ private unsafe ArraySegment OpenIndexEntry(IndexEntry indexEntry) ref var fileHeader = ref MemoryMarshal.AsRef(buffer.AsSpan()); if (fileHeader.m_size != indexEntry.EncodedSize) { // header struct: https://github.com/ladislav-zezula/CascLib/blob/22b558e710d730edaa7b1610349081fce7fb0f7a/src/CascStructs.h#L244 - + // header check: https://github.com/ladislav-zezula/CascLib/blob/22b558e710d730edaa7b1610349081fce7fb0f7a/src/CascReadFile.cpp#L177 // also validating size matches // BUT // apparently the data can just blte with no DataHeader? // lets collect a fourCC to try debug the crashes here - + if (!BinaryPrimitives.TryReadUInt32LittleEndian(buffer.AsSpan(sizeof(DataHeader)), out var fourCC)) { fourCC = 0xDEADBEEFu; } - + // todo: i was checking for just zeroed here.. but some headers are invalid data // from aini: // Size Wrong Other Samples[0]: @@ -216,7 +216,7 @@ private unsafe ArraySegment OpenIndexEntry(IndexEntry indexEntry) // FourCC: 45544C42 // Expected Size: 30581 // Offset: 0x1FDEA87E - + // && headerSpan.SequenceEqual(ZEROED_HEADER) var headerSpan = buffer.AsSpan(0, sizeof(DataHeader)); if (ALLOW_CORRUPT_HEADER && fourCC == BLTEStream.Magic) { @@ -241,7 +241,7 @@ private unsafe ArraySegment OpenIndexEntry(IndexEntry indexEntry) } public unsafe bool OpenIndexEntryForDebug(IndexEntry indexEntry, out DataHeader header, out uint fourCC) { - + header = default; fourCC = 0xDEADBEEFu; @@ -252,7 +252,7 @@ public unsafe bool OpenIndexEntryForDebug(IndexEntry indexEntry, out DataHeader // give me fourCC sizeToRead += 4; } - + var dataHandle = m_dataFiles[indexEntry.Index]; var buffer = new byte[sizeToRead]; var bytesRead = RandomAccess.Read(dataHandle, buffer, indexEntry.Offset); @@ -273,11 +273,11 @@ public unsafe bool OpenIndexEntryForDebug(IndexEntry indexEntry, out DataHeader public string GetDataFilePath(int index) { return Path.Combine(ContainerDirectory, DataDirectory, $"data.{index:D3}") + _client.CreateArgs.ExtraFileEnding; } - + public string GetConfigFilePath(string key) { return Path.Combine(ContainerDirectory, ConfigDirectory, key.Substring(0, 2), key.Substring(2, 2), key); } - + public string GetCDNIndexFilePath(string key) { return Path.Combine(ContainerDirectory, CDNIndicesDirectory, $"{key}.index"); } @@ -298,8 +298,8 @@ public static string GetContainerDirectory(TACTProduct product) { if (product == TACTProduct.Hearthstone) return "Hearthstone_Data"; - if (product == TACTProduct.Warcraft3 || product == TACTProduct.WorldOfWarcraft || - product == TACTProduct.Diablo2 || product == TACTProduct.Diablo3 || product == TACTProduct.Diablo4 || + if (product == TACTProduct.Warcraft3 || product == TACTProduct.WorldOfWarcraft || + product == TACTProduct.Diablo2 || product == TACTProduct.Diablo3 || product == TACTProduct.Diablo4 || product == TACTProduct.BlackOps4 || product == TACTProduct.ModernWarfare) return "Data"; diff --git a/TACTLib/Container/IContainerHandler.cs b/TACTLib/Container/IContainerHandler.cs index 446a7c9..b4c6d72 100644 --- a/TACTLib/Container/IContainerHandler.cs +++ b/TACTLib/Container/IContainerHandler.cs @@ -5,4 +5,4 @@ public interface IContainerHandler { ArraySegment? OpenEKey(FullEKey ekey, int eSize); bool CheckResidency(FullEKey ekey); } -} \ No newline at end of file +} diff --git a/TACTLib/Container/StaticContainerHandler.cs b/TACTLib/Container/StaticContainerHandler.cs index 74724b7..d090e6f 100644 --- a/TACTLib/Container/StaticContainerHandler.cs +++ b/TACTLib/Container/StaticContainerHandler.cs @@ -2,30 +2,28 @@ using System.Buffers.Binary; using System.IO; using System.Linq; -using System.Numerics; using CommunityToolkit.HighPerformance.Helpers; using TACTLib.Client; using TACTLib.Helpers; namespace TACTLib.Container { - public class StaticContainerHandler : IContainerHandler { - private readonly ClientHandler m_client; - private readonly string m_containerDirectory; + public abstract class StaticContainerHandler : IContainerHandler { + protected readonly ClientHandler m_client; + protected readonly string m_basePath; - private readonly byte m_keyLayoutIndexBits; - private readonly KeyLayout[] m_keyLayouts; + protected readonly byte m_keyLayoutIndexBits; + protected readonly KeyLayout[] m_keyLayouts; - private struct KeyLayout { + protected struct KeyLayout { public byte m_chunkBits; public byte m_archiveBits; public byte m_offsetBits; - public byte m_offsetMultiplier; + public uint m_offsetMultiplier; } - public StaticContainerHandler(ClientHandler client) { + protected StaticContainerHandler(ClientHandler client) { m_client = client; - if (client.BasePath == null) throw new Exception("no 'BasePath' specified"); - m_containerDirectory = Path.Combine(client.BasePath, GetContainerDirectory(client.Product)); + m_basePath = client.BasePath ?? throw new Exception("no 'BasePath' specified"); m_keyLayoutIndexBits = byte.Parse(client.ConfigHandler.BuildConfig.Values["key-layout-index-bits"][0]); m_keyLayouts = new KeyLayout[Math.Max((int)Math.Pow(2, m_keyLayoutIndexBits), 1)]; @@ -39,12 +37,12 @@ public StaticContainerHandler(ClientHandler client) { m_chunkBits = byte.Parse(keyLayoutPair.Value[0]), m_archiveBits = byte.Parse(keyLayoutPair.Value[1]), m_offsetBits = byte.Parse(keyLayoutPair.Value[2]), - m_offsetMultiplier = keyLayoutPair.Value.Count > 3 ? byte.Parse(keyLayoutPair.Value[3]) : (byte)1 + m_offsetMultiplier = Math.Max(1, keyLayoutPair.Value.Count > 3 ? uint.Parse(keyLayoutPair.Value[3]) : 1), }; } } - private void ExtractStorageLocation(FullEKey ekey, out ulong chunk, out ulong archive, out ulong offset) { + public void ExtractStorageLocation(FullEKey ekey, out ulong chunk, out ulong archive, out ulong offset, out int keyLayoutIndex) { //var chunk = 0ul; //var archive = 0ul; //var offset = 0ul; @@ -56,7 +54,7 @@ private void ExtractStorageLocation(FullEKey ekey, out ulong chunk, out ulong ar //Console.Out.WriteLine($"{ekeyHiUl}"); var keyLayoutBitCount = m_keyLayoutIndexBits; var keyLayoutIndexBitOffset = 56-keyLayoutBitCount; - var keyLayoutIndex = BitHelper.ExtractRange(ekeyHiUl, (byte)keyLayoutIndexBitOffset, keyLayoutBitCount); + keyLayoutIndex = (int) BitHelper.ExtractRange(ekeyHiUl, (byte)keyLayoutIndexBitOffset, keyLayoutBitCount); var keyLayout = m_keyLayouts[keyLayoutIndex]; var chunkBitCount = keyLayout.m_chunkBits; @@ -72,18 +70,19 @@ private void ExtractStorageLocation(FullEKey ekey, out ulong chunk, out ulong ar offset = BitHelper.ExtractRange(ekeyHiUl, (byte) offsetBitOffset, offsetBitCount) * keyLayout.m_offsetMultiplier; } - private static string GetFileName(ulong chunk, ulong archive) { - return $"data.{chunk:D3}.{archive:D3}"; - } + protected abstract string ContainerDirectory { get; } + protected abstract string GetFileName(ulong chunk, ulong archive, int index = 0); + protected virtual string GetFilePath(ulong chunk, ulong archive, int index = 0) => Path.Join(m_basePath, ContainerDirectory, GetFileName(chunk, archive, index)); - private string GetFilePath(ulong chunk, ulong archive) { - return Path.Combine(m_containerDirectory, GetFileName(chunk, archive)); + public string GetFilePath(FullEKey ekey) { + ExtractStorageLocation(ekey, out var chunk, out var archive, out var offset, out var index); + return GetFilePath(chunk, archive, index); } public ArraySegment? OpenEKey(FullEKey ekey, int eSize) { - ExtractStorageLocation(ekey, out var chunk, out var archive, out var offset); + ExtractStorageLocation(ekey, out var chunk, out var archive, out var offset, out var index); - using var stream = File.OpenRead(GetFilePath(chunk, archive)); + using var stream = File.OpenRead(GetFilePath(chunk, archive, index)); stream.Position = (long)offset; var data = GC.AllocateUninitializedArray(eSize); stream.DefinitelyRead(data); @@ -92,15 +91,8 @@ private string GetFilePath(ulong chunk, ulong archive) { } public bool CheckResidency(FullEKey ekey) { - ExtractStorageLocation(ekey, out var chunk, out var archive, out _); - return File.Exists(GetFilePath(chunk, archive)); - } - - private static string GetContainerDirectory(TACTProduct product) { - if (product == TACTProduct.Overwatch) - return Path.Combine("data"); - - throw new NotImplementedException($"Product \"{product}\" is not supported."); + ExtractStorageLocation(ekey, out var chunk, out var archive, out _, out var index); + return File.Exists(GetFilePath(chunk, archive, index)); } } -} \ No newline at end of file +} diff --git a/TACTLib/Container/StaticContainerHandlerAttribute.cs b/TACTLib/Container/StaticContainerHandlerAttribute.cs new file mode 100644 index 0000000..1cbc784 --- /dev/null +++ b/TACTLib/Container/StaticContainerHandlerAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace TACTLib.Container; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +public sealed class StaticContainerHandlerAttribute : Attribute { + public TACTProduct Product; + + public StaticContainerHandlerAttribute(TACTProduct product) { + Product = product; + } +} diff --git a/TACTLib/Container/StaticContainerHandlerFactory.cs b/TACTLib/Container/StaticContainerHandlerFactory.cs new file mode 100644 index 0000000..55f4bb1 --- /dev/null +++ b/TACTLib/Container/StaticContainerHandlerFactory.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using TACTLib.Client; +using TACTLib.Helpers; + +namespace TACTLib.Container; + +public static class StaticContainerHandlerFactory { + private static readonly Dictionary _handlers = new(); + + public static IContainerHandler? GetHandler(TACTProduct product, ClientHandler client) { + var handlerType = GetHandlerType(product); + if (handlerType == null) return null; + + using var _ = new PerfCounter($"{handlerType.Name}::ctor`ClientHandler`Stream"); + return (IContainerHandler)Activator.CreateInstance(handlerType, client)!; + } + + public static Type? GetHandlerType(TACTProduct product) { + if (!_handlers.TryGetValue(product, out var type)) { + type = Assembly.GetExecutingAssembly().GetTypes().FirstOrDefault(x => typeof(IContainerHandler).IsAssignableFrom(x) && x.GetCustomAttributes().Any(i => i.Product == product)); + } + return type; + } + + public static void SetHandler(TACTProduct product, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type) { + _handlers[product] = type; + } +} diff --git a/TACTLib/Container/StaticContainerHandler_Fenris.cs b/TACTLib/Container/StaticContainerHandler_Fenris.cs new file mode 100644 index 0000000..495995e --- /dev/null +++ b/TACTLib/Container/StaticContainerHandler_Fenris.cs @@ -0,0 +1,23 @@ +using System.Globalization; +using TACTLib.Client; + +namespace TACTLib.Container; + +[StaticContainerHandler(TACTProduct.Diablo4)] +public class StaticContainerHandler_Fenris : StaticContainerHandler { + public StaticContainerHandler_Fenris(ClientHandler client) : base(client) { } + + protected override string GetFileName(ulong chunk, ulong archive, int index = 0) => index switch { + 1 => $"{chunk:D3}/{archive >> 16}-{GetSnoType(archive & 0xffff)}.dat", + 2 => $"{chunk:D3}/{archive}-child.dat", + 3 => $"{chunk:D3}/0x{archive:X04}-payload.dat", + _ => $"{chunk:D3}/0x{archive:X04}-meta.dat", + }; + + private static string GetSnoType(ulong type) => type switch { + 0 => "payload", + _ => (type - 1).ToString("D4", CultureInfo.InvariantCulture), + }; + + protected override string ContainerDirectory => "Data"; +} diff --git a/TACTLib/Container/StaticContainerHandler_Osi.cs b/TACTLib/Container/StaticContainerHandler_Osi.cs new file mode 100644 index 0000000..3d1ee95 --- /dev/null +++ b/TACTLib/Container/StaticContainerHandler_Osi.cs @@ -0,0 +1,11 @@ +using TACTLib.Client; + +namespace TACTLib.Container; + +[StaticContainerHandler(TACTProduct.Diablo2)] +public class StaticContainerHandler_Osi : StaticContainerHandler { + public StaticContainerHandler_Osi(ClientHandler client) : base(client) { } + + protected override string GetFileName(ulong chunk, ulong archive, int index = 0) => $"{chunk:D2}-{archive:x08}.data"; + protected override string ContainerDirectory => "data"; +} diff --git a/TACTLib/Container/StaticContainerHandler_Tank.cs b/TACTLib/Container/StaticContainerHandler_Tank.cs new file mode 100644 index 0000000..00832cd --- /dev/null +++ b/TACTLib/Container/StaticContainerHandler_Tank.cs @@ -0,0 +1,11 @@ +using TACTLib.Client; + +namespace TACTLib.Container; + +[StaticContainerHandler(TACTProduct.Overwatch)] +public class StaticContainerHandler_Tank : StaticContainerHandler { + public StaticContainerHandler_Tank(ClientHandler client) : base(client) { } + + protected override string GetFileName(ulong chunk, ulong archive, int index = 0) => $"data.{chunk:D3}.{archive:D3}"; + protected override string ContainerDirectory => "data"; +} diff --git a/TACTLib/Core/EncodingHandler.cs b/TACTLib/Core/EncodingHandler.cs index 5eefbdb..6c4e0bd 100644 --- a/TACTLib/Core/EncodingHandler.cs +++ b/TACTLib/Core/EncodingHandler.cs @@ -23,11 +23,11 @@ public class EncodingHandler { private readonly byte[] ESpecBlock; public EncodingHandler(ClientHandler client) : this(client, - client.ConfigHandler.BuildConfig.Encoding.EncodingKey, client.ConfigHandler.BuildConfig.EncodingSize!.EncodedSize) + client.ConfigHandler.BuildConfig.Encoding!.EncodingKey, client.ConfigHandler.BuildConfig.EncodingSize!.EncodedSize) { } - public EncodingHandler(ClientHandler client, FullEKey eKey, int eSize) : this(client.OpenEKey(eKey, eSize)) + public EncodingHandler(ClientHandler client, FullEKey eKey, int eSize) : this(client.OpenEKey(eKey, eSize, null)) { } @@ -184,7 +184,7 @@ private bool TryGetEKeyESpecEntry(FullEKey ekey, out EKeyESpecEntry foundEntry) foundEntry = entries[foundIndex]; return true; } - + NOT_FOUND: foundEntry = default; return false; @@ -200,7 +200,7 @@ public int GetEncodedSize(FullEKey ekey) { return checked((int)foundEntry.FileSize.ToInt()); } - + public int? GetESpecIndex(FullEKey ekey) { if (!TryGetEKeyESpecEntry(ekey, out var foundEntry)) { diff --git a/TACTLib/Core/Key/FullKey.cs b/TACTLib/Core/Key/FullKey.cs index ec481e8..05e1dff 100644 --- a/TACTLib/Core/Key/FullKey.cs +++ b/TACTLib/Core/Key/FullKey.cs @@ -13,7 +13,6 @@ namespace TACTLib.Core.Key { /// [StructLayout(LayoutKind.Sequential, Pack = 1)] [InlineArray(CASC_FULL_KEY_SIZE)] - [DebuggerDisplay("{ToHexString()}")] [SuppressMessage("ReSharper", "UseSymbolAlias")] public struct FullKey : IComparable, IEquatable { // ReSharper disable once InconsistentNaming @@ -30,6 +29,8 @@ public readonly string ToHexString() { return Extensions.ToHexString(this); } + public override string ToString() => ToHexString(); + /// /// Create from a hex string /// diff --git a/TACTLib/Core/Product/Fenris/ProductHandler_Fenris.cs b/TACTLib/Core/Product/Fenris/ProductHandler_Fenris.cs index 9b92c29..7cc3342 100644 --- a/TACTLib/Core/Product/Fenris/ProductHandler_Fenris.cs +++ b/TACTLib/Core/Product/Fenris/ProductHandler_Fenris.cs @@ -77,8 +77,10 @@ public ProductHandler_Fenris(ClientHandler client, IDisposable? stream) { return null; } - using var locStream = Client.VFS.Open(path); - return new VFSFileTree(Client, locStream!); + using var stream = Client.VFS.Open(path); + var vfs = new VFSFileTree(Client); + vfs.Load(stream); + return vfs; } private SnoManifest? LoadManifest(string path, Locale locale, SnoManifestRole role) { @@ -370,7 +372,9 @@ private static ReadOnlyMemory OpenGlobalMeta(uint id, Dictionary>> GlobalLocalizedSnoData { get; } = []; public Dictionary>> GlobalSnoData { get; } = []; diff --git a/TACTLib/Core/VFS/VFSCFile.cs b/TACTLib/Core/VFS/VFSCFile.cs deleted file mode 100644 index 8cc6cc4..0000000 --- a/TACTLib/Core/VFS/VFSCFile.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace TACTLib.Core.VFS { - public class VFSCFile : VFSFile { - public CKey CKey; - } -} \ No newline at end of file diff --git a/TACTLib/Core/VFS/VFSFile.cs b/TACTLib/Core/VFS/VFSFile.cs index 1b6d6bd..d078f82 100644 --- a/TACTLib/Core/VFS/VFSFile.cs +++ b/TACTLib/Core/VFS/VFSFile.cs @@ -1,8 +1,11 @@ namespace TACTLib.Core.VFS { - public class VFSFile { + public record struct VFSFile { public string? Name; - public int ContentSize; - public CKey EKey; public int Offset; + public CKey EKey; + public string? ESpec; + public int ESize; + public CKey? CKey; + public int CSize; } } diff --git a/TACTLib/Core/VFS/VFSFileTree.cs b/TACTLib/Core/VFS/VFSFileTree.cs index edbe7bc..8bb02ce 100644 --- a/TACTLib/Core/VFS/VFSFileTree.cs +++ b/TACTLib/Core/VFS/VFSFileTree.cs @@ -1,8 +1,7 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; @@ -13,44 +12,51 @@ public class VFSFileTree { private readonly ClientHandler _client; private readonly Dictionary _files; - private readonly VFSManifestReader.Manifest _manifest; + private readonly List _manifests; - public readonly ReadOnlyCollection Files; + public IEnumerable Files => _files.Keys; - public VFSFileTree(ClientHandler client, Stream stream) { + public VFSFileTree(ClientHandler client) { _client = client; - using BinaryReader reader = new BinaryReader(stream, Encoding.ASCII); - //using (Stream file = File.OpenWrite("vfs.hex")) { - // stream.CopyTo(file); - // stream.Position = 0; - //} + _files = []; + _manifests = []; + } + + public bool Load(Stream? stream) { + if (!IsVFSFile(stream)) { + return false; + } + + using var reader = new BinaryReader(stream, Encoding.ASCII); - _manifest = VFSManifestReader.Read(reader); + var manifest = VFSManifestReader.Read(reader); + _manifests.Add(manifest); - _files = new Dictionary(_manifest.Files.Count); - foreach (VFSFile file in _manifest.Files) { + _files.EnsureCapacity(_files.Count + manifest.Files.Count); + foreach (var file in manifest.Files) { if (file.Name != null) { - _files[file.Name] = file; + _files.Add(file.Name, file); } } - Files = Array.AsReadOnly(_files.Keys.ToArray()); + return true; } - /// - /// Checks if a stream is a VFS file - /// - /// - /// - public static bool IsVFSFile(Stream stream) { - if (stream.Length - stream.Position < Unsafe.SizeOf()) { - return false; - } - - var magic = 0u; - stream.ReadExactly(MemoryMarshal.AsBytes(new Span(ref magic))); - return magic == 0x53465654; - } + /// + /// Checks if a stream is a VFS file + /// + /// + /// + public static bool IsVFSFile([NotNullWhen(true)] Stream? stream) { + if (stream == null || stream.Length - stream.Position < Unsafe.SizeOf()) { + return false; + } + + var magic = 0u; + stream.ReadExactly(MemoryMarshal.AsBytes(new Span(ref magic))); + stream.Position -= sizeof(uint); + return magic == 0x53465654; + } /// /// Open file by path @@ -59,19 +65,23 @@ public static bool IsVFSFile(Stream stream) { /// /// where esize? public Stream? Open(string file) { - if (!_files.TryGetValue(file, out var vfsFile)) { - return null; - } + if (!_files.TryGetValue(file, out var vfsFile)) { + return null; + } + + if (vfsFile.CKey is {} cKey && _client.OpenCKey(cKey) is {} stream) { + return stream; + } - if (vfsFile is VFSCFile cFile) { - return _client.OpenCKey(cFile.CKey); - } + if (vfsFile.ESize == 0) { + if (_client.EncodingHandler == null) { + throw new NotSupportedException("where esize?"); + } - if (_client.IsStaticContainer && vfsFile.ContentSize == 0) { - throw new NotImplementedException("where esize?"); - } + vfsFile.ESize = _client.EncodingHandler.GetEncodedSize(vfsFile.EKey); + } - return _client.OpenEKey(vfsFile.EKey, vfsFile.ContentSize == 0 ? _client.EncodingHandler!.GetEncodedSize(vfsFile.EKey) : vfsFile.ContentSize); - } + return _client.OpenEKey(vfsFile.EKey, vfsFile.ESize, vfsFile.ESpec); + } } } diff --git a/TACTLib/Core/VFS/VFSManifestReader.cs b/TACTLib/Core/VFS/VFSManifestReader.cs index 8028027..2b53a37 100644 --- a/TACTLib/Core/VFS/VFSManifestReader.cs +++ b/TACTLib/Core/VFS/VFSManifestReader.cs @@ -54,7 +54,12 @@ public enum PathEntryFlags { /// /// The NodeValue in path table entry is valid /// - NODE_VALUE = 0x0004 + NODE_VALUE = 0x0004, + + /// + /// Path is a byte sequence, not text + /// + PATH_IS_BYTES = 0x0008, } [StructLayout(LayoutKind.Sequential, Pack = 1)] @@ -147,14 +152,16 @@ public class Manifest { public readonly uint VfsTableSize; public readonly uint CftTableOffset; public readonly uint CftTableSize; + public readonly uint EstTableOffset; + public readonly uint EstTableSize; public readonly ushort MaxDepth; public readonly int CftOffsSize; - //public readonly int EstOffsSize; + public readonly int EstOffsSize; public readonly List Files; - public Manifest(ManifestHeader header) { + public Manifest(ManifestHeader header, BinaryReader reader) { Header = header; Flags = header.GetFlags(); @@ -165,10 +172,15 @@ public Manifest(ManifestHeader header) { CftTableOffset = header.CftTableOffset.ToInt(); CftTableSize = header.CftTableSize.ToInt(); MaxDepth = header.MaxDepth.ToInt(); + CftOffsSize = GetOffsetFieldSize(CftTableSize); - //EstOffsSize = GetOffsetFieldSize(header.EstTableSize); + if ((Flags & ManifestFlags.WRITE_SUPPORT) != 0) { + EstTableOffset = reader.ReadUInt32BE(); + EstTableSize = reader.ReadUInt32BE(); + EstOffsSize = GetOffsetFieldSize(EstTableSize); + } - Files = new List(); + Files = []; } // Returns size of "container file table offset" files in the VFS. @@ -199,7 +211,7 @@ private static byte PeekByte(BinaryReader reader) { return value; } - private static PathEntry ReadPathEntry(BinaryReader reader, long pathTableEnd) { + private static PathEntry ReadPathEntry(BinaryReader reader, long pathTableEnd, bool isBytes) { var pathEntry = new PathEntry(); var bBefore = PeekByte(reader); @@ -210,7 +222,20 @@ private static PathEntry ReadPathEntry(BinaryReader reader, long pathTableEnd) { if (reader.BaseStream.Position < pathTableEnd && bBefore != 0xFF) { var length = reader.ReadByte(); - pathEntry.Name = Encoding.UTF8.GetString(reader.ReadBytes(length)); + if (length > 0) { + var bBeforePath = PeekByte(reader); + if (bBeforePath == 0xFF && !isBytes) { + pathEntry.NodeFlags |= PathEntryFlags.PATH_IS_BYTES; + isBytes = true; + reader.BaseStream.Position++; + length--; + } + + if (length > 0) { + var pathBytes = reader.ReadBytes(length); + pathEntry.Name = isBytes ? Convert.ToHexString(pathBytes) : Encoding.UTF8.GetString(pathBytes); + } + } } var bAfter = PeekByte(reader); @@ -237,11 +262,11 @@ private static PathEntry ReadPathEntry(BinaryReader reader, long pathTableEnd) { public static Manifest Read(BinaryReader reader) { var header = reader.Read(); - if (header.Magic != 0x53465654) { - throw new InvalidDataException(); - } + if (header.Magic != 0x53465654) { + throw new InvalidDataException(); + } - Manifest manifest = new Manifest(header); + Manifest manifest = new Manifest(header, reader); ParseDirectoryData(manifest, reader); @@ -271,16 +296,17 @@ private static void ParseDirectoryData(Manifest manifest, BinaryReader reader) { } } - ParsePathFileTable(manifest, reader, rootDirPtr, rootDirEnd, ""); + ParsePathFileTable(manifest, reader, rootDirPtr, rootDirEnd); } - private static void ParsePathFileTable(Manifest manifest, BinaryReader reader, long pathTablePtr, long pathTableEnd, string pathBuffer) { + private static void ParsePathFileTable(Manifest manifest, BinaryReader reader, long pathTablePtr, long pathTableEnd, string pathBuffer = "", bool isBytes = false) { reader.BaseStream.Position = pathTablePtr; string pathBufferBak = pathBuffer; while (reader.BaseStream.Position < pathTableEnd) { - var entry = ReadPathEntry(reader, pathTableEnd); + var entry = ReadPathEntry(reader, pathTableEnd, isBytes); + isBytes |= (entry.NodeFlags & PathEntryFlags.PATH_IS_BYTES) != 0; pathBuffer = AppendNodeToPath(entry, pathBuffer); @@ -296,7 +322,7 @@ private static void ParsePathFileTable(Manifest manifest, BinaryReader reader, l Debug.Assert((entry.NodeValue & TVFS_FOLDER_SIZE_MASK) >= sizeof(int)); // Recursively call the folder parser on the same file - ParsePathFileTable(manifest, reader, reader.BaseStream.Position, directoryEnd, pathBuffer); + ParsePathFileTable(manifest, reader, reader.BaseStream.Position, directoryEnd, pathBuffer, isBytes); // skip directory data reader.BaseStream.Position = directoryEnd; @@ -346,16 +372,7 @@ private static VFSFile ReadVfsSpanEntries(Manifest manifest, BinaryReader reader var fileOffset = reader.ReadInt32BE(); spanSize = reader.ReadInt32BE(); - var cftOffset = 0; - if (manifest.CftOffsSize == 1) { - cftOffset = reader.ReadByte(); - } else if (manifest.CftOffsSize == 2) { - cftOffset = reader.ReadInt16BE(); - } else if (manifest.CftOffsSize == 3) { - cftOffset = reader.ReadInt24BE(); - } else if (manifest.CftOffsSize == 4) { - cftOffset = reader.ReadInt32BE(); - } + var cftOffset = ReadVarOfs(reader, manifest.CftOffsSize); var cftFileTable = manifest.CftTableOffset; var cftFileEntry = cftFileTable + cftOffset; @@ -363,15 +380,38 @@ private static VFSFile ReadVfsSpanEntries(Manifest manifest, BinaryReader reader reader.BaseStream.Position = cftFileEntry; var eKey = reader.Read(); - VFSFile file = new VFSFile { - Offset = fileOffset, - ContentSize = spanSize, + var encSize = reader.ReadInt32BE(); + var eSpecOffset = ReadVarOfs(reader, manifest.EstOffsSize); + var cKey = (manifest.Flags & ManifestFlags.INCLUDE_CKEY) != 0 ? reader.Read() : default; + // todo: patch support + + var eSpec = default(string?); + if (eSpecOffset > -1) { + reader.BaseStream.Position = manifest.EstTableOffset + eSpecOffset; + eSpec = reader.ReadCString(); + } + var file = new VFSFile { Name = null, - EKey = eKey + Offset = fileOffset, + EKey = eKey, + ESpec = eSpec, + ESize = encSize, + CKey = cKey, + CSize = spanSize, }; return file; } + private static int ReadVarOfs(BinaryReader reader, int size) => + size switch { + 0 => -1, + 1 => reader.ReadByte(), + 2 => reader.ReadInt16BE(), + 3 => reader.ReadInt24BE(), + 4 => reader.ReadInt32BE(), + _ => -1 + }; + private static string AppendNodeToPath(PathEntry entry, string path) { if ((entry.NodeFlags & PathEntryFlags.PATH_SEPARATOR_PRE) != 0) path += "/"; diff --git a/TACTLib/Helpers/Extensions.cs b/TACTLib/Helpers/Extensions.cs index 75fb3c3..5a68aaa 100644 --- a/TACTLib/Helpers/Extensions.cs +++ b/TACTLib/Helpers/Extensions.cs @@ -1,12 +1,13 @@ using System; using System.IO; using System.Runtime.InteropServices; +using System.Text; using CommunityToolkit.HighPerformance; namespace TACTLib.Helpers { public static class Extensions { #region BinaryReader - + public static void DefinitelyRead(this Stream stream, Span buffer) { stream.ReadExactly(buffer); @@ -16,7 +17,7 @@ public static void DefinitelyRead(this BinaryReader reader, Span buffer) { DefinitelyRead(reader.BaseStream, buffer); } - + /// /// Read struct from a BinaryReader /// @@ -27,14 +28,14 @@ public static T Read(this BinaryReader reader) where T : unmanaged { return reader.BaseStream.Read(); } - + public static unsafe T Read(this Stream stream) where T : unmanaged { var result = default(T); stream.DefinitelyRead(new Span(&result, sizeof(T))); return result; } - + /// /// Read array of structs from a reader /// @@ -46,11 +47,11 @@ public static T[] ReadArray(this BinaryReader reader, int count) where T : un { return reader.BaseStream.ReadArray(count); } - + public static T[] ReadArray(this Stream stream, int count) where T : unmanaged { if (count == 0) return Array.Empty(); - + var result = new T[count]; stream.DefinitelyRead(result.AsSpan().AsBytes()); return result; @@ -67,7 +68,7 @@ public static void Write(this BinaryWriter writer, T @struct) where T : unman var bytes = MemoryMarshal.CreateReadOnlySpan(ref @struct, 1).AsBytes(); writer.Write(bytes); } - + /// /// Write an array of structs to a BinaryWriter /// @@ -79,7 +80,7 @@ public static void WriteStructArray(this BinaryWriter writer, T[] @struct) wh var bytes = @struct.AsSpan().AsBytes(); writer.Write(bytes); } - + /// Read a big endian 32-bit int // ReSharper disable once InconsistentNaming public static int ReadInt32BE(this BinaryReader reader) @@ -93,7 +94,7 @@ public static short ReadInt16BE(this BinaryReader reader) { return (short)ReadUInt16BE(reader); } - + /// Read a big endian 16-bit uint // ReSharper disable once InconsistentNaming public static ushort ReadUInt16BE(this BinaryReader reader) @@ -109,12 +110,31 @@ public static int ReadInt24BE(this BinaryReader reader) var s = reader.Read(); return s.ToInt(); } + + /// Read a big endian 32-bit uint + // ReSharper disable once InconsistentNaming + public static uint ReadUInt32BE(this BinaryReader reader) + { + var s = reader.Read(); + return s.ToInt(); + } + + /// Read a big endian 32-bit uint + // ReSharper disable once InconsistentNaming + public static string ReadCString(this BinaryReader reader) { + var sb = new StringBuilder(); + byte ch; + while((ch = reader.ReadByte()) != 0) { + sb.Append((char) ch); + } + return sb.ToString(); + } #endregion - + /// Convert to a hexadecimal string public static string ToHexString(this ReadOnlySpan data) { return Convert.ToHexString(data); } } -} \ No newline at end of file +} diff --git a/TACTLib/TACTProduct.cs b/TACTLib/TACTProduct.cs index acb111f..61e66e2 100644 --- a/TACTLib/TACTProduct.cs +++ b/TACTLib/TACTProduct.cs @@ -16,7 +16,7 @@ public enum TACTProduct { /// catalogs Catalog, - /// osib + /// osi, osib, osi_dev, osi_dev_2, osi_vendor_* Diablo2, /// d3, d3b, d3cn, d3t @@ -51,7 +51,7 @@ public enum TACTProduct { /// odin ModernWarfare, - + /// fenris Diablo4, } @@ -257,4 +257,4 @@ public static TACTProduct TryGetProductFromUID(string? productCode) { } } } -} \ No newline at end of file +}