Files
SabreTools.Serialization/SabreTools.Serialization.Readers/NintendoDisc.cs
Dimensional 49aa6895b6 Dolphin lib (#85)
* Add GCZ, WIA/RVZ, and NintendoDisc (GameCube/Wii) format support

Port of DolphinIsoLib into SabreTools.Serialization architecture:
- Data.Models: GCZ/, WIA/, NintendoDisc/ model subdirectories (15 files)
- Serialization.Readers: GCZ, WIA, NintendoDisc readers
- Serialization.Writers: GCZ, WIA writers (structural metadata; full round-trip TODO)
- Wrappers: NintendoDisc, GCZ, WIA wrappers with Encryption partial class
- Wrappers: WiaRvzCompressionHelper (BZip2/LZMA/LZMA2/Zstd, net462+ guarded)
- WrapperType + WrapperFactory: GCZ, WIA, NintendoDisc entries added

GetInnerWrapper() decompression and NintendoDisc.Extraction FST extraction
are stubbed with TODO comments pending full implementation.

* Implement GetInnerWrapper for GCZ and WIA, full NintendoDisc extraction

* Add GCZ/WIA/RVZ write pipeline and Nintendo disc compression helpers

* Add WIA/RVZ table decompression, NintendoDisc/GCZ printing, NintendoDisc detection for .iso files

* Fix NintendoDisc header layout, GC magic, and add embedded disc header to WIA/GCZ printing

- Fix GCMagicWord: 0xC23D3C1F -> 0xC2339F3D (confirmed from Dolphin DiscUtils.h)
- Fix disc header field layout to match Dolphin's confirmed offsets:
  MakerCode is bytes 4-5 of the 6-char GameId (no separate field at 0x006),
  DiscNumber at 0x006, DiscVersion at 0x007, unused region is 14 bytes (0x00A-0x017)
- Update NintendoDisc reader: derive MakerCode from GameId[4..5], fix skip count
- Add ParseDiscHeaderOnly() to reader for partial (short) stream parsing
- Guard DisableHash/DisableEnc reads at the 0x080 boundary for 128-byte embedded headers
- Guard DOL/FST skip for streams shorter than full 0x440 boot block
- Fix WrapperFactory: NintendoDisc magic detection now precedes .iso -> ISO9660 fallback
- Add GameId-prefix heuristic in WrapperFactory for GC discs lacking magic word
- Add GameId-prefix platform fallback in reader for GC discs without GCMagicWord
- Add DiscHeader property to WIA wrapper (parsed from Header2.DiscHeader bytes)
- Add DiscHeader property to GCZ wrapper (decompresses first block only)
- Add ReadDiscHeader() helper to GCZ for lightweight first-block decompression
- Print embedded disc header (Game ID, Maker, Disc/Rev, Title) in WIA.Printing.cs and GCZ.Printing.cs

* Fix Wii partition extraction: correct IV, FST size shift, partition naming

- Block decryption: IV is at raw block offset 0x3D0 (still-encrypted),
  matching Dolphin/DolphinIsoLib WiiPartitionDecryptor.DecryptBlock exactly.
- FST size field at boot.bin 0x428 is also stored >>2 on Wii; apply <<2
  to get true byte size.
- Partition folder naming now matches DolphinIsoLib WiiDiscExtractor exactly:
  type 0->GM+n, 1->UP+n, 2->CH+n, printable ASCII unknown->raw 4-char string,
  non-printable->P{index}. SSBB VC channels extract as HA8E, HA9E, etc.
- ExtractionTool peek buffer increased from 16 to 32 bytes.

Verified: SSBB GM0 extracts 5524 files, boot.bin/fst.bin byte-identical
to Dolphin reference extraction.

* Fix FST extraction: create zero-byte files instead of skipping them

Files with fileSize=0 in the FST were silently skipped. Now they are
created as empty files, matching Dolphin/DolphinIsoLib behavior.

Verified: SSBB now extracts 5958 files with 0 missing, 0 extra,
0 size mismatches, and 0 hash mismatches vs DolphinIsoLib reference.

* Add GCZ/WIA/RVZ virtual stream extraction via NintendoDisc wrapper

* Address PR #85 review comments (Copilot + mnadareski)

* Address PR #85 review comments

* Replace custom endian helpers and SHA1 with SabreTools.IO equivalents

* Update GCZ.Printing.cs

* Update NintendoDisc.Printing.cs

* Update WIA.Printing.cs

* Add WIA/RVZ Wii partition crypto round-trip support

- Add AesCbc internal helper (BouncyCastle AES-CBC encrypt/decrypt)
- Add NintendoDisc.CommonKeyProvider hook for injectable test keys
- Fix sha1.Terminate() missing in all three ComputeSha1 helpers in WIA.cs
- Fix Wii partition dataOff alignment to 0x8000 boundary
- Add WIA.EncryptWiiGroup (internal) for re-encrypting plaintext groups
- Add WIA.DumpIso to WIA.Writing.cs (WIA/RVZ -> flat ISO conversion)
- Add WiaVirtualStream on-demand group decompression
- Add _preDecryptedReader bypass on NintendoDisc for WIA extraction path
- Add WIATests.cs with Wii crypto round-trip test using synthetic data
- Move DumpIso from WIA.Extraction.cs to WIA.Writing.cs
- Bump DumpIso read buffer from 1 MiB to 2 MiB (aligns to WIA chunk size)
- Add InternalsVisibleTo SabreTools.Wrappers.Test in csproj

* Remove hardcoded Wii common keys from NintendoDisc.Encryption

- Delete the embedded WiiCommonKeyRetail and WiiCommonKeyKorean byte
  arrays from NintendoDisc.Encryption.cs.
- Make CommonKeyProvider public so any caller (not just tests) can
  inject keys; DecryptTitleKey now returns null when no key is
  available for the requested index rather than falling back to
  hardcoded values.
- Add NintendoDiscEncryptionTests.cs:
    - Argument guard and no-provider tests for DecryptTitleKey.
    - Fake-key round-trip test (encrypt then decrypt with injected key).
    - Integration test that reads TestData/NintendoDisc/keys.json,
      verifies each key against hardcoded SHA256 constants, and skips
      silently if the file is absent or the keys do not match.
    - LoadKeyProvider helper (named JSON format, index-keyed).
- Add [Collection(NintendoDisc)] to both NintendoDiscEncryptionTests
  and WIATests to prevent parallel access to the static
  CommonKeyProvider from racing between test classes.
- Add TestData/NintendoDisc/keys.json.example documenting the
  expected key file format.
- Add Newtonsoft.Json reference to SabreTools.Wrappers.Test.csproj.

* Didn't actually commit the changes. My bad. Fixed.

* Edited a comment

* Added in XUnit outputs that show up in Test Viewer in VS

---------

Co-authored-by: Matt Nadareski <mnadareski@outlook.com>
2026-05-12 10:41:32 -04:00

206 lines
7.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
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 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;
// 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 0x00A0x017)
data.ReadBytes(0x0E);
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;
}
#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.
/// </summary>
private static bool IsGameCubeTitleType(char c)
{
return c == 'G' || c == 'D' || c == 'R';
}
}
}