Skip to main content

Crate zipatch_rs

Crate zipatch_rs 

Source
Expand description

Parser and applier for FFXIV ZiPatch (.patch) binary files.

zipatch-rs decodes the binary patch format that Square Enix ships for Final Fantasy XIV and writes the decoded changes to a local game installation. The library never touches the network — it operates entirely on byte streams you supply.

§Quick start

The most common usage — apply a single patch file to an install root with all defaults — is a single call:

zipatch_rs::apply_patch_file(
    "H2017.07.11.0000.0000a.patch",
    "/opt/ffxiv/game",
).unwrap();

For consumers that need progress observers, custom checkpoint sinks, a non-default platform, or multi-patch session reuse, use the two-step form directly:

use zipatch_rs::{ApplyConfig, open_patch};

let reader = open_patch("H2017.07.11.0000.0000a.patch").unwrap();
let ctx = ApplyConfig::new("/opt/ffxiv/game");
ctx.apply_patch(reader).unwrap();

§Choosing an apply pipeline

zipatch-rs ships two apply pipelines. They are not alternatives in the sense of “one is better” — they answer different questions.

  • Sequential (ApplyConfig::apply_patch) walks a single patch in chunk order and writes each chunk to disk as it is parsed. One pass, one patch, bounded memory, minimum latency from “start” to “first byte on disk”. This is the default; reach for it unless you specifically need what the indexed pipeline offers.
  • Indexed (PlanBuilderPlanIndexApplier::execute) folds one or many patches into a deduplicated, target-major Plan in memory, then replays it against the install. The plan is pure data: it can be inspected, persisted (with the serde feature), CRC-populated via Plan::with_crc32, verified against the current install via PlanVerifier, and used to drive a repair via IndexApplier::execute_with_manifest with a RepairManifest.

Pick by what the consumer needs:

NeedPipeline
Apply one patch to an install, in order, nowSequential
Apply a chain of N patches and dedupe writes superseded mid-chainIndexed
Pre-validate the chain against the install before touching a byteIndexed
Pre-compute expected CRC32s and verify the install against themIndexed
Produce a RepairManifest of drifted regionsIndexed
Persist a structured description of pending work for later replayIndexed
Minimum latency to first write; bounded streaming memorySequential
Patch source is forward-only (no seeks)Sequential

Costs to weigh: the indexed pipeline does two passes over the patch source (plan-build, then apply) and holds the plan in memory; the source must support random access, which is why the indexed entry points take a PatchSource (typically FilePatchSource) rather than a plain Read. The sequential pipeline does a single forward pass and never holds more than one chunk in flight.

Starting sequential and growing to indexed later is a supported path — the ApplyConfig knobs (with_observer, with_cancel_token, with_checkpoint_sink, with_platform, with_vfs) all carry across to IndexApplier with the same names and semantics.

// Sequential: one patch, one pass.
zipatch_rs::apply_patch_file("patch.patch", "/opt/ffxiv/game").unwrap();
// Indexed: build a plan over a chain, then apply it.
use zipatch_rs::{IndexApplier, PlanBuilder, open_patch};
use zipatch_rs::index::FilePatchSource;

let mut builder = PlanBuilder::new();
builder.add_patch("p1.patch", open_patch("p1.patch").unwrap()).unwrap();
builder.add_patch("p2.patch", open_patch("p2.patch").unwrap()).unwrap();
let plan = builder.finish();

let source = FilePatchSource::open_chain(["p1.patch", "p2.patch"]).unwrap();
IndexApplier::new(source, "/opt/ffxiv/game").execute(&plan).unwrap();

§Inspecting a patch without applying it

Iterate the reader directly to inspect chunks without touching the filesystem:

use zipatch_rs::{Chunk, ZiPatchReader};
use std::fs::File;

let mut reader = ZiPatchReader::new(File::open("patch.patch").unwrap()).unwrap();
while let Some(rec) = reader.next_chunk().unwrap() {
    match rec.chunk {
        Chunk::FileHeader(h) => println!("patch version: {:?}", h),
        Chunk::AddDirectory(d) => println!("mkdir {}", d.name),
        Chunk::Sqpk(cmd) => println!("sqpk: {cmd:?}"),
        _ => {}
    }
}

