RegionIO/internal/world/store_test.go
Master290 113a59e365 Resolve block names to the default state, not the first one
blocks.json lists a block's states in getPossibleStates() order -- the property
cartesian product -- and separately marks which one is the default. The parser
declared only id and properties, so the default flag was dropped on the floor
and nameToStateID returned states[0]. Those differ for 642 of the 1168 blocks.

What that produced, measured rather than guessed: every redstone vein in the
world was lit=true and glowing, every sunflower was placed as its own top half
with nothing under it, and oak stairs came out upside down and waterlogged. It
also quietly disagreed with the StateGrass, StateOakLog and StateOakLeaf
constants next door in encode.go, which are the real defaults.

Now it starts from the default state and applies the properties it recognises,
keeping the default's value for an unknown key or an illegal value -- which is
what vanilla does when it reads a palette entry. That matters on the disk path:
a chunk written with a property we no longer know used to decode to a random
corner state instead of something sane.

The signature grows an ok result, because air was doing double duty as both a
real block and "no such name".

Separately, blockPaletteEntry filled the Properties compound by ranging a Go
map. nbt.Compound preserves insertion order precisely so encoding is
deterministic, so saving one chunk twice produced different region-file bytes
for every multi-property block. Keys are sorted now.
2026-07-27 03:35:39 +03:00

550 lines
16 KiB
Go

