Skip to the page
calcite

Running a server

Worlds and vanilla

How Calcite matches vanilla, how that's checked, where it deliberately differs, and moving worlds between them.

Calcite means to do what vanilla 26.3 does, to the bit, wherever a player, a farm or a data pack could tell. The exceptions are vanilla's bugs and exploits, which it fixes and lists.

How it's checked#

An oracle rig runs the same inputs through vanilla's server code and through Calcite's, and compares the results bit for bit: floats as raw bits, so a near miss is a failure. Vanilla's results are recorded once and their hashes committed, and Calcite is scored against them on every change. The rig covers, among the rest:

  • Worldgen. The Overworld, the Nether and the End pass the worldgen suites on the rig's grid of seeds, block for block. With Terralith and Terratonic, every one of 264 sampled chunks has what must be vanilla's; the blocks that differ are those the order chunks are decorated in decides, which vanilla doesn't fix either.

  • Light. Every block gets the same light level, from 0 to 15, as in vanilla, in every workload.

  • The protocol. The login and configuration suites match vanilla packet for packet, and Mojang's own 26.3 client joins, walks and chats.

  • Saves. A world vanilla made, loaded and saved by Calcite, reads back in vanilla.

  • Lockstep. A session recorded on many threads, replayed on one, gives the same packets and lines.

How the rig works, and how to run it, is in its README.

Moving a world#

Worlds round-trip: Calcite reads and writes vanilla 26.3's format, its region files, level.dat, player data and the rest, so a world can move from vanilla to Calcite and back. Keep a backup anyway: Calcite is pre-release.

A world from an older version goes through vanilla 26.3 first, which upgrades it. Calcite reads only what 26.3 writes.

Data packs#

A world's data packs, in its datapacks folder, load as vanilla loads them, with their overlays and filters, and worldgen packs such as Terralith and Terratonic generate as they do in vanilla. Vanilla's own data comes from Mojang's client jar, read from an installed game or downloaded the first time; Calcite contains no Mojang code or assets.

Deliberate differences#

Each place Calcite means to differ from vanilla is listed, with the rig cases it covers, so a fix never hides a failure and a failure never passes as a fix. Each is a bug vanilla has that Calcite fixes, an exploit it closes, Calcite saying what it is (identity), or vanilla depending on its JVM or system (platform).

A ban that has ended is no bana vanilla bug, fixed

Vanilla: PlayerList.canPlayerLogin asks UserBanList.isBanned, which only looks the player up, so a ban whose expires has passed still answers yes. It then calls get, which first drops every ban that has ended and so returns null, and reads the ban's reason from that. The login fails with a NullPointerException, and the next one finds no ban. An IP ban that has ended does the same.

Calcite: a ban that ended before the login is no ban, so the player gets in on the first try. A ban ends after its expires, not at it, as vanilla's hasExpired has it (D161). src/test/access.calx checks a ban a millisecond either side of its end.

Why: the exception is an oversight between two methods, not a choice. The player it hits is one vanilla meant to let in.

No rig probe or session scenario bans a player yet, so login:ban-ended-* names the cases one would have.

The server's brand is calciteCalcite says what it is

Vanilla: the first packet of configuration is minecraft:brand, from MinecraftServer.getServerModName(), which is vanilla.

Calcite: the brand is calcite (D132).

Why: a client shows the brand in its debug screen, and the server isn't vanilla. Plugins, bug reports and server lists can then tell which server they're talking to, as they can with every other server that isn't vanilla. Nothing in the client acts on the brand.

The protocol suites see it in every session that gets to configuration.

A player list that can't be read isn't written overa vanilla bug, fixed

Vanilla: as it starts, DedicatedPlayerList loads banned-players.json, banned-ips.json, ops.json and whitelist.json, then saves the first three whatever happened. A list that can't be read is logged, as Failed to load operators list: ..., and keeps what it read before it broke: nothing if the file isn't JSON, or the entries before the first that isn't an object. Saving it then writes those over the file, so a typo in ops.json leaves [] and every op gone. The whitelist is only written when it's missing.

Calcite: a list that can't be read is logged the same way and keeps the same entries, but the file is left as it is, so it can be fixed and the server started again. A list that's missing is written as [], as vanilla writes it (D161).

Why: losing a server's ops and bans to one wrong character is a side effect of saving every list as it starts, not a choice, and no one is let in or kept out differently while the server runs.

No rig probe compares the server's folder yet, so login:list-unreadable-* names the cases one would have.

logs/latest.log ends its lines with \n on every systemwhere vanilla depends on its system

Vanilla: Mojang's pattern ends each line with log4j's %n, which is Java's System.lineSeparator(). That's \r\n on Windows and \n elsewhere, in latest.log and on the console alike.

Calcite: each line ends with \n, in latest.log and on the console, on Windows too, as Calx writes the same bytes on every system (std.io).

Why: a log's lines then read the same wherever they were written, and every tool that reads them, Notepad included, takes \n. Hosting panels such as Pterodactyl run on Linux, where vanilla writes \n anyway.

No rig probe compares log files yet, so log:line-endings names the case one would have.

logs/latest.log rolls once as the server startsa vanilla bug, fixed

