Write chunk NBT vanilla reads: flat root, byte section Y, four-bit block palettes
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.
This commit is contained in:
parent
113a59e365
commit
e0fdddd887
4 changed files with 437 additions and 54 deletions
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -73,9 +73,16 @@ java -cp "<out>;$CP" VanillaBlockStateDump > internal/world/block_properties.bin
|
|||
|
||||
The 39 jars under `libraries/` are required; the server jar alone will not boot the registry. Bump
|
||||
the format version in both the Java and `internal/world/block_properties.go` whenever the layout or
|
||||
a flag's meaning changes. Substring-matching block names is how the light table was wrong before
|
||||
(`grass_block` matched "grass", `bedrock` matched "bed"); don't reintroduce that shape of guess
|
||||
anywhere.
|
||||
a flag's meaning changes.
|
||||
|
||||
The same trick verifies output, not just constants. `tools/VanillaChunkFormatCheck.java` opens a
|
||||
region file we wrote with vanilla's own `RegionFile`, `NbtIo`, `Strategy` and `SimpleBitStorage` and
|
||||
fails if the root is not flat, a section `Y` is not a byte, or a palette array is not the width
|
||||
vanilla derives from its palette size. Note the server jar is *signed*, so a helper cannot be
|
||||
declared inside a `net.minecraft.*` package — reach protected members by reflection instead.
|
||||
|
||||
Substring-matching block names is how the light table was wrong before (`grass_block` matched
|
||||
"grass", `bedrock` matched "bed"); don't reintroduce that shape of guess anywhere.
|
||||
|
||||
## Layout
|
||||
|
||||
|
|
|
|||
168
internal/world/anvil_format_test.go
Normal file
168
internal/world/anvil_format_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -12,8 +12,8 @@ import (
|
|||
)
|
||||
|
||||
// store.go is the persistence layer between the in-memory Chunk model and the
|
||||
// on-disk Anvil region files. It converts a Chunk to/from the "Level"-nested
|
||||
// chunk NBT (26.1.2: per-section block_states/biomes, heightmaps, yPos) and
|
||||
// on-disk Anvil region files. It converts a Chunk to/from vanilla's chunk NBT
|
||||
// (26.1.2: flat root, per-section block_states/biomes, heightmaps, yPos) and
|
||||
// routes the compressed NBT through RegionFile.
|
||||
//
|
||||
// The store keeps one RegionFile per region (32×32 chunks), opened lazily and
|
||||
|
|
@ -32,7 +32,7 @@ const dataVersion26 = 4790
|
|||
// 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 = 8
|
||||
const generatorVersion = 9
|
||||
|
||||
// generatorVersionTag is the NBT key holding generatorVersion. It is namespaced
|
||||
// because it is ours, not part of the vanilla chunk format.
|
||||
|
|
@ -284,11 +284,19 @@ func (s *Store) Close() error {
|
|||
return firstErr
|
||||
}
|
||||
|
||||
// chunkToNBT builds the Level-nested on-disk NBT for a chunk. The wire Encode()
|
||||
// format is not reusable here: disk uses named palettes and the 26.1.2 Level
|
||||
// layout with per-section biomes.
|
||||
// chunkToNBT builds the on-disk NBT for a chunk. The wire Encode() format is
|
||||
// not reusable here: disk uses named palettes and per-section biomes.
|
||||
//
|
||||
// The layout is vanilla Anvil, flat at the root. It used to nest everything
|
||||
// under a "Level" compound, which is where chunk data lived until 1.18 and
|
||||
// where SerializableChunkData has not looked since — so nothing outside this
|
||||
// package could read our region files, and we could not read a world the
|
||||
// official server generated. That last part is what the surface-height parity
|
||||
// capture needs.
|
||||
func chunkToNBT(c *Chunk) *nbt.Compound {
|
||||
level := nbt.NewCompound().
|
||||
root := nbt.NewCompound().
|
||||
Set("DataVersion", nbt.Int(dataVersion26)).
|
||||
Set(generatorVersionTag, nbt.Int(generatorVersion)).
|
||||
Set("xPos", nbt.Int(c.X)).
|
||||
Set("zPos", nbt.Int(c.Z)).
|
||||
Set("yPos", nbt.Int(int32(minYSection))).
|
||||
|
|
@ -296,7 +304,7 @@ func chunkToNBT(c *Chunk) *nbt.Compound {
|
|||
Set("LastUpdate", nbt.Long(0)).
|
||||
Set("InhabitedTime", nbt.Long(0))
|
||||
if c.lightReady {
|
||||
level.Set("isLightOn", nbt.Byte(1))
|
||||
root.Set("isLightOn", nbt.Byte(1))
|
||||
}
|
||||
|
||||
// Sections: one compound per vertical section, including empty ones so the
|
||||
|
|
@ -306,17 +314,14 @@ func chunkToNBT(c *Chunk) *nbt.Compound {
|
|||
for si := 0; si < SectionCount; si++ {
|
||||
sections.Elems = append(sections.Elems, sectionToNBT(c, si))
|
||||
}
|
||||
level.Set("sections", sections)
|
||||
root.Set("sections", sections)
|
||||
|
||||
level.Set("Heightmaps", buildHeightmaps(c))
|
||||
root.Set("Heightmaps", buildHeightmaps(c))
|
||||
// Required-but-empty fields so vanilla loads the chunk without complaints.
|
||||
level.Set("block_entities", nbt.List{ElemID: nbt.TagCompound})
|
||||
level.Set("structures", nbt.NewCompound())
|
||||
root.Set("block_entities", nbt.List{ElemID: nbt.TagCompound})
|
||||
root.Set("structures", nbt.NewCompound())
|
||||
|
||||
return nbt.NewCompound().
|
||||
Set("DataVersion", nbt.Int(dataVersion26)).
|
||||
Set(generatorVersionTag, nbt.Int(generatorVersion)).
|
||||
Set("Level", level)
|
||||
return root
|
||||
}
|
||||
|
||||
// sectionToNBT builds one section compound: Y + block_states + biomes. Palettes
|
||||
|
|
@ -324,7 +329,9 @@ func chunkToNBT(c *Chunk) *nbt.Compound {
|
|||
// reads as "the whole section is this one entry".
|
||||
func sectionToNBT(c *Chunk, si int) *nbt.Compound {
|
||||
yIdx := int32(si + minYSection)
|
||||
sec := nbt.NewCompound().Set("Y", nbt.Int(yIdx))
|
||||
// Vanilla writes Y as a byte and reads it with getByteOr; an Int here makes
|
||||
// every section decode as index 0 on the other side.
|
||||
sec := nbt.NewCompound().Set("Y", nbt.Byte(int8(yIdx)))
|
||||
|
||||
// Block states: build a palette of distinct IDs in the section, then a packed
|
||||
// long array of indices (only when more than one distinct value).
|
||||
|
|
@ -346,8 +353,8 @@ func sectionToNBT(c *Chunk, si int) *nbt.Compound {
|
|||
palList.Elems = append(palList.Elems, blockPaletteEntry(id))
|
||||
}
|
||||
blockStates.Set("palette", palList)
|
||||
if len(palette) > 1 {
|
||||
blockStates.Set("data", packIndices(s[:], indexOf))
|
||||
if bits := blockStorageBits(len(palette)); bits > 0 {
|
||||
blockStates.Set("data", packIndices(s[:], indexOf, bits))
|
||||
}
|
||||
} else {
|
||||
// Empty section → air palette, no data.
|
||||
|
|
@ -377,8 +384,8 @@ func sectionToNBT(c *Chunk, si int) *nbt.Compound {
|
|||
biomePalList.Elems = append(biomePalList.Elems, nbt.String(biomeNameByID(id)))
|
||||
}
|
||||
biomes.Set("palette", biomePalList)
|
||||
if c.biomes[si] != nil && len(biomePalette) > 1 {
|
||||
biomes.Set("data", packIndices(c.biomes[si][:], biomeIndexOf))
|
||||
if bits := biomeStorageBits(len(biomePalette)); c.biomes[si] != nil && bits > 0 {
|
||||
biomes.Set("data", packIndices(c.biomes[si][:], biomeIndexOf, bits))
|
||||
}
|
||||
sec.Set("biomes", biomes)
|
||||
if c.lightReady {
|
||||
|
|
@ -434,18 +441,33 @@ func topNonAirY(c *Chunk, x, z int) int {
|
|||
return MinY - 1
|
||||
}
|
||||
|
||||
// packIndices packs a slice of IDs into a long array using the minimum bit width
|
||||
// for the palette size, mirroring the network paletted-container packing (no
|
||||
// value spans a long boundary in vanilla's chunk NBT).
|
||||
func packIndices(ids []uint16, indexOf map[uint16]int) nbt.LongArray {
|
||||
bits := bitsFor(len(indexOf))
|
||||
// blockStorageBits is Strategy$1.getConfigurationForPaletteSize(...).bitsInStorage()
|
||||
// for a block palette: nothing at all for a single entry, and never fewer than
|
||||
// four bits otherwise. Vanilla's tableswitch sends bit counts 1 through 4 all to
|
||||
// the same four-bit linear configuration, so a palette of 2..16 states is stored
|
||||
// four bits wide even though two would fit. Packing it tighter, as we did,
|
||||
// produces a long array of the wrong length and vanilla refuses the section.
|
||||
func blockStorageBits(paletteSize int) int {
|
||||
bits := bitsFor(paletteSize)
|
||||
if bits > 0 && bits < 4 {
|
||||
return 4
|
||||
}
|
||||
return bits
|
||||
}
|
||||
|
||||
// biomeStorageBits is the same for a biome palette, where Strategy$2 has no
|
||||
// floor: the width really is ceil(log2(size)), and a Global configuration above
|
||||
// three bits still stores palette indices, just at its own width.
|
||||
func biomeStorageBits(paletteSize int) int { return bitsFor(paletteSize) }
|
||||
|
||||
// packIndices packs a slice of IDs into a long array at the given bit width,
|
||||
// with no value spanning a long boundary — vanilla's SimpleBitStorage layout.
|
||||
// A width of zero means the container carries no data array at all.
|
||||
func packIndices(ids []uint16, indexOf map[uint16]int, bits int) nbt.LongArray {
|
||||
if bits < 1 {
|
||||
bits = 1
|
||||
return nil
|
||||
}
|
||||
perLong := 64 / bits
|
||||
if perLong == 0 {
|
||||
perLong = 1
|
||||
}
|
||||
numLongs := (len(ids) + perLong - 1) / perLong
|
||||
longs := make(nbt.LongArray, numLongs)
|
||||
for i, id := range ids {
|
||||
|
|
@ -471,16 +493,8 @@ func nbtToChunk(root *nbt.Compound, regionX, regionZ, localX, localZ int) (*Chun
|
|||
return nil, ErrChunkNotFound
|
||||
}
|
||||
|
||||
levelTag, ok := root.Get("Level")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("world: chunk NBT missing Level")
|
||||
}
|
||||
level, ok := levelTag.(*nbt.Compound)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("world: Level is not a compound")
|
||||
}
|
||||
cx := int32(nbtAsInt(level, "xPos"))
|
||||
cz := int32(nbtAsInt(level, "zPos"))
|
||||
cx := int32(nbtAsInt(root, "xPos"))
|
||||
cz := int32(nbtAsInt(root, "zPos"))
|
||||
wantX := int32(regionX*32 + localX)
|
||||
wantZ := int32(regionZ*32 + localZ)
|
||||
if cx != wantX || cz != wantZ {
|
||||
|
|
@ -488,21 +502,24 @@ func nbtToChunk(root *nbt.Compound, regionX, regionZ, localX, localZ int) (*Chun
|
|||
}
|
||||
|
||||
c := &Chunk{X: cx, Z: cz, biome: BiomePlains}
|
||||
if lightTag, ok := level.Get("isLightOn"); ok {
|
||||
if lightTag, ok := root.Get("isLightOn"); ok {
|
||||
if enabled, ok := lightTag.(nbt.Byte); ok && enabled != 0 {
|
||||
c.lightReady = true
|
||||
}
|
||||
}
|
||||
|
||||
// Sections.
|
||||
if secTag, ok := level.Get("sections"); ok {
|
||||
if secTag, ok := root.Get("sections"); ok {
|
||||
if secList, ok := secTag.(nbt.List); ok && secList.ElemID == nbt.TagCompound {
|
||||
for _, st := range secList.Elems {
|
||||
sc, ok := st.(*nbt.Compound)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
yIdx := int(nbtAsInt(sc, "Y"))
|
||||
yIdx, ok := nbtAsSectionY(sc, "Y")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
si := yIdx - minYSection
|
||||
if si < 0 || si >= SectionCount {
|
||||
continue
|
||||
|
|
@ -581,7 +598,7 @@ func readBlockStates(c *Chunk, si int, sc *nbt.Compound) {
|
|||
}
|
||||
if dataTag, ok := bs.Get("data"); ok {
|
||||
if data, ok := dataTag.(nbt.LongArray); ok {
|
||||
unpackIndices(s[:], ids, data)
|
||||
unpackIndices(s[:], ids, data, blockStorageBits(len(ids)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -619,7 +636,7 @@ func readBiomes(c *Chunk, si int, sc *nbt.Compound) {
|
|||
if dataTag, ok := bc.Get("data"); ok {
|
||||
if data, ok := dataTag.(nbt.LongArray); ok {
|
||||
cells := new([biomeCellsPerSection]uint16)
|
||||
unpackIndices(cells[:], ids, data)
|
||||
unpackIndices(cells[:], ids, data, biomeStorageBits(len(ids)))
|
||||
c.biomes[si] = cells
|
||||
}
|
||||
}
|
||||
|
|
@ -653,6 +670,26 @@ func nbtAsInt(c *nbt.Compound, name string) int32 {
|
|||
return 0
|
||||
}
|
||||
|
||||
// nbtAsSectionY reads a section index, which vanilla writes as a byte. It also
|
||||
// accepts a short or an int so a chunk written before we matched vanilla still
|
||||
// decodes, and reports whether the tag was there at all — a section with no Y
|
||||
// is not section 0, it is malformed.
|
||||
func nbtAsSectionY(c *nbt.Compound, name string) (int, bool) {
|
||||
t, ok := c.Get(name)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch v := t.(type) {
|
||||
case nbt.Byte:
|
||||
return int(int8(v)), true
|
||||
case nbt.Short:
|
||||
return int(int16(v)), true
|
||||
case nbt.Int:
|
||||
return int(int32(v)), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func nbtAsString(c *nbt.Compound, name string) nbt.String {
|
||||
if t, ok := c.Get(name); ok {
|
||||
if v, ok := t.(nbt.String); ok {
|
||||
|
|
@ -664,15 +701,11 @@ func nbtAsString(c *nbt.Compound, name string) nbt.String {
|
|||
|
||||
// unpackIndices reverses packIndices: fills dst with palette IDs using the
|
||||
// packed long array.
|
||||
func unpackIndices(dst []uint16, ids []uint16, data nbt.LongArray) {
|
||||
bits := bitsFor(len(ids))
|
||||
func unpackIndices(dst []uint16, ids []uint16, data nbt.LongArray, bits int) {
|
||||
if bits < 1 {
|
||||
bits = 1
|
||||
return
|
||||
}
|
||||
perLong := 64 / bits
|
||||
if perLong == 0 {
|
||||
perLong = 1
|
||||
}
|
||||
mask := int64(1)<<uint(bits) - 1
|
||||
for i := range dst {
|
||||
longIdx := i / perLong
|
||||
|
|
|
|||
175
tools/VanillaChunkFormatCheck.java
Normal file
175
tools/VanillaChunkFormatCheck.java
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.io.DataInputStream;
|
||||
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.IdMap;
|
||||
import net.minecraft.nbt.ByteTag;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.nbt.LongArrayTag;
|
||||
import net.minecraft.nbt.NbtAccounter;
|
||||
import net.minecraft.nbt.NbtIo;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.util.SimpleBitStorage;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.chunk.Strategy;
|
||||
import net.minecraft.world.level.chunk.storage.RegionFile;
|
||||
import net.minecraft.world.level.chunk.storage.RegionStorageInfo;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
// Checks that a region file RegionIO wrote is readable as vanilla Anvil, using
|
||||
// vanilla's own classes rather than our idea of the format:
|
||||
//
|
||||
// * RegionFile parses the sector header and decompresses the chunk stream
|
||||
// * NbtIo reads the chunk NBT
|
||||
// * the root is flat -- no "Level" wrapper, which is where chunk data lived
|
||||
// until 1.18 and where nothing has looked since
|
||||
// * every section's Y is a ByteTag, the type SerializableChunkData reads
|
||||
// * every packed palette array is accepted by vanilla's own SimpleBitStorage
|
||||
// at the width vanilla's own Strategy derives from the palette size, which
|
||||
// is the check that catches an array packed too tight
|
||||
//
|
||||
// It stops short of SerializableChunkData.parse, which needs a RegistryAccess
|
||||
// carrying the datapack biome registry -- more scaffolding than this earns.
|
||||
//
|
||||
// CP="versions/26.1.2/server-26.1.2.jar;$(find libraries -name '*.jar' | tr '\n' ';')"
|
||||
// javac -nowarn -cp "$CP" -d <out> tools/VanillaChunkFormatCheck.java
|
||||
// java -cp "<out>;$CP" VanillaChunkFormatCheck <world>/region/r.-2.-2.mca
|
||||
//
|
||||
// Strategy.getConfigurationForPaletteSize is protected and the server jar is
|
||||
// signed, so this cannot simply live in vanilla's package -- it is reached by
|
||||
// reflection instead. Restating the width table here would defeat the point of
|
||||
// checking against vanilla rather than against our reading of vanilla.
|
||||
public final class VanillaChunkFormatCheck {
|
||||
private static int failures = 0;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length != 1) {
|
||||
System.err.println("usage: VanillaChunkFormatCheck <region file.mca>");
|
||||
System.exit(2);
|
||||
}
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
|
||||
Path file = Paths.get(args[0]);
|
||||
Path folder = file.getParent();
|
||||
RegionStorageInfo info = new RegionStorageInfo(
|
||||
"regionio",
|
||||
ResourceKey.create(ResourceKey.createRegistryKey(Identifier.withDefaultNamespace("dimension")),
|
||||
Identifier.withDefaultNamespace("overworld")),
|
||||
"chunk");
|
||||
|
||||
// The width maths only reads bitsInStorage(), which for a Global
|
||||
// configuration is the bit count it was handed, so the IdMap a Strategy
|
||||
// is built over does not affect it. Reusing the block-state registry for
|
||||
// both keeps this tool from needing the datapack biome registry.
|
||||
IdMap<?> anyMap = Block.BLOCK_STATE_REGISTRY;
|
||||
Strategy<?> blockStrategy = Strategy.createForBlockStates(anyMap);
|
||||
Strategy<?> biomeStrategy = Strategy.createForBiomes(anyMap);
|
||||
|
||||
int chunks = 0;
|
||||
try (RegionFile region = new RegionFile(info, file, folder, true)) {
|
||||
for (int lz = 0; lz < 32; lz++) {
|
||||
for (int lx = 0; lx < 32; lx++) {
|
||||
ChunkPos pos = new ChunkPos(lx, lz);
|
||||
if (!region.hasChunk(pos)) continue;
|
||||
try (DataInputStream in = region.getChunkDataInputStream(pos)) {
|
||||
if (in == null) continue;
|
||||
CompoundTag root = NbtIo.read(in, NbtAccounter.unlimitedHeap());
|
||||
checkChunk(root, blockStrategy, biomeStrategy);
|
||||
chunks++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chunks == 0) {
|
||||
System.out.println("FAIL: the region file contains no chunks");
|
||||
System.exit(1);
|
||||
}
|
||||
System.out.printf("checked %d chunks, %d failures%n", chunks, failures);
|
||||
System.exit(failures == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
private static void checkChunk(CompoundTag root, Strategy<?> blocks, Strategy<?> biomes) {
|
||||
if (root.get("Level") != null) {
|
||||
fail("chunk NBT still nests under \"Level\"");
|
||||
}
|
||||
for (String key : new String[]{"xPos", "yPos", "zPos", "Status", "sections", "Heightmaps"}) {
|
||||
if (root.get(key) == null) fail("root is missing \"" + key + "\"");
|
||||
}
|
||||
ListTag sections = root.getListOrEmpty("sections");
|
||||
if (sections.isEmpty()) fail("chunk has no sections");
|
||||
for (int i = 0; i < sections.size(); i++) {
|
||||
CompoundTag section = sections.getCompoundOrEmpty(i);
|
||||
Tag y = section.get("Y");
|
||||
if (!(y instanceof ByteTag)) {
|
||||
fail("section " + i + " Y is " + (y == null ? "absent" : y.getClass().getSimpleName())
|
||||
+ ", vanilla reads it with getByteOr");
|
||||
}
|
||||
checkContainer(section, "block_states", blocks, 4096, i);
|
||||
checkContainer(section, "biomes", biomes, 64, i);
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkContainer(CompoundTag section, String key, Strategy<?> strategy, int entries, int index) {
|
||||
CompoundTag container = section.getCompoundOrEmpty(key);
|
||||
if (container.isEmpty()) {
|
||||
fail("section " + index + " has no \"" + key + "\"");
|
||||
return;
|
||||
}
|
||||
int paletteSize = container.getListOrEmpty("palette").size();
|
||||
if (paletteSize == 0) {
|
||||
fail("section " + index + " " + key + " has an empty palette");
|
||||
return;
|
||||
}
|
||||
int bits = bitsInStorage(strategy, paletteSize);
|
||||
Tag data = container.get("data");
|
||||
if (bits == 0) {
|
||||
if (data != null) {
|
||||
fail("section " + index + " " + key + ": palette of " + paletteSize
|
||||
+ " needs no data array but one is present");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!(data instanceof LongArrayTag array)) {
|
||||
fail("section " + index + " " + key + ": palette of " + paletteSize
|
||||
+ " needs a " + bits + "-bit data array, found " + (data == null ? "none" : data.getClass().getSimpleName()));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Throws InitializationException when the long count does not match
|
||||
// the width vanilla expects -- exactly the failure a too-tightly
|
||||
// packed array produces.
|
||||
new SimpleBitStorage(bits, entries, array.getAsLongArray());
|
||||
} catch (RuntimeException e) {
|
||||
fail("section " + index + " " + key + ": palette of " + paletteSize
|
||||
+ " at " + bits + " bits: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// bitsInStorage asks vanilla how wide a container with this many palette
|
||||
// entries is stored on disk.
|
||||
private static int bitsInStorage(Strategy<?> strategy, int paletteSize) {
|
||||
try {
|
||||
Method forSize = Strategy.class.getDeclaredMethod("getConfigurationForPaletteSize", int.class);
|
||||
forSize.setAccessible(true);
|
||||
Object configuration = forSize.invoke(strategy, paletteSize);
|
||||
Method bits = configuration.getClass().getMethod("bitsInStorage");
|
||||
bits.setAccessible(true);
|
||||
return (Integer) bits.invoke(configuration);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException("cannot reach Strategy.getConfigurationForPaletteSize", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void fail(String message) {
|
||||
System.out.println("FAIL: " + message);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue