First round of cleanup for DolphinLib additions

This commit is contained in:
Matt Nadareski
2026-05-12 20:33:53 -04:00
parent 49aa6895b6
commit 2a0e8e2eb0
53 changed files with 3861 additions and 2586 deletions

View File

@@ -0,0 +1,30 @@
using SabreTools.Data.Models.NintendoDisc;
namespace SabreTools.Data.Extensions
{
// TODO: Write tests for these
public static class NintendoDiscExtensions
{
/// <summary>
/// Get the platform associated with a disc header
/// </summary>
public static Platform GetPlatform(this DiscHeader header)
{
if (header.WiiMagic == Constants.WiiMagicWord)
return Platform.Wii;
else if (header.GCMagic == Constants.GCMagicWord)
return Platform.GameCube;
else if (header.GameId is not null && header.GameId.Length >= 1 && IsGameCubeTitleType(header.GameId[0]))
return Platform.GameCube;
else
return Platform.Unknown;
}
/// <summary>
/// Returns true if the GameId first character is a known GameCube title type prefix.
/// Used as a fallback when the GC magic word is absent from the disc image.
/// </summary>
public static bool IsGameCubeTitleType(this char c)
=> c == 'G' || c == 'D' || c == 'R';
}
}

View File

@@ -17,10 +17,10 @@ namespace SabreTools.Data.Models.GCZ
/// Each value encodes both the offset of the block within the compressed data section
/// and a compression flag in the top bit:
/// <list type="bullet">
/// <item>Top bit CLEAR block is zlib/deflate-compressed at that offset.</item>
/// <item>Top bit SET block is stored uncompressed at that offset.</item>
/// <item>Top bit CLEAR -> block is zlib/deflate-compressed at that offset.</item>
/// <item>Top bit SET -> block is stored uncompressed at that offset.</item>
/// </list>
/// Offset is <c>value &amp; ~UncompressedFlag</c>.
/// Offset is value &amp; ~UncompressedFlag.
/// </summary>
public ulong[] BlockPointers { get; set; } = [];

View File

@@ -30,7 +30,7 @@
public FolderIndex FolderIndex { get; set; }
/// <summary>
/// Date of this file, in the format ((year1980) << 9)+(month << 5)+(day), where
/// Date of this file, in the format ((year-1980) << 9)+(month << 5)+(day), where
/// month={1..12} and day = { 1..31 }. This "date" is typically considered the "last modified" date in local
/// time, but the actual definition is application-defined.
/// </summary>

View File

@@ -11,47 +11,8 @@ namespace SabreTools.Data.Models.NintendoDisc
public const uint WiiMagicWord = 0x5D1C9EA3;
#endregion
#region Disc header layout
// Offsets within the 0x440-byte boot block.
// Layout confirmed against Dolphin source (VolumeDisc.cpp / DiscUtils.h):
// 0x0000x003 Title code (4 chars, e.g. "GAFE")
// 0x0040x005 Maker code (2 chars, e.g. "01") — Dolphin Read(0x4, 2)
// 0x006 Disc number — Dolphin GetDiscNumber() Read(6)
// 0x007 Revision — Dolphin GetRevision() Read(7)
// 0x008 Audio streaming
// 0x009 Streaming buffer size
// 0x00A0x017 Unused (14 bytes)
// 0x018 Wii magic (0x5D1C9EA3)
// 0x01C GC magic (0xC2339F3D)
// 0x0200x07F Game title (0x60 bytes)
// 0x080 Disable hash verification
// 0x081 Disable disc encryption
public const int TitleCodeOffset = 0x000;
public const int TitleCodeLength = 4;
public const int MakerCodeOffset = 0x004;
public const int MakerCodeLength = 2;
/// <summary>Full 6-char game ID = TitleCode[4] + MakerCode[2]</summary>
public const int GameIdOffset = 0x000;
public const int GameIdLength = 6;
public const int DiscNumberOffset = 0x006;
public const int DiscVersionOffset = 0x007;
public const int AudioStreamingOffset = 0x008;
public const int StreamingBufferSizeOffset = 0x009;
public const int WiiMagicOffset = 0x018;
public const int GCMagicOffset = 0x01C;
public const int GameTitleOffset = 0x020;
public const int GameTitleLength = 0x060;
public const int DisableHashVerificationOffset = 0x080;
public const int DisableDiscEncryptionOffset = 0x081;
public const int DolOffsetField = 0x420;
public const int FstOffsetField = 0x424;
public const int FstSizeField = 0x428;
public const int DiscHeaderSize = 0x440;
#endregion
#region BI2 data
public const int Bi2Address = 0x000440;
@@ -97,6 +58,7 @@ namespace SabreTools.Data.Models.NintendoDisc
public const int WiiBlockHeaderSize = 0x0400;
public const int WiiBlockDataSize = 0x7C00;
public const int WiiBlocksPerGroup = 64;
public const int WiiBlockHashSize = 0x0400;
public const int WiiGroupSize = WiiBlocksPerGroup * WiiBlockSize;
public const int WiiGroupDataSize = WiiBlocksPerGroup * WiiBlockDataSize;

View File

@@ -0,0 +1,64 @@
namespace SabreTools.Data.Models.NintendoDisc
{
/// <see href="https://wiki.tockdom.com/wiki/DOL_(File_Format)"/>
public class DOLHeader
{
/// <summary>
/// Section offsets indicate where each section's data begins relative
/// to the start of this header. 0 for unused sections.
/// </summary>
/// <remarks>
/// Big-endian
/// Indexes 0-6 are text sections.
/// Indexes 7-17 are data sections.
/// </remarks>
public uint[] SectionOffsetTable { get; set; } = new uint[18];
/// <summary>
/// Section address indicates where each section should be copied to by
/// the loader as a virtual memory address. 0 for unused sections.
/// </summary>
/// <remarks>
/// Big-endian
/// Indexes 0-6 are text sections.
/// Indexes 7-17 are data sections.
/// </remarks>
public uint[] SectionAddressTable { get; set; } = new uint[18];
/// <summary>
/// Section lengths indicate the size in bytes of each section.
/// 0 for unused sections.
/// </summary>
/// <remarks>
/// Big-endian
/// Indexes 0-6 are text sections.
/// Indexes 7-17 are data sections.
/// </remarks>
public uint[] SectionLengthsTable { get; set; } = new uint[18];
/// <summary>
/// bss address indicates the start of the zero initialised (bss) range.
/// </summary>
/// <remarks>Big-endian</remarks>
public uint BSSAddress { get; set; }
/// <summary>
/// bss length indicates the size in bytes of the zero initialised (bss) range.
/// </summary>
/// <remarks>Big-endian</remarks>
public uint BSSLength { get; set; }
/// <summary>
/// Entry point indicates the virtual memory address of a function to run after
/// the DOL has been loaded in order to start the program.
/// This function should not return.
/// </summary>
/// <remarks>Big-endian</remarks>
public uint EntryPoint { get; set; }
/// <summary>
/// Padding
/// </summary>
public byte[] Padding { get; set; } = new byte[0x1C];
}
}

View File

@@ -10,11 +10,6 @@ namespace SabreTools.Data.Models.NintendoDisc
/// </summary>
public DiscHeader Header { get; set; } = new();
/// <summary>
/// Detected platform (GameCube or Wii)
/// </summary>
public Platform Platform { get; set; }
/// <summary>
/// Wii partition table entries (Wii discs only)
/// </summary>

View File

@@ -12,12 +12,6 @@ namespace SabreTools.Data.Models.NintendoDisc
/// <remarks>6 bytes at offset 0x000</remarks>
public string GameId { get; set; } = string.Empty;
/// <summary>
/// 2-character ASCII maker / publisher code (e.g. "01")
/// </summary>
/// <remarks>Derived from GameId bytes at offset 0x0040x005; not a separate on-disc field</remarks>
public string MakerCode { get; set; } = string.Empty;
/// <summary>
/// Zero-based disc number for multi-disc games
/// </summary>
@@ -38,6 +32,11 @@ namespace SabreTools.Data.Models.NintendoDisc
/// </summary>
public byte StreamingBufferSize { get; set; }
/// <summary>
/// Unused 0x0E bytes (offsets 0x00A-0x017)
/// </summary>
public byte[] Padding00A { get; set; } = [];
/// <summary>
/// Wii magic word at offset 0x018 (0x5D1C9EA3 for Wii discs, 0 for GameCube)
/// </summary>
@@ -63,6 +62,11 @@ namespace SabreTools.Data.Models.NintendoDisc
/// </summary>
public byte DisableDiscEncryption { get; set; }
/// <summary>
/// Unused padding until DOL/FST offset fields at 0x420
/// </summary>
public byte[] Padding082 { get; set; } = [];
/// <summary>
/// Offset of the main DOL executable (no shift for GameCube; &lt;&lt;2 for Wii)
/// </summary>
@@ -77,5 +81,10 @@ namespace SabreTools.Data.Models.NintendoDisc
/// Maximum size of the File System Table in bytes
/// </summary>
public uint FstSize { get; set; }
/// <summary>
/// Remaining bytes to complete the 0x440 header
/// </summary>
public byte[] Padding42C { get; set; } = [];
}
}

View File

@@ -0,0 +1,26 @@
namespace SabreTools.Data.Models.NintendoDisc
{
public class FileSystemTable
{
/// <summary>
/// 8 bytes of unknown data
/// </summary>
/// <remarks>
/// Maps to <see cref="FileSystemTableEntry.NameOffset"/> and
/// <see cref="FileSystemTableEntry.FileOffset"/> but is unused?
/// </remarks>
public byte[] Unknown { get; set; } = new byte[8];
/// <summary>
/// Number of entries in the table
/// </summary>
/// <remarks>Big-endian</remarks>
public uint EntryCount { get; set; }
/// <summary>
/// File system table entries
/// </summary>
/// <remarks>Length given by <see cref="EntryCount"/></remarks>
public FileSystemTableEntry[] Entries { get; set; } = [];
}
}

View File

@@ -0,0 +1,26 @@
namespace SabreTools.Data.Models.NintendoDisc
{
/// <summary>
/// File entry with start and end byte offsets on disc
/// </summary>
public class FileSystemTableEntry
{
/// <summary>
/// Offset to the entry name
/// </summary>
/// <remarks>Big-endian, has high byte set to 0xFF if a directory entry</remarks>
public uint NameOffset { get; set; }
/// <summary>
/// Offset to the start of the file
/// </summary>
/// <remarks>Big-endian</remarks>
public uint FileOffset { get; set; }
/// <summary>
/// File size
/// </summary>
/// <remarks>Big-endian</remarks>
public uint FileSize { get; set; }
}
}

View File

@@ -0,0 +1,23 @@
namespace SabreTools.Data.Models.NintendoDisc
{
public class WiiPartitionGroup
{
/// <summary>
/// Number of entries in the group
/// </summary>
/// <remarks>Big-endian</remarks>
public uint Count { get; set; }
/// <summary>
/// Offset to the start of the table
/// </summary>
/// <remarks>Big-endian, requires left bit shift of 2 to get the real value</remarks>
public uint Offset { get; set; }
/// <summary>
/// Entries for the group, stored at <see cref="Offset"/>
/// </summary>
/// <remarks>Number of entries determined by <see cref="Count"/></remarks>
public WiiPartitionTableEntry[] Entries { get; set; } = [];
}
}

View File

@@ -2,15 +2,15 @@ namespace SabreTools.Data.Models.NintendoDisc
{
/// <summary>
/// A single entry in the Wii disc partition table.
/// The table lives at 0x400000x4FFFF on the disc.
/// The table lives at 0x40000-0x4FFFF on the disc.
/// </summary>
/// <see href="https://wiibrew.org/wiki/Wii_disc#Partition_table"/>
public sealed class WiiPartitionTableEntry
{
/// <summary>
/// Absolute byte offset of the partition on the disc.
/// Stored on-disc as <c>offset &gt;&gt; 2</c> (big-endian u32).
/// Absolute byte offset of the partition on the disc
/// </summary>
/// <remarks>Big-endian, requires left bit shift of 2 to get the real value</remarks>
public long Offset { get; set; }
/// <summary>

View File

@@ -6,7 +6,7 @@ namespace SabreTools.Data.Models.OLE
/// VT_STORAGE (0x0043), VT_STREAMED_OBJECT (0x0044), VT_STORED_OBJECT (0x0044), and
/// VT_VERSIONED_STREAM (0x0049). It MUST be represented as a CodePageString, and its value MUST
/// be derived from the property identifier of the property represented according to the following
/// Augmented BackusNaur Form (ABNF) [RFC4234] syntax.
/// Augmented Backus-Naur Form (ABNF) [RFC4234] syntax.
///
/// Indirectproperty = "prop" propertyIdentifier
///

View File

@@ -6,16 +6,28 @@ namespace SabreTools.Data.Models.WIA
/// </summary>
public sealed class PartitionDataEntry
{
/// <summary>Zero-based index of the first sector covered by this range</summary>
/// <summary>
/// Zero-based index of the first sector covered by this range
/// </summary>
/// <remarks>Big-endian</remarks>
public uint FirstSector { get; set; }
/// <summary>Number of sectors covered by this range</summary>
/// <summary>
/// Number of sectors covered by this range
/// </summary>
/// <remarks>Big-endian</remarks>
public uint NumberOfSectors { get; set; }
/// <summary>Index into the group-entry array of the first group for this range</summary>
/// <summary>
/// Index into the group-entry array of the first group for this range
/// </summary>
/// <remarks>Big-endian</remarks>
public uint GroupIndex { get; set; }
/// <summary>Number of groups covering this range</summary>
/// <summary>
/// Number of groups covering this range
/// </summary>
/// <remarks>Big-endian</remarks>
public uint NumberOfGroups { get; set; }
}
}

View File

@@ -12,10 +12,14 @@ namespace SabreTools.Data.Models.WIA
/// <remarks>16 bytes</remarks>
public byte[] PartitionKey { get; set; } = new byte[16];
/// <summary>First sector range for this partition (typically encrypted data)</summary>
/// <summary>
/// First sector range for this partition (typically encrypted data)
/// </summary>
public PartitionDataEntry DataEntry0 { get; set; } = new();
/// <summary>Second sector range for this partition (typically decrypted/raw data)</summary>
/// <summary>
/// Second sector range for this partition (typically decrypted/raw data)
/// </summary>
public PartitionDataEntry DataEntry1 { get; set; } = new();
}
}

View File

@@ -6,16 +6,28 @@ namespace SabreTools.Data.Models.WIA
/// </summary>
public sealed class RawDataEntry
{
/// <summary>Byte offset of this region within the equivalent ISO image</summary>
/// <summary>
/// Byte offset of this region within the equivalent ISO image
/// </summary>
/// <remarks>Big-endian</remarks>
public ulong DataOffset { get; set; }
/// <summary>Size of this region in bytes</summary>
/// <summary>
/// Size of this region in bytes
/// </summary>
/// <remarks>Big-endian</remarks>
public ulong DataSize { get; set; }
/// <summary>Index into the group-entry array of the first group for this region</summary>
/// <summary>
/// Index into the group-entry array of the first group for this region
/// </summary>
/// <remarks>Big-endian</remarks>
public uint GroupIndex { get; set; }
/// <summary>Number of groups covering this region</summary>
/// <summary>
/// Number of groups covering this region
/// </summary>
/// <remarks>Big-endian</remarks>
public uint NumberOfGroups { get; set; }
}
}

View File

@@ -8,19 +8,22 @@ namespace SabreTools.Data.Models.WIA
{
/// <summary>
/// Actual byte offset of this group's data within the RVZ file.
/// (On disk this value is stored as <c>offset &gt;&gt; 2</c>.)
/// (On disk this value is stored as offset &gt;&gt; 2.)
/// </summary>
public ulong DataOffset { get; set; }
/// <remarks>Big-endian</remarks>
public uint DataOffset { get; set; }
/// <summary>
/// Total size of this group's data (compressed + any RVZ-pack section) in bytes
/// </summary>
/// <remarks>Big-endian</remarks>
public uint DataSize { get; set; }
/// <summary>
/// Size of the RVZ-packed (junk-stripped) portion within this group's data.
/// 0 means no RVZ packing was applied.
/// </summary>
/// <remarks>Big-endian</remarks>
public uint RvzPackedSize { get; set; }
}
}

View File

@@ -8,13 +8,14 @@ namespace SabreTools.Data.Models.WIA
{
/// <summary>
/// Actual byte offset of this group's data within the WIA file.
/// (On disk this value is stored as <c>offset &gt;&gt; 2</c>.)
/// </summary>
public ulong DataOffset { get; set; }
/// <remarks>Big-endian, requires left bit shift of 2 to get the real value</remarks>
public uint DataOffset { get; set; }
/// <summary>
/// Compressed size of this group's data in bytes (0 means group contains only zeroes)
/// </summary>
/// <remarks>Big-endian</remarks>
public uint DataSize { get; set; }
}
}

View File

@@ -15,16 +15,19 @@ namespace SabreTools.Data.Models.WIA
/// <summary>
/// Format version (e.g. 0x01000000)
/// </summary>
/// <remarks>Big-endian</remarks>
public uint Version { get; set; }
/// <summary>
/// Minimum version required to read this file
/// </summary>
/// <remarks>Big-endian</remarks>
public uint VersionCompatible { get; set; }
/// <summary>
/// Size of WiaHeader2 in bytes
/// </summary>
/// <remarks>Big-endian</remarks>
public uint Header2Size { get; set; }
/// <summary>
@@ -36,11 +39,13 @@ namespace SabreTools.Data.Models.WIA
/// <summary>
/// Total size of the equivalent uncompressed ISO image in bytes
/// </summary>
/// <remarks>Big-endian</remarks>
public ulong IsoFileSize { get; set; }
/// <summary>
/// Total size of this WIA / RVZ file in bytes
/// </summary>
/// <remarks>Big-endian</remarks>
public ulong WiaFileSize { get; set; }
/// <summary>

View File

@@ -10,22 +10,26 @@ namespace SabreTools.Data.Models.WIA
/// <summary>
/// Disc type: 1 = GameCube, 2 = Wii
/// </summary>
/// <remarks>Big-endian</remarks>
public WiaDiscType DiscType { get; set; }
/// <summary>
/// Compression algorithm applied to group data
/// </summary>
/// <remarks>Big-endian</remarks>
public WiaRvzCompressionType CompressionType { get; set; }
/// <summary>
/// Informational compression level used when writing (19)
/// Informational compression level used when writing (1-9)
/// </summary>
/// <remarks>Big-endian</remarks>
public int CompressionLevel { get; set; }
/// <summary>
/// Group / chunk size in bytes.
/// WIA requires exactly 2 MiB; RVZ accepts powers of 2 between 32 KiB and 2 MiB.
/// </summary>
/// <remarks>Big-endian</remarks>
public uint ChunkSize { get; set; }
/// <summary>
@@ -37,16 +41,19 @@ namespace SabreTools.Data.Models.WIA
/// <summary>
/// Number of PartitionEntry structures that follow the raw-data entries
/// </summary>
/// <remarks>Big-endian</remarks>
public uint NumberOfPartitionEntries { get; set; }
/// <summary>
/// Size of each PartitionEntry in bytes
/// </summary>
/// <remarks>Big-endian</remarks>
public uint PartitionEntrySize { get; set; }
/// <summary>
/// File offset of the PartitionEntry array
/// </summary>
/// <remarks>Big-endian</remarks>
public ulong PartitionEntriesOffset { get; set; }
/// <summary>
@@ -58,31 +65,37 @@ namespace SabreTools.Data.Models.WIA
/// <summary>
/// Number of RawDataEntry structures
/// </summary>
/// <remarks>Big-endian</remarks>
public uint NumberOfRawDataEntries { get; set; }
/// <summary>
/// File offset of the RawDataEntry array
/// </summary>
/// <remarks>Big-endian</remarks>
public ulong RawDataEntriesOffset { get; set; }
/// <summary>
/// Total size in bytes of all RawDataEntry structures
/// </summary>
/// <remarks>Big-endian</remarks>
public uint RawDataEntriesSize { get; set; }
/// <summary>
/// Number of group entries (WiaGroupEntry or RvzGroupEntry)
/// </summary>
/// <remarks>Big-endian</remarks>
public uint NumberOfGroupEntries { get; set; }
/// <summary>
/// File offset of the group-entry array
/// </summary>
/// <remarks>Big-endian</remarks>
public ulong GroupEntriesOffset { get; set; }
/// <summary>
/// Total size in bytes of all group entries
/// </summary>
/// <remarks>Big-endian</remarks>
public uint GroupEntriesSize { get; set; }
/// <summary>

View File

@@ -1,6 +1,5 @@
using System.IO;
using SabreTools.Data.Models.GCZ;
using SabreTools.IO.Extensions;
using SabreTools.Numerics.Extensions;
#pragma warning disable IDE0017 // Simplify object initialization
@@ -31,6 +30,7 @@ namespace SabreTools.Serialization.Readers
return null;
// Validate block count — guard against absurdly large tables
// TODO: Determine if this block count is arbitrary
if (archive.Header.NumBlocks == 0 || archive.Header.NumBlocks > 0x100000)
return null;
@@ -38,15 +38,17 @@ namespace SabreTools.Serialization.Readers
// Read block pointer table (8 bytes per block)
archive.BlockPointers = new ulong[numBlocks];
byte[] ptrBuf = data.ReadBytes(numBlocks * 8);
for (int i = 0; i < numBlocks; i++)
archive.BlockPointers[i] = System.BitConverter.ToUInt64(ptrBuf, i * 8);
{
archive.BlockPointers[i] = data.ReadUInt64LittleEndian();
}
// Read block hash table (4 bytes per block, Adler-32)
archive.BlockHashes = new uint[numBlocks];
byte[] hashBuf = data.ReadBytes(numBlocks * 4);
for (int i = 0; i < numBlocks; i++)
archive.BlockHashes[i] = System.BitConverter.ToUInt32(hashBuf, i * 4);
{
archive.BlockHashes[i] = data.ReadUInt32LittleEndian();
}
return archive;
}
@@ -56,16 +58,23 @@ namespace SabreTools.Serialization.Readers
}
}
private static GczHeader ParseGczHeader(Stream data)
/// <summary>
/// Parse a Stream into a GczHeader
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled GczHeader on success, null on error</returns>
public static GczHeader ParseGczHeader(Stream data)
{
var header = new GczHeader();
header.MagicCookie = data.ReadUInt32LittleEndian();
header.SubType = data.ReadUInt32LittleEndian();
header.CompressedDataSize = data.ReadUInt64LittleEndian();
header.DataSize = data.ReadUInt64LittleEndian();
header.BlockSize = data.ReadUInt32LittleEndian();
header.NumBlocks = data.ReadUInt32LittleEndian();
return header;
var obj = new GczHeader();
obj.MagicCookie = data.ReadUInt32LittleEndian();
obj.SubType = data.ReadUInt32LittleEndian();
obj.CompressedDataSize = data.ReadUInt64LittleEndian();
obj.DataSize = data.ReadUInt64LittleEndian();
obj.BlockSize = data.ReadUInt32LittleEndian();
obj.NumBlocks = data.ReadUInt32LittleEndian();
return obj;
}
}
}

View File

@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using SabreTools.Data.Extensions;
using SabreTools.Data.Models.NintendoDisc;
using SabreTools.IO.Extensions;
using SabreTools.Numerics.Extensions;
@@ -31,31 +33,27 @@ namespace SabreTools.Serialization.Readers
// Determine platform from magic words; fall back to GameId prefix for
// GC discs that omit the magic word (e.g. some redump/scene ISOs)
if (disc.Header.WiiMagic == Constants.WiiMagicWord)
disc.Platform = Platform.Wii;
else if (disc.Header.GCMagic == Constants.GCMagicWord)
disc.Platform = Platform.GameCube;
else if (disc.Header.GameId != null && disc.Header.GameId.Length >= 1
&& IsGameCubeTitleType(disc.Header.GameId[0]))
disc.Platform = Platform.GameCube;
else
disc.Platform = Platform.Unknown;
Platform platform = disc.Header.GetPlatform();
// Parse Wii-specific structures
if (disc.Platform == Platform.Wii)
if (platform == Platform.Wii)
{
// Partition table starts at 0x40000
long partTableEnd = initialOffset + Constants.WiiPartitionTableAddress
long partTableEnd = initialOffset
+ Constants.WiiPartitionTableAddress
+ (Constants.WiiPartitionGroupCount * 8);
if (data.Length >= partTableEnd)
if (partTableEnd < data.Length)
{
data.Seek(initialOffset + Constants.WiiPartitionTableAddress, SeekOrigin.Begin);
disc.PartitionTableEntries = ParsePartitionTable(data, initialOffset);
}
// Region data at 0x4E000
long regionEnd = initialOffset + Constants.WiiRegionDataAddress + Constants.WiiRegionDataSize;
if (data.Length >= regionEnd)
if (regionEnd < data.Length)
{
data.Seek(initialOffset + Constants.WiiRegionDataAddress, SeekOrigin.Begin);
disc.RegionData = ParseRegionData(data);
disc.RegionData = ParseWiiRegionData(data);
}
}
@@ -67,139 +65,215 @@ namespace SabreTools.Serialization.Readers
}
}
#region Header parsing
/// <summary>
/// Parses just the disc header fields from the given stream without requiring
/// the full 0x440-byte boot block. Requires at least 0x82 bytes (enough to
/// reach AudioStreaming and StreamingBufferSize) to be useful; the DOL/FST
/// fields will be zero when the stream is shorter than 0x42B bytes.
/// Parse a Stream into a DiscHeader
/// </summary>
public static DiscHeader? ParseDiscHeaderOnly(Stream? data)
/// <param name="data">Stream to parse</param>
/// <returns>Filled DiscHeader on success, null on error</returns>
public static DiscHeader ParseDiscHeader(Stream data)
{
if (data is null || !data.CanRead || data.Length - data.Position < 6)
return null;
try { return ParseDiscHeader(data); }
catch { return null; }
}
private static DiscHeader ParseDiscHeader(Stream data)
{
var header = new DiscHeader();
var obj = new DiscHeader();
// 0x000: 4-char title code + 2-char maker code stored as one 6-byte GameId field
byte[] gameIdBytes = data.ReadBytes(Constants.GameIdLength);
header.GameId = Encoding.ASCII.GetString(gameIdBytes).TrimEnd('\0');
byte[] gameIdBytes = data.ReadBytes(6);
obj.GameId = Encoding.ASCII.GetString(gameIdBytes);
// Maker code is the last 2 chars of the GameId (offsets 0x0040x005).
// Dolphin reads it with Read(0x4, 2) — there is no separate field at 0x006.
header.MakerCode = header.GameId != null && header.GameId.Length >= 6
? header.GameId.Substring(4, 2)
: string.Empty;
obj.DiscNumber = data.ReadByteValue();
obj.DiscVersion = data.ReadByteValue();
obj.AudioStreaming = data.ReadByteValue();
obj.StreamingBufferSize = data.ReadByteValue();
obj.Padding00A = data.ReadBytes(0x0E);
obj.WiiMagic = data.ReadUInt32BigEndian();
obj.GCMagic = data.ReadUInt32BigEndian();
// 0x006: disc number, 0x007: revision (Dolphin GetDiscNumber/GetRevision)
header.DiscNumber = data.ReadByteValue();
header.DiscVersion = data.ReadByteValue();
// 0x008: audio streaming, 0x009: streaming buffer size
header.AudioStreaming = data.ReadByteValue();
header.StreamingBufferSize = data.ReadByteValue();
byte[] titleBytes = data.ReadBytes(0x60);
obj.GameTitle = Encoding.ASCII.GetString(titleBytes);
// Skip unused 0x0E bytes (offsets 0x00A0x017)
data.ReadBytes(0x0E);
obj.DisableHashVerification = data.ReadByteValue();
obj.DisableDiscEncryption = data.ReadByteValue();
obj.Padding082 = data.ReadBytes(0x39E);
obj.DolOffset = data.ReadUInt32BigEndian();
obj.FstOffset = data.ReadUInt32BigEndian();
obj.FstSize = data.ReadUInt32BigEndian();
obj.Padding42C = data.ReadBytes(0x14);
header.WiiMagic = data.ReadUInt32BigEndian();
header.GCMagic = data.ReadUInt32BigEndian();
byte[] titleBytes = data.ReadBytes(Constants.GameTitleLength);
header.GameTitle = Encoding.ASCII.GetString(titleBytes).TrimEnd('\0');
header.DisableHashVerification = data.Position < data.Length ? data.ReadByteValue() : (byte)0;
header.DisableDiscEncryption = data.Position < data.Length ? data.ReadByteValue() : (byte)0;
// Skip to DOL/FST offset fields at 0x420.
// Position so far: 6+1+1+1+1+14+4+4+96+1+1 = 130 = 0x82
int skipToBootBlock = Constants.DolOffsetField - 0x82;
if (data.Length - data.Position < skipToBootBlock + 12)
return header;
data.ReadBytes(skipToBootBlock);
header.DolOffset = data.ReadUInt32BigEndian();
header.FstOffset = data.ReadUInt32BigEndian();
header.FstSize = data.ReadUInt32BigEndian();
// Skip the remaining bytes to complete the 0x440 header
// We are at 0x420 + 12 = 0x42C; need to reach 0x440
data.ReadBytes(Constants.DiscHeaderSize - (Constants.DolOffsetField + 12));
return header;
return obj;
}
#endregion
#region Wii partition table parsing
private static WiiPartitionTableEntry[]? ParsePartitionTable(Stream data, long baseOffset)
{
data.Seek(baseOffset + Constants.WiiPartitionTableAddress, SeekOrigin.Begin);
// Read 4 partition groups; each group has a count and a shifted offset
var allEntries = new System.Collections.Generic.List<WiiPartitionTableEntry>();
for (int g = 0; g < Constants.WiiPartitionGroupCount; g++)
{
uint count = data.ReadUInt32BigEndian();
uint shiftedOffset = data.ReadUInt32BigEndian();
if (count == 0)
continue;
long tableOffset = baseOffset + ((long)shiftedOffset << 2);
long savedPosition = data.Position;
if (tableOffset + ((long)count * 8) > data.Length)
{
data.Seek(savedPosition, SeekOrigin.Begin);
continue;
}
data.Seek(tableOffset, SeekOrigin.Begin);
for (uint i = 0; i < count; i++)
{
var entry = new WiiPartitionTableEntry();
uint rawOffset = data.ReadUInt32BigEndian();
entry.Offset = (long)rawOffset << 2;
entry.Type = data.ReadUInt32BigEndian();
allEntries.Add(entry);
}
data.Seek(savedPosition, SeekOrigin.Begin);
}
return allEntries.Count > 0 ? allEntries.ToArray() : null;
}
#endregion
#region Wii region data parsing
private static WiiRegionData ParseRegionData(Stream data)
{
var region = new WiiRegionData();
region.RegionSetting = data.ReadUInt32BigEndian();
region.AgeRatings = data.ReadBytes(16);
return region;
}
#endregion
/// <summary>
/// Returns true if the GameId first character is a known GameCube title type prefix.
/// Used as a fallback when the GC magic word is absent from the disc image.
/// Parse a byte array into a DOLHeader
/// </summary>
private static bool IsGameCubeTitleType(char c)
/// <param name="data">Byte array to parse</param>
/// <returns>Filled DOLHeader on success, null on error</returns>
public static DOLHeader ParseDOLHeader(byte[] data, ref int offset)
{
return c == 'G' || c == 'D' || c == 'R';
var obj = new DOLHeader();
obj.SectionOffsetTable = new uint[18];
for (int i = 0; i < obj.SectionOffsetTable.Length; i++)
{
obj.SectionOffsetTable[i] = data.ReadUInt32BigEndian(ref offset);
}
obj.SectionAddressTable = new uint[18];
for (int i = 0; i < obj.SectionAddressTable.Length; i++)
{
obj.SectionAddressTable[i] = data.ReadUInt32BigEndian(ref offset);
}
obj.SectionLengthsTable = new uint[18];
for (int i = 0; i < obj.SectionLengthsTable.Length; i++)
{
obj.SectionLengthsTable[i] = data.ReadUInt32BigEndian(ref offset);
}
obj.BSSAddress = data.ReadUInt32BigEndian(ref offset);
obj.BSSLength = data.ReadUInt32BigEndian(ref offset);
obj.EntryPoint = data.ReadUInt32BigEndian(ref offset);
obj.Padding = data.ReadBytes(ref offset, 0x1C);
return obj;
}
/// <summary>
/// Parse a byte array into a ParseFileSystemTable
/// </summary>
/// <param name="data">Byte array to parse</param>
/// <returns>Filled ParseFileSystemTable on success, null on error</returns>
public static FileSystemTable? ParseFileSystemTable(byte[] data)
{
// Check that the root entry exists
if (data.Length < 12)
return null;
var obj = new FileSystemTable();
// Read the root entry first
int offset = 0;
obj.Unknown = data.ReadBytes(ref offset, 8);
obj.EntryCount = data.ReadUInt32BigEndian(ref offset);
if (obj.EntryCount < 1 || (obj.EntryCount * 12) > data.Length)
return null;
// Read all entries
offset = 0;
obj.Entries = new FileSystemTableEntry[obj.EntryCount];
for (int i = 0; i < obj.Entries.Length; i++)
{
obj.Entries[i] = ParseFileSystemTableEntry(data, ref offset);
}
return obj;
}
/// <summary>
/// Parse a byte array into a FileSystemTableEntry
/// </summary>
/// <param name="data">Byte array to parse</param>
/// <returns>Filled FileSystemTableEntry on success, null on error</returns>
public static FileSystemTableEntry ParseFileSystemTableEntry(byte[] data, ref int offset)
{
var obj = new FileSystemTableEntry();
obj.NameOffset = data.ReadUInt32BigEndian(ref offset);
obj.FileOffset = data.ReadUInt32BigEndian(ref offset);
obj.FileSize = data.ReadUInt32BigEndian(ref offset);
return obj;
}
/// <summary>
/// Parse a Stream into a partition table
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled partition table on success, null on error</returns>
public static WiiPartitionTableEntry[]? ParsePartitionTable(Stream data, long initialOffset)
{
// Read 4 partition groups; each group has a count and a shifted offset
var allEntries = new List<WiiPartitionTableEntry>();
for (int i = 0; i < Constants.WiiPartitionGroupCount; i++)
{
var group = ParseWiiPartitionGroup(data, initialOffset);
if (group.Count == 0)
continue;
// TODO: Keep group entries separate
allEntries.AddRange(group.Entries);
}
return allEntries.Count > 0 ? [.. allEntries] : null;
}
/// <summary>
/// Parse a Stream into a WiiPartitionGroup
/// </summary>
/// <param name="data">Stream to parse</param>
/// <param name="initialOffset">Initial offset into the stream</param>
/// <returns>Filled WiiPartitionTableEntry on success, null on error</returns>
public static WiiPartitionGroup ParseWiiPartitionGroup(Stream data, long initialOffset)
{
var obj = new WiiPartitionGroup();
obj.Count = data.ReadUInt32BigEndian();
obj.Offset = data.ReadUInt32BigEndian();
// Empty groups should not attempt parsing
if (obj.Count == 0)
return obj;
// Determine the table offset
long tableOffset = initialOffset + (obj.Offset << 2);
if (tableOffset + (obj.Count * 8) > data.Length)
return obj;
// Seek to the table, if possible
long savedPosition = data.Position;
data.Seek(tableOffset, SeekOrigin.Begin);
// Parse the table
List<WiiPartitionTableEntry> entries = [];
for (uint i = 0; i < obj.Count; i++)
{
var entry = ParseWiiPartitionTableEntry(data);
entries.Add(entry);
}
// Set the entries and reset the stream
obj.Entries = [.. entries];
data.Seek(savedPosition, SeekOrigin.Begin);
return obj;
}
/// <summary>
/// Parse a Stream into a WiiPartitionTableEntry
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled WiiPartitionTableEntry on success, null on error</returns>
public static WiiPartitionTableEntry ParseWiiPartitionTableEntry(Stream data)
{
var obj = new WiiPartitionTableEntry();
obj.Offset = data.ReadUInt32BigEndian();
obj.Type = data.ReadUInt32BigEndian();
return obj;
}
/// <summary>
/// Parse a Stream into a WiiRegionData
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled WiiRegionData on success, null on error</returns>
public static WiiRegionData ParseWiiRegionData(Stream data)
{
var obj = new WiiRegionData();
obj.RegionSetting = data.ReadUInt32BigEndian();
obj.AgeRatings = data.ReadBytes(16);
return obj;
}
}
}

