# Library usage guide

`RVZSharp` is a pure managed library (no native code) for **.NET 8, .NET 9 and .NET 10**.
It reads and writes Dolphin **RVZ** and **WIA** disc images, writes the legacy **GCZ** format,
reads the remaining legacy GameCube/Wii formats (**CISO/WBI, WBFS, TGC, NFS**), and exposes
every format through one interface that serves the original disc bytes.

```
Install-Package RVZSharp          # Package Manager
dotnet add package RVZSharp       # .NET CLI
```

All public types live in the `RVZSharp` assembly; the main namespaces are:

| Namespace | Contents |
|---|---|
| `RVZSharp.Blobs` | `IBlobReader`, `Blob` (factory), `BlobType`, per-format readers |
| `RVZSharp` | `RvzReader`, `RvzWriter`, `WiaWriter`, `GczWriter`, `CisoWriter`, `WbfsWriter`, `TgcWriter`, `DiscHasher`, `BlobStream`, `RvzWriteOptions`, `GczWriteOptions` |
| `RVZSharp.Models` | models: `DiscInfo`, `DiscFileInfo`, container structs (`WiaFileHead`, `WiaDisc`, `WiaPartEntry`, `GroupEntry`, `HashExceptionEntry`, `DiscHashes`), options records and enums (`CompressionType`, `BlobType`, `DiscType`, `WiaRvzFormat`) |
| `RVZSharp.Chunks` | `ChunkDecoder`, `ExceptionListParser` |
| `RVZSharp.Compression` | codec factories: `CompressionCodecFactory`, `CompressionEncoderFactory` (the vendored 7-Zip LZMA port is internal) |
| `RVZSharp.IO` | `Adler32`, `SpanReader`, `SectionStream`, `NonDisposingStream` |
| `RVZSharp.Packing` | `RvzPackingDecoder`, `RvzPackingEncoder`, `LaggedFibonacciGenerator` |
| `RVZSharp.Wii` | `PartitionRegionBuilder`, `WiiHashCalculator`, `WiiVolume`, `WiiPartitionExtractor`, `PartitionReader` |
| `RVZSharp.Files` | `DiscFileSystem` (FST parsing and file extraction; the `DiscFileInfo` tree nodes live in `RVZSharp.Models`) |
| `RVZSharp.Verification` | `DiscVerifier` and the `VerificationReport`/`VerificationIssue`/`PartitionVerification`/`VerificationSeverity` models |

---

## Quickstart

```csharp
using RVZSharp;
using RVZSharp.Blobs;
using RVZSharp.Models;

// 1. Open any disc image (format is auto-detected from the magic bytes).
using var blob = Blob.Open(@"C:\games\my-game.rvz");

// 2. Read the whole disc as ISO bytes.
byte[] iso = blob.ReadFully();          // fine for small images

// 3. Or convert it to RVZ with a different codec.
using var output = File.Create(@"C:\games\my-game-v2.rvz");
RvzWriter.Write(blob, output, new RvzWriteOptions
{
    Compression = CompressionType.Zstd,
    CompressionLevel = 5,
    ChunkSize = 0x200000,               // 2 MiB
});
```

That is the entire core API: open anything, read ISO bytes, write RVZ.

---

## Opening disc images

Everything starts with `Blob.Open`, which sniffs the first four bytes and returns the right
reader:

| Magic bytes | Format | Reader type |
|---|---|---|
| `52 56 5A 01` (`RVZ\x01`) | RVZ | `RvzReader` |
| `57 49 41 01` (`WIA\x01`) | WIA | `RvzReader` |
| `43 49 53 4F` (`CISO`) | CISO/WBI | `CisoBlob` |
| `01 C0 0B B1` | GCZ | `GczBlob` |
| `57 42 46 53` (`WBFS`) | WBFS | `WbfsBlob` |
| `45 47 47 53` (`EGGS`) | NFS | `NfsBlob` |
| `AE 0F 38 A2` | TGC | `TgcBlob` |
| anything else | plain ISO | `PlainBlob` |

### Overloads

```csharp
// From a path (the reader owns and closes the file stream).
using IBlobReader blob = Blob.Open(@"C:\games\game.gcz");

// From an existing stream.
using var stream = File.OpenRead(@"C:\games\game.iso");
using IBlobReader blob = Blob.Open(stream, filePath: @"C:\games\game.iso", leaveOpen: false);

// NFS with an explicit AES key (bypasses the code/htk.bin lookup).
byte[] key = ReadKeyFromSomewhere();          // 16 bytes
using IBlobReader blob = Blob.Open(@"C:\content\hif_000000.nfs", key);
```

Notes:

- `Blob.Open(Stream, ...)` requires a **seekable** stream (`ArgumentException` otherwise).
- The `filePath` argument is consulted for NFS (locating `code/htk.bin` and the
  `hif_00000X.nfs` continuation files), for **split WBFS** images (`game.wbfs` +
  `game.wbf1…` parts are opened like Dolphin; the declared size is checked against the sum
  of the parts) and for **split plain ISOs** (`game.part0.iso` + `game.part1.iso…`,
  concatenated like Dolphin's `SplitPlainFileReader`; a zero-size continuation falls back to
  the first part). NFS files must be named `hif_000000.nfs` and live in a directory named
  `content`; use the key overload to bypass the on-disk lookup.
- `leaveOpen` controls whether disposing the reader also disposes the stream.
- **What `Blob.Open` accepts and rejects:** a file that starts with a *recognized*
  container magic is always parsed as that container; a parse failure throws an
  `RvzException` subclass (`RvzFormatException` for structural damage,
  `RvzUnsupportedException` for newer versions, `RvzHashMismatchException` for failed
  integrity checks). Only files with **no recognizable magic** fall back to `PlainBlob`
  (an arbitrary file then opens successfully and `ReadAt` serves its bytes — whether it
  is a real disc is a separate question the writer answers). CISO is validated lazily
  like Dolphin: a header with a plausible block size opens even if the payload is
  garbage (absent blocks decode to zeroes); the disc-header validation in
  `RvzWriter.Write` is what keeps such garbage from being wrapped into an RVZ.

### Is it a real disc?