Vanilla: Mojang's log4j2.xml gives logs/latest.log two triggering policies. TimeBasedTriggeringPolicy comes first, and sets its next roll to the midnight after the file's own time, the day it was last written. Then OnStartupTriggeringPolicy archives the file left from before. That leaves the next roll at a midnight that's already past. So the first line the server logs rolls the file again. The new latest.log is still empty, so there's nothing to archive, and log4j deletes the empty file. DefaultRolloverStrategy still purges the old day's archives first, though:

  • With 7 that day, it removes the lowest and moves the rest down again. The day ends with 6, and one archive more is lost. With 2026-10-28-1 to -7 holding f1 to f7, and yesterday's latest.log, vanilla leaves f3 to f7 and that file as 1 to 6.

  • With a number of 7 or more, it moves every archive down once more. {3, 7} becomes {1, 5, 6}, not {2, 6, 7}.

This was measured by running log4j 2.26.0 with Mojang's configuration in set-up folders (CAL-46).

Calcite: a roll with nothing to archive does nothing. So each start archives the old file once, with the same name and number as vanilla's first roll, and the day's archives are moved once. Everything else is as vanilla does it: the names, the numbers, the gaps, the 7 a day, and when the file rolls. src/test/log_file.calx checks each case, and says what vanilla left where this changes it.

Why: the second roll is a side effect of the policies' order, not a choice. It deletes a log for no reason. Nothing reads the logs folder by number, and no player can see it.

No rig probe compares the logs folder yet, so log:rollover-* names the cases one would have.

server.properties ends its lines with \n on Windows, and names the zone by its offsetwhere vanilla depends on its system

Vanilla: Properties.store ends each line with Java's System.lineSeparator(), \r\n on Windows. Its date line is Date.toString(), which names the zone by its abbreviation, such as CET, from the JDK's own copy of tzdata, wherever it runs.

Calcite: each line ends with \n, on Windows too, as in logs/latest.log (log-line-endings). The zone's name is the system's tzdata abbreviation, which is Java's in each of the 37 zones measured with JDK 25, and where it's only a number, such as Istanbul's +03, the offset as Java writes it then, GMT+03:00. Windows names zones only in full, such as W. Europe Standard Time, so there the name is always the offset: #Thu Oct 29 10:48:03 GMT+01:00 2026 for vanilla's #Thu Oct 29 10:48:03 CET 2026.

Why: the file then reads the same wherever it was written, and every editor takes \n. The date is a comment that nothing reads. Naming the zone on Windows as Java does would mean carrying a table from Windows' zones to tzdata's and each one's abbreviations, for that comment alone. Elsewhere, where servers are usually hosted, the file is vanilla's byte for byte.

No rig probe compares server.properties yet, so the cases name the ones it would have.

An entity-broadcast-range-percentage that isn't a number is the defaulta vanilla bug, fixed

Vanilla: DedicatedServerProperties reads entity-broadcast-range-percentage with Integer.parseInt and no default for a value that doesn't parse, as every other number it reads has. So entity-broadcast-range-percentage=abc throws a NumberFormatException, and the server logs Failed to start the minecraft server and stops, measured with the 26.3 jar.

Calcite: it's the default, 100, as max-players=abc is 20, and that's what it writes back to server.properties. A number is kept between 10 and 1000, as vanilla keeps it.

Why: the one setting that stops the server over a typo is an oversight, not a choice: every other number vanilla reads, max-world-size and its neighbors included, falls back to its default.

No rig probe compares server.properties yet, so the case names the one it would have.

SNBT nesting has a fixed depth limita vanilla bug, fixed

Vanilla: the SNBT parser (TagParser, over SnbtGrammar) recurses once per nesting level and has no depth limit of its own. Nest compounds or lists deeply enough and it throws StackOverflowError. How deep is enough depends on the thread's stack size, the JIT tier each parser method has reached, and what ran before. In the self-check, compounds overflowed somewhere between depth 511 and 514 depending on case order and compiler, and lists did only under the interpreter. Depth 256 never overflowed.

Binary NBT doesn't have this problem: NbtAccounter stops reading at depth 512 with an NbtAccounterException.

Calcite: SNBT gets the same limit as binary NBT. Nesting past it is rejected with a normal parse error, the same on every machine. Shallower input, such as the depth 128 and 256 cases, must still match vanilla exactly.

The levels are compounds, lists and operations such as bool(...) together, since each is a rule that recurses, and vanilla overflows on any mix of them. The 513th level fails the parse at once, whatever else could have read it: the error is Tried to read NBT tag with too high complexity, depth > 512, the message binary NBT has, at the position of that level's {, [ or (, so [ 513 times fails at position 512. src/test/snbt.calx checks both kinds.

Why: a StackOverflowError is an Error, not an Exception, so it gets past the catch (Exception e) in both Commands.performCommand and PacketProcessor instead of being handled as a failed parse. Calcite has no JVM stack to overflow, so copying the exact depth isn't even possible. A fixed limit is deterministic, matches the binary one, and is far deeper than real data needs. The depth 511 to 514 cases are also quarantined (expected/26.3/quarantine.json), since vanilla's own result isn't repeatable.

The list, with each difference's cases, is in rig/differences/.

This page is docs/guide/vanilla.md in the repo. Change it there, and the site follows.