Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions WheelWizard/Features/Archives/ArchivesExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace WheelWizard.Features.Archives;

public static class ArchivesExtensions
{
public static IServiceCollection AddArchives(this IServiceCollection services)
{
services.AddSingleton<ISzsArchiveDecoder, SzsArchiveDecoder>();
return services;
}
}
3 changes: 3 additions & 0 deletions WheelWizard/Features/Archives/Domain/DecodedArchive.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace WheelWizard.Features.Archives;

public sealed record DecodedArchive(IReadOnlyDictionary<string, byte[]> Files);
3 changes: 3 additions & 0 deletions WheelWizard/Features/Archives/Domain/U8Node.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace WheelWizard.Features.Archives;

public sealed record U8Node(int Type, int NameOffset, int DataOffset, int Size);
8 changes: 8 additions & 0 deletions WheelWizard/Features/Archives/ISzsArchiveDecoder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace WheelWizard.Features.Archives;

public interface ISzsArchiveDecoder
{
OperationResult<DecodedArchive> TryDecodeU8Archive(byte[] bytes);

OperationResult<byte[]> DecompressYaz0IfNeeded(byte[] bytes);
}
176 changes: 176 additions & 0 deletions WheelWizard/Features/Archives/SzsArchiveDecoder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
using WheelWizard.Helpers;

namespace WheelWizard.Features.Archives;

