Add vanilla parity harness and harden server boundaries

This commit is contained in:
Daniar Mannanov 2026-08-10 22:52:44 +03:00
parent 1924cb5591
commit ca019756ec
25 changed files with 1118 additions and 217 deletions

View file

@ -16,7 +16,7 @@ var biomeParametersJSON []byte
// rawParameter mirrors one entry of biome_parameters.json: a biome name plus its
// climate ranges. Each axis value is a [min, max] array; depth is normally a
// scalar (0.0 surface / 1.0 underground) but a few cave entries carry a [min,
// max] array, so it is decoded loosely (see depthScalar).
// max] array, so it is decoded loosely (see depthRange).
type rawParameter struct {
Biome string `json:"biome"`
Param struct {
@ -31,21 +31,20 @@ type rawParameter struct {
}
// depthRange extracts a depth band from a raw entry. It accepts a JSON number
// (mapped to the half-open band [v, v+1) so a scalar value matches exactly one
// integer depth layer), a single-element [v] array (same as the scalar), or a
// (mapped to the exact inclusive range [v,v]), a single-element [v] array, or a
// two-element [min, max] range (used by cave biomes like lush/dripstone_caves
// whose depth is [0.2, 0.9]). Returns ok=false only for malformed input.
func depthRange(v any) (worldgen.ClimateRange, bool) {
switch d := v.(type) {
case float64:
q := worldgen.Quantize(d)
return worldgen.ClimateRange{Min: q, Max: q + 1}, true
return worldgen.ClimateRange{Min: q, Max: q}, true
case []any:
switch len(d) {
case 1:
if f, ok := d[0].(float64); ok {
q := worldgen.Quantize(f)
return worldgen.ClimateRange{Min: q, Max: q + 1}, true
return worldgen.ClimateRange{Min: q, Max: q}, true
}
case 2:
lo, ok1 := d[0].(float64)
@ -59,8 +58,7 @@ func depthRange(v any) (worldgen.ClimateRange, bool) {
}
// biomeTable is the full biome parameter table (surface + underground twins +
// cave biomes), built once at init. The finder's range-contains check on the
// depth axis selects the correct layer per cell.
// cave biomes), built once at init.
var (
biomeTable *worldgen.ParameterTable
biomeTableOnce sync.Once
@ -92,7 +90,7 @@ func loadBiomeTable() *worldgen.ParameterTable {
// makeBiomeParameter converts a raw JSON entry into a BiomeParameter, mapping
// the [min,max] ranges to quantized ClimateRanges. depth is a ClimateRange
// (half-open band for scalar depths, explicit range for cave biomes).
// (exact range for scalar depths, explicit range for cave biomes).
func makeBiomeParameter(e rawParameter, depth worldgen.ClimateRange) worldgen.BiomeParameter {
qr := func(a [2]float64) worldgen.ClimateRange {
return worldgen.ClimateRange{Min: worldgen.Quantize(a[0]), Max: worldgen.Quantize(a[1])}
@ -105,7 +103,7 @@ func makeBiomeParameter(e rawParameter, depth worldgen.ClimateRange) worldgen.Bi
qr(e.Param.Continentalness),
qr(e.Param.Erosion),
qr(e.Param.Weirdness),
depth, // half-open band (scalar) or explicit range (cave biomes)
depth,
},
Offset: worldgen.Quantize(e.Param.Offset),
}

View file

@ -147,26 +147,9 @@ func (r *RegionFile) WriteChunk(localX, localZ int, nbt []byte) error {
defer r.mu.Unlock()
idx := locationIndex(localX, localZ)
old := r.offsets[idx]
oldSectors := 0
if old != 0 {
oldSectors = int(old & 0xFF)
}
// Decide where to write. Reuse the existing allocation if it still fits;
// otherwise append at end-of-file.
var offset int
switch {
case old != 0 && oldSectors == sectorsNeeded:
offset = int(old >> 8)
case old != 0 && oldSectors >= sectorsNeeded:
// Keep the old offset but record the smaller count (the tail of the old
// allocation becomes unreferenced dead space; acceptable for now).
offset = int(old >> 8)
default:
// Append after the last used sector.
offset = r.endSectorLocked()
}
// Always use copy-on-write. Reusing the published allocation would let a
// crash during WriteAt corrupt the only readable copy of the chunk.
offset := r.endSectorLocked()
// Build the on-disk record: length + compression byte + compressed data,
// zero-padded to a sector boundary.
@ -177,13 +160,23 @@ func (r *RegionFile) WriteChunk(localX, localZ int, nbt []byte) error {
if _, err := r.f.WriteAt(rec, off); err != nil {
return err
}
// Update the offset table and timestamp, then persist both tables.
r.offsets[idx] = uint32(offset<<8) | uint32(sectorsNeeded)
if err := r.writeTablesLocked(); err != nil {
// Publish the new location only after the complete record is durable. A
// crash before this sync leaves an unreachable tail and the old slot intact.
if err := r.f.Sync(); err != nil {
return err
}
return r.f.Sync()
location := uint32(offset<<8) | uint32(sectorsNeeded)
var locationBytes [4]byte
binary.BigEndian.PutUint32(locationBytes[:], location)
if _, err := r.f.WriteAt(locationBytes[:], int64(idx*4)); err != nil {
return err
}
if err := r.f.Sync(); err != nil {
return err
}
r.offsets[idx] = location
return nil
}
// writeTablesLocked writes the offset + timestamp tables back to the header.
@ -205,6 +198,11 @@ func (r *RegionFile) writeTablesLocked() error {
// i.e. where new chunk data can be appended. Caller holds r.mu.
func (r *RegionFile) endSectorLocked() int {
maxUsed := headerSectors
if info, err := r.f.Stat(); err == nil {
if sectors := int((info.Size() + sectorSize - 1) / sectorSize); sectors > maxUsed {
maxUsed = sectors
}
}
for _, loc := range r.offsets {
if loc == 0 {
continue

View file

@ -2,6 +2,7 @@ package world
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@ -32,7 +33,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 = 11
const generatorVersion = 12
// generatorVersionTag is the NBT key holding generatorVersion. It is namespaced
// because it is ours, not part of the vanilla chunk format.
@ -233,6 +234,17 @@ func (s *Store) regionFor(cx, cz int32) (*RegionFile, error) {
// LoadChunk reads and decodes the chunk at (cx, cz). It returns ErrChunkNotFound
// when the chunk is not stored.
func (s *Store) LoadChunk(cx, cz int32) (*Chunk, error) {
return s.loadChunk(cx, cz, true)
}
// LoadVanillaChunk reads an official-server chunk without requiring RegionIO's
// generator stamp. It exists for parity tooling; runtime world loading must use
// LoadChunk so stale RegionIO terrain still regenerates.
func (s *Store) LoadVanillaChunk(cx, cz int32) (*Chunk, error) {
return s.loadChunk(cx, cz, false)
}
func (s *Store) loadChunk(cx, cz int32, requireGeneratorVersion bool) (*Chunk, error) {
rx, rz, lx, lz := regionIndex(cx, cz)
rf, err := s.regionFor(cx, cz)
if err != nil {
@ -250,7 +262,7 @@ func (s *Store) LoadChunk(cx, cz int32) (*Chunk, error) {
if !ok {
return nil, fmt.Errorf("world: chunk (%d,%d) root is not a compound", cx, cz)
}
return nbtToChunk(root, rx, rz, lx, lz)
return nbtToChunkVersioned(root, rx, rz, lx, lz, requireGeneratorVersion)
}
// SaveChunk encodes the chunk and writes it to its region file.
@ -483,13 +495,17 @@ func packIndices(ids []uint16, indexOf map[uint16]int, bits 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) {
return nbtToChunkVersioned(root, regionX, regionZ, localX, localZ, true)
}
func nbtToChunkVersioned(root *nbt.Compound, regionX, regionZ, localX, localZ int, requireGeneratorVersion bool) (*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 {
if requireGeneratorVersion && nbtAsInt(root, generatorVersionTag) != generatorVersion {
return nil, ErrChunkNotFound
}
@ -508,27 +524,39 @@ func nbtToChunk(root *nbt.Compound, regionX, regionZ, localX, localZ int) (*Chun
}
}
// Sections.
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, ok := nbtAsSectionY(sc, "Y")
if !ok {
continue
}
si := yIdx - minYSection
if si < 0 || si >= SectionCount {
continue
}
readBlockStates(c, si, sc)
readBiomes(c, si, sc)
readLightSection(c, si, sc)
}
secTag, ok := root.Get("sections")
if !ok {
return nil, errors.New("world: chunk NBT missing sections")
}
secList, ok := secTag.(nbt.List)
if !ok || secList.ElemID != nbt.TagCompound {
return nil, errors.New("world: chunk sections is not a compound list")
}
seenSections := make(map[int]bool, len(secList.Elems))
for index, st := range secList.Elems {
sc, ok := st.(*nbt.Compound)
if !ok {
return nil, fmt.Errorf("world: section %d is not a compound", index)
}
yIdx, ok := nbtAsSectionY(sc, "Y")
if !ok {
return nil, fmt.Errorf("world: section %d has no valid Y", index)
}
si := yIdx - minYSection
if si < 0 || si >= SectionCount {
continue
}
if seenSections[si] {
return nil, fmt.Errorf("world: duplicate section Y %d", yIdx)
}
seenSections[si] = true
if err := readBlockStates(c, si, sc); err != nil {
return nil, fmt.Errorf("world: section Y %d block states: %w", yIdx, err)
}
if err := readBiomes(c, si, sc); err != nil {
return nil, fmt.Errorf("world: section Y %d biomes: %w", yIdx, err)
}
readLightSection(c, si, sc)
}
return c, nil
}
@ -555,36 +583,48 @@ func readLightSection(c *Chunk, si int, sc *nbt.Compound) {
// readBlockStates decodes a section's block_states {palette, data?} into the
// chunk's section array. A palette of size 1 fills the whole section; otherwise
// the packed data array is unpacked.
func readBlockStates(c *Chunk, si int, sc *nbt.Compound) {
func readBlockStates(c *Chunk, si int, sc *nbt.Compound) error {
bsTag, ok := sc.Get("block_states")
if !ok {
return
return errors.New("missing block_states")
}
bs, ok := bsTag.(*nbt.Compound)
if !ok {
return
return errors.New("block_states is not a compound")
}
palTag, ok := bs.Get("palette")
if !ok {
return
return errors.New("missing palette")
}
pal, ok := palTag.(nbt.List)
if !ok || pal.ElemID != nbt.TagCompound {
return
return errors.New("palette is not a compound list")
}
if len(pal.Elems) == 0 || len(pal.Elems) > totalBlockStates {
return fmt.Errorf("palette size %d out of range", len(pal.Elems))
}
// Decode palette entries to state IDs.
ids := make([]uint16, len(pal.Elems))
for i, e := range pal.Elems {
ec, ok := e.(*nbt.Compound)
if !ok {
ids[i] = StateAir
continue
return fmt.Errorf("palette entry %d is not a compound", i)
}
name := string(nbtAsString(ec, "Name"))
nameTag, ok := ec.Get("Name")
if !ok {
return fmt.Errorf("palette entry %d has no Name", i)
}
nameValue, ok := nameTag.(nbt.String)
if !ok || nameValue == "" {
return fmt.Errorf("palette entry %d has invalid Name", i)
}
name := string(nameValue)
props := readProps(ec)
// An unknown block name decodes to air rather than to a neighbour's
// state; that loses the block but does not corrupt the column.
ids[i], _ = nameToStateID(name, props)
var resolved bool
ids[i], resolved = nameToStateID(name, props)
if !resolved {
return fmt.Errorf("unknown block state %q", name)
}
}
c.section(si) // ensure allocated
s := c.sections[si]
@ -594,36 +634,55 @@ func readBlockStates(c *Chunk, si int, sc *nbt.Compound) {
fill[i] = ids[0]
}
c.sections[si] = &fill
return
return nil
}
if dataTag, ok := bs.Get("data"); ok {
if data, ok := dataTag.(nbt.LongArray); ok {
unpackIndices(s[:], ids, data, blockStorageBits(len(ids)))
}
dataTag, ok := bs.Get("data")
if !ok {
return errors.New("multi-entry palette has no data")
}
data, ok := dataTag.(nbt.LongArray)
if !ok {
return errors.New("data is not a long array")
}
bits := blockStorageBits(len(ids))
if err := validatePackedData(len(s), bits, data); err != nil {
return err
}
return unpackIndices(s[:], ids, data, bits)
}
// readBiomes decodes a section's biomes {palette, data?} into the per-cell array.
func readBiomes(c *Chunk, si int, sc *nbt.Compound) {
func readBiomes(c *Chunk, si int, sc *nbt.Compound) error {
bTag, ok := sc.Get("biomes")
if !ok {
return
return errors.New("missing biomes")
}
bc, ok := bTag.(*nbt.Compound)
if !ok {
return
return errors.New("biomes is not a compound")
}
palTag, ok := bc.Get("palette")
if !ok {
return
return errors.New("missing palette")
}
pal, ok := palTag.(nbt.List)
if !ok || pal.ElemID != nbt.TagString {
return
return errors.New("palette is not a string list")
}
if len(pal.Elems) == 0 || len(pal.Elems) > totalBiomes {
return fmt.Errorf("palette size %d out of range", len(pal.Elems))
}
ids := make([]uint16, len(pal.Elems))
for i, e := range pal.Elems {
ids[i] = biomeIDByName(string(e.(nbt.String)))
name, ok := e.(nbt.String)
if !ok {
return fmt.Errorf("palette entry %d is not a string", i)
}
id := registry.Index("minecraft:worldgen/biome", string(name))
if id < 0 {
return fmt.Errorf("unknown biome %q", name)
}
ids[i] = uint16(id)
}
if len(ids) == 1 {
cells := new([biomeCellsPerSection]uint16)
@ -631,15 +690,26 @@ func readBiomes(c *Chunk, si int, sc *nbt.Compound) {
cells[i] = ids[0]
}
c.biomes[si] = cells
return
return nil
}
if dataTag, ok := bc.Get("data"); ok {
if data, ok := dataTag.(nbt.LongArray); ok {
cells := new([biomeCellsPerSection]uint16)
unpackIndices(cells[:], ids, data, biomeStorageBits(len(ids)))
c.biomes[si] = cells
}
dataTag, ok := bc.Get("data")
if !ok {
return errors.New("multi-entry palette has no data")
}
data, ok := dataTag.(nbt.LongArray)
if !ok {
return errors.New("data is not a long array")
}
bits := biomeStorageBits(len(ids))
cells := new([biomeCellsPerSection]uint16)
if err := validatePackedData(len(cells), bits, data); err != nil {
return err
}
if err := unpackIndices(cells[:], ids, data, bits); err != nil {
return err
}
c.biomes[si] = cells
return nil
}
func readProps(c *nbt.Compound) map[string]string {
@ -701,21 +771,32 @@ 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 int) {
func validatePackedData(entries, bits int, data nbt.LongArray) error {
if bits < 1 {
return
return errors.New("invalid zero-bit packed data")
}
perLong := 64 / bits
want := (entries + perLong - 1) / perLong
if len(data) != want {
return fmt.Errorf("packed data has %d longs, want %d", len(data), want)
}
return nil
}
func unpackIndices(dst []uint16, ids []uint16, data nbt.LongArray, bits int) error {
if bits < 1 {
return errors.New("invalid zero-bit packed data")
}
perLong := 64 / bits
mask := int64(1)<<uint(bits) - 1
for i := range dst {
longIdx := i / perLong
bitOff := (i % perLong) * bits
if longIdx >= len(data) {
break
}
idx := int((data[longIdx] >> uint(bitOff)) & mask)
if idx >= 0 && idx < len(ids) {
dst[i] = ids[idx]
if idx < 0 || idx >= len(ids) {
return fmt.Errorf("palette index %d out of range %d", idx, len(ids))
}
dst[i] = ids[idx]
}
return nil
}

View file

@ -74,6 +74,26 @@ func TestRegionFileOverwrite(t *testing.T) {
}
}
func TestRegionFileOverwriteUsesCopyOnWrite(t *testing.T) {
dir := t.TempDir()
rf, err := OpenRegion(dir, 0, 0)
if err != nil {
t.Fatal(err)
}
defer rf.Close()
if err := rf.WriteChunk(1, 1, []byte("first")); err != nil {
t.Fatal(err)
}
first := rf.offsets[locationIndex(1, 1)] >> 8
if err := rf.WriteChunk(1, 1, []byte("second")); err != nil {
t.Fatal(err)
}
second := rf.offsets[locationIndex(1, 1)] >> 8
if second <= first {
t.Fatalf("overwrite reused published sector %d; new location is %d", first, 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) {
@ -126,6 +146,44 @@ func TestStoreChunkRoundTrip(t *testing.T) {
}
}
func TestChunkNBTRejectsMissingSections(t *testing.T) {
root := nbt.NewCompound().
Set(generatorVersionTag, nbt.Int(generatorVersion)).
Set("xPos", nbt.Int(0)).
Set("zPos", nbt.Int(0))
if _, err := nbtToChunk(root, 0, 0, 0, 0); err == nil {
t.Fatal("accepted chunk without sections")
}
}
func TestChunkNBTRejectsMalformedPaletteData(t *testing.T) {
root := chunkToNBT(GenerateFlat(0, 0))
sectionsTag, _ := root.Get("sections")
sections := sectionsTag.(nbt.List)
section := sections.Elems[0].(*nbt.Compound)
blocksTag, _ := section.Get("block_states")
blocks := blocksTag.(*nbt.Compound)
blocks.Set("data", nbt.LongArray{0})
if _, err := nbtToChunk(root, 0, 0, 0, 0); err == nil {
t.Fatal("accepted packed block data with the wrong length")
}
}
func TestChunkNBTRejectsUnknownBlock(t *testing.T) {
root := chunkToNBT(NewChunk(0, 0, BiomePlains))
sectionsTag, _ := root.Get("sections")
sections := sectionsTag.(nbt.List)
section := sections.Elems[0].(*nbt.Compound)
blocksTag, _ := section.Get("block_states")
blocks := blocksTag.(*nbt.Compound)
blocks.Set("palette", nbt.List{ElemID: nbt.TagCompound, Elems: []nbt.Tag{
nbt.NewCompound().Set("Name", nbt.String("minecraft:not_a_block")),
}})
if _, err := nbtToChunk(root, 0, 0, 0, 0); err == nil {
t.Fatal("accepted unknown block palette entry")
}
}
func TestStoreLightRoundTrip(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(dir)

View file

@ -1,7 +1,9 @@
package world
import (
"encoding/binary"
"encoding/json"
"io"
"math"
"os"
"strconv"
@ -9,6 +11,73 @@ import (
"testing"
)
const vanillaParityFixture = "testdata/vanilla_overworld_12345.bin"
func TestVanillaBlockParity(t *testing.T) {
f, err := os.Open(vanillaParityFixture)
if err != nil {
if os.Getenv("REGIONIO_REQUIRE_PARITY") == "1" {
t.Fatalf("required parity fixture: %v", err)
}
t.Skip("vanilla block fixture not installed; run cmd/vanillacapture with Java 25")
}
defer f.Close()
var header [24]byte
if _, err := io.ReadFull(f, header[:]); err != nil {
t.Fatal(err)
}
if string(header[:8]) != "RIOPAR01" {
t.Fatalf("bad parity fixture magic %q", header[:8])
}
seed := int64(binary.BigEndian.Uint64(header[8:16]))
count := int(binary.BigEndian.Uint32(header[16:20]))
if seed != 12345 || count <= 0 {
t.Fatalf("fixture seed=%d chunks=%d", seed, count)
}
gen := NewVanillaGenerator(seed)
for chunkIndex := 0; chunkIndex < count; chunkIndex++ {
var coords [8]byte
if _, err := io.ReadFull(f, coords[:]); err != nil {
t.Fatal(err)
}
cx := int32(binary.BigEndian.Uint32(coords[:4]))
cz := int32(binary.BigEndian.Uint32(coords[4:]))
chunk := gen(cx, cz)
var state [2]byte
for y := MinY; y < MinY+WorldHeight; y++ {
for z := 0; z < 16; z++ {
for x := 0; x < 16; x++ {
if _, err := io.ReadFull(f, state[:]); err != nil {
t.Fatal(err)
}
want := binary.BigEndian.Uint16(state[:])
if got := chunk.GetBlock(x, y, z); got != want {
t.Fatalf("chunk (%d,%d) block (%d,%d,%d): got state %d want %d", cx, cz, x, y, z, got, want)
}
}
}
}
for y := MinY; y < MinY+WorldHeight; y += biomeCellSize {
for z := 0; z < 16; z += biomeCellSize {
for x := 0; x < 16; x += biomeCellSize {
if _, err := io.ReadFull(f, state[:]); err != nil {
t.Fatal(err)
}
want := binary.BigEndian.Uint16(state[:])
if got := chunk.GetBiome(x, y, z); got != want {
t.Fatalf("chunk (%d,%d) biome (%d,%d,%d): got %d want %d", cx, cz, x, y, z, got, want)
}
}
}
}
}
var trailing [1]byte
if n, err := f.Read(trailing[:]); n != 0 || err != io.EOF {
t.Fatalf("fixture has trailing data or read error: n=%d err=%v", n, err)
}
}
// TestVanillaParity compares our generated surface heights against heights
// captured from the official server (seed 12345, normal terrain). Requires
// /tmp/vanilla_ground.json from the capture step.