View File

@@ -26,44 +26,57 @@ namespace SabreTools.Serialization.Readers
var archive = new DiscImage();
// Parse Header1
archive.Header1 = ParseHeader1(data);
// Validate magic
archive.Header1 = ParseWiaHeader1(data);
if (archive.Header1.Magic != Constants.WiaMagic && archive.Header1.Magic != Constants.RvzMagic)
return null;
// Parse Header2
archive.Header2 = ParseHeader2(data);
archive.Header2 = ParseWiaHeader2(data);
// Parse partition entries (Wii discs only)
if (archive.Header2.NumberOfPartitionEntries > 0
&& archive.Header2.PartitionEntriesOffset > 0)
if (archive.Header2.NumberOfPartitionEntries > 0 && archive.Header2.PartitionEntriesOffset > 0)
{
data.Seek(initialOffset + (long)archive.Header2.PartitionEntriesOffset, SeekOrigin.Begin);
archive.PartitionEntries = ParsePartitionEntries(
data, (int)archive.Header2.NumberOfPartitionEntries);
archive.PartitionEntries = new PartitionEntry[archive.Header2.NumberOfPartitionEntries];
for (int i = 0; i < archive.PartitionEntries.Length; i++)
{
archive.PartitionEntries[i] = ParsePartitionEntry(data); ;
}
}
// Parse raw data entries
if (archive.Header2.NumberOfRawDataEntries > 0
&& archive.Header2.RawDataEntriesOffset > 0)
if (archive.Header2.NumberOfRawDataEntries > 0 && archive.Header2.RawDataEntriesOffset > 0)
{
data.Seek(initialOffset + (long)archive.Header2.RawDataEntriesOffset, SeekOrigin.Begin);
archive.RawDataEntries = ParseRawDataEntries(
data, (int)archive.Header2.NumberOfRawDataEntries);
archive.RawDataEntries = new RawDataEntry[archive.Header2.NumberOfRawDataEntries];
for (int i = 0; i < archive.Header2.NumberOfRawDataEntries; i++)
{
archive.RawDataEntries[i] = ParseRawDataEntry(data);
}
}
// Parse group entries
if (archive.Header2.NumberOfGroupEntries > 0
&& archive.Header2.GroupEntriesOffset > 0)
if (archive.Header2.NumberOfGroupEntries > 0 && archive.Header2.GroupEntriesOffset > 0)
{
data.Seek(initialOffset + (long)archive.Header2.GroupEntriesOffset, SeekOrigin.Begin);
if (archive.Header1.Magic == Constants.RvzMagic)
archive.RvzGroupEntries = ParseRvzGroupEntries(
data, (int)archive.Header2.NumberOfGroupEntries);
{
archive.RvzGroupEntries = new RvzGroupEntry[archive.Header2.NumberOfGroupEntries];
for (int i = 0; i < archive.Header2.NumberOfGroupEntries; i++)
{
archive.RvzGroupEntries[i] = ParseRvzGroupEntry(data);
}
}
else
archive.GroupEntries = ParseWiaGroupEntries(
data, (int)archive.Header2.NumberOfGroupEntries);
{
archive.GroupEntries = new WiaGroupEntry[archive.Header2.NumberOfGroupEntries];
for (int i = 0; i < archive.Header2.NumberOfGroupEntries; i++)
{
archive.GroupEntries[i] = ParseWiaGroupEntry(data); ;
}
}
}
return archive;
@@ -74,121 +87,184 @@ namespace SabreTools.Serialization.Readers
}
}
#region Header parsing
private static WiaHeader1 ParseHeader1(Stream data)
/// <summary>
/// Parse a Stream into a PartitionDataEntry
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled PartitionDataEntry on success, null on error</returns>
public static PartitionDataEntry ParsePartitionDataEntry(Stream data)
{
var h = new WiaHeader1();
h.Magic = data.ReadUInt32LittleEndian();
h.Version = data.ReadUInt32BigEndian();
h.VersionCompatible = data.ReadUInt32BigEndian();
h.Header2Size = data.ReadUInt32BigEndian();
h.Header2Hash = data.ReadBytes(20);
h.IsoFileSize = data.ReadUInt64BigEndian();
h.WiaFileSize = data.ReadUInt64BigEndian();
h.Header1Hash = data.ReadBytes(20);
return h;
var obj = new PartitionDataEntry();
obj.FirstSector = data.ReadUInt32BigEndian();
obj.NumberOfSectors = data.ReadUInt32BigEndian();
obj.GroupIndex = data.ReadUInt32BigEndian();
obj.NumberOfGroups = data.ReadUInt32BigEndian();
return obj;
}
private static WiaHeader2 ParseHeader2(Stream data)
/// <summary>
/// Parse a Stream into a PartitionEntry
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled PartitionEntry on success, null on error</returns>
public static PartitionEntry ParsePartitionEntry(Stream data)
{
var h = new WiaHeader2();
h.DiscType = (WiaDiscType)data.ReadUInt32BigEndian();
h.CompressionType = (WiaRvzCompressionType)data.ReadUInt32BigEndian();
h.CompressionLevel = data.ReadInt32BigEndian();
h.ChunkSize = data.ReadUInt32BigEndian();
h.DiscHeader = data.ReadBytes(0x80);
h.NumberOfPartitionEntries = data.ReadUInt32BigEndian();
h.PartitionEntrySize = data.ReadUInt32BigEndian();
h.PartitionEntriesOffset = data.ReadUInt64BigEndian();
h.PartitionEntriesHash = data.ReadBytes(20);
h.NumberOfRawDataEntries = data.ReadUInt32BigEndian();
h.RawDataEntriesOffset = data.ReadUInt64BigEndian();
h.RawDataEntriesSize = data.ReadUInt32BigEndian();
h.NumberOfGroupEntries = data.ReadUInt32BigEndian();
h.GroupEntriesOffset = data.ReadUInt64BigEndian();
h.GroupEntriesSize = data.ReadUInt32BigEndian();
h.CompressorDataSize = data.ReadByteValue();
h.CompressorData = data.ReadBytes(7);
return h;
var obj = new PartitionEntry();
obj.PartitionKey = data.ReadBytes(16);
obj.DataEntry0 = ParsePartitionDataEntry(data);
obj.DataEntry1 = ParsePartitionDataEntry(data);
return obj;
}
#endregion
#region Table parsing
private static PartitionEntry[] ParsePartitionEntries(Stream data, int count)
/// <summary>
/// Parse a Stream into a RawDataEntry
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled RawDataEntry on success, null on error</returns>
public static RawDataEntry ParseRawDataEntry(Stream data)
{
var entries = new PartitionEntry[count];
for (int i = 0; i < count; i++)
{
var e = new PartitionEntry();
e.PartitionKey = data.ReadBytes(16);
e.DataEntry0 = ParsePartitionDataEntry(data);
e.DataEntry1 = ParsePartitionDataEntry(data);
entries[i] = e;
}
var obj = new RawDataEntry();
return entries;
obj.DataOffset = data.ReadUInt64BigEndian();
obj.DataSize = data.ReadUInt64BigEndian();
obj.GroupIndex = data.ReadUInt32BigEndian();
obj.NumberOfGroups = data.ReadUInt32BigEndian();
return obj;
}
private static PartitionDataEntry ParsePartitionDataEntry(Stream data)
/// <summary>
/// Parse a byte array into a RawDataEntry
/// </summary>
/// <param name="data">Byte array to parse</param>
/// <returns>Filled RawDataEntry on success, null on error</returns>
public static RawDataEntry ParseRawDataEntry(byte[] data, ref int offset)
{
var e = new PartitionDataEntry();
e.FirstSector = data.ReadUInt32BigEndian();
e.NumberOfSectors = data.ReadUInt32BigEndian();
e.GroupIndex = data.ReadUInt32BigEndian();
e.NumberOfGroups = data.ReadUInt32BigEndian();
return e;
var obj = new RawDataEntry();
obj.DataOffset = data.ReadUInt64BigEndian(ref offset);
obj.DataSize = data.ReadUInt64BigEndian(ref offset);
obj.GroupIndex = data.ReadUInt32BigEndian(ref offset);
obj.NumberOfGroups = data.ReadUInt32BigEndian(ref offset);
return obj;
}
private static RawDataEntry[] ParseRawDataEntries(Stream data, int count)
/// <summary>
/// Parse a Stream into a RvzGroupEntry
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled RvzGroupEntry on success, null on error</returns>
public static RvzGroupEntry ParseRvzGroupEntry(Stream data)
{
var entries = new RawDataEntry[count];
for (int i = 0; i < count; i++)
{
var e = new RawDataEntry();
e.DataOffset = data.ReadUInt64BigEndian();
e.DataSize = data.ReadUInt64BigEndian();
e.GroupIndex = data.ReadUInt32BigEndian();
e.NumberOfGroups = data.ReadUInt32BigEndian();
entries[i] = e;
}
var obj = new RvzGroupEntry();
return entries;
obj.DataOffset = data.ReadUInt32BigEndian();
obj.DataSize = data.ReadUInt32BigEndian();
obj.RvzPackedSize = data.ReadUInt32BigEndian();
return obj;
}
private static WiaGroupEntry[] ParseWiaGroupEntries(Stream data, int count)
/// <summary>
/// Parse a byte array into a RvzGroupEntry
/// </summary>
/// <param name="data">Byte array to parse</param>
/// <returns>Filled RvzGroupEntry on success, null on error</returns>
public static RvzGroupEntry ParseRvzGroupEntry(byte[] data, ref int offset)
{
var entries = new WiaGroupEntry[count];
for (int i = 0; i < count; i++)
{
var e = new WiaGroupEntry();
// DataOffset stored as actual_offset >> 2
e.DataOffset = (ulong)data.ReadUInt32BigEndian() << 2;
e.DataSize = data.ReadUInt32BigEndian();
entries[i] = e;
}
var obj = new RvzGroupEntry();
return entries;
obj.DataOffset = data.ReadUInt32BigEndian(ref offset);
obj.DataSize = data.ReadUInt32BigEndian(ref offset);
obj.RvzPackedSize = data.ReadUInt32BigEndian(ref offset);
return obj;
}
private static RvzGroupEntry[] ParseRvzGroupEntries(Stream data, int count)
/// <summary>
/// Parse a Stream into a WiaGroupEntry
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled WiaGroupEntry on success, null on error</returns>
public static WiaGroupEntry ParseWiaGroupEntry(Stream data)
{
var entries = new RvzGroupEntry[count];
for (int i = 0; i < count; i++)
{
var e = new RvzGroupEntry();
// DataOffset stored as actual_offset >> 2
e.DataOffset = (ulong)data.ReadUInt32BigEndian() << 2;
e.DataSize = data.ReadUInt32BigEndian();
e.RvzPackedSize = data.ReadUInt32BigEndian();
entries[i] = e;
}
var obj = new WiaGroupEntry();
return entries;
obj.DataOffset = data.ReadUInt32BigEndian();
obj.DataSize = data.ReadUInt32BigEndian();
return obj;
}
#endregion
/// <summary>
/// Parse a byte array into a WiaGroupEntry
/// </summary>
/// <param name="data">Byte array to parse</param>
/// <returns>Filled WiaGroupEntry on success, null on error</returns>
public static WiaGroupEntry ParseWiaGroupEntry(byte[] data, ref int offset)
{
var obj = new WiaGroupEntry();
obj.DataOffset = data.ReadUInt32BigEndian(ref offset);
obj.DataSize = data.ReadUInt32BigEndian(ref offset);
return obj;
}
/// <summary>
/// Parse a Stream into a WiaHeader1
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled WiaHeader1 on success, null on error</returns>
public static WiaHeader1 ParseWiaHeader1(Stream data)
{
var obj = new WiaHeader1();
obj.Magic = data.ReadUInt32LittleEndian();
obj.Version = data.ReadUInt32BigEndian();
obj.VersionCompatible = data.ReadUInt32BigEndian();
obj.Header2Size = data.ReadUInt32BigEndian();
obj.Header2Hash = data.ReadBytes(20);
obj.IsoFileSize = data.ReadUInt64BigEndian();
obj.WiaFileSize = data.ReadUInt64BigEndian();
obj.Header1Hash = data.ReadBytes(20);
return obj;
}
/// <summary>
/// Parse a Stream into a WiaHeader2
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled WiaHeader2 on success, null on error</returns>
public static WiaHeader2 ParseWiaHeader2(Stream data)
{
var obj = new WiaHeader2();
obj.DiscType = (WiaDiscType)data.ReadUInt32BigEndian();
obj.CompressionType = (WiaRvzCompressionType)data.ReadUInt32BigEndian();
obj.CompressionLevel = data.ReadInt32BigEndian();
obj.ChunkSize = data.ReadUInt32BigEndian();
obj.DiscHeader = data.ReadBytes(0x80);
obj.NumberOfPartitionEntries = data.ReadUInt32BigEndian();
obj.PartitionEntrySize = data.ReadUInt32BigEndian();
obj.PartitionEntriesOffset = data.ReadUInt64BigEndian();
obj.PartitionEntriesHash = data.ReadBytes(20);
obj.NumberOfRawDataEntries = data.ReadUInt32BigEndian();
obj.RawDataEntriesOffset = data.ReadUInt64BigEndian();
obj.RawDataEntriesSize = data.ReadUInt32BigEndian();
obj.NumberOfGroupEntries = data.ReadUInt32BigEndian();
obj.GroupEntriesOffset = data.ReadUInt64BigEndian();
obj.GroupEntriesSize = data.ReadUInt32BigEndian();
obj.CompressorDataSize = data.ReadByteValue();
obj.CompressorData = data.ReadBytes(7);
return obj;
}
}
}

View File

@@ -18,7 +18,7 @@ namespace SabreTools.Serialization.Writers
if (string.IsNullOrEmpty(path))
return false;
if (obj is null || !ValidateArchive(obj))
if (obj is null || !ValidateDiscImage(obj))
return false;
using var fs = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
@@ -35,41 +35,56 @@ namespace SabreTools.Serialization.Writers
if (stream is null || !stream.CanWrite)
return false;
if (obj is null || !ValidateArchive(obj))
if (obj is null || !ValidateDiscImage(obj))
return false;
// Header (32 bytes, little-endian)
stream.WriteLittleEndian(obj.Header.MagicCookie);
stream.WriteLittleEndian(obj.Header.SubType);
stream.WriteLittleEndian(obj.Header.CompressedDataSize);
stream.WriteLittleEndian(obj.Header.DataSize);
stream.WriteLittleEndian(obj.Header.BlockSize);
stream.WriteLittleEndian(obj.Header.NumBlocks);
WriteHeader(stream, obj.Header);
// Block pointer table (8 bytes per block, little-endian)
foreach (ulong ptr in obj.BlockPointers)
stream.WriteLittleEndian(ptr);
foreach (ulong pointer in obj.BlockPointers)
{
stream.WriteLittleEndian(pointer);
}
// Block hash table (4 bytes per block, little-endian)
foreach (uint hash in obj.BlockHashes)
{
stream.WriteLittleEndian(hash);
}
stream.Flush();
return true;
}
private static bool ValidateArchive(DiscImage obj)
/// <summary>
/// Write GczHeader data to the stream
/// </summary>
/// <param name="stream">Stream to write to</param>
public static void WriteHeader(Stream stream, GczHeader obj)
{
stream.WriteLittleEndian(obj.MagicCookie);
stream.WriteLittleEndian(obj.SubType);
stream.WriteLittleEndian(obj.CompressedDataSize);
stream.WriteLittleEndian(obj.DataSize);
stream.WriteLittleEndian(obj.BlockSize);
stream.WriteLittleEndian(obj.NumBlocks);
}
/// <summary>
/// Validate that disc image is writable
/// </summary>
private static bool ValidateDiscImage(DiscImage obj)
{
if (obj.Header is null)
return false;
if (obj.Header.MagicCookie != Constants.MagicCookie)
return false;
if (obj.Header.NumBlocks == 0)
return false;
if (obj.BlockPointers is null || obj.BlockPointers.Length != (int)obj.Header.NumBlocks)
if (obj.BlockPointers.Length != obj.Header.NumBlocks)
return false;
if (obj.BlockHashes is null || obj.BlockHashes.Length != (int)obj.Header.NumBlocks)
if (obj.BlockHashes.Length != obj.Header.NumBlocks)
return false;
return true;
}
}

View File

@@ -1,6 +1,8 @@
using System;
using System.IO;
using SabreTools.Data.Models.WIA;
using SabreTools.Numerics.Extensions;
using static SabreTools.Data.Models.WIA.Constants;
namespace SabreTools.Serialization.Writers
{
@@ -18,7 +20,7 @@ namespace SabreTools.Serialization.Writers
if (string.IsNullOrEmpty(path))
return false;
if (obj is null || !ValidateArchive(obj))
if (obj is null || !ValidateDiscImage(obj))
return false;
using var fs = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
@@ -35,121 +37,170 @@ namespace SabreTools.Serialization.Writers
if (stream is null || !stream.CanWrite)
return false;
if (obj is null || !ValidateArchive(obj))
if (obj is null || !ValidateDiscImage(obj))
return false;
WriteHeader1(stream, obj.Header1);
WriteHeader2(stream, obj.Header2);
WriteWiaHeader1(stream, obj.Header1);
WriteWiaHeader2(stream, obj.Header2);
// Partition entries
if (obj.PartitionEntries != null)
if (obj.PartitionEntries is not null)
{
foreach (var pe in obj.PartitionEntries)
{
WritePartitionEntry(stream, pe);
}
}
// Raw data entries
foreach (var re in obj.RawDataEntries)
{
WriteRawDataEntry(stream, re);
}
// Group entries
if (obj.Header1.Magic == Constants.RvzMagic && obj.RvzGroupEntries != null)
if (obj.Header1.Magic == RvzMagic && obj.RvzGroupEntries is not null)
{
foreach (var ge in obj.RvzGroupEntries)
{
WriteRvzGroupEntry(stream, ge);
}
}
else if (obj.Header1.Magic != Constants.RvzMagic && obj.GroupEntries != null)
else if (obj.Header1.Magic != RvzMagic && obj.GroupEntries is not null)
{
foreach (var ge in obj.GroupEntries)
{
WriteWiaGroupEntry(stream, ge);
}
}
stream.Flush();
return true;
}
private static bool ValidateArchive(DiscImage obj)
/// <summary>
/// Write PartitionDataEntry data to the stream
/// </summary>
/// <param name="stream">Stream to write to</param>
public static void WritePartitionDataEntry(Stream stream, PartitionDataEntry obj)
{
if (obj.Header1 is null || obj.Header2 is null)
return false;
if (obj.Header1.Magic != Constants.WiaMagic && obj.Header1.Magic != Constants.RvzMagic)
stream.WriteBigEndian(obj.FirstSector);
stream.WriteBigEndian(obj.NumberOfSectors);
stream.WriteBigEndian(obj.GroupIndex);
stream.WriteBigEndian(obj.NumberOfGroups);
stream.Flush();
}
/// <summary>
/// Write PartitionEntry data to the stream
/// </summary>
/// <param name="stream">Stream to write to</param>
public static void WritePartitionEntry(Stream stream, PartitionEntry obj)
{
stream.Write(obj.PartitionKey);
WritePartitionDataEntry(stream, obj.DataEntry0);
WritePartitionDataEntry(stream, obj.DataEntry1);
stream.Flush();
}
/// <summary>
/// Write RawDataEntry data to the stream
/// </summary>
/// <param name="stream">Stream to write to</param>
public static void WriteRawDataEntry(Stream stream, RawDataEntry obj)
{
stream.WriteBigEndian(obj.DataOffset);
stream.WriteBigEndian(obj.DataSize);
stream.WriteBigEndian(obj.GroupIndex);
stream.WriteBigEndian(obj.NumberOfGroups);
stream.Flush();
}
/// <summary>
/// Write RvzGroupEntry data to the stream
/// </summary>
/// <param name="stream">Stream to write to</param>
public static void WriteRvzGroupEntry(Stream stream, RvzGroupEntry obj)
{
stream.WriteBigEndian(obj.DataOffset);
stream.WriteBigEndian(obj.DataSize);
stream.WriteBigEndian(obj.RvzPackedSize);
stream.Flush();
}
/// <summary>
/// Write WiaGroupEntry data to the stream
/// </summary>
/// <param name="stream">Stream to write to</param>
public static void WriteWiaGroupEntry(Stream stream, WiaGroupEntry obj)
{
stream.WriteBigEndian(obj.DataOffset);
stream.WriteBigEndian(obj.DataSize);
stream.Flush();
}
/// <summary>
/// Write WiaHeader1 data to the stream
/// </summary>
/// <param name="stream">Stream to write to</param>
public static void WriteWiaHeader1(Stream stream, WiaHeader1 obj)
{
stream.WriteLittleEndian(obj.Magic);
stream.WriteBigEndian(obj.Version);
stream.WriteBigEndian(obj.VersionCompatible);
stream.WriteBigEndian(obj.Header2Size);
stream.Write(obj.Header2Hash);
stream.WriteBigEndian(obj.IsoFileSize);
stream.WriteBigEndian(obj.WiaFileSize);
stream.Write(obj.Header1Hash);
stream.Flush();
}
/// <summary>
/// Write WiaHeader2 data to the stream
/// </summary>
/// <param name="stream">Stream to write to</param>
public static void WriteWiaHeader2(Stream stream, WiaHeader2 obj)
{
stream.WriteBigEndian((uint)obj.DiscType);
stream.WriteBigEndian((uint)obj.CompressionType);
stream.WriteBigEndian(obj.CompressionLevel);
stream.WriteBigEndian(obj.ChunkSize);
byte[] dh = obj.DiscHeader;
stream.Write(dh, 0, Math.Min(dh.Length, DiscHeaderStoredSize));
if (dh.Length < DiscHeaderStoredSize)
stream.Write(new byte[DiscHeaderStoredSize - dh.Length], 0, DiscHeaderStoredSize - dh.Length);
stream.WriteBigEndian(obj.NumberOfPartitionEntries);
stream.WriteBigEndian(obj.PartitionEntrySize);
stream.WriteBigEndian(obj.PartitionEntriesOffset);
stream.Write(obj.PartitionEntriesHash);
stream.WriteBigEndian(obj.NumberOfRawDataEntries);
stream.WriteBigEndian(obj.RawDataEntriesOffset);
stream.WriteBigEndian(obj.RawDataEntriesSize);
stream.WriteBigEndian(obj.NumberOfGroupEntries);
stream.WriteBigEndian(obj.GroupEntriesOffset);
stream.WriteBigEndian(obj.GroupEntriesSize);
stream.WriteByte(obj.CompressorDataSize);
byte[] prop = obj.CompressorData;
stream.Write(prop, 0, Math.Min(prop.Length, 7));
if (prop.Length < 7)
stream.Write(new byte[7 - prop.Length], 0, 7 - prop.Length);
stream.Flush();
}
/// <summary>
/// Validate that disc image is writable
/// </summary>
private static bool ValidateDiscImage(DiscImage obj)
{
if (obj.Header1.Magic != WiaMagic && obj.Header1.Magic != RvzMagic)
return false;
return true;
}
#region Write helpers
private static void WriteHeader1(Stream s, WiaHeader1 h)
{
s.WriteLittleEndian(h.Magic);
s.WriteBigEndian(h.Version);
s.WriteBigEndian(h.VersionCompatible);
s.WriteBigEndian(h.Header2Size);
s.Write(h.Header2Hash, 0, 20);
s.WriteBigEndian(h.IsoFileSize);
s.WriteBigEndian(h.WiaFileSize);
s.Write(h.Header1Hash, 0, 20);
}
private static void WriteHeader2(Stream s, WiaHeader2 h)
{
s.WriteBigEndian((uint)h.DiscType);
s.WriteBigEndian((uint)h.CompressionType);
s.WriteBigEndian(h.CompressionLevel);
s.WriteBigEndian(h.ChunkSize);
s.Write(h.DiscHeader, 0, 0x80);
s.WriteBigEndian(h.NumberOfPartitionEntries);
s.WriteBigEndian(h.PartitionEntrySize);
s.WriteBigEndian(h.PartitionEntriesOffset);
s.Write(h.PartitionEntriesHash, 0, 20);
s.WriteBigEndian(h.NumberOfRawDataEntries);
s.WriteBigEndian(h.RawDataEntriesOffset);
s.WriteBigEndian(h.RawDataEntriesSize);
s.WriteBigEndian(h.NumberOfGroupEntries);
s.WriteBigEndian(h.GroupEntriesOffset);
s.WriteBigEndian(h.GroupEntriesSize);
s.WriteByte(h.CompressorDataSize);
s.Write(h.CompressorData, 0, 7);
}
private static void WritePartitionDataEntry(Stream s, PartitionDataEntry e)
{
s.WriteBigEndian(e.FirstSector);
s.WriteBigEndian(e.NumberOfSectors);
s.WriteBigEndian(e.GroupIndex);
s.WriteBigEndian(e.NumberOfGroups);
}
private static void WritePartitionEntry(Stream s, PartitionEntry e)
{
s.Write(e.PartitionKey, 0, 16);
WritePartitionDataEntry(s, e.DataEntry0);
WritePartitionDataEntry(s, e.DataEntry1);
}
private static void WriteRawDataEntry(Stream s, RawDataEntry e)
{
s.WriteBigEndian(e.DataOffset);
s.WriteBigEndian(e.DataSize);
s.WriteBigEndian(e.GroupIndex);
s.WriteBigEndian(e.NumberOfGroups);
}
private static void WriteWiaGroupEntry(Stream s, WiaGroupEntry e)
{
// DataOffset stored as actual_offset >> 2
s.WriteBigEndian((uint)(e.DataOffset >> 2));
s.WriteBigEndian(e.DataSize);
}
private static void WriteRvzGroupEntry(Stream s, RvzGroupEntry e)
{
// DataOffset stored as actual_offset >> 2
s.WriteBigEndian((uint)(e.DataOffset >> 2));
s.WriteBigEndian(e.DataSize);
s.WriteBigEndian(e.RvzPackedSize);
}
#endregion
}
}

View File

