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:
parent
0a2845fa76
commit
90e9380ae7
9 changed files with 224 additions and 606 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -495,3 +495,56 @@ func TestConcurrentAutosavePreservesLatestEdit(t *testing.T) {
|
|||
t.Fatalf("persisted final block = %d, want %d", got, StateBedrock)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratorVersionStampRejectsStaleChunks covers the guard that keeps a
|
||||
// saved world from masking generator changes.
|
||||
//
|
||||
// chunkAt prefers the store over the generator, so without this check a chunk
|
||||
// saved by an older build is served forever and every later worldgen fix looks
|
||||
// like it did nothing in the already-explored area around spawn. Chunks written
|
||||
// before the stamp existed carry no tag and must be rejected the same way.
|
||||
func TestGeneratorVersionStampRejectsStaleChunks(t *testing.T) {
|
||||
toRoot := func(t *testing.T, c *nbt.Compound) *nbt.Compound {
|
||||
t.Helper()
|
||||
_, tag, err := nbt.UnmarshalNamed(nbt.MarshalNamed("", c))
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
root, ok := tag.(*nbt.Compound)
|
||||
if !ok {
|
||||
t.Fatal("root is not a compound")
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
t.Run("current version loads", func(t *testing.T) {
|
||||
root := toRoot(t, chunkToNBT(NewChunk(0, 0, BiomePlains)))
|
||||
if _, err := nbtToChunk(root, 0, 0, 0, 0); err != nil {
|
||||
t.Fatalf("nbtToChunk on a freshly written chunk: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("older version is rejected", func(t *testing.T) {
|
||||
c := chunkToNBT(NewChunk(0, 0, BiomePlains))
|
||||
c.Set(generatorVersionTag, nbt.Int(generatorVersion-1))
|
||||
if _, err := nbtToChunk(toRoot(t, c), 0, 0, 0, 0); err != ErrChunkNotFound {
|
||||
t.Errorf("err = %v, want ErrChunkNotFound so the chunk regenerates", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing stamp is rejected", func(t *testing.T) {
|
||||
// What every chunk written before this guard existed looks like.
|
||||
c := chunkToNBT(NewChunk(0, 0, BiomePlains))
|
||||
stripped := nbt.NewCompound()
|
||||
for _, name := range c.Keys() {
|
||||
if name == generatorVersionTag {
|
||||
continue
|
||||
}
|
||||
v, _ := c.Get(name)
|
||||
stripped.Set(name, v)
|
||||
}
|
||||
if _, err := nbtToChunk(toRoot(t, stripped), 0, 0, 0, 0); err != ErrChunkNotFound {
|
||||
t.Errorf("err = %v, want ErrChunkNotFound so the chunk regenerates", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue