What has actually been verified, and by what evidence. Anything not listed as verified should be read as not verified.
Reconstruction: archive to a playable world.
An archive was exported into a world created by an unmodified Minecraft 26.2 client, and the result loaded and rendered correctly. Evidence:
- The client's own debug screen reported reading the exported region file.
- A fixture arranged so that each class of error looks different from the outside was checked in game: a red line along +X and a blue line along +Z would swap if the horizontal axes were transposed; three oak logs with
axis=x,axis=y,axis=zwould look alike if block state properties were lost; a second floor at y=-64 would vanish if negative section coordinates were mishandled. All were correct. - The chunk at section Y=-4 loaded and rendered, so the negative-height path introduced in 1.18 is exercised.
- An independently written Anvil reader, implemented from the format specification rather than from the writer, unpacked the palette and long array and confirmed twelve specific blocks at their expected coordinates in the file inside the world.
This run also found a real defect that no byte-level test could: Minecraft 26.2 stores every dimension under dimensions/<namespace>/<path>/region/, including the vanilla three, and there is no top-level region/ directory. The exporter had been writing to the older layout, which the game silently ignores.
The capture game test is the standing form of this check. It passes from a clean run: 158 chunks enqueued, none dropped, no snapshot failures, 158 ready bundles, and no quarantined or partial entries once the writer drained. All 158 import, fsck reports zero errors, and the archive resolves them to 55 stored objects.
Cross-release conversion: a converted world opened by an older server.
A capture taken from 26.2 was converted for 1.21.11 and opened by Mojang's 1.21.11 server, whose SHA-1 matched the one their version manifest publishes. The server was then asked what it had found. Evidence:
- The target is a real release rather than a guess.
profiles/minecraft-java-1.21.11.jsonis extracted bycmd/mcprofilefrom that release's own jar, and against 26.2 it is genuinely smaller: 1,168 blocks to 1,198 and 65 biomes to 66. The thirty it lacks are the cinnabar family, the sulfur family, and the golden dandelion in both its planted and potted forms; the biome it lacks isminecraft:sulfur_caves. It holds nothing 26.2 does not. - Converting the 158-observation capture wrote 157 chunks into four region files under
world/region/, which is the layout 1.21.11 uses and not the one 26.2 uses. The exporter chose it by asking the world it was writing into. A reader that never touches the Go writer reports data version 4903 on the faithful export and 4671 on the converted copy. - The server loaded that world and logged no chunk error.
- Over RCON,
execute if blockwas run at five coordinates: oak log at 2 65 1, oak stairs at 4 65 1, stone at 0 64 0, grass block at 0 -61 0, bedrock at 0 -64 0. All five reportedTest passed. The same test for a block that is not at 2 65 1 reportedTest failed, so the check distinguishes rather than passing whatever it is given. - The server shut down reporting all chunks saved, and it had rewritten the chunk: the palette came back in a different order, which is the signature of Minecraft re-serialising from its own world model rather than leaving the file alone. Every block was still at the same coordinate, and the chunk still carried data version 4671 and status
minecraft:full.
The coordinates were not chosen by hand. An independently written Anvil reader unpacked the palette and long array from the converted file and reported where each block sat, so the question put to Minecraft was formed from the bytes rather than from the code that produced them.
Two limits belong with this. The conversion of that particular world reported no loss, which is the truth for it: it is superflat and uses none of the thirty blocks 1.21.11 lacks. The loss paths are covered by tests against the same profile, not by this run. And this was a server, so nothing here says how the world renders.
The whole path, once, by one person, on a public server.
Somebody installed through the desktop application, played on top.earthmc.net, and came back. Nothing was staged: the server is a real one they chose, the session is however long they played, and the archive is the one the application keeps.
- The capture folder held 608 ready bundles and nothing quarantined or half-written.
- All 608 imported, none failed, in three and a half minutes. The archive came out at 608 observations, 4,115 objects, 33.1 MB, over 395 chunks of one server.
- The recordings were left in the capture folder, and the screen said so rather than leaving somebody to wonder whether they had been consumed.
- A disposition was declared by name before anything could be exported. It was refused until then.
- 395 chunks were written into a world, landing in
dimensions/minecraft/overworld/region/— the 26.2 layout, chosen by asking the world rather than assuming, which is the defect a real client found once before. - The two region files written hold 380 and 15 chunks, which is the 395 the export reported. The four region files that world already had were left with their 3,540 chunks untouched.
- An independently written Anvil reader, which never touches the Go writer, read chunk (712,320) and (713,319) out of those files: data version 4903, status
minecraft:full, and real terrain — stone, grass block, water, deepslate — at coordinates around x 11,400 and z 5,120, consistent with the region numbering.
Nothing was observed twice and nothing was withheld, so this run exercises the faithful path rather than the conflict or redaction ones. The world written into was a copy of one the player already had, because a world's seed and rules are never invented; their two existing worlds were not touched.
A released build installing, from the release itself. The worldledger-desktop-windows-amd64.exe published with v0.3.0 was downloaded, its SHA-256 matched the checksum published beside it at the time, and it reports version 0.3.0 and carries the address of that release's own mod jar. Installing with it put the released jar into the mods folder with a digest matching the published asset, and the health check then read six green lines.
Uninstalling with it, after the launcher settings had been rewritten the way opening the launcher rewrites them, left nothing behind and kept everything that was not ours. A checksum of every file two levels down under .minecraft came back to what it was before, apart from the launcher timestamp that had been changed on purpose.
The address the application fetches from was then requested with no credentials at all, which is what a stranger's copy does: 200, 128,149 bytes, and a digest matching what SHA256SUMS.txt on the releases page declares.
What that release does not currently let a reader repeat. v0.3.0 was drafted before the workflow learned to compute one checksum list over everything it publishes, so the SHA256SUMS.txt on that release names the mod jar and nothing else. The per-file .sha256 siblings that covered the other six assets were clutter and were removed by hand afterwards, which is why the check described above cannot be repeated as written: somebody downloading the desktop application from that release today has no published checksum to compare it against. The list the workflow now produces covers every file it uploads, from the next release on.
The desktop application, as far as it has been run.
The window opens. Built for Windows and launched on a machine with the WebView2 runtime, it shows a native window titled WorldLedger, and closing that window ends the process. Built with -H=windowsgui it shows one window rather than a console behind its own. In browser mode, a page that stops reporting in ends the process after the timeout, which was checked with a shortened timer: without it a closed tab would leave the program running with no way for its owner to stop it.
The path was walked end to end in a browser against forty real capture bundles, on a fabricated Minecraft directory: the health screen read the machine, forty recordings were found and imported, a disposition was declared, forty places were written into a world, and the time-travel map drew. The archive core was called directly throughout; nothing parses command output.
What walking it as a player found. That first walk followed the path and everything on it worked, which is a different question from whether it was the right path. Walking it again against an archive holding two servers, on a machine where the mod had been removed but its folder had not, found five things a working screen can still get wrong, and all five are now fixed and re-checked in the running application:
- The window promised a Remove and had none. The consent dialog said anything replaced is kept and Remove puts it all back;
/api/uninstallexisted and its own refusal named a button on the set-up screen; no such button had ever been drawn. Reversibility is what the install asks somebody to accept on, so it can no longer be a thing only a developer can reach. - Nothing anywhere named the responsibility. NOTICE carries it, the README points at it, the site has a paragraph of it, and the window somebody double-clicks said none of it. It is now the first thing the application shows, once, and stays reachable from the rail afterwards.
- Two screens silently picked a server. Making a world exported the first declared server and time travel read the first server, neither of them saying which. On an archive holding a 157-chunk session and a 6-chunk one, the 6-chunk one won both: the export would have written it without a word, and time travel reported "only one session so far" while the other sat beside it. Both screens now name the server, offer the choice, and default to whichever holds the most rather than whichever sorts first.
- The play screen read a leftover folder as proof of capture. A spool directory outlives the mod that wrote it, so a machine with the mod removed was told to go and play while nothing would have been recorded. It now asks the same checks the set-up screen asks.
- Time travel could show a moment and not go to it. The export has always taken an instant and the window only ever sent "now" — so the one thing a world downloader structurally cannot do was visible on screen and reachable nowhere. A comparison now offers to build either of its two moments, and an export at a time before anything was observed is refused with what to do instead rather than quietly writing the present.
Two smaller ones came from the same walk. 608 recordings already taken in were occupying 126 MB and 30,979 files inside a real .minecraft, reported as a bare count with no way to act on it; the size is now shown and clearing them is offered, and only bundles already imported can be cleared. And the declaration screen claimed to offer the contributor's name as the default for who is deciding while reading a field the status had never had, so the one step deliberately left to a person came up blank every time.
Where those defects lived, and what now stands where they were. Four of the five were on the seam between the page and the API, and nothing held the two halves together: an endpoint the page never called, a field the page read that had never existed. Both are the same shape — one side changed and the other did not — and neither breaks anything visible, so both survived a walk that only asked whether the screens rendered.
desktop/ui now reads the page as text and checks it against the Go it talks to: every /api/ path the page asks for is one api.Mount registers, every endpoint registered is one the page reaches, and every field the page reads off a status or a spool state is one the struct's JSON tags produce. Reintroducing either real defect was tried and fails the build, naming it: the page reads status.declared_by, which the status does not have, and nothing on the page reaches [/api/uninstall]. Each check refuses to pass on an empty result, because a pattern that stops matching would otherwise report agreement it never looked for — which it caught first on itself, when the extractor was written with one dot too many.
How the application asks. Everything consequential — writing five files into a Minecraft, removing them again, deleting recordings, writing over a world somebody has played in — was agreed to in window.confirm. That heads the question with 127.0.0.1:53211 says, renders a list of file paths as one run of text, and makes consent depend on script dialogs being switched on in whatever is showing the page. It is now a sheet in the application, with each file as its own row and its full path; Escape and Cancel both answer no. Checked in the running application: the five planned files appear as five rows, cancelling wrote nothing, and a mods folder still does not exist on the machine it was tried on.
The worst defect this project has had, found by somebody using the window.
An export replaced whole region files. A region file holds up to 1,024 chunks; an export usually has a handful in any one of them; the file was laid out from what the export was given and written over what was there. Every other chunk in it was deleted — chunks this archive never observed and had no standing to touch — and nothing failed, because a shorter file is not an error.
It was found by a person walking the path in the native window. They exported into a world they had made two weeks earlier, and it went from 16 MB to 2.8 MB. The world's own point-of-interest data, which the export does not write, still names 29 chunks in the two regions it replaced; those chunks are gone. Nothing had warned them, because the size heuristic that asks "have you played in this?" only speaks above 40 MB and that world was 16.
Three things were wrong at once and all three are fixed:
- The write was destructive.
Region.Adoptnow reads a region file that is already there and keeps every chunk the export does not supply, copying the frames through without decompressing, parsing or re-encoding them: this archive did not observe them and has no business having an opinion about their contents, only about not losing them. Where the two meet, the export wins, so it can still correct what it wrote before. A region file that cannot be read stops the export instead of being replaced by a smaller one, and it is read before anything is written, so a refusal leaves the world alone. - It was measured on the data that exposed it. Writing the 6 chunks the archive holds for
top.earthmc.netinto a copied world whose region file held 380 reported 374 kept, and the world's chunk count across all six region files was 552 before and 552 after. Before the fix the same operation left that file with 6. - The confirmation was reassuringly wrong. It said recordings are written over "where they overlap what is there", which describes chunks and was true of nothing. It now says a place you recorded that also exists there is replaced and everywhere else is left alone, which is what the code does.
ExportReport carries Kept, and both the window and the command line report it, because "did this eat my world" is the question somebody actually has and the export previously could not answer it. convert shares the same writer, so it is covered by the same fix. On the command line, exporting the six chunks the archive holds into a copy of that world prints 374 chunk(s) already in those files were left as they were, exits 0, and leaves the world's 552 chunks at 552.
Everywhere else this project writes into a file it did not create. The defect above is a shape, not an incident — laying a file out from only what we have and writing it over what somebody else put there — so the other three places were checked for the same thing.
capture.propertieshad it, smaller. That step runs whenever the contributor is blank, and blank is exactly the state of somebody who installed the mod themselves, started the game once, and tunedcoalesce_ticksorqueue_capacitybefore getting round to their name. A fresh three-line file replaced all of it. It now edits the contributor line where it stands and passes every other line through — settings, comments, blank lines, the file's own choice of line ending — and only writes from scratch when there is no file. A commented-outcontributoris not mistaken for the setting.launcher_profiles.jsondid not have it: the entry is added to the document that is there and removed by deleting one key, and a test has covered the launcher's own installations surviving since that path was written.- The spool has the destructive operation but not the defect: clearing removes only bundles marked as already imported, and
spool.Discardchecks each name itself rather than trusting the caller, so a ready bundle offered to it fails the whole call before anything is removed.
Two more things that person found, which the walkthrough before them had not.
The window is now told to draw at the display's real resolution. It never declared DPI awareness and the library it uses does not either, so on any display scaled above 100% Windows rendered it for 96 dots per inch and stretched the result: everything worked and every letter was soft. The initial size is scaled to match, because a window asked for in 96-dpi pixels comes out physically small once the process is aware.
And the path ended without saying what had happened. It ended in one green line where the whole screen had been — no count, no folder, no files, nothing about what was already in that world. The last screen now answers the four questions that are actually asked there: what went in, what was already there and survived, where it is, and what to do next. On the same run, the install had also refused after the person had typed a name, read five file paths and agreed to all five, because a build from source carries no address for the mod; that refusal now happens before the asking, since consent collected for something that cannot happen is worse than not asking.
Three more the window had, found by looking for the same shapes again. None of them fails anything. Two were re-checked in the running application afterwards and the third, which needs a second install into a real Minecraft, is covered by tests over the merge itself and is marked as such below.
Reloading the page broke the application, and in browser mode killed it. The token arrives in the address and is stripped immediately so it does not reach history — and it lived only in a variable, so pressing F5 left the page with no token, every call answering 401, and the keepalive silenced, which ended the process forty-five seconds later. F5 on a screen that looks stuck is the most natural recovery action there is. The token now survives in sessionStorage, which is not what the original note was about: it empties when the tab closes and is shared with no other tab, and a token only ever works for the server that minted it. Checked in the running application: after a reload the address carries no token, the token is recovered, /api/status answers 200 and the page draws.
Those four rejections were also the only failures in the application that came back as bare text, so the page could say "the application answered 401" and had nothing to suggest. All four now carry a problem and a next step, with a test over every one.
A second set-up destroyed the record of the first. A plan holds steps only for what is missing, so a second run is usually one step, and the record was written over rather than added to — leaving Remove able to delete one jar while reporting "Your Minecraft is back to what it was", with the Fabric profile, the launcher entry, Fabric API and capture.properties all still there. The play screen sends people to Set up for exactly the case that causes it. Records merge now: the digest from the newer install, because that is what an uninstall compares against, and the backup from the older one, because that is what was there before this application first touched the file. Reproducing that end to end means installing into a real Minecraft twice, which has not been done; what is covered is the merge itself, by tests, including that a first install is unchanged by passing through it.
And the whole application only ever meant the overworld. The page sent no dimension, so export, moments and travel all defaulted to it while the server's chunk count summed every dimension — so an evening in the Nether showed "800 places recorded" on one screen and "nothing recorded at that moment" on the next, with the word overworld appearing nowhere. The status now reports each dimension and its count, and both screens have a World chooser beside the server one.
In the native window, as far as it was watched. All of the above was exercised through the browser, which is the same page and the same server but not the same renderer. The window was then opened on Windows against the WebView2 runtime 151.0.4129.101 and a capture of it shows the application drawing: the numbered rail, the disposition cards, and a server's existing decision shown as the selected one. Within four seconds of that window opening, the first-run notice was accepted and written to disk, so the sheet appeared in the window, its button answered, and the request reached the archive's own directory — but the click was not this project's to claim, because somebody was at the keyboard, and it is recorded here as what it is rather than as a run.
What was not done is driving that window further. Doing so means taking over the screen of whoever is using the machine, and the attempt to do it captured a window that had nothing to do with this project, which is reason enough to stop. The confirmation sheet is therefore covered in the window only by what it shares with the notice: they are one element wearing two ids, with the same class and the same code, and the contract test under desktop/ui fails if they stop being so. That is a smaller claim than a run and it is the one the evidence supports.
The installer, against a real Minecraft.
It was run against an unmodified Minecraft installation with no mods and no config directory, with the mod jar rebuilt from source first. Evidence:
- The five steps it listed beforehand are the five files it wrote, and no others: the Fabric version profile, an entry in the launcher's own list, Fabric API, the mod, and
capture.properties. - Fabric API arrived as a real archive of 53 entries and 2,531,175 bytes rather than an error page, and the mod jar's SHA-256 matches the jar that was built.
- The whole of it took a quarter of a second, because no installer program is run: Fabric publishes the version profile the launcher needs, so this is two small downloads and four writes.
- The health check then reported ready, with all six lines green, including the contributor it had written.
- The launcher's own two installations were still there afterwards, alongside the added one, and the rest of that file was intact.
- Uninstalling put everything back. Every file it wrote is gone; the four directories it created are gone;
launcher_profiles.jsonis byte-for-byte what it was before; and a SHA-256 of every file two levels deep under.minecraftis identical to the same list taken before installing.
What this run does not cover: the game was not started afterwards, so the mod is verified as installed rather than as loading. The capture game test is the standing check for whether the adapter runs, and it runs on every push.
- Canonical identity. Observation ids, state digests, and object hashes have committed golden vectors. Changing one requires a specification-level explanation. The identity preimage encodes an instant as integer seconds and nanoseconds rather than as formatted text, because text leaves trailing zeros in the fractional part to each language's convention and two conforming implementations would otherwise disagree; golden vectors cover the values where those conventions differ.
- Point-in-time selection. Corroboration, supersession, and conflict are distinguished by a simultaneity window, and selection is order independent. Exercised on live capture data, not only on fixtures.
- Archive manifests. Two archives compare by a root digest and localise their differences to individual chunks without transferring any chunk data.
- Cross-language canonicalization. The Java adapter and the Go reference encoder agree byte for byte on committed fixtures.
- Canonical decoding. Every committed golden component decodes and re-encodes to identical bytes. Decoding validates canonical form, so a successful decode is also proof the stored bytes are canonical. Five fuzz targets exercise this against arbitrary input.
- Bundle ingress. Hostile bundles are rejected: wrong digests, wrong sizes, path traversal, symlink escape, truncated manifests, oversized components.
fsckpasses after any failed import. - Anvil output. Bit packing, Java modified UTF-8 including supplementary characters, region layout, sector alignment, and reproducibility have exact byte-level assertions.
- Epoch selection. The corroborated-first policy is total and order-independent; repeated submissions from one contributor cannot manufacture corroboration.
- Structure placement.
internal/seedis checked against vectors generated by a real JVM runningjava.util.Random. - Release profiles. The committed 26.2 profile is checked against values independently readable from the game artifact.
- Attestation. An ed25519 signature over an observation id verifies, and fails when moved to another observation, when the signature is altered, when the key is swapped, or when it was made without the domain separator. The archive refuses to store an attestation that does not verify. A valid signature from an unregistered key is reported as valid and unrecognised rather than as an endorsement, and a second key cannot register a label another key already holds.
- Object existence negotiation. Two mirrors work out what to transfer from their fingerprints alone, in both directions, without either opening the other's archive.
- Archive exchange. Two archives that never shared a database converge to the same manifest root by exchanging transfer bundles in both directions, exercised on the two real capture sessions on disk. A bundle whose object bytes were substituted is refused, and so is one whose observation was reattributed to another contributor, because the identity no longer matches the record. A repeated import changes nothing.
- What a crash leaves behind. An atomic write creates its temporary in the directory it will be renamed into, because a rename across filesystems is not atomic, so a crash leaves one inside the archive. The index enumeration refused every entry it did not recognise, which meant one badly timed power loss made every read of that archive fail from then on with
unexpected entry in chunk indexwhilefsckreported no errors, since it only looks at.idxfiles. It steps over them now, at all three levels it enumerates, andOpensweeps them under the exclusive lock it already holds. Tests cover the archive still opening, still reading, and the residue being gone; with the prefix check removed the first reportsunexpected file in chunk indexwhere it should have read the archive. - Deleting only what is known to be dead. That sweep first covered the object store's temporaries as well, and it should not have. It turned two CI jobs red, which is the only reason it was caught, and it would have broken a real concurrent import the same way. The object store does not take the archive lock, so a file under
objects/tmpcan belong to a write happening at that moment in another process — and the window is not the one it appears to be, because the store closes its temporary before renaming it, so for part of every write the file is an ordinary closed file that any operating system will let a second process delete. The rename then fails with the file simply gone. Nothing at open time can tell an abandoned object temporary from a live one, so it no longer tries; a test holds that invariant, and with the sweep put back it fails on every run rather than occasionally. The cost is that object temporaries still accumulate after a killed import, which is smaller than deleting somebody's write in progress. - A crash during a declaration does not cost the archive. The records kept beside the observations — publication policies, redactions, identities, attestations, landmarks — were each written with a call that truncates in place, so a crash part way through left a file that parses as nothing. Every reader of it then failed: a half-written redaction takes
redact list,redact purge, coverage and export with it, until somebody finds and deletes a file nobody told them about. They fail closed, which is the right direction and not a recovery. All five go through one atomic write now, so the previous contents survive intact or the new ones replace them, with nothing in between. The archive and the object store keep their own older versions of the same thing: both work, both are covered, and moving them would be churn with a real downside and no visible gain. - An interrupted purge finishes. The journal recorded observation ids only, so once the observations were removed an id no longer related to anything on disk: a replay found nothing to remove, skipped the object phase, and deleted the journal as finished. The bytes somebody had asked to have removed stayed there, after
redact purgehad reported success, andfscknever looks for objects nothing references. The journal carries the references now, gathered while the observations can still be read, and a journal in the older shape still replays as far as it ever could. The journal is also the one input to recovery that is a file rather than something just computed, and an id that cannot name an observation is refused rather than reaching a path join. - The cross-platform gate can fail. The claim above rests entirely on one command turning a detected disagreement into a non-zero exit, and
internal/archivetested the comparison while nothing tested that. A comparison that stopped returning an error would have printed the divergence and gone green, which is worse than no gate. It was tried: a fingerprint whose root matches its entries but whose first chunk state differs produces1 chunk(s) disagreeand exit 1, and two captures sharing no chunk are refused rather than passing on an empty overlap. Four tests incmd/worldledgernow hold both of those, and the two cases that must stay quiet — agreement, and a change only one capture stayed long enough to see. - Cross-platform digest agreement. The same observed world state, captured by a Windows client here and by a Linux client in CI, canonicalized to identical bytes. The two fingerprints agree on all 157 chunks both observed, with no chunk seen by only one side and no state either could not account for, and the two files are byte-identical at 24,972 bytes. The game test pins the world seed, generator and view distance so that a difference between the two could only have come from the encoder. That reference is committed, so every CI run now compares against it and fails on a disagreement rather than reporting one.
- Spool storage. Identical component bytes are stored once. Twenty bundles declaring 199,671 bytes occupied 36,879 on disk, identical components resolved to a single file, and deleting one bundle after import left a bundle sharing its bytes readable. A 40 KB budget stopped capture after 32 bundles and left all 32 in place; the same writer with a large budget kept going.
- Player-facing notices. The text shown in game is built with no Minecraft type in it and is asserted directly: the disabled notice names the file and the setting, a clean session does not mention drops, a lossy one names them separately from the total, and a session that captured nothing does not read like capture being switched off. The class that draws them holds no logic.
- In-game commands. The client game test parses
/worldledger,/worldledger status,/worldledger spooland/worldledger reloadagainst the live client dispatcher and requires each to reach something executable, then sends one through the client's own command path the way a keystroke would. Registering without throwing is not the same as a command a player can type, and the two fail separately: a tree of the wrong shape parses nothing, and a tree of the right shape can still have a handler that throws on its first line. - Redaction. Contributor and region scopes withhold matching observations from everything the archive builds for sharing. Purging removes observation records, removes objects nothing else references, and reports every object it kept along with the surviving contributor that still needs it. An interrupted purge is journalled and finished when the archive is next opened, because either half-done order leaves a state the integrity check rejects.
CI runs the Go gates on Linux and separately tests, vets, and builds the Windows-specific filesystem and locking paths. The Fabric build runs in Linux CI.
Capture: multiplayer session to archive.
The client game test at adapters/fabric/src/gametest/ passed against a real 26.2 client. It starts a dedicated server, connects a real client, applies block and biome changes, disconnects, and verifies the published bundles: manifest schema, dimension, component presence, and every component's declared size and digest against the bytes on disk. It fails when no bundle appears, so a run with capture disabled cannot pass quietly.
The run produces ready bundles with no quarantined or temporary entries left behind, all of which import into a Go archive; a repeated import is idempotent and fsck reports zero errors. Exporting that archive produces a world.
That closes the loop end to end: a live multiplayer session becomes an archive, and the archive becomes a world.
It also found a defect that no unit test could. A disconnect releases every dirty chunk at once, and the first run discarded 108 of 158 chunk snapshots because the bounded queue refused what it could not hold. The bound exists so game threads never wait on disk, which is right during play but inverted at disconnect: there is no gameplay left to protect, and refusing a job only destroys observed state. The final flush now waits for the writer under a single ten-second budget shared by the whole flush, so memory stays bounded and leaving a server stays bounded, while nothing observed is thrown away.
That budget was later found to be running somewhere it did not belong. The same flush runs at a dimension transition, and it was using the disconnect number — so walking through a nether portal onto a slow disk could hold a player on the thread that draws their frames for up to ten seconds, mid-game. The argument above is exactly why that is wrong: waiting is right at a disconnect because there is no gameplay left to protect, and at a portal there is. A transition now waits one second and counts what will not fit as dropped coverage, which is reported; a ten-second freeze in the middle of a portal is not something a player can be compensated for at all.
After the fix the same test enqueued 158 chunks and dropped none, while the in-play retry path was unchanged.
What those observations cost on disk is worth stating exactly, because a count of observations is not a count of bytes and this said otherwise until it was measured. The 158 observations reference 7,900 components declaring 32,121,143 bytes; content addressing resolves those to 55 objects holding 229,101, which is 0.7% of what was declared. Repeatedly snapshotting an unchanged chunk stores nothing new, and neither does a section identical to one already held.
The raw logs from both runs are kept outside the repository, because they carry the absolute paths of the machine that produced them. What a reader can run instead is the test itself — ./gradlew runClientGametest -Pworldledger.acceptMinecraftEula=true from adapters/fabric, or scripts/run-client-gametest.ps1 where Gradle will not start — and both end by re-checking the capture fingerprint against the committed reference.
The same game test runs in Linux CI on every push, headless under a software renderer, and passes there. Because the test fails when no bundle appears, a passing run is evidence that capture produced and verified bundles on that platform, not merely that the client started.
What capture costs the thread that draws frames. This sat under "not verified" while nobody had measured it. It has been, repeatedly, and the figures below are what a real session produced. The first one was not comfortable:
169 ticks, mean 1080.1 us, max 15216.3 us (30.433% of a 50 ms tick)
The same session enqueued 158 chunks, dropped none, and failed on none.
The mean is unremarkable: a millisecond on the ticks that did work. The maximum is not. A frame at 60 fps has 16.7 ms, and the worst tick spent 15.2 ms copying one chunk's state on the thread that draws. That is a dropped frame, and calling it 30% of a tick understates it, because a tick is not the budget a player perceives.
What it is bounded by is already right: max_snapshots_per_tick is 1, so the worst case is one full-height chunk, which is 24 sections of 4,096 block states.
There turned out to be a third option besides spreading a chunk over several ticks, which a torn copy would break, and capturing less than a chunk, which loses coverage: ask fewer questions for the same answer. Most of a chunk is one state repeated, and Minecraft stores such a section with a single-value palette, which the client can recognise in constant time. Reading 4,096 positions to be told the same thing 4,096 times is work with no result.
Measured outside the game on real PalettedContainers, over a chunk of 24 sections modelled as 20 uniform and 4 mixed:
before after
time 830.1 us 221.8 us 3.7x
allocation 1,185 KB 233 KB 5.1x less
The mix is a model rather than a measurement, so the real gain depends on how much of a real chunk is uniform. The canonical bytes are unchanged by construction, and were checked against the shipped algorithm on uniform sections of five different states, on a section made uniform by writing rather than by its palette, and on states that differ only by a block property.
A game test on the same machine afterwards reported:
177 ticks, mean 195.7 us, max 7570.6 us (15.141% of a 50 ms tick)
The fingerprint from that run is byte-identical to the committed reference, so nothing about what was captured changed.
The mean fell by 5.5 times, more than the 3.7 measured outside the game, because a real chunk is more uniform than the model.
The maximum did not fall with the mean. It halved where the mean fell by more than five, so most of that tick was never the per-block work: less garbage makes a young-generation collection rarer without making one shorter.
Reporting where the worst tick fell settled what it was. A later run:
191 ticks, mean 373.0 us, max 7895.8 us (15.792% of a 50 ms tick); worst was tick 1 of 191, 4 tick(s) over 5 ms
The worst tick is the first one, which is a cost paid once as a session warms up rather than a stutter that recurs, and four of 191 ticks exceeded 5 ms. At 7.9 ms against a 16.7 ms frame none of them is a dropped frame at 60 fps. A maximum alone could not have told these apart from a session stuttering every few seconds, which is why the position and the slow-tick count are reported with it.
The fixture world has since been given the shapes an upgrade breaks — a block entity, waterlogging, property-rich states, mixed sections at both ends of the build range, a second biome — and the cost did not move with it:
186 ticks, mean 310.5 us, max 6955.0 us (13.910% of a 50 ms tick); worst was tick 1 of 186, 3 tick(s) over 5 ms
That run also enqueued 158 chunks with none dropped and no snapshot failures, produced 158 ready bundles that all imported, and its fingerprint was byte-identical to the committed reference.
This is one machine, one scripted world, and a small pinned area. A player exploring loads far more chunks, so this is a floor for how often the cost is paid, though not for how large any single payment is.
The Go core now has benchmarks over the committed fixtures and the committed capture bundle, covering the two halves test-strategy.md asks for. Measured on one Windows machine, so they are indicative rather than guarantees:
decode a block section ~12 us 8-17 allocations
decode a high-palette section ~592 us 12,293 allocations
encode a block section ~354-448 us 4,121 allocations
encode a full-height chunk ~10.6 ms 98,823 allocations
import one bundle, fresh archive ~32-53 ms 1,742 allocations
import one bundle, already held ~15-20 ms 1,700 allocations
The import figures above use the committed capture bundle, which carries four components. A bundle from a real session carries about fifty.
Importing 158 of those measured 2 minutes 25 seconds, or roughly 918 ms each. Almost none of that was the durability it looked like.
Every component's path was resolved from the volume root, opening a handle per element, and every component in a bundle shares nearly all of that walk: fifty components spent 98 ms repeating it against 8 ms of actually opening the files. Resolving each directory once per bundle brought the same 158 bundles to 36 to 45 seconds.
What was left did look like durability, and the object store did fsync once per component. But it fsynced before checking whether the object was already there, so it was making an object durable and then deleting it. That session held 7,900 components and 55 distinct objects: 99% of those fsyncs were for bytes already on disk. Checking first brought the same import to 23 to 25 seconds, and a second import of the same session to the same figure rather than to the 38 seconds it cost before.
The remaining floor is genuine. An object the archive has never seen must be written and forced to disk before the import is acknowledged, and no amount of ordering avoids that. What the archive no longer pays is the same cost for bytes it already has.
Two of these are worth reading carefully. Encoding costs roughly thirty times what decoding does and allocates about once per block state; the reference encoder is used by the fixture tooling rather than on any path a player waits on, so this is a known cost rather than a problem to date. Import spends its time in durability, not computation: seventeen hundred allocations against tens of milliseconds is the signature of the fsync calls that put an observation on disk before the import is acknowledged. Reimporting an observation already held is cheaper but not free, because it still reads and hashes every component rather than trusting the digest the bundle declares.
How a converted world renders. A 1.21.11 server has opened one and answered for its contents, which is recorded above. No 1.21.11 client has opened one, so nothing here covers lighting, or how the seams between converted and generated chunks look to a player.
Conversion of a world that actually loses something. The world that has been through a real older release loses nothing, because it uses none of the thirty blocks 1.21.11 lacks. What each policy does when there is something to lose is covered by tests against the 1.21.11 profile — the block is named in the report under every policy, the default policy leaves the chunk unwritten rather than substituting for it, the report policy refuses outright, and a chunk of blocks the release does have passes through unchanged — but no such world has been opened in Minecraft.
report did not refuse for every kind of loss. Refusal was decided inside the translator, which sees blocks, biomes and the build range; block entities are dropped a layer above it. So a conversion whose only loss was every chest, sign and furnace in the world listed them and then wrote the world anyway — under the one policy whose entire purpose is to write nothing. It refuses now, and names --keep-block-entities as the alternative.
Two renames onto one block are also refused. A rename asserts the same block under a different name, which is why it carries the source's properties and why it is not counted as a loss; two sources arriving at one name breaks both, the palette rebuild collapses them, and the run reported "translated with no loss". That is not an exotic mistake — the shipped rename table has the chain kelp_top → kelp → kelp_plant, and reversing it for a downgrade produces exactly it. A merge is a substitution, which is counted as lossy and does not carry properties unless asked, and the refusal says so.
An import and a purge running at the same time. Objects are stored outside the archive lock, deliberately: an import of two hundred bundles should not hold the archive while it hashes tens of megabytes. The cost is a window between storing a component and committing the record that names it. A redact purge in that window sees no observation referencing the object and removes it, and the commit then writes a record pointing at nothing, which the next fsck reports as a missing object.
It is narrower than it first looks. A purge only removes objects the observations it is deleting referenced, so the import has to be storing a component with the same digest — which is the deduplication case, two contributors who saw the same chunk in the same state, so it is real rather than theoretical.
It is unfixed, and the attempt is worth recording. Requiring every named object to be present at commit time, checked under the lock, is a two-line guard and a correct invariant: it is what fsck already demands. It also failed 22 tests across two packages, because AddObservation is used throughout as "record this observation" with object storage a separate concern, and rewriting that many test setups to satisfy a guard is a change to what those tests exercise rather than a fix. The alternative, holding the lock across an import's stores and its commit, is a design change with real consequences for how long one import blocks everything else. Neither is worth doing badly, and the failure mode is loud and recoverable: fsck names the object, and importing the bundle again stores it.
An unsigned application on a stranger's Windows. SmartScreen will warn about a binary from an unknown publisher, and security software may quarantine it. Nothing here is signed, the README says so, and whether to buy a signing certificate is a decision nobody has made.
The exported world opened in Minecraft. The chunks are in the world and an independent reader agrees about what they contain. Nobody has yet loaded that world and walked around in it, which is what the 26.2 export was verified by once before and this particular world has not been.
The desktop application anywhere but Windows. It compiles for Linux and macOS and the browser path is the whole application there rather than a degraded one, but no window is created on either and neither has been run.
Race detection on Windows. The race gate needs cgo and is run in Linux CI only.
Running the client game test without Gradle. Gradle can fail to start, with Unable to establish loopback connection from every task before any build logic runs. The client game test is the only end-to-end exercise of capture and the only thing that re-checks the capture fingerprint, so losing it to that would mean losing the gate.
scripts/run-client-gametest.ps1 in the repository runs it anyway. Loom writes the whole launch specification to adapters/fabric/.gradle/loom-cache/launch.cfg during a normal build, and it stays valid afterwards, so the script compiles the four source sets with javac into the directories that file names, prepares the run directory exactly as prepareClientGametest does, and launches the same client through dev-launch-injector.
It does not accept Mojang's EULA. build.gradle deliberately refuses to make that decision for an operator, and the script refuses in the same way: the run directory has to carry an eula.txt from a run someone authorised. -BuildOnly compiles and prepares without opening a window.
The Gradle failure and the game test failure have one cause, and it is a directory. The game test starts a Minecraft dedicated server; its networking is Netty; a Netty event loop is a java.nio Selector. On Windows a Selector's wakeup pipe is a pair of AF_UNIX sockets — WEPollSelectorImpl constructs a PipeImpl with preferAfUnix set — and the socket file is created in %TEMP%. Where an AF_UNIX connect() to that directory is refused, the JDK reports Unable to establish loopback connection, naming a mechanism it did not use. That is why TCP loopback working proves nothing, why Pipe.open() still works (it asks for TCP), and why Gradle prints the same words from the same cause: its daemon connector opens a Selector too. Minecraft reaches it several minutes into startup and calls it failed to create a child event loop.
Where this was diagnosed, AF_UNIX connect() failed with WSAEINVAL for every path under %USERPROFILE%\AppData — which is where %TEMP% lives — while C:\Windows\Temp and the checkout itself both worked. jdk.net.unixdomain.tmpdir decides where the socket file goes. Setting it in the JDK's own conf/net.properties fixes every JVM started from that JDK at once, including the Gradle daemon, which nothing in this repository launches. With that set, Gradle runs normally and this script stops being the only way in.
The script no longer stops at the check. Its preflight opens a Selector the way the JDK would, and only if that fails looks for a directory where one can be opened and passes it to the client, so a machine that has not had net.properties set still runs the game test. Everything before the launch — compiling all four source sets, expanding the manifest, preparing the run directory — works regardless, which is what -BuildOnly is for.
This is a fallback, not a replacement. Gradle remains what CI uses and what produces a release JAR.
level.datis never generated. Exports are written into a world the target client created.- Block entity payloads are not migrated across releases, and are dropped by default when converting.
- Release profiles carry no block state property definitions. A client jar describes block states only through its rendering definitions, which omit properties that do not change a model, so property-level validation would reject valid states.
- Structure placement is modelled; biome and terrain generation are not.