packutils — design #
Status: the linking port (plan M2, 2026-10-15) and harvest (M3, 2026-10-16) implemented. AI-authored (Claude).
1. What it is #
packutils is the C++23 library behind Packwand's work on a game instance beside a pack:
- Linking (
link,unlink,link --watch): a pack's content folders linked into a game directory, so the instance follows the pack. A symlink on Linux and macOS, a junction on Windows, and a copy kept in step by polling where neither works or--copyasks. Ported from Packwand's Zig engine (ledgers D-ZIG-LINK-1, D-ZIG-LINK-2) with its state files byte for byte. - Harvesting (M3, experimental): the reverse, an instance's changes brought back into the pack, as a plan Packwand guards and commits.
Packwand stays one static binary: packutils is linked in, as packluau is. Packwand owns the pack's rules (which folders are linked, the inventory rules), the clock, the watch loop and every write to pack sources; packutils owns placing, recording, copying and removing links.
2. Architecture #
include/packutils.h: the C ABI. Zig reads it through translate-c inapps/packwand/src/mpm_packutils(arch_check A10), andmpm_infra/files/PackutilsLinked.zigis the adapter the commands use.src/abi.cpp: the exported functions; every one isnoexcept.src/links.cpp: the engine.src/launchers.cpp(instance folders),src/records.cpp(links.json),src/mirror.cpp(a copy's sync rules and manifest),src/json.cpp(strict reader, canonical writer),src/fs_*.cpp(POSIX and Win32, links never followed).- No third-party code; hashing comes from the host (section 3).
3. The ABI (PACKUTILS_ABI 1) #
packutils_create(host, host_abi, &rt, &err)checks the ABI,host->sizeand that every host function is set.pu_hostcarries:cancel: a byte; nonzero stops the running operation at its next check (between files, and while waiting for the links lock).sha256(ctx, bytes, out): names a copy's manifest file (hex(sha256(instance)[0..8])).sha512_file(ctx, path, out): a file's SHA-512 read through. A copy's manifest records it, and harvest's plan carries it, so hashes compare directly with Packwand's own.- Hashing comes from the host by design: packutils carries no cryptography, and every digest is the one Packwand's std computes. Decided before ABI 1 shipped. ABI 1 is unreleased, so harvest's three functions joined it without a bump.
- Each operation takes one JSON request (UTF-8, an object) and answers one JSON document, valid
until the next call or
packutils_free_result. Every request names"pack"(the pack's real full path) and may set"lock_files"(default true; false only for Wine, which has no byte-range locks),"lock_timeout_ms"(default 10000) and"links_unavailable"(a test seam: no directory links, so copies).
| Function | Request members | Answer |
|---|---|---|
packutils_links_load |
— | {"links":[Link]} |
packutils_resolve_instance |
instance |
{"instance": real path, "launcher"?: {kind, minecraft, loader, loader_version}} |
packutils_link_check |
instance, folders |
{"checks":[{folder, slot, path}]} |
packutils_link |
instance, checks, force, copy, now (Unix seconds) |
{"placed":[{folder, mode, kept, backup?, blocked}]} |
packutils_unlink |
instance (or null for every instance) |
{"removed":[{link, outcome, restored}]} |
packutils_sync |
link (a copy link) |
{"copied","deleted","blocked","failed","kept","conflicts","unknown"} |
packutils_harvest_plan |
instance, pack_folders, never?, folders?, files?, mods? |
{"findings":[{kind, path, sha512}],"candidates":[...]} |
packutils_lock |
— | {}; the runtime holds the links lock until packutils_unlock(rt) |
resolve_instance takes a game directory or a launcher's instance folder. A Prism (MultiMC,
PolyMC) folder holds instance.cfg; its game directory is .minecraft/ (older MultiMC:
minecraft/), and one with neither has never been started and is refused. launcher reports what
its mmc-pack.json names (kind is prism; a member it does not state is empty), and Packwand
warns when that differs from the pack (src/launchers.cpp).
Link is {instance, folder, mode, backup?}, mode one of symlink, junction, copy;
slot one of free, empty_directory, ours, taken; outcome one of removed, missing,
left_in_place.
4. Failures #
pu_error carries a status (PU_FAILED, PU_INVALID for a malformed request, PU_CANCELED,
PU_INTERNAL), and with PU_FAILED a kind (the host's error kinds), a stable code and a message:
| Code | Kind | When |
|---|---|---|
InstanceInvalid |
invalid_request | the game directory is missing, not a directory, or overlaps the pack |
LinksLockTimeout |
conflict | another process held .packwand/lock for the whole timeout |
LinkConflict |
conflict | a slot holds a folder the pack did not link, or a copy is no longer one |
NotALink |
conflict | a recorded link was replaced by a real folder |
LinkRetargeted |
conflict | a recorded link points elsewhere now |
LinksInvalid |
invalid_request | links.json is not one this build writes |
LinksTooNew |
invalid_request | a newer build wrote links.json (upgrade is set) |
MirrorManifestInvalid |
invalid_request | a copy manifest is not one this build writes |
MirrorManifestTooNew |
invalid_request | a newer build wrote a copy manifest (upgrade is set) |
HarvestFolderInvalid |
invalid_request | a folders name the instance lacks, or the pack has |
HarvestFileMissing |
not_found | a files name that is no file at the instance's top |
HarvestUnreadable |
operation_failed | an instance file could not be read for its hash |
FileSystem |
operation_failed | the file system refused: path, cause (Zig's error name), directory |
For FileSystem the host words the message, as .NET would have, from the cause and path.
5. State files #
From the Zig engine, unchanged: .packwand/links.json (version 1), .packwand/lock,
.packwand/.gitignore (*), and backups in the instance's
.packwand-backup/<folder>-<yyyyMMddTHHmmssZ>[-n].
A copy's manifest, .packwand/copies/<16 hex>-<folder>.json, is version 2 (ledger
D-ZIG-HARVEST-1): each placed file maps to {"source":[size,mtime_ns],"target":[size,mtime_ns], "sha512":"<hex>"|null}, the source's stamp when copied, the copy's own stamp after, and the
SHA-512 of the bytes placed. Version 1 ("<path>":[size,mtime_ns], the source's stamp alone) is
still read; a newer version is refused with upgrade. Both files are written by the canonical JSON
writer, whose bytes the tests pin.
What sync does with each file the source and the copy both hold (mirror::classify): stamps
decide first, and a hash is read only when a stamp moved, so a pass reads no file that did not
change.
| The copy | The source | Sync |
|---|---|---|
| as placed | unchanged | nothing (a moved stamp with the same bytes is refreshed) |
| as placed | changed | copies |
| changed | unchanged | keeps it, reported once (kept) |
| changed | changed | keeps it (conflicts), unless both now hold the same bytes |
| version 1 entry | either | hashes both: the same becomes version 2; otherwise unknown, kept |
| never placed | — | keeps it, unless it is the same bytes, which are adopted |
| deleted | — | copies the pack's back |
A placed file the source dropped is deleted only while it is as placed; a changed one becomes the
instance's (it leaves the manifest), and a version 1 one is unknown. unlink follows the same
rule, so an edited copy stays; it also removes any copy file whose bytes the pack's own file holds
(as after a harvest), since nothing is lost.
6. Harvest #
packutils_harvest_plan reads only, holding the links lock for the whole scan. Per copy link into
the instance it reports (mirror::findings): edited, conflict, unknown, added, deleted,
and jar for a jar added at the top of the pack's mods folder; symlinked and junctioned folders
are the pack's own and report nothing. candidates are the instance's top-level folders the pack
does not link (not dot-folders, the backup folder, or never); folders takes one whole, and
files names top-level files (file). Each finding but deleted carries the instance file's
SHA-512 as read.
Packwand decides the rest (harvest_command.zig): what is taken (--prefer, --path,
--deletions), jars identified into sidecars, destinations the pack cannot keep. Its apply holds
the links lock through packutils_lock from reading the instance until the commit ends (lock
order: the links lock, then the pack's write lock; sync never takes the write lock), re-hashes each
instance file against the plan, and commits through commitSources guarded by each pack file's
SHA-512 as planned.