All notable changes to kzstd are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Encoder-side parity work closing several gaps against the libzstd/RFC 8878 spec — the 128 KiB single-block input limit lifted, real ratio improvements for both dictionary-compressed and dictionary-free frames, plus dictionary-ID and content-checksum validation. No wire format or public-API changes, except where noted below.
Zstd.compresstakes an opt-inchecksum: Boolean = falseparameter; when true, the encoder setsContent_Checksum_Flagand appends the XXH64 checksum of the input. Defaults to false, so every existing call site's frame bytes are unchanged.Zstd.compressnow accepts input up to 128 MiB (dictionary content + data combined), up from 128 KiB. It cuts the input into 128 KiB (Block_Maximum_Size) chunks and emits them as one multi-block frame,Last_Blockset on the final block only; anything larger than 128 KiB used to be rejected with aZstdException. The new 128 MiB ceiling replaces that guard: beyond it, the frame's required window would exceed libzstd's default decompression limit (ZSTD_WINDOWLOG_LIMIT_DEFAULT), so it's still rejected rather than emitting a frame most real-world libzstd consumers would refuse.Zstd.decompresshas always read multi-block frames from any encoder, and real libzstd reads these. 3 MB of synthetic JSON telemetry compresses to 556,657 bytes across 24 blocks — between libzstd's level 3 (579,374) and level 19 (362,967).
- The encoder now emits repeat-offset sequence codes when a match's distance repeats one of the three most-recently-used offsets, instead of always an explicit literal offset (#48).
- With a trained dictionary, the encoder now reuses the dictionary's own trained FSE tables for sequences ("Repeat" mode) and its trained Huffman table for literals ("Treeless"), whenever they cover what a given block needs and doing so is smaller than the previous fallback (predefined FSE tables, raw literals) — a real, measurable size reduction for dictionary-compressed frames, not just a wire-format curiosity (#50, #51).
- Without a dictionary — the plain
Zstd.compress(data)call — the encoder now entropy-codes each block from the block's OWN data, where before it could only emit raw literals and the spec's predefined FSE distributions: Huffman literals built from the block's byte histogram (Literals_Block_Type2), and FSE tables for the literal-length / offset / match-length streams normalized from the block's own code counts (Symbol_Compression_Mode2). Measured: ~7.8 KB of synthetic JSON telemetry records 2007 → 1521 bytes, 887 bytes of concatenated structured records 487 → 425, a 208-byte prose sample 213 → 190. - The encoder now also emits the RLE forms the decoder has always read:
RLE_Blockfor a constant input (a 1500-byte run of one byte, 17 → 10 bytes), RLE literals when every literal is the same byte, and RLE sequence tables when a stream's every code is the same (a sample of 26 byte-runs, 85 → 42 bytes). - Every per-block encoding choice — the literals section and each of the three sequence streams independently — is now made by measuring every valid alternative and taking the smallest, so a form is used only when it actually wins. Ties keep the previous behaviour, and dictionary-compressed frames come out the same size as before.
- Entropy tables and the three repeat offsets are now carried from block to
block within a frame, which is what the format means by them: "Repeat"
sequence tables and "Treeless" literals name the PREVIOUS block's tables, and
the dictionary's only for a frame's first block. A later block therefore
reuses a table for nothing instead of describing its own, and a
RaworRLEblock describes nothing and so leaves the state untouched — the block after a stretch of incompressible data still reaches the dictionary's own tables. A 128 KiB noise block followed by a dictionary-trained sample compresses that sample's block to 46 bytes rather than 54. level(1–22) now governs match-finding search depth: higher levels search more candidate matches per position, which can shrink output at the cost of more work. Level 19 (Zstd.DEFAULT_LEVEL) maps to exactly the search depth the encoder always used, so the mapping itself changes no output; the encoder still uses one fixed strategy at every level, not zstd's other per-level parameters (#52).
- The decoder now validates a frame's Content_Checksum when
Content_Checksum_Flagis set: it reads the trailing 4-byte XXH64 checksum and throwsZstdExceptionon a mismatch against the decoded content, for ANY conformant frame — not just kzstd's own — so a reallibzstd-produced frame (checksums are on by default in thezstdCLI) is no longer accepted with silently-corrupted content. - The decoder now validates a frame's Dictionary_ID (RFC 8878 §3.1.1.3)
against the supplied dictionary's own embedded Dictionary_ID (RFC 8878
§5), when both are present. Decoding a real libzstd frame (which sets a
real Dictionary_ID by default when compressing with a proper
Zstandard-format dictionary) with the wrong
ZstdDictionarynow throws aZstdExceptionthat clearly names it as a dictionary-ID mismatch, instead of a confusing generic corruption error. Frames with no declared Dictionary_ID (kzstd's own encoder always emits these) and raw content dictionaries (no embedded ID) are unaffected — fully backward compatible.
- Blocks are compressed independently: a match never reaches back into an earlier block's output, only into this block and the dictionary. Large inputs therefore compress less well than a windowed encoder would manage — and combined with the 1023-byte literals cap below, a full 128 KiB block keeps raw literals and takes its ratio from the sequence tables alone. A windowed, cross-block matcher is the follow-up.
- Huffman-coded literals stay single-stream, so they apply to at most 1023 bytes of literals per block, and their tree description uses the direct 4-bit weight form, so a block containing a literal byte above 128 falls back to raw literals. FSE-compressed weight descriptions and the 4-stream literals layout would lift those limits and are not implemented; neither affects decoding, which reads both.
- A dictionary-compressed frame's correctness depends on the dictionary's entropy tables matching what the decoder is seeded with, not just its content. The Dictionary_ID check above catches this when both the frame and the supplied dictionary are proper-format (embedded-ID) dictionaries with different IDs; it can't catch a wrong raw-content dictionary (no embedded ID to compare) or a frame with no declared Dictionary_ID (kzstd's own encoder never sets one) — those still decode with whatever tables the wrong dictionary provides, silently. Use the same trained dictionary bytes on both ends, as always.
Dependency and toolchain refresh, plus CI/quality hardening — no codec or public-API changes.
- CodeQL and OpenSSF Scorecard scanning (#17).
- A Dokka API docs site, published to GitHub Pages (#18).
- klib ABI validation across every target, not just JVM (#16).
- A remote HTTP build cache plus Konan (Kotlin/Native toolchain) caching in CI (#15).
- A Spotless/detekt quality gate and a dedicated Linux test leg (#28).
- Non-gating Codecov coverage upload, later gated on regression (#24).
- Onboarded to the OSS Community Develocity instance for Build Scans and remote caching (#36), then restricted cache writes to trusted events only (#37).
- Funding and issue templates (#22).
- Built with Gradle 9.7.0 (was 9.6.1, itself bumped from 9.5.x this cycle) (#26, #40).
- Every third-party GitHub Action is now pinned to a full commit SHA (#25).
com.github.luben:zstd-jni(JVM-only interop test dependency) updated to 1.5.7-13 (#30, #43).com.diffplug.spotlessupdated to 8.9.0 (#31);junit-framework(JVM test suite) to 6.1.3 (#42).- Renormalized
gradlew.batline endings so a fresh clone is clean on Windows (#33).
- Pinned yarn
resolution()floors for the Kotlin/JS test harness, clearing open Dependabot alerts (#21). Dev-time only; nothing underkotlin-js-store/ships in published artifacts. - Pinned the Gradle wrapper checksum (#22).
Dependency and toolchain refresh — no codec or public-API changes.
- Relicensed source SPDX headers from GPL-3.0-only to GPL-3.0-or-later (aligns with the Meshtastic org standard; the LICENSE file is unchanged).
- Built with Kotlin 2.4.10 (was 2.4.0).
- junit-framework (JVM test suite only) updated to 6.1.2.
- Pinned yarn
resolution()floors for the Kotlin/JS test harness — ws 8.21.0, serialize-javascript 7.0.5, webpack 5.104.1, diff 8.0.3 — clearing the open Dependabot alerts. Dev-time only; nothing underkotlin-js-store/ships in published artifacts. - Dependabot no longer tries (and fails) to update the Kotlin-managed yarn lock
under
kotlin-js-store/; Renovate handles all dependency updates.
Initial release. A standalone, pure-Kotlin multiplatform Zstandard (zstd) codec, extracted from TAKPacket-SDK.
- One-shot
Zstd.compress/Zstd.decompressover standard zstd frames, with dictionary and dictionary-less overloads — interoperable with libzstd in both directions. - A digested
ZstdDictionary(bytes)that parses entropy tables and indexes its content once in its constructor; immutable and safe to share across threads. - A single public
ZstdExceptionerror type, and a requiredmaxSizedecompression-bomb guard on every decode. - 13 Kotlin Multiplatform targets: JVM; JS (browser + Node); Wasm/JS; Wasm/WASI; and nine native (iOS arm64 / simulator-arm64 / x64, macOS arm64, tvOS arm64 / simulator-arm64, Linux x64 / arm64, Windows mingw-x64).
- Zero runtime dependencies (Kotlin standard library only).
- No streaming API — one-shot by design.
compressemits a single block per frame, so input is bounded by zstd's 128 KiBBlock_Maximum_Size; larger inputs throwZstdException.decompressreads multi-block frames from any encoder. Multi-block encoding is planned.- The
levelparameter is currently a no-op; the encoder uses a single fixed greedy/lazy strategy. Frames remain libzstd-compatible regardless.