Fork verification: did the lab reproduce the live world?¶
A save file contains blocks and entity NBT but not the order the level ticks
entities in, and that order decides the outcome of anything computed entity by
entity. So "fork a live world" is only worth something if the restored world
ticks its entities in the recorded order. protocol-snapshot.md
defines what a snapshot is; tools/fork_verify.py is the consumer half of that
contract: it reads a fork directory, drives the bridge daemon over its loopback
API, and decides whether a restore was faithful. It does not take the game's
word for it - the verdict comes from the files and from a fresh snapshot read
back off disk.
What it reads¶
A fork directory is whatever mc_fork copied: region files plus
meta.json and entities.jsonl. The tool only needs the last two.
| Input | Used for |
|---|---|
meta.json: protocol, entities, orderHash |
validation: protocol 1, record count, and the hash the file must reproduce |
meta.json: radius |
the fresh snapshot in check asks the same radius (0/absent = every entity the level ticks) |
meta.json: tick, dimension |
orientation only; they are printed and never compared |
entities.jsonl: line order |
the tick order - the authoritative thing this whole track exists to preserve |
entities.jsonl: uuid |
orderHash, duplicate detection, and matching entities between two snapshots |
entities.jsonl: type, pos, nbt |
/summon lines for restore |
entities.jsonl: pos, vel |
position and velocity deltas in diff |
orderHash is the one value that proves a restore reproduced the order:
sha256(join(":", uuids in tick order))[:16]. The tool recomputes it from the
file and never trusts the copy in meta.json.
Subcommands¶
| Command | Does | Exit code |
|---|---|---|
inspect <forkDir> |
prints the meta fields, record count, type histogram, the recomputed hash, and every validation error | 0 valid, 1 invalid |
restore <forkDir> |
prints the /summon lines in recorded order (dry run, the default) |
0, 1 if the recording cannot become commands |
restore <forkDir> --apply --api-port 8765 |
issues them through the bridge one at a time, progress every 100 | 0, 1 if any command failed |
check <forkDir> --api-port 8765 [--name <tmp>] |
asks for a fresh snapshot and compares hash and per-type counts | 0 MATCH, 1 MISMATCH |
diff <forkDirA> <forkDirB> |
order hashes, per-type counts, first divergent index, largest position/velocity deltas | 0 identical, 1 different |
selftest |
builds synthetic snapshots in a temp dir and checks all of the above against a fake bridge | 0 pass, 1 fail |
inspect and diff are offline: they only touch the filesystem. restore
--apply and check need a bridge; both say which port they tried and exit 2 if
there is no daemon there.
python tools/fork_verify.py inspect <forkDir>
python tools/fork_verify.py diff <forkDirA> <forkDirB> # comparable experiment runs
python tools/fork_verify.py selftest # no bridge, no game
Dry-run output is one command per line on stdout and a one-line summary on stderr, so it is also a function file:
python tools/fork_verify.py restore <forkDir> > restore.mcfunction
python tools/fork_verify.py restore <forkDir> --from 500 --limit 100
The real sequence, once the mod and bridge land¶
Untouched from the rest of the toolchain: the bridge owns the only connection to the game, and this tool is one more loopback client of it.
# 0. game side (frozen in-process, blocks copied): the bridge's fork tool
mc-bridge call fork '{"name": "before", "radius": 64}'
# 1. what did we record? (offline; exits 1 if the file contradicts itself)
python tools/fork_verify.py inspect <forkDir>
# 2. bring it back into the isolated lab server, one /summon at a time
python tools/fork_verify.py restore <forkDir> # look first
python tools/fork_verify.py restore <forkDir> --apply --api-port 8765
# 3. the acceptance test: fresh snapshot of the lab, compared with the recording
python tools/fork_verify.py check <forkDir> --api-port 8765
# 4. two runs of the same experiment, compared entity by entity
python tools/fork_verify.py diff <forkDirA> <forkDirB>
check leaves the fresh snapshot on disk (named --name, else
forkverify-<utc timestamp>) so the failed case can be inspected afterwards,
and prints the path it read.
Reading a check¶
fork: <forkDir> (521 records, hash 9f2c1d6a8b0e4f37, tick 104233, minecraft:overworld)
live: <snapshotDir> (521 records, hash 9f2c1d6a8b0e4f37, tick 20, minecraft:overworld)
type fork live delta
--------------------- ---- ---- -----
minecraft:sulfur_cube 512 512 0
minecraft:item 8 8 0
minecraft:cow 1 1 0
total 521 521 0
orderHash: MATCH
counts: MATCH
result: MATCH
orderHash is the acceptance criterion: it can only match if the lab ticks the
same entities in the same order. counts catches the other half - a restore
that put the right entities in the right order but gave one of them the wrong
type, which the hash cannot see. A fork directory with any validation issue can
never come back MATCH, because an unverifiable recording is not evidence.
Reading a diff¶
diff answers the experiment question ("did run B differ from run A, and by
how much?") rather than the restore question:
order: first divergent index N- the first position where the tick order differs, with the uuid each side has there. Everything before N ticks alike.orderHash: MATCH|MISMATCHplus per-type counts.- the largest position and velocity deltas for uuids present in both runs,
each row with the entity's uuid and type.
--top Nsets the row count (0 = all).
Exit code 1 means "not identical", which is a finding, not an error: two experiments are supposed to differ. The tool prints zero-delta tables as a single line so a 500-entity comparison stays readable.
Decisions this tool made, and what was ambiguous¶
- Line order is the tick order. The
orderfield is validated against the line it sits on (ordermust equal the 0-based line index, and the set of values must be exactly0..n-1), but a restore follows the lines, not the field. The protocol describes the file as "one JSON object per line, in tick order", and a file that disagrees with itself is reported instead of silently reordered. - Dry run is the default.
--applyis required to touch the game, and--dry-runexists so a script can say what it means. Nothing is sent while the tool is only printing. - Restore drives
command, notrestore. The bridge has a whole-filerestoretool, but this tool issues one/summonper bridge call so that--from,--limit, the 100-entity progress report and per-entity failure handling all mean something. The client class still wrapssnapshot,snapshots,fork,restoreandorderexactly as the contract names them (the selftest asserts the method names and parameter shapes). checkreads the fresh snapshot off disk. The bridge'ssnapshotreturns a directory, and the per-type counts need the entities themselves - so the tool assumes what the rest of the toolchain assumes: the bridge and the game are on the machine running the tool. If the lab moved the snapshot directory somewhere this tool cannot read, that is a hard error, not a silent downgrade to comparing hashes only.checkuses the fork's radius. A radius-limited recording is compared with a radius-limited fresh snapshot; onlyradius <= 0or an absent radius asks for every entity. The radius is measured from the player (client vantage), so a lab that restores entities around a different reference point will show up as a count mismatch - which is honest, not noise.nbtis required by validation, tolerated by restore. A record with nonbtis a validation error (the protocol saysnbtis exactly what/summonaccepts), andrestoreprints a warning and summons without it - the entity appears, its in-memory state does not, so a faithful restore cannot be claimed for it.- Restore refuses only what it cannot act on. A missing or malformed
type/pos, or an unparseable line, stops the run; order, uuid and hash problems are warnings, because the lines are still summonable.inspectalways shows the complete list. - One command per top-level record, passengers included. The protocol
writes one line per entity the level ticks, so the restore issues exactly
those lines in file order and does nothing clever about
passengersorvehicle; attachment is the NBT's job, and de-duplicating on this side would break the order guarantee. - Counts are compared as recorded,
minecraft:cowandcoware two different types. The mod writes fully qualified registry names on both vantages, and hiding a spelling difference would hide a real bug. --limit 0means "all remaining", and--fromis a 0-based index into the recorded order;--frompast the end prints nothing and exits 0.- Missing
velis not an error.pos/vel/rotation are a convenience copy of what is innbt, so a snapshot withoutvelstill validates; it just has nothing to compare, anddiffsays so instead of inventing zeros. - A fork with validation issues can never pass
check, even when the live side matches it. Comparing against a file that contradicts its own metadata proves nothing. --api-portselects the instance;targetis not used. The bridge'srestoreandordertake an optionaltarget, and the client class passes it through, but the CLI reaches a lab the way the rest of this toolchain does: point--api-portat the daemon that owns that instance.
The selftest¶
python tools/fork_verify.py selftest
It builds seven synthetic snapshot directories under a temp directory (an
identical pair, a pair with two entities swapped, a pair where one entity moved
0.5 blocks, one where a type changed, one whose recorded hash lies, and a
deliberately broken one), serves them through a fake transport with the
LocalApiClient shape, and asserts what inspect, restore, check and
diff report - including exit codes, the printed /summon lines, the
divergent index, the 0.5-block delta, the per-type table, and the bridge method
names and parameters the client sends. No bridge, no game, no real snapshot:
it is the part of this track that can be verified on a laptop.
What this tool is not¶
- It never starts, stops or freezes Minecraft, and it never copies files: that is the mod and the bridge.
- It does not compute physics or compare blocks.
diffcompares entities; the region files are the bridge's business and a block-level comparison would be a different tool. - It does not decide what a difference means. It reports the first divergence and the largest deltas; the experiment decides whether that is a finding.