Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d944d50
Add GCZ, WIA/RVZ, and NintendoDisc (GameCube/Wii) format support
Dimensional Apr 17, 2026
2915d24
Implement GetInnerWrapper for GCZ and WIA, full NintendoDisc extraction
Dimensional Apr 17, 2026
d598a7e
Add GCZ/WIA/RVZ write pipeline and Nintendo disc compression helpers
Dimensional Apr 17, 2026
c96fa2b
Add WIA/RVZ table decompression, NintendoDisc/GCZ printing, NintendoD…
Dimensional Apr 17, 2026
242c0f7
Fix NintendoDisc header layout, GC magic, and add embedded disc heade…
Dimensional Apr 17, 2026
53b9366
Fix Wii partition extraction: correct IV, FST size shift, partition n…
Dimensional Apr 18, 2026
c620499
Fix FST extraction: create zero-byte files instead of skipping them
Dimensional Apr 18, 2026
a6d1093
Add GCZ/WIA/RVZ virtual stream extraction via NintendoDisc wrapper
Dimensional Apr 18, 2026
d85e97c
Address PR #85 review comments (Copilot + mnadareski)
Dimensional Apr 19, 2026
51d8a87
Address PR #85 review comments
Dimensional Apr 19, 2026
5217f41
Replace custom endian helpers and SHA1 with SabreTools.IO equivalents
Dimensional Apr 22, 2026
a9cc96b
Merge branch 'main' into DolphinLib
mnadareski May 4, 2026
9cd62d9
Update GCZ.Printing.cs
mnadareski May 4, 2026
c6f477e
Update NintendoDisc.Printing.cs
mnadareski May 4, 2026
9667392
Update WIA.Printing.cs
mnadareski May 4, 2026
05bf4ed
Add WIA/RVZ Wii partition crypto round-trip support
Dimensional Apr 23, 2026
095d6ef
Merge branch 'DolphinLib' of https://github.com/Dimensional/SabreTool…
Dimensional May 4, 2026
a014f5e
Remove hardcoded Wii common keys from NintendoDisc.Encryption
Dimensional May 11, 2026
42ff5eb
Didn't actually commit the changes. My bad. Fixed.
Dimensional May 11, 2026
08fc523
Edited a comment
Dimensional May 12, 2026
a30e32d
Added in XUnit outputs that show up in Test Viewer in VS
Dimensional May 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ExtractionTool/Features/MainFeature.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ private void ExtractFile(string file)
Console.WriteLine($"Attempting to extract all files from {file}");
using Stream stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);

// Read the first 16 bytes
byte[] magic = stream.PeekBytes(16);
// Read the first 32 bytes
byte[] magic = stream.PeekBytes(32);

// Get the file type
string extension = Path.GetExtension(file).TrimStart('.');
Expand Down
4 changes: 2 additions & 2 deletions InfoPrint/Features/MainFeature.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ private void PrintFileInfo(string file)
{
using Stream stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);

// Read the first 16 bytes
byte[] magic = stream.PeekBytes(16);
// Read the first 32 bytes
byte[] magic = stream.PeekBytes(32);

// Get the file type
string extension = Path.GetExtension(file).TrimStart('.');
Expand Down
11 changes: 2 additions & 9 deletions SabreTools.Data.Models/GCZ/Archive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,9 @@ public class DiscImage
public ulong[] BlockPointers { get; set; } = [];

/// <summary>
/// Adler-32 (stored as CRC32) hashes of the uncompressed block data,
/// one per block. Used for integrity verification.
/// Adler-32 checksums of the uncompressed block data, one per block.
/// Used for integrity verification after decompression.
/// </summary>
public uint[] BlockHashes { get; set; } = [];