@@ -46,6 +46,9 @@ namespace SabreTools.Serialization.Writers
return true;
}
/// <summary>
/// Validate that Volume is writable
/// </summary>
public static bool ValidateVolume(Volume? obj)
{
// If the data is invalid

View File

@@ -54,6 +54,7 @@
<Compile Include="..\SabreTools.Serialization.Writers\*.cs" Exclude="..\SabreTools.Serialization.Writers\ExtensionAttribute.cs" />
<Compile Include="..\SabreTools.Wrappers\*.cs" Exclude="..\SabreTools.Wrappers\ExtensionAttribute.cs" />
<Compile Include="..\SabreTools.Wrappers\_EXTERNAL\StormLibSharp\**\*.cs" />
<Compile Include="..\SabreTools.Wrappers\Helpers\*.cs" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,60 @@
using System.IO;
using System.Linq;
using Xunit;
namespace SabreTools.Wrappers.Test
{
public class GCZTests
{
[Fact]
public void NullArray_Null()
{
byte[]? data = null;
int offset = 0;
var actual = GCZ.Create(data, offset);
Assert.Null(actual);
}
[Fact]
public void EmptyArray_Null()
{
byte[]? data = [];
int offset = 0;
var actual = GCZ.Create(data, offset);
Assert.Null(actual);
}
[Fact]
public void InvalidArray_Null()
{
byte[]? data = [.. Enumerable.Repeat<byte>(0xFF, 1024)];
int offset = 0;
var actual = GCZ.Create(data, offset);
Assert.Null(actual);
}
[Fact]
public void NullStream_Null()
{
Stream? data = null;
var actual = GCZ.Create(data);
Assert.Null(actual);
}
[Fact]
public void EmptyStream_Null()
{
Stream? data = new MemoryStream([]);
var actual = GCZ.Create(data);
Assert.Null(actual);
}
[Fact]
public void InvalidStream_Null()
{
Stream? data = new MemoryStream([.. Enumerable.Repeat<byte>(0xFF, 1024)]);
var actual = GCZ.Create(data);
Assert.Null(actual);
}
}
}

View File

@@ -1,238 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using Newtonsoft.Json;
using Xunit;
using Xunit.Abstractions;
namespace SabreTools.Wrappers.Test
{
[Collection("NintendoDisc")]
public class NintendoDiscEncryptionTests
{
private readonly ITestOutputHelper _output;
public NintendoDiscEncryptionTests(ITestOutputHelper output)
{
_output = output;
}
// -----------------------------------------------------------------------
// DecryptTitleKey — no provider set
// -----------------------------------------------------------------------
[Fact]
public void DecryptTitleKey_NoProvider_ReturnsNull()
{
NintendoDisc.CommonKeyProvider = null;
Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[8], 0));
}
// -----------------------------------------------------------------------
// DecryptTitleKey — argument guards
// -----------------------------------------------------------------------
[Fact]
public void DecryptTitleKey_NullEncKey_ReturnsNull()
{
NintendoDisc.CommonKeyProvider = _ => new byte[16];
try { Assert.Null(NintendoDisc.DecryptTitleKey(null!, new byte[8], 0)); }
finally { NintendoDisc.CommonKeyProvider = null; }
}
[Fact]
public void DecryptTitleKey_WrongLengthEncKey_ReturnsNull()
{
NintendoDisc.CommonKeyProvider = _ => new byte[16];
try { Assert.Null(NintendoDisc.DecryptTitleKey(new byte[8], new byte[8], 0)); }
finally { NintendoDisc.CommonKeyProvider = null; }
}
[Fact]
public void DecryptTitleKey_NullTitleId_ReturnsNull()
{
NintendoDisc.CommonKeyProvider = _ => new byte[16];
try { Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], null!, 0)); }
finally { NintendoDisc.CommonKeyProvider = null; }
}
[Fact]
public void DecryptTitleKey_WrongLengthTitleId_ReturnsNull()
{
NintendoDisc.CommonKeyProvider = _ => new byte[16];
try { Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[4], 0)); }
finally { NintendoDisc.CommonKeyProvider = null; }
}
// -----------------------------------------------------------------------
// DecryptTitleKey — provider returns null for unknown index
// -----------------------------------------------------------------------
[Fact]
public void DecryptTitleKey_UnknownIndex_ReturnsNull()
{
NintendoDisc.CommonKeyProvider = _ => null;
try { Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[8], 0)); }
finally { NintendoDisc.CommonKeyProvider = null; }
}
// -----------------------------------------------------------------------
// DecryptTitleKey — round-trip with injected key
// -----------------------------------------------------------------------
[Fact]
public void DecryptTitleKey_WithInjectedKey_RoundTrips()
{
byte[] commonKey =
{
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xF0, 0x0D,
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
};
byte[] plainTitleKey =
{
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10,
};
byte[] titleId = { 0x00, 0x01, 0x00, 0x45, 0x52, 0x53, 0x42, 0x00 };
byte[] iv = new byte[16];
Array.Copy(titleId, 0, iv, 0, 8);
byte[] encTitleKey = AesCbc.Encrypt(plainTitleKey, commonKey, iv)
?? throw new InvalidOperationException("AesCbc.Encrypt returned null");
NintendoDisc.CommonKeyProvider = _ => commonKey;
try
{
byte[]? decrypted = NintendoDisc.DecryptTitleKey(encTitleKey, titleId, 0);
Assert.NotNull(decrypted);
Assert.Equal(plainTitleKey, decrypted);
}
finally { NintendoDisc.CommonKeyProvider = null; }
}
// -----------------------------------------------------------------------
// Integration test — reads a real key file supplied by the user.
//
// Copy keys.json.example to keys.json and fill in the real key bytes.
// The test is silently skipped when the file is absent OR when the loaded
// keys do not hash to the expected SHA256 values hardcoded below, so CI
// stays green without real keys in the repository.
// -----------------------------------------------------------------------
// SHA256(retail common key bytes)
private const string RetailKeySha256 = "de38aeab4fe0c36d828a47e6fd315100e7ce234d3b00aa25e6ad6f5ff2824af8";
// SHA256(Korean common key bytes)
private const string KoreanKeySha256 = "b9f42ca27a1e178f0f14ebf1a05d486fa8db8d08875336c4e6e8dfae29f2901c";
[Fact]
public void LoadFromKeyFile_RealKeys_DecryptTitleKey_Succeeds()
{
string keyFile = Path.Combine(
AppContext.BaseDirectory, "TestData", "NintendoDisc", "keys.json");
_output.WriteLine($"Looking for key file: {keyFile}");
if (!File.Exists(keyFile))
{
_output.WriteLine("Key file not found — test skipped.");
return;
}
_output.WriteLine("Key file found. Parsing...");
var provider = LoadKeyProvider(keyFile);
NintendoDisc.CommonKeyProvider = provider;
try
{
byte[]? retail = provider.Invoke(0);
byte[]? korean = provider.Invoke(1);
string retailHash = retail is null ? "(missing)" : Sha256Hex(retail);
string koreanHash = korean is null ? "(missing)" : Sha256Hex(korean);
_output.WriteLine($"retail (index 0) SHA256 : {retailHash}");
_output.WriteLine($" expected : {RetailKeySha256}");
_output.WriteLine($" match : {retailHash == RetailKeySha256}");
_output.WriteLine($"korean (index 1) SHA256 : {koreanHash}");
_output.WriteLine($" expected : {KoreanKeySha256}");
_output.WriteLine($" match : {koreanHash == KoreanKeySha256}");
if (retail is null || retailHash != RetailKeySha256)
{
_output.WriteLine("retail key did not match — integration assertions skipped.");
return;
}
if (korean is null || koreanHash != KoreanKeySha256)
{
_output.WriteLine("korean key did not match — integration assertions skipped.");
return;
}
_output.WriteLine("Both keys verified — running assertions.");
Assert.Equal(16, retail.Length);
Assert.Equal(16, korean.Length);
_output.WriteLine("Assertions passed.");
}
finally { NintendoDisc.CommonKeyProvider = null; }
}
private static string Sha256Hex(byte[] data)
{
using var sha = SHA256.Create();
return BitConverter.ToString(sha.ComputeHash(data)).Replace("-", string.Empty).ToLowerInvariant();
}
// -----------------------------------------------------------------------
// Helper — parses the named JSON key file and returns a provider delegate.
// Lives here in the test project; the library itself never does file I/O.
// -----------------------------------------------------------------------
/// <summary>
/// Parses a named Wii common-key JSON file and returns a
/// <see cref="NintendoDisc.CommonKeyProvider"/>-compatible delegate.
/// </summary>
/// <remarks>
/// Expected file format:
/// <code>
/// [
/// { "name": "retail", "index": 0, "key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" },
/// { "name": "korean", "index": 1, "key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
/// ]
/// </code>
/// Whitespace inside hex strings is ignored. Returns <see langword="null"/> from the
/// delegate for any index not present in the file.
/// </remarks>
internal static Func<byte, byte[]?> LoadKeyProvider(string path)
{
string json = File.ReadAllText(path);
var entries = JsonConvert.DeserializeObject<List<WiiKeyEntry>>(json)
?? throw new FormatException("Key file could not be deserialized.");
var map = new Dictionary<byte, byte[]>();
foreach (var entry in entries)
{
if (entry.Key is null)
throw new FormatException($"Entry '{entry.Name}' is missing a key value.");
string hex = entry.Key.Replace(" ", string.Empty).Replace("-", string.Empty);
if (hex.Length != 32)
throw new FormatException($"Entry '{entry.Name}' key must be 16 bytes (32 hex chars), got {hex.Length / 2}.");
byte[] bytes = new byte[16];
for (int i = 0; i < 16; i++)
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
map[entry.Index] = bytes;
}
return index => map.TryGetValue(index, out byte[]? k) ? k : null;
}
private sealed class WiiKeyEntry
{
[JsonProperty("name")] public string? Name { get; set; }
[JsonProperty("index")] public byte Index { get; set; }
[JsonProperty("key")] public string? Key { get; set; }
}
}
}

View File

@@ -0,0 +1,94 @@
using System;
using Xunit;
namespace SabreTools.Wrappers.Test
{
[Collection("NintendoDisc")]
public class NintendoDiscTests
{
#region DecryptTitleKey
[Fact]
public void DecryptTitleKey_EmptyKeys_Null()
{
NintendoDisc.RetailCommonKey = [];
NintendoDisc.KoreanCommonKey = [];
Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[8], 0));
}
[Fact]
public void DecryptTitleKey_NullEncKey_Null()
{
NintendoDisc.RetailCommonKey = new byte[16];
NintendoDisc.KoreanCommonKey = new byte[16];
Assert.Null(NintendoDisc.DecryptTitleKey(null, new byte[8], 0));
}
[Fact]
public void DecryptTitleKey_WrongLengthEncKey_Null()
{
NintendoDisc.RetailCommonKey = new byte[16];
NintendoDisc.KoreanCommonKey = new byte[16];
Assert.Null(NintendoDisc.DecryptTitleKey(new byte[8], new byte[8], 0));
}
[Fact]
public void DecryptTitleKey_NullTitleId_Null()
{
NintendoDisc.RetailCommonKey = new byte[16];
NintendoDisc.KoreanCommonKey = new byte[16];
Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], null, 0));
}
[Fact]
public void DecryptTitleKey_WrongLengthTitleId_Null()
{
NintendoDisc.RetailCommonKey = new byte[16];
NintendoDisc.KoreanCommonKey = new byte[16];
Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[4], 0));
}
[Theory]
[InlineData(-1)]
[InlineData(2)]
public void DecryptTitleKey_UnknownIndex_Null(int index)
{
Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[8], index));
}
[Fact]
public void DecryptTitleKey_WithInjectedKey_RoundTrips()
{
byte[] commonKey =
[
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xF0, 0x0D,
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
];
byte[] plainTitleKey =
[
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10,
];
byte[] titleId = [0x00, 0x01, 0x00, 0x45, 0x52, 0x53, 0x42, 0x00];
byte[] iv = new byte[16];
Array.Copy(titleId, 0, iv, 0, 8);
byte[] encTitleKey = AesCbc.Encrypt(plainTitleKey, commonKey, iv)
?? throw new InvalidOperationException("AesCbc.Encrypt returned null");
NintendoDisc.RetailCommonKey = commonKey;
NintendoDisc.KoreanCommonKey = commonKey;
byte[]? decrypted = NintendoDisc.DecryptTitleKey(encTitleKey, titleId, 0);
Assert.NotNull(decrypted);
Assert.Equal(plainTitleKey, decrypted);
}
#endregion
}
}

View File

@@ -3,15 +3,48 @@ using System.IO;
using System.Linq;
using SabreTools.Numerics.Extensions;
using Xunit;
using static SabreTools.Data.Models.NintendoDisc.Constants;
namespace SabreTools.Wrappers.Test
{
[Collection("NintendoDisc")]
public class WIATests
{
// -----------------------------------------------------------------------
// WIA.Create null / invalid guards
// -----------------------------------------------------------------------
/// <summary>
/// Arbitrary test-only common key — no relation to any real Wii key.
/// Used by both <see cref="BuildMinimalWiiIso"/> and <see cref="EncryptTitleKeyIndependent"/>.
/// </summary>
private static readonly byte[] TestCommonKey =
[
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xF0, 0x0D,
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
];
#region Constants
private const int HeaderAreaSize = 0x8000;
private const long IsoSize = Partition1Data + WiiGroupSize;
private const long Partition0Offset = 0x60000;
private const long Partition0Data = Partition0Offset + HeaderAreaSize;
private const long Partition1Offset = Partition0Data + WiiGroupSize;
private const long Partition1Data = Partition1Offset + HeaderAreaSize;
private const long PartitionListOffset = 0x50000;
private const long PartitionTableOffset = 0x40000;
#endregion
public WIATests()
{
NintendoDisc.RetailCommonKey = TestCommonKey;
NintendoDisc.KoreanCommonKey = TestCommonKey;
}
[Fact]
public void NullArray_Null()
@@ -64,66 +97,21 @@ namespace SabreTools.Wrappers.Test
Assert.Null(actual);
}
// -----------------------------------------------------------------------
// DumpIso guard
// -----------------------------------------------------------------------
/// <summary>
/// Build the smallest valid WIA we can to get a non-null wrapper,
/// but for the guard test we only need to exercise the null-path branch.
/// We can create a real wrapper via the round-trip helper and then call
/// DumpIso with a null path — that must return false.
/// </summary>
[Fact]
public void DumpIso_NullPath_ReturnsFalse()
{
// Build the smallest valid WIA we can to get a non-null wrapper,
// but for the guard test we only need to exercise the null-path branch.
// We can create a real wrapper via the round-trip helper and then call
// DumpIso with a null path — that must return false.
var wia = BuildMinimalWiiWia();
Assert.NotNull(wia);
Assert.False(wia!.DumpIso(null!));
Assert.False(wia!.DumpIso(null));
}
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
/// <summary>
/// Builds a minimal synthetic Wii disc (one WiiGroup per partition) and returns a live
/// <see cref="WIA"/> wrapper backed by a <see cref="MemoryStream"/>.
/// Returns null if any step fails.
/// </summary>
private static WIA? BuildMinimalWiiWia()
{
NintendoDisc.CommonKeyProvider = _ => TestCommonKey;
try
{
byte[] iso = BuildMinimalWiiIso(TestCommonKey);
var nd = NintendoDisc.Create(new MemoryStream(iso));
if (nd is null) return null;
var ms = new MemoryStream();
bool ok = WIA.ConvertFromDiscToStream(nd, ms,
isRvz: false,
compressionType: Data.Models.WIA.WiaRvzCompressionType.None,
compressionLevel: 5,
chunkSize: Data.Models.WIA.Constants.DefaultChunkSize,
out _);
if (!ok) return null;
ms.Position = 0;
return WIA.Create(ms);
}
catch
{
return null;
}
finally
{
NintendoDisc.CommonKeyProvider = null;
}
}
// -----------------------------------------------------------------------
// Round-trip: Wii (partition crypto — encrypt → WIA → dump → decrypt)
// -----------------------------------------------------------------------
/// <summary>
/// Builds a synthetic Wii disc with 2 fake partitions (each 1 WiiGroup = 64 × 0x8000 bytes of
/// known plaintext encrypted with an arbitrary key), converts it to WIA (NONE compression),
@@ -134,10 +122,10 @@ namespace SabreTools.Wrappers.Test
/// This exercises both directions:
/// • WIA write path re-encrypts partition data correctly (<see cref="WIA.ConvertFromDiscToStream"/>)
/// • WIA read path (<see cref="WiaVirtualStream"/>) re-encrypts WIA decrypted groups back to
/// ISO-layout AES-CBC blocks via <c>GetCachedEncGroup</c> / <c>EncryptWiiGroup</c>
/// ISO-layout AES-CBC blocks via GetCachedEncGroup / EncryptWiiGroup
///
/// Anti-bias: the final decryption uses <see cref="NintendoDisc.DecryptBlock"/> — a single-block
/// AES-CBC call that is completely independent of <c>EncryptWiiGroup</c> — so a symmetric bug
/// AES-CBC call that is completely independent of EncryptWiiGroup — so a symmetric bug
/// (broken encrypt paired with broken decrypt) would still fail the plaintext comparison.
/// The title key is encrypted via <see cref="AesCbc.Encrypt"/> (BouncyCastle), while the
/// verification uses <see cref="NintendoDisc.DecryptBlock"/> — a different code path.
@@ -145,9 +133,6 @@ namespace SabreTools.Wrappers.Test
[Fact]
public void Wii_WiaNoneRoundTrip_Succeeds()
{
NintendoDisc.CommonKeyProvider = _ => TestCommonKey;
try
{
// ---- Build synthetic Wii ISO ----
byte[] iso = BuildMinimalWiiIso(TestCommonKey);
@@ -165,8 +150,7 @@ namespace SabreTools.Wrappers.Test
compressionLevel: 5,
chunkSize: Data.Models.WIA.Constants.DefaultChunkSize,
out Exception? writeEx);
Assert.True(written,
$"ConvertFromDiscToStream failed: {writeEx?.GetType().Name}: {writeEx?.Message}\n{writeEx?.StackTrace}");
Assert.True(written, $"ConvertFromDiscToStream failed: {writeEx?.GetType().Name}: {writeEx?.Message}\n{writeEx?.StackTrace}");
// ---- Decompress back to ISO ----
wiaMs.Position = 0;
@@ -181,58 +165,85 @@ namespace SabreTools.Wrappers.Test
byte[] dumpedIso = File.ReadAllBytes(tempIso);
const int WiiBlockSize = 0x8000;
const int WiiBlockDataSize = 0x7C00;
const int WiiBlocksPerGroup = 64;
const int WiiGroupSize = WiiBlocksPerGroup * WiiBlockSize;
const int HeaderAreaSize = 0x8000;
const long Partition0Offset = 0x60000;
const long Partition0Data = Partition0Offset + HeaderAreaSize;
const long Partition1Offset = Partition0Data + WiiGroupSize;
const long Partition1Data = Partition1Offset + HeaderAreaSize;
byte[] titleKey = new byte[16]
{
byte[] titleKey =
[
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10,
};
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10,
];
byte[] plain0 = new byte[WiiBlocksPerGroup * WiiBlockDataSize];
for (int i = 0; i < plain0.Length; i++) plain0[i] = 0xAA;
for (int i = 0; i < plain0.Length; i++)
{
plain0[i] = 0xAA;
}
byte[] plain1 = new byte[WiiBlocksPerGroup * WiiBlockDataSize];
for (int i = 0; i < plain1.Length; i++) plain1[i] = 0xBB;
for (int i = 0; i < plain1.Length; i++)
{
plain1[i] = 0xBB;
}
// ---- Anti-bias verification: decrypt each block using DecryptBlock only ----
VerifyPartitionPlaintext(dumpedIso, Partition0Data, plain0, titleKey,
WiiBlocksPerGroup, WiiBlockSize, WiiBlockDataSize, partitionLabel: "Partition 0");
VerifyPartitionPlaintext(dumpedIso,
Partition0Data,
plain0,
titleKey,
WiiBlocksPerGroup,
WiiBlockSize,
WiiBlockDataSize,
partitionLabel: "Partition 0");
VerifyPartitionPlaintext(dumpedIso, Partition1Data, plain1, titleKey,
WiiBlocksPerGroup, WiiBlockSize, WiiBlockDataSize, partitionLabel: "Partition 1");
VerifyPartitionPlaintext(dumpedIso,
Partition1Data,
plain1,
titleKey,
WiiBlocksPerGroup,
WiiBlockSize,
WiiBlockDataSize,
partitionLabel: "Partition 1");
}
finally
{
if (File.Exists(tempIso)) File.Delete(tempIso);
}
}
finally
{
NintendoDisc.CommonKeyProvider = null;
if (File.Exists(tempIso))
File.Delete(tempIso);
}
}
// -----------------------------------------------------------------------
// Wii test helpers
// -----------------------------------------------------------------------
#region Wii test helpers
/// <summary>
/// Arbitrary test-only common key — no relation to any real Wii key.
/// Used by both <see cref="BuildMinimalWiiIso"/> and <see cref="EncryptTitleKeyIndependent"/>.
/// Builds a minimal synthetic Wii disc (one WiiGroup per partition) and returns a live
/// <see cref="WIA"/> wrapper backed by a <see cref="MemoryStream"/>.
/// Returns null if any step fails.
/// </summary>
private static readonly byte[] TestCommonKey =
private static WIA? BuildMinimalWiiWia()
{
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xF0, 0x0D,
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
};
try
{
byte[] iso = BuildMinimalWiiIso(TestCommonKey);
var nd = NintendoDisc.Create(new MemoryStream(iso));
if (nd is null)
return null;
var ms = new MemoryStream();
bool ok = WIA.ConvertFromDiscToStream(nd, ms,
isRvz: false,
compressionType: Data.Models.WIA.WiaRvzCompressionType.None,
compressionLevel: 5,
chunkSize: Data.Models.WIA.Constants.DefaultChunkSize,
out _);
if (!ok)
return null;
ms.Position = 0;
return WIA.Create(ms);
}
catch
{
return null;
}
}
/// <summary>
/// Builds a minimal synthetic Wii ISO with 2 partitions (1 WiiGroup each), encrypted
@@ -240,37 +251,29 @@ namespace SabreTools.Wrappers.Test
/// </summary>
private static byte[] BuildMinimalWiiIso(byte[] commonKey)
{
const int WiiBlockSize = 0x8000;
const int WiiBlockDataSize = 0x7C00;
const int WiiBlocksPerGroup = 64;
const int WiiGroupDataSize = WiiBlocksPerGroup * WiiBlockDataSize;
const int WiiGroupSize = WiiBlocksPerGroup * WiiBlockSize;
byte[] titleKey = new byte[16]
{
byte[] titleKey =
[
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10,
};
byte[] titleId = new byte[8] { 0x00, 0x01, 0x00, 0x45, 0x52, 0x53, 0x42, 0x00 };
];
byte[] titleId = [0x00, 0x01, 0x00, 0x45, 0x52, 0x53, 0x42, 0x00];
byte[] encTitleKey = EncryptTitleKeyIndependent(titleKey, titleId, commonKey);
byte[] plain0 = new byte[WiiGroupDataSize];
for (int i = 0; i < plain0.Length; i++) plain0[i] = 0xAA;
for (int i = 0; i < plain0.Length; i++)
{
plain0[i] = 0xAA;
}
byte[] plain1 = new byte[WiiGroupDataSize];
for (int i = 0; i < plain1.Length; i++) plain1[i] = 0xBB;
for (int i = 0; i < plain1.Length; i++)
{
plain1[i] = 0xBB;
}
byte[] enc0 = WIA.EncryptWiiGroup(plain0, titleKey, WiiBlocksPerGroup);
byte[] enc1 = WIA.EncryptWiiGroup(plain1, titleKey, WiiBlocksPerGroup);
const long PartitionTableOffset = 0x40000;
const long PartitionListOffset = 0x50000;
const long Partition0Offset = 0x60000;
const int HeaderAreaSize = 0x8000; // data starts one full block after partition base
const long Partition0Data = Partition0Offset + HeaderAreaSize;
const long Partition1Offset = Partition0Data + WiiGroupSize;
const long Partition1Data = Partition1Offset + HeaderAreaSize;
const long IsoSize = Partition1Data + WiiGroupSize;
byte[] iso = new byte[IsoSize];
iso[0] = (byte)'R'; iso[1] = (byte)'S'; iso[2] = (byte)'B'; iso[3] = (byte)'E';
@@ -296,8 +299,19 @@ namespace SabreTools.Wrappers.Test
return iso;
}
private static void WritePartitionHeader(byte[] iso, long partOffset,
byte[] encTitleKey, byte[] titleId, byte ckIdx)
/// <summary>
///
/// </summary>
/// <param name="iso"></param>
/// <param name="partOffset"></param>
/// <param name="encTitleKey"></param>
/// <param name="titleId"></param>
/// <param name="ckIdx"></param>
private static void WritePartitionHeader(byte[] iso,
long partOffset,
byte[] encTitleKey,
byte[] titleId,
byte ckIdx)
{
// Signature type 0x10001 at partOffset+0
int off = (int)partOffset;
@@ -325,12 +339,16 @@ namespace SabreTools.Wrappers.Test
/// <summary>
/// Decrypts each block of one WII partition in the dumped ISO using only
/// <see cref="NintendoDisc.DecryptBlock"/> (a single-block AES-CBC call that is
/// completely independent of <c>EncryptWiiGroup</c>) and asserts the decrypted
/// completely independent of EncryptWiiGroup) and asserts the decrypted
/// block data matches the corresponding slice of <paramref name="expectedPlaintext"/>.
/// </summary>
private static void VerifyPartitionPlaintext(byte[] iso, long dataStart,
byte[] expectedPlaintext, byte[] titleKey,
int blocksPerGroup, int blockSize, int blockDataSize,
private static void VerifyPartitionPlaintext(byte[] iso,
long dataStart,
byte[] expectedPlaintext,
byte[] titleKey,
int blocksPerGroup,
int blockSize,
int blockDataSize,
string partitionLabel)
{
for (int b = 0; b < blocksPerGroup; b++)
@@ -370,6 +388,6 @@ namespace SabreTools.Wrappers.Test
?? throw new InvalidOperationException("AesCbc.Encrypt returned null");
}
}
}
#endregion
}
}

View File

@@ -1,3 +1,8 @@
using System.IO;
using SabreTools.IO.Compression.Deflate;
using SabreTools.IO.Extensions;
using static SabreTools.Data.Models.GCZ.Constants;
namespace SabreTools.Wrappers
{
public partial class GCZ : IExtractable
@@ -5,9 +10,87 @@ namespace SabreTools.Wrappers
/// <inheritdoc/>
public bool Extract(string outputDirectory, bool includeDebug)
{
// Decompress GCZ to obtain the inner disc image, then delegate extraction.
var inner = GetInnerWrapper();
return inner?.Extract(outputDirectory, includeDebug) ?? false;
if (inner is null)
return false;
return inner.Extract(outputDirectory, includeDebug);
}
#region Inner Wrapper
/// <summary>
/// Returns a NintendoDisc wrapper backed by a virtual stream that decompresses
/// GCZ blocks on demand, avoiding loading the entire ISO into memory.
/// </summary>
public NintendoDisc? GetInnerWrapper()
{
if (BlockPointers.Length == 0)
return null;
if (Header.DataSize == 0)
return null;
var stream = new GczVirtualStream(this);
return NintendoDisc.Create(stream);
}
/// <summary>
/// Decompresses a single GCZ block by index and returns its raw bytes.
/// Returns null on failure; returns a zero-filled block if the compressed size is zero.
/// </summary>
internal byte[]? DecompressBlock(int blockIndex)
{
if (blockIndex < 0 || blockIndex >= BlockPointers.Length)
return null;
ulong ptr = BlockPointers[blockIndex];
long blockFileOffset = DataOffset + (long)(ptr & ~UncompressedFlag);
ulong nextRaw = (blockIndex + 1 < BlockPointers.Length)
? BlockPointers[blockIndex + 1] & ~UncompressedFlag
: CompressedDataSize;
int compSize = (int)(nextRaw - (ptr & ~UncompressedFlag));
if (compSize <= 0)
return new byte[BlockSize];
byte[] raw = ReadRangeFromSource(blockFileOffset, compSize);
if (raw.Length != compSize)
return null;
// Verify Adler-32 checksum on the compressed (raw) data before decompressing
if (BlockHashes is not null && blockIndex < BlockHashes.Length)
{
uint actual = Adler.Adler32(1, raw, 0, raw.Length);
if (actual != BlockHashes[blockIndex])
return null;
}
// If the data is raw, just return
if ((ptr & UncompressedFlag) != 0)
return raw;
// GCZ blocks are zlib-framed: 2-byte header + deflate data + 4-byte Adler-32 trailer.
// Strip the frame and feed raw deflate data to DeflateStream.
if (raw.Length < 6)
return null;
try
{
using var cs = new MemoryStream(raw, 2, raw.Length - 6);
using var ds = new DeflateStream(cs, CompressionMode.Decompress);
using var os = new MemoryStream();
ds.BlockCopy(os, blockSize: 4096);
return os.ToArray();
}
catch
{
return null;
}
}
#endregion
}
}

View File

@@ -1,4 +1,6 @@
using System.Text;
using SabreTools.Data.Models.GCZ;
using SabreTools.Data.Models.NintendoDisc;
using SabreTools.Text.Extensions;
namespace SabreTools.Wrappers
@@ -18,25 +20,41 @@ namespace SabreTools.Wrappers
{
builder.AppendLine("GCZ Information:");
builder.AppendLine("-------------------------");
builder.AppendLine(Header.MagicCookie, "Magic Cookie");
builder.AppendLine(Header.SubType, "Sub-Type");
builder.AppendLine(Header.CompressedDataSize, "Compressed Data Size");
builder.AppendLine(Header.DataSize, "Uncompressed Data Size");
builder.AppendLine(Header.BlockSize, "Block Size");
builder.AppendLine(Header.NumBlocks, "Block Count");
builder.AppendLine();
var discHeader = DiscHeader;
if (discHeader is not null)
Print(builder, Header);
Print(builder, DiscHeader);
}
private static void Print(StringBuilder builder, GczHeader header)
{
builder.AppendLine(" Header:");
builder.AppendLine(" -------------------------");
builder.AppendLine(header.MagicCookie, "Magic Cookie");
builder.AppendLine(header.SubType, "Sub-Type");
builder.AppendLine(header.CompressedDataSize, "Compressed Data Size");
builder.AppendLine(header.DataSize, "Uncompressed Data Size");
builder.AppendLine(header.BlockSize, "Block Size");
builder.AppendLine(header.NumBlocks, "Block Count");
builder.AppendLine();
}
private static void Print(StringBuilder builder, DiscHeader? header)
{
builder.AppendLine(" Embedded Disc Header:");
builder.AppendLine(" -------------------------");
if (header is null)
{
builder.AppendLine("Embedded Disc Header:");
builder.AppendLine(discHeader.GameId, " Game ID");
builder.AppendLine(discHeader.MakerCode, " Maker Code");
builder.AppendLine(discHeader.DiscNumber, " Disc Number");
builder.AppendLine(discHeader.DiscVersion, " Disc Version");
builder.AppendLine(discHeader.GameTitle, " Game Title");
builder.AppendLine(" No embedded disc header");
builder.AppendLine();
return;
}
builder.AppendLine(header.GameId, " Game ID");
builder.AppendLine(header.DiscNumber, " Disc Number");
builder.AppendLine(header.DiscVersion, " Disc Version");
builder.AppendLine(header.GameTitle, " Game Title");
builder.AppendLine();
}
}
}

View File

