Our region files were not Anvil. Three things stood between them and vanilla's
deserializer, and each is enough on its own:
* everything was nested under a "Level" compound. Chunk data lived there until
1.18; SerializableChunkData builds a flat root and never looks for the key.
* a section's Y was an Int. Vanilla writes putByte and reads getByteOr, so
every section of ours decodes as index 0 and overwrites the one before it.
* block palettes were packed at ceil(log2(size)) bits. Strategy's tableswitch
routes bit counts 1 through 4 to the same four-bit configuration, so a
palette of 2..16 states is four bits wide on disk. Ours were one to three,
which makes the long array a quarter of the length vanilla computes, and
SimpleBitStorage rejects the section outright rather than misreading it.
Biome containers were already right: Strategy has no such floor for them, and a
Global configuration above three bits still stores palette indices, just at its
own width. The suspicion that biomes collapsed on save/load was unfounded --
what let it stand is that every round-trip test in this package set blocks and
asserted blocks, so nothing proved biomes survived. They do now, per cell, for
every palette width a section can hold.
Verified against vanilla rather than against our reading of it:
tools/VanillaChunkFormatCheck.java opens a region file we wrote using vanilla's
RegionFile, NbtIo, Strategy and SimpleBitStorage. Sixteen generated chunks pass.
Reverting either the Y type or the palette floor makes it fail with vanilla's
own message -- "Invalid length given for storage, got: 64 but expected: 256" --
so the check can fail, which is the only reason to trust it passing.
Reading a world the official server generated is what the surface-height parity
capture in CLAUDE.md has always needed, and this is half of it.
168 lines
5.8 KiB
Go
168 lines
5.8 KiB
Go
package world
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"regionio/internal/nbt"
|
|
)
|
|
|
|
// TestChunkNBTIsVanillaAnvil pins the three things that made our region files
|
|
// unreadable by the official server, and its files unreadable by us: chunk data
|
|
// nested under a "Level" compound (where it lived until 1.18), a section index
|
|
// written as an Int where vanilla writes and reads a byte, and block palettes
|
|
// packed tighter than vanilla's four-bit floor.
|
|
func TestChunkNBTIsVanillaAnvil(t *testing.T) {
|
|
c := NewChunk(3, -5, BiomePlains)
|
|
for lx := 0; lx < 16; lx++ {
|
|
for lz := 0; lz < 16; lz++ {
|
|
c.SetBlock(lx, 0, lz, StateStone)
|
|
}
|
|
}
|
|
c.SetBlock(0, 0, 0, StateDirt) // a second palette entry
|
|
|
|
root := chunkToNBT(c)
|
|
if _, ok := root.Get("Level"); ok {
|
|
t.Error("chunk NBT still nests under Level; vanilla reads a flat root")
|
|
}
|
|
for _, key := range []string{"xPos", "yPos", "zPos", "Status", "sections", "Heightmaps", "block_entities"} {
|
|
if _, ok := root.Get(key); !ok {
|
|
t.Errorf("chunk NBT root is missing %q", key)
|
|
}
|
|
}
|
|
|
|
secTag, ok := root.Get("sections")
|
|
if !ok {
|
|
t.Fatal("no sections")
|
|
}
|
|
sections := secTag.(nbt.List)
|
|
if len(sections.Elems) != SectionCount {
|
|
t.Fatalf("%d sections, want %d", len(sections.Elems), SectionCount)
|
|
}
|
|
for i, e := range sections.Elems {
|
|
sec := e.(*nbt.Compound)
|
|
y, ok := sec.Get("Y")
|
|
if !ok {
|
|
t.Fatalf("section %d has no Y", i)
|
|
}
|
|
if _, isByte := y.(nbt.Byte); !isByte {
|
|
t.Fatalf("section %d Y is %T, want nbt.Byte", i, y)
|
|
}
|
|
if got, _ := nbtAsSectionY(sec, "Y"); got != i+minYSection {
|
|
t.Fatalf("section %d Y decodes to %d, want %d", i, got, i+minYSection)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPaletteStorageWidths checks the packed long array is the length vanilla
|
|
// computes from the palette size alone. A section packed at the wrong width has
|
|
// the wrong number of longs, and vanilla's SimpleBitStorage rejects it outright
|
|
// rather than reading it crooked.
|
|
func TestPaletteStorageWidths(t *testing.T) {
|
|
blockCases := []struct{ palette, bits int }{
|
|
{1, 0}, {2, 4}, {5, 4}, {16, 4}, {17, 5}, {32, 5}, {33, 6}, {64, 6}, {257, 9},
|
|
}
|
|
for _, c := range blockCases {
|
|
if got := blockStorageBits(c.palette); got != c.bits {
|
|
t.Errorf("blockStorageBits(%d) = %d, want %d", c.palette, got, c.bits)
|
|
}
|
|
}
|
|
biomeCases := []struct{ palette, bits int }{
|
|
{1, 0}, {2, 1}, {3, 2}, {4, 2}, {5, 3}, {8, 3}, {9, 4}, {16, 4}, {33, 6}, {64, 6},
|
|
}
|
|
for _, c := range biomeCases {
|
|
if got := biomeStorageBits(c.palette); got != c.bits {
|
|
t.Errorf("biomeStorageBits(%d) = %d, want %d", c.palette, got, c.bits)
|
|
}
|
|
}
|
|
|
|
// The four-bit floor is the part that used to be wrong: a two-entry block
|
|
// palette must still occupy 4096 entries at 4 bits, which is 256 longs.
|
|
c := NewChunk(0, 0, BiomePlains)
|
|
c.SetBlock(0, 0, 0, StateStone)
|
|
c.SetBlock(1, 0, 0, StateDirt)
|
|
sec := sectionToNBT(c, (0-MinY)>>4)
|
|
bs := mustCompound(t, sec, "block_states")
|
|
data, ok := bs.Get("data")
|
|
if !ok {
|
|
t.Fatal("a two-entry block palette wrote no data array")
|
|
}
|
|
if got, want := len(data.(nbt.LongArray)), sectionVol/(64/4); got != want {
|
|
t.Errorf("two-entry block palette packed into %d longs, want %d (4 bits, 16 per long)", got, want)
|
|
}
|
|
}
|
|
|
|
// TestStoreBiomeRoundTrip is the coverage whose absence let a phantom bug stand:
|
|
// every save/load test in this package set blocks and asserted blocks, so
|
|
// nothing proved the biomes survived. They do — this keeps it that way.
|
|
func TestStoreBiomeRoundTrip(t *testing.T) {
|
|
original := NewChunk(2, -3, BiomePlains)
|
|
// Three distinct biomes inside one section, so the palette needs two bits
|
|
// and the packed array is actually exercised.
|
|
const y = 0
|
|
original.SetBiome(0, y, 0, 1)
|
|
original.SetBiome(4, y, 0, 2)
|
|
original.SetBiome(8, y, 8, 3)
|
|
// A second section with a different spread, and one cell per 4x4x4 cell in
|
|
// a third so the palette is wide.
|
|
for i := 0; i < biomeCellsPerSection; i++ {
|
|
bx, by, bz := i&3, (i>>4)&3, (i>>2)&3
|
|
original.SetBiome(bx*4, 32+by*4, bz*4, uint16(i%17))
|
|
}
|
|
// A section left untouched keeps the chunk-wide fallback rather than an
|
|
// array, which is the single-entry-palette branch on both sides.
|
|
original.biome = 7
|
|
|
|
decoded, err := nbtToChunk(chunkToNBT(original), 0, -1, 2, 29)
|
|
if err != nil {
|
|
t.Fatalf("round trip: %v", err)
|
|
}
|
|
for si := 0; si < SectionCount; si++ {
|
|
for i := 0; i < biomeCellsPerSection; i++ {
|
|
bx, by, bz := i&3, (i>>4)&3, (i>>2)&3
|
|
lx, ly, lz := bx*4, MinY+si*16+by*4, bz*4
|
|
if got, want := decoded.GetBiome(lx, ly, lz), original.GetBiome(lx, ly, lz); got != want {
|
|
t.Fatalf("section %d cell %d at (%d,%d,%d): biome %d, want %d", si, i, lx, ly, lz, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStoreBiomePaletteWidthSweep walks every palette size a section can hold,
|
|
// which is the axis a change to the index packer would break.
|
|
func TestStoreBiomePaletteWidthSweep(t *testing.T) {
|
|
for distinct := 1; distinct <= biomeCellsPerSection; distinct++ {
|
|
t.Run(fmt.Sprintf("palette-%d", distinct), func(t *testing.T) {
|
|
original := NewChunk(0, 0, BiomePlains)
|
|
const si = 8
|
|
for i := 0; i < biomeCellsPerSection; i++ {
|
|
bx, by, bz := i&3, (i>>4)&3, (i>>2)&3
|
|
original.SetBiome(bx*4, MinY+si*16+by*4, bz*4, uint16(i%distinct))
|
|
}
|
|
decoded, err := nbtToChunk(chunkToNBT(original), 0, 0, 0, 0)
|
|
if err != nil {
|
|
t.Fatalf("round trip: %v", err)
|
|
}
|
|
for i := 0; i < biomeCellsPerSection; i++ {
|
|
bx, by, bz := i&3, (i>>4)&3, (i>>2)&3
|
|
lx, ly, lz := bx*4, MinY+si*16+by*4, bz*4
|
|
if got, want := decoded.GetBiome(lx, ly, lz), original.GetBiome(lx, ly, lz); got != want {
|
|
t.Fatalf("cell %d: biome %d, want %d", i, got, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func mustCompound(t *testing.T, c *nbt.Compound, key string) *nbt.Compound {
|
|
t.Helper()
|
|
tag, ok := c.Get(key)
|
|
if !ok {
|
|
t.Fatalf("missing %q", key)
|
|
}
|
|
inner, ok := tag.(*nbt.Compound)
|
|
if !ok {
|
|
t.Fatalf("%q is %T, want a compound", key, tag)
|
|
}
|
|
return inner
|
|
}
|