§In-memory doctest

The following example builds a minimal well-formed patch in memory — magic header, one ADIR chunk (which creates a directory), and an EOF_ terminator — then applies it to a temporary directory. This mirrors the technique used in the crate’s own unit tests.

use std::io::Cursor;
use zipatch_rs::{ApplyConfig, Chunk, ZiPatchReader};

// ZiPatch file magic: \x91ZIPATCH\r\n\x1a\n
const MAGIC: [u8; 12] = [
    0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48,
    0x0D, 0x0A, 0x1A, 0x0A,
];

/// Wrap `tag + body` into a length-prefixed, CRC32-verified chunk frame.
fn make_chunk(tag: &[u8; 4], body: &[u8]) -> Vec<u8> {
    // CRC is computed over tag ++ body (NOT including the leading body_len).
    let mut crc_input = Vec::new();
    crc_input.extend_from_slice(tag);
    crc_input.extend_from_slice(body);
    let crc = crc32fast::hash(&crc_input);

    let mut out = Vec::new();
    out.extend_from_slice(&(body.len() as u32).to_be_bytes()); // body_len: u32 BE
    out.extend_from_slice(tag);                                // tag: 4 bytes
    out.extend_from_slice(body);                               // body: body_len bytes
    out.extend_from_slice(&crc.to_be_bytes());                 // crc32: u32 BE
    out
}

// ADIR body: big-endian u32 name length followed by the name bytes.
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&7u32.to_be_bytes()); // name_len
adir_body.extend_from_slice(b"created");          // name

// Assemble the full patch stream.
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));

// Apply to a temporary directory.
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyConfig::new(tmp.path());
let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
ctx.apply_patch(reader).unwrap();

assert!(tmp.path().join("created").is_dir());

§Error handling

Each layer returns its own domain-specific error type (ParseError, ApplyError, IndexError, VerifyError); the corresponding ParseResult, ApplyResult, IndexResult, and VerifyResult aliases at the crate root cover the common signatures.

Callers that want a single ?-friendly type at the edges of their code can use the umbrella Error enum (and the matching Result alias) — every domain error From-converts into it, so a one-line ? collapses the four domains into one match arm.

use zipatch_rs::{apply_patch_file, Error, Result};

fn install(patch: &str, root: &str) -> Result<()> {
    apply_patch_file(patch, root)?; // ApplyError -> Error via From
    Ok(())
}

§Progress and cancellation

ApplyConfig::with_observer installs an ApplyObserver that is called after each chunk applies (with the chunk index, tag, and running byte count from chunk::ChunkRecord::bytes_read) and polled inside long- running chunks for cancellation. Returning std::ops::ControlFlow::Break from a per-chunk callback, or true from ApplyObserver::should_cancel, aborts the apply call with ApplyError::Cancelled. Parsing-only consumers and existing apply_patch callers that never install an observer pay nothing — the default is a no-op.

For cancellation alone, prefer CancelToken: a cheap cloneable AtomicBool wrapper installed via ApplyConfig::with_cancel_token (or IndexApplier::with_cancel_token). Hold a clone on whichever thread initiates cancellation, call CancelToken::cancel on a user gesture, and the apply driver picks up the flip at the next chunk boundary or inner-loop poll point. The token composes with an installed observer — both are polled, either can abort.

§Tracing

The library emits structured tracing events and spans across the parse, plan-build, apply, and verify entry points. Levels follow a “one event per logical operation at info!, per-target/per-fs-op at debug!, per-region/byte-level work at trace!” cadence. The top-level spans listed below are emitted at the info level so a subscriber configured at the default level can scope output via span filtering, while per-target sub-spans emit at debug. Recoverable anomalies — stale manifest entries, unknown platform IDs, missing-but-ignored files — fire warn!; errors are returned via the domain error types rather than logged. No subscriber is configured here — that is the consumer’s responsibility.

§Stability contract