@@ -1,45 +1,14 @@
using System;
using SabreTools.IO.Compression.Deflate;
using System.IO;
using SabreTools.Data.Models.GCZ;
using SabreTools.IO.Compression.Deflate;
using SabreTools.IO.Extensions;
using SabreTools.Numerics.Extensions;
namespace SabreTools.Wrappers
{
public partial class GCZ : IWritable
{
/// <summary>
/// Compress a NintendoDisc wrapper to a GCZ file at the given path.
/// </summary>
/// <param name="source">Decompressed disc image to compress.</param>
/// <param name="outputPath">Destination file path.</param>
/// <param name="blockSize">
/// GCZ block size: 32 KiB, 64 KiB, or 128 KiB.
/// Defaults to <see cref="Constants.DefaultBlockSize"/> (32 KiB).
/// </param>
/// <returns>True on success, false on failure.</returns>
public static bool ConvertFromDisc(NintendoDisc source, string outputPath,
uint blockSize = Constants.DefaultBlockSize)
{
if (source is null)
return false;
if (string.IsNullOrEmpty(outputPath))
return false;
if (blockSize != Constants.BlockSize32K &&
blockSize != Constants.BlockSize64K &&
blockSize != Constants.BlockSize128K)
return false;
try
{
using var fs = File.Open(outputPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
return WriteGcz(source, fs, blockSize);
}
catch
{
return false;
}
}
/// <inheritdoc/>
public bool Write(string outputPath, bool includeDebug)
{
@@ -53,19 +22,44 @@ namespace SabreTools.Wrappers
outputPath = Path.GetFullPath(outputFilename);
}
if (Model?.Header is null)
{
if (includeDebug) Console.WriteLine("Model was invalid, cannot write!");
return false;
}
var writer = new Serialization.Writers.GCZ { Debug = includeDebug };
return writer.SerializeFile(Model, outputPath);
}
// -----------------------------------------------------------------------
// Core GCZ compression pipeline (ISO → GCZ)
// -----------------------------------------------------------------------
#region Core GCZ Compression Pipeline (ISO -> GCZ)
/// <summary>
/// Compress a NintendoDisc wrapper to a GCZ file at the given path.
/// </summary>
/// <param name="source">Decompressed disc image to compress.</param>
/// <param name="outputPath">Destination file path.</param>
/// <param name="blockSize">
/// GCZ block size: 32 KiB, 64 KiB, or 128 KiB.
/// Defaults to <see cref="Constants.DefaultBlockSize"/> (32 KiB).
/// </param>
/// <returns>True on success, false on failure.</returns>
public static bool ConvertFromDisc(NintendoDisc source, string outputPath, uint blockSize = Constants.DefaultBlockSize)
{
if (string.IsNullOrEmpty(outputPath))
return false;
if (blockSize != Constants.BlockSize32K
&& blockSize != Constants.BlockSize64K
&& blockSize != Constants.BlockSize128K)
{
return false;
}
try
{
using var fs = File.Open(outputPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
return WriteGcz(source, fs, blockSize);
}
catch
{
return false;
}
}
/// <summary>
/// Write a GCZ image to <paramref name="destination"/> from a decompressed disc source.
@@ -83,36 +77,36 @@ namespace SabreTools.Wrappers
long headerPos = destination.Position;
var header = new GczHeader
{
MagicCookie = Constants.MagicCookie,
SubType = 0,
MagicCookie = Constants.MagicCookie,
SubType = 0,
CompressedDataSize = 0,
DataSize = (ulong)sourceSize,
BlockSize = blockSize,
NumBlocks = numBlocks,
DataSize = (ulong)sourceSize,
BlockSize = blockSize,
NumBlocks = numBlocks,
};
WriteHeader(destination, header);
Serialization.Writers.GCZ.WriteHeader(destination, header);
// ---- Step 2: Reserve block-pointer table (8 bytes each) ----
long blockTablePos = destination.Position;
var blockPointers = new ulong[numBlocks];
destination.Position += (long)numBlocks * 8;
var blockPointers = new ulong[numBlocks];
destination.SeekIfPossible(numBlocks * 8, SeekOrigin.Current);
// ---- Step 3: Reserve block-hash table (4 bytes each) ----
var blockHashes = new uint[numBlocks];
destination.Position += (long)numBlocks * 4;
destination.SeekIfPossible(numBlocks * 4, SeekOrigin.Current);
// ---- Step 4: Data section starts here ----
long dataStartPos = destination.Position;
var readBuf = new byte[blockSize];
var compressBuf = new byte[(int)blockSize * 2];
long dataStartPos = destination.Position;
var readBuf = new byte[blockSize];
var compressBuf = new byte[(int)blockSize * 2];
for (uint bi = 0; bi < numBlocks; bi++)
for (int i = 0; i < numBlocks; i++)
{
long blockOffset = (long)bi * blockSize;
int blockDataSize = (int)Math.Min(blockSize, sourceSize - blockOffset);
long blockOffset = i * blockSize;
int blockDataSize = (int)Math.Min(blockSize, sourceSize - blockOffset);
byte[]? raw = source.ReadData(blockOffset, blockDataSize);
if (raw is null || raw.Length != blockDataSize)
byte[] raw = source.ReadData(blockOffset, blockDataSize);
if (raw.Length != blockDataSize)
return false;
if (blockDataSize < readBuf.Length)
@@ -123,20 +117,18 @@ namespace SabreTools.Wrappers
// Record pointer as offset relative to data section start
ulong blockPointer = (ulong)(destination.Position - dataStartPos);
int compressedSize;
bool useCompression = TryCompressBlock(readBuf, blockDataSize, compressBuf, out compressedSize);
bool useCompression = TryCompressBlock(readBuf, blockDataSize, compressBuf, out int compressedSize);
if (useCompression)
{
blockPointers[bi] = blockPointer;
blockPointers[i] = blockPointer;
destination.Write(compressBuf, 0, compressedSize);
blockHashes[bi] = Adler.Adler32(1, compressBuf, 0, compressedSize);
blockHashes[i] = Adler.Adler32(1, compressBuf, 0, compressedSize);
}
else
{
blockPointers[bi] = blockPointer | Constants.UncompressedFlag;
blockPointers[i] = blockPointer | Constants.UncompressedFlag;
destination.Write(readBuf, 0, blockDataSize);
blockHashes[bi] = Adler.Adler32(1, readBuf, 0, blockDataSize);
blockHashes[i] = Adler.Adler32(1, readBuf, 0, blockDataSize);
}
}
@@ -145,26 +137,30 @@ namespace SabreTools.Wrappers
header.CompressedDataSize = (ulong)(finalEnd - dataStartPos);
// ---- Step 6: Write block-pointer table ----
destination.Position = blockTablePos;
destination.SeekIfPossible(blockTablePos, SeekOrigin.Begin);
foreach (ulong ptr in blockPointers)
WriteUInt64LE(destination, ptr);
{
destination.WriteLittleEndian(ptr);
}
// ---- Step 7: Write block-hash table ----
foreach (uint h in blockHashes)
WriteUInt32LE(destination, h);
{
destination.WriteLittleEndian(h);
}
// ---- Step 8: Patch header ----
destination.Position = headerPos;
WriteHeader(destination, header);
destination.SeekIfPossible(headerPos, SeekOrigin.Begin);
Serialization.Writers.GCZ.WriteHeader(destination, header);
destination.Position = finalEnd;
destination.Flush();
return true;
}
// -----------------------------------------------------------------------
// Compression helpers
// -----------------------------------------------------------------------
#endregion
#region Compression Helpers
/// <summary>
/// Attempts to zlib-compress <paramref name="inputSize"/> bytes of <paramref name="input"/>
@@ -172,57 +168,31 @@ namespace SabreTools.Wrappers
/// when the result is smaller than 97 % of the original (Dolphin's threshold).
/// GCZ uses the zlib framing: 2-byte header (0x78 0x9C) + deflate stream + 4-byte Adler-32 tail.
/// </summary>
private static bool TryCompressBlock(byte[] input, int inputSize, byte[] output, out int compressedSize)
{
using (var ms = new MemoryStream(output))
{
ms.WriteByte(0x78);
ms.WriteByte(0x9C);
using (var ds = new DeflateStream(ms, CompressionMode.Compress, leaveOpen: true))
private static bool TryCompressBlock(byte[] input, int inputSize, byte[] output, out int compressedSize)
{
ds.Write(input, 0, inputSize);
using (var ms = new MemoryStream(output))
{
ms.WriteByte(0x78);
ms.WriteByte(0x9C);
using (var ds = new DeflateStream(ms, CompressionMode.Compress, leaveOpen: true))
{
ds.Write(input, 0, inputSize);
}
uint adler = Adler.Adler32(1, input, 0, inputSize);
ms.WriteByte((byte)(adler >> 24));
ms.WriteByte((byte)(adler >> 16));
ms.WriteByte((byte)(adler >> 8));
ms.WriteByte((byte)adler);
compressedSize = (int)ms.Position;
}
int threshold = inputSize * 97 / 100;
return compressedSize < threshold;
}
uint adler = Adler.Adler32(1, input, 0, inputSize);
ms.WriteByte((byte)(adler >> 24));
ms.WriteByte((byte)(adler >> 16));
ms.WriteByte((byte)(adler >> 8));
ms.WriteByte((byte)adler);
compressedSize = (int)ms.Position;
}
int threshold = inputSize * 97 / 100;
return compressedSize < threshold;
}
// -----------------------------------------------------------------------
// Little-endian binary write helpers
// -----------------------------------------------------------------------
private static void WriteHeader(Stream s, GczHeader h)
{
WriteUInt32LE(s, h.MagicCookie);
WriteUInt32LE(s, h.SubType);
WriteUInt64LE(s, h.CompressedDataSize);
WriteUInt64LE(s, h.DataSize);
WriteUInt32LE(s, h.BlockSize);
WriteUInt32LE(s, h.NumBlocks);
}
private static void WriteUInt32LE(Stream s, uint v)
{
s.WriteByte((byte)v);
s.WriteByte((byte)(v >> 8));
s.WriteByte((byte)(v >> 16));
s.WriteByte((byte)(v >> 24));
}
private static void WriteUInt64LE(Stream s, ulong v)
{
WriteUInt32LE(s, (uint)v);
WriteUInt32LE(s, (uint)(v >> 32));
}
#endregion
}
}

View File

@@ -1,7 +1,9 @@
using System.IO;
using System.IO.Compression;
using SabreTools.Data.Models.GCZ;
using SabreTools.Data.Models.NintendoDisc;
using SabreTools.IO.Compression.Deflate;
using SabreTools.IO.Extensions;
using static SabreTools.Data.Models.GCZ.Constants;
namespace SabreTools.Wrappers
{
@@ -19,39 +21,24 @@ namespace SabreTools.Wrappers
/// <inheritdoc cref="DiscImage.Header"/>
public GczHeader Header => Model.Header;
/// <summary>
/// Total decompressed size of the disc image in bytes
/// </summary>
public ulong DataSize => Model.Header.DataSize;
/// <inheritdoc cref="GczHeader.CompressedDataSize"/>
public ulong CompressedDataSize => Header.CompressedDataSize;
/// <summary>
/// Number of compressed blocks in this image
/// </summary>
public uint NumBlocks => Model.Header.NumBlocks;
/// <inheritdoc cref="GczHeader.DataSize"/>
public ulong DataSize => Header.DataSize;
/// <summary>
/// Size of each uncompressed block in bytes
/// </summary>
public uint BlockSize => Model.Header.BlockSize;
/// <inheritdoc cref="GczHeader.NumBlocks"/>
public uint NumBlocks => Header.NumBlocks;
/// <summary>
/// Block pointer table — top bit indicates uncompressed flag
/// </summary>
/// <inheritdoc cref="GczHeader.BlockSize"/>
public uint BlockSize => Header.BlockSize;
/// <inheritdoc cref="DiscImage.BlockPointers"/>
public ulong[] BlockPointers => Model.BlockPointers;
/// <summary>
/// Adler-32 hashes of each uncompressed block
/// </summary>
/// <inheritdoc cref="DiscImage.BlockHashes"/>
public uint[] BlockHashes => Model.BlockHashes;
/// <summary>
/// Byte offset within the GCZ file where the compressed block data begins.
/// Computed as: <c>HeaderSize + (NumBlocks * 8) + (NumBlocks * 4)</c>.
/// </summary>
private long DataOffset => Data.Models.GCZ.Constants.HeaderSize
+ ((long)Model.Header.NumBlocks * 8)
+ ((long)Model.Header.NumBlocks * 4);
/// <summary>
/// Disc header parsed by decompressing the first block of the GCZ image.
/// </summary>
@@ -59,16 +46,19 @@ namespace SabreTools.Wrappers
{
get
{
if (_discHeaderCached)
return _discHeader;
_discHeader = ReadDiscHeader();
_discHeaderCached = true;
return _discHeader;
if (field is not null)
return field;
field = ReadDiscHeader();
return field;
}
}
private DiscHeader? _discHeader;
private bool _discHeaderCached;
/// <summary>
/// Byte offset within the GCZ file where the compressed block data begins.
/// Computed as: HeaderSize + (NumBlocks * 8) + (NumBlocks * 4).
/// </summary>
public long DataOffset => HeaderSize + (NumBlocks * 8) + (NumBlocks * 4);
#endregion
@@ -146,83 +136,7 @@ namespace SabreTools.Wrappers
#endregion
#region Inner Wrapper
/// <summary>
/// Returns a NintendoDisc wrapper backed by a virtual stream that decompresses
/// GCZ blocks on demand, avoiding loading the entire ISO into memory.
/// </summary>
public NintendoDisc? GetInnerWrapper()
{
if (Model.BlockPointers is null || Model.BlockPointers.Length == 0)
return null;
if (Model.Header.DataSize == 0)
return null;
var vStream = new GczVirtualStream(this);
return NintendoDisc.Create(vStream);
}
/// <summary>
/// Decompresses a single GCZ block by index and returns its raw bytes.
/// Returns null on failure; returns a zero-filled block if the compressed size is zero.
/// </summary>
internal byte[]? DecompressBlock(int blockIndex)
{
const ulong UncompressedFlag = 0x8000000000000000UL;
if (blockIndex < 0 || blockIndex >= Model.BlockPointers.Length)
return null;
ulong ptr = Model.BlockPointers[blockIndex];
bool uncompressed = (ptr & UncompressedFlag) != 0;
long blockFileOffset = DataOffset + (long)(ptr & ~UncompressedFlag);
ulong nextRaw = (blockIndex + 1 < Model.BlockPointers.Length)
? Model.BlockPointers[blockIndex + 1] & ~UncompressedFlag
: Model.Header.CompressedDataSize;
int compSize = (int)(nextRaw - (ptr & ~UncompressedFlag));
if (compSize <= 0)
return new byte[Model.Header.BlockSize];
byte[] raw = ReadRangeFromSource(blockFileOffset, compSize);
if (raw is null || raw.Length != compSize)
return null;
// Verify Adler-32 checksum on the compressed (raw) data before decompressing
if (Model.BlockHashes != null && blockIndex < Model.BlockHashes.Length)
{
uint actual = Adler.Adler32(1, raw, 0, raw.Length);
if (actual != Model.BlockHashes[blockIndex])
return null;
}
if (uncompressed)
return raw;
// GCZ blocks are zlib-framed: 2-byte header + deflate data + 4-byte Adler-32 trailer.
// Strip the frame and feed raw deflate data to DeflateStream.
if (raw.Length < 6)
return null;
try
{
using var cs = new MemoryStream(raw, 2, raw.Length - 6);
using var ds = new DeflateStream(cs, CompressionMode.Decompress);
using var os = new MemoryStream();
byte[] buf = new byte[4096];
int n;
while ((n = ds.Read(buf, 0, buf.Length)) > 0)
os.Write(buf, 0, n);
return os.ToArray();
}
catch
{
return null;
}
}
#region Header
/// <summary>
/// Decompresses just the first block of the GCZ image to read the disc header,
@@ -230,57 +144,54 @@ namespace SabreTools.Wrappers
/// </summary>
private DiscHeader? ReadDiscHeader()
{
const ulong UncompressedFlag = 0x8000000000000000UL;
if (Model.BlockPointers is null || Model.BlockPointers.Length == 0)
if (BlockPointers is null || BlockPointers.Length == 0)
return null;
ulong ptr = Model.BlockPointers[0];
bool uncompressed = (ptr & UncompressedFlag) != 0;
long blockFileOffset = DataOffset + (long)(ptr & ~UncompressedFlag);
ulong pointer = BlockPointers[0];
ulong nextRaw = Model.BlockPointers.Length > 1
? Model.BlockPointers[1] & ~UncompressedFlag
: Model.Header.CompressedDataSize;
int compSize = (int)(nextRaw - (ptr & ~UncompressedFlag));
ulong nextRaw = BlockPointers.Length > 1
? BlockPointers[1] & ~UncompressedFlag
: Header.CompressedDataSize;
int compSize = (int)(nextRaw - (pointer & ~UncompressedFlag));
if (compSize <= 0)
return null;
long blockFileOffset = DataOffset + (long)(pointer & ~UncompressedFlag);
byte[] raw = ReadRangeFromSource(blockFileOffset, compSize);
if (raw is null || raw.Length != compSize)
if (raw.Length != compSize)
return null;
bool uncompressed = (pointer & UncompressedFlag) != 0;
if (uncompressed)
{
using var ms2 = new MemoryStream(raw);
var disc2 = new Serialization.Readers.NintendoDisc().Deserialize(ms2);
return disc2?.Header;
var disc = new Serialization.Readers.NintendoDisc().Deserialize(raw, offset: 0);
return disc?.Header;
}
if (raw.Length < 6)
return null;
byte[] block;
try
else
{
using var cs = new MemoryStream(raw, 2, raw.Length - 6);
using var ds = new DeflateStream(cs, CompressionMode.Decompress);
using var os = new MemoryStream();
byte[] buf = new byte[4096];
int n;
while ((n = ds.Read(buf, 0, buf.Length)) > 0)
os.Write(buf, 0, n);
block = os.ToArray();
}
catch
{
return null;
}
if (raw.Length < 6)
return null;
using var ms = new MemoryStream(block);
var disc = new Serialization.Readers.NintendoDisc().Deserialize(ms);
return disc?.Header;
byte[] block;
try
{
using var cs = new MemoryStream(raw, 2, raw.Length - 6);
using var ds = new DeflateStream(cs, CompressionMode.Decompress);
using var os = new MemoryStream();
ds.BlockCopy(os, blockSize: 4096);
block = os.ToArray();
}
catch
{
return null;
}
var disc = new Serialization.Readers.NintendoDisc().Deserialize(block, offset: 0);
return disc?.Header;
}
}
#endregion

View File

@@ -2,19 +2,20 @@ using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
// TODO: Move to IO
namespace SabreTools.Wrappers
{
/// <summary>
/// AES-128-CBC encrypt/decrypt helpers used by NintendoDisc and WIA/RVZ.
/// </summary>
/// <remarks>
/// Implemented directly via BouncyCastle because <c>SabreTools.Security.Cryptography</c>
/// currently only exposes AES-CTR. When an <c>AESCBC</c> wrapper is added to that
/// Implemented directly via BouncyCastle because SabreTools.Security.Cryptography
/// currently only exposes AES-CTR. When an AESCBC wrapper is added to that
/// library, replace the bodies of <see cref="Decrypt"/> and <see cref="Encrypt"/> with
/// the equivalent <c>AESCBC.Decrypt</c> / <c>AESCBC.Encrypt</c> calls and remove the
/// the equivalent AESCBC.Decrypt / AESCBC.Encrypt calls and remove the
/// BouncyCastle using directives from this file.
/// </remarks>
internal static class AesCbc
public static class AesCbc
{
/// <summary>
/// Decrypts <paramref name="data"/> with AES-128-CBC (no padding).
@@ -60,11 +61,16 @@ namespace SabreTools.Wrappers
}
}
/// <summary>
/// Create an AES/CBC cipher with a given key and initial value
/// </summary>
private static IBufferedCipher CreateCipher(bool forEncryption, byte[] key, byte[] iv)
{
var keyParam = new KeyParameter(key);
var cipher = CipherUtilities.GetCipher("AES/CBC/NoPadding");
cipher.Init(forEncryption, new ParametersWithIV(keyParam, iv));
return cipher;
}
}

View File

@@ -1,5 +1,4 @@
using System.Collections.Generic;
using SabreTools.Numerics.Extensions;
namespace SabreTools.Wrappers
{
@@ -7,31 +6,33 @@ namespace SabreTools.Wrappers
/// Lightweight GameCube / Wii File-System Table (FST) reader used by
/// <see cref="RvzPackEncoder"/> to distinguish real-file regions from junk.
///
/// Mirrors Dolphin's <c>FileSystemGCWii</c> offset-to-file-info cache
/// (<c>m_offset_file_info_cache</c>).
/// Mirrors Dolphin's FileSystemGCWii offset-to-file-info cache
/// (m_offset_file_info_cache).
/// </summary>
internal sealed class GcFst
public sealed class FileSystemTableReader
{
private const int EntrySize = 12;
/// <summary>File entry with start and end byte offsets on disc.</summary>
internal struct FileEntry
/// <summary>
/// File entry with start and end byte offsets on disc
/// </summary>
public struct FileEntry
{
public long FileStart;
public long FileEnd;
}
// Sorted ascending by FileEnd for O(log n) upper_bound queries.
/// <remarks>
/// Sorted ascending by FileEnd for O(log n) upper_bound queries.
/// </remarks>
private readonly List<FileEntry> _files;
private GcFst(List<FileEntry> files)
private FileSystemTableReader(List<FileEntry> files)
{
_files = files;
}
/// <summary>
/// Parses a raw FST binary blob and returns a <see cref="GcFst"/>,
/// or <c>null</c> if the data is too short or structurally invalid.
/// Parses a raw FST binary blob and returns a <see cref="FileSystemTableReader"/>,
/// or null if the data is too short or structurally invalid.
/// </summary>
/// <param name="fstData">
/// Raw FST bytes exactly as stored on disc (GameCube) or in decrypted
@@ -41,50 +42,50 @@ namespace SabreTools.Wrappers
/// Bit-shift to convert raw file-offset fields to byte addresses.
/// 0 for GameCube (direct bytes); 2 for Wii (offset × 4).
/// </param>
public static GcFst? TryParse(byte[] fstData, int offsetShift)
public static FileSystemTableReader? TryParse(byte[] fstData, int offsetShift)
{
if (fstData == null || fstData.Length < EntrySize)
// Read the file system table
var table = Serialization.Readers.NintendoDisc.ParseFileSystemTable(fstData);
if (table is null)
return null;
// Root entry (index 0): FILE_SIZE field = total number of FST entries.
int rootOffset = 8;
uint totalEntries = fstData.ReadUInt32BigEndian(ref rootOffset);
if (totalEntries < 1 || ((long)totalEntries * EntrySize) > fstData.Length)
return null;
var files = new List<FileEntry>((int)(totalEntries - 1));
for (uint i = 1; i < totalEntries; i++)
// Filter out the entries to non-empty files only
var filtered = new List<FileEntry>();
foreach (var entry in table.Entries)
{
int off = (int)(i * EntrySize);
int nameOffPos = off;
int fileOffPos = off + 4;
int fileSizePos = off + 8;
uint nameOffField = fstData.ReadUInt32BigEndian(ref nameOffPos);
uint fileOffField = fstData.ReadUInt32BigEndian(ref fileOffPos);
uint fileSizeField = fstData.ReadUInt32BigEndian(ref fileSizePos);
// Directory entry
if ((entry.NameOffset & 0xFF000000) != 0)
continue;
if ((nameOffField & 0xFF000000u) != 0) continue; // directory entry
if (fileSizeField == 0) continue; // empty file
// Empty file
if (entry.FileSize == 0)
continue;
long fileStart = (long)fileOffField << offsetShift;
long fileEnd = fileStart + fileSizeField;
files.Add(new FileEntry { FileStart = fileStart, FileEnd = fileEnd });
long fileStart = entry.FileOffset << offsetShift;
long fileEnd = fileStart + entry.FileSize;
var fileEntry = new FileEntry
{
FileStart = fileStart,
FileEnd = fileEnd,
};
filtered.Add(fileEntry);
}
// Sort ascending by FileEnd so binary-search upper_bound works correctly.
files.Sort(delegate(FileEntry a, FileEntry b)
filtered.Sort(delegate (FileEntry a, FileEntry b)
{
return a.FileEnd.CompareTo(b.FileEnd);
});
return new GcFst(files);
return new FileSystemTableReader(filtered);
}
/// <summary>
/// Returns the file entry whose byte range contains <paramref name="discOffset"/>,
/// or <c>null</c> if no file does.
/// or null if no file does.
/// </summary>
/// TODO: Determine how to use List<T>.BinarySearch here
public FileEntry? FindFileInfo(long discOffset)
{
if (_files.Count == 0)
@@ -104,7 +105,7 @@ namespace SabreTools.Wrappers
if (lo >= _files.Count)
return null;
FileEntry e = _files[lo];
var e = _files[lo];
if (e.FileStart <= discOffset)
return e;
@@ -113,7 +114,7 @@ namespace SabreTools.Wrappers
/// <summary>
/// Returns the smallest FileEnd value strictly greater than
/// <paramref name="discOffset"/>, or <c>null</c> if there is none.
/// <paramref name="discOffset"/>, or null if there is none.
/// </summary>
public long? FindNextFileEnd(long discOffset)
{
@@ -135,7 +136,7 @@ namespace SabreTools.Wrappers
/// <summary>
/// Returns the smallest FileStart value strictly greater than
/// <paramref name="discOffset"/>, or <c>null</c> if there is none.
/// <paramref name="discOffset"/>, or null if there is none.
/// </summary>
public long? FindNextFileStart(long discOffset)
{
@@ -166,6 +167,5 @@ namespace SabreTools.Wrappers
return best;
}
}
}
}
}

View File

@@ -7,9 +7,16 @@ namespace SabreTools.Wrappers
/// A read-only seekable stream that decompresses GCZ blocks on demand.
/// Avoids loading the entire decompressed disc image into memory.
/// </summary>
internal sealed class GczVirtualStream : Stream
public sealed class GczVirtualStream : Stream
{
/// <summary>
/// GCZ wrapper used for reading
/// </summary>
private readonly GCZ _gcz;
/// <summary>
/// Virtual position within the stream
/// </summary>
private long _position;
// Single-block cache to avoid re-decompressing on adjacent reads within the same block.
@@ -21,10 +28,19 @@ namespace SabreTools.Wrappers
_gcz = gcz ?? throw new ArgumentNullException(nameof(gcz));
}
/// <inheritdoc/>
public override bool CanRead => true;
/// <inheritdoc/>
public override bool CanSeek => true;
/// <inheritdoc/>
public override bool CanWrite => false;
/// <inheritdoc/>
public override long Length => (long)_gcz.DataSize;
/// <inheritdoc/>
public override long Position
{
get => _position;
@@ -32,10 +48,12 @@ namespace SabreTools.Wrappers
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(value));
_position = value;
}
}
/// <inheritdoc/>
public override int Read(byte[] buffer, int offset, int count)
{
if (buffer is null)
@@ -78,25 +96,15 @@ namespace SabreTools.Wrappers
return totalRead;
}
private byte[]? GetBlock(int blockIndex)
{
if (_cachedBlockIndex == blockIndex)
return _cachedBlock;
byte[]? block = _gcz.DecompressBlock(blockIndex);
_cachedBlockIndex = blockIndex;
_cachedBlock = block;
return block;
}
/// <inheritdoc/>
public override long Seek(long offset, SeekOrigin origin)
{
long newPos;
switch (origin)
{
case SeekOrigin.Begin: newPos = offset; break;
case SeekOrigin.Begin: newPos = offset; break;
case SeekOrigin.Current: newPos = _position + offset; break;
case SeekOrigin.End: newPos = Length + offset; break;
case SeekOrigin.End: newPos = Length + offset; break;
default: throw new ArgumentOutOfRangeException(nameof(origin));
}
@@ -107,10 +115,29 @@ namespace SabreTools.Wrappers
return _position;
}
/// <inheritdoc/>
public override void Flush() { }
/// <inheritdoc/>
public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
/// <summary>
/// Decompress and retrieve a possibly-cached data block
/// </summary>
/// <param name="blockIndex">Block index to retrieve</param>
/// <returns>Decompressed block data on success, null otherwise</returns>
private byte[]? GetBlock(int blockIndex)
{
if (_cachedBlockIndex == blockIndex)
return _cachedBlock;
byte[]? block = _gcz.DecompressBlock(blockIndex);
_cachedBlockIndex = blockIndex;
_cachedBlock = block;
return block;
}
}
}

View File

