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

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

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

* Implement GetInnerWrapper for GCZ and WIA, full NintendoDisc extraction

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

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

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

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

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

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

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

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

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

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

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

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

* Address PR #85 review comments

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

* Update GCZ.Printing.cs

* Update NintendoDisc.Printing.cs

* Update WIA.Printing.cs

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

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

* Remove hardcoded Wii common keys from NintendoDisc.Encryption

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

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

* Edited a comment

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

---------

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

267 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.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 &lt; 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
}
}