using System; using System.IO; using SabreTools.Data.Models.NintendoDisc; using SabreTools.Data.Models.WIA; using SabreTools.Hashing; #if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER using SabreTools.IO.Extensions; #endif using SabreTools.Security.Cryptography; #if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER using SharpCompress.Compressors; using SharpCompress.Compressors.BZip2; using SharpCompress.Compressors.LZMA; using SharpCompress.Compressors.ZStandard; #endif using static SabreTools.Data.Models.NintendoDisc.Constants; using static SabreTools.Data.Models.WIA.Constants; using WiaReader = SabreTools.Serialization.Readers.WIA; namespace SabreTools.Wrappers { public partial class WIA : WrapperBase { #region Descriptive Properties /// public override string DescriptionString => "WIA / RVZ Compressed GameCube / Wii Disc Image"; #endregion #region Extension Properties /// public WiaGroupEntry[]? GroupEntries => Model.GroupEntries; /// public WiaHeader1 Header1 => Model.Header1; /// public WiaHeader2 Header2 => Model.Header2; /// True if this is an RVZ file; false if this is a WIA file. public bool IsRvz => Header1.Magic == RvzMagic; /// public PartitionEntry[]? PartitionEntries => Model.PartitionEntries; /// public RawDataEntry[] RawDataEntries => Model.RawDataEntries; /// public RvzGroupEntry[]? RvzGroupEntries => Model.RvzGroupEntries; /// /// Total uncompressed ISO size in bytes /// public ulong IsoFileSize => Header1.IsoFileSize; /// /// Disc header parsed from the 128-byte raw disc header stored in Header2. /// public DiscHeader? DiscHeader { get { if (field is not null) return field; byte[]? raw = Header2.DiscHeader; if (raw is null || raw.Length < 0x20) return null; using var ms = new MemoryStream(raw); field = Serialization.Readers.NintendoDisc.ParseDiscHeader(ms); return field; } } #endregion #region Constructors /// public WIA(DiscImage model, byte[] data) : base(model, data) { } /// public WIA(DiscImage model, byte[] data, int offset) : base(model, data, offset) { } /// public WIA(DiscImage model, byte[] data, int offset, int length) : base(model, data, offset, length) { } /// public WIA(DiscImage model, Stream data) : base(model, data) { } /// public WIA(DiscImage model, Stream data, long offset) : base(model, data, offset) { } /// public WIA(DiscImage model, Stream data, long offset, long length) : base(model, data, offset, length) { } #endregion #region Static Constructors /// /// Create a WIA/RVZ wrapper from a byte array and offset /// /// Byte array representing the WIA or RVZ image /// Offset within the array to parse /// A WIA wrapper on success, null on failure public static WIA? 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 WIA/RVZ wrapper from a Stream /// /// Stream representing the WIA or RVZ image /// A WIA wrapper on success, null on failure public static WIA? Create(Stream? data) { // If the data is invalid if (data is null || !data.CanRead) return null; try { long currentOffset = data.Position; var model = new WiaReader().Deserialize(data); if (model is null) return null; #if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER // The reader parsed the compressed table blobs as raw bytes. // Re-read and decompress them here now that we have the compression parameters. DecompressTables(model, data, currentOffset); #endif return new WIA(model, data, currentOffset); } catch { return null; } } #if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER /// /// Re-reads the partition entries, raw data entries, and group entries from the source /// stream, decompresses them using the algorithm specified in Header2, and replaces the /// (garbage) values that the reader left in the model. /// private static void DecompressTables(DiscImage model, Stream data, long baseOffset) { var comp = model.Header2.CompressionType; // None / Purge tables are stored as plain big-endian structs — already parsed correctly. if (comp == WiaRvzCompressionType.None || comp == WiaRvzCompressionType.Purge) return; var compData = model.Header2.CompressorData ?? new byte[7]; byte compDataSize = model.Header2.CompressorDataSize; // --- Raw data entries (stored compressed) --- if (model.Header2.NumberOfRawDataEntries > 0 && model.Header2.RawDataEntriesOffset > 0 && model.Header2.RawDataEntriesSize > 0) { int count = (int)model.Header2.NumberOfRawDataEntries; int compressedSize = (int)model.Header2.RawDataEntriesSize; int expectedSize = count * RawDataEntrySize; data.Seek(baseOffset + (long)model.Header2.RawDataEntriesOffset, SeekOrigin.Begin); byte[] buf = new byte[compressedSize]; int read = data.Read(buf, 0, compressedSize); if (read < compressedSize) return; byte[] plain = Decompress(comp, buf, 0, compressedSize, compData, compDataSize); if (plain is null || plain.Length < expectedSize) return; model.RawDataEntries = ParseRawDataEntries(plain, count); } // --- Group entries (stored compressed) --- if (model.Header2.NumberOfGroupEntries > 0 && model.Header2.GroupEntriesOffset > 0 && model.Header2.GroupEntriesSize > 0) { int count = (int)model.Header2.NumberOfGroupEntries; int compressedSize = (int)model.Header2.GroupEntriesSize; int entrySize = model.Header1.Magic == RvzMagic ? RvzGroupEntrySize : WiaGroupEntrySize; int expectedSize = count * entrySize; data.Seek(baseOffset + (long)model.Header2.GroupEntriesOffset, SeekOrigin.Begin); byte[] buf = new byte[compressedSize]; int read = data.Read(buf, 0, compressedSize); if (read < compressedSize) return; byte[] plain = Decompress(comp, buf, 0, compressedSize, compData, compDataSize); if (plain is null || plain.Length < expectedSize) return; if (model.Header1.Magic == RvzMagic) model.RvzGroupEntries = ParseRvzGroupEntries(plain, count); else model.GroupEntries = ParseWiaGroupEntries(plain, count); } } /// /// Parses raw data entries from a plain (already decompressed) byte array. /// private static RawDataEntry[] ParseRawDataEntries(byte[] plain, int count) { var entries = new RawDataEntry[count]; int offset = 0; for (int i = 0; i < count; i++) { entries[i] = WiaReader.ParseRawDataEntry(plain, ref offset); } return entries; } /// /// Parses WIA group entries from a plain (already decompressed) byte array. /// private static WiaGroupEntry[] ParseWiaGroupEntries(byte[] plain, int count) { var entries = new WiaGroupEntry[count]; int offset = 0; for (int i = 0; i < count; i++) { entries[i] = WiaReader.ParseWiaGroupEntry(plain, ref offset); } return entries; } /// /// Parses RVZ group entries from a plain (already decompressed) byte array. /// private static RvzGroupEntry[] ParseRvzGroupEntries(byte[] plain, int count) { var entries = new RvzGroupEntry[count]; int offset = 0; for (int i = 0; i < count; i++) { entries[i] = WiaReader.ParseRvzGroupEntry(plain, ref offset); } return entries; } #endif #endregion #region Compression /// /// Dictionary sizes per compression level 1-9 (index 0 unused). /// /// Mirrors Dolphin WIACompression.cpp dict_size choices. private static readonly int[] DictSizes = [ 0, // 0: unused 1 << 16, // 1: 64 KiB 1 << 20, // 2: 1 MiB 1 << 22, // 3: 4 MiB 1 << 22, // 4: 4 MiB 1 << 23, // 5: 8 MiB 1 << 23, // 6: 8 MiB 1 << 24, // 7: 16 MiB 1 << 25, // 8: 32 MiB 1 << 26, // 9: 64 MiB ]; /// /// /// /// /// private static int GetDictSize(int level) => DictSizes[Math.Max(1, Math.Min(9, level))]; /// /// Returns the raw LZMA2 dict-size property byte for a given dictionary size. /// private static uint Lzma2DictSize(byte p) => (uint)((2 | (p & 1)) << ((p / 2) + 11)); /// /// /// /// /// private static byte EncodeLzma2DictSize(uint d) { byte e = 0; while (e < 40 && d > Lzma2DictSize(e)) { e++; } return e; } /// /// Fills the compressor-data bytes for /// and . /// LZMA: 5 bytes. LZMA2: 1 byte. Others: 0 bytes. /// internal static void GetCompressorData(WiaRvzCompressionType type, int level, out byte[] propData, out byte propSize) { propData = new byte[7]; int dictSize = GetDictSize(level); switch (type) { case WiaRvzCompressionType.LZMA: propData[0] = 0x5D; // propByte for default pb=2,lp=0,lc=3 propData[1] = (byte)dictSize; propData[2] = (byte)(dictSize >> 8); propData[3] = (byte)(dictSize >> 16); propData[4] = (byte)(dictSize >> 24); propSize = 5; break; case WiaRvzCompressionType.LZMA2: propData[0] = EncodeLzma2DictSize((uint)dictSize); propSize = 1; break; // All cases below default to 0 case WiaRvzCompressionType.None: case WiaRvzCompressionType.Purge: case WiaRvzCompressionType.Bzip2: case WiaRvzCompressionType.Zstd: default: propSize = 0; break; } } /// /// Compress using the specified algorithm. /// internal static byte[] Compress(WiaRvzCompressionType type, byte[] data, int offset, int length, int level, byte[] compressorData, byte compressorDataSize) { #if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER switch (type) { case WiaRvzCompressionType.Bzip2: return CompressBzip2(data, offset, length); case WiaRvzCompressionType.LZMA: return CompressLzma(data, offset, length, level, isLzma2: false); case WiaRvzCompressionType.LZMA2: return CompressLzma(data, offset, length, level, isLzma2: true); case WiaRvzCompressionType.Zstd: return CompressZstd(data, offset, length, level); // Do not use compression case WiaRvzCompressionType.None: case WiaRvzCompressionType.Purge: default: throw new ArgumentException($"Cannot compress type {type}", nameof(type)); } #else throw new PlatformNotSupportedException("WIA/RVZ compression requires .NET 4.6.2 or later."); #endif } /// /// Decompress using the specified algorithm. /// internal static byte[] Decompress(WiaRvzCompressionType type, byte[] data, int offset, int length, byte[] compressorData, byte compressorDataSize) { #if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER switch (type) { case WiaRvzCompressionType.Bzip2: return DecompressBzip2(data, offset, length); case WiaRvzCompressionType.LZMA: byte[] lzmaProps = new byte[compressorDataSize]; Array.Copy(compressorData, lzmaProps, compressorDataSize); return DecompressLzma(data, offset, length, lzmaProps, isLzma2: false); case WiaRvzCompressionType.LZMA2: byte[] lzma2Props = new byte[compressorDataSize]; Array.Copy(compressorData, lzma2Props, compressorDataSize); return DecompressLzma(data, offset, length, lzma2Props, isLzma2: true); case WiaRvzCompressionType.Zstd: return DecompressZstd(data, offset, length); // Do not use compression case WiaRvzCompressionType.None: case WiaRvzCompressionType.Purge: default: throw new ArgumentException($"Cannot decompress type {type}", nameof(type)); } #else throw new PlatformNotSupportedException("WIA/RVZ decompression requires .NET 4.6.2 or later."); #endif } #if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER /// /// Compress data using bzip2 /// /// Source data /// Offset into the source data /// Length within the source data /// Compressed data private static byte[] CompressBzip2(byte[] data, int offset, int length) { using var outMs = new MemoryStream(); using (var bz2 = BZip2Stream.Create(outMs, CompressionMode.Compress, false, true)) { bz2.Write(data, offset, length); } return outMs.ToArray(); } /// /// Decompress data using bzip2 /// /// Source data /// Offset into the source data /// Length within the source data /// Uncompressed data private static byte[] DecompressBzip2(byte[] data, int offset, int length) { using var inMs = new MemoryStream(data, offset, length); using var bz2 = BZip2Stream.Create(inMs, CompressionMode.Decompress, false, false); using var outMs = new MemoryStream(); bz2.BlockCopy(outMs); return outMs.ToArray(); } /// /// Compress data using LZMA/LZMA2 /// /// Source data /// Offset into the source data /// Length within the source data /// LZMA/LZMA2 level /// Indicates if LZMA2 is used /// Compressed data private static byte[] CompressLzma(byte[] data, int offset, int length, int level, bool isLzma2) { int dictSize = GetDictSize(level); using var outMs = new MemoryStream(); using (var lzma = LzmaStream.Create(new LzmaEncoderProperties(true, dictSize), isLzma2, outMs)) { lzma.Write(data, offset, length); } return outMs.ToArray(); } /// /// Decompress data using LZMA/LZMA2 /// /// Source data /// Offset into the source data /// Length within the source data /// LZMA properties /// Indicates if LZMA2 is used /// Uncompressed data private static byte[] DecompressLzma(byte[] data, int offset, int length, byte[] props, bool isLzma2) { using var inMs = new MemoryStream(data, offset, length); using var lzma = LzmaStream.Create(props, inMs, length, -1, null, isLzma2, false); using var outMs = new MemoryStream(); lzma.BlockCopy(outMs); return outMs.ToArray(); } /// /// Compress data using Zstd /// /// Source data /// Offset into the source data /// Length within the source data /// Zstd level /// Compressed data private static byte[] CompressZstd(byte[] data, int offset, int length, int level) { using var outMs = new MemoryStream(); using (var zstd = new ZStandardStream(outMs, CompressionMode.Compress, level)) { zstd.Write(data, offset, length); } return outMs.ToArray(); } /// /// Decompress data using Zstd /// /// Source data /// Offset into the source data /// Length within the source data /// Uncompressed data private static byte[] DecompressZstd(byte[] data, int offset, int length) { using var inMs = new MemoryStream(data, offset, length); using var zstd = new ZStandardStream(inMs); using var outMs = new MemoryStream(); zstd.BlockCopy(outMs); return outMs.ToArray(); } #endif #endregion #region Inner Wrapper // Cache for on-demand decompression in ReadVirtual. private uint _cachedRawGroupIndex = uint.MaxValue; private byte[]? _cachedRawGroup; private uint _cachedEncGroupIndex = uint.MaxValue; private byte[]? _cachedEncGroup; /// /// Returns a NintendoDisc wrapper backed by a virtual stream that decompresses /// WIA/RVZ groups on demand, avoiding loading the entire ISO into memory. /// public NintendoDisc? GetInnerWrapper() { if (Header1.IsoFileSize == 0) return null; var vStream = new WiaVirtualStream(this); var disc = NintendoDisc.Create(vStream); if (disc is null) return null; // For Wii discs: WIA/RVZ stores partition data already decrypted. // Wire a pre-decrypted reader so NintendoDisc.Extraction bypasses its // AES-CBC decrypt pass and reads directly from our decompressed groups. if (PartitionEntries is not null && PartitionEntries.Length > 0) disc._preDecryptedReader = PreDecryptedReader; return disc; } /// /// Used by . /// Matches (absolute ISO offset of the encrypted data /// area) to the corresponding WIA by comparing it with /// de.FirstSector * 0x8000, then delegates to /// . /// private byte[]? PreDecryptedReader(long absDataOffset, long partitionDataOffset, int length) { if (PartitionEntries is null) return null; foreach (var entry in PartitionEntries) { // The data area of this partition starts at de.FirstSector * 0x8000 long deIsoStart = (long)entry.DataEntry0.FirstSector * WiiBlockSize; long deIsoEnd = deIsoStart + ((long)entry.DataEntry0.NumberOfSectors * WiiBlockSize); if (absDataOffset >= deIsoStart && absDataOffset < deIsoEnd) return ReadDecryptedPartitionBytes(entry, partitionDataOffset, length); if (entry.DataEntry1 is { NumberOfSectors: > 0 }) { long de1Start = (long)entry.DataEntry1.FirstSector * WiiBlockSize; long de1End = de1Start + ((long)entry.DataEntry1.NumberOfSectors * WiiBlockSize); if (absDataOffset >= de1Start && absDataOffset < de1End) return ReadDecryptedPartitionBytes(entry, partitionDataOffset, length); } } return null; } /// /// Reads bytes of the virtual decompressed ISO at /// into , decompressing /// WIA/RVZ groups on demand. Returns the number of bytes read. /// internal int ReadVirtual(long offset, byte[] buffer, int bufferOffset, int count) { long isoSize = (long)Header1.IsoFileSize; if (offset >= isoSize || count <= 0) return 0; count = (int)Math.Min(count, isoSize - offset); int totalRead = 0; while (totalRead < count) { long pos = offset + totalRead; int got = ReadVirtualChunk(pos, buffer, bufferOffset + totalRead, count - totalRead); if (got <= 0) { // Advance past one "zero" byte to avoid infinite loops over gaps. buffer[bufferOffset + totalRead] = 0; totalRead++; } else { totalRead += got; } } return totalRead; } /// /// Reads bytes for one contiguous segment of the virtual ISO starting at . /// Returns 0 if the position is not covered by any known data entry (caller fills with zeros). /// private int ReadVirtualChunk(long pos, byte[] buffer, int bufferOffset, int count) { // 1. Disc header (first 0x80 bytes stored verbatim in Header2.DiscHeader) if (pos < DiscHeaderStoredSize && Header2.DiscHeader.Length > 0) { int available = (int)Math.Min(DiscHeaderStoredSize - pos, count); int srcAvail = Math.Min(available, Header2.DiscHeader.Length - (int)pos); if (srcAvail > 0) Array.Copy(Header2.DiscHeader, (int)pos, buffer, bufferOffset, srcAvail); if (available > srcAvail) Array.Clear(buffer, bufferOffset + srcAvail, available - srcAvail); return available; } uint chunkSize = Header2.ChunkSize; var comp = Header2.CompressionType; byte[] compData = Header2.CompressorData; byte compDataSize = Header2.CompressorDataSize; // 2. Raw data entries (non-partition disc data) if (RawDataEntries.Length > 0) { foreach (var entry in RawDataEntries) { if (entry.DataSize == 0 || entry.NumberOfGroups == 0) continue; long rdeStart = (long)entry.DataOffset; long rdeEnd = rdeStart + (long)entry.DataSize; if (pos < rdeStart || pos >= rdeEnd) continue; long skippedData = rdeStart % 0x8000; long adjustedBase = rdeStart - skippedData; long adjustedPos = pos - adjustedBase; uint g = (uint)(adjustedPos / chunkSize); int offsetInGroup = (int)(adjustedPos % chunkSize); if (g >= entry.NumberOfGroups) continue; uint groupFileIdx = entry.GroupIndex + g; byte[]? groupBytes = GetCachedRawGroup(groupFileIdx, comp, compData, compDataSize, chunkSize); if (groupBytes is null) return 0; int available = groupBytes.Length - offsetInGroup; if (available <= 0) return 0; int remainingInEntry = (int)Math.Min(rdeEnd - pos, count); // Also clamp to the end of this group long groupIsoEnd = adjustedBase + ((long)(g + 1) * chunkSize); int remainingInGroup = (int)Math.Min(groupIsoEnd - pos, remainingInEntry); int toCopy = Math.Min(available, remainingInGroup); if (toCopy <= 0) return 0; Array.Copy(groupBytes, offsetInGroup, buffer, bufferOffset, toCopy); return toCopy; } } // 3. Partition data entries (Wii encrypted partition data) if (PartitionEntries is not null && PartitionEntries.Length > 0) { foreach (var pe in PartitionEntries) { int ret = ReadPartitionChunk(pe.DataEntry0, pe.PartitionKey, pos, buffer, bufferOffset, count, comp, compData, compDataSize, chunkSize); if (ret > 0) return ret; ret = ReadPartitionChunk(pe.DataEntry1, pe.PartitionKey, pos, buffer, bufferOffset, count, comp, compData, compDataSize, chunkSize); if (ret > 0) return ret; } } return 0; } /// /// Reads bytes of decrypted Wii partition data beginning at /// , a byte offset in the 0x7C00-block partition-data space. /// Spans across both DataEntry0 and DataEntry1 of the partition entry. /// Maps directly to the decompressed WIA/RVZ group data — no re-encryption is performed. /// internal byte[]? ReadDecryptedPartitionBytes(PartitionEntry pe, long partDataOffset, int length) { if (length <= 0 || pe is null) return null; uint chunkSize = Header2.ChunkSize; var comp = Header2.CompressionType; byte[] compData = Header2.CompressorData ?? new byte[7]; byte compDataSize = Header2.CompressorDataSize; int blocksPerGroup = (int)(chunkSize / WiiBlockSize); byte[] result = new byte[length]; int produced = 0; // DataEntry0 covers [0 .. de0.NumberOfSectors * 0x7C00) in partition-data space var de0 = pe.DataEntry0; long de0DataSize = (long)de0.NumberOfSectors * WiiBlockDataSize; // DataEntry1 (if present) immediately follows var de1 = pe.DataEntry1; long de1DataSize = de1 is not null ? (long)de1.NumberOfSectors * WiiBlockDataSize : 0; while (produced < length) { long off = partDataOffset + produced; // Determine which DataEntry covers this offset PartitionDataEntry de; long deRelOff; // offset within this DataEntry's decrypted data space if (off < de0DataSize) { de = de0; deRelOff = off; } else if (de1 is not null && de1.NumberOfGroups > 0 && off < de0DataSize + de1DataSize) { de = de1; deRelOff = off - de0DataSize; } else { break; // beyond available data } long blockNum = deRelOff / WiiBlockDataSize; int offsetInBlock = (int)(deRelOff % WiiBlockDataSize); long groupRelative = blockNum / blocksPerGroup; int blockInGroup = (int)(blockNum % blocksPerGroup); if (groupRelative >= de.NumberOfGroups) break; uint groupFileIdx = de.GroupIndex + (uint)groupRelative; long dataOffsetForLfg = groupRelative * blocksPerGroup * WiiBlockDataSize; byte[]? decrypted = ReadDecryptedGroupData(groupFileIdx, comp, compData, compDataSize, blocksPerGroup, WiiBlockDataSize, dataOffsetForLfg); if (decrypted is null) break; int offsetInGroup = (blockInGroup * WiiBlockDataSize) + offsetInBlock; int available = decrypted.Length - offsetInGroup; if (available <= 0) break; int remainingInGroup = (blocksPerGroup * WiiBlockDataSize) - offsetInGroup; int toCopy = Math.Min(length - produced, Math.Min(available, remainingInGroup)); if (toCopy <= 0) break; Array.Copy(decrypted, offsetInGroup, result, produced, toCopy); produced += toCopy; } if (produced <= 0) return null; if (produced < length) Array.Resize(ref result, produced); return result; } /// /// /// /// /// /// /// /// /// /// /// /// /// /// private int ReadPartitionChunk(PartitionDataEntry de, byte[] partitionKey, long pos, byte[] buffer, int bufferOffset, int count, WiaRvzCompressionType comp, byte[] compData, byte compDataSize, uint chunkSize) { if (de.NumberOfSectors == 0 || de.NumberOfGroups == 0) return 0; if (chunkSize == 0) return 0; int blocksPerGroup = (int)(chunkSize / WiiBlockSize); long isoDataStart = (long)de.FirstSector * WiiBlockSize; long isoDataEnd = isoDataStart + ((long)de.NumberOfSectors * WiiBlockSize); if (pos < isoDataStart || pos >= isoDataEnd) return 0; long offsetInPartition = pos - isoDataStart; long blockNum = offsetInPartition / WiiBlockSize; int offsetInBlock = (int)(offsetInPartition % WiiBlockSize); long groupNum = blockNum / blocksPerGroup; int blockInGroup = (int)(blockNum % blocksPerGroup); if (groupNum >= de.NumberOfGroups) return 0; uint groupFileIdx = de.GroupIndex + (uint)groupNum; byte[]? encryptedGroup = GetCachedEncGroup(groupFileIdx, de, partitionKey, comp, compData, compDataSize, blocksPerGroup); if (encryptedGroup is null) return 0; int offsetInEncGroup = (blockInGroup * WiiBlockSize) + offsetInBlock; int available = encryptedGroup.Length - offsetInEncGroup; if (available <= 0) return 0; long remainingInEntry = isoDataEnd - pos; // Stay within this group long groupIsoEnd = isoDataStart + ((groupNum + 1) * blocksPerGroup * WiiBlockSize); long remainingInGroup = groupIsoEnd - pos; int toCopy = (int)Math.Min(count, Math.Min(Math.Min(available, remainingInEntry), remainingInGroup)); if (toCopy <= 0) return 0; Array.Copy(encryptedGroup, offsetInEncGroup, buffer, bufferOffset, toCopy); return toCopy; } /// /// /// /// /// /// /// /// /// private byte[]? GetCachedRawGroup(uint groupFileIdx, WiaRvzCompressionType comp, byte[] compData, byte compDataSize, uint chunkSize) { if (_cachedRawGroupIndex == groupFileIdx) return _cachedRawGroup; byte[]? group = ReadGroupRaw(groupFileIdx, comp, compData, compDataSize, chunkSize); _cachedRawGroupIndex = groupFileIdx; _cachedRawGroup = group; return group; } /// /// /// /// /// /// /// /// /// /// /// private byte[]? GetCachedEncGroup(uint groupFileIdx, PartitionDataEntry de, byte[] partitionKey, WiaRvzCompressionType comp, byte[] compData, byte compDataSize, int blocksPerGroup) { if (_cachedEncGroupIndex == groupFileIdx) return _cachedEncGroup; long dataOffsetForLfg = (groupFileIdx - de.GroupIndex) * blocksPerGroup * 0x7C00; byte[]? decrypted = ReadDecryptedGroupData(groupFileIdx, comp, compData, compDataSize, blocksPerGroup, 0x7C00, dataOffsetForLfg); if (decrypted is null) return null; byte[] encrypted = EncryptWiiGroup(decrypted, partitionKey, blocksPerGroup); _cachedEncGroupIndex = groupFileIdx; _cachedEncGroup = encrypted; return encrypted; } /// /// Reads and decompresses one raw (non-partition) group. /// Returns chunkSize bytes of raw ISO data, or null on failure. /// private byte[]? ReadGroupRaw(uint groupIdx, WiaRvzCompressionType comp, byte[] compressorData, byte compressorDataSize, uint chunkSize) { if (IsRvz) { if (RvzGroupEntries is null || groupIdx >= RvzGroupEntries.Length) return null; var ge = RvzGroupEntries[groupIdx]; bool isRvzCompressed = (ge.DataSize & 0x80000000u) != 0; uint dataSize = ge.DataSize & 0x7FFFFFFFu; if (dataSize == 0) return new byte[chunkSize]; byte[] fileData = ReadRangeFromSource((long)ge.DataOffset << 2, (int)dataSize); return DecompressGroupBytes(fileData, 0, (int)dataSize, comp, compressorData, compressorDataSize, (int)chunkSize, IsRvz, isRvzCompressed, ge.RvzPackedSize, groupIdx * chunkSize, isWiiPartition: false, chunkSize); } else { if (GroupEntries is null || groupIdx >= GroupEntries.Length) return null; var ge = GroupEntries[groupIdx]; if (ge.DataSize == 0) return new byte[chunkSize]; byte[] fileData = ReadRangeFromSource((long)ge.DataOffset << 2, (int)ge.DataSize); return DecompressGroupBytes(fileData, 0, (int)ge.DataSize, comp, compressorData, compressorDataSize, (int)chunkSize, IsRvz, false, 0, 0, false, chunkSize); } } /// /// Reads and decompresses a Wii partition group, returning the hash-stripped decrypted data. /// private byte[]? ReadDecryptedGroupData(uint groupIdx, WiaRvzCompressionType comp, byte[] compressorData, byte compressorDataSize, int blocksPerGroup, int blockDataSize, long dataOffsetForLfg) { int decryptedGroupSize = blocksPerGroup * blockDataSize; if (IsRvz) { if (RvzGroupEntries is null || groupIdx >= RvzGroupEntries.Length) return null; var ge = RvzGroupEntries[groupIdx]; bool isRvzCompressed = (ge.DataSize & 0x80000000u) != 0; uint dataSize = ge.DataSize & 0x7FFFFFFFu; if (dataSize == 0) return new byte[decryptedGroupSize]; byte[] fileData = ReadRangeFromSource((long)ge.DataOffset << 2, (int)dataSize); return DecompressGroupBytes(fileData, 0, (int)dataSize, comp, compressorData, compressorDataSize, decryptedGroupSize, IsRvz, isRvzCompressed, ge.RvzPackedSize, dataOffsetForLfg, true, Header2.ChunkSize); } else { if (GroupEntries is null || groupIdx >= GroupEntries.Length) return null; var ge = GroupEntries[groupIdx]; if (ge.DataSize == 0) return new byte[decryptedGroupSize]; byte[] fileData2 = ReadRangeFromSource((long)ge.DataOffset << 2, (int)ge.DataSize); return DecompressGroupBytes(fileData2, 0, (int)ge.DataSize, comp, compressorData, compressorDataSize, decryptedGroupSize, IsRvz, false, 0, 0L, true, Header2.ChunkSize); } } /// /// Decompresses raw group bytes according to the WIA compression type and strips any /// exception-list header, returning the plain data payload. /// private static byte[]? DecompressGroupBytes(byte[] fileData, int offset, int length, WiaRvzCompressionType comp, byte[] compressorData, byte compressorDataSize, int expectedSize, bool isRvz, bool isRvzCompressed, uint rvzPackedSize, long dataOffsetForLfg, bool isWiiPartition, uint chunkSize = 2 * 1024 * 1024) { if (fileData is null || fileData.Length < length) return null; // Mirrors DolphinIsoLib WiaRvzReader::ReadGroupCore logic: // Decompress first (Bzip2/LZMA/LZMA2/Zstd), then RVZ-unpack junk regions if present. bool shouldDecompress = comp > WiaRvzCompressionType.Purge && (!isRvz || isRvzCompressed); if (comp == WiaRvzCompressionType.None) { // NONE: exception lists precede data with 4-byte alignment for Wii partitions int dataStart = isWiiPartition ? SkipExceptionLists(fileData, offset, length, chunkSize) : offset; int mainLen = length - (dataStart - offset); byte[] noneData = new byte[expectedSize]; Array.Copy(fileData, dataStart, noneData, 0, Math.Min(mainLen, expectedSize)); return noneData; } else if (comp == WiaRvzCompressionType.Purge) { // Exception list precedes the Purge payload; capture it for SHA-1, then decompress. int purgeStart = isWiiPartition ? SkipExceptionLists(fileData, offset, length, chunkSize) : offset; int exceptionLen = purgeStart - offset; byte[]? exceptionBytes = exceptionLen > 0 ? new byte[exceptionLen] : null; if (exceptionBytes is not null) Array.Copy(fileData, offset, exceptionBytes, 0, exceptionLen); int purgeLen = length - exceptionLen; return IO.Compression.PURGE.Decompressor.Decompress(fileData, purgeStart, purgeLen, expectedSize, exceptionBytes); } else { // Bzip2 / LZMA / LZMA2 / Zstd — delegate to compression helpers byte[]? workingData; if (shouldDecompress) { try { workingData = Decompress(comp, fileData, offset, length, compressorData, compressorDataSize); } catch { return null; } if (workingData is null) return null; } else { workingData = fileData; } // RVZ-pack step: junk regions are stored as LFG seeds rather than raw bytes. if (isRvz && rvzPackedSize > 0) { // Exception lists are always present for Wii partition groups. // When compressed (shouldDecompress=true), they are NOT padded to 4-byte alignment. // When uncompressed (shouldDecompress=false), they ARE padded to 4-byte alignment. int rvzDataStart = isWiiPartition ? (shouldDecompress ? SkipExceptionListsNoAlign(workingData, 0, workingData.Length, chunkSize) : SkipExceptionLists(workingData, 0, workingData.Length, chunkSize)) : 0; int rvzDataLen = workingData.Length - rvzDataStart; byte[] rvzPayload = new byte[rvzDataLen]; Array.Copy(workingData, rvzDataStart, rvzPayload, 0, rvzDataLen); var rvzDecomp = new IO.Compression.RVZPack.Decompressor(rvzPayload, rvzPackedSize, dataOffsetForLfg); byte[] unpacked = new byte[expectedSize]; int bytesRead = rvzDecomp.Decompress(unpacked, 0, expectedSize); if (bytesRead < expectedSize) Array.Resize(ref unpacked, bytesRead); return unpacked; } // Skip exception lists always present for Wii partition groups. // Compressed groups: no 4-byte alignment. Uncompressed groups: 4-byte aligned. int dataStart = isWiiPartition ? (shouldDecompress ? SkipExceptionListsNoAlign(workingData, 0, workingData.Length, chunkSize) : SkipExceptionLists(workingData, 0, workingData.Length, chunkSize)) : 0; int mainLen = workingData.Length - dataStart; byte[] data = new byte[expectedSize]; Array.Copy(workingData, dataStart, data, 0, Math.Min(mainLen, expectedSize)); return data; } } /// /// Skips the packed exception-list header at the start of group data (NONE/Purge path). /// Exception lists are 4-byte-aligned after the last list. /// Returns the offset of the first data byte. /// private static int SkipExceptionLists(byte[] data, int offset, int length, uint chunkSize = 2 * 1024 * 1024) { // Number of exception lists = max(1, chunkSize / WiiGroupSize). // For WIA chunkSize==2MiB this is always 1. // For RVZ sub-2MiB chunks this is also 1 (chunkSize <= groupSize). const uint WiiGroupSize = 2 * 1024 * 1024; // 0x200000 int numLists = Math.Max(1, (int)(chunkSize / WiiGroupSize)); int pos = offset; for (int i = 0; i < numLists && pos + 2 <= offset + length; i++) { ushort count = (ushort)((data[pos] << 8) | data[pos + 1]); pos += 2; // Each exception entry is 2 + 20 = 22 bytes pos += count * 22; // 4-byte alignment after last list if (i == numLists - 1) pos = (pos + 3) & ~3; } return pos; } /// /// Skips exception lists in compressed group data (Bzip2/LZMA/etc.) where /// lists are NOT 4-byte aligned. /// private static int SkipExceptionListsNoAlign(byte[] data, int offset, int length, uint chunkSize = 2 * 1024 * 1024) { const uint WiiGroupSize = 2 * 1024 * 1024; int numLists = Math.Max(1, (int)(chunkSize / WiiGroupSize)); int pos = offset; for (int i = 0; i < numLists && pos + 2 <= offset + length; i++) { ushort count = (ushort)((data[pos] << 8) | data[pos + 1]); pos += 2; pos += count * 22; } return pos; } /// /// Re-encrypts one decrypted hash-stripped Wii group back into standard ISO-layout /// encrypted 0x8000-byte blocks. Mirrors Dolphin's VolumeWii::EncryptGroup. /// internal static byte[] EncryptWiiGroup(byte[] decryptedData, byte[] key, int blocksPerGroup) { const int H0Count = 31; const int H1Count = 8; const int H2Count = 8; const int HashLen = 20; // --- Build H0 / H1 / H2 hash arrays --- byte[][][] h0 = new byte[blocksPerGroup][][]; for (int b = 0; b < blocksPerGroup; b++) { h0[b] = new byte[H0Count][]; int blockBase = b * WiiBlockDataSize; for (int h = 0; h < H0Count; h++) { int src = blockBase + (h * 0x400); int len = Math.Min(0x400, decryptedData.Length - src); h0[b][h] = ComputeSha1(decryptedData, src < decryptedData.Length ? src : 0, Math.Max(0, len)); } } // H1[h1Group][slot] = SHA1 of block (h1Group*8+slot)'s 31 H0 hashes byte[][][] h1 = new byte[H1Count][][]; for (int g = 0; g < H1Count; g++) { h1[g] = new byte[H1Count][]; for (int s = 0; s < H1Count; s++) { int blockIdx = (g * H1Count) + s; if (blockIdx >= blocksPerGroup) { h1[g][s] = new byte[HashLen]; continue; } byte[] h0Concat = new byte[H0Count * HashLen]; for (int i = 0; i < H0Count; i++) { Array.Copy(h0[blockIdx][i], 0, h0Concat, i * HashLen, HashLen); } h1[g][s] = ComputeSha1(h0Concat, 0, h0Concat.Length); } } // H2[h2Idx] = SHA1 of H1 group h2Idx's 8 hashes (same for every block) byte[][] h2 = new byte[H2Count][]; for (int i = 0; i < H2Count; i++) { int grp = Math.Min(i, h1.Length - 1); byte[] h1Concat = new byte[H1Count * HashLen]; for (int s = 0; s < H1Count; s++) { Array.Copy(h1[grp][s], 0, h1Concat, s * HashLen, HashLen); } h2[i] = ComputeSha1(h1Concat, 0, h1Concat.Length); } byte[] result = new byte[blocksPerGroup * WiiBlockSize]; for (int b = 0; b < blocksPerGroup; b++) { // Serialize hash block byte[] hashBlock = new byte[WiiBlockHashSize]; int off = 0; // H0 (31 * 20 = 0x26C) for (int i = 0; i < H0Count; i++) { Array.Copy(h0[b][i], 0, hashBlock, off, HashLen); off += HashLen; } off += 0x14; // padding0 // H1 for this block's group (8 * 20 = 0xA0) int h1Grp = b / H1Count; if (h1Grp < h1.Length) { for (int i = 0; i < H1Count; i++) { Array.Copy(h1[h1Grp][i], 0, hashBlock, off, HashLen); off += HashLen; } } else { off += H1Count * HashLen; } off += 0x20; // padding1 // H2 (8 * 20 = 0xA0) for (int i = 0; i < H2Count; i++) { Array.Copy(h2[i], 0, hashBlock, off, HashLen); off += HashLen; } // Note: off is now 0x3D4; IV will sit at 0x3D0 after encryption // Encrypt hash block with IV = zero byte[] encHashBlock = AESCBC.Encrypt(hashBlock, key, new byte[16]) ?? new byte[WiiBlockHashSize]; // Extract IV for data block from offset 0x3D0 of the encrypted hash block byte[] iv = new byte[16]; Array.Copy(encHashBlock, 0x3D0, iv, 0, 16); // Encrypt data block int dataSrc = b * WiiBlockDataSize; int dataLen = Math.Min(WiiBlockDataSize, decryptedData.Length - dataSrc); byte[] dataBlock = new byte[WiiBlockDataSize]; if (dataLen > 0) Array.Copy(decryptedData, dataSrc, dataBlock, 0, dataLen); byte[] encDataBlock = AESCBC.Encrypt(dataBlock, key, iv) ?? new byte[WiiBlockDataSize]; int dest = b * WiiBlockSize; Array.Copy(encHashBlock, 0, result, dest, WiiBlockHashSize); Array.Copy(encDataBlock, 0, result, dest + WiiBlockHashSize, WiiBlockDataSize); } return result; } /// /// Get a segmented SHA-1 hash for input data /// private static byte[] ComputeSha1(byte[] data, int offset, int count) { if (count == 0) return new byte[20]; using var sha1 = new HashWrapper(HashType.SHA1); sha1.Process(data, offset, count); sha1.Terminate(); return sha1.CurrentHashBytes ?? new byte[20]; } #endregion } }