@@ -1,72 +1,102 @@
using System;
using SabreTools.Numerics.Extensions;
// TODO: Move to IO
namespace SabreTools.Wrappers
{
/// <summary>
/// Lagged Fibonacci Generator matching Dolphin's LaggedFibonacciGenerator exactly.
/// Used to regenerate Nintendo's deterministic "junk" padding data in disc images.
/// RVZ format identifies junk regions and stores only a 68-byte seed (17 u32 words)
/// RVZ format identifies junk regions and stores only a 68-byte seed (17 uint words)
/// instead of the full data, enabling significant compression of padding areas.
/// </summary>
internal class LaggedFibonacciGenerator
public class LaggedFibonacciGenerator
{
/// <summary>
///
/// </summary>
private const int LFG_K = 521;
private const int LFG_J = 32;
/// <summary>Size of the LFG output buffer in bytes (LFG_K * 4 = 2084).</summary>
public const int BUFFER_BYTES = LFG_K * 4;
/// <summary>Size of the seed in 32-bit words (68 bytes total).</summary>
public const int SEED_SIZE = 17;
private readonly uint[] m_buffer = new uint[LFG_K];
private int m_position_bytes = 0;
/// <summary>
/// Initializes the generator from a 17-element u32 seed array.
/// Each seed word is treated as a raw LE u32 from the file (Dolphin: reinterpret_cast then swap32).
///
/// </summary>
private const int LFG_J = 32;
/// <summary>
/// Size of the LFG output buffer in bytes (LFG_K * 4 = 2084)
/// </summary>
public const int BUFFER_BYTES = LFG_K * 4;
/// <summary>
/// Size of the seed in 32-bit words (68 bytes total)
/// </summary>
public const int SEED_SIZE = 17;
/// <summary>
///
/// </summary>
private readonly uint[] _buffer = new uint[LFG_K];
/// <summary>
///
/// </summary>
private int _position = 0;
/// <summary>
/// Initializes the generator from a 17-element uint seed array.
/// Each seed word is treated as a raw LE uint from the file (Dolphin: reinterpret_cast then swap32).
/// </summary>
public void SetSeed(uint[] seed)
{
if (seed == null || seed.Length < SEED_SIZE)
throw new ArgumentException($"Seed must contain at least {SEED_SIZE} u32 values.", nameof(seed));
throw new ArgumentException($"Seed must contain at least {SEED_SIZE} uint values.", nameof(seed));
m_position_bytes = 0;
// Reinterpret LE bytes as BE (Dolphin swap32)
_position = 0;
for (int i = 0; i < SEED_SIZE; i++)
m_buffer[i] = SwapU32(seed[i]); // reinterpret LE bytes as BE (Dolphin swap32)
{
_buffer[i] = SwapU32(seed[i]);
}
Initialize(false);
}
/// <summary>
/// Initializes the generator from a 68-byte seed (17 BE u32 values as in the RVZ file).
/// Matches Dolphin: m_buffer[i] = Common::swap32(seed + i * 4).
/// Initializes the generator from a 68-byte seed (17 BE uint values as in the RVZ file).
/// </summary>
/// <remarks>Matches Dolphin: m_buffer[i] = Common::swap32(seed + i * 4).</remarks>
public void SetSeed(byte[] seedBytes)
{
if (seedBytes == null || seedBytes.Length < SEED_SIZE * 4)
throw new ArgumentException($"Seed must be {SEED_SIZE * 4} bytes.", nameof(seedBytes));
m_position_bytes = 0;
_position = 0;
int offset = 0;
for (int i = 0; i < SEED_SIZE; i++)
m_buffer[i] = ReadBigEndianU32(seedBytes, i * 4);
{
_buffer[i] = seedBytes.ReadUInt32BigEndian(ref offset);
}
Initialize(false);
}
/// <summary>
/// Skips forward by <paramref name="count"/> bytes in the output stream.
/// Matches Dolphin: LaggedFibonacciGenerator::Forward(size_t count).
/// </summary>
/// <remarks>Matches Dolphin: LaggedFibonacciGenerator::Forward(size_t count).</remarks>
public void Forward(int count)
{
m_position_bytes += count;
while (m_position_bytes >= BUFFER_BYTES)
_position += count;
while (_position >= BUFFER_BYTES)
{
ForwardStep();
m_position_bytes -= BUFFER_BYTES;
_position -= BUFFER_BYTES;
}
}
/// <summary>Generates <paramref name="count"/> junk bytes and returns them.</summary>
/// <summary>
/// Generates <paramref name="count"/> junk bytes and returns them.
/// </summary>
public byte[] GetBytes(int count)
{
byte[] output = new byte[count];
@@ -78,20 +108,21 @@ namespace SabreTools.Wrappers
/// Generates junk bytes into <paramref name="output"/> starting at <paramref name="outputOffset"/>.
/// Matches Dolphin: LaggedFibonacciGenerator::GetBytes using memcpy pattern.
/// </summary>
/// <remarks></remarks>
public void GetBytes(int count, byte[] output, int outputOffset)
{
while (count > 0)
{
int length = Math.Min(count, BUFFER_BYTES - m_position_bytes);
Buffer.BlockCopy(m_buffer, m_position_bytes, output, outputOffset, length);
m_position_bytes += length;
int length = Math.Min(count, BUFFER_BYTES - _position);
Buffer.BlockCopy(_buffer, _position, output, outputOffset, length);
_position += length;
count -= length;
outputOffset += length;
if (m_position_bytes == BUFFER_BYTES)
if (_position == BUFFER_BYTES)
{
ForwardStep();
m_position_bytes = 0;
_position = 0;
}
}
}
@@ -100,81 +131,98 @@ namespace SabreTools.Wrappers
/// Returns a single junk byte at the current position, advancing by one byte.
/// Matches Dolphin: LaggedFibonacciGenerator::GetByte.
/// </summary>
/// <remarks></remarks>
internal byte GetByte()
{
int wordIdx = m_position_bytes / 4;
int byteInWord = m_position_bytes % 4;
byte result = (byte)(m_buffer[wordIdx] >> (byteInWord * 8)); // LE byte order
int wordIdx = _position / 4;
int byteInWord = _position % 4;
byte result = (byte)(_buffer[wordIdx] >> (byteInWord * 8)); // LE byte order
m_position_bytes++;
if (m_position_bytes == BUFFER_BYTES)
_position++;
if (_position == BUFFER_BYTES)
{
ForwardStep();
m_position_bytes = 0;
_position = 0;
}
return result;
}
// -------------------------------------------------------------------
// Private forward/backward state steps
// -------------------------------------------------------------------
#region Private forward/backward state steps
/// <summary>
/// Full buffer state step forward — Dolphin: Forward() (no args).
/// Full buffer state step forward
/// </summary>
/// <remarks>
/// Dolphin: Forward() (no args).
/// for i in [0,J): buf[i] ^= buf[i + K - J] (= buf[i + 489])
/// for i in [J,K): buf[i] ^= buf[i - J] (= buf[i - 32])
/// </summary>
/// </remarks>
private void ForwardStep()
{
for (int i = 0; i < LFG_J; i++)
m_buffer[i] ^= m_buffer[i + LFG_K - LFG_J];
{
_buffer[i] ^= _buffer[i + LFG_K - LFG_J];
}
for (int i = LFG_J; i < LFG_K; i++)
m_buffer[i] ^= m_buffer[i - LFG_J];
{
_buffer[i] ^= _buffer[i - LFG_J];
}
}
/// <summary>
/// Partial or full buffer state step backward — undoes ForwardStep.
/// Dolphin: Backward(size_t start_word, size_t end_word).
/// </summary>
/// <remarks>Dolphin: Backward(size_t start_word, size_t end_word).</remarks>
private void Backward(int startWord = 0, int endWord = LFG_K)
{
int loopEnd = Math.Max(LFG_J, startWord);
// Undo second loop of ForwardStep (reversed)
for (int i = Math.Min(endWord, LFG_K); i > loopEnd; i--)
m_buffer[i - 1] ^= m_buffer[i - 1 - LFG_J];
{
_buffer[i - 1] ^= _buffer[i - 1 - LFG_J];
}
// Undo first loop of ForwardStep (reversed)
for (int i = Math.Min(endWord, LFG_J); i > startWord; i--)
m_buffer[i - 1] ^= m_buffer[i - 1 + LFG_K - LFG_J];
{
_buffer[i - 1] ^= _buffer[i - 1 + LFG_K - LFG_J];
}
}
/// <summary>
/// Recovers the original 17-word seed from the current buffer state and outputs it
/// as LE u32 values into <paramref name="seedOut"/>.
/// Dolphin: Reinitialize(u32 seed_out[]).
/// as LE uint values into <paramref name="seedOut"/>.
/// </summary>
/// <remarks>Dolphin: Reinitialize(uint seed_out[]).</remarks>
private bool Reinitialize(uint[] seedOut)
{
for (int i = 0; i < 4; i++)
{
Backward();
}
// Swap all words back to big-endian representation
for (int i = 0; i < LFG_K; i++)
m_buffer[i] = SwapU32(m_buffer[i]);
{
_buffer[i] = SwapU32(_buffer[i]);
}
// Reconstruct bits 16-17 for the first SEED_SIZE words
for (int i = 0; i < SEED_SIZE; i++)
{
m_buffer[i] = (m_buffer[i] & 0xFF00FFFF)
| ((m_buffer[i] << 2) & 0x00FC0000)
| (((m_buffer[i + 16] ^ m_buffer[i + 15]) << 9) & 0x00030000);
_buffer[i] = (_buffer[i] & 0xFF00FFFF)
| ((_buffer[i] << 2) & 0x00FC0000)
| (((_buffer[i + 16] ^ _buffer[i + 15]) << 9) & 0x00030000);
}
// Output seed as LE u32 values (swap32 converts BELE)
// Output seed as LE uint values (swap32 converts BE->LE)
for (int i = 0; i < SEED_SIZE; i++)
seedOut[i] = SwapU32(m_buffer[i]);
{
seedOut[i] = SwapU32(_buffer[i]);
}
return Initialize(true);
}
@@ -183,91 +231,98 @@ namespace SabreTools.Wrappers
/// Fills m_buffer[SEED_SIZE..K-1] from the first SEED_SIZE words, applies the output
/// transform, and runs 4× ForwardStep. When <paramref name="checkExisting"/> is true,
/// verifies the data in m_buffer[SEED_SIZE..] matches the recurrence.
/// Dolphin: Initialize(bool check_existing_data).
/// </summary>
/// <remarks>Dolphin: Initialize(bool check_existing_data).</remarks>
private bool Initialize(bool checkExisting)
{
for (int i = SEED_SIZE; i < LFG_K; i++)
{
uint calculated = (m_buffer[i - 17] << 23)
^ (m_buffer[i - 16] >> 9)
^ m_buffer[i - 1];
uint calculated = (_buffer[i - 17] << 23)
^ (_buffer[i - 16] >> 9)
^ _buffer[i - 1];
if (checkExisting)
{
uint actual = (m_buffer[i] & 0xFF00FFFF) | ((m_buffer[i] << 2) & 0x00FC0000);
uint actual = (_buffer[i] & 0xFF00FFFF) | ((_buffer[i] << 2) & 0x00FC0000);
if ((calculated & 0xFFFCFFFF) != actual)
return false;
}
m_buffer[i] = calculated;
_buffer[i] = calculated;
}
// Output transform: each word swap32((x & 0xFF00FFFF) | ((x >> 2) & 0x00FF0000))
// Output transform: each word -> swap32((x & 0xFF00FFFF) | ((x >> 2) & 0x00FF0000))
for (int i = 0; i < LFG_K; i++)
m_buffer[i] = SwapU32((m_buffer[i] & 0xFF00FFFF) | ((m_buffer[i] >> 2) & 0x00FF0000));
{
_buffer[i] = SwapU32((_buffer[i] & 0xFF00FFFF) | ((_buffer[i] >> 2) & 0x00FF0000));
}
for (int i = 0; i < 4; i++)
{
ForwardStep();
}
return true;
}
// -------------------------------------------------------------------
// Static seed-recovery API (used by RvzPackDecompressor)
// -------------------------------------------------------------------
#endregion
#region Static seed-recovery API (used by RvzPackDecompressor)
/// <summary>
/// Attempts to recover a 17-word seed from disc data starting at
/// <paramref name="dataStart"/> within <paramref name="data"/>.
/// <paramref name="size"/> is the number of bytes to match (up to the next 32 KiB boundary).
/// <paramref name="dataOffsetMod"/> is <c>discOffset % 0x8000</c> — the offset within
/// <paramref name="dataOffsetMod"/> is discOffset % 0x8000 — the offset within
/// the current LFG cycle.
/// Returns the number of bytes that were successfully reconstructed (0 = not junk data).
/// Matches Dolphin: LaggedFibonacciGenerator::GetSeed(u8*, size_t, size_t, u32[]).
/// </summary>
/// <returns>the number of bytes that were successfully reconstructed (0 = not junk data).</returns>
/// <remarks>Matches Dolphin: LaggedFibonacciGenerator::GetSeed(u8*, size_t, size_t, uint[]).</remarks>
public static int GetSeed(byte[] data, int dataStart, int size, int dataOffsetMod, uint[] seedOut)
{
if (size <= 0 || dataStart < 0 || dataStart + size > data.Length)
return 0;
// Skip any bytes before the next u32-aligned boundary
// Skip any bytes before the next uint-aligned boundary
int bytesToSkip = (4 - (dataOffsetMod % 4)) % 4;
if (bytesToSkip >= size)
return 0;
int u32DataStart = dataStart + bytesToSkip;
int u32Size = (size - bytesToSkip) / 4;
int u32DataOffset = (dataOffsetMod + bytesToSkip) / 4;
int uintDataStart = dataStart + bytesToSkip;
int uintSize = (size - bytesToSkip) / 4;
int uintDataOffset = (dataOffsetMod + bytesToSkip) / 4;
if (u32Size < LFG_K)
if (uintSize < LFG_K)
return 0;
// Read disc bytes as LE u32 values (Dolphin: reinterpret_cast<const u32*>)
uint[] u32Data = new uint[u32Size];
for (int i = 0; i < u32Size; i++)
u32Data[i] = ReadLittleEndianU32(data, u32DataStart + (i * 4));
// Read disc bytes as little-endian uint values (Dolphin: reinterpret_cast<const uint*>)
uint[] uintData = new uint[uintSize];
for (int i = 0; i < uintSize; i++)
{
uintData[i] = data.ReadUInt32LittleEndian(ref uintDataStart);
}
LaggedFibonacciGenerator lfg = new LaggedFibonacciGenerator();
if (!GetSeed_u32(u32Data, u32Size, u32DataOffset, lfg, seedOut))
var lfg = new LaggedFibonacciGenerator();
if (!GetSeed(uintData, uintSize, uintDataOffset, lfg, seedOut))
return 0;
// Set position to data_offset % BUFFER_BYTES and count matching bytes from data[dataStart]
lfg.m_position_bytes = dataOffsetMod % BUFFER_BYTES;
lfg._position = dataOffsetMod % BUFFER_BYTES;
int reconstructed = 0;
for (int i = 0; i < size && lfg.GetByte() == data[dataStart + i]; i++)
{
reconstructed++;
}
return reconstructed;
}
/// <summary>
/// Inner u32-level seed recovery.
/// Dolphin: GetSeed(const u32* data, size_t size, size_t data_offset, LFG*, u32[]).
/// Inner UInt32-level seed recovery.
/// </summary>
private static bool GetSeed_u32(uint[] data, int size, int dataOffset,
LaggedFibonacciGenerator lfg, uint[] seedOut)
/// <remarks>Dolphin: GetSeed(const uint* data, size_t size, size_t data_offset, LFG*, uint[]).</remarks>
private static bool GetSeed(uint[] data, int size, int dataOffset, LaggedFibonacciGenerator lfg, uint[] seedOut)
{
if (size < LFG_K)
return false;
@@ -285,35 +340,38 @@ namespace SabreTools.Wrappers
int dataOffsetDivK = dataOffset / LFG_K;
// Rotate data into buffer so buffer[dataOffsetModK] = data[0]
Array.Copy(data, 0, lfg.m_buffer, dataOffsetModK, LFG_K - dataOffsetModK);
Array.Copy(data, 0, lfg._buffer, dataOffsetModK, LFG_K - dataOffsetModK);
if (dataOffsetModK > 0)
Array.Copy(data, LFG_K - dataOffsetModK, lfg.m_buffer, 0, dataOffsetModK);
Array.Copy(data, LFG_K - dataOffsetModK, lfg._buffer, 0, dataOffsetModK);
lfg.Backward(0, dataOffsetModK);
for (int i = 0; i < dataOffsetDivK; i++)
{
lfg.Backward();
}
if (!lfg.Reinitialize(seedOut))
return false;
for (int i = 0; i < dataOffsetDivK; i++)
{
lfg.ForwardStep();
}
return true;
}
// -------------------------------------------------------------------
// Endian helpers
// -------------------------------------------------------------------
#endregion
internal static uint ReadBigEndianU32(byte[] data, int offset) =>
(uint)((data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]);
#region Helpers
private static uint ReadLittleEndianU32(byte[] data, int offset) =>
(uint)(data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24));
/// <summary>
/// Swap endinaness of a UInt32
/// </summary>
internal static uint SwapU32(uint value)
=> (value << 24) | ((value << 8) & 0x00FF0000) | ((value >> 8) & 0x0000FF00) | (value >> 24);
internal static uint SwapU32(uint value) =>
(value << 24) | ((value << 8) & 0x00FF0000) | ((value >> 8) & 0x0000FF00) | (value >> 24);
#endregion
}
}

View File

@@ -1,7 +1,9 @@
using System;
using System.IO;
using SabreTools.Hashing;
using SabreTools.Numerics.Extensions;
// TODO: Move to IO
namespace SabreTools.Wrappers
{
/// <summary>
@@ -20,6 +22,11 @@ namespace SabreTools.Wrappers
/// </summary>
internal static class PurgeCompressor
{
/// <summary>
/// Zero-byte runs of this length or fewer are bridged
/// </summary>
private const int MaxGap = 8;
/// <summary>
/// Compress <paramref name="data"/>[<paramref name="offset"/> ..
/// <paramref name="offset"/>+<paramref name="count"/>) into PURGE format.
@@ -35,8 +42,6 @@ namespace SabreTools.Wrappers
/// <returns>PURGE-compressed byte array (segments + 20-byte SHA-1).</returns>
public static byte[] Compress(byte[] data, int offset, int count, byte[]? precedingBytes = null)
{
const int MaxGap = 8; // zero-byte runs of this length or fewer are bridged
var output = new MemoryStream((count / 2) + 32);
int end = offset + count;
@@ -46,42 +51,46 @@ namespace SabreTools.Wrappers
{
// Skip leading zeros
while (pos < end && data[pos] == 0)
{
pos++;
}
if (pos >= end)
break;
// pos is now the start of a non-zero run (segment start)
int segStart = pos;
int segEnd = pos;
int segEnd = pos;
// Extend the segment, bridging zero-gaps of <= MaxGap bytes
while (segEnd < end)
{
// advance through non-zero bytes
while (segEnd < end && data[segEnd] != 0)
{
segEnd++;
}
// peek ahead: count zero bytes
int zeroRun = 0;
while (segEnd + zeroRun < end && data[segEnd + zeroRun] == 0)
{
zeroRun++;
}
// If the gap is small enough (and there is more non-zero data after it),
// bridge the gap by including it in the segment.
if (zeroRun > 0 && zeroRun <= MaxGap && segEnd + zeroRun < end)
{
segEnd += zeroRun; // include zeros in segment, keep scanning
}
else
{
break; // end of segment
}
}
// Trim trailing zeros from segment end
while (segEnd > segStart && data[segEnd - 1] == 0)
{
segEnd--;
}
if (segEnd <= segStart)
{
@@ -90,11 +99,10 @@ namespace SabreTools.Wrappers
}
uint segOffset = (uint)(segStart - offset);
uint segSize = (uint)(segEnd - segStart);
uint segSize = (uint)(segEnd - segStart);
// Write {u32 offsetBE, u32 sizeBE, data[segSize]}
WriteBeU32(output, segOffset);
WriteBeU32(output, segSize);
output.WriteBigEndian(segOffset);
output.WriteBigEndian(segSize);
output.Write(data, segStart, (int)segSize);
pos = segEnd;
@@ -112,24 +120,23 @@ namespace SabreTools.Wrappers
return result;
}
/// <summary>
/// Get the SHA-1 hash of preceeding bytes and segment data
/// </summary>
/// <param name="precedingBytes">Optional preceeding bytes</param>
/// <param name="segments">Segment data</param>
/// <returns>SHA-1 hash of the data, all 0x00 on error</returns>
private static byte[] ComputeSha1(byte[]? precedingBytes, byte[] segments)
{
using var sha1 = new HashWrapper(HashType.SHA1);
if (precedingBytes != null && precedingBytes.Length > 0)
if (precedingBytes is not null && precedingBytes.Length > 0)
sha1.Process(precedingBytes, 0, precedingBytes.Length);
sha1.Process(segments, 0, segments.Length);
sha1.Terminate();
return sha1.CurrentHashBytes ?? new byte[20];
}
private static void WriteBeU32(Stream s, uint value)
{
s.WriteByte((byte)(value >> 24));
s.WriteByte((byte)(value >> 16));
s.WriteByte((byte)(value >> 8));
s.WriteByte((byte)value);
return sha1.CurrentHashBytes ?? new byte[20];
}
}
}

View File

@@ -2,6 +2,7 @@ using System;
using SabreTools.Hashing;
using SabreTools.Numerics.Extensions;
// TODO: Move to IO
namespace SabreTools.Wrappers
{
/// <summary>
@@ -21,6 +22,7 @@ namespace SabreTools.Wrappers
internal static class PurgeDecompressor
{
private const int SHA1_SIZE = 20;
private const int SEGMENT_HEADER_SIZE = 8; // u32 offset + u32 size
/// <summary>
@@ -35,16 +37,18 @@ namespace SabreTools.Wrappers
/// exception-list section for Wii partition groups. Pass null for non-Wii groups.
/// </param>
/// <returns>
/// The decompressed byte array, or <c>null</c> if the data is malformed or the
/// The decompressed byte array, or null if the data is malformed or the
/// trailing SHA-1 does not match.
/// </returns>
public static byte[]? Decompress(
byte[] input, int inputOffset, int inputLength,
byte[] input,
int inputOffset,
int inputLength,
int decompressedSize,
byte[]? precedingBytes = null)
{
if (input is null) throw new ArgumentNullException(nameof(input));
if (inputLength < SHA1_SIZE) return null;
if (inputLength < SHA1_SIZE)
return null;
byte[] output = new byte[decompressedSize];
int pos = inputOffset;
@@ -52,7 +56,7 @@ namespace SabreTools.Wrappers
using (var sha1 = new HashWrapper(HashType.SHA1))
{
if (precedingBytes != null && precedingBytes.Length > 0)
if (precedingBytes is not null && precedingBytes.Length > 0)
sha1.Process(precedingBytes, 0, precedingBytes.Length);
while (pos < dataEnd)
@@ -62,7 +66,7 @@ namespace SabreTools.Wrappers
int headerPos = pos;
uint segOffset = input.ReadUInt32BigEndian(ref headerPos);
uint segSize = input.ReadUInt32BigEndian(ref headerPos);
uint segSize = input.ReadUInt32BigEndian(ref headerPos);
sha1.Process(input, pos, SEGMENT_HEADER_SIZE);
pos += SEGMENT_HEADER_SIZE;
@@ -84,7 +88,9 @@ namespace SabreTools.Wrappers
sha1.Terminate();
byte[]? computed = sha1.CurrentHashBytes;
if (computed is null) return null;
if (computed is null)
return null;
for (int i = 0; i < SHA1_SIZE; i++)
{
if (computed[i] != input[dataEnd + i])

View File

@@ -0,0 +1,143 @@
using System;
using SabreTools.Numerics.Extensions;
// TODO: Move to IO
namespace SabreTools.Wrappers
{
/// <summary>
/// Decompressor for RVZ packed format.
/// RVZ uses run-length encoding to store real data and junk data efficiently:
/// - Real data: size (4 bytes) + data bytes
/// - Junk data: size with high bit set (4 bytes) + 68-byte seed -> regenerate using LFG
/// </summary>
internal class RvzPackDecompressor
{
/// <summary>
///
/// </summary>
private readonly byte[] _packedData;
/// <summary>
///
/// </summary>
private readonly uint _rvzPackedSize;
/// <summary>
///
/// </summary>
private long _dataOffset;
/// <summary>
///
/// </summary>
private readonly LaggedFibonacciGenerator _lfg;
/// <summary>
///
/// </summary>
private int _inPosition = 0;
/// <summary>
///
/// </summary>
private uint _currentSize = 0;
/// <summary>
///
/// </summary>
private bool _currentIsJunk = false;
/// <summary>
/// Creates a new RVZ pack decompressor.
/// </summary>
/// <param name="packedData">The packed RVZ data</param>
/// <param name="rvzPackedSize">Expected size of packed data (for validation)</param>
/// <param name="dataOffset">Offset in the virtual disc (for LFG alignment)</param>
public RvzPackDecompressor(byte[] packedData, uint rvzPackedSize, long dataOffset)
{
_packedData = packedData;
_rvzPackedSize = rvzPackedSize;
_dataOffset = dataOffset;
_lfg = new LaggedFibonacciGenerator();
}
/// <summary>
/// Decompresses the packed data into the output buffer.
/// </summary>
/// <param name="output">Destination buffer</param>
/// <param name="outputOffset">Offset in destination buffer</param>
/// <param name="count">Number of bytes to decompress</param>
/// <returns>Number of bytes actually decompressed</returns>
public int Decompress(byte[] output, int outputOffset, int count)
{
int totalWritten = 0;
while (totalWritten < count && !IsDone())
{
if (_currentSize == 0)
{
if (!ReadNextSegment())
break;
}
int bytesToWrite = Math.Min((int)_currentSize, count - totalWritten);
if (_currentIsJunk)
{
_lfg.GetBytes(bytesToWrite, output, outputOffset + totalWritten);
}
else
{
Array.Copy(_packedData, _inPosition, output, outputOffset + totalWritten, bytesToWrite);
_inPosition += bytesToWrite;
}
_currentSize -= (uint)bytesToWrite;
totalWritten += bytesToWrite;
_dataOffset += bytesToWrite;
}
return totalWritten;
}
/// <summary>
/// Checks if decompression is complete.
/// </summary>
public bool IsDone() => _currentSize == 0 && _inPosition >= _rvzPackedSize;
/// <summary>
/// Read the next segment of packed data
/// </summary>
/// <returns>True if the segment was read and cached, false otherwise</returns>
private bool ReadNextSegment()
{
if (_inPosition + 4 > _packedData.Length)
return false;
// Size field is big-endian u32; high bit signals junk data
uint sizeField = _packedData.ReadUInt32BigEndian(ref _inPosition);
_currentIsJunk = (sizeField & 0x80000000) != 0;
_currentSize = sizeField & 0x7FFFFFFF;
if (_currentIsJunk)
{
if (_inPosition + (LaggedFibonacciGenerator.SEED_SIZE * 4) > _packedData.Length)
return false;
byte[] seed = new byte[LaggedFibonacciGenerator.SEED_SIZE * 4];
Array.Copy(_packedData, _inPosition, seed, 0, seed.Length);
_inPosition += seed.Length;
_lfg.SetSeed(seed);
// Advance LFG to the correct position within the buffer.
// Dolphin: lfg.m_position_bytes = data_offset % (LFG_K * sizeof(u32))
int offsetInBuffer = (int)(_dataOffset % LaggedFibonacciGenerator.BUFFER_BYTES);
if (offsetInBuffer > 0)
_lfg.Forward(offsetInBuffer);
}
return true;
}
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using SabreTools.Numerics.Extensions;
// TODO: Move to IO
namespace SabreTools.Wrappers
{
/// <summary>
@@ -10,7 +11,7 @@ namespace SabreTools.Wrappers
/// (Lagged Fibonacci Generator) junk regions with compact seed descriptors.
///
/// This is the exact inverse of <see cref="RvzPackDecompressor"/> and mirrors
/// Dolphin's <c>RVZPack()</c> in <c>WIABlob.cpp</c>.
/// Dolphin's RVZPack() in WIABlob.cpp.
///
/// Two-phase algorithm:
/// <list type="number">
@@ -22,34 +23,51 @@ namespace SabreTools.Wrappers
/// </summary>
internal static class RvzPackEncoder
{
// 17 u32s × 4 bytes = 68 bytes — minimum size to record a seed
/// <remarks>
/// 17 u32s × 4 bytes = 68 bytes — minimum size to record a seed
/// </remarks>
private const int SeedSizeBytes = LaggedFibonacciGenerator.SEED_SIZE * 4;
/// <summary>
///
/// </summary>
private sealed class JunkRegion
{
public long StartOffset;
public long StartOffset;
public uint[]? Seed;
}
/// <summary>Result of packing a single chunk: compressed payload and its logical size.</summary>
/// <summary>
/// Result of packing a single chunk: compressed payload and its logical size.
/// </summary>
internal struct ChunkResult
{
/// <summary>Packed payload, or <c>null</c> if the chunk contains no junk.</summary>
/// <summary>
/// Packed payload, or null if the chunk contains no junk.
/// </summary>
public byte[]? Packed;
/// <summary>Number of bytes the decompressor needs to consume from <see cref="Packed"/>.</summary>
/// <summary>
/// Number of bytes the decompressor needs to consume from <see cref="Packed"/>.
/// </summary>
public uint RvzPackedSize;
}
#region Public API
/// <summary>
/// RVZ-pack a single chunk.
/// Returns <c>null</c> if the chunk contains no junk (write raw instead).
/// Returns null if the chunk contains no junk (write raw instead).
/// <paramref name="rvzPackedSize"/> is the number of bytes actually needed
/// by the decompressor (may be &lt; packed.Length due to alignment).
/// </summary>
public static byte[]? Pack(byte[] data, int dataOffset, int size,
long discDataOffset, out uint rvzPackedSize, GcFst? fst = null)
/// <param name="data">Source buffer</param>
/// <param name="dataOffset">Start of data within <paramref name="data"/></param>
public static byte[]? Pack(byte[] data,
int dataOffset,
int size,
long discDataOffset,
out uint rvzPackedSize,
FileSystemTableReader? fst = null)
{
rvzPackedSize = 0;
if (size <= 0)
@@ -59,9 +77,9 @@ namespace SabreTools.Wrappers
if (junkInfo.Count == 0)
return null;
ChunkResult r = EmitChunk(data, dataOffset, 0L, size, size, junkInfo);
rvzPackedSize = r.RvzPackedSize;
return r.Packed;
ChunkResult result = EmitChunk(data, dataOffset, 0L, size, junkInfo);
rvzPackedSize = result.RvzPackedSize;
return result.Packed;
}
/// <summary>
@@ -69,101 +87,114 @@ namespace SabreTools.Wrappers
/// Performs one Phase-1 scan over the entire buffer, then calls
/// <see cref="EmitChunk"/> per chunk.
/// </summary>
/// <param name="data">Source buffer.</param>
/// <param name="dataOffset">Start of data within <paramref name="data"/>.</param>
/// <param name="totalSize">Total number of bytes to process.</param>
/// <param name="bytesPerChunk">Size of each individual chunk.</param>
/// <param name="numChunks">Number of chunks.</param>
/// <param name="discDataOffset">Disc-partition byte offset of the first byte.</param>
/// <param name="fst">Optional FST for file-boundary optimisation.</param>
/// <param name="data">Source buffer</param>
/// <param name="dataOffset">Start of data within <paramref name="data"/></param>
/// <param name="totalSize">Total number of bytes to process</param>
/// <param name="bytesPerChunk">Size of each individual chunk</param>
/// <param name="numChunks">Number of chunks</param>
/// <param name="discDataOffset">Disc-partition byte offset of the first byte</param>
/// <param name="fst">Optional FST for file-boundary optimisation</param>
/// <returns>
/// One <see cref="ChunkResult"/> per chunk;
/// <c>Packed == null</c> means the chunk has no junk and should be written raw.
/// Packed == null means the chunk has no junk and should be written raw.
/// </returns>
public static ChunkResult[] PackGroup(
byte[] data, int dataOffset, int totalSize,
int bytesPerChunk, int numChunks,
long discDataOffset, GcFst? fst = null)
public static ChunkResult[] PackGroup(byte[] data,
int dataOffset,
int totalSize,
int bytesPerChunk,
int numChunks,
long discDataOffset,
FileSystemTableReader? fst = null)
{
var junkInfo = ScanForJunk(data, dataOffset, totalSize, discDataOffset, fst);
var result = new ChunkResult[numChunks];
for (int c = 0; c < numChunks; c++)
for (int i = 0; i < numChunks; i++)
{
long chunkStart = (long)c * bytesPerChunk;
long chunkEnd = Math.Min(chunkStart + bytesPerChunk, totalSize);
result[c] = EmitChunk(data, dataOffset, chunkStart, chunkEnd, totalSize, junkInfo);
long chunkStart = (long)i * bytesPerChunk;
long chunkEnd = Math.Min(chunkStart + bytesPerChunk, totalSize);
result[i] = EmitChunk(data, dataOffset, chunkStart, chunkEnd, junkInfo);
}
return result;
}
#endregion
#region Phase 1 scan buffer for junk regions
/// <summary>
/// Scan buffer for junk regions
/// </summary>
/// <param name="data">Source buffer</param>
/// <param name="dataOffset">Start of data within <paramref name="data"/></param>
/// <param name="totalSize">Total number of bytes to process</param>
/// <param name="discDataOffset">Disc-partition byte offset of the first byte</param>
/// <param name="fst">Optional FST for file-boundary optimisation</param>
/// <returns></returns>
private static SortedDictionary<long, JunkRegion> ScanForJunk(
byte[] data, int dataOffset, int totalSize, long discDataOffset, GcFst? fst)
byte[] data,
int dataOffset,
int totalSize,
long discDataOffset,
FileSystemTableReader? fst)
{
var junkInfo = new SortedDictionary<long, JunkRegion>();
long position = 0;
long dataOff = discDataOffset;
long dataOff = discDataOffset;
while (position < totalSize)
{
// Step 1: count and advance past leading zeros
long zeroes = 0;
while ((position + zeroes) < totalSize &&
data[dataOffset + position + zeroes] == 0)
while ((position + zeroes) < totalSize && data[dataOffset + position + zeroes] == 0)
{
zeroes++;
}
if (zeroes > SeedSizeBytes)
{
junkInfo[position + zeroes] = new JunkRegion
{
StartOffset = position,
Seed = new uint[LaggedFibonacciGenerator.SEED_SIZE]
Seed = new uint[LaggedFibonacciGenerator.SEED_SIZE]
};
}
position += zeroes;
dataOff += zeroes;
dataOff += zeroes;
if (position >= totalSize)
break;
// Step 2: compute aligned read window (next 0x8000 boundary)
long nextBoundary = AlignUp(dataOff + 1, 0x8000);
long bytesToRead = Math.Min(nextBoundary - dataOff, totalSize - position);
int dataOffMod = (int)(dataOff % 0x8000);
long bytesToRead = Math.Min(nextBoundary - dataOff, totalSize - position);
int dataOffMod = (int)(dataOff % 0x8000);
// Step 3: ALWAYS call GetSeed unconditionally — no FST pre-check
var seed = new uint[LaggedFibonacciGenerator.SEED_SIZE];
int reconstructed = LaggedFibonacciGenerator.GetSeed(
data, (int)(dataOffset + position), (int)bytesToRead, dataOffMod, seed);
var seed = new uint[LaggedFibonacciGenerator.SEED_SIZE];
int reconstructed = LaggedFibonacciGenerator.GetSeed(data, (int)(dataOffset + position), (int)bytesToRead, dataOffMod, seed);
if (reconstructed > 0)
{
junkInfo[position + reconstructed] = new JunkRegion
{
StartOffset = position,
Seed = seed
Seed = seed
};
}
// Step 4: FST skip AFTER GetSeed
if (fst != null)
if (fst is not null)
{
long queryOff = dataOff + reconstructed;
GcFst.FileEntry? fileInfo = fst.FindFileInfo(queryOff);
if (fileInfo.HasValue)
FileSystemTableReader.FileEntry? fileInfo = fst.FindFileInfo(queryOff);
if (fileInfo is not null)
{
long fileEnd = fileInfo.Value.FileEnd;
if (fileEnd < (dataOff + bytesToRead))
{
position += fileEnd - dataOff;
dataOff = fileEnd;
dataOff = fileEnd;
continue;
}
}
@@ -171,33 +202,40 @@ namespace SabreTools.Wrappers
// Step 5: normal advance by block window
position += bytesToRead;
dataOff += bytesToRead;
dataOff += bytesToRead;
}
return junkInfo;
}
#endregion
#region Phase 2 emit packed segments for a single chunk
/// <summary>
/// Emit packed segments for a single chunk
/// </summary>
/// <param name="data">Source buffer</param>
/// <param name="dataOffset">Start of data within <paramref name="data"/></param>
/// <param name="chunkStart"></param>
/// <param name="chunkEnd"></param>
/// <param name="junkInfo"></param>
/// <returns></returns>
private static ChunkResult EmitChunk(
byte[] data, int dataOffset,
long chunkStart, long chunkEnd, long totalSize,
byte[] data,
int dataOffset,
long chunkStart,
long chunkEnd,
SortedDictionary<long, JunkRegion> junkInfo)
{
long currentOffset = chunkStart;
long currentOffset = chunkStart;
bool firstIteration = true;
var output = new MemoryStream((int)(chunkEnd - chunkStart));
var output = new MemoryStream((int)(chunkEnd - chunkStart));
uint packedSize = 0;
while (currentOffset < chunkEnd)
{
long remaining = chunkEnd - currentOffset;
long nextJunkStart = chunkEnd;
long nextJunkEnd = chunkEnd;
uint[]? junkSeed = null;
long remaining = chunkEnd - currentOffset;
long nextJunkStart = chunkEnd;
long nextJunkEnd = chunkEnd;
uint[]? junkSeed = null;
if (remaining > SeedSizeBytes)
{
@@ -206,12 +244,11 @@ namespace SabreTools.Wrappers
// Dolphin Phase-2 condition:
// key > currentOffset + SEED_SIZE_BYTES AND
// startOffset + SEED_SIZE_BYTES < chunkEnd
if ((kvp.Key > (currentOffset + SeedSizeBytes)) &&
((kvp.Value.StartOffset + SeedSizeBytes) < chunkEnd))
if ((kvp.Key > (currentOffset + SeedSizeBytes)) && ((kvp.Value.StartOffset + SeedSizeBytes) < chunkEnd))
{
nextJunkStart = Math.Max(currentOffset, kvp.Value.StartOffset);
nextJunkEnd = Math.Min(chunkEnd, kvp.Key);
junkSeed = kvp.Value.Seed;
nextJunkEnd = Math.Min(chunkEnd, kvp.Key);
junkSeed = kvp.Value.Seed;
break;
}
}
@@ -232,19 +269,21 @@ namespace SabreTools.Wrappers
{
output.WriteBigEndian((uint)nonJunkBytes);
output.Write(data, (int)(dataOffset + currentOffset), (int)nonJunkBytes);
packedSize += 4 + (uint)nonJunkBytes;
packedSize += 4 + (uint)nonJunkBytes;
currentOffset += nonJunkBytes;
}
// Emit junk-seed segment
long junkBytes = nextJunkEnd - currentOffset;
if (junkBytes > 0 && junkSeed != null)
if (junkBytes > 0 && junkSeed is not null)
{
output.WriteBigEndian(0x80000000u | (uint)junkBytes);
byte[] seedBytes = new byte[SeedSizeBytes];
Buffer.BlockCopy(junkSeed, 0, seedBytes, 0, SeedSizeBytes);
output.Write(seedBytes, 0, SeedSizeBytes);
packedSize += 4 + (uint)SeedSizeBytes;
packedSize += 4 + (uint)SeedSizeBytes;
currentOffset += junkBytes;
}
@@ -255,10 +294,12 @@ namespace SabreTools.Wrappers
return new ChunkResult { Packed = output.ToArray(), RvzPackedSize = packedSize };
}
#endregion
#region Helpers
/// <summary>
/// Align a value to a boundary
/// </summary>
/// TODO: Figure out how to use buffer alignment helpers here
private static long AlignUp(long value, long alignment) => (value + alignment - 1) & ~(alignment - 1);
#endregion

View File

@@ -9,6 +9,7 @@ using SharpCompress.Compressors.ZStandard;
#endif
using SabreTools.Data.Models.WIA;
// TODO: Move to IO
namespace SabreTools.Wrappers
{
/// <summary>
@@ -17,10 +18,12 @@ namespace SabreTools.Wrappers
/// </summary>
internal static class WiaRvzCompressionHelper
{
// Dictionary sizes per compression level 19 (index 0 unused).
// Mirrors Dolphin WIACompression.cpp dict_size choices.
/// <summary>
/// Dictionary sizes per compression level 1-9 (index 0 unused).
/// </summary>
/// <remarks>Mirrors Dolphin WIACompression.cpp dict_size choices.</remarks>
private static readonly int[] DictSizes =
{
[
0, // 0: unused
1 << 16, // 1: 64 KiB
1 << 20, // 2: 1 MiB
@@ -31,29 +34,43 @@ namespace SabreTools.Wrappers
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))];
/// <summary>
///
/// </summary>
/// <param name="level"></param>
/// <returns></returns>
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.
/// <summary>
/// Returns the raw LZMA2 dict-size property byte for a given dictionary size.
/// </summary>
private static uint Lzma2DictSize(byte p) => (uint)((2 | (p & 1)) << ((p / 2) + 11));
/// <summary>
///
/// </summary>
/// <param name="d"></param>
/// <returns></returns>
private static byte EncodeLzma2DictSize(uint d)
{
byte e = 0;
while (e < 40 && d > Lzma2DictSize(e))
{
e++;
}
return e;
}
/// <summary>
/// Fills the compressor-data bytes for <c>WiaHeader2.CompressorData</c> /
/// <c>WiaHeader2.CompressorDataSize</c>.
/// Fills the compressor-data bytes for <see cref="WiaHeader2.CompressorData"/>
/// and <see cref="WiaHeader2.CompressorDataSize"/>.
/// LZMA: 5 bytes. LZMA2: 1 byte. Others: 0 bytes.
/// </summary>
internal static void GetCompressorData(WiaRvzCompressionType type, int level,
out byte[] propData, out byte propSize)
internal static void GetCompressorData(WiaRvzCompressionType type, int level, out byte[] propData, out byte propSize)
{
propData = new byte[7];
int dictSize = GetDictSize(level);
@@ -74,15 +91,27 @@ namespace SabreTools.Wrappers
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;
}
}
/// <summary>Compress <paramref name="data"/> using the specified algorithm.</summary>
internal static byte[] Compress(WiaRvzCompressionType type, byte[] data, int offset,
int length, int level, byte[] compressorData, byte compressorDataSize)
/// <summary>
/// Compress <paramref name="data"/> using the specified algorithm.
/// </summary>
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)
@@ -95,6 +124,10 @@ namespace SabreTools.Wrappers
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));
}
@@ -103,29 +136,38 @@ namespace SabreTools.Wrappers
#endif
}
/// <summary>Decompress <paramref name="data"/> using the specified algorithm.</summary>
internal static byte[] Decompress(WiaRvzCompressionType type, byte[] data, int offset,
int length, byte[] compressorData, byte compressorDataSize)
/// <summary>
/// Decompress <paramref name="data"/> using the specified algorithm.
/// </summary>
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[] props = new byte[compressorDataSize];
Array.Copy(compressorData, props, compressorDataSize);
return DecompressLzma(data, offset, length, props, isLzma2: false);
}
byte[] lzmaProps = new byte[compressorDataSize];
Array.Copy(compressorData, lzmaProps, compressorDataSize);
return DecompressLzma(data, offset, length, lzmaProps, isLzma2: false);
case WiaRvzCompressionType.LZMA2:
{
byte[] props = new byte[compressorDataSize];
Array.Copy(compressorData, props, compressorDataSize);
return DecompressLzma(data, offset, length, props, isLzma2: true);
}
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));
}
@@ -135,7 +177,13 @@ namespace SabreTools.Wrappers
}
#if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER
/// <summary>
/// Compress data using bzip2
/// </summary>
/// <param name="data">Source data</param>
/// <param name="offset">Offset into the source data</param>
/// <param name="length">Length within the source data</param>
/// <returns>Compressed data</returns>
private static byte[] CompressBzip2(byte[] data, int offset, int length)
{
using var outMs = new MemoryStream();
@@ -143,18 +191,37 @@ namespace SabreTools.Wrappers
{
bz2.Write(data, offset, length);
}
return outMs.ToArray();
}
/// <summary>
/// Decompress data using bzip2
/// </summary>
/// <param name="data">Source data</param>
/// <param name="offset">Offset into the source data</param>
/// <param name="length">Length within the source data</param>
/// <returns>Uncompressed data</returns>
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();
}
/// <summary>
/// Compress data using LZMA/LZMA2
/// </summary>
/// <param name="data">Source data</param>
/// <param name="offset">Offset into the source data</param>
/// <param name="length">Length within the source data</param>
/// <param name="level">LZMA/LZMA2 level</param>
/// <param name="isLzma2">Indicates if LZMA2 is used</param>
/// <returns>Compressed data</returns>
private static byte[] CompressLzma(byte[] data, int offset, int length, int level, bool isLzma2)
{
int dictSize = GetDictSize(level);
@@ -163,37 +230,66 @@ namespace SabreTools.Wrappers
{
lzma.Write(data, offset, length);
}
return outMs.ToArray();
}
/// <summary>
/// Decompress data using LZMA/LZMA2
/// </summary>
/// <param name="data">Source data</param>
/// <param name="offset">Offset into the source data</param>
/// <param name="length">Length within the source data</param>
/// <param name="props">LZMA properties</param>
/// <param name="isLzma2">Indicates if LZMA2 is used</param>
/// <returns>Uncompressed data</returns>
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();
}
/// <summary>
/// Compress data using Zstd
/// </summary>
/// <param name="data">Source data</param>
/// <param name="offset">Offset into the source data</param>
/// <param name="length">Length within the source data</param>
/// <param name="level">Zstd level</param>
/// <returns>Compressed data</returns>
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))
using (var zstd = new ZStandardStream(outMs, CompressionMode.Compress, level))
{
zstd.Write(data, offset, length);
}
return outMs.ToArray();
}
/// <summary>
/// Decompress data using Zstd
/// </summary>
/// <param name="data">Source data</param>
/// <param name="offset">Offset into the source data</param>
/// <param name="length">Length within the source data</param>
/// <returns>Uncompressed data</returns>
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
}
}

