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 (
PlanBuilder→Plan→IndexApplier::execute) folds one or many patches into a deduplicated, target-majorPlanin memory, then replays it against the install. The plan is pure data: it can be inspected, persisted (with theserdefeature), CRC-populated viaPlan::with_crc32, verified against the current install viaPlanVerifier, and used to drive a repair viaIndexApplier::execute_with_manifestwith aRepairManifest.
Pick by what the consumer needs:
| Need | Pipeline |
|---|---|
| Apply one patch to an install, in order, now | Sequential |
| Apply a chain of N patches and dedupe writes superseded mid-chain | Indexed |
| Pre-validate the chain against the install before touching a byte | Indexed |
| Pre-compute expected CRC32s and verify the install against them | Indexed |
Produce a RepairManifest of drifted regions | Indexed |
| Persist a structured description of pending work for later replay | Indexed |
| Minimum latency to first write; bounded streaming memory | Sequential |
| 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 after2.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 ofwarn!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)
| Span | Entry point | Fields |
|---|---|---|
apply_patch | ApplyConfig::apply_patch | (none on creation) |
resume_apply_patch | ApplyConfig::resume_apply_patch | (none on creation) |
apply_plan | IndexApplier::execute_with_manifest | mode, targets, regions |
resume_execute | IndexApplier::execute / IndexApplier::resume_execute | plan_crc32, targets, regions, fs_ops |
build_plan_patch | PlanBuilder::add_patch | patch |
with_crc32 | Plan::with_crc32 | targets |
verify_plan | PlanVerifier::execute | targets |
verify_hashes | HashVerifier::execute | files |
§Debug-level (per-target / per-file sub-spans)
| Span | Parent | Fields |
|---|---|---|
apply_target | apply_plan / resume_execute | target_idx, path, regions |
verify_target | verify_plan | target |
verify_file | verify_hashes | path |
§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:
| Span | Message | Fields |
|---|---|---|
apply_patch | apply_patch: patch applied | chunks, bytes_read, resumed_from_chunk, elapsed_ms |
resume_apply_patch | resume_apply_patch: patch applied | chunks, bytes_read, resumed_from_chunk, skipped_bytes (resumed only), elapsed_ms |
resume_apply_patch | resume_apply_patch: resuming patch | patch_name, skipped_chunks, skipped_bytes, has_in_flight |
resume_execute | apply_plan: indexed apply complete | bytes_written, targets, resumed_from, elapsed_ms |
resume_execute | resume_execute: resuming indexed apply | plan_crc32, skipped_targets, skipped_regions, fs_ops_skipped |
apply_plan | apply_plan: manifest replay complete | bytes_written, targets, regions, elapsed_ms |
build_plan_patch | plan: patch consumed | chunks, targets, fs_ops |
(none — fires from PlanBuilder::finish) | plan: built | patches, targets, regions, fs_ops |
with_crc32 | with_crc32: populated CRC32 for regions | populated, skipped |
verify_plan | verify_plan: scan complete | targets, missing_targets, size_mismatched, damaged_targets, damaged_regions, elapsed_ms |
verify_hashes | verify_hashes: run complete | files, 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_msresumed_from_chunk,resumed_from,skipped_chunks,skipped_bytes,skipped_targets,skipped_regions,has_in_flight,fs_ops_skippedtargets,regions,fs_ops,target_idx,region_idx,target,path,modeplan_crc32,populated,skipped,bytes_writtenfiles,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:
new(…)— canonical constructor; fails only on structurally invalid input (ZiPatchReader::newvalidates the file magic).open(path)— opens a filesystem resource and returns aResult(index::FilePatchSource::open,index::FilePatchSource::open_chain).with_X(self, value) -> Self— builder-style setter; chains onApplyConfigandIndexApplier.into_X(self)— type transition consumingself(ApplyConfig::into_session).execute(self, …) -> Result<T>— runs a pipeline to completion (IndexApplier::execute,index::PlanVerifier::execute,verify::HashVerifier::execute).finish(self) -> T— builder finaliser with no I/O (index::PlanBuilder::finish).
The crate root re-exports the happy-path symbols. Secondary types live in their owning modules:
chunk—chunk::ChunkRecord,chunk::SqpkCommand,chunk::SqpackFileId,chunk::SqpkCompressedBlockapply—apply::CheckpointSink,apply::Checkpoint,apply::CheckpointPolicy,apply::SequentialCheckpoint,apply::IndexedCheckpoint,apply::NoopCheckpointSink,apply::NoopObserver,apply::Vfs,apply::StdFs,apply::InMemoryFsindex—index::PlanVerifier,index::PatchSource,index::FilePatchSource,index::RepairManifestnewtypes—newtypes::PatchIndex,newtypes::ChunkTag,newtypes::SchemaVersion
§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 parsedZiPatchchunks. - chunk
- Wire-format chunk types and the
ZiPatchReaderiterator. Wire-format chunk types and theZiPatchReaderstreaming 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_patchdriver or the indexedIndexApplier::executedriver.
Enums§
- Platform
- Target platform for
SqPackfile path resolution.
Functions§
- apply_
patch_ file - One-shot: open
patch_pathand apply every chunk toinstall_rootwith all defaults.