Container sniffing only recognizes the *file format*; a plain file that is not a disc
opens as `PlainBlob` too. To ask "is this actually a GameCube/Wii disc?", inspect the
decoded disc header (Wii magic at `0x18`, GameCube magic at `0x1C` — the same check
Dolphin's `TryCreateDisc` and `RvzWriter.Write` perform):

```csharp
using IBlobReader blob = Blob.Open(path);

if (!Blob.IsDisc(blob))                 // false for arbitrary data
    throw new InvalidDataException("not a GameCube/Wii disc");

DiscType type = Blob.GetDiscType(blob); // DiscType.Wii / DiscType.GameCube / DiscType.Unknown
```

`Blob.GetDiscType` / `Blob.IsDisc` read the decoded bytes, so they work for every input
format (plain ISO or any container). `RvzWriter.Write` rejects inputs that fail this check
with `RvzFormatException` before writing a byte.

### Disc metadata

`DiscInfo.TryRead(blob)` reads the volume metadata from the decoded disc header (Dolphin:
`VolumeDisc`); `DiscInfo.Read` throws `RvzFormatException` for non-discs:

```csharp
var info = DiscInfo.TryRead(blob);
if (info is not null)
{
    Console.WriteLine($"{info.GameId} {info.MakerId} rev {info.Revision}");
    Console.WriteLine($"{info.InternalName} ({info.Region}, {info.Country})");
    if (info.TitleId is ulong titleId)
        Console.WriteLine($"title 0x{titleId:X16}");
}
```

| Property | Meaning |
|---|---|
| `DiscType` | `GameCube` / `Wii` (from the header magic) |
| `GameId` / `MakerId` | 6- and 2-character IDs; non-alphanumeric bytes become `-` |
| `Revision` | revision byte (header offset 7) |
| `InternalName` | name at 0x20 (CP1252, or Shift-JIS for NTSC-J) |
| `Region` | `NTSC-J`, `NTSC-U`, `PAL`, `NTSC-K` or `Unknown` |
| `Country` | `Japan`, `USA`, `Europe`, … (with Dolphin's region fallback) |
| `CountryCode` | raw country byte (header offset 3) |
| `TitleId` | Wii title ID from the game partition's ticket, or null |

### As a `Stream`

`BlobStream` exposes any blob as a read-only, seekable `Stream` of any size:

```csharp
using var blob = Blob.Open(path);
using var stream = new BlobStream(blob);   // leaveOpen defaults to false
var reader = new BinaryReader(stream);
```

### Scrubbing

`ScrubbedBlob.Create(blob)` wraps any disc image and zeroes the data of every non-game Wii
partition (update/channel) — the safe subset of Dolphin's `DiscScrubber` that needs no
filesystem (FST) parser. It returns `null` for discs that cannot be scrubbed (non-Wii, or
no game partition). The CLI's `convert -s/--scrub` uses it, and the writers expose the same
behavior through `RvzWriteOptions.Scrub` / `GczWriteOptions.Scrub` (ignored for GameCube
discs and Wii images without a game partition).

### Lifetime

All readers implement `IDisposable`. Dispose them to release the underlying stream; the
readers themselves hold no other unmanaged resources.

---

## The `IBlobReader` contract

```csharp
public interface IBlobReader : IDisposable
{
    BlobType Type { get; }      // detected format
    long Length { get; }        // decoded ISO size in bytes
    int BlockSize { get; }      // natural block size, 0 when the format has none
    int ReadAt(long position, Span<byte> buffer);
    byte[] ReadFully();         // default implementation (streams via ReadAt, ≤ 2 GiB)
    byte[] ReadFully(IProgress<double>? progress, CancellationToken cancellationToken = default);
    long CopyTo(Stream destination, IProgress<double>? progress = null,
        CancellationToken cancellationToken = default);
    long CopyTo(Stream destination, IProgress<double>? progress, int maxThreads,
        CancellationToken cancellationToken = default);
    Task<byte[]> ReadFullyAsync(IProgress<double>? progress = null, CancellationToken cancellationToken = default);
    Task<long> CopyToAsync(Stream destination, IProgress<double>? progress = null,
        CancellationToken cancellationToken = default);
    Task<long> CopyToAsync(Stream destination, IProgress<double>? progress, int maxThreads,
        CancellationToken cancellationToken = default);
}
```

Every format — RVZ, GCZ, WBFS, a raw ISO — decodes to the same thing: the original disc
image bytes, randomly accessible.

```csharp
using var blob = Blob.Open(path);

// Random access: read one 0x8000-byte sector.
Span<byte> sector = stackalloc byte[0x8000];
blob.ReadAt(0x1234 * 0x8000, sector);

// Whole-image decode (images up to 2 GiB).
byte[] iso = blob.ReadFully();
```

- `ReadAt` returns the number of bytes actually read; it reads fewer bytes only at the end
  of the image. Reads are **not** required to be sequential.
- `BlockSize` is the format's natural block (GCZ: 0x4000, RVZ: chunk size, CISO: 0x8000,
  WBFS: cluster size, NFS: 0x8000, TGC/plain: 0).
- `ReadFully()` / `ReadFully(progress, ct)` are default interface methods: they work on
  every reader and are overridden where a faster path exists (`RvzReader`).
- `CopyTo` streams the whole image into any `Stream` in 1 MiB blocks — no 2 GiB limit —
  reporting progress and observing cancellation. `RvzReader` exposes the same method
  directly, so no cast is needed.

### Streaming pattern (large images)

GameCube/Wii images are 0.5–9.4 GiB. To avoid holding the whole image in memory, stream it
with `CopyTo`:

```csharp
using var blob = Blob.Open(path);
using var output = File.Create(@"C:\games\out.iso");
var progress = new Progress<double>(f => Console.Error.Write($"\r{f,6:P1}"));

blob.CopyTo(output, progress);
```

For explicit random-access control, the same loop can be written by hand with `ReadAt`
(1 MiB at a time, as `CopyTo` does internally).

### Parallel and async decoding

`CopyTo(..., maxThreads)` decodes RVZ/WIA chunks on a worker pool (raw chunks and 64-sector
partition regions are independent units; only the short file reads are serialized, so
LZMA/LZMA2 decompression runs in parallel). Results are written in disc order, so the output
is byte-identical for any thread count. Other formats fall back to the sequential default
implementation. `0` uses the processor count, `1` forces sequential.

```csharp
blob.CopyTo(output, progress, maxThreads: 0);   // parallel where supported
```

The `...Async` forms run the synchronous, CPU-bound decoders on the thread pool and return
a task; progress and cancellation behave exactly like the synchronous methods.

```csharp
byte[] iso = await blob.ReadFullyAsync(progress, cancellationToken);
await blob.CopyToAsync(output, progress, maxThreads: 0, cancellationToken);
```

---

## Reading RVZ / WIA in detail

`RvzReader` exposes the container metadata plus the decoded bytes:

```csharp
using var file = File.OpenRead("game.rvz");
using var reader = RvzReader.Open(file, leaveOpen: true);   // RVZ
using var reader = RvzReader.OpenWia(file, leaveOpen: true); // WIA

reader.IsWia;              // true for WIA containers
reader.Length;             // decoded ISO size
reader.BlockSize;          // the chunk size
reader.FileHead;           // WiaFileHead: magic, versions, SHA-1s, sizes
reader.Disc;               // WiaDisc: disc type, codec, chunk size, table locations
reader.Partitions;         // WiaPartEntry[]: key + two data ranges per partition
reader.RawDataEntries;     // WiaRawDataEntry[]: raw data ranges
reader.GroupEntries;       // GroupEntry[]: group table
```

Opening **validates the whole container**: magic, versions, the file-head and disc-struct
SHA-1s, the partition/raw/group table hashes, and structural rules (chunk size, group
coverage). A damaged file throws `RvzHashMismatchException` or `RvzFormatException` at
`Open`, never later.

WIA files (read-compatible version `0x00080000`) support the PURGE codec and lack Zstd and
packing; `RvzReader` handles both formats transparently.

---

## Reading the file system (FST)

`DiscFileSystem` parses the GameCube/Wii file system table (Dolphin: `FileSystemGCWii`) and
gives you the tree plus file data reads. GameCube discs open directly; Wii partitions open
through a decrypted `PartitionReader` view, so paths and offsets match Dolphin.

```csharp
using RVZSharp.Files;
using RVZSharp.Wii;

// GameCube (or any disc whose data starts at offset 0):
using var fs = DiscFileSystem.Open(blob);

// Wii: pick a partition (WiiVolume.GetPartitions, type 0 = DATA/game):
using var fs = DiscFileSystem.Open(blob, WiiVolume.GetPartitions(blob)[0]);

var file = fs.Find("files/maps/foo.dat");       // case-insensitive, '/' separators
foreach (var child in fs.Root.Children) { }     // Name, Path, IsDirectory, Size, Offset

using var output = File.Create("foo.dat");
fs.CopyFileTo(file, output);                    // streams the decrypted file bytes
```

- `Find` accepts a leading `/` and matches names case-insensitively (Dolphin behavior).
- Entry offsets are partition-relative; `CopyFileTo` reads through the same decrypted view,
  so no manual sector math is needed.
- Invalid file systems (no magic, truncated table, impossible entry ranges, no trailing NUL)
  throw `RvzFormatException` at `Open`.
- `PartitionReader` (public, `IBlobReader`) exposes any Wii partition's decrypted bytes
  directly; it decrypts sectors on demand and is also what `WiiVolume.GetFstOffset`/
  `GetFstSize` use internally.
- `Partition.Key` is the **plaintext** partition key: for RVZ/WIA inputs it is the
  container's authoritative partition-table key (which wins even when the disc ticket was
  re-signed), and for plain ISOs it is the ticket title key decrypted with the Wii common
  key (`WiiVolume.GetTitleKey`).