View File

@@ -10,6 +10,7 @@ namespace SabreTools.Wrappers
internal sealed class WiaVirtualStream : Stream
{
private readonly WIA _wia;
private long _position;
public WiaVirtualStream(WIA wia)
@@ -17,10 +18,19 @@ namespace SabreTools.Wrappers
_wia = wia ?? throw new ArgumentNullException(nameof(wia));
}
/// <inheritdoc/>
public override bool CanRead => true;
/// <inheritdoc/>
public override bool CanSeek => true;
/// <inheritdoc/>
public override bool CanWrite => false;
/// <inheritdoc/>
public override long Length => (long)_wia.IsoFileSize;
/// <inheritdoc/>
public override long Position
{
get => _position;
@@ -28,10 +38,12 @@ namespace SabreTools.Wrappers
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(value));
_position = value;
}
}
/// <inheritdoc/>
public override int Read(byte[] buffer, int offset, int count)
{
if (buffer is null)
@@ -53,14 +65,15 @@ namespace SabreTools.Wrappers
return read;
}
/// <inheritdoc/>
public override long Seek(long offset, SeekOrigin origin)
{
long newPos;
switch (origin)
{
case SeekOrigin.Begin: newPos = offset; break;
case SeekOrigin.Begin: newPos = offset; break;
case SeekOrigin.Current: newPos = _position + offset; break;
case SeekOrigin.End: newPos = Length + offset; break;
case SeekOrigin.End: newPos = Length + offset; break;
default: throw new ArgumentOutOfRangeException(nameof(origin));
}
@@ -71,10 +84,13 @@ namespace SabreTools.Wrappers
return _position;
}
/// <inheritdoc/>
public override void Flush() { }
/// <inheritdoc/>
public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}

View File

