mirror of
https://github.com/SabreTools/SabreTools.Serialization.git
synced 2026-07-09 02:16:55 +00:00
* 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>
267 lines
10 KiB
C#
267 lines
10 KiB
C#
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
|
||
}
|
||
}
|