tracing names are a public API for any consumer wiring up tracing-subscriber filters, OpenTelemetry exporters, log scrapers, or dashboards that key off span/event/field names. This crate treats them as such, with the following tiers:

  • Stable (SemVer-tracked) — the span names and field names listed in the catalog below at the info and debug levels, plus the per-operation completion-event messages explicitly called out. Renaming or removing one of these is a minor-version bump while the crate is on 0.x/1.x; a major-version bump after 2.0. New spans, new events, and new fields on existing spans are additive and ship in patch releases.
  • Best-effort (not contracted)trace! events and any field they carry, all event messages not explicitly listed, and the precise wording of warn! messages. These exist for operator forensics and may change in any release; depend on them only inside a development environment.

The canonical strings live in a crate-internal tracing_schema module so a rename lands in a single file. Field names are kept as literals at the emission sites (their tracing macro syntax for const-named fields would clutter the call site without a corresponding rename-cost benefit), but they appear in the stable catalog below and follow the same contract.

§Span catalog

All spans are tagged with the entry point they bracket and the fields they carry on creation. Sub-spans inherit the parent’s field set via the standard tracing span hierarchy.

§Info-level (top-level operations)

SpanEntry pointFields
apply_patchApplyConfig::apply_patch(none on creation)
resume_apply_patchApplyConfig::resume_apply_patch(none on creation)
apply_planIndexApplier::execute_with_manifestmode, targets, regions
resume_executeIndexApplier::execute / IndexApplier::resume_executeplan_crc32, targets, regions, fs_ops
build_plan_patchPlanBuilder::add_patchpatch
with_crc32Plan::with_crc32targets
verify_planPlanVerifier::executetargets
verify_hashesHashVerifier::executefiles

§Debug-level (per-target / per-file sub-spans)

SpanParentFields
apply_targetapply_plan / resume_executetarget_idx, path, regions
verify_targetverify_plantarget
verify_fileverify_hashespath

§Event catalog (info-level, per-operation completion)

These messages are emitted exactly once per call to their owning entry point on the success path, and are part of the stable contract:

SpanMessageFields
apply_patchapply_patch: patch appliedchunks, bytes_read, resumed_from_chunk, elapsed_ms
resume_apply_patchresume_apply_patch: patch appliedchunks, bytes_read, resumed_from_chunk, skipped_bytes (resumed only), elapsed_ms
resume_apply_patchresume_apply_patch: resuming patchpatch_name, skipped_chunks, skipped_bytes, has_in_flight
resume_executeapply_plan: indexed apply completebytes_written, targets, resumed_from, elapsed_ms
resume_executeresume_execute: resuming indexed applyplan_crc32, skipped_targets, skipped_regions, fs_ops_skipped
apply_planapply_plan: manifest replay completebytes_written, targets, regions, elapsed_ms
build_plan_patchplan: patch consumedchunks, targets, fs_ops
(none — fires from PlanBuilder::finish)plan: builtpatches, targets, regions, fs_ops
with_crc32with_crc32: populated CRC32 for regionspopulated, skipped
verify_planverify_plan: scan completetargets, missing_targets, size_mismatched, damaged_targets, damaged_regions, elapsed_ms
verify_hashesverify_hashes: run completefiles, failures, bytes_hashed, elapsed_ms

§Stable field-name vocabulary

Field names attached to the spans and events above are stable. Across the library they’re used consistently:

  • patch, patch_name, patches, chunks, bytes_read, elapsed_ms
  • resumed_from_chunk, resumed_from, skipped_chunks, skipped_bytes, skipped_targets, skipped_regions, has_in_flight, fs_ops_skipped
  • targets, regions, fs_ops, target_idx, region_idx, target, path, mode
  • plan_crc32, populated, skipped, bytes_written
  • files, failures, bytes_hashed, missing_targets, size_mismatched, damaged_targets, damaged_regions

Field names emitted only at debug! / trace! levels (e.g. next_chunk_index, bytes_into_target, block_idx, chunk_bytes, delete_zeros, decoded_skip, per-fs-op folder/kind) are best-effort and may change without a version bump.