@@ -1,44 +1,62 @@
using System;
using SabreTools.Data.Models.NintendoDisc;
using SabreTools.Hashing;
// TODO: Move to IO
namespace SabreTools.Wrappers
{
public partial class NintendoDisc
{
#region Wii Encryption / Decryption
/// <summary>
/// Retail common key
/// </summary>
public static byte[] KoreanCommonKey = [];
/// <summary>
/// Resolves a Wii common key by its ticket index (0 = retail, 1 = Korean).
/// Must be set by the caller before invoking <see cref="DecryptTitleKey"/>.
/// If <see langword="null"/>, or the delegate returns <see langword="null"/> for a given
/// index, decryption will return <see langword="null"/>.
/// Korean common key SHA-256 hash
/// </summary>
public static System.Func<byte, byte[]?>? CommonKeyProvider { get; set; }
private const string KoreanCommonKeySHA256 = "b9f42ca27a1e178f0f14ebf1a05d486fa8db8d08875336c4e6e8dfae29f2901c";
/// <summary>
/// Retail common key
/// </summary>
public static byte[] RetailCommonKey = [];
/// <summary>
/// Retail common key SHA-256 hash
/// </summary>
private const string RetailCommonKeySHA256 = "de38aeab4fe0c36d828a47e6fd315100e7ce234d3b00aa25e6ad6f5ff2824af8";
/// <summary>
/// Decrypt a Wii partition title key from the ticket data.
/// </summary>
/// <param name="encryptedTitleKey">16-byte encrypted title key from ticket offset 0x1BF</param>
/// <param name="titleId">8-byte title ID from ticket offset 0x1DC (big-endian)</param>
/// <param name="commonKeyIndex">
/// Common key index from ticket offset 0x1F1: 0 = retail, 1 = Korean
/// </param>
/// <param name="commonKeyIndex">Common key index to use for decryption</param>
/// <returns>Decrypted 16-byte title key, or null if no key is available for the given index</returns>
public static byte[]? DecryptTitleKey(byte[] encryptedTitleKey, byte[] titleId, byte commonKeyIndex)
public static byte[]? DecryptTitleKey(byte[]? encryptedTitleKey, byte[]? titleId, int commonKeyIndex)
{
if (encryptedTitleKey is null || encryptedTitleKey.Length != 16)
return null;
if (titleId is null || titleId.Length != 8)
return null;
byte[]? commonKey = CommonKeyProvider?.Invoke(commonKeyIndex);
if (commonKey is null || commonKey.Length != 16)
byte[] commonKey;
if (commonKeyIndex == 0)
commonKey = RetailCommonKey;
else if (commonKeyIndex == 1)
commonKey = KoreanCommonKey;
else
return null;
if (commonKey.Length != 16)
return null;
// IV is the 8-byte title ID padded with zeros to 16 bytes
byte[] iv = new byte[16];
System.Array.Copy(titleId, 0, iv, 0, 8);
Array.Copy(titleId, 0, iv, 0, 8);
return DecryptAesCbc(encryptedTitleKey, commonKey, iv);
return AesCbc.Decrypt(encryptedTitleKey, commonKey, iv);
}
/// <summary>
@@ -57,14 +75,39 @@ namespace SabreTools.Wrappers
if (iv is null || iv.Length != 16)
return null;
return DecryptAesCbc(encryptedData, titleKey, iv);
return AesCbc.Decrypt(encryptedData, titleKey, iv);
}
private static byte[]? DecryptAesCbc(byte[] data, byte[] key, byte[] iv)
/// <summary>
/// Validate the Korean common key based on hash and length
/// </summary>
/// <param name="commonKey">Korean common key to validate</param>
/// <returns>True if the key was valid, false otherwise</returns>
public static bool ValidateKoreanCommonKey(byte[]? commonKey)
{
return AesCbc.Decrypt(data, key, iv);
// Ignore invalid values
if (commonKey is null || commonKey.Length != 16)
return false;
// Hash the key and compare
string? actualHash = HashTool.GetByteArrayHash(commonKey, HashType.SHA256);
return string.Equals(actualHash, KoreanCommonKeySHA256, StringComparison.OrdinalIgnoreCase);
}
#endregion
/// <summary>
/// Validate the retail common key based on hash and length
/// </summary>
/// <param name="commonKey">Retail common key to validate</param>
/// <returns>True if the key was valid, false otherwise</returns>
public static bool ValidateRetailCommonKey(byte[]? commonKey)
{
// Ignore invalid values
if (commonKey is null || commonKey.Length != 16)
return false;
// Hash the key and compare
string? actualHash = HashTool.GetByteArrayHash(commonKey, HashType.SHA256);
return string.Equals(actualHash, RetailCommonKeySHA256, StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@@ -1,7 +1,10 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using SabreTools.Data.Models.NintendoDisc;
using SabreTools.Numerics.Extensions;
using SabreTools.Text.Extensions;
namespace SabreTools.Wrappers
{
@@ -15,11 +18,9 @@ namespace SabreTools.Wrappers
try
{
Directory.CreateDirectory(outputDirectory);
if (Model.Platform == Platform.GameCube)
if (Platform == Platform.GameCube)
return ExtractGameCube(outputDirectory);
else if (Model.Platform == Platform.Wii)
else if (Platform == Platform.Wii)
return ExtractWii(outputDirectory);
return false;
@@ -28,52 +29,72 @@ namespace SabreTools.Wrappers
{
if (includeDebug)
Console.Error.WriteLine(ex);
return false;
}
}
#region Pre-decrypted reader override
/// <summary>
/// When set, <see cref="ReadDecryptedPartitionRange"/> calls this delegate instead of
/// performing AES-CBC decryption. Used by WIA/RVZ extraction, where partition data is
/// already stored decrypted and the encrypt-then-decrypt round-trip is unnecessary.
/// Signature: (absDataOffset, partitionDataOffset, length) -> decrypted bytes or null.
/// </summary>
internal Func<long, long, int, byte[]?>? _preDecryptedReader;
#endregion
#region GameCube extraction
private bool ExtractGameCube(string dest)
/// <summary>
/// Extract GameCube data from a disc
/// </summary>
/// <param name="outputDirectory">Output directory to write to</param>
/// <returns>True if the extraction was successful, false otherwise</returns>
private bool ExtractGameCube(string outputDirectory)
{
string sysDir = Path.Combine(dest, "sys");
string sysDir = Path.Combine(outputDirectory, "sys");
Directory.CreateDirectory(sysDir);
// sys/boot.bin (disc header, 0x000 0x43F)
// sys/boot.bin (disc header, 0x000 - 0x43F)
WriteRange(0, Constants.DiscHeaderSize, Path.Combine(sysDir, "boot.bin"));
// sys/bi2.bin (0x440 0x243F)
// sys/bi2.bin (0x440 - 0x243F)
WriteRange(Constants.Bi2Address, Constants.Bi2Size, Path.Combine(sysDir, "bi2.bin"));
// sys/apploader.img
WriteApploader(sysDir);
WriteGCApploader(sysDir);
// DOL offset stored without shift on GameCube
long dolOffset = Model.Header.DolOffset;
long dolOffset = Header.DolOffset;
if (dolOffset > 0)
{
byte[]? dolHeader = ReadDisc(dolOffset, 0xE0);
if (dolHeader != null)
byte[] dolHeaderBytes = ReadRangeFromSource(dolOffset, 0xE0);
if (dolHeaderBytes.Length > 0)
{
int dolHeaderOffset = 0;
var dolHeader = Serialization.Readers.NintendoDisc.ParseDOLHeader(dolHeaderBytes, ref dolHeaderOffset);
int dolSize = GetDolSize(dolHeader);
WriteRange(dolOffset, dolSize, Path.Combine(sysDir, "main.dol"));
}
}
// FST offset stored without shift on GameCube
long fstOffset = Model.Header.FstOffset;
long fstSize = Model.Header.FstSize;
long fstOffset = Header.FstOffset;
long fstSize = Header.FstSize;
if (fstOffset > 0 && fstSize > 0)
{
WriteRange(fstOffset, (int)Math.Min(fstSize, int.MaxValue),
Path.Combine(sysDir, "fst.bin"));
WriteRange(fstOffset, (int)Math.Min(fstSize, int.MaxValue), Path.Combine(sysDir, "fst.bin"));
byte[]? fstData = ReadDisc(fstOffset, (int)Math.Min(fstSize, int.MaxValue));
if (fstData != null)
byte[] fstData = ReadRangeFromSource(fstOffset, (int)Math.Min(fstSize, int.MaxValue));
if (fstData.Length > 0)
{
string filesDir = Path.Combine(dest, "files");
string filesDir = Path.Combine(outputDirectory, "files");
Directory.CreateDirectory(filesDir);
ExtractFstFiles(fstData, offsetShift: 0, filesDir, ReadDisc);
ExtractFstFiles(fstData, offsetShift: 0, filesDir, ReadRangeFromSource);
}
}
@@ -84,28 +105,33 @@ namespace SabreTools.Wrappers
#region Wii extraction
private bool ExtractWii(string dest)
/// <summary>
/// Extract Wii data from a disc
/// </summary>
/// <param name="outputDirectory">Output directory to write to</param>
/// <returns>True if the extraction was successful, false otherwise</returns>
private bool ExtractWii(string outputDirectory)
{
// Unencrypted disc header area
string discDir = Path.Combine(dest, "disc");
string discDir = Path.Combine(outputDirectory, "disc");
Directory.CreateDirectory(discDir);
WriteRange(0, 0x100, Path.Combine(discDir, "header.bin"));
WriteRange(Constants.WiiRegionDataAddress, Constants.WiiRegionDataSize,
Path.Combine(discDir, "region.bin"));
WriteRange(Constants.WiiRegionDataAddress, Constants.WiiRegionDataSize, Path.Combine(discDir, "region.bin"));
if (Model.PartitionTableEntries is null)
return true;
var typeCounters = new System.Collections.Generic.Dictionary<uint, int>();
var typeCounters = new Dictionary<uint, int>();
foreach (var pte in Model.PartitionTableEntries)
{
long partOffset = pte.Offset;
long partOffset = pte.Offset << 2;
if (partOffset <= 0 || partOffset >= _dataSource.Length)
continue;
string partName = GetPartitionName(pte.Type, typeCounters);
string partDir = Path.Combine(dest, partName);
string partDir = Path.Combine(outputDirectory, partName);
Directory.CreateDirectory(partDir);
ExtractWiiPartition(partOffset, partDir);
@@ -114,15 +140,22 @@ namespace SabreTools.Wrappers
return true;
}
private void ExtractWiiPartition(long partOffset, string partDir)
/// <summary>
/// Extract a Wii partition
/// </summary>
/// <param name="partitionOffset">Offset to the partition</param>
/// <param name="outputDirectory">Output directory to write to</param>
private void ExtractWiiPartition(long partitionOffset, string outputDirectory)
{
// ticket.bin (unencrypted, 0x2A4 bytes at partition start)
WriteRange(partOffset, Constants.WiiTicketSize, Path.Combine(partDir, "ticket.bin"));
WriteRange(partitionOffset, Constants.WiiTicketSize, Path.Combine(outputDirectory, "ticket.bin"));
byte[]? ticketData = ReadDisc(partOffset, Constants.WiiTicketSize);
byte[]? ticketData = ReadRangeFromSource(partitionOffset, Constants.WiiTicketSize);
if (ticketData is null || ticketData.Length < Constants.TicketCommonKeyIndexOffset + 1)
return;
#region Title Key
// Decrypt title key
byte[] encTitleKey = new byte[16];
Array.Copy(ticketData, Constants.TicketEncryptedTitleKeyOffset, encTitleKey, 0, 16);
@@ -134,59 +167,76 @@ namespace SabreTools.Wrappers
if (titleKey is null)
return;
// TMD
byte[]? tmdSizeBytes = ReadDisc(partOffset + Constants.WiiTmdSizeAddress, 4);
#endregion
#region TMD
byte[] tmdSizeBytes = ReadRangeFromSource(partitionOffset + Constants.WiiTmdSizeAddress, 4);
int tmdSizePos = 0;
uint tmdSize = tmdSizeBytes != null
uint tmdSize = tmdSizeBytes.Length > 0
? tmdSizeBytes.ReadUInt32BigEndian(ref tmdSizePos)
: 0;
byte[]? tmdOffBytes = ReadDisc(partOffset + Constants.WiiTmdOffsetAddress, 4);
byte[] tmdOffBytes = ReadRangeFromSource(partitionOffset + Constants.WiiTmdOffsetAddress, 4);
int tmdOffPos = 0;
uint tmdOffShifted = tmdOffBytes != null
uint tmdOffShifted = tmdOffBytes.Length > 0
? tmdOffBytes.ReadUInt32BigEndian(ref tmdOffPos)
: 0;
long tmdOffset = (long)tmdOffShifted << 2;
if (tmdSize > 0 && tmdOffset > 0)
WriteRange(partOffset + tmdOffset, (int)tmdSize, Path.Combine(partDir, "tmd.bin"));
WriteRange(partitionOffset + tmdOffset, (int)tmdSize, Path.Combine(outputDirectory, "tmd.bin"));
// cert.bin
byte[]? certSizeBytes = ReadDisc(partOffset + Constants.WiiCertSizeAddress, 4);
#endregion
#region cert.bin
byte[] certSizeBytes = ReadRangeFromSource(partitionOffset + Constants.WiiCertSizeAddress, 4);
int certSizePos = 0;
uint certSize = certSizeBytes != null
uint certSize = certSizeBytes.Length > 0
? certSizeBytes.ReadUInt32BigEndian(ref certSizePos)
: 0;
byte[]? certOffBytes = ReadDisc(partOffset + Constants.WiiCertOffsetAddress, 4);
byte[] certOffBytes = ReadRangeFromSource(partitionOffset + Constants.WiiCertOffsetAddress, 4);
int certOffPos = 0;
uint certOffShifted = certOffBytes != null
uint certOffShifted = certOffBytes.Length > 0
? certOffBytes.ReadUInt32BigEndian(ref certOffPos)
: 0;
long certOffset = (long)certOffShifted << 2;
if (certSize > 0 && certOffset > 0)
WriteRange(partOffset + certOffset, (int)certSize, Path.Combine(partDir, "cert.bin"));
WriteRange(partitionOffset + certOffset, (int)certSize, Path.Combine(outputDirectory, "cert.bin"));
// h3.bin
byte[]? h3OffBytes = ReadDisc(partOffset + Constants.WiiH3OffsetAddress, 4);
#endregion
#region h3.bin
byte[] h3OffBytes = ReadRangeFromSource(partitionOffset + Constants.WiiH3OffsetAddress, 4);
int h3OffPos = 0;
uint h3OffShifted = h3OffBytes != null
uint h3OffShifted = h3OffBytes.Length > 0
? h3OffBytes.ReadUInt32BigEndian(ref h3OffPos)
: 0;
long h3Offset = (long)h3OffShifted << 2;
if (h3Offset > 0)
WriteRange(partOffset + h3Offset, Constants.WiiH3Size, Path.Combine(partDir, "h3.bin"));
WriteRange(partitionOffset + h3Offset, Constants.WiiH3Size, Path.Combine(outputDirectory, "h3.bin"));
#endregion
// Encrypted partition data start
byte[]? dataOffBytes = ReadDisc(partOffset + Constants.WiiDataOffsetAddress, 4);
byte[] dataOffBytes = ReadRangeFromSource(partitionOffset + Constants.WiiDataOffsetAddress, 4);
int dataOffPos = 0;
uint dataOffShifted = dataOffBytes != null
uint dataOffShifted = dataOffBytes.Length > 0
? dataOffBytes.ReadUInt32BigEndian(ref dataOffPos)
: 0;
long dataOffset = (long)dataOffShifted << 2;
if (dataOffset <= 0)
return;
long absDataOffset = partOffset + dataOffset;
long absDataOffset = partitionOffset + dataOffset;
string sysDir = Path.Combine(partDir, "sys");
string sysDir = Path.Combine(outputDirectory, "sys");
Directory.CreateDirectory(sysDir);
// Read boot block from decrypted partition (block 0, offset 0 within data)
@@ -197,57 +247,79 @@ namespace SabreTools.Wrappers
File.WriteAllBytes(Path.Combine(sysDir, "boot.bin"), bootBlock);
// bi2.bin
byte[]? bi2 = ReadDecryptedPartitionRange(absDataOffset, titleKey,
Constants.Bi2Address, Constants.Bi2Size);
if (bi2 != null)
byte[]? bi2 = ReadDecryptedPartitionRange(absDataOffset, titleKey, Constants.Bi2Address, Constants.Bi2Size);
if (bi2 is not null)
File.WriteAllBytes(Path.Combine(sysDir, "bi2.bin"), bi2);
// apploader
WriteWiiApploader(absDataOffset, titleKey, sysDir);
// DOL — stored offset is shifted <<2 in Wii partition
#region DOL
// Stored offset is shifted <<2 in Wii partition
int dolOffPos = 0x420;
uint dolOffShifted = bootBlock.ReadUInt32BigEndian(ref dolOffPos);
long dolOff = (long)dolOffShifted << 2;
if (dolOff > 0)
{
byte[]? dolHdr = ReadDecryptedPartitionRange(absDataOffset, titleKey, dolOff, 0xE0);
if (dolHdr != null)
byte[]? dolHeaderBytes = ReadDecryptedPartitionRange(absDataOffset, titleKey, dolOff, 0xE0);
if (dolHeaderBytes is not null)
{
int dolSize = GetDolSize(dolHdr);
int dolHeaderOffset = 0;
var dolHeader = Serialization.Readers.NintendoDisc.ParseDOLHeader(dolHeaderBytes, ref dolHeaderOffset);
int dolSize = GetDolSize(dolHeader);
byte[]? dol = ReadDecryptedPartitionRange(absDataOffset, titleKey, dolOff, dolSize);
if (dol != null)
if (dol is not null)
File.WriteAllBytes(Path.Combine(sysDir, "main.dol"), dol);
}
}
// FST — stored offset shifted <<2 in Wii partition
#endregion
#region FST
// Stored offset shifted <<2 in Wii partition
int fstOffPos = 0x424;
int fstSzPos = 0x428;
uint fstOffShifted = bootBlock.ReadUInt32BigEndian(ref fstOffPos);
uint fstSzShifted = bootBlock.ReadUInt32BigEndian(ref fstSzPos);
long fstOff = (long)fstOffShifted << 2;
int fstSzPos = 0x428;
uint fstSzShifted = bootBlock.ReadUInt32BigEndian(ref fstSzPos);
long fstSize = (long)fstSzShifted << 2; // also stored >>2 on Wii
if (fstOff > 0 && fstSize > 0)
{
byte[]? fstData = ReadDecryptedPartitionRange(absDataOffset, titleKey,
fstOff, (int)Math.Min(fstSize, int.MaxValue));
if (fstData != null)
byte[]? fstData = ReadDecryptedPartitionRange(absDataOffset, titleKey, fstOff, (int)Math.Min(fstSize, int.MaxValue));
if (fstData is not null)
{
File.WriteAllBytes(Path.Combine(sysDir, "fst.bin"), fstData);
string filesDir = Path.Combine(partDir, "files");
string filesDir = Path.Combine(outputDirectory, "files");
Directory.CreateDirectory(filesDir);
ExtractFstFiles(fstData, offsetShift: 2, filesDir,
ExtractFstFiles(fstData,
offsetShift: 2,
filesDir,
(offset, length) => ReadDecryptedPartitionRange(absDataOffset, titleKey, offset, length));
}
}
#endregion
}
#endregion
#region FST extraction
#region FST Extraction
private void ExtractFstFiles(byte[] fstData, int offsetShift, string filesDir,
/// <summary>
///
/// </summary>
/// <param name="fstData"></param>
/// <param name="offsetShift"></param>
/// <param name="filesDir"></param>
/// <param name="readFunc"></param>
private void ExtractFstFiles(byte[] fstData,
int offsetShift,
string filesDir,
Func<long, int, byte[]?> readFunc)
{
if (fstData is null || fstData.Length < 12)
@@ -260,18 +332,21 @@ namespace SabreTools.Wrappers
return;
// String table immediately follows all entries
long stringTableOffset = rootCount * 12;
int stringTableOffset = (int)(rootCount * 12);
ExtractFstDirectory(fstData, 1, (int)rootCount, stringTableOffset,
filesDir, offsetShift, readFunc);
ExtractFstDirectory(fstData, 1, (int)rootCount, stringTableOffset, filesDir, offsetShift, readFunc);
}
/// <summary>
/// Recursively extracts FST entries [start, end) into <paramref name="currentDir"/>.
/// Returns the index of the next entry after this directory.
/// </summary>
private int ExtractFstDirectory(byte[] fstData, int start, int end,
long stringTableOffset, string currentDir, int offsetShift,
private int ExtractFstDirectory(byte[] fstData,
int start,
int end,
int stringTableOffset,
string currentDir,
int offsetShift,
Func<long, int, byte[]?> readFunc)
{
int i = start;
@@ -283,14 +358,10 @@ namespace SabreTools.Wrappers
// Each FST entry is 12 bytes: [flags(1) | nameOff(3)] [fileOffRaw(4)] [fileSize(4)]
int fstEntryPos = fstBase;
uint flagsAndNameOff = fstData.ReadUInt32BigEndian(ref fstEntryPos);
byte flags = (byte)(flagsAndNameOff >> 24);
bool isDir = (flags & 1) != 0;
uint nameOff = flagsAndNameOff & 0x00FFFFFFu;
uint fileOffRaw = fstData.ReadUInt32BigEndian(ref fstEntryPos);
uint fileSize = fstData.ReadUInt32BigEndian(ref fstEntryPos);
var entry = Serialization.Readers.NintendoDisc.ParseFileSystemTableEntry(fstData, ref fstEntryPos);
string name = ReadFstString(fstData, stringTableOffset + nameOff);
int nameOffset = stringTableOffset + (int)(entry.NameOffset & 0x00FFFFFF);
string? name = fstData.ReadNullTerminatedAnsiString(ref nameOffset);
if (string.IsNullOrEmpty(name))
{
i++;
@@ -298,19 +369,21 @@ namespace SabreTools.Wrappers
}
// Sanitize name: replace path separators and reject/flatten dot-segments
name = name.Replace('/', '_').Replace('\\', '_');
name = name!.Replace('/', '_').Replace('\\', '_');
if (name == "." || name == "..")
name = "_";
name = name.TrimStart('.');
byte flags = (byte)(entry.NameOffset >> 24);
bool isDir = (flags & 1) != 0;
if (isDir)
{
// fileOffRaw = parent entry index; fileSize = last entry index in this dir
int nextEntry = (int)fileSize;
int nextEntry = (int)entry.FileSize;
string subDir = Path.Combine(currentDir, name);
Directory.CreateDirectory(subDir);
i = ExtractFstDirectory(fstData, i + 1, nextEntry, stringTableOffset,
subDir, offsetShift, readFunc);
i = ExtractFstDirectory(fstData, i + 1, nextEntry, stringTableOffset, subDir, offsetShift, readFunc);
}
else
{
@@ -319,16 +392,16 @@ namespace SabreTools.Wrappers
if (!string.IsNullOrEmpty(outDir))
Directory.CreateDirectory(outDir);
if (fileSize == 0)
if (entry.FileSize == 0)
{
// Zero-byte file — create empty
File.WriteAllBytes(outPath, new byte[0]);
File.WriteAllBytes(outPath, []);
}
else
{
long discOffset = (long)fileOffRaw << offsetShift;
byte[]? fileData = readFunc(discOffset, (int)Math.Min(fileSize, int.MaxValue));
if (fileData != null)
long discOffset = (long)entry.FileOffset << offsetShift;
byte[]? fileData = readFunc(discOffset, (int)Math.Min(entry.FileSize, int.MaxValue));
if (fileData is not null)
File.WriteAllBytes(outPath, fileData);
}
@@ -339,103 +412,155 @@ namespace SabreTools.Wrappers
return i;
}
private static string ReadFstString(byte[] fstData, long offset)
{
if (offset < 0 || offset >= fstData.Length)
return string.Empty;
int start = (int)offset;
int end = start;
while (end < fstData.Length && fstData[end] != 0)
end++;
return System.Text.Encoding.ASCII.GetString(fstData, start, end - start);
}
#endregion
#region Apploader helpers
#region Apploader Writing
private void WriteApploader(string sysDir)
/// <summary>
/// Write GameCube apploader.img to the output directory, if possible
/// </summary>
/// <param name="outputDirectory">Output directory to write to</param>
private bool WriteGCApploader(string outputDirectory)
{
byte[]? hdr = ReadDisc(Constants.ApploaderAddress, Constants.ApploaderHeaderSize);
if (hdr is null) return;
byte[] header = ReadRangeFromSource(Constants.ApploaderAddress, Constants.ApploaderHeaderSize);
if (header.Length == 0)
return false;
int codeSizePos = Constants.ApploaderCodeSizeOffset;
int trailerSizePos = Constants.ApploaderTrailerSizeOffset;
uint codeSize = hdr.ReadUInt32BigEndian(ref codeSizePos);
uint trailerSize = hdr.ReadUInt32BigEndian(ref trailerSizePos);
int index = Constants.ApploaderCodeSizeOffset;
uint codeSize = header.ReadUInt32BigEndian(ref index);
index = Constants.ApploaderTrailerSizeOffset;
uint trailerSize = header.ReadUInt32BigEndian(ref index);
int totalSize = Constants.ApploaderHeaderSize + (int)codeSize + (int)trailerSize;
WriteRange(Constants.ApploaderAddress, totalSize, Path.Combine(sysDir, "apploader.img"));
WriteRange(Constants.ApploaderAddress, totalSize, Path.Combine(outputDirectory, "apploader.img"));
return true;
}
/// <summary>
/// Write Wii apploader.img to the output directory, if possible
/// </summary>
/// <param name="absDataOffset"></param>
/// <param name="titleKey"></param>
/// <param name="sysDir"></param>
private void WriteWiiApploader(long absDataOffset, byte[] titleKey, string sysDir)
{
byte[]? hdr = ReadDecryptedPartitionRange(absDataOffset, titleKey,
Constants.ApploaderAddress, Constants.ApploaderHeaderSize);
if (hdr is null) return;
byte[]? header = ReadDecryptedPartitionRange(absDataOffset, titleKey, Constants.ApploaderAddress, Constants.ApploaderHeaderSize);
if (header is null)
return;
int wiiCodeSizePos = Constants.ApploaderCodeSizeOffset;
int wiiTrailerSizePos = Constants.ApploaderTrailerSizeOffset;
uint codeSize = hdr.ReadUInt32BigEndian(ref wiiCodeSizePos);
uint trailerSize = hdr.ReadUInt32BigEndian(ref wiiTrailerSizePos);
int index = Constants.ApploaderCodeSizeOffset;
uint codeSize = header.ReadUInt32BigEndian(ref index);
index = Constants.ApploaderTrailerSizeOffset;
uint trailerSize = header.ReadUInt32BigEndian(ref index);
int totalSize = Constants.ApploaderHeaderSize + (int)codeSize + (int)trailerSize;
byte[]? apploader = ReadDecryptedPartitionRange(absDataOffset, titleKey,
Constants.ApploaderAddress, totalSize);
if (apploader != null)
byte[]? apploader = ReadDecryptedPartitionRange(absDataOffset, titleKey, Constants.ApploaderAddress, totalSize);
if (apploader is not null)
File.WriteAllBytes(Path.Combine(sysDir, "apploader.img"), apploader);
}
#endregion
#region DOL size calculation
#region Helpers
private static int GetDolSize(byte[] dolHeader)
/// <summary>
/// Total byte length of the raw disc image data
/// </summary>
internal long DataLength => _dataSource.Length;
/// <summary>
/// Read <paramref name="length"/> bytes from the disc image at <paramref name="offset"/>.
/// </summary>
internal byte[] ReadData(long offset, int length) => ReadRangeFromSource(offset, length);
/// <summary>
/// Get the size of the DOL based on the header
/// </summary>
/// <param name="dolHeader">DOL header to retrieve the size for</param>
/// <returns>The size of the DOL on success, 0 otherwise</returns>
private static int GetDolSize(DOLHeader dolHeader)
{
// DOL header: 7 text section offsets (0x00), 11 data section offsets (0x1C),
// 7 text sizes (0x90), 11 data sizes (0xAC), BSS offset (0xD8), BSS size (0xDC),
// entry point (0xE0). Max (offset + size) over all sections gives the DOL size.
if (dolHeader is null || dolHeader.Length < 0xE0)
if (dolHeader.SectionOffsetTable.Length != 18)
return 0;
if (dolHeader.SectionLengthsTable.Length != 18)
return 0;
int maxEnd = 0;
// Text sections (7): offset table at 0x00, size table at 0x90
for (int s = 0; s < 7; s++)
// Loop through the 18 offset and size entries
for (int i = 0; i < 18; i++)
{
int offPos = s * 4;
int szPos = 0x90 + (s * 4);
int off = (int)dolHeader.ReadUInt32BigEndian(ref offPos);
int sz = (int)dolHeader.ReadUInt32BigEndian(ref szPos);
if (off > 0 && sz > 0) maxEnd = Math.Max(maxEnd, off + sz);
}
// Data sections (11): offset table at 0x1C, size table at 0xAC
for (int s = 0; s < 11; s++)
{
int offPos = 0x1C + (s * 4);
int szPos = 0xAC + (s * 4);
int off = (int)dolHeader.ReadUInt32BigEndian(ref offPos);
int sz = (int)dolHeader.ReadUInt32BigEndian(ref szPos);
if (off > 0 && sz > 0) maxEnd = Math.Max(maxEnd, off + sz);
uint offset = dolHeader.SectionOffsetTable[i];
uint size = dolHeader.SectionLengthsTable[i];
if (offset > 0 && size > 0)
maxEnd = (int)Math.Max(maxEnd, offset + size);
}
return maxEnd;
}
#endregion
/// <summary>
/// Get the name of a given partition type
/// </summary>
/// <param name="type">Parition type</param>
/// <param name="counters">Dictionary of counters used for global indexing</param>
/// <returns>String representing the partition name</returns>
private static string GetPartitionName(uint type, Dictionary<uint, int> counters)
{
string code;
switch (type)
{
// GM + counter
case 0: code = "GM"; break;
#region Wii partition block decryption helpers
// UP + counter
case 1: code = "UP"; break;
// CH + counter
case 2: code = "CH"; break;
// Unknown: if all 4 bytes are printable ASCII, use the raw 4-char string (no prefix, no counter).
// Otherwise fall back to P{globalIndex} — we use the cumulative counter sum as the index.
default:
byte b0 = (byte)(type >> 24),
b1 = (byte)(type >> 16),
b2 = (byte)(type >> 8),
b3 = (byte)type;
if (b0 >= 0x20 && b0 <= 0x7E
&& b1 >= 0x20 && b1 <= 0x7E
&& b2 >= 0x20 && b2 <= 0x7E
&& b3 >= 0x20 && b3 <= 0x7E)
{
return Encoding.ASCII.GetString([b0, b1, b2, b3]);
}
// Non-printable: use global partition index (sum of all counter values so far)
int globalIndex = 0;
foreach (var v in counters.Values)
{
globalIndex += v;
}
return $"P{globalIndex}";
}
int index = counters.TryGetValue(type, out int cv) ? cv : 0;
counters[type] = index + 1;
return $"{code}{index}";
}
/// <summary>
/// Reads <paramref name="length"/> bytes at <paramref name="partitionDataOffset"/> within
/// the decrypted partition data, decrypting 0x8000-byte blocks as needed.
/// <paramref name="absDataOffset"/> is the absolute ISO offset where the encrypted data begins.
/// </summary>
private byte[]? ReadDecryptedPartitionRange(long absDataOffset, byte[] titleKey,
long partitionDataOffset, int length)
private byte[]? ReadDecryptedPartitionRange(long absDataOffset, byte[] titleKey, long partitionDataOffset, int length)
{
if (length <= 0) return null;
if (length <= 0)
return null;
// WIA/RVZ fast path: data is already decrypted; skip the AES round-trip.
if (_preDecryptedReader is not null)
@@ -448,11 +573,11 @@ namespace SabreTools.Wrappers
{
long dataOff = partitionDataOffset + produced;
long blockNum = dataOff / Constants.WiiBlockDataSize;
int offsetInBlock = (int)(dataOff % Constants.WiiBlockDataSize);
int offsetInBlock = (int)(dataOff % Constants.WiiBlockDataSize);
long encBlockOffset = absDataOffset + (blockNum * Constants.WiiBlockSize);
byte[]? encBlock = ReadDisc(encBlockOffset, Constants.WiiBlockSize);
if (encBlock is null || encBlock.Length < Constants.WiiBlockSize)
byte[] encBlock = ReadRangeFromSource(encBlockOffset, Constants.WiiBlockSize);
if (encBlock.Length < Constants.WiiBlockSize)
break;
// IV is at offset 0x3D0 of the raw (still-encrypted) block.
@@ -460,7 +585,7 @@ namespace SabreTools.Wrappers
byte[] iv = new byte[16];
Array.Copy(encBlock, 0x3D0, iv, 0, 16);
// Decrypt the 0x7C00 data portion (bytes 0x4000x7FFF of the raw block)
// Decrypt the 0x7C00 data portion (bytes 0x400-0x7FFF of the raw block)
byte[] encData = new byte[Constants.WiiBlockDataSize];
Array.Copy(encBlock, Constants.WiiBlockHeaderSize, encData, 0, Constants.WiiBlockDataSize);
@@ -476,64 +601,26 @@ namespace SabreTools.Wrappers
return produced == length ? result : null;
}
#endregion
#region Misc helpers
/// <summary>
/// Write a range from the underlying data source to a file
/// </summary>
/// <param name="offset">Offset into the data source</param>
/// <param name="length">Length of the data to read and write</param>
/// <param name="filePath">Full path to the expected output file</param>
private void WriteRange(long offset, int length, string filePath)
{
if (length <= 0) return;
byte[]? data = ReadDisc(offset, length);
if (data is null) return;
string? dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllBytes(filePath, data);
}
if (length <= 0)
return;
private byte[]? ReadDisc(long offset, int length)
{
if (length <= 0 || offset < 0) return null;
byte[] data = ReadRangeFromSource(offset, length);
return data.Length == length ? data : null;
}
if (data.Length == 0)
return;
/// <summary>Total byte length of the raw disc image data.</summary>
internal long DataLength => _dataSource.Length;
string? dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
/// <summary>
/// Read <paramref name="length"/> bytes from the disc image at <paramref name="offset"/>.
/// Returns null if the range is out of bounds or a short read occurs.
/// </summary>
internal byte[]? ReadData(long offset, int length) => ReadDisc(offset, length);
private static string GetPartitionName(uint type,
System.Collections.Generic.Dictionary<uint, int> counters)
{
// Matches DolphinIsoLib WiiDiscExtractor.PartitionFolderName exactly.
// Known types: 0→GM+counter, 1→UP+counter, 2→CH+counter.
// Unknown: if all 4 bytes are printable ASCII, use the raw 4-char string (no prefix, no counter).
// Otherwise fall back to P{globalIndex} — we use the cumulative counter sum as the index.
string code;
switch (type)
{
case 0: code = "GM"; break;
case 1: code = "UP"; break;
case 2: code = "CH"; break;
default:
byte b0 = (byte)(type >> 24), b1 = (byte)(type >> 16),
b2 = (byte)(type >> 8), b3 = (byte)type;
if (b0 >= 0x20 && b0 <= 0x7E && b1 >= 0x20 && b1 <= 0x7E &&
b2 >= 0x20 && b2 <= 0x7E && b3 >= 0x20 && b3 <= 0x7E)
return System.Text.Encoding.ASCII.GetString(new byte[] { b0, b1, b2, b3 });
// Non-printable: use global partition index (sum of all counter values so far)
int globalIdx = 0;
foreach (var v in counters.Values) globalIdx += v;
return $"P{globalIdx}";
}
int idx = counters.TryGetValue(type, out int cv) ? cv : 0;
counters[type] = idx + 1;
return $"{code}{idx}";
File.WriteAllBytes(filePath, data);
}
#endregion

View File

@@ -1,4 +1,5 @@
using System.Text;
using SabreTools.Data.Models.NintendoDisc;
using SabreTools.Text.Extensions;
namespace SabreTools.Wrappers
@@ -19,44 +20,69 @@ namespace SabreTools.Wrappers
builder.AppendLine($"{Platform} Disc Image Information:");
builder.AppendLine("-------------------------");
builder.AppendLine("Disc Header:");
builder.AppendLine(Header.GameId, " Game ID");
builder.AppendLine(Header.MakerCode, " Maker Code");
builder.AppendLine(Header.DiscNumber, " Disc Number");
builder.AppendLine(Header.DiscVersion, " Disc Version");
builder.AppendLine(Header.AudioStreaming, " Audio Streaming");
builder.AppendLine(Header.StreamingBufferSize, " Streaming Buffer Size");
builder.AppendLine(Header.WiiMagic, " Wii Magic");
builder.AppendLine(Header.GCMagic, " GC Magic");
builder.AppendLine(Header.GameTitle, " Game Title");
builder.AppendLine(Header.DisableHashVerification, " Disable Hash Verification");
builder.AppendLine(Header.DisableDiscEncryption, " Disable Disc Encryption");
builder.AppendLine(Header.DolOffset, " DOL Offset");
builder.AppendLine(Header.FstOffset, " FST Offset");
builder.AppendLine(Header.FstSize, " FST Size");
Print(builder, Header);
Print(builder, PartitionTableEntries);
Print(builder, RegionData);
}
private static void Print(StringBuilder builder, DiscHeader header)
{
builder.AppendLine(" Disc Header:");
builder.AppendLine(" -------------------------");
builder.AppendLine(header.GameId, " Game ID");
builder.AppendLine(header.DiscNumber, " Disc Number");
builder.AppendLine(header.DiscVersion, " Disc Version");
builder.AppendLine(header.AudioStreaming, " Audio Streaming");
builder.AppendLine(header.StreamingBufferSize, " Streaming Buffer Size");
builder.AppendLine(header.WiiMagic, " Wii Magic");
builder.AppendLine(header.GCMagic, " GC Magic");
builder.AppendLine(header.GameTitle, " Game Title");
builder.AppendLine(header.DisableHashVerification, " Disable Hash Verification");
builder.AppendLine(header.DisableDiscEncryption, " Disable Disc Encryption");
builder.AppendLine(header.DolOffset, " DOL Offset");
builder.AppendLine(header.FstOffset, " FST Offset");
builder.AppendLine(header.FstSize, " FST Size");
builder.AppendLine();
}
if (PartitionTableEntries is { Length: > 0 })
private static void Print(StringBuilder builder, WiiPartitionTableEntry[]? entries)
{
builder.AppendLine(" Partition Table:");
builder.AppendLine(" -------------------------");
if (entries is null || entries.Length == 0)
{
builder.AppendLine($"Partition Table ({PartitionTableEntries.Length} entries):");
for (int i = 0; i < PartitionTableEntries.Length; i++)
{
var pt = PartitionTableEntries[i];
builder.AppendLine($" Partition {i}:");
builder.AppendLine(pt.Offset, " Offset");
builder.AppendLine(pt.Type, " Type");
}
builder.AppendLine(" No partition table entries");
builder.AppendLine();
return;
}
if (RegionData is not null)
for (int i = 0; i < entries.Length; i++)
{
builder.AppendLine("Region Data:");
builder.AppendLine(RegionData.RegionSetting, " Region Setting");
builder.AppendLine(RegionData.AgeRatings, " Age Ratings");
builder.AppendLine();
var entry = entries[i];
builder.AppendLine($" Partition Table Entry {i}");
builder.AppendLine(entry.Offset, " Offset");
builder.AppendLine(entry.Type, " Type");
}
builder.AppendLine();
}
private static void Print(StringBuilder builder, WiiRegionData? regionData)
{
builder.AppendLine(" Region Data:");
builder.AppendLine(" -------------------------");
if (regionData is null)
{
builder.AppendLine(" No region data");
builder.AppendLine();
return;
}
builder.AppendLine(regionData.RegionSetting, " Region Setting");
builder.AppendLine(regionData.AgeRatings, " Age Ratings");
builder.AppendLine();
}
}
}

View File

@@ -1,5 +1,5 @@
using System;
using System.IO;
using SabreTools.Data.Extensions;
using SabreTools.Data.Models.NintendoDisc;
namespace SabreTools.Wrappers
@@ -18,14 +18,19 @@ namespace SabreTools.Wrappers
/// <inheritdoc cref="Disc.Header"/>
public DiscHeader Header => Model.Header;
/// <inheritdoc cref="Disc.Platform"/>
public Platform Platform => Model.Platform;
/// <summary>
/// Detected platform (GameCube or Wii)
/// </summary>
public Platform Platform => Header.GetPlatform();
/// <inheritdoc cref="DiscHeader.GameId"/>
public string GameId => Model.Header.GameId;
/// <inheritdoc cref="DiscHeader.MakerCode"/>
public string MakerCode => Model.Header.MakerCode;
/// <summary>
/// 2-character ASCII maker / publisher code (e.g. "01")
/// </summary>
public string MakerCode
=> GameId.Length >= 6 ? GameId.Substring(4, 2) : string.Empty;
/// <inheritdoc cref="DiscHeader.GameTitle"/>
public string GameTitle => Model.Header.GameTitle;
@@ -44,18 +49,6 @@ namespace SabreTools.Wrappers
#endregion
#region Pre-decrypted reader override
/// <summary>
/// When set, <see cref="ReadDecryptedPartitionRange"/> calls this delegate instead of
/// performing AES-CBC decryption. Used by WIA/RVZ extraction, where partition data is
/// already stored decrypted and the encrypt-then-decrypt round-trip is unnecessary.
/// Signature: (absDataOffset, partitionDataOffset, length) → decrypted bytes or null.
/// </summary>
internal Func<long, long, int, byte[]?>? _preDecryptedReader;
#endregion
#region Constructors
/// <inheritdoc/>

View File

@@ -1,113 +0,0 @@
using System;
using SabreTools.Numerics.Extensions;
namespace SabreTools.Wrappers
{
/// <summary>
/// Decompressor for RVZ packed format.
/// RVZ uses run-length encoding to store real data and junk data efficiently:
/// - Real data: size (4 bytes) + data bytes
/// - Junk data: size with high bit set (4 bytes) + 68-byte seed → regenerate using LFG
/// </summary>
internal class RvzPackDecompressor
{
private readonly byte[] m_packed_data;
private readonly uint m_rvz_packed_size;
private long m_data_offset;
private readonly LaggedFibonacciGenerator m_lfg;
private int m_in_position = 0;
private uint m_current_size = 0;
private bool m_current_is_junk = false;
/// <summary>
/// Creates a new RVZ pack decompressor.
/// </summary>
/// <param name="packedData">The packed RVZ data</param>
/// <param name="rvzPackedSize">Expected size of packed data (for validation)</param>
/// <param name="dataOffset">Offset in the virtual disc (for LFG alignment)</param>
public RvzPackDecompressor(byte[] packedData, uint rvzPackedSize, long dataOffset)
{
m_packed_data = packedData ?? throw new ArgumentNullException(nameof(packedData));
m_rvz_packed_size = rvzPackedSize;
m_data_offset = dataOffset;
m_lfg = new LaggedFibonacciGenerator();
}
/// <summary>
/// Decompresses the packed data into the output buffer.
/// </summary>
/// <param name="output">Destination buffer</param>
/// <param name="outputOffset">Offset in destination buffer</param>
/// <param name="count">Number of bytes to decompress</param>
/// <returns>Number of bytes actually decompressed</returns>
public int Decompress(byte[] output, int outputOffset, int count)
{
int totalWritten = 0;
while (totalWritten < count && !IsDone())
{
if (m_current_size == 0)
{
if (!ReadNextSegment())
break;
}
int bytesToWrite = Math.Min((int)m_current_size, count - totalWritten);
if (m_current_is_junk)
{
m_lfg.GetBytes(bytesToWrite, output, outputOffset + totalWritten);
}
else
{
Array.Copy(m_packed_data, m_in_position, output, outputOffset + totalWritten, bytesToWrite);
m_in_position += bytesToWrite;
}
m_current_size -= (uint)bytesToWrite;
totalWritten += bytesToWrite;
m_data_offset += bytesToWrite;
}
return totalWritten;
}
/// <summary>
/// Checks if decompression is complete.
/// </summary>
public bool IsDone() => m_current_size == 0 && m_in_position >= m_rvz_packed_size;
private bool ReadNextSegment()
{
if (m_in_position + 4 > m_packed_data.Length)
return false;
// Size field is big-endian u32; high bit signals junk data
uint sizeField = m_packed_data.ReadUInt32BigEndian(ref m_in_position);
m_current_is_junk = (sizeField & 0x80000000) != 0;
m_current_size = sizeField & 0x7FFFFFFF;
if (m_current_is_junk)
{
if (m_in_position + (LaggedFibonacciGenerator.SEED_SIZE * 4) > m_packed_data.Length)
return false;
byte[] seed = new byte[LaggedFibonacciGenerator.SEED_SIZE * 4];
Array.Copy(m_packed_data, m_in_position, seed, 0, seed.Length);
m_in_position += seed.Length;
m_lfg.SetSeed(seed);
// Advance LFG to the correct position within the buffer.
// Dolphin: lfg.m_position_bytes = data_offset % (LFG_K * sizeof(u32))
int offsetInBuffer = (int)(m_data_offset % LaggedFibonacciGenerator.BUFFER_BYTES);
if (offsetInBuffer > 0)
m_lfg.Forward(offsetInBuffer);
}
return true;
}
}
}

View File

@@ -1,5 +1,3 @@
using System.IO;
namespace SabreTools.Wrappers
{
public partial class WIA : IExtractable
@@ -7,7 +5,6 @@ namespace SabreTools.Wrappers
/// <inheritdoc/>
public bool Extract(string outputDirectory, bool includeDebug)
{
// Decompress WIA/RVZ to obtain the inner disc image, then delegate extraction.
var inner = GetInnerWrapper();
return inner?.Extract(outputDirectory, includeDebug) ?? false;
}

View File

@@ -1,4 +1,6 @@
using System.Text;
using SabreTools.Data.Models.NintendoDisc;
using SabreTools.Data.Models.WIA;
using SabreTools.Text.Extensions;
namespace SabreTools.Wrappers
@@ -19,86 +21,125 @@ namespace SabreTools.Wrappers
string formatName = IsRvz ? "RVZ" : "WIA";
builder.AppendLine($"{formatName} Information:");
builder.AppendLine("-------------------------");
builder.AppendLine("Header 1:");
builder.AppendLine(Header1.Magic, " Magic");
builder.AppendLine(Header1.Version, " Version");
builder.AppendLine(Header1.VersionCompatible, " Version Compatible");
builder.AppendLine(Header1.Header2Size, " Header 2 Size");
builder.AppendLine(Header1.Header2Hash, " Header 2 Hash");
builder.AppendLine(Header1.IsoFileSize, " ISO File Size");
builder.AppendLine(Header1.WiaFileSize, " WIA File Size");
builder.AppendLine(Header1.Header1Hash, " Header 1 Hash");
builder.AppendLine();
builder.AppendLine("Header 2:");
builder.AppendLine(Header2.DiscType.ToString(), " Disc Type");
builder.AppendLine(Header2.CompressionType.ToString(), " Compression Type");
builder.AppendLine(Header2.CompressionLevel, " Compression Level");
builder.AppendLine(Header2.ChunkSize, " Chunk Size");
builder.AppendLine(Header2.DiscHeader, " Disc Header");
builder.AppendLine(Header2.NumberOfPartitionEntries, " Partition Entry Count");
builder.AppendLine(Header2.PartitionEntrySize, " Partition Entry Size");
builder.AppendLine(Header2.PartitionEntriesOffset, " Partition Entries Offset");
builder.AppendLine(Header2.PartitionEntriesHash, " Partition Entries Hash");
builder.AppendLine(Header2.NumberOfRawDataEntries, " Raw Data Entry Count");
builder.AppendLine(Header2.RawDataEntriesOffset, " Raw Data Entries Offset");
builder.AppendLine(Header2.RawDataEntriesSize, " Raw Data Entries Size");
builder.AppendLine(Header2.NumberOfGroupEntries, " Group Entry Count");
builder.AppendLine(Header2.GroupEntriesOffset, " Group Entries Offset");
builder.AppendLine(Header2.GroupEntriesSize, " Group Entries Size");
builder.AppendLine(Header2.CompressorDataSize, " Compressor Data Size");
builder.AppendLine(Header2.CompressorData, " Compressor Data");
Print(builder, Header1);
Print(builder, Header2);
Print(builder, DiscHeader);
Print(builder, PartitionEntries);
Print(builder, RawDataEntries);
}
private static void Print(StringBuilder builder, WiaHeader1 header)
{
builder.AppendLine(" Header 1:");
builder.AppendLine(" -------------------------");
builder.AppendLine(header.Magic, " Magic");
builder.AppendLine(header.Version, " Version");
builder.AppendLine(header.VersionCompatible, " Version Compatible");
builder.AppendLine(header.Header2Size, " Header 2 Size");
builder.AppendLine(header.Header2Hash, " Header 2 Hash");
builder.AppendLine(header.IsoFileSize, " ISO File Size");
builder.AppendLine(header.WiaFileSize, " WIA File Size");
builder.AppendLine(header.Header1Hash, " Header 1 Hash");
builder.AppendLine();
}
var discHeader = DiscHeader;
if (discHeader is not null)
private static void Print(StringBuilder builder, WiaHeader2 header)
{
builder.AppendLine(" Header 2:");
builder.AppendLine(" -------------------------");
builder.AppendLine(header.DiscType.ToString(), " Disc Type");
builder.AppendLine(header.CompressionType.ToString(), " Compression Type");
builder.AppendLine(header.CompressionLevel, " Compression Level");
builder.AppendLine(header.ChunkSize, " Chunk Size");
builder.AppendLine(header.DiscHeader, " Disc Header");
builder.AppendLine(header.NumberOfPartitionEntries, " Partition Entry Count");
builder.AppendLine(header.PartitionEntrySize, " Partition Entry Size");
builder.AppendLine(header.PartitionEntriesOffset, " Partition Entries Offset");
builder.AppendLine(header.PartitionEntriesHash, " Partition Entries Hash");
builder.AppendLine(header.NumberOfRawDataEntries, " Raw Data Entry Count");
builder.AppendLine(header.RawDataEntriesOffset, " Raw Data Entries Offset");
builder.AppendLine(header.RawDataEntriesSize, " Raw Data Entries Size");
builder.AppendLine(header.NumberOfGroupEntries, " Group Entry Count");
builder.AppendLine(header.GroupEntriesOffset, " Group Entries Offset");
builder.AppendLine(header.GroupEntriesSize, " Group Entries Size");
builder.AppendLine(header.CompressorDataSize, " Compressor Data Size");
builder.AppendLine(header.CompressorData, " Compressor Data");
builder.AppendLine();
}
private static void Print(StringBuilder builder, DiscHeader? header)
{
builder.AppendLine(" Embedded Disc Header:");
builder.AppendLine(" -------------------------");
if (header is null)
{
builder.AppendLine("Embedded Disc Header:");
builder.AppendLine(discHeader.GameId, " Game ID");
builder.AppendLine(discHeader.MakerCode, " Maker Code");
builder.AppendLine(discHeader.DiscNumber, " Disc Number");
builder.AppendLine(discHeader.DiscVersion, " Disc Version");
builder.AppendLine(discHeader.GameTitle, " Game Title");
builder.AppendLine(" No embedded disc header");
builder.AppendLine();
return;
}
if (PartitionEntries is { Length: > 0 })
{
builder.AppendLine($"Partition Entries ({PartitionEntries.Length}):");
for (int i = 0; i < PartitionEntries.Length; i++)
{
var pe = PartitionEntries[i];
builder.AppendLine($" Partition {i}:");
builder.AppendLine(pe.PartitionKey, " Partition Key");
builder.AppendLine(pe.DataEntry0.FirstSector, " Data Entry 0 First Sector");
builder.AppendLine(pe.DataEntry0.NumberOfSectors, " Data Entry 0 Sector Count");
builder.AppendLine(pe.DataEntry0.GroupIndex, " Data Entry 0 Group Index");
builder.AppendLine(pe.DataEntry0.NumberOfGroups, " Data Entry 0 Group Count");
builder.AppendLine(pe.DataEntry1.FirstSector, " Data Entry 1 First Sector");
builder.AppendLine(pe.DataEntry1.NumberOfSectors, " Data Entry 1 Sector Count");
builder.AppendLine(pe.DataEntry1.GroupIndex, " Data Entry 1 Group Index");
builder.AppendLine(pe.DataEntry1.NumberOfGroups, " Data Entry 1 Group Count");
}
builder.AppendLine(header.GameId, " Game ID");
builder.AppendLine(header.DiscNumber, " Disc Number");
builder.AppendLine(header.DiscVersion, " Disc Version");
builder.AppendLine(header.GameTitle, " Game Title");
builder.AppendLine();
}
private static void Print(StringBuilder builder, PartitionEntry[]? entries)
{
builder.AppendLine(" Partition Entry Table:");
builder.AppendLine(" -------------------------");
if (entries is null || entries.Length == 0)
{
builder.AppendLine(" No partition table entries");
builder.AppendLine();
return;
}
if (RawDataEntries is { Length: > 0 })
for (int i = 0; i < entries.Length; i++)
{
builder.AppendLine($"Raw Data Entries ({RawDataEntries.Length}):");
for (int i = 0; i < RawDataEntries.Length; i++)
{
var rde = RawDataEntries[i];
builder.AppendLine($" Raw Data Entry {i}:");
builder.AppendLine(rde.DataOffset, " Data Offset");
builder.AppendLine(rde.DataSize, " Data Size");
builder.AppendLine(rde.GroupIndex, " Group Index");
builder.AppendLine(rde.NumberOfGroups, " Group Count");
}
var entry = entries[i];
builder.AppendLine();
builder.AppendLine($" Partition Table Entry {i}:");
builder.AppendLine(entry.PartitionKey, " Partition Key");
builder.AppendLine(entry.DataEntry0.FirstSector, " Data Entry 0 First Sector");
builder.AppendLine(entry.DataEntry0.NumberOfSectors, " Data Entry 0 Sector Count");
builder.AppendLine(entry.DataEntry0.GroupIndex, " Data Entry 0 Group Index");
builder.AppendLine(entry.DataEntry0.NumberOfGroups, " Data Entry 0 Group Count");
builder.AppendLine(entry.DataEntry1.FirstSector, " Data Entry 1 First Sector");
builder.AppendLine(entry.DataEntry1.NumberOfSectors, " Data Entry 1 Sector Count");
builder.AppendLine(entry.DataEntry1.GroupIndex, " Data Entry 1 Group Index");
builder.AppendLine(entry.DataEntry1.NumberOfGroups, " Data Entry 1 Group Count");
}
builder.AppendLine();
}
private static void Print(StringBuilder builder, RawDataEntry[]? entries)
{
builder.AppendLine(" Raw Data Entry Table:");
builder.AppendLine(" -------------------------");
if (entries is null || entries.Length == 0)
{
builder.AppendLine(" No raw data table entries");
builder.AppendLine();
return;
}
for (int i = 0; i < entries.Length; i++)
{
var entry = entries[i];
builder.AppendLine($" Raw Data Table Entry {i}:");
builder.AppendLine(entry.DataOffset, " Data Offset");
builder.AppendLine(entry.DataSize, " Data Size");
builder.AppendLine(entry.GroupIndex, " Group Index");
builder.AppendLine(entry.NumberOfGroups, " Group Count");
}
builder.AppendLine();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
using System;
using System.IO;
using SabreTools.Hashing;
using SabreTools.Data.Models.NintendoDisc;
using SabreTools.Data.Models.WIA;
using WiaConstants = SabreTools.Data.Models.WIA.Constants;
using SabreTools.Hashing;
using static SabreTools.Data.Models.NintendoDisc.Constants;
using static SabreTools.Data.Models.WIA.Constants;
using WiaReader = SabreTools.Serialization.Readers.WIA;
namespace SabreTools.Wrappers
@@ -19,6 +20,9 @@ namespace SabreTools.Wrappers
#region Extension Properties
/// <inheritdoc cref="DiscImage.GroupEntries"/>
public WiaGroupEntry[]? GroupEntries => Model.GroupEntries;
/// <inheritdoc cref="DiscImage.Header1"/>
public WiaHeader1 Header1 => Model.Header1;
@@ -26,7 +30,7 @@ namespace SabreTools.Wrappers
public WiaHeader2 Header2 => Model.Header2;
/// <summary>True if this is an RVZ file; false if this is a WIA file.</summary>
public bool IsRvz => Model.Header1.Magic == WiaConstants.RvzMagic;
public bool IsRvz => Header1.Magic == RvzMagic;
/// <inheritdoc cref="DiscImage.PartitionEntries"/>
public PartitionEntry[]? PartitionEntries => Model.PartitionEntries;
@@ -34,10 +38,13 @@ namespace SabreTools.Wrappers
/// <inheritdoc cref="DiscImage.RawDataEntries"/>
public RawDataEntry[] RawDataEntries => Model.RawDataEntries;
/// <inheritdoc cref="DiscImage.RvzGroupEntries"/>
public RvzGroupEntry[]? RvzGroupEntries => Model.RvzGroupEntries;
/// <summary>
/// Total uncompressed ISO size in bytes
/// </summary>
public ulong IsoFileSize => Model.Header1.IsoFileSize;
public ulong IsoFileSize => Header1.IsoFileSize;
/// <summary>
/// Disc header parsed from the 128-byte raw disc header stored in Header2.
@@ -46,19 +53,19 @@ namespace SabreTools.Wrappers
{
get
{
if (_discHeader is not null)
return _discHeader;
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);
_discHeader = Serialization.Readers.NintendoDisc.ParseDiscHeaderOnly(ms);
return _discHeader;
field = Serialization.Readers.NintendoDisc.ParseDiscHeader(ms);
return field;
}
}
private DiscHeader? _discHeader;
#endregion
#region Constructors
@@ -125,9 +132,11 @@ namespace SabreTools.Wrappers
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);
}
@@ -137,6 +146,7 @@ namespace SabreTools.Wrappers
}
}
#if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER
/// <summary>
/// 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
@@ -144,7 +154,6 @@ namespace SabreTools.Wrappers
/// </summary>
private static void DecompressTables(DiscImage model, Stream data, long baseOffset)
{
#if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER
var comp = model.Header2.CompressionType;
// None / Purge tables are stored as plain big-endian structs — already parsed correctly.
@@ -155,13 +164,13 @@ namespace SabreTools.Wrappers
byte compDataSize = model.Header2.CompressorDataSize;
// --- Raw data entries (stored compressed) ---
if (model.Header2.NumberOfRawDataEntries > 0 &&
model.Header2.RawDataEntriesOffset > 0 &&
model.Header2.RawDataEntriesSize > 0)
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 * WiaConstants.RawDataEntrySize;
int expectedSize = count * RawDataEntrySize;
data.Seek(baseOffset + (long)model.Header2.RawDataEntriesOffset, SeekOrigin.Begin);
byte[] buf = new byte[compressedSize];
@@ -169,8 +178,7 @@ namespace SabreTools.Wrappers
if (read < compressedSize)
return;
byte[] plain = WiaRvzCompressionHelper.Decompress(
comp, buf, 0, compressedSize, compData, compDataSize);
byte[] plain = WiaRvzCompressionHelper.Decompress(comp, buf, 0, compressedSize, compData, compDataSize);
if (plain is null || plain.Length < expectedSize)
return;
@@ -178,13 +186,13 @@ namespace SabreTools.Wrappers
}
// --- Group entries (stored compressed) ---
if (model.Header2.NumberOfGroupEntries > 0 &&
model.Header2.GroupEntriesOffset > 0 &&
model.Header2.GroupEntriesSize > 0)
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 == WiaConstants.RvzMagic ? WiaConstants.RvzGroupEntrySize : WiaConstants.WiaGroupEntrySize;
int entrySize = model.Header1.Magic == RvzMagic ? RvzGroupEntrySize : WiaGroupEntrySize;
int expectedSize = count * entrySize;
data.Seek(baseOffset + (long)model.Header2.GroupEntriesOffset, SeekOrigin.Begin);
@@ -193,85 +201,66 @@ namespace SabreTools.Wrappers
if (read < compressedSize)
return;
byte[] plain = WiaRvzCompressionHelper.Decompress(
comp, buf, 0, compressedSize, compData, compDataSize);
byte[] plain = WiaRvzCompressionHelper.Decompress(comp, buf, 0, compressedSize, compData, compDataSize);
if (plain is null || plain.Length < expectedSize)
return;
if (model.Header1.Magic == WiaConstants.RvzMagic)
if (model.Header1.Magic == RvzMagic)
model.RvzGroupEntries = ParseRvzGroupEntries(plain, count);
else
model.GroupEntries = ParseWiaGroupEntries(plain, count);
}
#endif
}
#if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER
/// <summary>Parses raw data entries from a plain (already decompressed) byte array.</summary>
/// <summary>
/// Parses raw data entries from a plain (already decompressed) byte array.
/// </summary>
private static RawDataEntry[] ParseRawDataEntries(byte[] plain, int count)
{
var entries = new RawDataEntry[count];
int offset = 0;
for (int i = 0; i < count; i++)
{
int o = i * WiaConstants.RawDataEntrySize;
var e = new RawDataEntry();
e.DataOffset = ReadUInt64BE(plain, o);
e.DataSize = ReadUInt64BE(plain, o + 8);
e.GroupIndex = ReadUInt32BE(plain, o + 16);
e.NumberOfGroups = ReadUInt32BE(plain, o + 20);
entries[i] = e;
entries[i] = WiaReader.ParseRawDataEntry(plain, ref offset);
}
return entries;
}
/// <summary>Parses WIA group entries from a plain (already decompressed) byte array.</summary>
/// <summary>
/// Parses WIA group entries from a plain (already decompressed) byte array.
/// </summary>
private static WiaGroupEntry[] ParseWiaGroupEntries(byte[] plain, int count)
{
var entries = new WiaGroupEntry[count];
int offset = 0;
for (int i = 0; i < count; i++)
{
int o = i * WiaConstants.WiaGroupEntrySize;
var e = new WiaGroupEntry();
e.DataOffset = (ulong)ReadUInt32BE(plain, o) << 2;
e.DataSize = ReadUInt32BE(plain, o + 4);
entries[i] = e;
entries[i] = WiaReader.ParseWiaGroupEntry(plain, ref offset);
}
return entries;
}
/// <summary>Parses RVZ group entries from a plain (already decompressed) byte array.</summary>
/// <summary>
/// Parses RVZ group entries from a plain (already decompressed) byte array.
/// </summary>
private static RvzGroupEntry[] ParseRvzGroupEntries(byte[] plain, int count)
{
var entries = new RvzGroupEntry[count];
int offset = 0;
for (int i = 0; i < count; i++)
{
int o = i * WiaConstants.RvzGroupEntrySize;
var e = new RvzGroupEntry();
e.DataOffset = (ulong)ReadUInt32BE(plain, o) << 2;
e.DataSize = ReadUInt32BE(plain, o + 4);
e.RvzPackedSize = ReadUInt32BE(plain, o + 8);
entries[i] = e;
entries[i] = WiaReader.ParseRvzGroupEntry(plain, ref offset);
}
return entries;
}
#endif
#if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER
private static ulong ReadUInt64BE(byte[] b, int o)
{
return ((ulong)b[o] << 56) | ((ulong)b[o + 1] << 48) | ((ulong)b[o + 2] << 40) | ((ulong)b[o + 3] << 32)
| ((ulong)b[o + 4] << 24) | ((ulong)b[o + 5] << 16) | ((ulong)b[o + 6] << 8) | b[o + 7];
}
private static uint ReadUInt32BE(byte[] b, int o)
{
return ((uint)b[o] << 24) | ((uint)b[o + 1] << 16) | ((uint)b[o + 2] << 8) | b[o + 3];
}
#endif
#endregion
#region Inner Wrapper
@@ -288,7 +277,7 @@ namespace SabreTools.Wrappers
/// </summary>
public NintendoDisc? GetInnerWrapper()
{
if (Model.Header1.IsoFileSize == 0)
if (Header1.IsoFileSize == 0)
return null;
var vStream = new WiaVirtualStream(this);
@@ -299,47 +288,43 @@ namespace SabreTools.Wrappers
// 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 (Model.PartitionEntries is { Length: > 0 })
disc._preDecryptedReader = BuildPreDecryptedReader();
if (PartitionEntries is not null && PartitionEntries.Length > 0)
disc._preDecryptedReader = PreDecryptedReader;
return disc;
}
/// <summary>
/// Builds the delegate used by <see cref="NintendoDisc._preDecryptedReader"/>.
/// Used by <see cref="NintendoDisc._preDecryptedReader"/>.
/// Matches <paramref name="absDataOffset"/> (absolute ISO offset of the encrypted data
/// area) to the corresponding WIA <see cref="PartitionEntry"/> by comparing it with
/// <c>de.FirstSector * 0x8000</c>, then delegates to
/// de.FirstSector * 0x8000, then delegates to
/// <see cref="ReadDecryptedPartitionBytes"/>.
/// </summary>
private Func<long, long, int, byte[]?> BuildPreDecryptedReader()
private byte[]? PreDecryptedReader(long absDataOffset, long partitionDataOffset, int length)
{
const int WiiBlockSize = 0x8000;
return (absDataOffset, partitionDataOffset, length) =>
{
if (Model.PartitionEntries is null)
return null;
foreach (var pe in Model.PartitionEntries)
{
// The data area of this partition starts at de.FirstSector * 0x8000
long deIsoStart = (long)pe.DataEntry0.FirstSector * WiiBlockSize;
long deIsoEnd = deIsoStart + ((long)pe.DataEntry0.NumberOfSectors * WiiBlockSize);
if (absDataOffset >= deIsoStart && absDataOffset < deIsoEnd)
return ReadDecryptedPartitionBytes(pe, partitionDataOffset, length);
if (pe.DataEntry1 is { NumberOfSectors: > 0 })
{
long de1Start = (long)pe.DataEntry1.FirstSector * WiiBlockSize;
long de1End = de1Start + ((long)pe.DataEntry1.NumberOfSectors * WiiBlockSize);
if (absDataOffset >= de1Start && absDataOffset < de1End)
return ReadDecryptedPartitionBytes(pe, partitionDataOffset, 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;
}
/// <summary>
@@ -349,7 +334,7 @@ namespace SabreTools.Wrappers
/// </summary>
internal int ReadVirtual(long offset, byte[] buffer, int bufferOffset, int count)
{
long isoSize = (long)Model.Header1.IsoFileSize;
long isoSize = (long)Header1.IsoFileSize;
if (offset >= isoSize || count <= 0)
return 0;
@@ -382,32 +367,34 @@ namespace SabreTools.Wrappers
private int ReadVirtualChunk(long pos, byte[] buffer, int bufferOffset, int count)
{
// 1. Disc header (first 0x80 bytes stored verbatim in Header2.DiscHeader)
if (pos < WiaConstants.DiscHeaderStoredSize && Model.Header2.DiscHeader is { Length: > 0 })
if (pos < DiscHeaderStoredSize && Header2.DiscHeader.Length > 0)
{
int available = (int)Math.Min(WiaConstants.DiscHeaderStoredSize - pos, count);
int srcAvail = Math.Min(available, Model.Header2.DiscHeader.Length - (int)pos);
int available = (int)Math.Min(DiscHeaderStoredSize - pos, count);
int srcAvail = Math.Min(available, Header2.DiscHeader.Length - (int)pos);
if (srcAvail > 0)
Array.Copy(Model.Header2.DiscHeader, (int)pos, buffer, bufferOffset, srcAvail);
Array.Copy(Header2.DiscHeader, (int)pos, buffer, bufferOffset, srcAvail);
if (available > srcAvail)
Array.Clear(buffer, bufferOffset + srcAvail, available - srcAvail);
return available;
}
uint chunkSize = Model.Header2.ChunkSize;
var comp = Model.Header2.CompressionType;
byte[] compData = Model.Header2.CompressorData ?? new byte[7];
byte compDataSize = Model.Header2.CompressorDataSize;
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 (Model.RawDataEntries is { Length: > 0 })
if (RawDataEntries.Length > 0)
{
foreach (var rde in Model.RawDataEntries)
foreach (var entry in RawDataEntries)
{
if (rde.DataSize == 0 || rde.NumberOfGroups == 0)
if (entry.DataSize == 0 || entry.NumberOfGroups == 0)
continue;
long rdeStart = (long)rde.DataOffset;
long rdeEnd = rdeStart + (long)rde.DataSize;
long rdeStart = (long)entry.DataOffset;
long rdeEnd = rdeStart + (long)entry.DataSize;
if (pos < rdeStart || pos >= rdeEnd)
continue;
@@ -417,10 +404,10 @@ namespace SabreTools.Wrappers
uint g = (uint)(adjustedPos / chunkSize);
int offsetInGroup = (int)(adjustedPos % chunkSize);
if (g >= rde.NumberOfGroups)
if (g >= entry.NumberOfGroups)
continue;
uint groupFileIdx = rde.GroupIndex + g;
uint groupFileIdx = entry.GroupIndex + g;
byte[]? groupBytes = GetCachedRawGroup(groupFileIdx, comp, compData, compDataSize, chunkSize);
if (groupBytes is null)
return 0;
@@ -430,6 +417,7 @@ namespace SabreTools.Wrappers
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);
@@ -443,16 +431,35 @@ namespace SabreTools.Wrappers
}
// 3. Partition data entries (Wii encrypted partition data)
if (Model.PartitionEntries is { Length: > 0 })
if (PartitionEntries is not null && PartitionEntries.Length > 0)
{
foreach (var pe in Model.PartitionEntries)
foreach (var pe in PartitionEntries)
{
int r = ReadPartitionChunk(pe.DataEntry0, pe.PartitionKey, pos,
buffer, bufferOffset, count, comp, compData, compDataSize, chunkSize);
if (r > 0) return r;
r = ReadPartitionChunk(pe.DataEntry1, pe.PartitionKey, pos,
buffer, bufferOffset, count, comp, compData, compDataSize, chunkSize);
if (r > 0) return r;
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;
}
}
@@ -470,23 +477,21 @@ namespace SabreTools.Wrappers
if (length <= 0 || pe is null)
return null;
const int WiiBlockSize = 0x8000;
const int WiiBlockDataSize = 0x7C00;
uint chunkSize = Header2.ChunkSize;
var comp = Header2.CompressionType;
byte[] compData = Header2.CompressorData ?? new byte[7];
byte compDataSize = Header2.CompressorDataSize;
int blocksPerGroup = (int)(chunkSize / WiiBlockSize);
uint chunkSize = Model.Header2.ChunkSize;
var comp = Model.Header2.CompressionType;
byte[] compData = Model.Header2.CompressorData ?? new byte[7];
byte compDataSize = Model.Header2.CompressorDataSize;
int blocksPerGroup = (int)(chunkSize / WiiBlockSize);
byte[] result = new byte[length];
int produced = 0;
byte[] result = new byte[length];
int produced = 0;
// DataEntry0 covers [0 .. de0.NumberOfSectors * 0x7C00) in partition-data space.
// DataEntry1 (if present) immediately follows.
// DataEntry0 covers [0 .. de0.NumberOfSectors * 0x7C00) in partition-data space
var de0 = pe.DataEntry0;
var de1 = pe.DataEntry1;
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)
@@ -511,24 +516,29 @@ namespace SabreTools.Wrappers
break; // beyond available data
}
long blockNum = deRelOff / WiiBlockDataSize;
int offsetInBlock = (int)(deRelOff % WiiBlockDataSize);
long blockNum = deRelOff / WiiBlockDataSize;
int offsetInBlock = (int)(deRelOff % WiiBlockDataSize);
long groupRelative = blockNum / blocksPerGroup;
int blockInGroup = (int)(blockNum % blocksPerGroup);
int blockInGroup = (int)(blockNum % blocksPerGroup);
if (groupRelative >= de.NumberOfGroups)
break;
uint groupFileIdx = de.GroupIndex + (uint)groupRelative;
uint groupFileIdx = de.GroupIndex + (uint)groupRelative;
long dataOffsetForLfg = groupRelative * blocksPerGroup * WiiBlockDataSize;
byte[]? decrypted = ReadDecryptedGroupData(groupFileIdx, comp, compData, compDataSize,
blocksPerGroup, WiiBlockDataSize, dataOffsetForLfg);
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;
int offsetInGroup = (blockInGroup * WiiBlockDataSize) + offsetInBlock;
int available = decrypted.Length - offsetInGroup;
if (available <= 0)
break;
@@ -545,17 +555,38 @@ namespace SabreTools.Wrappers
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)
/// <summary>
///
/// </summary>
/// <param name="de"></param>
/// <param name="partitionKey"></param>
/// <param name="pos"></param>
/// <param name="buffer"></param>
/// <param name="bufferOffset"></param>
/// <param name="count"></param>
/// <param name="comp"></param>
/// <param name="compData"></param>
/// <param name="compDataSize"></param>
/// <param name="chunkSize"></param>
/// <returns></returns>
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;
const int WiiBlockSize = 0x8000;
if (chunkSize == 0)
return 0;
@@ -577,8 +608,7 @@ namespace SabreTools.Wrappers
return 0;
uint groupFileIdx = de.GroupIndex + (uint)groupNum;
byte[]? encryptedGroup = GetCachedEncGroup(groupFileIdx, de, partitionKey,
comp, compData, compDataSize, blocksPerGroup, chunkSize);
byte[]? encryptedGroup = GetCachedEncGroup(groupFileIdx, de, partitionKey, comp, compData, compDataSize, blocksPerGroup);
if (encryptedGroup is null)
return 0;
@@ -588,6 +618,7 @@ namespace SabreTools.Wrappers
return 0;
long remainingInEntry = isoDataEnd - pos;
// Stay within this group
long groupIsoEnd = isoDataStart + ((groupNum + 1) * blocksPerGroup * WiiBlockSize);
long remainingInGroup = groupIsoEnd - pos;
@@ -599,8 +630,20 @@ namespace SabreTools.Wrappers
return toCopy;
}
/// <summary>
///
/// </summary>
/// <param name="groupFileIdx"></param>
/// <param name="comp"></param>
/// <param name="compData"></param>
/// <param name="compDataSize"></param>
/// <param name="chunkSize"></param>
/// <returns></returns>
private byte[]? GetCachedRawGroup(uint groupFileIdx,
WiaRvzCompressionType comp, byte[] compData, byte compDataSize, uint chunkSize)
WiaRvzCompressionType comp,
byte[] compData,
byte compDataSize,
uint chunkSize)
{
if (_cachedRawGroupIndex == groupFileIdx)
return _cachedRawGroup;
@@ -611,15 +654,30 @@ namespace SabreTools.Wrappers
return group;
}
private byte[]? GetCachedEncGroup(uint groupFileIdx, PartitionDataEntry de, byte[] partitionKey,
WiaRvzCompressionType comp, byte[] compData, byte compDataSize, int blocksPerGroup, uint chunkSize)
/// <summary>
///
/// </summary>
/// <param name="groupFileIdx"></param>
/// <param name="de"></param>
/// <param name="partitionKey"></param>
/// <param name="comp"></param>
/// <param name="compData"></param>
/// <param name="compDataSize"></param>
/// <param name="blocksPerGroup"></param>
/// <returns></returns>
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);
byte[]? decrypted = ReadDecryptedGroupData(groupFileIdx, comp, compData, compDataSize, blocksPerGroup, 0x7C00, dataOffsetForLfg);
if (decrypted is null)
return null;
@@ -631,75 +689,128 @@ namespace SabreTools.Wrappers
/// <summary>
/// Reads and decompresses one raw (non-partition) group.
/// Returns <c>chunkSize</c> bytes of raw ISO data, or null on failure.
/// Returns chunkSize bytes of raw ISO data, or null on failure.
/// </summary>
private byte[]? ReadGroupRaw(uint groupIdx, WiaRvzCompressionType comp,
byte[] compressorData, byte compressorDataSize, uint chunkSize)
private byte[]? ReadGroupRaw(uint groupIdx,
WiaRvzCompressionType comp,
byte[] compressorData,
byte compressorDataSize,
uint chunkSize)
{
if (IsRvz)
{
if (Model.RvzGroupEntries is null || groupIdx >= Model.RvzGroupEntries.Length)
if (RvzGroupEntries is null || groupIdx >= RvzGroupEntries.Length)
return null;
var ge = Model.RvzGroupEntries[groupIdx];
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, (int)dataSize);
return DecompressGroupBytes(fileData, 0, (int)dataSize, comp,
compressorData, compressorDataSize, (int)chunkSize, IsRvz, isRvzCompressed,
ge.RvzPackedSize, groupIdx * chunkSize, false, 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 (Model.GroupEntries is null || groupIdx >= Model.GroupEntries.Length)
if (GroupEntries is null || groupIdx >= GroupEntries.Length)
return null;
var ge = Model.GroupEntries[groupIdx];
var ge = GroupEntries[groupIdx];
if (ge.DataSize == 0)
return new byte[chunkSize];
byte[] fileData = ReadRangeFromSource((long)ge.DataOffset, (int)ge.DataSize);
return DecompressGroupBytes(fileData, 0, (int)ge.DataSize, comp,
compressorData, compressorDataSize, (int)chunkSize, false, false,
0, 0L, false, 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);
}
}
/// <summary>
/// Reads and decompresses a Wii partition group, returning the hash-stripped decrypted data.
/// </summary>
private byte[]? ReadDecryptedGroupData(uint groupIdx, WiaRvzCompressionType comp,
byte[] compressorData, byte compressorDataSize, int blocksPerGroup, int blockDataSize,
private byte[]? ReadDecryptedGroupData(uint groupIdx,
WiaRvzCompressionType comp,
byte[] compressorData,
byte compressorDataSize,
int blocksPerGroup,
int blockDataSize,
long dataOffsetForLfg)
{
int decryptedGroupSize = blocksPerGroup * blockDataSize;
if (IsRvz)
{
if (Model.RvzGroupEntries is null || groupIdx >= Model.RvzGroupEntries.Length)
if (RvzGroupEntries is null || groupIdx >= RvzGroupEntries.Length)
return null;
var ge = Model.RvzGroupEntries[groupIdx];
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, (int)dataSize);
return DecompressGroupBytes(fileData, 0, (int)dataSize, comp,
compressorData, compressorDataSize, decryptedGroupSize, IsRvz, isRvzCompressed,
ge.RvzPackedSize, dataOffsetForLfg, true,
Model.Header2.ChunkSize);
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 (Model.GroupEntries is null || groupIdx >= Model.GroupEntries.Length)
if (GroupEntries is null || groupIdx >= GroupEntries.Length)
return null;
var ge = Model.GroupEntries[groupIdx];
var ge = GroupEntries[groupIdx];
if (ge.DataSize == 0)
return new byte[decryptedGroupSize];
byte[] fileData2 = ReadRangeFromSource((long)ge.DataOffset, (int)ge.DataSize);
return DecompressGroupBytes(fileData2, 0, (int)ge.DataSize, comp,
compressorData, compressorDataSize, decryptedGroupSize, false, false,
0, 0L, true,
Model.Header2.ChunkSize);
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);
}
}
@@ -707,10 +818,18 @@ namespace SabreTools.Wrappers
/// Decompresses raw group bytes according to the WIA compression type and strips any
/// exception-list header, returning the plain data payload.
/// </summary>
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,
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)
@@ -734,10 +853,10 @@ namespace SabreTools.Wrappers
// 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 != null)
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 PurgeDecompressor.Decompress(fileData, purgeStart, purgeLen, expectedSize, exceptionBytes);
}
@@ -785,6 +904,7 @@ namespace SabreTools.Wrappers
int bytesRead = rvzDecomp.Decompress(unpacked, 0, expectedSize);
if (bytesRead < expectedSize)
Array.Resize(ref unpacked, bytesRead);
return unpacked;
}
@@ -820,8 +940,10 @@ namespace SabreTools.Wrappers
{
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;
@@ -856,13 +978,10 @@ namespace SabreTools.Wrappers
/// </summary>
internal static byte[] EncryptWiiGroup(byte[] decryptedData, byte[] key, int blocksPerGroup)
{
const int WiiBlockSize = 0x8000;
const int WiiBlockDataSize = 0x7C00;
const int WiiBlockHashSize = 0x0400;
const int H0Count = 31;
const int H1Count = 8;
const int H2Count = 8;
const int HashLen = 20;
const int HashLen = 20;
// --- Build H0 / H1 / H2 hash arrays ---
byte[][][] h0 = new byte[blocksPerGroup][][];
@@ -894,7 +1013,10 @@ namespace SabreTools.Wrappers
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);
}
}
@@ -906,7 +1028,10 @@ namespace SabreTools.Wrappers
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);
}
@@ -919,7 +1044,11 @@ namespace SabreTools.Wrappers
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; }
for (int i = 0; i < H0Count; i++)
{
Array.Copy(h0[b][i], 0, hashBlock, off, HashLen);
off += HashLen;
}
off += 0x14; // padding0
@@ -927,7 +1056,11 @@ namespace SabreTools.Wrappers
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; }
for (int i = 0; i < H1Count; i++)
{
Array.Copy(h1[h1Grp][i], 0, hashBlock, off, HashLen);
off += HashLen;
}
}
else
{
@@ -964,16 +1097,19 @@ namespace SabreTools.Wrappers
return result;
}
private static byte[] ComputeSha1(byte[] data, int offset, int count)
{
if (count == 0)
return new byte[20];
/// <summary>
/// Get a segmented SHA-1 hash for input data
/// </summary>
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];
}
using var sha1 = new HashWrapper(HashType.SHA1);
sha1.Process(data, offset, count);
sha1.Terminate();
return sha1.CurrentHashBytes ?? new byte[20];
}
#endregion
}