Monorepo for PackTools - tooling for Minecraft Modpacks!
9.0 kB
Markdown

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 --copy asks. 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 in apps/packwand/src/mpm_packutils (arch_check A10), and mpm_infra/files/PackutilsLinked.zig is the adapter the commands use.
  • src/abi.cpp: the exported functions; every one is noexcept.
  • 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->size and that every host function is set. pu_host carries:
    • 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.