# 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/-[-n]`. A copy's manifest, `.packwand/copies/<16 hex>-.json`, is version 2 (ledger D-ZIG-HARVEST-1): each placed file maps to `{"source":[size,mtime_ns],"target":[size,mtime_ns], "sha512":""|null}`, the source's stamp when copied, the copy's own stamp after, and the SHA-512 of the bytes placed. Version 1 (`"":[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.