package world
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"regionio/internal/nbt"
)
// TestRegionFileRoundTrip writes arbitrary NBT bytes into a region file and
// reads them back, confirming the Anvil container (header + sectors + zlib)
// preserves the payload exactly.
func TestRegionFileRoundTrip(t *testing.T) {
dir := t.TempDir()
rf, err := OpenRegion(dir, 0, 0)
if err != nil {
t.Fatalf("OpenRegion: %v", err)
}
defer rf.Close()
payload := []byte("hello-regionio-chunk-nbt-payload-0123456789")
if err := rf.WriteChunk(3, 7, payload); err != nil {
t.Fatalf("WriteChunk: %v", err)
}
got, err := rf.ReadChunk(3, 7)
if err != nil {
t.Fatalf("ReadChunk: %v", err)
}
if string(got) != string(payload) {
t.Errorf("payload mismatch: got %q want %q", got, payload)
}
}
// TestRegionFileAbsentChunk confirms ReadChunk returns ErrChunkNotFound for a
// chunk that was never written (offset table entry is 0).
func TestRegionFileAbsentChunk(t *testing.T) {
rf, err := OpenRegion(t.TempDir(), 0, 0)
if err != nil {
t.Fatal(err)
}
defer rf.Close()
if _, err := rf.ReadChunk(5, 5); err != ErrChunkNotFound {
t.Errorf("absent chunk err = %v, want ErrChunkNotFound", err)
}
}
// TestRegionFileOverwrite confirms writing the same chunk twice keeps the
// latest data (the second write replaces the first).
func TestRegionFileOverwrite(t *testing.T) {
rf, err := OpenRegion(t.TempDir(), 0, 0)
if err != nil {
t.Fatal(err)
}
defer rf.Close()
if err := rf.WriteChunk(1, 1, []byte("first")); err != nil {
t.Fatal(err)
}
if err := rf.WriteChunk(1, 1, []byte("second")); err != nil {
t.Fatal(err)
}
got, err := rf.ReadChunk(1, 1)
if err != nil {
t.Fatal(err)
}
if string(got) != "second" {
t.Errorf("after overwrite got %q, want %q", got, "second")
}
}
// TestStoreChunkRoundTrip encodes a chunk to NBT, decodes it back, and confirms
// the blocks/biomes match. This validates the chunkToNBT/nbtToChunk bridge.
func TestStoreChunkRoundTrip(t *testing.T) {
original := NewChunk(10, -5, BiomePlains)
// Build a recognizable section: a grass surface over stone, with one water
// block, so the palette has >1 entry and exercises the packed long array.
si := (SeaLevel - MinY) >> 4
original.section(si)
s := original.sections[si]
for x := 0; x < 16; x++ {
for z := 0; z < 16; z++ {
s[blockIndex(x, SeaLevel, z)] = StateGrass
s[blockIndex(x, SeaLevel-1, z)] = StateDirt
}
}
s[blockIndex(0, SeaLevel, 0)] = StateWater
nbtBytes := nbt.MarshalNamed("", chunkToNBT(original))
if len(nbtBytes) == 0 {
t.Fatal("empty NBT")
}
_, tag, err := nbt.UnmarshalNamed(nbtBytes)
if err != nil {
t.Fatalf("decode: %v", err)
}
root, ok := tag.(*nbt.Compound)
if !ok {
t.Fatal("root not compound")
}
rx, rz, lx, lz := regionIndex(original.X, original.Z)
decoded, err := nbtToChunk(root, rx, rz, lx, lz)
if err != nil {
t.Fatalf("nbtToChunk: %v", err)
}
// Spot-check the recognizable blocks.
if got := decoded.GetBlock(0, SeaLevel, 0); got != StateWater {
t.Errorf("block (0, %d, 0) = %d, want water %d", SeaLevel, got, StateWater)
}
if got := decoded.GetBlock(5, SeaLevel, 5); got != StateGrass {
t.Errorf("block (5, %d, 5) = %d, want grass %d", SeaLevel, got, StateGrass)
}
if got := decoded.GetBlock(5, SeaLevel-1, 5); got != StateDirt {
t.Errorf("block (5, %d, 5) = %d, want dirt %d", SeaLevel-1, got, StateDirt)
}
if decoded.X != 10 || decoded.Z != -5 {
t.Errorf("coords = (%d,%d), want (10,-5)", decoded.X, decoded.Z)
}
if decoded.lightReady {
t.Error("legacy chunk without isLightOn loaded as light-ready")
}
}
func TestStoreLightRoundTrip(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
cache := NewCacheWithStore(-1, func(cx, cz int32) *Chunk {
return NewChunk(cx, cz, BiomePlains)
}, store)
left := cache.chunkAt(0, 0)
right := cache.chunkAt(1, 0)
if _, err := cache.FrameErr(0, 0); err != nil {
t.Fatal(err)
}
if _, err := cache.FrameErr(1, 0); err != nil {
t.Fatal(err)
}
glowstone, _ := nameToStateID("minecraft:glowstone", nil)
if valid, _ := cache.SetBlockWithLight(15, 0, 8, glowstone); !valid {
t.Fatal("glowstone edit rejected")
}
wantLeftSky, wantLeftBlock, ready := left.LightAt(15, 0, 8)
if !ready || wantLeftBlock != 15 {
t.Fatalf("pre-save source light = sky %d block %d ready %v", wantLeftSky, wantLeftBlock, ready)
}
wantRightSky, wantRightBlock, ready := right.LightAt(0, 0, 8)
if !ready || wantRightBlock != 14 {
t.Fatalf("pre-save neighbor light = sky %d block %d ready %v", wantRightSky, wantRightBlock, ready)
}
if err := cache.SaveAll(); err != nil {
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
store, err = NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store.Close()
loadedLeft, err := store.LoadChunk(0, 0)
if err != nil {
t.Fatal(err)
}
gotSky, gotBlock, gotReady := loadedLeft.LightAt(15, 0, 8)
if !gotReady || gotSky != wantLeftSky || gotBlock != wantLeftBlock {
t.Fatalf("loaded source light = sky %d block %d ready %v; want sky %d block %d ready", gotSky, gotBlock, gotReady, wantLeftSky, wantLeftBlock)
}
loadedRight, err := store.LoadChunk(1, 0)
if err != nil {
t.Fatal(err)
}
gotSky, gotBlock, gotReady = loadedRight.LightAt(0, 0, 8)
if !gotReady || gotSky != wantRightSky || gotBlock != wantRightBlock {
t.Fatalf("loaded neighbor light = sky %d block %d ready %v; want sky %d block %d ready", gotSky, gotBlock, gotReady, wantRightSky, wantRightBlock)
}
}
func TestCacheReconcilesPersistedLightWithLoadedNeighbor(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
glowstone, _ := nameToStateID("minecraft:glowstone", nil)
left := NewChunk(0, 0, BiomePlains)
left.SetBlock(15, 100, 8, glowstone)
if err := store.SaveChunk(left); err != nil {
t.Fatal(err)
}
// Simulate a chunk saved before its unloaded neighbor gained a light source.
right := NewChunk(1, 0, BiomePlains)
right.lightReady = true
if err := store.SaveChunk(right); err != nil {
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
store, err = NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store.Close()
cache := NewCacheWithLimit(-1, func(cx, cz int32) *Chunk {
return NewChunk(cx, cz, BiomePlains)
}, store, 1)
tickets := cache.NewTicketSet()
tickets.Replace([]ChunkPos{{X: 1, Z: 0}}, nil)
loaded, err := cache.chunkAtErr(1, 0)
if err != nil {
t.Fatal(err)
}
if _, block, ready := loaded.LightAt(0, 100, 8); !ready || block != 0 {
t.Fatalf("persisted pre-reconcile light = %d ready=%v, want stale zero", block, ready)
}
if _, err := cache.FrameErr(1, 0); err != nil {
t.Fatal(err)
}
if _, block, ready := loaded.LightAt(0, 100, 8); !ready || block != 14 {
t.Fatalf("reconciled border light = %d ready=%v, want 14", block, ready)
}
if err := cache.SaveAll(); err != nil {
t.Fatal(err)
}
tickets.Close()
if _, err := cache.FrameErr(10, 0); err != nil {
t.Fatal(err)
}
if hasChunk(cache, 1, 0) {
t.Fatal("released light chunk remained resident after LRU replacement")
}
reloadedAfterEviction, err := cache.chunkAtErr(1, 0)
if err != nil {
t.Fatal(err)
}
if _, block, ready := reloadedAfterEviction.LightAt(0, 100, 8); !ready || block != 14 {
t.Fatalf("light after ticket unload/reload = %d ready=%v, want 14", block, ready)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
store, err = NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store.Close()
reloaded, err := store.LoadChunk(1, 0)
if err != nil {
t.Fatal(err)
}
if _, block, ready := reloaded.LightAt(0, 100, 8); !ready || block != 14 {
t.Fatalf("persisted reconciled light = %d ready=%v, want 14", block, ready)
}
}
// TestStoreSaveLoadIntegration is the end-to-end "world survives restart" test:
// generate a chunk via a store-backed cache, edit a block, SaveAll, then open a
// fresh cache over the same store and confirm the edit is present.
func TestStoreSaveLoadIntegration(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(dir)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
gen := NewVanillaGenerator(12345)
cache := NewCacheWithStore(int32(256), gen, store)
// Force chunk (1,2) into the cache, then edit a block near the surface.
ch := cache.chunkAt(1, 2)
targetY := SeaLevel
// Find the top solid block in column (0,0) to place our marker above it.
markerY := targetY + 1
if !cache.SetBlock(1*16, markerY, 2*16, StateBedrock) {
t.Fatalf("SetBlock out of range y=%d", markerY)
}
_ = ch
if err := cache.SaveAll(); err != nil {
t.Fatalf("SaveAll: %v", err)
}
store.Close()
// New cache, same store — simulates a restart.
store2, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store2.Close()
cache2 := NewCacheWithStore(int32(256), NewVanillaGenerator(12345), store2)
loaded := cache2.chunkAt(1, 2)
if loaded == nil {
t.Fatal("nil loaded chunk")
}
if got := loaded.GetBlock(1*16, markerY, 2*16); got != StateBedrock {
t.Errorf("after reload marker block = %d, want bedrock %d", got, StateBedrock)
}
}
// TestStoreNegativeCoords exercises the floor-division region math for negative
// chunk coordinates (e.g. chunk -1 belongs to region -1, local 31).
func TestStoreNegativeCoords(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer store.Close()
c := NewChunk(-1, -1, BiomePlains)
c.section((SeaLevel - MinY) >> 4)
c.SetBlock(0, SeaLevel, 0, StateGrass)
if err := store.SaveChunk(c); err != nil {
t.Fatalf("SaveChunk(-1,-1): %v", err)
}
loaded, err := store.LoadChunk(-1, -1)
if err != nil {
t.Fatalf("LoadChunk(-1,-1): %v", err)
}
if loaded.X != -1 || loaded.Z != -1 {
t.Errorf("coords = (%d,%d), want (-1,-1)", loaded.X, loaded.Z)
}
// Confirm the file landed in region r.-1.-1.mca.
if _, err := os.Stat(filepath.Join(store.dir, "region", "r.-1.-1.mca")); err != nil {
t.Errorf("expected r.-1.-1.mca: %v", err)
}
}
// TestCacheAutosavePersistsEdits starts an autosave loop with a short interval,
// edits a block, and confirms a second cache sees the edit after the interval.
func TestCacheAutosavePersistsEdits(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
gen := NewVanillaGenerator(12345)
cache := NewCacheWithStore(int32(256), gen, store)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
autosaveDone := cache.StartAutosave(ctx, slog.Default(), 50*time.Millisecond)
cache.chunkAt(0, 0)
cache.SetBlock(8, SeaLevel, 8, StateBedrock)
// Wait for at least one autosave cycle.
time.Sleep(250 * time.Millisecond)
cancel() // stop the autosave loop so it releases the store
<-autosaveDone // wait for the goroutine to fully exit
store.Close()
// Fresh cache over the same store should see the edit without SaveAll.
store2, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store2.Close()
cache2 := NewCacheWithStore(int32(256), gen, store2)
loaded := cache2.chunkAt(0, 0)
if got := loaded.GetBlock(8, SeaLevel, 8); got != StateBedrock {
t.Errorf("autosaved block = %d, want bedrock %d", got, StateBedrock)
}
}
func TestStoreRejectsSeedMismatch(t *testing.T) {
dir := t.TempDir()
store, err := NewStoreForSeed(dir, 12345)
if err != nil {
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
store, err = NewStoreForSeed(dir, 12345)
if err != nil {
t.Fatalf("reopen with matching seed: %v", err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
if _, err := NewStoreForSeed(dir, 54321); err == nil {
t.Fatal("opening a world with a different seed succeeded")
}
}
func TestCacheDoesNotRegenerateCorruptStoredChunk(t *testing.T) {
dir := t.TempDir()
regionDir := filepath.Join(dir, "region")
rf, err := OpenRegion(regionDir, 0, 0)
if err != nil {
t.Fatal(err)
}
corrupt := []byte("not an nbt document")
if err := rf.WriteChunk(0, 0, corrupt); err != nil {
t.Fatal(err)
}
if err := rf.Close(); err != nil {
t.Fatal(err)
}
store, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store.Close()
var generated atomic.Bool
cache := NewCacheWithStore(256, func(cx, cz int32) *Chunk {
generated.Store(true)
return NewChunk(cx, cz, BiomePlains)
}, store)
if _, err := cache.FrameErr(0, 0); err == nil {
t.Fatal("FrameErr succeeded for corrupt stored chunk")
}
if generated.Load() {
t.Fatal("generator ran after a stored chunk read error")
}
if cache.SetBlock(0, SeaLevel, 0, StateBedrock) {
t.Fatal("SetBlock accepted an edit over a corrupt stored chunk")
}
if err := cache.SaveAll(); err != nil {
t.Fatal(err)
}
_, _, lx, lz := regionIndex(0, 0)
rf = store.regions[[2]int{0, 0}]
raw, err := rf.ReadChunk(lx, lz)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(raw, corrupt) {
t.Fatalf("stored corrupt payload was overwritten: %q", raw)
}
}
func TestConcurrentAutosavePreservesLatestEdit(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
cache := NewCacheWithStore(256, flatGen(), store)
cache.chunkAt(0, 0)
done := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
state := StateStone
if i%2 == 0 {
state = StateDirt
}
cache.SetBlock(5, SeaLevel, 5, state)
}
close(done)
}()
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
_ = cache.flushDirty()
}
}
}()
wg.Wait()
cache.SetBlock(5, SeaLevel, 5, StateBedrock)
if err := cache.SaveAll(); err != nil {
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
store, err = NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store.Close()
loaded, err := store.LoadChunk(0, 0)
if err != nil {
t.Fatal(err)
}
if got := loaded.GetBlock(5, SeaLevel, 5); got != StateBedrock {
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)
}
})
}