---

## Writing RVZ

```csharp
RvzWriter.Write(IBlobReader input, Stream output, RvzWriteOptions? options = null,
    IProgress<double>? progress = null, CancellationToken cancellationToken = default);
```

The writer mirrors Dolphin's converter:

- **Input validation:** the input must actually be a GameCube or Wii disc image — the
  decoded bytes must carry the disc header magic (Wii `5D 1C 9E A3` at offset `0x18`,
  GameCube `C2 33 9F 3D` at offset `0x1C`, matching Dolphin's `TryCreateDisc`). Anything
  else throws `RvzFormatException` before a single byte is written, so arbitrary data can
  never be wrapped into an unusable RVZ. This also means `Blob.Open` + `RvzWriter.Write`
  is a complete "is this a real disc?" pipeline for any input format; to run the same
  check up front, call `Blob.GetDiscType` / `Blob.IsDisc`.
- **Wii discs** (disc header magic + hash/encryption flags set) are stored with the
  partition optimization: partition data is written *decrypted* with SHA-1 hash exceptions,
  split at the FST end — this is what makes RVZ small.
- **GameCube discs** (and Wii discs without hashes/encryption) are stored as raw data.
- **PRNG junk** (the pseudo-random padding in Wii data) is detected and packed with a
  recovered Lagged-Fibonacci seed.
- All tables carry SHA-1 checksums; the output is fully self-describing and validated by
  any conforming reader (including Dolphin).
- **Parallel compression:** groups are packed and compressed on a bounded worker pool (one
  encoder per worker, like Dolphin's `MultithreadedCompressor`) and appended in group
  order, so the output is byte-identical regardless of `MaxThreads`. Only compressed
  groups are retained, so memory stays bounded by the thread count, not the disc size.

### `RvzWriteOptions`

| Member | Default | Notes |
|---|---|---|
| `Compression` | `Zstd` | `None`, `Bzip2`, `Lzma`, `Lzma2`, `Zstd`. `Purge` throws `RvzUnsupportedException` (use `WiaWriter` for PURGE). |
| `CompressionLevel` | `5` | `Bzip2`/`Lzma`/`Lzma2`: 1–9. `Zstd`: −131072..22 (negative levels = fast modes, 0 = default). Dolphin's converter default. |
| `ChunkSize` | `0x200000` | Power of two between 0x8000 (32 KiB) and 0x200000 (2 MiB), or a multiple of 0x200000 above that (Dolphin's rule). |
| `Packing` | `true` | Set `false` to store junk literally (larger file, no packing overhead). |
| `Scrub` | `false` | Zero the data of non-game Wii partitions before encoding (Dolphin's DiscScrubber). No-op for GameCube discs. |
| `MaxThreads` | `0` | Compression/packing worker count; `0` uses the processor count. Output bytes do not depend on this value. |

```csharp
var options = new RvzWriteOptions
{
    Compression = CompressionType.Lzma2,   // best ratio on compressible data
    CompressionLevel = 9,
    ChunkSize = 0x10000,                   // 64 KiB chunks
    Packing = true,
};
```

The writer does **not** dispose the output stream; the caller owns it.

### Progress and cancellation

Long conversions (multi-GiB images) can be monitored and canceled on both the write and the
decode path:

```csharp
using var cts = new CancellationTokenSource();
var progress = new Progress<double>(fraction =>
    Console.Error.Write($"\r{fraction,6:P1}"));

try
{
    // Encode: progress is the fraction of input bytes processed.
    RvzWriter.Write(blob, output, options,
        progress: progress, cancellationToken: cts.Token);

    // Decode/stream: progress is the fraction of image bytes decoded.
    blob.CopyTo(isoStream, progress, cts.Token);
}
catch (OperationCanceledException)
{
    Console.Error.WriteLine("\ncanceled.");
}
```

- `progress` receives a fraction in `[0, 1]`, reported per read; the sequence is monotonic
  and ends at `1.0`.
- Cancellation is observed between reads on both paths and throws
  `OperationCanceledException`.

### Verifying your output

`DiscHasher` computes the CRC-32, MD5 and SHA-1 of the decoded image in one streaming pass
(the same digests Dolphin's volume verifier reports):

```csharp
using var check = Blob.Open(outputPath);
DiscHashes hashes = DiscHasher.Compute(check, progress);

// Compare with the source image's hashes:
using var source = Blob.Open(inputPath);
var expected = DiscHasher.Compute(source);
if (!hashes.Sha1.AsSpan().SequenceEqual(expected.Sha1))
    throw new InvalidDataException("round-trip mismatch");
```

For RVZ/WIA the container's own integrity (all SHA-1s, structure rules) is already
validated by `RvzReader.Open` / `RvzReader.OpenWia`, so `DiscHasher` only needs to hash the
decoded bytes.

### Verifying Wii partitions

`DiscVerifier.Verify` mirrors Dolphin's verify tab: it enumerates the disc's Wii partitions,
checks each partition header, TMD structure and H3 table, and walks every data sector's
h0/h1/h2/h3 hash tree (decrypting sectors with the plaintext partition key derived by
`WiiVolume.GetPartitions`). Problems are reported per partition with a severity; only
`VerificationSeverity.High` problems (corrupt data) make a report invalid.

```csharp
using RVZSharp.Verification;

VerificationReport report = DiscVerifier.Verify(blob, progress);
foreach (var partition in report.Partitions)
{
    Console.WriteLine($"{partition.Name}: {partition.VerifiedBlocks} blocks, "
        + $"{partition.FailedBlocks} failed, tmd={partition.TmdValid}, h3={partition.H3TableValid}");
}

if (!report.IsValid)
{
    foreach (var issue in report.Issues.Concat(report.Partitions.SelectMany(p => p.Issues)))
        Console.Error.WriteLine($"[{issue.Severity}] {issue.Message}");
}
```

- GameCube discs have no hash trees and are always valid; a non-disc image is a single
  `High` issue.
- The walk is CPU-bound (AES + SHA-1 per sector) but never materializes the image; it is
  sequential and observes cancellation between sectors.
- `RVZSharp.Cli` exposes it as `verify --partitions`.

---

## Writing WIA

`WiaWriter` shares the writer core with `RvzWriter` (Dolphin: `ConvertToWIAOrRVZ`):

```csharp
using var input = Blob.Open(@"C:\games\game.iso");
using var output = File.Create(@"C:\games\game.wia");

WiaWriter.Write(input, output, new RvzWriteOptions
{
    Compression = CompressionType.Lzma2,   // None, Purge, Bzip2, Lzma, Lzma2
    CompressionLevel = 5,
    ChunkSize = 0x200000,                  // multiple of 2 MiB
});
```

- `CompressionType.Zstd` is rejected (`RvzUnsupportedException`) — WIA predates it.
- `CompressionType.Purge` is WIA-only and fully supported by the library (the CLI mirrors
  DolphinTool, whose `-c` choices do not include it).
- `RvzWriteOptions.Packing` is RVZ-only and ignored for WIA.
- The chunk size must be a multiple of 2 MiB (Dolphin's `IsDiscImageBlockSizeValid`).
- `options` defaults to `RvzWriteOptions.WiaDefault` (LZMA2, level 5, 2 MiB chunks).
- Input validation, progress and cancellation behave exactly like `RvzWriter.Write`.

---

## Writing GCZ

`GczWriter` mirrors Dolphin's `ConvertToGCZ`: the image is split into blocks, each block is
deflated (level 9) and stored compressed unless that saves fewer than 10 bytes, and the block
table carries a per-block Adler-32 of the stored bytes. GCZ has no compression choice — it is
always zlib deflate.

```csharp
using var input = Blob.Open(@"C:\games\game.iso");
using var output = File.Create(@"C:\games\game.gcz");

GczWriter.Write(input, output, new GczWriteOptions
{
    BlockSize = 0x4000,   // power of two; 16 KiB classic, Dolphin's GUI defaults to 128 KiB
    MaxThreads = 0,       // 0 = processor count; output is byte-identical for any value
});
```

- The output stream **must be seekable**: like Dolphin, the header and the block tables are
  written after the block data (a `FileStream` or `MemoryStream` works; a network stream does
  not).
- Input validation, progress and cancellation behave like `RvzWriter.Write`; the Wii-disc
  GCZ-specific warning ("may not offer space advantages") is a CLI concern.
- Unlike WIA/RVZ, GCZ stores the image as-is (no partition decryption or packing): Wii
  discs compress poorly unless scrubbed first.

---

## Writing CISO / WBFS / TGC

`CisoWriter`, `WbfsWriter` and `TgcWriter` complete the container matrix; all three accept
any input format (`IBlobReader`) or path and take an optional `IProgress<double>` and
cancellation token.

```csharp
using var input = Blob.Open(@"C:\games\game.iso");

// CISO/WBI: a presence map + only the blocks that contain data.
CisoWriter.Write(input, ciso, new CisoWriteOptions
{
    BlockSize = 0x200000, // power of two; the decoded image is BlockSize × 0x7FF8 bytes
    Scrub = true,         // zero non-game Wii partitions; all-zero blocks are not stored
});

// WBFS: Wii only. All-zero clusters share one zero-filled volume cluster.
WbfsWriter.Write(input, wbfs, new WbfsWriteOptions
{
    BlockSize = 0x200000, // power of two ≥ 32 KiB; the u16 map caps the disc at 65535 clusters
});

// TGC: GameCube only. The ISO is stored verbatim after the 56-byte header.
TgcWriter.Write(input, tgc);
```

- CISO and WBFS output streams **must be seekable** (the map/table is written after the
  data); TGC output can be any stream.
- A CISO's decoded length is always `BlockSize × 0x7FF8` (the map capacity), so a small
  image decodes with a zero tail — exactly Dolphin's semantics.
- WBFS decodes to the fixed Wii double-layer size; clusters past the input are zero.
- `CisoWriteOptions.Scrub` / `WbfsWriteOptions.Scrub` behave like the RVZ/GCZ `Scrub`
  option; the CLI applies `--scrub` before calling the writers.

---

## Compression codecs

Read side — decode any method found in a file:

```csharp
ICompressionDecoder decoder = CompressionCodecFactory.Create(CompressionType.Lzma2);
using Stream stream = decoder.CreateDecompressor(file, props, inputSize, outputSize);
```

Write side — compress like the writer does:

```csharp
var (encoder, props) = CompressionEncoderFactory.Create(CompressionType.Zstd, level: 5);
byte[] compressed = encoder.Compress(payload);
```

- `props` is the codec property blob stored in the disc struct's `compr_data` field
  (LZMA/LZMA2 dictionary settings; empty for the others).
- `ICompressionEncoder.AddPrecedingData(...)` is PURGE-specific (the exception lists that
  precede the compressed stream and are covered by its SHA-1 trailer).
- Supported methods: `None`, `Purge` (WIA), `Bzip2`, `LZMA`, `LZMA2`, `Zstd` (RVZ).

---

## RVZ packing API

Packing is what makes RVZ files small: Wii junk is a Lagged-Fibonacci PRNG stream, so a
68-byte seed regenerates it. The writer detects junk and emits segment streams; the reader
reconstructs it.

```csharp
// Encode a chunk (returns the segment stream + its packed size).
var mainData = new List<byte>();
uint packedSize = 0;
RvzPackingEncoder.Pack(payload, dataOffset: 0, bytesPerChunk: payload.Length, chunks: 1,
    allowJunkReuse: true, compression: true, mainData, ref packedSize);

// Decode a packed stream.
using var decoder = new RvzPackingDecoder(stream, dataOffset: 0);
decoder.Read(buffer, 0, buffer.Length);
```

`LaggedFibonacciGenerator` is public for advanced use — including seed recovery:

```csharp
var (seed, bytesReconstructed) = LaggedFibonacciGenerator.GetSeed(
    data, data.Length, dataOffset % LaggedFibonacciGenerator.BlockSize);
var lfg = new LaggedFibonacciGenerator();
lfg.SetSeed(seed);
lfg.ForwardBytes(dataOffset % LaggedFibonacciGenerator.BlockSize);
lfg.GetBytes(count, output);
```

In normal use you never touch these — they are needed only when implementing a custom
container that embeds packed chunks.

---

## Wii partition machinery

These types back the partition optimization:

- `WiiVolume` — disc-header parsing, partition-table discovery, ticket handling and FST
  offsets, plus `IsWiiDisc` / `HasWiiHashes` / `HasWiiEncryption`. `GetTitleKey(ticket)`
  decrypts the 16-byte title key at ticket + 0x1BF with the console common key (IV = the
  title ID at 0x1DC; retail/Korean/RVT keys), exactly like Dolphin's
  `TicketReader::GetTitleKey`.
- `PartitionRegionBuilder(key)` — rebuilds one encrypted 2 MiB region from decrypted
  payload + hash exceptions; used by `RvzReader.ReadAt`. `Finish()` returns the encrypted
  region bytes.
- `WiiHashCalculator` — SHA-1 hash-tree construction (`h0`/`h1`/`h2`) and hash-exception
  computation.
- `WiiPartitionExtractor` — writer side: reads encrypted regions from the input, decrypts
  them (AES-128-CBC), recomputes the hash tree, and diffs it against the original to
  produce the exception lists.
- `PartitionReader` — a decrypted `IBlobReader` view of one partition (0 = data-area start);
  decrypts sectors on demand and powers `DiscFileSystem` and the FST offset reads.

```csharp
foreach (var partition in WiiVolume.GetPartitions(blob))
{
    Console.WriteLine($"partition @ 0x{partition.Offset:X}, type {partition.Type}, "
        + $"{partition.DataSize} data bytes, key {Convert.ToHexString(partition.Key)}");
}
```

---

## Error handling

| Exception | Raised when |
|---|---|
| `RvzFormatException` | structural problems: truncated file, bad chunk size, unsupported method, missing disc coverage, decode stalls |
| `RvzHashMismatchException` | a SHA-1 (file head, disc struct, partition/raw/group tables) does not match |
| `RvzUnsupportedException` | a needed feature is not supported (e.g. PURGE inside RVZ, unknown codec) |
| `ArgumentException` | programmer errors: non-seekable stream, invalid chunk size |
| `OperationCanceledException` | `RvzWriter.Write` canceled via `CancellationToken` |

All `Rvz*Exception` types derive from `RvzException` (which derives from `Exception`), so
one catch covers the format-specific failures:

```csharp
try
{
    using var blob = Blob.Open(path);
    byte[] iso = blob.ReadFully();
}
catch (RvzException e)   // format + hash problems
{
    Console.Error.WriteLine($"The image is damaged or unsupported: {e.Message}");
}
```

Hashing is **verified eagerly**: RVZ/WIA containers validate all checksums when opened, so
a corrupt file fails fast at `Blob.Open`, not halfway through your reads.

---

## Recipes

**Convert any file to RVZ with progress, then decode back:**

```csharp
using var blob = Blob.Open(inputPath);
using var output = File.Create(outputPath);
RvzWriter.Write(blob, output, new RvzWriteOptions { Compression = CompressionType.Zstd },
    progress: new Progress<double>(p => Console.WriteLine($"{p:P1}")));
```

**Read the game title and region without decoding the disc:**

```csharp
using var blob = Blob.Open(path);
Span<byte> header = stackalloc byte[0x80];
blob.ReadAt(0, header);
string gameId = System.Text.Encoding.ASCII.GetString(header[..6]);
```

**List Wii partitions of any container:**

```csharp
using var blob = Blob.Open(path);
foreach (var partition in WiiVolume.GetPartitions(blob))
{
    Console.WriteLine($"0x{partition.Offset:X8}  type={partition.Type}  "
        + $"{partition.DataSize} bytes");
}
```

**Stream a 9.4 GiB WBFS to ISO without buffering:** use the streaming pattern above —
`WbfsBlob` reports its fixed logical size and serves zero clusters without allocating them.

---

## Thread safety

- `RvzReader.ReadAt` is **thread-safe**: concurrent reads share decoded units through a
  bounded LRU cache (16 MiB, `RvzReader.DefaultCacheSize`) and only serialize the short
  file reads. Do not dispose the reader while another thread is reading.
- The other `IBlobReader` implementations read through a single stream and are **not**
  thread-safe; use one reader per thread or synchronize access.
- The writers and the parallel decode path manage their own worker pools; progress reports
  are raised on the calling thread between groups/batches, and cancellation is observed at
  the same points.

## Compatibility notes

- Target frameworks: `net8.0`, `net9.0`, `net10.0` — the same assembly API on all three
  (enforced by `EnablePackageValidation` at pack time).
- Dependencies: `LZMA-SDK` (encoding), `SharpZipLib` (BZip2), `ZstdSharp.Port`
  (Zstandard) — all pure managed, all netstandard-compatible.
- The RVZ/WIA container logic is derived from Dolphin; the library is
  **GPL-2.0-or-later** (the same license Dolphin uses), so distributing or consuming it
  follows the GPL. See `LICENSE` and `THIRD-PARTY-NOTICES.md` in the package.
