The .zar format is ZArchive 0.1.2 by Exzap (Exzap/ZArchive). This page documents the on-disk layout as implemented by ZArchiveSharp, which is byte-identical to the reference implementation.
A .zar file stores a directory tree whose file contents are split into 64 KiB blocks, each independently compressed with zstd. The layout:
+---------------------------+
| Compressed data blocks | <- zstd frames, back to back
+---------------------------+
| Offset records | <- block size index
+---------------------------+
| Name table | <- deduplicated entry names
+---------------------------+
| File tree | <- directory entries
+---------------------------+
| Meta directory (empty) |
+---------------------------+
| Meta data (empty) |
+---------------------------+
| Footer (144 bytes) | <- section table + SHA-256
+---------------------------+
All integers are big-endian. Entry names are encoded in Windows-1252.
| Constant | Value | Meaning |
|---|---|---|
| Block size | 64 KiB (0x10000) |
Uncompressed block granularity |
| Entries per offset record | 16 | Blocks indexed per record |
| Invalid node | 0xFFFFFFFF |
Path-not-found handle |
| Root name offset | 0x7FFFFFFF |
Root dir has no name |
| Max file size | 2⁴⁸ − 1 | Per-file limit |
| Max name length | 0x7FFF |
15-bit length header |
| Footer magic | 0x169F52D6 |
Last u32 |
| Version | 0x61BF3A01 |
Second-to-last u32 |
File content is cut into 64 KiB blocks. Each block is compressed independently:
StoreBlock rule: compressed ≥ input → raw)Repeat-offset history is frame-scoped — each block is an independent frame, so repcodes never carry across blocks.
Each record indexes 16 blocks (40 bytes on disk):
struct CompressionOffsetRecord // 40 bytes, BE
{
uint64 baseOffset; // output offset of first block in record
uint16 sizes[16]; // per-block (compressedSize - 1)
}
A stored sizes[i] of 0xFFFF-decoding logic applies: actual compressed size = sizes[i] + 1. Blocks are located by:
blockIndex -> record = blockIndex / 16, slot = blockIndex % 16
blockOffset = record.baseOffset + sum(sizes[0..slot-1] + 1)
The records form a contiguous table placed after the compressed data.
Deduplicated entry names, each stored as:
uint16 length; // up to 0x7FFF
byte chars[length]; // Windows-1252
Names longer than 0x7FFF characters are truncated on characters before encoding (not bytes) — a faithful port of a 0.1.2 quirk.
Windows-1252 decoding maps 0x80–0x9F through the CP1252 high table (undefined slots map to C1 controls); characters with no representation encode to ? (0x3F).
Name order is pack order (first appearance) by default; ZArchiveWriter accepts a pre-seeded order (ZarPipelineOptions.NameOrder) so callers that walk a source tree in discovery order can emit the table in that order instead.
A flat array of 16-byte entries, serialized in preorder:
struct FileDirectoryEntry // 16 bytes, BE
{
uint32 nameOffsetAndTypeFlag; // bit 31: file flag; bits 0-30: name offset
uint32 field1;
uint32 field2;
uint32 field3;
}
| Field | Meaning |
|---|---|
field1 |
File offset low 32 bits |
field2 |
File size low 32 bits |
field3 |
bits 0–15: offset high 16; bits 16–31: size high 16 |
Offset and size are 48-bit values assembled from the split fields. Offsets are logical (uncompressed) input offsets.
| Field | Meaning |
|---|---|
field1 |
First child node index |
field2 |
Child count |
field3 |
Reserved (0) |
The root directory is entry 0 with the special name offset 0x7FFFFFFF.
144 bytes, placed at the end:
struct Footer // 144 bytes, BE
{
OffsetInfo compressedData; // 16 bytes: uint64 offset + uint64 size
OffsetInfo offsetRecords; // 16
OffsetInfo names; // 16
OffsetInfo fileTree; // 16
OffsetInfo metaDirectory; // 16 (empty in 0.1.2)
OffsetInfo metaData; // 16 (empty in 0.1.2)
byte integrityHash[32]; // SHA-256
uint64 totalSize; // including footer
uint32 version; // 0x61BF3A01
uint32 magic; // 0x169F52D6 <- magic/version at the END
}
The SHA-256 covers every output byte written before the footer, then the footer itself with the hash field zeroed. This detects truncation and most corruption; the reader rejects archives whose hash does not verify.
/ and \ accepted on input; stored paths use /(byte)c2 - (byte)c1) and “shorter string sorts after its prefix”TryOpen returns null on any validation failure — it never throws (mirrors the C++ open chain); with leaveOpen: false (the default), a failed TryOpen(Stream) also disposes the stream, so ownership only transfers on success. The out ZArchiveOpenFailure overloads report the specific reason (BadMagic, LengthMismatch, SectionOutOfRange, …)ZArchiveReaderOptions.CacheBlockCount) holds decompressed blocksTryGetDirEntry) and canonical names (TryGetNodeName), and GetDirEntryCount clamps crafted counts to the file-tree boundsOpenRead/TryOpenRead expose a seekable per-entry stream over the block cacheOffsetInfo.IsWithinValidRange, child ranges, directory indices); a block fault mid-read returns a short read instead of looking like EOF, and extraction rejects a crafted directory child range that runs past the file tree instead of silently skipping entries| Limit | Value |
|---|---|
| File size | 2⁴⁸ − 1 bytes |
| Name length | 0x7FFF chars |
| Block count | ~2⁶⁴ blocks (offset records are 64-bit based) |
zarchive.exe and vice versa (tested both directions)ZArchiveTool.Pack sorts entries by default (deterministicOrder: true); pass false to mirror native enumeration for parity runs