From c399070f597d131f38c08f4adb0ad9c81192164b Mon Sep 17 00:00:00 2001 From: Master290 Date: Mon, 27 Jul 2026 01:28:04 +0300 Subject: [PATCH] Write biome palettes in the form the client actually reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vanilla's SECTION_BIOMES palette strategy switches on the bit count with `tableswitch {0..3}`: 0 single-valued, 1-3 linear, and everything else falls through to the global palette. There is no hashmap tier for biomes — that exists only for block states, whose 0..8 switch we already implement correctly. We were writing a linear palette all the way up to 7 bits. A section holding 9 or more distinct biomes therefore went out as a 4-bit indirect container while the client read it as global: no palette prefix consumed, long array re-read at 7 bits, and every field after it in the chunk payload misaligned. Sections that straddle the surface and the cave biomes really do carry that many, so this is reachable in ordinary terrain rather than a corner case. Checked against the jar rather than recalled: javap -c on Strategy$2 shows the {0..3} switch with Configuration$Global in the default arm. The test decodes each container the way the client would and requires it to consume exactly the bytes we produced, so a misframed container shows up as a byte count instead of needing a client to notice. Restoring the old threshold fails the 9-, 20- and 65-biome cases. --- internal/world/encode.go | 12 ++++- internal/world/encode_test.go | 94 +++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/internal/world/encode.go b/internal/world/encode.go index fc50a5a..ef8658f 100644 --- a/internal/world/encode.go +++ b/internal/world/encode.go @@ -63,6 +63,16 @@ const ( biomeCellsXZ = 16 / biomeCellSize // 4 biomeCellsPerSection = biomeCellsXZ * biomeCellsXZ * biomeCellsXZ // 64 totalBiomes = 65 // synced minecraft:worldgen/biome registry size + + // maxBiomeLinearBits is the widest indirect (linear) biome palette the client + // will read. Vanilla's SECTION_BIOMES strategy switches on the bit count with + // `tableswitch {0..3}`: 0 is single-valued, 1-3 are linear, and everything + // else falls through to the global palette — there is no hashmap tier for + // biomes, unlike block states. Writing a linear palette at 4+ bits makes the + // client read the container as global: it consumes no palette prefix and + // re-reads the long array at bitsFor(totalBiomes), so the rest of the chunk + // payload is misaligned. + maxBiomeLinearBits = 3 ) // Chunk is a 16xWorldHeightx16 column of block states. Each section may carry a @@ -412,7 +422,7 @@ func writeBiomePalette(w *protocol.Writer, s *[biomeCellsPerSection]uint16) { if bpe < 1 { bpe = 1 // minimum for the indirect biome format } - if bpe > bitsFor(totalBiomes) { + if bpe > maxBiomeLinearBits { writeBiomeDirect(w, s) return } diff --git a/internal/world/encode_test.go b/internal/world/encode_test.go index 332e7f0..be5cf11 100644 --- a/internal/world/encode_test.go +++ b/internal/world/encode_test.go @@ -172,3 +172,97 @@ func readInt32(t *testing.T, b []byte) int32 { } return int32(uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) } + +// readBiomeContainerAsClient consumes one biome paletted container exactly the +// way the vanilla client does, and returns the number of bytes it used. +// +// The client picks the palette form from the bits-per-entry byte alone, using +// the SECTION_BIOMES strategy: `tableswitch {0..3}` where 0 is single-valued, +// 1-3 are linear (palette prefix present), and every other value falls through +// to the global palette — no palette prefix, and the data re-read at the +// registry's own bit width regardless of the byte we sent. This differs from +// block states, which additionally have a hashmap tier for 5-8 bits. +func readBiomeContainerAsClient(t *testing.T, buf []byte) int { + t.Helper() + r := protocol.NewReader(buf) + bpe, err := r.ReadByte() + if err != nil { + t.Fatalf("bits per entry: %v", err) + } + if bpe == 0 { + if _, err := r.VarInt(); err != nil { + t.Fatalf("single value: %v", err) + } + return len(buf) - r.Remaining() + } + dataBits := int(bpe) + if dataBits <= maxBiomeLinearBits { + n, err := r.VarInt() + if err != nil || n < 0 { + t.Fatalf("palette length: %v", err) + } + for i := int32(0); i < n; i++ { + if _, err := r.VarInt(); err != nil { + t.Fatalf("palette entry %d: %v", i, err) + } + } + } else { + dataBits = bitsFor(totalBiomes) + } + perLong := 64 / dataBits + longs := (biomeCellsPerSection + perLong - 1) / perLong + for i := 0; i < longs; i++ { + if _, err := r.Int64(); err != nil { + t.Fatalf("data long %d: %v", i, err) + } + } + return len(buf) - r.Remaining() +} + +// TestBiomePaletteFormMatchesVanillaThresholds pins the SECTION_BIOMES palette +// contract at the linear/global boundary. +// +// A section holding 9 or more distinct biomes needs 4 bits per entry. Written as +// a linear palette, the client reads it as global instead: it consumes no +// palette prefix and re-reads the long array at 7 bits, so it walks off the end +// of the container and every following field in the chunk payload is +// misaligned. Sections straddling the surface and the cave biomes really do +// carry that many, so this is reachable in ordinary terrain. +// +// The assertion is the desync itself: decode each container the way the client +// would and require it to consume exactly the bytes we produced. +func TestBiomePaletteFormMatchesVanillaThresholds(t *testing.T) { + globalBits := bitsFor(totalBiomes) + + for _, tc := range []struct { + name string + distinct int + wantBits int + }{ + {"one biome stays single-valued", 1, 0}, + {"two biomes", 2, 1}, + {"eight biomes is the widest linear palette", 8, 3}, + {"nine biomes must switch to global", 9, globalBits}, + {"twenty biomes", 20, globalBits}, + {"every registry biome", totalBiomes, globalBits}, + } { + t.Run(tc.name, func(t *testing.T) { + var cells [biomeCellsPerSection]uint16 + for i := range cells { + cells[i] = uint16(i % tc.distinct) + } + + w := protocol.NewWriter(128) + writeBiomePalette(w, &cells) + got := w.Bytes() + + if int(got[0]) != tc.wantBits { + t.Errorf("bits per entry = %d, want %d", got[0], tc.wantBits) + } + if used := readBiomeContainerAsClient(t, got); used != len(got) { + t.Errorf("client consumed %d of %d bytes; container is misframed by %d", + used, len(got), len(got)-used) + } + }) + } +}