Stop the saved world from masking generator changes

chunkAt prefers the store over the generator, and nothing invalidated a stored
chunk when the generator changed. Combined with four region files committed to
the repo, the chunks around spawn were frozen output from an older generator:
worldgen fixes looked like they did nothing in exactly the area you land in when
you join, and a fresh clone inherited that world.

Chunks now carry a RegionIOGeneratorVersion stamp, written on save and checked on
load; a mismatch returns ErrChunkNotFound so the caller regenerates. Chunks
written before the stamp existed have no tag, decode as 0, and are invalidated
the same way. Bump the constant in any commit that changes generator output.

This is deliberately per-chunk and deliberately quiet. The world metadata file
already guards the seed, where a mismatch means two incompatible terrains and
refusing to open is right. A generator change is routine by comparison and should
just regenerate.

world/region/*.mca and chat.md are untracked (left on disk) and /world/ is
gitignored, superseding the narrower /world/regionio-world.json rule, along with
/.refjava/ and root-level session transcripts.

CLAUDE.md covers the parts of working here that README does not: the
no-dependencies rule, the version-stamp rule and why forgetting it looks like a
failed fix, where the vanilla ground truth lives and how to query the jar
directly with unzip and javap, the precedent for dumping runtime constants with a
throwaway Java program, and an honest list of which layers are bit-exact versus
approximated.
This commit is contained in:
Master290 2026-07-27 01:28:05 +03:00
parent 0a2845fa76
commit 90e9380ae7
9 changed files with 224 additions and 606 deletions

View file

@ -23,6 +23,21 @@ import (
// from versions/.../server.jar's version.json "world_version".
const dataVersion26 = 4790
// generatorVersion identifies the output of the current chunk generator. Every
// chunk we save carries it, and loading rejects any chunk stamped differently.
//
// BUMP THIS in any commit that changes what the generator produces.
//
// Without it a world directory silently pins whatever the generator did the
// first time it ran: chunkAt prefers the store over the generator, so the
// already-explored area around spawn keeps its old terrain and every later fix
// looks like it did nothing in exactly the place you are standing.
const generatorVersion = 1
// generatorVersionTag is the NBT key holding generatorVersion. It is namespaced
// because it is ours, not part of the vanilla chunk format.
const generatorVersionTag = "RegionIOGeneratorVersion"
// minYSection is the on-disk "yPos": the section index at MinY (-64 → -4),
// since sections are 16 blocks tall and the overworld is 24 sections from
// section index -4 to 19.
@ -254,6 +269,7 @@ func chunkToNBT(c *Chunk) *nbt.Compound {
return nbt.NewCompound().
Set("DataVersion", nbt.Int(dataVersion26)).
Set(generatorVersionTag, nbt.Int(generatorVersion)).
Set("Level", level)
}
@ -399,6 +415,16 @@ func packIndices(ids []uint16, indexOf map[uint16]int) nbt.LongArray {
// absolute coordinates are derived from the on-disk xPos/zPos (authoritative);
// the region/local coords passed in are used only to validate.
func nbtToChunk(root *nbt.Compound, regionX, regionZ, localX, localZ int) (*Chunk, error) {
// Reject anything the current generator did not produce so the caller
// regenerates instead of serving stale terrain. Chunks written before the
// stamp existed have no tag and decode as 0, so they are invalidated too.
// This is per-chunk on purpose: the world metadata file guards the seed,
// which is a hard mismatch, while a generator change is routine and should
// quietly regenerate rather than refuse to open the world.
if v := nbtAsInt(root, generatorVersionTag); v != generatorVersion {
return nil, ErrChunkNotFound
}
levelTag, ok := root.Get("Level")
if !ok {
return nil, fmt.Errorf("world: chunk NBT missing Level")