public sealed class SzsArchiveDecoder : ISzsArchiveDecoder
{
private const uint U8Magic = 0x55aa382d;

public OperationResult<DecodedArchive> TryDecodeU8Archive(byte[] bytes)
{
try
{
var decompressResult = DecompressYaz0IfNeeded(bytes);
if (decompressResult.IsFailure)
return decompressResult.Error;

var raw = decompressResult.Value;

if (raw.Length < 8)
return new OperationError { Message = "The provided file is too small to contain a valid U8 archive header." };
if (BigEndianBinaryHelper.BufferToUint32(raw, 0) != U8Magic)
return new OperationError { Message = "The provided file is not a valid Yaz0/U8 archive." };

return ParseU8Archive(raw);
}
catch (Exception ex)
{
return new OperationError { Message = $"Failed to decode U8 archive: {ex.Message}", Exception = ex };
}
}

public OperationResult<byte[]> DecompressYaz0IfNeeded(byte[] bytes)
{
try
{
if (bytes.Length < 4)
return bytes;

if (BinaryStringHelper.ReadAscii(bytes, 0, 4) != "Yaz0")
return bytes;

if (bytes.Length < 8)
return new OperationError { Message = "Yaz0 header is truncated." };

var outputSize = checked((int)BigEndianBinaryHelper.BufferToUint32(bytes, 4));
var output = new byte[outputSize];
var src = 0x10;
var dst = 0;
var groupHeader = 0;
var bitsRemaining = 0;

while (dst < output.Length)
{
if (bitsRemaining == 0)
{
if (src >= bytes.Length)
return new OperationError { Message = "Yaz0 group header is truncated." };
groupHeader = bytes[src++];
bitsRemaining = 8;
}

if ((groupHeader & 0x80) != 0)
{
if (src >= bytes.Length)
return new OperationError { Message = "Yaz0 literal chunk is truncated." };
output[dst++] = bytes[src++];
}
else
{
if (src + 1 >= bytes.Length)
return new OperationError { Message = "Yaz0 backreference is truncated." };

var b1 = bytes[src++];
var b2 = bytes[src++];
var backOffset = (((b1 & 0x0f) << 8) | b2) + 1;
var length = b1 >> 4;
if (length == 0)
{
if (src >= bytes.Length)
return new OperationError { Message = "Yaz0 extended length byte is truncated." };
length = bytes[src++] + 0x12;
}
else
{
length += 2;
}

if (backOffset > dst)
return new OperationError { Message = "Yaz0 backreference offset is out of bounds." };

var copySrc = dst - backOffset;
for (var index = 0; index < length && dst < output.Length; index++)
output[dst++] = output[copySrc++];
}

groupHeader <<= 1;
bitsRemaining--;
}

return output;
}
catch (Exception ex)
{
return new OperationError { Message = $"Failed to decompress Yaz0 data: {ex.Message}", Exception = ex };
}
}

private static DecodedArchive ParseU8Archive(byte[] bytes)
{
var rootOffset = (int)BigEndianBinaryHelper.BufferToUint32(bytes, 4);
var rootNode = ReadU8Node(bytes, rootOffset);
var nodeCount = rootNode.Size;
var stringTableOffset = rootOffset + nodeCount * 12;
var files = new Dictionary<string, byte[]>(StringComparer.Ordinal);

if (rootNode.Type != 1)
throw new InvalidDataException("U8 root node is not a directory.");
if (nodeCount == 0 || stringTableOffset > bytes.Length)
throw new InvalidDataException("U8 node table is invalid or truncated.");

void Walk(int dirIndex, string prefix, int parentEndIndex)
{
var nodeIndex = dirIndex + 1;
var directoryNode = ReadU8Node(bytes, rootOffset + dirIndex * 12);
var endIndex = Math.Min(directoryNode.Size, parentEndIndex);

if (endIndex <= dirIndex)
return;

while (nodeIndex < endIndex)
{
var nodeOffset = rootOffset + nodeIndex * 12;
var node = ReadU8Node(bytes, nodeOffset);
var nameOffset = stringTableOffset + node.NameOffset;

if (nameOffset < stringTableOffset || nameOffset >= bytes.Length)
{
nodeIndex++;
continue;
}

var name = BinaryStringHelper.ReadNullTerminatedAscii(bytes, nameOffset);
var logicalPath = string.IsNullOrEmpty(prefix) ? name : $"{prefix}/{name}";

if (node.Type == 1)
{
Walk(nodeIndex, logicalPath, endIndex);
nodeIndex = Math.Min(Math.Max(node.Size, nodeIndex + 1), endIndex);
}
else
{
var endOffset = node.DataOffset + node.Size;
if (endOffset <= bytes.Length && endOffset >= node.DataOffset)
files[logicalPath] = bytes[node.DataOffset..endOffset];
nodeIndex++;
}
}
}

Walk(0, string.Empty, nodeCount);
return new(files);
}

private static U8Node ReadU8Node(byte[] bytes, int offset)
{
if (offset + 12 > bytes.Length)
throw new InvalidDataException("U8 node table is truncated.");

return new(
bytes[offset] == 0 ? 0 : 1,
(bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3],
(int)BigEndianBinaryHelper.BufferToUint32(bytes, offset + 4),
(int)BigEndianBinaryHelper.BufferToUint32(bytes, offset + 8)
);
}
}
14 changes: 12 additions & 2 deletions WheelWizard/Features/GameBanana/Domain/GameBananaModPreview.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Text.Json.Serialization;
using WheelWizard.Services;

namespace WheelWizard.GameBanana.Domain;

Expand Down Expand Up @@ -56,5 +55,16 @@ public class GameBananaModPreview
public required string ModelName { get; set; }

[JsonIgnore]
public bool UsesPatches => ModStorageSystemHelper.UsesPatches(Tags);
public bool UsesPatches => Tags.Any(tag => IsPatchesTag(tag.Title));

private static bool IsPatchesTag(string? tagTitle)
{
var normalizedTitle = tagTitle?.Trim();
if (string.IsNullOrWhiteSpace(normalizedTitle))
return false;

var titleOnly = normalizedTitle.Split(':', 2)[0].Trim();
return titleOnly.Equals("patch", StringComparison.OrdinalIgnoreCase)
|| titleOnly.Equals("patches", StringComparison.OrdinalIgnoreCase);
}
}
Loading
Loading