Files
SabreTools.Serialization/SabreTools.Wrappers/NintendoDisc.Extraction.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

542 lines
22 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;
using System.IO;
using SabreTools.Data.Models.NintendoDisc;
using SabreTools.Numerics.Extensions;
namespace SabreTools.Wrappers
{
public partial class NintendoDisc : IExtractable
{
/// <inheritdoc/>
public bool Extract(string outputDirectory, bool includeDebug)
{
if (string.IsNullOrEmpty(outputDirectory))
return false;
try
{
Directory.CreateDirectory(outputDirectory);
if (Model.Platform == Platform.GameCube)
return ExtractGameCube(outputDirectory);
else if (Model.Platform == Platform.Wii)
return ExtractWii(outputDirectory);
return false;
}
catch (Exception ex)
{
if (includeDebug)
Console.Error.WriteLine(ex);
return false;
}
}
#region GameCube extraction
private bool ExtractGameCube(string dest)
{
string sysDir = Path.Combine(dest, "sys");
Directory.CreateDirectory(sysDir);
// sys/boot.bin (disc header, 0x000 0x43F)
WriteRange(0, Constants.DiscHeaderSize, Path.Combine(sysDir, "boot.bin"));
// sys/bi2.bin (0x440 0x243F)
WriteRange(Constants.Bi2Address, Constants.Bi2Size, Path.Combine(sysDir, "bi2.bin"));
// sys/apploader.img
WriteApploader(sysDir);
// DOL offset stored without shift on GameCube
long dolOffset = Model.Header.DolOffset;
if (dolOffset > 0)
{
byte[]? dolHeader = ReadDisc(dolOffset, 0xE0);
if (dolHeader != null)
{
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;
if (fstOffset > 0 && fstSize > 0)
{
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)
{
string filesDir = Path.Combine(dest, "files");
Directory.CreateDirectory(filesDir);
ExtractFstFiles(fstData, offsetShift: 0, filesDir, ReadDisc);
}
}
return true;
}
#endregion
#region Wii extraction
private bool ExtractWii(string dest)
{
// Unencrypted disc header area
string discDir = Path.Combine(dest, "disc");
Directory.CreateDirectory(discDir);
WriteRange(0, 0x100, Path.Combine(discDir, "header.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>();
foreach (var pte in Model.PartitionTableEntries)
{
long partOffset = pte.Offset;
if (partOffset <= 0 || partOffset >= _dataSource.Length)
continue;
string partName = GetPartitionName(pte.Type, typeCounters);
string partDir = Path.Combine(dest, partName);
Directory.CreateDirectory(partDir);
ExtractWiiPartition(partOffset, partDir);
}
return true;
}
private void ExtractWiiPartition(long partOffset, string partDir)
{
// ticket.bin (unencrypted, 0x2A4 bytes at partition start)
WriteRange(partOffset, Constants.WiiTicketSize, Path.Combine(partDir, "ticket.bin"));
byte[]? ticketData = ReadDisc(partOffset, Constants.WiiTicketSize);
if (ticketData is null || ticketData.Length < Constants.TicketCommonKeyIndexOffset + 1)
return;
// Decrypt title key
byte[] encTitleKey = new byte[16];
Array.Copy(ticketData, Constants.TicketEncryptedTitleKeyOffset, encTitleKey, 0, 16);
byte[] titleId = new byte[8];
Array.Copy(ticketData, Constants.TicketTitleIdOffset, titleId, 0, 8);
byte commonKeyIdx = ticketData[Constants.TicketCommonKeyIndexOffset];
byte[]? titleKey = DecryptTitleKey(encTitleKey, titleId, commonKeyIdx);
if (titleKey is null)
return;
// TMD
byte[]? tmdSizeBytes = ReadDisc(partOffset + Constants.WiiTmdSizeAddress, 4);
int tmdSizePos = 0;
uint tmdSize = tmdSizeBytes != null
? tmdSizeBytes.ReadUInt32BigEndian(ref tmdSizePos)
: 0;
byte[]? tmdOffBytes = ReadDisc(partOffset + Constants.WiiTmdOffsetAddress, 4);
int tmdOffPos = 0;
uint tmdOffShifted = tmdOffBytes != null
? 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"));
// cert.bin
byte[]? certSizeBytes = ReadDisc(partOffset + Constants.WiiCertSizeAddress, 4);
int certSizePos = 0;
uint certSize = certSizeBytes != null
? certSizeBytes.ReadUInt32BigEndian(ref certSizePos)
: 0;
byte[]? certOffBytes = ReadDisc(partOffset + Constants.WiiCertOffsetAddress, 4);
int certOffPos = 0;
uint certOffShifted = certOffBytes != null
? 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"));
// h3.bin
byte[]? h3OffBytes = ReadDisc(partOffset + Constants.WiiH3OffsetAddress, 4);
int h3OffPos = 0;
uint h3OffShifted = h3OffBytes != null
? h3OffBytes.ReadUInt32BigEndian(ref h3OffPos)
: 0;
long h3Offset = (long)h3OffShifted << 2;
if (h3Offset > 0)
WriteRange(partOffset + h3Offset, Constants.WiiH3Size, Path.Combine(partDir, "h3.bin"));
// Encrypted partition data start
byte[]? dataOffBytes = ReadDisc(partOffset + Constants.WiiDataOffsetAddress, 4);
int dataOffPos = 0;
uint dataOffShifted = dataOffBytes != null
? dataOffBytes.ReadUInt32BigEndian(ref dataOffPos)
: 0;
long dataOffset = (long)dataOffShifted << 2;
if (dataOffset <= 0)
return;
long absDataOffset = partOffset + dataOffset;
string sysDir = Path.Combine(partDir, "sys");
Directory.CreateDirectory(sysDir);
// Read boot block from decrypted partition (block 0, offset 0 within data)
byte[]? bootBlock = ReadDecryptedPartitionRange(absDataOffset, titleKey, 0, Constants.DiscHeaderSize);
if (bootBlock is null)
return;
File.WriteAllBytes(Path.Combine(sysDir, "boot.bin"), bootBlock);
// bi2.bin
byte[]? bi2 = ReadDecryptedPartitionRange(absDataOffset, titleKey,
Constants.Bi2Address, Constants.Bi2Size);
if (bi2 != null)
File.WriteAllBytes(Path.Combine(sysDir, "bi2.bin"), bi2);
// apploader
WriteWiiApploader(absDataOffset, titleKey, sysDir);
// 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)
{
int dolSize = GetDolSize(dolHdr);
byte[]? dol = ReadDecryptedPartitionRange(absDataOffset, titleKey, dolOff, dolSize);
if (dol != null)
File.WriteAllBytes(Path.Combine(sysDir, "main.dol"), dol);
}
}
// 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;
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)
{
File.WriteAllBytes(Path.Combine(sysDir, "fst.bin"), fstData);
string filesDir = Path.Combine(partDir, "files");
Directory.CreateDirectory(filesDir);
ExtractFstFiles(fstData, offsetShift: 2, filesDir,
(offset, length) => ReadDecryptedPartitionRange(absDataOffset, titleKey, offset, length));
}
}
}
#endregion
#region FST extraction
private void ExtractFstFiles(byte[] fstData, int offsetShift, string filesDir,
Func<long, int, byte[]?> readFunc)
{
if (fstData is null || fstData.Length < 12)
return;
// Root entry is at offset 0; its fileSize field = total entry count
int rootPos = 8;
uint rootCount = fstData.ReadUInt32BigEndian(ref rootPos);
if (rootCount < 1 || rootCount > 1024 * 1024)
return;
// String table immediately follows all entries
long stringTableOffset = rootCount * 12;
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,
Func<long, int, byte[]?> readFunc)
{
int i = start;
while (i < end)
{
int fstBase = i * 12;
if ((fstBase + 12) > fstData.Length)
break;
// 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);
string name = ReadFstString(fstData, stringTableOffset + nameOff);
if (string.IsNullOrEmpty(name))
{
i++;
continue;
}
// Sanitize name: replace path separators and reject/flatten dot-segments
name = name.Replace('/', '_').Replace('\\', '_');
if (name == "." || name == "..")
name = "_";
name = name.TrimStart('.');
if (isDir)
{
// fileOffRaw = parent entry index; fileSize = last entry index in this dir
int nextEntry = (int)fileSize;
string subDir = Path.Combine(currentDir, name);
Directory.CreateDirectory(subDir);
i = ExtractFstDirectory(fstData, i + 1, nextEntry, stringTableOffset,
subDir, offsetShift, readFunc);
}
else
{
string outPath = Path.Combine(currentDir, name);
string? outDir = Path.GetDirectoryName(outPath);
if (!string.IsNullOrEmpty(outDir))
Directory.CreateDirectory(outDir);
if (fileSize == 0)
{
// Zero-byte file — create empty
File.WriteAllBytes(outPath, new byte[0]);
}
else
{
long discOffset = (long)fileOffRaw << offsetShift;
byte[]? fileData = readFunc(discOffset, (int)Math.Min(fileSize, int.MaxValue));
if (fileData != null)
File.WriteAllBytes(outPath, fileData);
}
i++;
}
}
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
private void WriteApploader(string sysDir)
{
byte[]? hdr = ReadDisc(Constants.ApploaderAddress, Constants.ApploaderHeaderSize);
if (hdr is null) return;
int codeSizePos = Constants.ApploaderCodeSizeOffset;
int trailerSizePos = Constants.ApploaderTrailerSizeOffset;
uint codeSize = hdr.ReadUInt32BigEndian(ref codeSizePos);
uint trailerSize = hdr.ReadUInt32BigEndian(ref trailerSizePos);
int totalSize = Constants.ApploaderHeaderSize + (int)codeSize + (int)trailerSize;
WriteRange(Constants.ApploaderAddress, totalSize, Path.Combine(sysDir, "apploader.img"));
}
private void WriteWiiApploader(long absDataOffset, byte[] titleKey, string sysDir)
{
byte[]? hdr = ReadDecryptedPartitionRange(absDataOffset, titleKey,
Constants.ApploaderAddress, Constants.ApploaderHeaderSize);
if (hdr is null) return;
int wiiCodeSizePos = Constants.ApploaderCodeSizeOffset;
int wiiTrailerSizePos = Constants.ApploaderTrailerSizeOffset;
uint codeSize = hdr.ReadUInt32BigEndian(ref wiiCodeSizePos);
uint trailerSize = hdr.ReadUInt32BigEndian(ref wiiTrailerSizePos);
int totalSize = Constants.ApploaderHeaderSize + (int)codeSize + (int)trailerSize;
byte[]? apploader = ReadDecryptedPartitionRange(absDataOffset, titleKey,
Constants.ApploaderAddress, totalSize);
if (apploader != null)
File.WriteAllBytes(Path.Combine(sysDir, "apploader.img"), apploader);
}
#endregion
#region DOL size calculation
private static int GetDolSize(byte[] 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)
return 0;
int maxEnd = 0;
// Text sections (7): offset table at 0x00, size table at 0x90
for (int s = 0; s < 7; s++)
{
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);
}
return maxEnd;
}
#endregion
#region Wii partition block decryption helpers
/// <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)
{
if (length <= 0) return null;
// WIA/RVZ fast path: data is already decrypted; skip the AES round-trip.
if (_preDecryptedReader is not null)
return _preDecryptedReader(absDataOffset, partitionDataOffset, length);
var result = new byte[length];
int produced = 0;
while (produced < length)
{
long dataOff = partitionDataOffset + produced;
long blockNum = 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)
break;
// IV is at offset 0x3D0 of the raw (still-encrypted) block.
// Matches Dolphin / DolphinIsoLib WiiPartitionDecryptor.DecryptBlock.
byte[] iv = new byte[16];
Array.Copy(encBlock, 0x3D0, iv, 0, 16);
// Decrypt the 0x7C00 data portion (bytes 0x4000x7FFF of the raw block)
byte[] encData = new byte[Constants.WiiBlockDataSize];
Array.Copy(encBlock, Constants.WiiBlockHeaderSize, encData, 0, Constants.WiiBlockDataSize);
byte[]? decData = DecryptBlock(encData, titleKey, iv);
if (decData is null)
break;
int canCopy = Math.Min(Constants.WiiBlockDataSize - offsetInBlock, length - produced);
Array.Copy(decData, offsetInBlock, result, produced, canCopy);
produced += canCopy;
}
return produced == length ? result : null;
}
#endregion
#region Misc helpers
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);
}
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;
}
/// <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"/>.
/// 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}";
}
#endregion
}
}