/// <summary>
/// Byte offset within the GCZ file where the compressed block data begins.
/// Computed as: <c>HeaderSize + (NumBlocks * 8) + (NumBlocks * 4)</c>.
/// </summary>
/// <remarks>Not parsed from stream; computed during deserialization.</remarks>
public long DataOffset { get; set; }
}
}
2 changes: 1 addition & 1 deletion SabreTools.Data.Models/NintendoDisc/WiiRegionData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace SabreTools.Data.Models.NintendoDisc
public sealed class WiiRegionData
{
/// <summary>
/// Region setting byte:
/// Region setting uint:
/// 0 = Japan, 1 = USA, 2 = Europe, 3 = Korea,
/// 4 = China, 5 = Taiwan, 6 = Germany, 7 = France
/// </summary>
Expand Down
9 changes: 2 additions & 7 deletions SabreTools.Data.Models/WIA/Archive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,13 @@ public class DiscImage
public RawDataEntry[] RawDataEntries { get; set; } = [];

/// <summary>
/// WIA group entries (populated when <see cref="IsRvz"/> is false)
/// WIA group entries (populated for WIA files)
/// </summary>
public WiaGroupEntry[]? GroupEntries { get; set; }

/// <summary>
/// RVZ group entries (populated when <see cref="IsRvz"/> is true)
/// RVZ group entries (populated for RVZ files)
/// </summary>
public RvzGroupEntry[]? RvzGroupEntries { get; set; }

/// <summary>
/// True if this is an RVZ file; false if this is a WIA file
/// </summary>
public bool IsRvz { get; set; }
}
}
71 changes: 71 additions & 0 deletions SabreTools.Serialization.Readers/GCZ.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.IO;
using SabreTools.Data.Models.GCZ;
using SabreTools.IO.Extensions;
using SabreTools.Numerics.Extensions;

#pragma warning disable IDE0017 // Simplify object initialization
namespace SabreTools.Serialization.Readers
{
public class GCZ : BaseBinaryReader<DiscImage>
{
/// <inheritdoc/>
public override DiscImage? Deserialize(Stream? data)
{
// If the data is invalid
if (data is null || !data.CanRead)
return null;

// Need at least the header
if (data.Length - data.Position < Constants.HeaderSize)
return null;

try
{
long initialOffset = data.Position;

var archive = new DiscImage();

// Parse the header
archive.Header = ParseGczHeader(data);
if (archive.Header.MagicCookie != Constants.MagicCookie)
return null;

// Validate block count — guard against absurdly large tables
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are large tables valid? Or is there a known cutoff? I see you're using 0x100000 so knowing where that comes from would be neat.

if (archive.Header.NumBlocks == 0 || archive.Header.NumBlocks > 0x100000)
return null;

int numBlocks = (int)archive.Header.NumBlocks;

// Read block pointer table (8 bytes per block)
archive.BlockPointers = new ulong[numBlocks];
byte[] ptrBuf = data.ReadBytes(numBlocks * 8);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can take advantage of my ReadUInt64LittleEndian extension method in a loop instead of reading the entire block and then using ToUInt64, which is machine-dependent for endianness. This applies to any other places that you may be using BitConverter.

for (int i = 0; i < numBlocks; i++)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formatting nitpick: This applies to all for and while loops - even if the contents of the loop are a single statement, I still prefer that they use curly braces. if statements do not have this formatting quirk.

archive.BlockPointers[i] = System.BitConverter.ToUInt64(ptrBuf, i * 8);

// 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);

return archive;
}
catch
{
return null;
}
}

private static GczHeader ParseGczHeader(Stream data)
{
var header = new GczHeader();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicks that apply to multiple places, I just picked the first place:

  • For consistency, I prefer using obj as the name of the returned value in individual Parse methods. It makes them easier to read and is definitely a conventional thing rather than practical.
  • Please add newlines after the creation of the object and after the final Read (right before the return). This helps for readability.
  • I'd encourage you to copy-paste the summary from another Parse method in one of the existing readers
  • For reasons that would take too long to explain, I would prefer if you make Parse methods public unless it is something that requires additional information to be used.

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;
}
}
}
205 changes: 205 additions & 0 deletions SabreTools.Serialization.Readers/NintendoDisc.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
using System.IO;
using System.Text;
using SabreTools.Data.Models.NintendoDisc;
using SabreTools.IO.Extensions;
using SabreTools.Numerics.Extensions;

