mirror of
https://github.com/SabreTools/SabreTools.Serialization.git
synced 2026-07-08 18:06:41 +00:00
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>
This commit is contained in:
@@ -118,8 +118,8 @@ namespace ExtractionTool.Features
|
||||
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('.');
|
||||
|
||||
@@ -150,8 +150,8 @@ namespace InfoPrint.Features
|
||||
{
|
||||
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('.');
|
||||
|
||||
@@ -25,16 +25,9 @@ namespace SabreTools.Data.Models.GCZ
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,18 +28,13 @@ namespace SabreTools.Data.Models.WIA
|
||||
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
SabreTools.Serialization.Readers/GCZ.cs
Normal file
71
SabreTools.Serialization.Readers/GCZ.cs
Normal file
@@ -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
|
||||
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);
|
||||
for (int i = 0; i < numBlocks; i++)
|
||||
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();
|
||||
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
SabreTools.Serialization.Readers/NintendoDisc.cs
Normal file
205
SabreTools.Serialization.Readers/NintendoDisc.cs
Normal file
@@ -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)
|
||||
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);
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
194
SabreTools.Serialization.Readers/WIA.cs
Normal file
194
SabreTools.Serialization.Readers/WIA.cs
Normal file
@@ -0,0 +1,194 @@
|
||||
using System.IO;
|
||||
using SabreTools.Data.Models.WIA;
|
||||
using SabreTools.IO.Extensions;
|
||||
using SabreTools.Numerics.Extensions;
|
||||
|
||||
#pragma warning disable IDE0017 // Simplify object initialization
|
||||
namespace SabreTools.Serialization.Readers
|
||||
{
|
||||
public class WIA : 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 Header1
|
||||
if (data.Length - data.Position < Constants.Header1Size)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
long initialOffset = data.Position;
|
||||
|
||||
var archive = new DiscImage();
|
||||
|
||||
// Parse Header1
|
||||
archive.Header1 = ParseHeader1(data);
|
||||
|
||||
// Validate magic
|
||||
if (archive.Header1.Magic != Constants.WiaMagic && archive.Header1.Magic != Constants.RvzMagic)
|
||||
return null;
|
||||
|
||||
// Parse Header2
|
||||
archive.Header2 = ParseHeader2(data);
|
||||
|
||||
// Parse partition entries (Wii discs only)
|
||||
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);
|
||||
}
|
||||
|
||||
// Parse raw data entries
|
||||
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);
|
||||
}
|
||||
|
||||
// Parse group entries
|
||||
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);
|
||||
else
|
||||
archive.GroupEntries = ParseWiaGroupEntries(
|
||||
data, (int)archive.Header2.NumberOfGroupEntries);
|
||||
}
|
||||
|
||||
return archive;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#region Header parsing
|
||||
|
||||
private static WiaHeader1 ParseHeader1(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;
|
||||
}
|
||||
|
||||
private static WiaHeader2 ParseHeader2(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;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Table parsing
|
||||
|
||||
private static PartitionEntry[] ParsePartitionEntries(Stream data, int count)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static PartitionDataEntry ParsePartitionDataEntry(Stream data)
|
||||
{
|
||||
var e = new PartitionDataEntry();
|
||||
e.FirstSector = data.ReadUInt32BigEndian();
|
||||
e.NumberOfSectors = data.ReadUInt32BigEndian();
|
||||
e.GroupIndex = data.ReadUInt32BigEndian();
|
||||
e.NumberOfGroups = data.ReadUInt32BigEndian();
|
||||
return e;
|
||||
}
|
||||
|
||||
private static RawDataEntry[] ParseRawDataEntries(Stream data, int count)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static WiaGroupEntry[] ParseWiaGroupEntries(Stream data, int count)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static RvzGroupEntry[] ParseRvzGroupEntries(Stream data, int count)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
76
SabreTools.Serialization.Writers/GCZ.cs
Normal file
76
SabreTools.Serialization.Writers/GCZ.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.IO;
|
||||
using SabreTools.Data.Models.GCZ;
|
||||
using SabreTools.Numerics.Extensions;
|
||||
|
||||
namespace SabreTools.Serialization.Writers
|
||||
{
|
||||
// TODO: Full round-trip write (including compressed block data) requires a source
|
||||
// IBlobReader. This implementation serializes only the structural metadata
|
||||
// (header + block pointer table + block hash table) to a stream or file.
|
||||
public class GCZ : IFileWriter<DiscImage>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public bool Debug { get; set; } = false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool SerializeFile(DiscImage? obj, string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return false;
|
||||
|
||||
if (obj is null || !ValidateArchive(obj))
|
||||
return false;
|
||||
|
||||
using var fs = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
return SerializeStream(obj, fs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize the GCZ structural metadata (header + tables) to a stream.
|
||||
/// Writes: 32-byte header, block pointer table, block hash table.
|
||||
/// The caller is responsible for writing compressed block data afterward.
|
||||
/// </summary>
|
||||
public bool SerializeStream(DiscImage? obj, Stream? stream)
|
||||
{
|
||||
if (stream is null || !stream.CanWrite)
|
||||
return false;
|
||||
|
||||
if (obj is null || !ValidateArchive(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);
|
||||
|
||||
// Block pointer table (8 bytes per block, little-endian)
|
||||
foreach (ulong ptr in obj.BlockPointers)
|
||||
stream.WriteLittleEndian(ptr);
|
||||
|
||||
// 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)
|
||||
{
|
||||
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)
|
||||
return false;
|
||||
if (obj.BlockHashes is null || obj.BlockHashes.Length != (int)obj.Header.NumBlocks)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
155
SabreTools.Serialization.Writers/WIA.cs
Normal file
155
SabreTools.Serialization.Writers/WIA.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System.IO;
|
||||
using SabreTools.Data.Models.WIA;
|
||||
using SabreTools.Numerics.Extensions;
|
||||
|
||||
namespace SabreTools.Serialization.Writers
|
||||
{
|
||||
// TODO: Full round-trip write (including compressed group data) requires a source
|
||||
// IBlobReader and compression pipeline. This implementation serializes only
|
||||
// the structural metadata (Header1, Header2, and all lookup tables).
|
||||
public class WIA : IFileWriter<DiscImage>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public bool Debug { get; set; } = false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool SerializeFile(DiscImage? obj, string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return false;
|
||||
|
||||
if (obj is null || !ValidateArchive(obj))
|
||||
return false;
|
||||
|
||||
using var fs = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
return SerializeStream(obj, fs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize the WIA / RVZ structural metadata to a stream.
|
||||
/// Writes Header1, Header2, partition entries, raw data entries, and group entries.
|
||||
/// The caller is responsible for writing group (compressed block) data.
|
||||
/// </summary>
|
||||
public bool SerializeStream(DiscImage? obj, Stream? stream)
|
||||
{
|
||||
if (stream is null || !stream.CanWrite)
|
||||
return false;
|
||||
|
||||
if (obj is null || !ValidateArchive(obj))
|
||||
return false;
|
||||
|
||||
WriteHeader1(stream, obj.Header1);
|
||||
WriteHeader2(stream, obj.Header2);
|
||||
|
||||
// Partition entries
|
||||
if (obj.PartitionEntries != 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)
|
||||
{
|
||||
foreach (var ge in obj.RvzGroupEntries)
|
||||
WriteRvzGroupEntry(stream, ge);
|
||||
}
|
||||
else if (obj.Header1.Magic != Constants.RvzMagic && obj.GroupEntries != null)
|
||||
{
|
||||
foreach (var ge in obj.GroupEntries)
|
||||
WriteWiaGroupEntry(stream, ge);
|
||||
}
|
||||
|
||||
stream.Flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ValidateArchive(DiscImage obj)
|
||||
{
|
||||
if (obj.Header1 is null || obj.Header2 is null)
|
||||
return false;
|
||||
if (obj.Header1.Magic != Constants.WiaMagic && obj.Header1.Magic != Constants.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
|
||||
}
|
||||
}
|
||||
238
SabreTools.Wrappers.Test/NintendoDiscEncryptionTests.cs
Normal file
238
SabreTools.Wrappers.Test/NintendoDiscEncryptionTests.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
375
SabreTools.Wrappers.Test/WIATests.cs
Normal file
375
SabreTools.Wrappers.Test/WIATests.cs
Normal file
@@ -0,0 +1,375 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using SabreTools.Numerics.Extensions;
|
||||
using Xunit;
|
||||
|
||||
namespace SabreTools.Wrappers.Test
|
||||
{
|
||||
[Collection("NintendoDisc")]
|
||||
public class WIATests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// WIA.Create null / invalid guards
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void NullArray_Null()
|
||||
{
|
||||
byte[]? data = null;
|
||||
int offset = 0;
|
||||
var actual = WIA.Create(data, offset);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyArray_Null()
|
||||
{
|
||||
byte[]? data = [];
|
||||
int offset = 0;
|
||||
var actual = WIA.Create(data, offset);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidArray_Null()
|
||||
{
|
||||
byte[]? data = [.. Enumerable.Repeat<byte>(0xFF, 1024)];
|
||||
int offset = 0;
|
||||
var actual = WIA.Create(data, offset);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullStream_Null()
|
||||
{
|
||||
Stream? data = null;
|
||||
var actual = WIA.Create(data);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyStream_Null()
|
||||
{
|
||||
Stream? data = new MemoryStream([]);
|
||||
var actual = WIA.Create(data);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidStream_Null()
|
||||
{
|
||||
Stream? data = new MemoryStream([.. Enumerable.Repeat<byte>(0xFF, 1024)]);
|
||||
var actual = WIA.Create(data);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DumpIso guard
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[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!));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// 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),
|
||||
/// reads it back through <see cref="WIA.DumpIso"/>, then decrypts every Wii data block in the
|
||||
/// dumped ISO using <see cref="NintendoDisc.DecryptBlock"/> and asserts the decrypted bytes
|
||||
/// match the original plaintext.
|
||||
///
|
||||
/// 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>
|
||||
///
|
||||
/// 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
|
||||
/// (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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Wii_WiaNoneRoundTrip_Succeeds()
|
||||
{
|
||||
NintendoDisc.CommonKeyProvider = _ => TestCommonKey;
|
||||
try
|
||||
{
|
||||
// ---- Build synthetic Wii ISO ----
|
||||
byte[] iso = BuildMinimalWiiIso(TestCommonKey);
|
||||
|
||||
// ---- NintendoDisc.Create must succeed ----
|
||||
var nd = NintendoDisc.Create(new MemoryStream(iso));
|
||||
Assert.NotNull(nd);
|
||||
Assert.NotNull(nd!.PartitionTableEntries);
|
||||
Assert.Equal(2, nd.PartitionTableEntries!.Length);
|
||||
|
||||
// ---- Compress to WIA (NONE, no lossy transforms) ----
|
||||
using var wiaMs = new MemoryStream();
|
||||
bool written = WIA.ConvertFromDiscToStream(nd, wiaMs,
|
||||
isRvz: false,
|
||||
compressionType: Data.Models.WIA.WiaRvzCompressionType.None,
|
||||
compressionLevel: 5,
|
||||
chunkSize: Data.Models.WIA.Constants.DefaultChunkSize,
|
||||
out Exception? writeEx);
|
||||
Assert.True(written,
|
||||
$"ConvertFromDiscToStream failed: {writeEx?.GetType().Name}: {writeEx?.Message}\n{writeEx?.StackTrace}");
|
||||
|
||||
// ---- Decompress back to ISO ----
|
||||
wiaMs.Position = 0;
|
||||
var wia = WIA.Create(wiaMs);
|
||||
Assert.NotNull(wia);
|
||||
|
||||
string tempIso = Path.GetTempFileName() + ".iso";
|
||||
try
|
||||
{
|
||||
bool dumped = wia!.DumpIso(tempIso);
|
||||
Assert.True(dumped, "DumpIso should succeed");
|
||||
|
||||
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]
|
||||
{
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
|
||||
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;
|
||||
byte[] plain1 = new byte[WiiBlocksPerGroup * WiiBlockDataSize];
|
||||
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, Partition1Data, plain1, titleKey,
|
||||
WiiBlocksPerGroup, WiiBlockSize, WiiBlockDataSize, partitionLabel: "Partition 1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempIso)) File.Delete(tempIso);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
NintendoDisc.CommonKeyProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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"/>.
|
||||
/// </summary>
|
||||
private static readonly byte[] TestCommonKey =
|
||||
{
|
||||
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xF0, 0x0D,
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal synthetic Wii ISO with 2 partitions (1 WiiGroup each), encrypted
|
||||
/// with <paramref name="commonKey"/>.
|
||||
/// </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]
|
||||
{
|
||||
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[] encTitleKey = EncryptTitleKeyIndependent(titleKey, titleId, commonKey);
|
||||
|
||||
byte[] plain0 = new byte[WiiGroupDataSize];
|
||||
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;
|
||||
|
||||
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';
|
||||
iso[4] = (byte)'0'; iso[5] = (byte)'1';
|
||||
iso[0x18] = 0x5D; iso[0x19] = 0x1C; iso[0x1A] = 0x9E; iso[0x1B] = 0xA3;
|
||||
|
||||
int off = (int)PartitionTableOffset;
|
||||
iso.WriteBigEndian(ref off, 2u);
|
||||
iso.WriteBigEndian(ref off, (uint)(PartitionListOffset >> 2));
|
||||
|
||||
off = (int)PartitionListOffset;
|
||||
iso.WriteBigEndian(ref off, (uint)(Partition0Offset >> 2));
|
||||
iso.WriteBigEndian(ref off, 0u);
|
||||
iso.WriteBigEndian(ref off, (uint)(Partition1Offset >> 2));
|
||||
iso.WriteBigEndian(ref off, 1u);
|
||||
|
||||
WritePartitionHeader(iso, Partition0Offset, encTitleKey, titleId, ckIdx: 0);
|
||||
WritePartitionHeader(iso, Partition1Offset, encTitleKey, titleId, ckIdx: 0);
|
||||
|
||||
Array.Copy(enc0, 0, iso, Partition0Data, enc0.Length);
|
||||
Array.Copy(enc1, 0, iso, Partition1Data, enc1.Length);
|
||||
|
||||
return iso;
|
||||
}
|
||||
|
||||
private static void WritePartitionHeader(byte[] iso, long partOffset,
|
||||
byte[] encTitleKey, byte[] titleId, byte ckIdx)
|
||||
{
|
||||
// Signature type 0x10001 at partOffset+0
|
||||
int off = (int)partOffset;
|
||||
iso.WriteBigEndian(ref off, 0x10001u);
|
||||
|
||||
// Encrypted title key at partOffset+0x1BF (16 bytes)
|
||||
Array.Copy(encTitleKey, 0, iso, partOffset + 0x1BF, 16);
|
||||
|
||||
// Title ID at partOffset+0x1DC (8 bytes)
|
||||
Array.Copy(titleId, 0, iso, partOffset + 0x1DC, 8);
|
||||
|
||||
// Common key index at partOffset+0x1F1
|
||||
iso[partOffset + 0x1F1] = ckIdx;
|
||||
|
||||
// Data offset at partOffset+0x2B8 (shifted >>2): data starts at +0x8000
|
||||
// 0x8000 >> 2 = 0x2000
|
||||
off = (int)(partOffset + 0x2B8);
|
||||
iso.WriteBigEndian(ref off, 0x2000u);
|
||||
|
||||
// Data size at partOffset+0x2BC (shifted >>2): exactly 1 WiiGroup = 0x200000
|
||||
// 0x200000 >> 2 = 0x80000
|
||||
iso.WriteBigEndian(ref off, 0x80000u);
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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,
|
||||
string partitionLabel)
|
||||
{
|
||||
for (int b = 0; b < blocksPerGroup; b++)
|
||||
{
|
||||
long blockOff = dataStart + ((long)b * blockSize);
|
||||
|
||||
// IV = bytes at offset 0x3D0 within the encrypted hash block
|
||||
byte[] iv = new byte[16];
|
||||
Array.Copy(iso, blockOff + 0x3D0, iv, 0, 16);
|
||||
|
||||
// Encrypted data block follows the 0x400-byte hash block
|
||||
byte[] encData = new byte[blockDataSize];
|
||||
Array.Copy(iso, blockOff + 0x400, encData, 0, blockDataSize);
|
||||
|
||||
byte[]? dec = NintendoDisc.DecryptBlock(encData, titleKey, iv);
|
||||
Assert.NotNull(dec);
|
||||
|
||||
// Compare against known plaintext slice
|
||||
int plainOff = b * blockDataSize;
|
||||
for (int i = 0; i < blockDataSize; i++)
|
||||
{
|
||||
if (dec![i] != expectedPlaintext[plainOff + i])
|
||||
Assert.Fail($"{partitionLabel} block {b} byte {i}: expected 0x{expectedPlaintext[plainOff + i]:X2}, got 0x{dec[i]:X2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts a Wii title key with the given <paramref name="commonKey"/> using
|
||||
/// <see cref="AesCbc.Encrypt"/>.
|
||||
/// </summary>
|
||||
private static byte[] EncryptTitleKeyIndependent(byte[] titleKey, byte[] titleId, byte[] commonKey)
|
||||
{
|
||||
byte[] iv = new byte[16];
|
||||
Array.Copy(titleId, 0, iv, 0, 8);
|
||||
return AesCbc.Encrypt(titleKey, commonKey, iv)
|
||||
?? throw new InvalidOperationException("AesCbc.Encrypt returned null");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
71
SabreTools.Wrappers/AesCbc.cs
Normal file
71
SabreTools.Wrappers/AesCbc.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Security;
|
||||
|
||||
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
|
||||
/// 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
|
||||
/// BouncyCastle using directives from this file.
|
||||
/// </remarks>
|
||||
internal static class AesCbc
|
||||
{
|
||||
/// <summary>
|
||||
/// Decrypts <paramref name="data"/> with AES-128-CBC (no padding).
|
||||
/// Returns null if any argument is invalid or decryption fails.
|
||||
/// </summary>
|
||||
/// <param name="data">Ciphertext to decrypt.</param>
|
||||
/// <param name="key">16-byte AES key.</param>
|
||||
/// <param name="iv">16-byte initialisation vector.</param>
|
||||
public static byte[]? Decrypt(byte[] data, byte[] key, byte[] iv)
|
||||
{
|
||||
// TODO: replace with AESCBC.Decrypt(data, key, iv) once
|
||||
// SabreTools.Security.Cryptography adds an AES-CBC wrapper.
|
||||
try
|
||||
{
|
||||
var cipher = CreateCipher(forEncryption: false, key, iv);
|
||||
return cipher.DoFinal(data);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts <paramref name="data"/> with AES-128-CBC (no padding).
|
||||
/// Returns null if any argument is invalid or encryption fails.
|
||||
/// </summary>
|
||||
/// <param name="data">Plaintext to encrypt.</param>
|
||||
/// <param name="key">16-byte AES key.</param>
|
||||
/// <param name="iv">16-byte initialisation vector.</param>
|
||||
public static byte[]? Encrypt(byte[] data, byte[] key, byte[] iv)
|
||||
{
|
||||
// TODO: replace with AESCBC.Encrypt(data, key, iv) once
|
||||
// SabreTools.Security.Cryptography adds an AES-CBC wrapper.
|
||||
try
|
||||
{
|
||||
var cipher = CreateCipher(forEncryption: true, key, iv);
|
||||
return cipher.DoFinal(data);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
SabreTools.Wrappers/GCZ.Extraction.cs
Normal file
13
SabreTools.Wrappers/GCZ.Extraction.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
public partial class GCZ : IExtractable
|
||||
{
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
SabreTools.Wrappers/GCZ.Printing.cs
Normal file
42
SabreTools.Wrappers/GCZ.Printing.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Text;
|
||||
using SabreTools.Text.Extensions;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
public partial class GCZ : IPrintable
|
||||
{
|
||||
#if NETCOREAPP
|
||||
/// <inheritdoc/>
|
||||
public string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions);
|
||||
#else
|
||||
/// <inheritdoc/>
|
||||
public string ExportJSON() => Newtonsoft.Json.JsonConvert.SerializeObject(Model, _jsonSerializerOptions);
|
||||
#endif
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void PrintInformation(StringBuilder builder)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
228
SabreTools.Wrappers/GCZ.Writing.cs
Normal file
228
SabreTools.Wrappers/GCZ.Writing.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using SabreTools.IO.Compression.Deflate;
|
||||
using System.IO;
|
||||
using SabreTools.Data.Models.GCZ;
|
||||
|
||||
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)
|
||||
{
|
||||
// Re-serialise the structural metadata (header + tables) only.
|
||||
// Full round-trip compression from an already-GCZ source requires ConvertFromDisc.
|
||||
if (string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
string outputFilename = Filename is null
|
||||
? (Guid.NewGuid().ToString() + ".gcz")
|
||||
: (Filename + ".new");
|
||||
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)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Write a GCZ image to <paramref name="destination"/> from a decompressed disc source.
|
||||
/// Matches Dolphin's CompressFileToBlob() in CompressedBlob.cpp.
|
||||
/// </summary>
|
||||
private static bool WriteGcz(NintendoDisc source, Stream destination, uint blockSize)
|
||||
{
|
||||
long sourceSize = source.DataLength;
|
||||
if (sourceSize <= 0)
|
||||
return false;
|
||||
|
||||
uint numBlocks = (uint)((sourceSize + blockSize - 1) / blockSize);
|
||||
|
||||
// ---- Step 1: Write placeholder header (will be patched at end) ----
|
||||
long headerPos = destination.Position;
|
||||
var header = new GczHeader
|
||||
{
|
||||
MagicCookie = Constants.MagicCookie,
|
||||
SubType = 0,
|
||||
CompressedDataSize = 0,
|
||||
DataSize = (ulong)sourceSize,
|
||||
BlockSize = blockSize,
|
||||
NumBlocks = numBlocks,
|
||||
};
|
||||
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;
|
||||
|
||||
// ---- Step 3: Reserve block-hash table (4 bytes each) ----
|
||||
var blockHashes = new uint[numBlocks];
|
||||
destination.Position += (long)numBlocks * 4;
|
||||
|
||||
// ---- Step 4: Data section starts here ----
|
||||
long dataStartPos = destination.Position;
|
||||
var readBuf = new byte[blockSize];
|
||||
var compressBuf = new byte[(int)blockSize * 2];
|
||||
|
||||
for (uint bi = 0; bi < numBlocks; bi++)
|
||||
{
|
||||
long blockOffset = (long)bi * blockSize;
|
||||
int blockDataSize = (int)Math.Min(blockSize, sourceSize - blockOffset);
|
||||
|
||||
byte[]? raw = source.ReadData(blockOffset, blockDataSize);
|
||||
if (raw is null || raw.Length != blockDataSize)
|
||||
return false;
|
||||
|
||||
if (blockDataSize < readBuf.Length)
|
||||
Array.Copy(raw, readBuf, blockDataSize);
|
||||
else
|
||||
readBuf = raw;
|
||||
|
||||
// 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);
|
||||
|
||||
if (useCompression)
|
||||
{
|
||||
blockPointers[bi] = blockPointer;
|
||||
destination.Write(compressBuf, 0, compressedSize);
|
||||
blockHashes[bi] = Adler.Adler32(1, compressBuf, 0, compressedSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
blockPointers[bi] = blockPointer | Constants.UncompressedFlag;
|
||||
destination.Write(readBuf, 0, blockDataSize);
|
||||
blockHashes[bi] = Adler.Adler32(1, readBuf, 0, blockDataSize);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step 5: Patch header with final compressed-data size ----
|
||||
long finalEnd = destination.Position;
|
||||
header.CompressedDataSize = (ulong)(finalEnd - dataStartPos);
|
||||
|
||||
// ---- Step 6: Write block-pointer table ----
|
||||
destination.Position = blockTablePos;
|
||||
foreach (ulong ptr in blockPointers)
|
||||
WriteUInt64LE(destination, ptr);
|
||||
|
||||
// ---- Step 7: Write block-hash table ----
|
||||
foreach (uint h in blockHashes)
|
||||
WriteUInt32LE(destination, h);
|
||||
|
||||
// ---- Step 8: Patch header ----
|
||||
destination.Position = headerPos;
|
||||
WriteHeader(destination, header);
|
||||
|
||||
destination.Position = finalEnd;
|
||||
destination.Flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Compression helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to zlib-compress <paramref name="inputSize"/> bytes of <paramref name="input"/>
|
||||
/// into <paramref name="output"/>. Returns true and sets <paramref name="compressedSize"/>
|
||||
/// 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))
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
288
SabreTools.Wrappers/GCZ.cs
Normal file
288
SabreTools.Wrappers/GCZ.cs
Normal file
@@ -0,0 +1,288 @@
|
||||
using System.IO;
|
||||
using SabreTools.Data.Models.GCZ;
|
||||
using SabreTools.Data.Models.NintendoDisc;
|
||||
using SabreTools.IO.Compression.Deflate;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
public partial class GCZ : WrapperBase<DiscImage>
|
||||
{
|
||||
#region Descriptive Properties
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string DescriptionString => "GCZ Compressed GameCube / Wii Disc Image";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extension Properties
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <summary>
|
||||
/// Number of compressed blocks in this image
|
||||
/// </summary>
|
||||
public uint NumBlocks => Model.Header.NumBlocks;
|
||||
|
||||
/// <summary>
|
||||
/// Size of each uncompressed block in bytes
|
||||
/// </summary>
|
||||
public uint BlockSize => Model.Header.BlockSize;
|
||||
|
||||
/// <summary>
|
||||
/// Block pointer table — top bit indicates uncompressed flag
|
||||
/// </summary>
|
||||
public ulong[] BlockPointers => Model.BlockPointers;
|
||||
|
||||
/// <summary>
|
||||
/// Adler-32 hashes of each uncompressed block
|
||||
/// </summary>
|
||||
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>
|
||||
public DiscHeader? DiscHeader
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_discHeaderCached)
|
||||
return _discHeader;
|
||||
_discHeader = ReadDiscHeader();
|
||||
_discHeaderCached = true;
|
||||
return _discHeader;
|
||||
}
|
||||
}
|
||||
|
||||
private DiscHeader? _discHeader;
|
||||
private bool _discHeaderCached;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
|
||||
/// <inheritdoc/>
|
||||
public GCZ(DiscImage model, byte[] data) : base(model, data) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public GCZ(DiscImage model, byte[] data, int offset) : base(model, data, offset) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public GCZ(DiscImage model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public GCZ(DiscImage model, Stream data) : base(model, data) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public GCZ(DiscImage model, Stream data, long offset) : base(model, data, offset) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public GCZ(DiscImage model, Stream data, long offset, long length) : base(model, data, offset, length) { }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Static Constructors
|
||||
|
||||
/// <summary>
|
||||
/// Create a GCZ wrapper from a byte array and offset
|
||||
/// </summary>
|
||||
/// <param name="data">Byte array representing the GCZ image</param>
|
||||
/// <param name="offset">Offset within the array to parse</param>
|
||||
/// <returns>A GCZ wrapper on success, null on failure</returns>
|
||||
public static GCZ? Create(byte[]? data, int offset)
|
||||
{
|
||||
// If the data is invalid
|
||||
if (data is null || data.Length == 0)
|
||||
return null;
|
||||
|
||||
// If the offset is out of bounds
|
||||
if (offset < 0 || offset >= data.Length)
|
||||
return null;
|
||||
|
||||
// Create a memory stream and use that
|
||||
var dataStream = new MemoryStream(data, offset, data.Length - offset);
|
||||
return Create(dataStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a GCZ wrapper from a Stream
|
||||
/// </summary>
|
||||
/// <param name="data">Stream representing the GCZ image</param>
|
||||
/// <returns>A GCZ wrapper on success, null on failure</returns>
|
||||
public static GCZ? Create(Stream? data)
|
||||
{
|
||||
// If the data is invalid
|
||||
if (data is null || !data.CanRead)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
long currentOffset = data.Position;
|
||||
|
||||
var model = new Serialization.Readers.GCZ().Deserialize(data);
|
||||
if (model is null)
|
||||
return null;
|
||||
|
||||
return new GCZ(model, data, currentOffset);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses just the first block of the GCZ image to read the disc header,
|
||||
/// without decompressing the entire image.
|
||||
/// </summary>
|
||||
private DiscHeader? ReadDiscHeader()
|
||||
{
|
||||
const ulong UncompressedFlag = 0x8000000000000000UL;
|
||||
|
||||
if (Model.BlockPointers is null || Model.BlockPointers.Length == 0)
|
||||
return null;
|
||||
|
||||
ulong ptr = Model.BlockPointers[0];
|
||||
bool uncompressed = (ptr & UncompressedFlag) != 0;
|
||||
long blockFileOffset = DataOffset + (long)(ptr & ~UncompressedFlag);
|
||||
|
||||
ulong nextRaw = Model.BlockPointers.Length > 1
|
||||
? Model.BlockPointers[1] & ~UncompressedFlag
|
||||
: Model.Header.CompressedDataSize;
|
||||
int compSize = (int)(nextRaw - (ptr & ~UncompressedFlag));
|
||||
|
||||
if (compSize <= 0)
|
||||
return null;
|
||||
|
||||
byte[] raw = ReadRangeFromSource(blockFileOffset, compSize);
|
||||
if (raw is null || raw.Length != compSize)
|
||||
return null;
|
||||
|
||||
if (uncompressed)
|
||||
{
|
||||
using var ms2 = new MemoryStream(raw);
|
||||
var disc2 = new Serialization.Readers.NintendoDisc().Deserialize(ms2);
|
||||
return disc2?.Header;
|
||||
}
|
||||
|
||||
if (raw.Length < 6)
|
||||
return null;
|
||||
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream(block);
|
||||
var disc = new Serialization.Readers.NintendoDisc().Deserialize(ms);
|
||||
return disc?.Header;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
171
SabreTools.Wrappers/GcFst.cs
Normal file
171
SabreTools.Wrappers/GcFst.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using System.Collections.Generic;
|
||||
using SabreTools.Numerics.Extensions;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
/// <summary>
|
||||
/// 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>).
|
||||
/// </summary>
|
||||
internal sealed class GcFst
|
||||
{
|
||||
private const int EntrySize = 12;
|
||||
|
||||
/// <summary>File entry with start and end byte offsets on disc.</summary>
|
||||
internal struct FileEntry
|
||||
{
|
||||
public long FileStart;
|
||||
public long FileEnd;
|
||||
}
|
||||
|
||||
// Sorted ascending by FileEnd for O(log n) upper_bound queries.
|
||||
private readonly List<FileEntry> _files;
|
||||
|
||||
private GcFst(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.
|
||||
/// </summary>
|
||||
/// <param name="fstData">
|
||||
/// Raw FST bytes exactly as stored on disc (GameCube) or in decrypted
|
||||
/// Wii partition data.
|
||||
/// </param>
|
||||
/// <param name="offsetShift">
|
||||
/// 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)
|
||||
{
|
||||
if (fstData == null || fstData.Length < EntrySize)
|
||||
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++)
|
||||
{
|
||||
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);
|
||||
|
||||
if ((nameOffField & 0xFF000000u) != 0) continue; // directory entry
|
||||
if (fileSizeField == 0) continue; // empty file
|
||||
|
||||
long fileStart = (long)fileOffField << offsetShift;
|
||||
long fileEnd = fileStart + fileSizeField;
|
||||
files.Add(new FileEntry { FileStart = fileStart, FileEnd = fileEnd });
|
||||
}
|
||||
|
||||
// Sort ascending by FileEnd so binary-search upper_bound works correctly.
|
||||
files.Sort(delegate(FileEntry a, FileEntry b)
|
||||
{
|
||||
return a.FileEnd.CompareTo(b.FileEnd);
|
||||
});
|
||||
|
||||
return new GcFst(files);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the file entry whose byte range contains <paramref name="discOffset"/>,
|
||||
/// or <c>null</c> if no file does.
|
||||
/// </summary>
|
||||
public FileEntry? FindFileInfo(long discOffset)
|
||||
{
|
||||
if (_files.Count == 0)
|
||||
return null;
|
||||
|
||||
// Binary search: first index where _files[i].FileEnd > discOffset
|
||||
int lo = 0, hi = _files.Count;
|
||||
while (lo < hi)
|
||||
{
|
||||
int mid = (lo + hi) >> 1;
|
||||
if (_files[mid].FileEnd <= discOffset)
|
||||
lo = mid + 1;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
|
||||
if (lo >= _files.Count)
|
||||
return null;
|
||||
|
||||
FileEntry e = _files[lo];
|
||||
if (e.FileStart <= discOffset)
|
||||
return e;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the smallest FileEnd value strictly greater than
|
||||
/// <paramref name="discOffset"/>, or <c>null</c> if there is none.
|
||||
/// </summary>
|
||||
public long? FindNextFileEnd(long discOffset)
|
||||
{
|
||||
if (_files.Count == 0)
|
||||
return null;
|
||||
|
||||
int lo = 0, hi = _files.Count;
|
||||
while (lo < hi)
|
||||
{
|
||||
int mid = (lo + hi) >> 1;
|
||||
if (_files[mid].FileEnd <= discOffset)
|
||||
lo = mid + 1;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
|
||||
return lo < _files.Count ? _files[lo].FileEnd : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the smallest FileStart value strictly greater than
|
||||
/// <paramref name="discOffset"/>, or <c>null</c> if there is none.
|
||||
/// </summary>
|
||||
public long? FindNextFileStart(long discOffset)
|
||||
{
|
||||
if (_files.Count == 0)
|
||||
return null;
|
||||
|
||||
// Sort is by FileEnd; scan all entries whose FileEnd > discOffset
|
||||
int lo = 0, hi = _files.Count;
|
||||
while (lo < hi)
|
||||
{
|
||||
int mid = (lo + hi) >> 1;
|
||||
if (_files[mid].FileEnd <= discOffset)
|
||||
lo = mid + 1;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
|
||||
long? best = null;
|
||||
for (int i = lo; i < _files.Count; i++)
|
||||
{
|
||||
long start = _files[i].FileStart;
|
||||
if (start <= discOffset)
|
||||
continue;
|
||||
|
||||
if (best == null || start < best.Value)
|
||||
best = start;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
116
SabreTools.Wrappers/GczVirtualStream.cs
Normal file
116
SabreTools.Wrappers/GczVirtualStream.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
private readonly GCZ _gcz;
|
||||
private long _position;
|
||||
|
||||
// Single-block cache to avoid re-decompressing on adjacent reads within the same block.
|
||||
private int _cachedBlockIndex = -1;
|
||||
private byte[]? _cachedBlock;
|
||||
|
||||
public GczVirtualStream(GCZ gcz)
|
||||
{
|
||||
_gcz = gcz ?? throw new ArgumentNullException(nameof(gcz));
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => (long)_gcz.DataSize;
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set
|
||||
{
|
||||
if (value < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
_position = value;
|
||||
}
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (buffer is null)
|
||||
throw new ArgumentNullException(nameof(buffer));
|
||||
if (offset < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
if (count < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
if (offset + count > buffer.Length)
|
||||
throw new ArgumentException("offset + count exceeds buffer length");
|
||||
|
||||
long remaining = Length - _position;
|
||||
if (remaining <= 0 || count <= 0)
|
||||
return 0;
|
||||
|
||||
count = (int)Math.Min(count, remaining);
|
||||
|
||||
int totalRead = 0;
|
||||
uint blockSize = _gcz.BlockSize;
|
||||
|
||||
while (totalRead < count && _position < Length)
|
||||
{
|
||||
int blockIndex = (int)(_position / blockSize);
|
||||
int offsetInBlock = (int)(_position % blockSize);
|
||||
|
||||
byte[]? block = GetBlock(blockIndex);
|
||||
if (block is null)
|
||||
break;
|
||||
|
||||
int available = block.Length - offsetInBlock;
|
||||
int toCopy = Math.Min(count - totalRead, available);
|
||||
if (toCopy <= 0)
|
||||
break;
|
||||
|
||||
Array.Copy(block, offsetInBlock, buffer, offset + totalRead, toCopy);
|
||||
totalRead += toCopy;
|
||||
_position += toCopy;
|
||||
}
|
||||
|
||||
return totalRead;
|
||||
}
|
||||
|
||||
private byte[]? GetBlock(int blockIndex)
|
||||
{
|
||||
if (_cachedBlockIndex == blockIndex)
|
||||
return _cachedBlock;
|
||||
|
||||
byte[]? block = _gcz.DecompressBlock(blockIndex);
|
||||
_cachedBlockIndex = blockIndex;
|
||||
_cachedBlock = block;
|
||||
return block;
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
long newPos;
|
||||
switch (origin)
|
||||
{
|
||||
case SeekOrigin.Begin: newPos = offset; break;
|
||||
case SeekOrigin.Current: newPos = _position + offset; break;
|
||||
case SeekOrigin.End: newPos = Length + offset; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(origin));
|
||||
}
|
||||
|
||||
if (newPos < 0)
|
||||
throw new IOException("Seek position cannot be negative.");
|
||||
|
||||
_position = newPos;
|
||||
return _position;
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
319
SabreTools.Wrappers/LaggedFibonacciGenerator.cs
Normal file
319
SabreTools.Wrappers/LaggedFibonacciGenerator.cs
Normal file
@@ -0,0 +1,319 @@
|
||||
using System;
|
||||
|
||||
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)
|
||||
/// instead of the full data, enabling significant compression of padding areas.
|
||||
/// </summary>
|
||||
internal class LaggedFibonacciGenerator
|
||||
{
|
||||
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>
|
||||
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));
|
||||
|
||||
m_position_bytes = 0;
|
||||
for (int i = 0; i < SEED_SIZE; i++)
|
||||
m_buffer[i] = SwapU32(seed[i]); // reinterpret LE bytes as BE (Dolphin swap32)
|
||||
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).
|
||||
/// </summary>
|
||||
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;
|
||||
for (int i = 0; i < SEED_SIZE; i++)
|
||||
m_buffer[i] = ReadBigEndianU32(seedBytes, i * 4);
|
||||
Initialize(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skips forward by <paramref name="count"/> bytes in the output stream.
|
||||
/// Matches Dolphin: LaggedFibonacciGenerator::Forward(size_t count).
|
||||
/// </summary>
|
||||
public void Forward(int count)
|
||||
{
|
||||
m_position_bytes += count;
|
||||
while (m_position_bytes >= BUFFER_BYTES)
|
||||
{
|
||||
ForwardStep();
|
||||
m_position_bytes -= BUFFER_BYTES;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Generates <paramref name="count"/> junk bytes and returns them.</summary>
|
||||
public byte[] GetBytes(int count)
|
||||
{
|
||||
byte[] output = new byte[count];
|
||||
GetBytes(count, output, 0);
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates junk bytes into <paramref name="output"/> starting at <paramref name="outputOffset"/>.
|
||||
/// Matches Dolphin: LaggedFibonacciGenerator::GetBytes using memcpy pattern.
|
||||
/// </summary>
|
||||
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;
|
||||
count -= length;
|
||||
outputOffset += length;
|
||||
|
||||
if (m_position_bytes == BUFFER_BYTES)
|
||||
{
|
||||
ForwardStep();
|
||||
m_position_bytes = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a single junk byte at the current position, advancing by one byte.
|
||||
/// Matches Dolphin: LaggedFibonacciGenerator::GetByte.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
m_position_bytes++;
|
||||
if (m_position_bytes == BUFFER_BYTES)
|
||||
{
|
||||
ForwardStep();
|
||||
m_position_bytes = 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Private forward/backward state steps
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Full buffer state step forward — 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>
|
||||
private void ForwardStep()
|
||||
{
|
||||
for (int i = 0; i < LFG_J; i++)
|
||||
m_buffer[i] ^= m_buffer[i + LFG_K - LFG_J];
|
||||
for (int i = LFG_J; i < LFG_K; i++)
|
||||
m_buffer[i] ^= m_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>
|
||||
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];
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
/// <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[]).
|
||||
/// </summary>
|
||||
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]);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Output seed as LE u32 values (swap32 converts BE→LE)
|
||||
for (int i = 0; i < SEED_SIZE; i++)
|
||||
seedOut[i] = SwapU32(m_buffer[i]);
|
||||
|
||||
return Initialize(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
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];
|
||||
|
||||
if (checkExisting)
|
||||
{
|
||||
uint actual = (m_buffer[i] & 0xFF00FFFF) | ((m_buffer[i] << 2) & 0x00FC0000);
|
||||
if ((calculated & 0xFFFCFFFF) != actual)
|
||||
return false;
|
||||
}
|
||||
|
||||
m_buffer[i] = calculated;
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
ForwardStep();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 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
|
||||
/// 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>
|
||||
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
|
||||
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;
|
||||
|
||||
if (u32Size < 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));
|
||||
|
||||
LaggedFibonacciGenerator lfg = new LaggedFibonacciGenerator();
|
||||
if (!GetSeed_u32(u32Data, u32Size, u32DataOffset, 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;
|
||||
|
||||
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[]).
|
||||
/// </summary>
|
||||
private static bool GetSeed_u32(uint[] data, int size, int dataOffset,
|
||||
LaggedFibonacciGenerator lfg, uint[] seedOut)
|
||||
{
|
||||
if (size < LFG_K)
|
||||
return false;
|
||||
|
||||
// Quick sanity check: bits 22-23 of swap32(x) must equal bits 20-21
|
||||
// (a property of the LFG output transform).
|
||||
for (int i = 0; i < LFG_K; i++)
|
||||
{
|
||||
uint x = SwapU32(data[i]);
|
||||
if ((x & 0x00C00000) != ((x >> 2) & 0x00C00000))
|
||||
return false;
|
||||
}
|
||||
|
||||
int dataOffsetModK = dataOffset % LFG_K;
|
||||
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);
|
||||
if (dataOffsetModK > 0)
|
||||
Array.Copy(data, LFG_K - dataOffsetModK, lfg.m_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
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
internal static uint ReadBigEndianU32(byte[] data, int offset) =>
|
||||
(uint)((data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]);
|
||||
|
||||
private static uint ReadLittleEndianU32(byte[] data, int offset) =>
|
||||
(uint)(data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24));
|
||||
|
||||
internal static uint SwapU32(uint value) =>
|
||||
(value << 24) | ((value << 8) & 0x00FF0000) | ((value >> 8) & 0x0000FF00) | (value >> 24);
|
||||
}
|
||||
}
|
||||
70
SabreTools.Wrappers/NintendoDisc.Encryption.cs
Normal file
70
SabreTools.Wrappers/NintendoDisc.Encryption.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using SabreTools.Data.Models.NintendoDisc;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
public partial class NintendoDisc
|
||||
{
|
||||
#region Wii Encryption / Decryption
|
||||
|
||||
/// <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"/>.
|
||||
/// </summary>
|
||||
public static System.Func<byte, byte[]?>? CommonKeyProvider { get; set; }
|
||||
|
||||
/// <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>
|
||||
/// <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)
|
||||
{
|
||||
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)
|
||||
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);
|
||||
|
||||
return DecryptAesCbc(encryptedTitleKey, commonKey, iv);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypt one Wii block of data (0x7C00 bytes) using AES-128-CBC.
|
||||
/// </summary>
|
||||
/// <param name="encryptedData">0x7C00 bytes of encrypted block data</param>
|
||||
/// <param name="titleKey">16-byte partition title key</param>
|
||||
/// <param name="iv">16-byte initialization vector (last 16 bytes of the preceding hash block)</param>
|
||||
/// <returns>Decrypted 0x7C00-byte block data, or null on error</returns>
|
||||
public static byte[]? DecryptBlock(byte[] encryptedData, byte[] titleKey, byte[] iv)
|
||||
{
|
||||
if (encryptedData is null || encryptedData.Length != Constants.WiiBlockDataSize)
|
||||
return null;
|
||||
if (titleKey is null || titleKey.Length != 16)
|
||||
return null;
|
||||
if (iv is null || iv.Length != 16)
|
||||
return null;
|
||||
|
||||
return DecryptAesCbc(encryptedData, titleKey, iv);
|
||||
}
|
||||
|
||||
private static byte[]? DecryptAesCbc(byte[] data, byte[] key, byte[] iv)
|
||||
{
|
||||
return AesCbc.Decrypt(data, key, iv);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
541
SabreTools.Wrappers/NintendoDisc.Extraction.cs
Normal file
541
SabreTools.Wrappers/NintendoDisc.Extraction.cs
Normal file
@@ -0,0 +1,541 @@
|
||||
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 0x400–0x7FFF 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
|
||||
}
|
||||
}
|
||||
62
SabreTools.Wrappers/NintendoDisc.Printing.cs
Normal file
62
SabreTools.Wrappers/NintendoDisc.Printing.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.Text;
|
||||
using SabreTools.Text.Extensions;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
public partial class NintendoDisc : IPrintable
|
||||
{
|
||||
#if NETCOREAPP
|
||||
/// <inheritdoc/>
|
||||
public string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions);
|
||||
#else
|
||||
/// <inheritdoc/>
|
||||
public string ExportJSON() => Newtonsoft.Json.JsonConvert.SerializeObject(Model, _jsonSerializerOptions);
|
||||
#endif
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void PrintInformation(StringBuilder builder)
|
||||
{
|
||||
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");
|
||||
builder.AppendLine();
|
||||
|
||||
if (PartitionTableEntries is { 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();
|
||||
}
|
||||
|
||||
if (RegionData is not null)
|
||||
{
|
||||
builder.AppendLine("Region Data:");
|
||||
builder.AppendLine(RegionData.RegionSetting, " Region Setting");
|
||||
builder.AppendLine(RegionData.AgeRatings, " Age Ratings");
|
||||
builder.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
133
SabreTools.Wrappers/NintendoDisc.cs
Normal file
133
SabreTools.Wrappers/NintendoDisc.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using SabreTools.Data.Models.NintendoDisc;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
public partial class NintendoDisc : WrapperBase<Disc>
|
||||
{
|
||||
#region Descriptive Properties
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string DescriptionString => "Nintendo GameCube / Wii Disc Image";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extension Properties
|
||||
|
||||
/// <inheritdoc cref="Disc.Header"/>
|
||||
public DiscHeader Header => Model.Header;
|
||||
|
||||
/// <inheritdoc cref="Disc.Platform"/>
|
||||
public Platform Platform => Model.Platform;
|
||||
|
||||
/// <inheritdoc cref="DiscHeader.GameId"/>
|
||||
public string GameId => Model.Header.GameId;
|
||||
|
||||
/// <inheritdoc cref="DiscHeader.MakerCode"/>
|
||||
public string MakerCode => Model.Header.MakerCode;
|
||||
|
||||
/// <inheritdoc cref="DiscHeader.GameTitle"/>
|
||||
public string GameTitle => Model.Header.GameTitle;
|
||||
|
||||
/// <inheritdoc cref="DiscHeader.DiscNumber"/>
|
||||
public byte DiscNumber => Model.Header.DiscNumber;
|
||||
|
||||
/// <inheritdoc cref="DiscHeader.DiscVersion"/>
|
||||
public byte DiscVersion => Model.Header.DiscVersion;
|
||||
|
||||
/// <inheritdoc cref="Disc.PartitionTableEntries"/>
|
||||
public WiiPartitionTableEntry[]? PartitionTableEntries => Model.PartitionTableEntries;
|
||||
|
||||
/// <inheritdoc cref="Disc.RegionData"/>
|
||||
public WiiRegionData? RegionData => Model.RegionData;
|
||||
|
||||
#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/>
|
||||
public NintendoDisc(Disc model, byte[] data) : base(model, data) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public NintendoDisc(Disc model, byte[] data, int offset) : base(model, data, offset) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public NintendoDisc(Disc model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public NintendoDisc(Disc model, Stream data) : base(model, data) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public NintendoDisc(Disc model, Stream data, long offset) : base(model, data, offset) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public NintendoDisc(Disc model, Stream data, long offset, long length) : base(model, data, offset, length) { }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Static Constructors
|
||||
|
||||
/// <summary>
|
||||
/// Create a Nintendo disc image wrapper from a byte array and offset
|
||||
/// </summary>
|
||||
/// <param name="data">Byte array representing the disc image</param>
|
||||
/// <param name="offset">Offset within the array to parse</param>
|
||||
/// <returns>A NintendoDisc wrapper on success, null on failure</returns>
|
||||
public static NintendoDisc? Create(byte[]? data, int offset)
|
||||
{
|
||||
// If the data is invalid
|
||||
if (data is null || data.Length == 0)
|
||||
return null;
|
||||
|
||||
// If the offset is out of bounds
|
||||
if (offset < 0 || offset >= data.Length)
|
||||
return null;
|
||||
|
||||
// Create a memory stream and use that
|
||||
var dataStream = new MemoryStream(data, offset, data.Length - offset);
|
||||
return Create(dataStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a Nintendo disc image wrapper from a Stream
|
||||
/// </summary>
|
||||
/// <param name="data">Stream representing the disc image</param>
|
||||
/// <returns>A NintendoDisc wrapper on success, null on failure</returns>
|
||||
public static NintendoDisc? Create(Stream? data)
|
||||
{
|
||||
// If the data is invalid
|
||||
if (data is null || !data.CanRead)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
long currentOffset = data.Position;
|
||||
|
||||
var model = new Serialization.Readers.NintendoDisc().Deserialize(data);
|
||||
if (model is null)
|
||||
return null;
|
||||
|
||||
return new NintendoDisc(model, data, currentOffset);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
135
SabreTools.Wrappers/PurgeCompressor.cs
Normal file
135
SabreTools.Wrappers/PurgeCompressor.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using SabreTools.Hashing;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
/// <summary>
|
||||
/// Compresses data using the WIA PURGE format.
|
||||
///
|
||||
/// PURGE layout produced:
|
||||
/// [ { u32 offset BE, u32 size BE, data[size] } ] ... (zero or more segments)
|
||||
/// [ SHA-1 (20 bytes) ]
|
||||
///
|
||||
/// Only non-zero byte runs are emitted as segments; consecutive non-zero regions
|
||||
/// separated by a gap of 8 or fewer zero bytes are merged into a single segment.
|
||||
/// The SHA-1 covers: <paramref name="precedingBytes"/> (e.g. exception-list prefix) +
|
||||
/// all segment headers and data bytes.
|
||||
///
|
||||
/// This is the exact inverse of <see cref="PurgeDecompressor"/>.
|
||||
/// </summary>
|
||||
internal static class PurgeCompressor
|
||||
{
|
||||
/// <summary>
|
||||
/// Compress <paramref name="data"/>[<paramref name="offset"/> ..
|
||||
/// <paramref name="offset"/>+<paramref name="count"/>) into PURGE format.
|
||||
/// </summary>
|
||||
/// <param name="data">Source buffer.</param>
|
||||
/// <param name="offset">Start of data within <paramref name="data"/>.</param>
|
||||
/// <param name="count">Number of bytes to compress.</param>
|
||||
/// <param name="precedingBytes">
|
||||
/// Optional bytes that precede this payload in the WIA group
|
||||
/// (e.g. the serialised exception list). Included in the SHA-1 but not emitted.
|
||||
/// Pass null or empty if there are none.
|
||||
/// </param>
|
||||
/// <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;
|
||||
int pos = offset;
|
||||
|
||||
while (pos < end)
|
||||
{
|
||||
// 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;
|
||||
|
||||
// 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)
|
||||
{
|
||||
pos = segEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
uint segOffset = (uint)(segStart - offset);
|
||||
uint segSize = (uint)(segEnd - segStart);
|
||||
|
||||
// Write {u32 offsetBE, u32 sizeBE, data[segSize]}
|
||||
WriteBeU32(output, segOffset);
|
||||
WriteBeU32(output, segSize);
|
||||
output.Write(data, segStart, (int)segSize);
|
||||
|
||||
pos = segEnd;
|
||||
}
|
||||
|
||||
byte[] segments = output.ToArray();
|
||||
|
||||
// SHA-1 over: precedingBytes + segments
|
||||
byte[] hash = ComputeSha1(precedingBytes, segments);
|
||||
|
||||
// Final result: segments + hash
|
||||
byte[] result = new byte[segments.Length + 20];
|
||||
Array.Copy(segments, 0, result, 0, segments.Length);
|
||||
Array.Copy(hash, 0, result, segments.Length, 20);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] ComputeSha1(byte[]? precedingBytes, byte[] segments)
|
||||
{
|
||||
using var sha1 = new HashWrapper(HashType.SHA1);
|
||||
|
||||
if (precedingBytes != 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
SabreTools.Wrappers/PurgeDecompressor.cs
Normal file
98
SabreTools.Wrappers/PurgeDecompressor.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using SabreTools.Hashing;
|
||||
using SabreTools.Numerics.Extensions;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
/// <summary>
|
||||
/// Decompresses WIA PURGE-compressed group data.
|
||||
///
|
||||
/// PURGE layout (uncompressed exception-list prefix already stripped by caller):
|
||||
/// [ { u32 offset BE, u32 size BE } { data[size] } ] ... (zero or more segments)
|
||||
/// [ SHA-1 (20 bytes) ]
|
||||
///
|
||||
/// The SHA-1 covers: <paramref name="precedingBytes"/> (exception-list bytes, if any) +
|
||||
/// all segment headers and their data bytes.
|
||||
/// Bytes in the output not covered by any segment are implicitly 0x00.
|
||||
///
|
||||
/// References: Dolphin WIACompression.cpp — PurgeDecompressor / PurgeCompressor
|
||||
/// docs/WiaAndRvz.md — wia_segment_t section
|
||||
/// </summary>
|
||||
internal static class PurgeDecompressor
|
||||
{
|
||||
private const int SHA1_SIZE = 20;
|
||||
private const int SEGMENT_HEADER_SIZE = 8; // u32 offset + u32 size
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses a PURGE-compressed block.
|
||||
/// </summary>
|
||||
/// <param name="input">Buffer containing the compressed data.</param>
|
||||
/// <param name="inputOffset">Byte offset within <paramref name="input"/> where compressed data starts.</param>
|
||||
/// <param name="inputLength">Number of bytes of compressed data (segments + trailing SHA-1).</param>
|
||||
/// <param name="decompressedSize">Expected decompressed output size in bytes.</param>
|
||||
/// <param name="precedingBytes">
|
||||
/// Bytes that precede the compressed data in the SHA-1 computation — the uncompressed
|
||||
/// 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
|
||||
/// trailing SHA-1 does not match.
|
||||
/// </returns>
|
||||
public static byte[]? Decompress(
|
||||
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;
|
||||
|
||||
byte[] output = new byte[decompressedSize];
|
||||
int pos = inputOffset;
|
||||
int dataEnd = inputOffset + inputLength - SHA1_SIZE;
|
||||
|
||||
using (var sha1 = new HashWrapper(HashType.SHA1))
|
||||
{
|
||||
if (precedingBytes != null && precedingBytes.Length > 0)
|
||||
sha1.Process(precedingBytes, 0, precedingBytes.Length);
|
||||
|
||||
while (pos < dataEnd)
|
||||
{
|
||||
if (pos + SEGMENT_HEADER_SIZE > dataEnd)
|
||||
return null;
|
||||
|
||||
int headerPos = pos;
|
||||
uint segOffset = input.ReadUInt32BigEndian(ref headerPos);
|
||||
uint segSize = input.ReadUInt32BigEndian(ref headerPos);
|
||||
|
||||
sha1.Process(input, pos, SEGMENT_HEADER_SIZE);
|
||||
pos += SEGMENT_HEADER_SIZE;
|
||||
|
||||
if (segSize == 0)
|
||||
continue;
|
||||
|
||||
if (pos + (int)segSize > dataEnd)
|
||||
return null;
|
||||
|
||||
if (segOffset + segSize > (uint)decompressedSize)
|
||||
return null;
|
||||
|
||||
Array.Copy(input, pos, output, (int)segOffset, (int)segSize);
|
||||
sha1.Process(input, pos, (int)segSize);
|
||||
pos += (int)segSize;
|
||||
}
|
||||
|
||||
sha1.Terminate();
|
||||
|
||||
byte[]? computed = sha1.CurrentHashBytes;
|
||||
if (computed is null) return null;
|
||||
for (int i = 0; i < SHA1_SIZE; i++)
|
||||
{
|
||||
if (computed[i] != input[dataEnd + i])
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
}
|
||||
113
SabreTools.Wrappers/RvzPackDecompressor.cs
Normal file
113
SabreTools.Wrappers/RvzPackDecompressor.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
266
SabreTools.Wrappers/RvzPackEncoder.cs
Normal file
266
SabreTools.Wrappers/RvzPackEncoder.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using SabreTools.Numerics.Extensions;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
/// <summary>
|
||||
/// Encodes disc data into RVZ-Pack format by replacing predictable LFG
|
||||
/// (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>.
|
||||
///
|
||||
/// Two-phase algorithm:
|
||||
/// <list type="number">
|
||||
/// <item>Phase 1 (<see cref="ScanForJunk"/>): walk the buffer, identify LFG
|
||||
/// junk regions, build a map keyed by end-offset.</item>
|
||||
/// <item>Phase 2 (<see cref="EmitChunk"/>): for each chunk, use the map to
|
||||
/// emit alternating real-data and junk-seed segments.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static class RvzPackEncoder
|
||||
{
|
||||
// 17 u32s × 4 bytes = 68 bytes — minimum size to record a seed
|
||||
private const int SeedSizeBytes = LaggedFibonacciGenerator.SEED_SIZE * 4;
|
||||
|
||||
private sealed class JunkRegion
|
||||
{
|
||||
public long StartOffset;
|
||||
public uint[]? Seed;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public byte[]? Packed;
|
||||
/// <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).
|
||||
/// <paramref name="rvzPackedSize"/> is the number of bytes actually needed
|
||||
/// by the decompressor (may be < packed.Length due to alignment).
|
||||
/// </summary>
|
||||
public static byte[]? Pack(byte[] data, int dataOffset, int size,
|
||||
long discDataOffset, out uint rvzPackedSize, GcFst? fst = null)
|
||||
{
|
||||
rvzPackedSize = 0;
|
||||
if (size <= 0)
|
||||
return null;
|
||||
|
||||
var junkInfo = ScanForJunk(data, dataOffset, size, discDataOffset, fst);
|
||||
if (junkInfo.Count == 0)
|
||||
return null;
|
||||
|
||||
ChunkResult r = EmitChunk(data, dataOffset, 0L, size, size, junkInfo);
|
||||
rvzPackedSize = r.RvzPackedSize;
|
||||
return r.Packed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RVZ-pack a multi-chunk buffer (e.g. a full 2 MiB Wii group).
|
||||
/// 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>
|
||||
/// <returns>
|
||||
/// One <see cref="ChunkResult"/> per chunk;
|
||||
/// <c>Packed == null</c> 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)
|
||||
{
|
||||
var junkInfo = ScanForJunk(data, dataOffset, totalSize, discDataOffset, fst);
|
||||
|
||||
var result = new ChunkResult[numChunks];
|
||||
for (int c = 0; c < numChunks; c++)
|
||||
{
|
||||
long chunkStart = (long)c * bytesPerChunk;
|
||||
long chunkEnd = Math.Min(chunkStart + bytesPerChunk, totalSize);
|
||||
result[c] = EmitChunk(data, dataOffset, chunkStart, chunkEnd, totalSize, junkInfo);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Phase 1 — scan buffer for junk regions
|
||||
|
||||
private static SortedDictionary<long, JunkRegion> ScanForJunk(
|
||||
byte[] data, int dataOffset, int totalSize, long discDataOffset, GcFst? fst)
|
||||
{
|
||||
var junkInfo = new SortedDictionary<long, JunkRegion>();
|
||||
|
||||
long position = 0;
|
||||
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)
|
||||
zeroes++;
|
||||
|
||||
if (zeroes > SeedSizeBytes)
|
||||
{
|
||||
junkInfo[position + zeroes] = new JunkRegion
|
||||
{
|
||||
StartOffset = position,
|
||||
Seed = new uint[LaggedFibonacciGenerator.SEED_SIZE]
|
||||
};
|
||||
}
|
||||
|
||||
position += 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);
|
||||
|
||||
// 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);
|
||||
|
||||
if (reconstructed > 0)
|
||||
{
|
||||
junkInfo[position + reconstructed] = new JunkRegion
|
||||
{
|
||||
StartOffset = position,
|
||||
Seed = seed
|
||||
};
|
||||
}
|
||||
|
||||
// Step 4: FST skip AFTER GetSeed
|
||||
if (fst != null)
|
||||
{
|
||||
long queryOff = dataOff + reconstructed;
|
||||
GcFst.FileEntry? fileInfo = fst.FindFileInfo(queryOff);
|
||||
if (fileInfo.HasValue)
|
||||
{
|
||||
long fileEnd = fileInfo.Value.FileEnd;
|
||||
if (fileEnd < (dataOff + bytesToRead))
|
||||
{
|
||||
position += fileEnd - dataOff;
|
||||
dataOff = fileEnd;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: normal advance by block window
|
||||
position += bytesToRead;
|
||||
dataOff += bytesToRead;
|
||||
}
|
||||
|
||||
return junkInfo;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Phase 2 — emit packed segments for a single chunk
|
||||
|
||||
private static ChunkResult EmitChunk(
|
||||
byte[] data, int dataOffset,
|
||||
long chunkStart, long chunkEnd, long totalSize,
|
||||
SortedDictionary<long, JunkRegion> junkInfo)
|
||||
{
|
||||
long currentOffset = chunkStart;
|
||||
bool firstIteration = true;
|
||||
|
||||
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;
|
||||
|
||||
if (remaining > SeedSizeBytes)
|
||||
{
|
||||
foreach (var kvp in junkInfo)
|
||||
{
|
||||
// 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))
|
||||
{
|
||||
nextJunkStart = Math.Max(currentOffset, kvp.Value.StartOffset);
|
||||
nextJunkEnd = Math.Min(chunkEnd, kvp.Key);
|
||||
junkSeed = kvp.Value.Seed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On the first iteration, bail out if there is no junk in this chunk
|
||||
if (firstIteration)
|
||||
{
|
||||
if (nextJunkStart == chunkEnd)
|
||||
return new ChunkResult { Packed = null, RvzPackedSize = 0 };
|
||||
|
||||
firstIteration = false;
|
||||
}
|
||||
|
||||
// Emit real-data segment before the junk region
|
||||
long nonJunkBytes = nextJunkStart - currentOffset;
|
||||
if (nonJunkBytes > 0)
|
||||
{
|
||||
output.WriteBigEndian((uint)nonJunkBytes);
|
||||
output.Write(data, (int)(dataOffset + currentOffset), (int)nonJunkBytes);
|
||||
packedSize += 4 + (uint)nonJunkBytes;
|
||||
currentOffset += nonJunkBytes;
|
||||
}
|
||||
|
||||
// Emit junk-seed segment
|
||||
long junkBytes = nextJunkEnd - currentOffset;
|
||||
if (junkBytes > 0 && junkSeed != 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;
|
||||
currentOffset += junkBytes;
|
||||
}
|
||||
|
||||
if (junkSeed == null)
|
||||
break;
|
||||
}
|
||||
|
||||
return new ChunkResult { Packed = output.ToArray(), RvzPackedSize = packedSize };
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static long AlignUp(long value, long alignment) => (value + alignment - 1) & ~(alignment - 1);
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,17 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SabreTools.Wrappers.Test" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.NetCore" Version="1.9.0" Condition="$(TargetFramework.StartsWith(`net2`)) OR $(TargetFramework.StartsWith(`net3`)) OR $(TargetFramework.StartsWith(`net40`))" />
|
||||
<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" Condition="!$(TargetFramework.StartsWith(`net2`)) AND !$(TargetFramework.StartsWith(`net3`)) AND !$(TargetFramework.StartsWith(`net40`))" />
|
||||
<PackageReference Include="GrindCore" Version="0.7.0" />
|
||||
<PackageReference Include="GrindCore.SharpCompress" Version="0.46.2" Condition="!$(TargetFramework.StartsWith(`net2`)) AND !$(TargetFramework.StartsWith(`net3`)) AND !$(TargetFramework.StartsWith(`net40`)) AND !$(TargetFramework.StartsWith(`net452`))" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
|
||||
15
SabreTools.Wrappers/WIA.Extraction.cs
Normal file
15
SabreTools.Wrappers/WIA.Extraction.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.IO;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
public partial class WIA : IExtractable
|
||||
{
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
104
SabreTools.Wrappers/WIA.Printing.cs
Normal file
104
SabreTools.Wrappers/WIA.Printing.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.Text;
|
||||
using SabreTools.Text.Extensions;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
public partial class WIA : IPrintable
|
||||
{
|
||||
#if NETCOREAPP
|
||||
/// <inheritdoc/>
|
||||
public string ExportJSON() => System.Text.Json.JsonSerializer.Serialize(Model, _jsonSerializerOptions);
|
||||
#else
|
||||
/// <inheritdoc/>
|
||||
public string ExportJSON() => Newtonsoft.Json.JsonConvert.SerializeObject(Model, _jsonSerializerOptions);
|
||||
#endif
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void PrintInformation(StringBuilder builder)
|
||||
{
|
||||
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");
|
||||
builder.AppendLine();
|
||||
|
||||
var discHeader = DiscHeader;
|
||||
if (discHeader is not 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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (RawDataEntries is { Length: > 0 })
|
||||
{
|
||||
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");
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1552
SabreTools.Wrappers/WIA.Writing.cs
Normal file
1552
SabreTools.Wrappers/WIA.Writing.cs
Normal file
File diff suppressed because it is too large
Load Diff
980
SabreTools.Wrappers/WIA.cs
Normal file
980
SabreTools.Wrappers/WIA.cs
Normal file
@@ -0,0 +1,980 @@
|
||||
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 WiaReader = SabreTools.Serialization.Readers.WIA;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
public partial class WIA : WrapperBase<DiscImage>
|
||||
{
|
||||
#region Descriptive Properties
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string DescriptionString => "WIA / RVZ Compressed GameCube / Wii Disc Image";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extension Properties
|
||||
|
||||
/// <inheritdoc cref="DiscImage.Header1"/>
|
||||
public WiaHeader1 Header1 => Model.Header1;
|
||||
|
||||
/// <inheritdoc cref="DiscImage.Header2"/>
|
||||
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;
|
||||
|
||||
/// <inheritdoc cref="DiscImage.PartitionEntries"/>
|
||||
public PartitionEntry[]? PartitionEntries => Model.PartitionEntries;
|
||||
|
||||
/// <inheritdoc cref="DiscImage.RawDataEntries"/>
|
||||
public RawDataEntry[] RawDataEntries => Model.RawDataEntries;
|
||||
|
||||
/// <summary>
|
||||
/// Total uncompressed ISO size in bytes
|
||||
/// </summary>
|
||||
public ulong IsoFileSize => Model.Header1.IsoFileSize;
|
||||
|
||||
/// <summary>
|
||||
/// Disc header parsed from the 128-byte raw disc header stored in Header2.
|
||||
/// </summary>
|
||||
public DiscHeader? DiscHeader
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_discHeader is not null)
|
||||
return _discHeader;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private DiscHeader? _discHeader;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
|
||||
/// <inheritdoc/>
|
||||
public WIA(DiscImage model, byte[] data) : base(model, data) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public WIA(DiscImage model, byte[] data, int offset) : base(model, data, offset) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public WIA(DiscImage model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public WIA(DiscImage model, Stream data) : base(model, data) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public WIA(DiscImage model, Stream data, long offset) : base(model, data, offset) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public WIA(DiscImage model, Stream data, long offset, long length) : base(model, data, offset, length) { }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Static Constructors
|
||||
|
||||
/// <summary>
|
||||
/// Create a WIA/RVZ wrapper from a byte array and offset
|
||||
/// </summary>
|
||||
/// <param name="data">Byte array representing the WIA or RVZ image</param>
|
||||
/// <param name="offset">Offset within the array to parse</param>
|
||||
/// <returns>A WIA wrapper on success, null on failure</returns>
|
||||
public static WIA? Create(byte[]? data, int offset)
|
||||
{
|
||||
// If the data is invalid
|
||||
if (data is null || data.Length == 0)
|
||||
return null;
|
||||
|
||||
// If the offset is out of bounds
|
||||
if (offset < 0 || offset >= data.Length)
|
||||
return null;
|
||||
|
||||
// Create a memory stream and use that
|
||||
var dataStream = new MemoryStream(data, offset, data.Length - offset);
|
||||
return Create(dataStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a WIA/RVZ wrapper from a Stream
|
||||
/// </summary>
|
||||
/// <param name="data">Stream representing the WIA or RVZ image</param>
|
||||
/// <returns>A WIA wrapper on success, null on failure</returns>
|
||||
public static WIA? Create(Stream? data)
|
||||
{
|
||||
// If the data is invalid
|
||||
if (data is null || !data.CanRead)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
long currentOffset = data.Position;
|
||||
|
||||
var model = new WiaReader().Deserialize(data);
|
||||
if (model is null)
|
||||
return null;
|
||||
|
||||
// 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);
|
||||
|
||||
return new WIA(model, data, currentOffset);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// (garbage) values that the reader left in the model.
|
||||
/// </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.
|
||||
if (comp == WiaRvzCompressionType.None || comp == WiaRvzCompressionType.Purge)
|
||||
return;
|
||||
|
||||
var compData = model.Header2.CompressorData ?? new byte[7];
|
||||
byte compDataSize = model.Header2.CompressorDataSize;
|
||||
|
||||
// --- Raw data entries (stored compressed) ---
|
||||
if (model.Header2.NumberOfRawDataEntries > 0 &&
|
||||
model.Header2.RawDataEntriesOffset > 0 &&
|
||||
model.Header2.RawDataEntriesSize > 0)
|
||||
{
|
||||
int count = (int)model.Header2.NumberOfRawDataEntries;
|
||||
int compressedSize = (int)model.Header2.RawDataEntriesSize;
|
||||
int expectedSize = count * WiaConstants.RawDataEntrySize;
|
||||
|
||||
data.Seek(baseOffset + (long)model.Header2.RawDataEntriesOffset, SeekOrigin.Begin);
|
||||
byte[] buf = new byte[compressedSize];
|
||||
int read = data.Read(buf, 0, compressedSize);
|
||||
if (read < compressedSize)
|
||||
return;
|
||||
|
||||
byte[] plain = WiaRvzCompressionHelper.Decompress(
|
||||
comp, buf, 0, compressedSize, compData, compDataSize);
|
||||
if (plain is null || plain.Length < expectedSize)
|
||||
return;
|
||||
|
||||
model.RawDataEntries = ParseRawDataEntries(plain, count);
|
||||
}
|
||||
|
||||
// --- Group entries (stored compressed) ---
|
||||
if (model.Header2.NumberOfGroupEntries > 0 &&
|
||||
model.Header2.GroupEntriesOffset > 0 &&
|
||||
model.Header2.GroupEntriesSize > 0)
|
||||
{
|
||||
int count = (int)model.Header2.NumberOfGroupEntries;
|
||||
int compressedSize = (int)model.Header2.GroupEntriesSize;
|
||||
int entrySize = model.Header1.Magic == WiaConstants.RvzMagic ? WiaConstants.RvzGroupEntrySize : WiaConstants.WiaGroupEntrySize;
|
||||
int expectedSize = count * entrySize;
|
||||
|
||||
data.Seek(baseOffset + (long)model.Header2.GroupEntriesOffset, SeekOrigin.Begin);
|
||||
byte[] buf = new byte[compressedSize];
|
||||
int read = data.Read(buf, 0, compressedSize);
|
||||
if (read < compressedSize)
|
||||
return;
|
||||
|
||||
byte[] plain = WiaRvzCompressionHelper.Decompress(
|
||||
comp, buf, 0, compressedSize, compData, compDataSize);
|
||||
if (plain is null || plain.Length < expectedSize)
|
||||
return;
|
||||
|
||||
if (model.Header1.Magic == WiaConstants.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>
|
||||
private static RawDataEntry[] ParseRawDataEntries(byte[] plain, int count)
|
||||
{
|
||||
var entries = new RawDataEntry[count];
|
||||
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;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <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];
|
||||
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;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <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];
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Cache for on-demand decompression in ReadVirtual.
|
||||
private uint _cachedRawGroupIndex = uint.MaxValue;
|
||||
private byte[]? _cachedRawGroup;
|
||||
private uint _cachedEncGroupIndex = uint.MaxValue;
|
||||
private byte[]? _cachedEncGroup;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a NintendoDisc wrapper backed by a virtual stream that decompresses
|
||||
/// WIA/RVZ groups on demand, avoiding loading the entire ISO into memory.
|
||||
/// </summary>
|
||||
public NintendoDisc? GetInnerWrapper()
|
||||
{
|
||||
if (Model.Header1.IsoFileSize == 0)
|
||||
return null;
|
||||
|
||||
var vStream = new WiaVirtualStream(this);
|
||||
var disc = NintendoDisc.Create(vStream);
|
||||
if (disc is null)
|
||||
return null;
|
||||
|
||||
// For Wii discs: WIA/RVZ stores partition data already decrypted.
|
||||
// Wire a pre-decrypted reader so NintendoDisc.Extraction bypasses its
|
||||
// AES-CBC decrypt pass and reads directly from our decompressed groups.
|
||||
if (Model.PartitionEntries is { Length: > 0 })
|
||||
disc._preDecryptedReader = BuildPreDecryptedReader();
|
||||
|
||||
return disc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the delegate 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
|
||||
/// <see cref="ReadDecryptedPartitionBytes"/>.
|
||||
/// </summary>
|
||||
private Func<long, long, int, byte[]?> BuildPreDecryptedReader()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <paramref name="count"/> bytes of the virtual decompressed ISO at
|
||||
/// <paramref name="offset"/> into <paramref name="buffer"/>, decompressing
|
||||
/// WIA/RVZ groups on demand. Returns the number of bytes read.
|
||||
/// </summary>
|
||||
internal int ReadVirtual(long offset, byte[] buffer, int bufferOffset, int count)
|
||||
{
|
||||
long isoSize = (long)Model.Header1.IsoFileSize;
|
||||
if (offset >= isoSize || count <= 0)
|
||||
return 0;
|
||||
|
||||
count = (int)Math.Min(count, isoSize - offset);
|
||||
int totalRead = 0;
|
||||
|
||||
while (totalRead < count)
|
||||
{
|
||||
long pos = offset + totalRead;
|
||||
int got = ReadVirtualChunk(pos, buffer, bufferOffset + totalRead, count - totalRead);
|
||||
if (got <= 0)
|
||||
{
|
||||
// Advance past one "zero" byte to avoid infinite loops over gaps.
|
||||
buffer[bufferOffset + totalRead] = 0;
|
||||
totalRead++;
|
||||
}
|
||||
else
|
||||
{
|
||||
totalRead += got;
|
||||
}
|
||||
}
|
||||
|
||||
return totalRead;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads bytes for one contiguous segment of the virtual ISO starting at <paramref name="pos"/>.
|
||||
/// Returns 0 if the position is not covered by any known data entry (caller fills with zeros).
|
||||
/// </summary>
|
||||
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 })
|
||||
{
|
||||
int available = (int)Math.Min(WiaConstants.DiscHeaderStoredSize - pos, count);
|
||||
int srcAvail = Math.Min(available, Model.Header2.DiscHeader.Length - (int)pos);
|
||||
if (srcAvail > 0)
|
||||
Array.Copy(Model.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;
|
||||
|
||||
// 2. Raw data entries (non-partition disc data)
|
||||
if (Model.RawDataEntries is { Length: > 0 })
|
||||
{
|
||||
foreach (var rde in Model.RawDataEntries)
|
||||
{
|
||||
if (rde.DataSize == 0 || rde.NumberOfGroups == 0)
|
||||
continue;
|
||||
|
||||
long rdeStart = (long)rde.DataOffset;
|
||||
long rdeEnd = rdeStart + (long)rde.DataSize;
|
||||
if (pos < rdeStart || pos >= rdeEnd)
|
||||
continue;
|
||||
|
||||
long skippedData = rdeStart % 0x8000;
|
||||
long adjustedBase = rdeStart - skippedData;
|
||||
long adjustedPos = pos - adjustedBase;
|
||||
uint g = (uint)(adjustedPos / chunkSize);
|
||||
int offsetInGroup = (int)(adjustedPos % chunkSize);
|
||||
|
||||
if (g >= rde.NumberOfGroups)
|
||||
continue;
|
||||
|
||||
uint groupFileIdx = rde.GroupIndex + g;
|
||||
byte[]? groupBytes = GetCachedRawGroup(groupFileIdx, comp, compData, compDataSize, chunkSize);
|
||||
if (groupBytes is null)
|
||||
return 0;
|
||||
|
||||
int available = groupBytes.Length - offsetInGroup;
|
||||
if (available <= 0)
|
||||
return 0;
|
||||
|
||||
int remainingInEntry = (int)Math.Min(rdeEnd - pos, count);
|
||||
// Also clamp to the end of this group
|
||||
long groupIsoEnd = adjustedBase + ((long)(g + 1) * chunkSize);
|
||||
int remainingInGroup = (int)Math.Min(groupIsoEnd - pos, remainingInEntry);
|
||||
int toCopy = Math.Min(available, remainingInGroup);
|
||||
if (toCopy <= 0)
|
||||
return 0;
|
||||
|
||||
Array.Copy(groupBytes, offsetInGroup, buffer, bufferOffset, toCopy);
|
||||
return toCopy;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Partition data entries (Wii encrypted partition data)
|
||||
if (Model.PartitionEntries is { Length: > 0 })
|
||||
{
|
||||
foreach (var pe in Model.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;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <paramref name="length"/> bytes of decrypted Wii partition data beginning at
|
||||
/// <paramref name="partDataOffset"/>, a byte offset in the 0x7C00-block partition-data space.
|
||||
/// Spans across both DataEntry0 and DataEntry1 of the partition entry.
|
||||
/// Maps directly to the decompressed WIA/RVZ group data — no re-encryption is performed.
|
||||
/// </summary>
|
||||
internal byte[]? ReadDecryptedPartitionBytes(PartitionEntry pe, long partDataOffset, int length)
|
||||
{
|
||||
if (length <= 0 || pe is null)
|
||||
return null;
|
||||
|
||||
const int WiiBlockSize = 0x8000;
|
||||
const int WiiBlockDataSize = 0x7C00;
|
||||
|
||||
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;
|
||||
|
||||
// DataEntry0 covers [0 .. de0.NumberOfSectors * 0x7C00) in partition-data space.
|
||||
// DataEntry1 (if present) immediately follows.
|
||||
var de0 = pe.DataEntry0;
|
||||
var de1 = pe.DataEntry1;
|
||||
long de0DataSize = (long)de0.NumberOfSectors * WiiBlockDataSize;
|
||||
long de1DataSize = de1 is not null ? (long)de1.NumberOfSectors * WiiBlockDataSize : 0;
|
||||
|
||||
while (produced < length)
|
||||
{
|
||||
long off = partDataOffset + produced;
|
||||
|
||||
// Determine which DataEntry covers this offset
|
||||
PartitionDataEntry de;
|
||||
long deRelOff; // offset within this DataEntry's decrypted data space
|
||||
if (off < de0DataSize)
|
||||
{
|
||||
de = de0;
|
||||
deRelOff = off;
|
||||
}
|
||||
else if (de1 is not null && de1.NumberOfGroups > 0 && off < de0DataSize + de1DataSize)
|
||||
{
|
||||
de = de1;
|
||||
deRelOff = off - de0DataSize;
|
||||
}
|
||||
else
|
||||
{
|
||||
break; // beyond available data
|
||||
}
|
||||
|
||||
long blockNum = deRelOff / WiiBlockDataSize;
|
||||
int offsetInBlock = (int)(deRelOff % WiiBlockDataSize);
|
||||
long groupRelative = blockNum / blocksPerGroup;
|
||||
int blockInGroup = (int)(blockNum % blocksPerGroup);
|
||||
|
||||
if (groupRelative >= de.NumberOfGroups)
|
||||
break;
|
||||
|
||||
uint groupFileIdx = de.GroupIndex + (uint)groupRelative;
|
||||
long dataOffsetForLfg = groupRelative * blocksPerGroup * WiiBlockDataSize;
|
||||
|
||||
byte[]? decrypted = ReadDecryptedGroupData(groupFileIdx, comp, compData, compDataSize,
|
||||
blocksPerGroup, WiiBlockDataSize, dataOffsetForLfg);
|
||||
if (decrypted is null)
|
||||
break;
|
||||
|
||||
int offsetInGroup = (blockInGroup * WiiBlockDataSize) + offsetInBlock;
|
||||
int available = decrypted.Length - offsetInGroup;
|
||||
if (available <= 0)
|
||||
break;
|
||||
|
||||
int remainingInGroup = (blocksPerGroup * WiiBlockDataSize) - offsetInGroup;
|
||||
int toCopy = Math.Min(length - produced, Math.Min(available, remainingInGroup));
|
||||
if (toCopy <= 0)
|
||||
break;
|
||||
|
||||
Array.Copy(decrypted, offsetInGroup, result, produced, toCopy);
|
||||
produced += toCopy;
|
||||
}
|
||||
|
||||
if (produced <= 0)
|
||||
return null;
|
||||
if (produced < length)
|
||||
Array.Resize(ref result, produced);
|
||||
return result;
|
||||
}
|
||||
|
||||
private int ReadPartitionChunk(PartitionDataEntry de, byte[] partitionKey, long pos,
|
||||
byte[] buffer, int bufferOffset, int count,
|
||||
WiaRvzCompressionType comp, byte[] compData, byte compDataSize, uint chunkSize)
|
||||
{
|
||||
if (de.NumberOfSectors == 0 || de.NumberOfGroups == 0)
|
||||
return 0;
|
||||
|
||||
const int WiiBlockSize = 0x8000;
|
||||
if (chunkSize == 0)
|
||||
return 0;
|
||||
|
||||
int blocksPerGroup = (int)(chunkSize / WiiBlockSize);
|
||||
long isoDataStart = (long)de.FirstSector * WiiBlockSize;
|
||||
long isoDataEnd = isoDataStart + ((long)de.NumberOfSectors * WiiBlockSize);
|
||||
|
||||
if (pos < isoDataStart || pos >= isoDataEnd)
|
||||
return 0;
|
||||
|
||||
long offsetInPartition = pos - isoDataStart;
|
||||
long blockNum = offsetInPartition / WiiBlockSize;
|
||||
int offsetInBlock = (int)(offsetInPartition % WiiBlockSize);
|
||||
|
||||
long groupNum = blockNum / blocksPerGroup;
|
||||
int blockInGroup = (int)(blockNum % blocksPerGroup);
|
||||
|
||||
if (groupNum >= de.NumberOfGroups)
|
||||
return 0;
|
||||
|
||||
uint groupFileIdx = de.GroupIndex + (uint)groupNum;
|
||||
byte[]? encryptedGroup = GetCachedEncGroup(groupFileIdx, de, partitionKey,
|
||||
comp, compData, compDataSize, blocksPerGroup, chunkSize);
|
||||
if (encryptedGroup is null)
|
||||
return 0;
|
||||
|
||||
int offsetInEncGroup = (blockInGroup * WiiBlockSize) + offsetInBlock;
|
||||
int available = encryptedGroup.Length - offsetInEncGroup;
|
||||
if (available <= 0)
|
||||
return 0;
|
||||
|
||||
long remainingInEntry = isoDataEnd - pos;
|
||||
// Stay within this group
|
||||
long groupIsoEnd = isoDataStart + ((groupNum + 1) * blocksPerGroup * WiiBlockSize);
|
||||
long remainingInGroup = groupIsoEnd - pos;
|
||||
int toCopy = (int)Math.Min(count, Math.Min(Math.Min(available, remainingInEntry), remainingInGroup));
|
||||
if (toCopy <= 0)
|
||||
return 0;
|
||||
|
||||
Array.Copy(encryptedGroup, offsetInEncGroup, buffer, bufferOffset, toCopy);
|
||||
return toCopy;
|
||||
}
|
||||
|
||||
private byte[]? GetCachedRawGroup(uint groupFileIdx,
|
||||
WiaRvzCompressionType comp, byte[] compData, byte compDataSize, uint chunkSize)
|
||||
{
|
||||
if (_cachedRawGroupIndex == groupFileIdx)
|
||||
return _cachedRawGroup;
|
||||
|
||||
byte[]? group = ReadGroupRaw(groupFileIdx, comp, compData, compDataSize, chunkSize);
|
||||
_cachedRawGroupIndex = groupFileIdx;
|
||||
_cachedRawGroup = group;
|
||||
return group;
|
||||
}
|
||||
|
||||
private byte[]? GetCachedEncGroup(uint groupFileIdx, PartitionDataEntry de, byte[] partitionKey,
|
||||
WiaRvzCompressionType comp, byte[] compData, byte compDataSize, int blocksPerGroup, uint chunkSize)
|
||||
{
|
||||
if (_cachedEncGroupIndex == groupFileIdx)
|
||||
return _cachedEncGroup;
|
||||
|
||||
long dataOffsetForLfg = (groupFileIdx - de.GroupIndex) * blocksPerGroup * 0x7C00;
|
||||
byte[]? decrypted = ReadDecryptedGroupData(groupFileIdx, comp, compData, compDataSize,
|
||||
blocksPerGroup, 0x7C00, dataOffsetForLfg);
|
||||
if (decrypted is null)
|
||||
return null;
|
||||
|
||||
byte[] encrypted = EncryptWiiGroup(decrypted, partitionKey, blocksPerGroup);
|
||||
_cachedEncGroupIndex = groupFileIdx;
|
||||
_cachedEncGroup = encrypted;
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads and decompresses one raw (non-partition) group.
|
||||
/// Returns <c>chunkSize</c> bytes of raw ISO data, or null on failure.
|
||||
/// </summary>
|
||||
private byte[]? ReadGroupRaw(uint groupIdx, WiaRvzCompressionType comp,
|
||||
byte[] compressorData, byte compressorDataSize, uint chunkSize)
|
||||
{
|
||||
if (IsRvz)
|
||||
{
|
||||
if (Model.RvzGroupEntries is null || groupIdx >= Model.RvzGroupEntries.Length)
|
||||
return null;
|
||||
var ge = Model.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);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Model.GroupEntries is null || groupIdx >= Model.GroupEntries.Length)
|
||||
return null;
|
||||
var ge = Model.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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,
|
||||
long dataOffsetForLfg)
|
||||
{
|
||||
int decryptedGroupSize = blocksPerGroup * blockDataSize;
|
||||
|
||||
if (IsRvz)
|
||||
{
|
||||
if (Model.RvzGroupEntries is null || groupIdx >= Model.RvzGroupEntries.Length)
|
||||
return null;
|
||||
var ge = Model.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);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Model.GroupEntries is null || groupIdx >= Model.GroupEntries.Length)
|
||||
return null;
|
||||
var ge = Model.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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
uint chunkSize = 2 * 1024 * 1024)
|
||||
{
|
||||
if (fileData is null || fileData.Length < length)
|
||||
return null;
|
||||
|
||||
// Mirrors DolphinIsoLib WiaRvzReader::ReadGroupCore logic:
|
||||
// Decompress first (Bzip2/LZMA/LZMA2/Zstd), then RVZ-unpack junk regions if present.
|
||||
bool shouldDecompress = comp > WiaRvzCompressionType.Purge && (!isRvz || isRvzCompressed);
|
||||
|
||||
if (comp == WiaRvzCompressionType.None)
|
||||
{
|
||||
// NONE: exception lists precede data with 4-byte alignment for Wii partitions
|
||||
int dataStart = isWiiPartition ? SkipExceptionLists(fileData, offset, length, chunkSize) : offset;
|
||||
int mainLen = length - (dataStart - offset);
|
||||
byte[] noneData = new byte[expectedSize];
|
||||
Array.Copy(fileData, dataStart, noneData, 0, Math.Min(mainLen, expectedSize));
|
||||
return noneData;
|
||||
}
|
||||
else if (comp == WiaRvzCompressionType.Purge)
|
||||
{
|
||||
// Exception list precedes the Purge payload; capture it for SHA-1, then decompress.
|
||||
int purgeStart = isWiiPartition ? SkipExceptionLists(fileData, offset, length, chunkSize) : offset;
|
||||
int exceptionLen = purgeStart - offset;
|
||||
byte[]? exceptionBytes = exceptionLen > 0
|
||||
? new byte[exceptionLen] : null;
|
||||
if (exceptionBytes != null)
|
||||
Array.Copy(fileData, offset, exceptionBytes, 0, exceptionLen);
|
||||
int purgeLen = length - exceptionLen;
|
||||
return PurgeDecompressor.Decompress(fileData, purgeStart, purgeLen, expectedSize, exceptionBytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Bzip2 / LZMA / LZMA2 / Zstd — delegate to WiaRvzCompressionHelper
|
||||
byte[]? workingData;
|
||||
if (shouldDecompress)
|
||||
{
|
||||
try
|
||||
{
|
||||
workingData = WiaRvzCompressionHelper.Decompress(
|
||||
comp, fileData, offset, length, compressorData, compressorDataSize);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (workingData is null)
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
workingData = fileData;
|
||||
}
|
||||
|
||||
// RVZ-pack step: junk regions are stored as LFG seeds rather than raw bytes.
|
||||
if (isRvz && rvzPackedSize > 0)
|
||||
{
|
||||
// Exception lists are always present for Wii partition groups.
|
||||
// When compressed (shouldDecompress=true), they are NOT padded to 4-byte alignment.
|
||||
// When uncompressed (shouldDecompress=false), they ARE padded to 4-byte alignment.
|
||||
int rvzDataStart = isWiiPartition
|
||||
? (shouldDecompress
|
||||
? SkipExceptionListsNoAlign(workingData, 0, workingData.Length, chunkSize)
|
||||
: SkipExceptionLists(workingData, 0, workingData.Length, chunkSize))
|
||||
: 0;
|
||||
int rvzDataLen = workingData.Length - rvzDataStart;
|
||||
byte[] rvzPayload = new byte[rvzDataLen];
|
||||
Array.Copy(workingData, rvzDataStart, rvzPayload, 0, rvzDataLen);
|
||||
|
||||
var rvzDecomp = new RvzPackDecompressor(rvzPayload, rvzPackedSize, dataOffsetForLfg);
|
||||
byte[] unpacked = new byte[expectedSize];
|
||||
int bytesRead = rvzDecomp.Decompress(unpacked, 0, expectedSize);
|
||||
if (bytesRead < expectedSize)
|
||||
Array.Resize(ref unpacked, bytesRead);
|
||||
return unpacked;
|
||||
}
|
||||
|
||||
// Skip exception lists always present for Wii partition groups.
|
||||
// Compressed groups: no 4-byte alignment. Uncompressed groups: 4-byte aligned.
|
||||
int dataStart = isWiiPartition
|
||||
? (shouldDecompress
|
||||
? SkipExceptionListsNoAlign(workingData, 0, workingData.Length, chunkSize)
|
||||
: SkipExceptionLists(workingData, 0, workingData.Length, chunkSize))
|
||||
: 0;
|
||||
int mainLen = workingData.Length - dataStart;
|
||||
byte[] data = new byte[expectedSize];
|
||||
Array.Copy(workingData, dataStart, data, 0, Math.Min(mainLen, expectedSize));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skips the packed exception-list header at the start of group data (NONE/Purge path).
|
||||
/// Exception lists are 4-byte-aligned after the last list.
|
||||
/// Returns the offset of the first data byte.
|
||||
/// </summary>
|
||||
private static int SkipExceptionLists(byte[] data, int offset, int length, uint chunkSize = 2 * 1024 * 1024)
|
||||
{
|
||||
// Number of exception lists = max(1, chunkSize / WiiGroupSize).
|
||||
// For WIA chunkSize==2MiB this is always 1.
|
||||
// For RVZ sub-2MiB chunks this is also 1 (chunkSize <= groupSize).
|
||||
const uint WiiGroupSize = 2 * 1024 * 1024; // 0x200000
|
||||
int numLists = Math.Max(1, (int)(chunkSize / WiiGroupSize));
|
||||
|
||||
int pos = offset;
|
||||
for (int i = 0; i < numLists && pos + 2 <= offset + length; i++)
|
||||
{
|
||||
ushort count = (ushort)((data[pos] << 8) | data[pos + 1]);
|
||||
pos += 2;
|
||||
// Each exception entry is 2 + 20 = 22 bytes
|
||||
pos += count * 22;
|
||||
// 4-byte alignment after last list
|
||||
if (i == numLists - 1)
|
||||
pos = (pos + 3) & ~3;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skips exception lists in compressed group data (Bzip2/LZMA/etc.) where
|
||||
/// lists are NOT 4-byte aligned.
|
||||
/// </summary>
|
||||
private static int SkipExceptionListsNoAlign(byte[] data, int offset, int length, uint chunkSize = 2 * 1024 * 1024)
|
||||
{
|
||||
const uint WiiGroupSize = 2 * 1024 * 1024;
|
||||
int numLists = Math.Max(1, (int)(chunkSize / WiiGroupSize));
|
||||
|
||||
int pos = offset;
|
||||
for (int i = 0; i < numLists && pos + 2 <= offset + length; i++)
|
||||
{
|
||||
ushort count = (ushort)((data[pos] << 8) | data[pos + 1]);
|
||||
pos += 2;
|
||||
pos += count * 22;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-encrypts one decrypted hash-stripped Wii group back into standard ISO-layout
|
||||
/// encrypted 0x8000-byte blocks. Mirrors Dolphin's VolumeWii::EncryptGroup.
|
||||
/// </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;
|
||||
|
||||
// --- Build H0 / H1 / H2 hash arrays ---
|
||||
byte[][][] h0 = new byte[blocksPerGroup][][];
|
||||
for (int b = 0; b < blocksPerGroup; b++)
|
||||
{
|
||||
h0[b] = new byte[H0Count][];
|
||||
int blockBase = b * WiiBlockDataSize;
|
||||
for (int h = 0; h < H0Count; h++)
|
||||
{
|
||||
int src = blockBase + (h * 0x400);
|
||||
int len = Math.Min(0x400, decryptedData.Length - src);
|
||||
h0[b][h] = ComputeSha1(decryptedData, src < decryptedData.Length ? src : 0, Math.Max(0, len));
|
||||
}
|
||||
}
|
||||
|
||||
// H1[h1Group][slot] = SHA1 of block (h1Group*8+slot)'s 31 H0 hashes
|
||||
byte[][][] h1 = new byte[H1Count][][];
|
||||
for (int g = 0; g < H1Count; g++)
|
||||
{
|
||||
h1[g] = new byte[H1Count][];
|
||||
for (int s = 0; s < H1Count; s++)
|
||||
{
|
||||
int blockIdx = (g * H1Count) + s;
|
||||
if (blockIdx >= blocksPerGroup)
|
||||
{
|
||||
h1[g][s] = new byte[HashLen];
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] h0Concat = new byte[H0Count * HashLen];
|
||||
for (int i = 0; i < H0Count; i++)
|
||||
Array.Copy(h0[blockIdx][i], 0, h0Concat, i * HashLen, HashLen);
|
||||
h1[g][s] = ComputeSha1(h0Concat, 0, h0Concat.Length);
|
||||
}
|
||||
}
|
||||
|
||||
// H2[h2Idx] = SHA1 of H1 group h2Idx's 8 hashes (same for every block)
|
||||
byte[][] h2 = new byte[H2Count][];
|
||||
for (int i = 0; i < H2Count; i++)
|
||||
{
|
||||
int grp = Math.Min(i, h1.Length - 1);
|
||||
byte[] h1Concat = new byte[H1Count * HashLen];
|
||||
for (int s = 0; s < H1Count; s++)
|
||||
Array.Copy(h1[grp][s], 0, h1Concat, s * HashLen, HashLen);
|
||||
h2[i] = ComputeSha1(h1Concat, 0, h1Concat.Length);
|
||||
}
|
||||
|
||||
byte[] result = new byte[blocksPerGroup * WiiBlockSize];
|
||||
|
||||
for (int b = 0; b < blocksPerGroup; b++)
|
||||
{
|
||||
// Serialize hash block
|
||||
byte[] hashBlock = new byte[WiiBlockHashSize];
|
||||
int off = 0;
|
||||
|
||||
// H0 (31 * 20 = 0x26C)
|
||||
for (int i = 0; i < H0Count; i++) { Array.Copy(h0[b][i], 0, hashBlock, off, HashLen); off += HashLen; }
|
||||
|
||||
off += 0x14; // padding0
|
||||
|
||||
// H1 for this block's group (8 * 20 = 0xA0)
|
||||
int h1Grp = b / H1Count;
|
||||
if (h1Grp < h1.Length)
|
||||
{
|
||||
for (int i = 0; i < H1Count; i++) { Array.Copy(h1[h1Grp][i], 0, hashBlock, off, HashLen); off += HashLen; }
|
||||
}
|
||||
else
|
||||
{
|
||||
off += H1Count * HashLen;
|
||||
}
|
||||
|
||||
off += 0x20; // padding1
|
||||
|
||||
// H2 (8 * 20 = 0xA0)
|
||||
for (int i = 0; i < H2Count; i++) { Array.Copy(h2[i], 0, hashBlock, off, HashLen); off += HashLen; }
|
||||
// Note: off is now 0x3D4; IV will sit at 0x3D0 after encryption
|
||||
|
||||
// Encrypt hash block with IV = zero
|
||||
byte[] encHashBlock = AesCbc.Encrypt(hashBlock, key, new byte[16]) ?? new byte[WiiBlockHashSize];
|
||||
|
||||
// Extract IV for data block from offset 0x3D0 of the encrypted hash block
|
||||
byte[] iv = new byte[16];
|
||||
Array.Copy(encHashBlock, 0x3D0, iv, 0, 16);
|
||||
|
||||
// Encrypt data block
|
||||
int dataSrc = b * WiiBlockDataSize;
|
||||
int dataLen = Math.Min(WiiBlockDataSize, decryptedData.Length - dataSrc);
|
||||
byte[] dataBlock = new byte[WiiBlockDataSize];
|
||||
if (dataLen > 0)
|
||||
Array.Copy(decryptedData, dataSrc, dataBlock, 0, dataLen);
|
||||
|
||||
byte[] encDataBlock = AesCbc.Encrypt(dataBlock, key, iv) ?? new byte[WiiBlockDataSize];
|
||||
|
||||
int dest = b * WiiBlockSize;
|
||||
Array.Copy(encHashBlock, 0, result, dest, WiiBlockHashSize);
|
||||
Array.Copy(encDataBlock, 0, result, dest + WiiBlockHashSize, WiiBlockDataSize);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] ComputeSha1(byte[] data, int offset, int count)
|
||||
{
|
||||
if (count == 0)
|
||||
return new byte[20];
|
||||
|
||||
using var sha1 = new HashWrapper(HashType.SHA1);
|
||||
sha1.Process(data, offset, count);
|
||||
sha1.Terminate();
|
||||
return sha1.CurrentHashBytes ?? new byte[20];
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
199
SabreTools.Wrappers/WiaRvzCompressionHelper.cs
Normal file
199
SabreTools.Wrappers/WiaRvzCompressionHelper.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
#if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER
|
||||
using System.IO;
|
||||
using SabreTools.IO.Extensions;
|
||||
using SharpCompress.Compressors;
|
||||
using SharpCompress.Compressors.BZip2;
|
||||
using SharpCompress.Compressors.LZMA;
|
||||
using SharpCompress.Compressors.ZStandard;
|
||||
#endif
|
||||
using SabreTools.Data.Models.WIA;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
/// <summary>
|
||||
/// Compress and decompress helpers for WIA / RVZ group and table data.
|
||||
/// Mirrors Dolphin's WIACompression.cpp: Bzip2, LZMA (raw, no stream header), LZMA2, and Zstd.
|
||||
/// </summary>
|
||||
internal static class WiaRvzCompressionHelper
|
||||
{
|
||||
// Dictionary sizes per compression level 1–9 (index 0 unused).
|
||||
// Mirrors Dolphin WIACompression.cpp dict_size choices.
|
||||
private static readonly int[] DictSizes =
|
||||
{
|
||||
0, // 0: unused
|
||||
1 << 16, // 1: 64 KiB
|
||||
1 << 20, // 2: 1 MiB
|
||||
1 << 22, // 3: 4 MiB
|
||||
1 << 22, // 4: 4 MiB
|
||||
1 << 23, // 5: 8 MiB
|
||||
1 << 23, // 6: 8 MiB
|
||||
1 << 24, // 7: 16 MiB
|
||||
1 << 25, // 8: 32 MiB
|
||||
1 << 26, // 9: 64 MiB
|
||||
};
|
||||
|
||||
private static int GetDictSize(int level) =>
|
||||
DictSizes[Math.Max(1, Math.Min(9, level))];
|
||||
|
||||
// Returns the raw LZMA2 dict-size property byte for a given dictionary size.
|
||||
private static uint Lzma2DictSize(byte p) => (uint)((2 | (p & 1)) << ((p / 2) + 11));
|
||||
|
||||
private static byte EncodeLzma2DictSize(uint d)
|
||||
{
|
||||
byte e = 0;
|
||||
while (e < 40 && d > Lzma2DictSize(e))
|
||||
e++;
|
||||
return e;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills the compressor-data bytes for <c>WiaHeader2.CompressorData</c> /
|
||||
/// <c>WiaHeader2.CompressorDataSize</c>.
|
||||
/// LZMA: 5 bytes. LZMA2: 1 byte. Others: 0 bytes.
|
||||
/// </summary>
|
||||
internal static void GetCompressorData(WiaRvzCompressionType type, int level,
|
||||
out byte[] propData, out byte propSize)
|
||||
{
|
||||
propData = new byte[7];
|
||||
int dictSize = GetDictSize(level);
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case WiaRvzCompressionType.LZMA:
|
||||
propData[0] = 0x5D; // propByte for default pb=2,lp=0,lc=3
|
||||
propData[1] = (byte)dictSize;
|
||||
propData[2] = (byte)(dictSize >> 8);
|
||||
propData[3] = (byte)(dictSize >> 16);
|
||||
propData[4] = (byte)(dictSize >> 24);
|
||||
propSize = 5;
|
||||
break;
|
||||
|
||||
case WiaRvzCompressionType.LZMA2:
|
||||
propData[0] = EncodeLzma2DictSize((uint)dictSize);
|
||||
propSize = 1;
|
||||
break;
|
||||
|
||||
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)
|
||||
{
|
||||
#if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER
|
||||
switch (type)
|
||||
{
|
||||
case WiaRvzCompressionType.Bzip2:
|
||||
return CompressBzip2(data, offset, length);
|
||||
case WiaRvzCompressionType.LZMA:
|
||||
return CompressLzma(data, offset, length, level, isLzma2: false);
|
||||
case WiaRvzCompressionType.LZMA2:
|
||||
return CompressLzma(data, offset, length, level, isLzma2: true);
|
||||
case WiaRvzCompressionType.Zstd:
|
||||
return CompressZstd(data, offset, length, level);
|
||||
default:
|
||||
throw new ArgumentException($"Cannot compress type {type}", nameof(type));
|
||||
}
|
||||
#else
|
||||
throw new PlatformNotSupportedException("WIA/RVZ compression requires .NET 4.6.2 or later.");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
case WiaRvzCompressionType.LZMA2:
|
||||
{
|
||||
byte[] props = new byte[compressorDataSize];
|
||||
Array.Copy(compressorData, props, compressorDataSize);
|
||||
return DecompressLzma(data, offset, length, props, isLzma2: true);
|
||||
}
|
||||
case WiaRvzCompressionType.Zstd:
|
||||
return DecompressZstd(data, offset, length);
|
||||
default:
|
||||
throw new ArgumentException($"Cannot decompress type {type}", nameof(type));
|
||||
}
|
||||
#else
|
||||
throw new PlatformNotSupportedException("WIA/RVZ decompression requires .NET 4.6.2 or later.");
|
||||
#endif
|
||||
}
|
||||
|
||||
#if NET462_OR_GREATER || NETCOREAPP || NETSTANDARD2_0_OR_GREATER
|
||||
|
||||
private static byte[] CompressBzip2(byte[] data, int offset, int length)
|
||||
{
|
||||
using var outMs = new MemoryStream();
|
||||
using (var bz2 = BZip2Stream.Create(outMs, CompressionMode.Compress, false, true))
|
||||
{
|
||||
bz2.Write(data, offset, length);
|
||||
}
|
||||
return outMs.ToArray();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private static byte[] CompressLzma(byte[] data, int offset, int length, int level, bool isLzma2)
|
||||
{
|
||||
int dictSize = GetDictSize(level);
|
||||
using var outMs = new MemoryStream();
|
||||
using (var lzma = LzmaStream.Create(new LzmaEncoderProperties(true, dictSize), isLzma2, outMs))
|
||||
{
|
||||
lzma.Write(data, offset, length);
|
||||
}
|
||||
return outMs.ToArray();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
zstd.Write(data, offset, length);
|
||||
}
|
||||
return outMs.ToArray();
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
80
SabreTools.Wrappers/WiaVirtualStream.cs
Normal file
80
SabreTools.Wrappers/WiaVirtualStream.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace SabreTools.Wrappers
|
||||
{
|
||||
/// <summary>
|
||||
/// A read-only seekable stream that decompresses WIA/RVZ groups on demand.
|
||||
/// Avoids loading the entire decompressed disc image into memory.
|
||||
/// </summary>
|
||||
internal sealed class WiaVirtualStream : Stream
|
||||
{
|
||||
private readonly WIA _wia;
|
||||
private long _position;
|
||||
|
||||
public WiaVirtualStream(WIA wia)
|
||||
{
|
||||
_wia = wia ?? throw new ArgumentNullException(nameof(wia));
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => (long)_wia.IsoFileSize;
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set
|
||||
{
|
||||
if (value < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
_position = value;
|
||||
}
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (buffer is null)
|
||||
throw new ArgumentNullException(nameof(buffer));
|
||||
if (offset < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
if (count < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
if (offset + count > buffer.Length)
|
||||
throw new ArgumentException("offset + count exceeds buffer length");
|
||||
|
||||
long remaining = Length - _position;
|
||||
if (remaining <= 0 || count <= 0)
|
||||
return 0;
|
||||
|
||||
count = (int)Math.Min(count, remaining);
|
||||
int read = _wia.ReadVirtual(_position, buffer, offset, count);
|
||||
_position += read;
|
||||
return read;
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
long newPos;
|
||||
switch (origin)
|
||||
{
|
||||
case SeekOrigin.Begin: newPos = offset; break;
|
||||
case SeekOrigin.Current: newPos = _position + offset; break;
|
||||
case SeekOrigin.End: newPos = Length + offset; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(origin));
|
||||
}
|
||||
|
||||
if (newPos < 0)
|
||||
throw new IOException("Seek position cannot be negative.");
|
||||
|
||||
_position = newPos;
|
||||
return _position;
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ namespace SabreTools.Wrappers
|
||||
WrapperType.Executable => CreateExecutableWrapper(data),
|
||||
WrapperType.FDS => FDS.Create(data),
|
||||
WrapperType.GCF => GCF.Create(data),
|
||||
WrapperType.GCZ => GCZ.Create(data),
|
||||
WrapperType.GZip => GZip.Create(data),
|
||||
WrapperType.InstallShieldArchiveV3 => InstallShieldArchiveV3.Create(data),
|
||||
WrapperType.InstallShieldCAB => InstallShieldCabinet.Create(data),
|
||||
@@ -44,6 +45,7 @@ namespace SabreTools.Wrappers
|
||||
WrapperType.NCF => NCF.Create(data),
|
||||
WrapperType.NESCart => NESCart.Create(data),
|
||||
WrapperType.Nitro => Nitro.Create(data),
|
||||
WrapperType.NintendoDisc => NintendoDisc.Create(data),
|
||||
WrapperType.PAK => PAK.Create(data),
|
||||
WrapperType.PFF => PFF.Create(data),
|
||||
WrapperType.PIC => PIC.Create(data),
|
||||
@@ -68,6 +70,8 @@ namespace SabreTools.Wrappers
|
||||
WrapperType.WAD => WAD3.Create(data),
|
||||
WrapperType.WiseOverlayHeader => WiseOverlayHeader.Create(data),
|
||||
WrapperType.WiseScript => WiseScript.Create(data),
|
||||
WrapperType.WIA => WIA.Create(data),
|
||||
WrapperType.RVZ => WIA.Create(data),
|
||||
WrapperType.XboxExecutable => XboxExecutable.Create(data),
|
||||
WrapperType.XDVDFS => XDVDFS.Create(data),
|
||||
WrapperType.XenonExecutable => XenonExecutable.Create(data),
|
||||
@@ -139,6 +143,14 @@ namespace SabreTools.Wrappers
|
||||
// Cache the current offset
|
||||
long initialOffset = stream.Position;
|
||||
|
||||
// Try NintendoDisc (GameCube / Wii) first — detected by magic at 0x018 / 0x01C
|
||||
var nintendoWrapper = NintendoDisc.Create(stream);
|
||||
if (nintendoWrapper is not null)
|
||||
return nintendoWrapper;
|
||||
|
||||
// Reset position in stream
|
||||
stream.SeekIfPossible(initialOffset, SeekOrigin.Begin);
|
||||
|
||||
// Try to get an Xbox disc image wrapper (must be before ISO9660)
|
||||
var xboxWrapper = XboxISO.Create(stream);
|
||||
if (xboxWrapper is not null)
|
||||
@@ -472,6 +484,17 @@ namespace SabreTools.Wrappers
|
||||
|
||||
#endregion
|
||||
|
||||
#region GCZ
|
||||
|
||||
// GCZ magic cookie (0xB10BC001 stored little-endian)
|
||||
if (magic.StartsWith([0x01, 0xC0, 0x0B, 0xB1]))
|
||||
return WrapperType.GCZ;
|
||||
|
||||
if (extension.Equals("gcz", StringComparison.OrdinalIgnoreCase))
|
||||
return WrapperType.GCZ;
|
||||
|
||||
#endregion
|
||||
|
||||
#region GZip
|
||||
|
||||
if (magic.StartsWith(Data.Models.GZIP.Constants.SignatureBytes))
|
||||
@@ -518,6 +541,36 @@ namespace SabreTools.Wrappers
|
||||
|
||||
#endregion
|
||||
|
||||
#region NintendoDisc
|
||||
|
||||
// Wii disc magic at offset 0x018 (0x5D1C9EA3 stored big-endian on disc)
|
||||
if (magic.Length > 0x1B && magic[0x18] == 0x5D && magic[0x19] == 0x1C && magic[0x1A] == 0x9E && magic[0x1B] == 0xA3)
|
||||
return WrapperType.NintendoDisc;
|
||||
|
||||
// GameCube disc magic at offset 0x01C (0xC2339F3D stored big-endian on disc)
|
||||
if (magic.Length > 0x1F && magic[0x1C] == 0xC2 && magic[0x1D] == 0x33 && magic[0x1E] == 0x9F && magic[0x1F] == 0x3D)
|
||||
return WrapperType.NintendoDisc;
|
||||
|
||||
// GameCube/Wii disc by GameId prefix: first byte is a known title type code,
|
||||
// bytes 1-2 are ASCII letters (region + developer), bytes 3-4 are ASCII digits or letters (title code),
|
||||
// byte 5 is an ASCII digit (disc number). Covers redump ISOs that lack magic words.
|
||||
if (magic.Length > 5
|
||||
&& IsNintendoDiscTitleType(magic[0])
|
||||
&& magic[1] >= 0x41 && magic[1] <= 0x5A // A-Z
|
||||
&& ((magic[2] >= 0x30 && magic[2] <= 0x39) || (magic[2] >= 0x41 && magic[2] <= 0x5A)) // 0-9 or A-Z
|
||||
&& ((magic[3] >= 0x30 && magic[3] <= 0x39) || (magic[3] >= 0x41 && magic[3] <= 0x5A)) // 0-9 or A-Z
|
||||
&& ((magic[4] >= 0x30 && magic[4] <= 0x39) || (magic[4] >= 0x41 && magic[4] <= 0x5A)) // 0-9 or A-Z
|
||||
&& magic[5] >= 0x30 && magic[5] <= 0x39 // 0-9
|
||||
&& (extension.Equals("iso", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals("gcm", StringComparison.OrdinalIgnoreCase)))
|
||||
return WrapperType.NintendoDisc;
|
||||
|
||||
// .gcm files are always GameCube disc images
|
||||
if (extension.Equals("gcm", StringComparison.OrdinalIgnoreCase))
|
||||
return WrapperType.NintendoDisc;
|
||||
|
||||
#endregion
|
||||
|
||||
#region ISO9660
|
||||
|
||||
if (extension.Equals("iso", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -1034,6 +1087,24 @@ namespace SabreTools.Wrappers
|
||||
|
||||
#endregion
|
||||
|
||||
#region WIA
|
||||
|
||||
// WIA magic ("WIA\x01" stored little-endian: 0x01414957)
|
||||
if (magic.StartsWith([0x57, 0x49, 0x41, 0x01]))
|
||||
return WrapperType.WIA;
|
||||
|
||||
// RVZ magic ("RVZ\x01" stored little-endian: 0x015A5652)
|
||||
if (magic.StartsWith([0x52, 0x56, 0x5A, 0x01]))
|
||||
return WrapperType.RVZ;
|
||||
|
||||
if (extension.Equals("wia", StringComparison.OrdinalIgnoreCase))
|
||||
return WrapperType.WIA;
|
||||
|
||||
if (extension.Equals("rvz", StringComparison.OrdinalIgnoreCase))
|
||||
return WrapperType.RVZ;
|
||||
|
||||
#endregion
|
||||
|
||||
#region XboxExecutable
|
||||
|
||||
if (magic.StartsWith(Data.Models.XboxExecutable.Constants.MagicBytes))
|
||||
@@ -1115,5 +1186,17 @@ namespace SabreTools.Wrappers
|
||||
// We couldn't find a supported match
|
||||
return WrapperType.UNKNOWN;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the byte is a known Nintendo disc title type code
|
||||
/// (first byte of the 6-char GameId, e.g. 'G'=GameCube, 'R'=GameCube,
|
||||
/// 'D'=GameCube demo, 'S'=Wii, 'F'=Wii channel)
|
||||
/// </summary>
|
||||
private static bool IsNintendoDiscTitleType(byte b)
|
||||
{
|
||||
// Standard GameCube and Wii title type prefixes used by Nintendo and licensees
|
||||
return b == (byte)'G' || b == (byte)'D' || b == (byte)'R'
|
||||
|| b == (byte)'S' || b == (byte)'F';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,11 @@ namespace SabreTools.Wrappers
|
||||
/// </summary>
|
||||
GCF,
|
||||
|
||||
/// <summary>
|
||||
/// GCZ compressed GameCube / Wii disc image
|
||||
/// </summary>
|
||||
GCZ,
|
||||
|
||||
/// <summary>
|
||||
/// gzip archive
|
||||
/// </summary>
|
||||
@@ -162,6 +167,11 @@ namespace SabreTools.Wrappers
|
||||
/// </summary>
|
||||
Nitro,
|
||||
|
||||
/// <summary>
|
||||
/// Nintendo GameCube / Wii disc image
|
||||
/// </summary>
|
||||
NintendoDisc,
|
||||
|
||||
/// <summary>
|
||||
/// Half-Life Package File
|
||||
/// </summary>
|
||||
@@ -288,6 +298,16 @@ namespace SabreTools.Wrappers
|
||||
/// </summary>
|
||||
WiseScript,
|
||||
|
||||
/// <summary>
|
||||
/// WIA compressed GameCube / Wii disc image
|
||||
/// </summary>
|
||||
WIA,
|
||||
|
||||
/// <summary>
|
||||
/// RVZ compressed GameCube / Wii disc image
|
||||
/// </summary>
|
||||
RVZ,
|
||||
|
||||
/// <summary>
|
||||
/// XBox Executable
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user