§API conventions

All constructors follow consistent naming:

The crate root re-exports the happy-path symbols. Secondary types live in their owning modules:

§Async usage

zipatch-rs is a synchronous crate. Every public trait (index::PatchSource, apply::CheckpointSink, ApplyObserver, apply::Vfs) and every driver entry point (ApplyConfig::apply_patch, IndexApplier::execute, ZiPatchReader::next_chunk) is blocking. The crate has no runtime dependency.

This is a deliberate design choice. The apply-side hot path is dominated by DEFLATE decompression (CPU-bound) and filesystem syscalls (blocking by their nature on every mainstream OS); neither benefits from an async signature. Staying sync also keeps the crate trivially embeddable in a CLI binary, a Tauri command, or an async-runtime worker without forcing a runtime choice or pulling tokio into the dependency graph.

§Driving the crate from an async runtime

Async consumers (e.g. a tokio-based launcher) park the whole apply call on a blocking-pool thread:

// pseudo-code; the crate has no tokio dependency
let install_path = install_path.clone();
let patch_path   = patch_path.clone();
let cancel       = zipatch_rs::CancelToken::new();
let cancel_bg    = cancel.clone();

// ... wire `cancel.cancel()` to a tokio::select! arm or UI handler ...

let result = tokio::task::spawn_blocking(move || {
    let reader = zipatch_rs::open_patch(&patch_path)?;
    let ctx = zipatch_rs::ApplyConfig::new(install_path)
        .with_cancel_token(cancel_bg);
    ctx.apply_patch(reader)
}).await??;

Per-chunk progress reaches the async UI through an mpsc (or tokio-mpsc-equivalent) channel whose Sender lives inside an ApplyObserver impl and whose Receiver is polled from an async task. Consumers that need both progress and cancellation pair an observer (for progress) with a CancelToken (for cancellation) — they compose without a wrapper observer.

§Async-backed sources

Implementors whose backing storage is itself async (e.g. an in-progress download, an async KV store for checkpoints, a remote object-store apply::Vfs) satisfy the sync traits by bridging through a channel against a separate async task: the sync method blocks on a response from the async side. Because the trait methods run from inside the spawn_blocking thread, blocking on the channel does not stall the runtime’s reactor.

Re-exports§

pub use chunk::open_patch;
pub use chunk::Chunk;
pub use chunk::ZiPatchReader;
pub use apply::ApplyConfig;
pub use apply::ApplyObserver;
pub use apply::ApplySession;
pub use apply::CancelToken;
pub use apply::ChunkEvent;
pub use index::IndexApplier;
pub use index::Plan;
pub use index::PlanBuilder;
pub use error::ApplyError;
pub use error::ApplyResult;
pub use error::Error;
pub use error::IndexError;
pub use error::IndexResult;
pub use error::ParseError;
pub use error::ParseResult;
pub use error::Result;
pub use error::VerifyError;
pub use error::VerifyResult;

Modules§

apply
Filesystem application of parsed chunks (ApplyConfig). Filesystem application of parsed ZiPatch chunks.
chunk
Wire-format chunk types and the ZiPatchReader iterator. Wire-format chunk types and the ZiPatchReader streaming parser.
error
Domain-split error types (ParseError, ApplyError, IndexError, VerifyError). Error types for parsing, applying, indexing, and verifying ZiPatch streams.
index
Indexed-apply plan model and single-patch builder (Plan, PlanBuilder). Pure-data indexed-apply plan model, builder, applier, and verifier.
newtypes
Strongly-typed wrappers around primitive identifiers in the public API (newtypes::PatchIndex, newtypes::ChunkTag, newtypes::SchemaVersion). Strongly-typed wrappers around primitive identifiers that appear in the public API.
verify
Post-apply integrity verification against caller-supplied file hashes. Post-apply integrity check for files produced by either the sequential apply_patch driver or the indexed IndexApplier::execute driver.

Enums§

Platform
Target platform for SqPack file path resolution.

Functions§

apply_patch_file
One-shot: open patch_path and apply every chunk to install_root with all defaults.