#pragma warning disable IDE0017 // Simplify object initialization
namespace SabreTools.Serialization.Readers
{
public class NintendoDisc : BaseBinaryReader<Disc>
{
/// <inheritdoc/>
public override Disc? Deserialize(Stream? data)
{
// If the data is invalid
if (data is null || !data.CanRead)
return null;

// Need at least the disc header
if (data.Length - data.Position < Constants.DiscHeaderSize)
return null;

try
{
long initialOffset = data.Position;

var disc = new Disc();

// Parse the disc header
disc.Header = ParseDiscHeader(data);

// 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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to GCZ, I mentioned in the models that this feels like a good thing to have as an extension property. With that said, I can see the utility for parsing here, so don't feel like you need to remove this logic from here entirely.

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;

// Parse Wii-specific structures
if (disc.Platform == Platform.Wii)
{
// Partition table starts at 0x40000
long partTableEnd = initialOffset + Constants.WiiPartitionTableAddress
+ (Constants.WiiPartitionGroupCount * 8);
if (data.Length >= partTableEnd)
disc.PartitionTableEntries = ParsePartitionTable(data, initialOffset);

// Region data at 0x4E000
long regionEnd = initialOffset + Constants.WiiRegionDataAddress + Constants.WiiRegionDataSize;
if (data.Length >= regionEnd)
{
data.Seek(initialOffset + Constants.WiiRegionDataAddress, SeekOrigin.Begin);
disc.RegionData = ParseRegionData(data);
}
}

return disc;
}
catch
{
return null;
}
}

#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.
/// </summary>
public static DiscHeader? ParseDiscHeaderOnly(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();

// 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');

// Maker code is the last 2 chars of the GameId (offsets 0x004–0x005).
// 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;

// 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();

// Skip unused 0x0E bytes (offsets 0x00A–0x017)
data.ReadBytes(0x0E);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pedantic: Padding and "unused" bytes tend to be read as well in the majority of situations. It may not be useful, but it provides a more accurate picture. This applies to any other places where you're using "empty" reads.


header.WiiMagic = data.ReadUInt32BigEndian();
header.GCMagic = data.ReadUInt32BigEndian();
Comment on lines +110 to +111
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has me a bit confused. Are there actually two distinct fields for Wii and GC magic values in the data? Or are these meant to be a single property that could be either the Wii or GC magic value?


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;
}

#endregion

#region Wii partition table parsing

private static WiiPartitionTableEntry[]? ParsePartitionTable(Stream data, long baseOffset)
{
data.Seek(baseOffset + Constants.WiiPartitionTableAddress, SeekOrigin.Begin);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally the parsing methods shouldn't have to be aware of moving to their own start locations. I would prefer if you do the seek outside of this method and let this code assume it's at the correct spot.


// Read 4 partition groups; each group has a count and a shifted offset
var allEntries = new System.Collections.Generic.List<WiiPartitionTableEntry>();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if you're only using things like List once in the code, it's perfectly fine to have it as a top-level using statement. The only times that this usually doesn't happen is if something is framework-gated or if there is a name conflict otherwise.


for (int g = 0; g < Constants.WiiPartitionGroupCount; g++)
{
uint count = data.ReadUInt32BigEndian();
uint shiftedOffset = data.ReadUInt32BigEndian();

if (count == 0)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this check be done directly after you read the count? Or are there always guaranteed to be the count and offset fields?

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();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: I used to do this as well where "nested" reads were just done directly. I currently prefer having separate Parse methods, even for these cases.

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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: I prefer the newer syntax for transforming collections

Suggested change
return allEntries.Count > 0 ? allEntries.ToArray() : null;
return allEntries.Count > 0 ? [.. allEntries] : 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.
/// </summary>
private static bool IsGameCubeTitleType(char c)
{
return c == 'G' || c == 'D' || c == 'R';
}
}
}
Loading