.3pk Release Format — Specification v1
Plinth packs a release as a .3pk manifest archive plus sibling component archives holding the heavy model files. The .3pk is the portable source of truth for everything the catalog knows about a release: raw STLs carry no metadata, so the manifest is what lets one user's curation — names, poses, scale, supports, tags, and per-file pose assignments — survive intact when another user scans the same release.
This document freezes format version 1. The manifest carries "version": 1; readers reject unknown majors and ignore unknown fields within a known major (forward-compatible).
Physical layout
A packed release is a directory (the distributable unit):
Designer-05-2026-Dungeon Classics/
├── release.3pk # zip: manifest.json + images/ + licence/
├── galeb duhr.zip # component archive (one per group/model)
├── giant badger.zip
└── behir.ziprelease.3pk— a zip archive containingmanifest.json, the release-level metadata jsons, preview images, and the creator's licence (packed under the canonical namelicence.<ext>when the builder's licence toggle is on). Small; this is what a client fetches first to learn the release's shape.- Component archives (
<component>.zip, later<component>.tar.zst) — the model files (STL/OBJ/3MF/LYS/…) for one group/model. One archive per component so a client can fetch, verify, or update them independently (the Modular Package Strategy: update detection and selective import fall straight out of the per-component checksums — both shipped, see Import below).
The .3pk never embeds the heavy model files — only references them by archive filename + checksum. A single-file distribution is a future option; v1 is deliberately modular.
manifest.json schema (v1)
{
"format": "3pk",
"version": 1,
"generator": "plinth/<app-version>",
"release": {
"name": "Dungeon Classics",
"designer": "Some Designer",
"date": "2026-05", // YYYY-MM (canonical; input MM/YYYY is normalized)
"version": "1.0.0",
"description": "…",
"tags": ["dungeon", "classic"],
"images": ["images/cover.png"], // paths inside release.3pk
},
"components": [
{
"name": "galeb duhr", // the logical model / group
"archive": "galeb duhr.zip", // sibling file, relative to the release dir
"checksum": "blake3:9f86d0…", // of the archive bytes — drives update detection
"size_bytes": 896812345,
"dedup": true, // archive stores duplicate contents once (see Deduplication)
"models": [
{
"id": "0e37…-uuid", // stable identity; survives moves/rescans
"name": "galeb duhr", // scanner/base name
"custom_name": null, // user override, if any (else null)
"description": null,
"group": "galeb duhr",
"tags": ["earth-elemental"],
"designer": "Some Studio", // the studio/brand (defaults from release, per-model override)
"sculptor": "A. Artist", // the individual artist, if known
"pose": "A", // model-level metadata (→ model_user_meta)
"scale": "32mm",
"support_status": "unsupported",
"release_date": "2026-05",
"preview": "images/galeb duhr A.png",
"files": [
// paths are relative to the component archive root
{
"name": "A/body.stl",
"checksum": "blake3:…",
"size_bytes": 1234,
"pose": "A",
"support_status": "unsupported",
},
{
"name": "shared/base.stl",
"checksum": "blake3:…",
"size_bytes": 567,
"pose": null,
"support_status": null,
},
],
},
],
},
],
}Field → catalog mapping
The manifest is the wire form of the catalog's rescan-safe tables. On import the scanner restores:
| Manifest field | Catalog destination |
|---|---|
models[].custom_name, pose, scale, support_status, release_date, preview | model_user_meta (overrides scanner inference) |
models[].tags | model_tags (source metadata) |
models[].files[].pose / support_status | file_variants (the dump-folder splits) |
release.*, components[] | models.release_name/designer, group identity |
Because file_variants rides in the manifest, a folder someone split into poses on their machine reappears already split on yours — the whole point of making pose metadata rather than folder structure.
Checksums
- Algorithm: BLAKE3 (already shipped for duplicate detection), encoded
blake3:<hex>. - Component
checksum(required): hash of the archive file's bytes. This is what update detection compares — a changed component is a changed hash, so a client re-imports only what moved. - File
checksum(recommended): hash of each model file's content, for granular integrity and cross-release dedup.
Deduplication
A component archive MAY store each unique file content once. Creators routinely repeat a base or body STL across weapon/pose variants; shipping those bytes five times helps no one. The rules:
- The manifest lists every file name, each with its content checksum. Identical files simply share a checksum.
- The archive stores the bytes for each checksum at (at least) one of its names; other names with the same checksum may be absent from the archive.
- A component that elided anything sets
"dedup": true(additive field; absent reads as false) so tooling knows plain unzipping is not enough. - On extraction, a manifest name missing from the archive is rematerialized from an extracted file with the same checksum — hardlinked where the destination filesystem supports it (the release lands on disk already deduplicated), copied otherwise. Either way every listed name exists afterwards.
Non-dedup readers/writers interoperate: a v1 archive without elision is just the degenerate case where every checksum is stored under every name.
Write path (packer)
At pack time the builder already stages models with their catalog metadata. Packing then:
- Groups staged models into components (by
group, else per model). - Writes each component's model files into
<component>.zip, hashing the archive →checksum, and each file → per-filechecksum. Entries inside a component follow the canonical catalog layout (Supported|Unsupported[/Variant]/files, amodel.jsonper leaf), so an imported release is already normal-form — the on-disk normalizer plans zero moves for it. - Emits
manifest.jsonfrom the staged metadata includingfile_variantsfor any split folders, plus release-level info. - Zips
manifest.json+ release images + licence intorelease.3pk.
Compression is ZIP in v1 (the only writer today); TAR+Zstd is a tracked upgrade and only changes component archive extensions + the reader's dispatch, not the manifest schema.
Import (opening a release.3pk)
Opening or drag-dropping a release.3pk first inspects it — nothing touches the disk until the user confirms:
- Read
manifest.json; reject unknownversionmajors. - Resolve the canonical landing spot (
Designer/YYYY-MM Releaseunder the library) and read the local manifest a previous import left there, if any. - Diff each incoming component's
checksumagainst the local manifest: new (not imported before), changed (checksums differ), unchanged (identical), packed (the local copy is compressed at rest — unpack first), or missing archive (the sibling zip isn't next to the.3pk).
The import dialog pre-selects new + changed components; the user can deselect anything or re-select an unchanged component to repair deleted files. The confirmed import then, per selected component:
- Verifies the archive bytes against the manifest
checksum— a truncated download or bit-rot is refused per component, the rest of the release still imports. - On an update, moves aside any file the user edited since the last import (bytes match neither the old nor the incoming checksum) as
<name> (edited).<ext>— slicer-saved supports are never truncated. - Extracts, rematerializing dedup-elided names (see Deduplication).
- On an update, deletes files the previous import wrote that the new manifest no longer lists, and sweeps emptied dirs. Files the user added themselves were never in a manifest and survive.
Finally the manifest is written into the release dir recording what is actually on disk: new entries for components that imported, the previous entry for ones that failed or were deselected. A partially failed update therefore still reads as "changed" on the next inspect — update detection stays truthful across partial runs.
A catalog scan afterwards restores the packed curation from the model.json sidecars. Legacy release.json / model.json sidecars remain readable; the .3pk manifest supersedes them when both are present.
Versioning & compatibility
versionis a single integer. Same-major additions are additive fields (ignored by older readers); a breaking change bumps the major and readers refuse to guess.- There is no live release yet, so v1 is defined freely here — no migration from a prior on-disk format is owed.
Out of scope for v1 (tracked separately)
- Single self-contained
.3pkcontainer (v1 is modular). - Partial download over a network (selective import of local components shipped; fetching only changed components from a host needs a distribution channel that doesn't exist yet).
- TAR+Zstd component compression (todo: replace ZIP for local compression/cataloging).