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

31
.github/workflows/verify.yml vendored Normal file
View file

@ -0,0 +1,31 @@
name: verify
on:
push:
pull_request:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26.x'
cache: true
- run: go build ./...
- run: go vet ./...
- run: go test ./...
race:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26.x'
cache: true
- run: go test -race ./...

View file

@ -10,12 +10,14 @@ README.md describes what the server does. This file is about how to work on it.
```
go build ./... && go vet ./...
make test # go test ./...
make test-race # the race-sensitive subset
make verify # both
make test-race # go test -race ./...
make verify # build, vet, tests, and race tests
make parity # requires the committed vanilla block fixture
go run ./cmd/regionio -seed 12345 # serves on 0.0.0.0:25565
go run ./cmd/regionio -seed 12345 -world "" # in-memory world, nothing read from or written to disk
go run ./cmd/gendump # client-free generator diagnostics
go run ./cmd/vanillacapture # regenerate vanilla block parity fixture (Java 25)
```
## Hard rules
@ -124,7 +126,7 @@ we can tell, but no vanilla capture confirms them: the aquifer (`worldgen/aquife
The whole `noise_router` is parsed. `preliminary_surface_level` is reachable through
`od.PreliminarySurfaceLevelAt`, which quart-aligns and memoises across chunks the way `NoiseChunk`
does; the `vein_*` keys are parsed but nothing reads them yet.
does. The `vein_*` keys drive `OreVeinifier` during the material pass.
The rule tree is **seed-bound**: `od.SurfaceRule()` returns a `*SurfaceRuleSet` compiled against the
world's `RandomState`, because `noise_threshold` and `vertical_gradient` cannot work without it. Get
@ -137,8 +139,9 @@ parse time).
Known gaps, roughly in order of how visible they are:
- **No carvers and no ore veins.** Caves come only from the density router; `configured_carver` is
not extracted and the `OreVeinifier` over the parsed `vein_*` keys is not written.
- **No generic placed/configured feature system.** Configured caves, canyons, and noise-router ore
veins are implemented, but ordinary ores, flora, trees, and springs still use hand-written
decoration rather than biome generation stages and placement modifiers.
- **No `PerlinSimplexNoise`**, so two corners of `Biome.coldEnoughToSnow` are missing: the height
adjustment that cools a column above sea level + 17, and the `frozen` temperature modifier that
warms patches of frozen ocean. Base temperatures are real (`worldgen/biome_temperature.go`,
@ -169,7 +172,9 @@ started as gendump checks and run under `make verify`: `TestCavesAreDry`, `TestN
`go test -race` needs cgo and a C toolchain; on a Windows box without gcc, `make test-race` cannot
run at all.
`internal/world/vanilla_parity_test.go` compares surface heights against a capture from the official
server and skips when the capture is absent. Note it reads a hardcoded `/tmp` path, so on Windows it
never runs. A capture is produced by running the vanilla server headless at a known seed and reading
its region files back with our own `regionfile.go` + `nbt`.
`cmd/vanillacapture` runs the official bundler jar in an isolated temporary world, force-loads fixed
chunks, reads their region files, and writes `internal/world/testdata/vanilla_overworld_12345.bin`.
The fixture contains every block state and 4x4x4 biome cell. Java 25 is required. `make parity`
requires the fixture and fails when it is absent; ordinary `go test ./...` skips that one test so a
fresh checkout remains buildable without Mojang's non-redistributable jar. The older optional
`/tmp/vanilla_ground.json` height report remains diagnostic only.

View file

@ -1,10 +1,19 @@
.PHONY: test test-race verify
.PHONY: build vet test test-race parity verify
build:
go build ./...
vet:
go vet ./...
test:
go test ./...
test-race:
go test -race ./internal/network ./internal/server ./internal/world \
-run 'Test(Integration|BoundaryEdit|PlayerInfo|PlayerRegistry|Concurrent|Incremental|EncodeLight|Cache|Store|Eviction|Region|Ticket|Streamer|LoadSixteen)'
go test -race ./...
verify: test test-race
parity:
test -f internal/world/testdata/vanilla_overworld_12345.bin
REGIONIO_REQUIRE_PARITY=1 go test ./internal/world -run TestVanillaBlockParity
verify: build vet test test-race

View file

@ -55,6 +55,10 @@ go test -race ./internal/network ./internal/server ./internal/world \
-run 'Test(Integration|BoundaryEdit|PlayerInfo|PlayerRegistry|Concurrent|Incremental|EncodeLight|Cache|Store|Eviction|Region|Ticket|Streamer|LoadSixteen)'
# or run both gates:
make verify
# strict block/biome comparison; requires a fixture generated with Java 25:
go run ./cmd/vanillacapture -server server.jar
make parity
```
The integration suite exercises four clients across two visibility regions:
@ -80,8 +84,9 @@ an already admitted frame calculation completes atomically rather than being
interrupted halfway. Unowned clean chunks remain as an LRU warm cache until
capacity pressure evicts them. Structures, placed features, mob AI,
authentication, inventory, and survival mechanics remain intentionally partial.
The density router is vanilla-derived, while biome/surface/decoration layers
still contain approximations and require stricter parity fixtures.
The density router, configured carvers, and noise-router ore veins are
vanilla-derived. Surface and biome selection are ported but still need broader
runtime captures; ordinary decoration and structures remain approximations.
## Project layout

282
cmd/vanillacapture/main.go Normal file
View file

@ -0,0 +1,282 @@
// Command vanillacapture runs the official server for fixed chunks and writes a
// block-by-block parity fixture consumed by internal/world tests.
package main
import (
"bufio"
"context"
"encoding/binary"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"regionio/internal/world"
)
const fixtureMagic = "RIOPAR01"
type chunkPos struct{ x, z int32 }
func main() {
serverJar := flag.String("server", "server.jar", "Mojang bundler server.jar")
java := flag.String("java", "java", "Java 25 executable")
seed := flag.Int64("seed", 12345, "world seed")
chunksFlag := flag.String("chunks", "0,0;1,0;0,1;-1,-1", "semicolon-separated chunk coordinates")
output := flag.String("output", "internal/world/testdata/vanilla_overworld_12345.bin", "output fixture")
keep := flag.Bool("keep", false, "keep the temporary vanilla world")
flag.Parse()
chunks, err := parseChunks(*chunksFlag)
if err != nil {
fatal(err)
}
jar, err := filepath.Abs(*serverJar)
if err != nil {
fatal(err)
}
if _, err := os.Stat(jar); err != nil {
fatal(fmt.Errorf("server jar: %w", err))
}
if err := requireJava25(*java); err != nil {
fatal(err)
}
work, err := os.MkdirTemp("", "regionio-vanilla-capture-")
if err != nil {
fatal(err)
}
if !*keep {
defer os.RemoveAll(work)
} else {
fmt.Fprintf(os.Stderr, "vanilla workspace: %s\n", work)
}
if err := prepareServer(work, *seed); err != nil {
fatal(err)
}
if err := runServer(*java, jar, work, chunks); err != nil {
fatal(err)
}
if err := writeFixture(filepath.Join(work, "world"), *output, *seed, chunks); err != nil {
fatal(err)
}
fmt.Printf("wrote %s: seed %d, %d chunks\n", *output, *seed, len(chunks))
}
func requireJava25(java string) error {
cmd := exec.Command(java, "-version")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("Java 25 is required: %w", err)
}
version := string(out)
if !strings.Contains(version, `version "25`) && !strings.Contains(version, `openjdk 25`) {
return fmt.Errorf("Java 25 is required; %s -version returned %q", java, strings.TrimSpace(version))
}
return nil
}
func prepareServer(dir string, seed int64) error {
if err := os.WriteFile(filepath.Join(dir, "eula.txt"), []byte("eula=true\n"), 0o644); err != nil {
return err
}
properties := fmt.Sprintf("level-seed=%d\nonline-mode=false\nspawn-protection=0\nview-distance=2\nsimulation-distance=2\nmax-tick-time=-1\n", seed)
return os.WriteFile(filepath.Join(dir, "server.properties"), []byte(properties), 0o644)
}
func runServer(java, jar, dir string, chunks []chunkPos) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, java, "-Xms1G", "-Xmx2G", "-jar", jar, "nogui")
cmd.Dir = dir
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
output, err := cmd.StdoutPipe()
if err != nil {
return err
}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
return err
}
events := make(chan string, 4)
scanDone := make(chan error, 1)
go func() {
scanner := bufio.NewScanner(output)
for scanner.Scan() {
line := scanner.Text()
fmt.Println(line)
if strings.Contains(line, "Done (") || strings.Contains(line, "Saved the game") {
events <- line
}
}
close(events)
scanDone <- scanner.Err()
}()
if err := waitFor(events, "Done (", 5*time.Minute); err != nil {
_ = stdin.Close()
_ = cmd.Wait()
return fmt.Errorf("vanilla startup: %w", err)
}
for _, chunk := range chunks {
x, z := int64(chunk.x)*16, int64(chunk.z)*16
if _, err := fmt.Fprintf(stdin, "execute in minecraft:overworld run forceload add %d %d\n", x, z); err != nil {
return err
}
}
// Force-load tickets are processed asynchronously by the server tick. Give
// terrain, decoration, and lighting time to reach FULL before flushing.
time.Sleep(20 * time.Second)
if _, err := io.WriteString(stdin, "save-all flush\n"); err != nil {
return err
}
if err := waitFor(events, "Saved the game", 5*time.Minute); err != nil {
return fmt.Errorf("vanilla save: %w", err)
}
if _, err := io.WriteString(stdin, "stop\n"); err != nil {
return err
}
_ = stdin.Close()
if err := cmd.Wait(); err != nil {
return err
}
if err := <-scanDone; err != nil {
return err
}
return ctx.Err()
}
func waitFor(lines <-chan string, text string, timeout time.Duration) error {
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case line, ok := <-lines:
if !ok {
return errors.New("server exited before expected log message")
}
if strings.Contains(line, text) {
return nil
}
case <-timer.C:
return fmt.Errorf("timeout waiting for %q", text)
}
}
}
func writeFixture(worldDir, output string, seed int64, chunks []chunkPos) error {
store, err := world.NewStore(worldDir)
if err != nil {
return err
}
defer store.Close()
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
return err
}
tmp := output + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
ok := false
defer func() {
_ = f.Close()
if !ok {
_ = os.Remove(tmp)
}
}()
var header [24]byte
copy(header[:8], fixtureMagic)
binary.BigEndian.PutUint64(header[8:16], uint64(seed))
binary.BigEndian.PutUint32(header[16:20], uint32(len(chunks)))
binary.BigEndian.PutUint32(header[20:24], 4790)
if _, err := f.Write(header[:]); err != nil {
return err
}
var value [8]byte
for _, pos := range chunks {
chunk, err := store.LoadVanillaChunk(pos.x, pos.z)
if err != nil {
return fmt.Errorf("load vanilla chunk (%d,%d): %w", pos.x, pos.z, err)
}
binary.BigEndian.PutUint32(value[:4], uint32(pos.x))
binary.BigEndian.PutUint32(value[4:], uint32(pos.z))
if _, err := f.Write(value[:]); err != nil {
return err
}
for y := world.MinY; y < world.MinY+world.WorldHeight; y++ {
for z := 0; z < 16; z++ {
for x := 0; x < 16; x++ {
binary.BigEndian.PutUint16(value[:2], chunk.GetBlock(x, y, z))
if _, err := f.Write(value[:2]); err != nil {
return err
}
}
}
}
for y := world.MinY; y < world.MinY+world.WorldHeight; y += 4 {
for z := 0; z < 16; z += 4 {
for x := 0; x < 16; x += 4 {
binary.BigEndian.PutUint16(value[:2], chunk.GetBiome(x, y, z))
if _, err := f.Write(value[:2]); err != nil {
return err
}
}
}
}
}
if err := f.Sync(); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Rename(tmp, output); err != nil {
return err
}
ok = true
return nil
}
func parseChunks(raw string) ([]chunkPos, error) {
parts := strings.Split(raw, ";")
chunks := make([]chunkPos, 0, len(parts))
seen := make(map[chunkPos]bool)
for _, part := range parts {
coords := strings.Split(strings.TrimSpace(part), ",")
if len(coords) != 2 {
return nil, fmt.Errorf("invalid chunk %q", part)
}
x, err := strconv.ParseInt(strings.TrimSpace(coords[0]), 10, 32)
if err != nil {
return nil, err
}
z, err := strconv.ParseInt(strings.TrimSpace(coords[1]), 10, 32)
if err != nil {
return nil, err
}
pos := chunkPos{int32(x), int32(z)}
if !seen[pos] {
chunks = append(chunks, pos)
seen[pos] = true
}
}
if len(chunks) == 0 {
return nil, errors.New("no chunks requested")
}
return chunks, nil
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "vanillacapture:", err)
os.Exit(1)
}

View file

@ -10,12 +10,16 @@ var (
errTruncated = errors.New("nbt: truncated input")
errBadTag = errors.New("nbt: unknown tag id")
errNegativeLen = errors.New("nbt: negative length")
errTooDeep = errors.New("nbt: nesting exceeds limit")
)
const maxDecodeDepth = 512
// decoder walks a byte slice, tracking a cursor.
type decoder struct {
b []byte
pos int
b []byte
pos int
depth int
}
// Unmarshal decodes a network-format payload (unnamed root) into a Tag.
@ -135,12 +139,13 @@ func (d *decoder) payload(id byte) (Tag, error) {
if err != nil {
return nil, err
}
if err := d.need(int(int32(n))); err != nil {
count, err := d.count(n, 1)
if err != nil {
return nil, err
}
out := make(ByteArray, n)
copy(out, d.b[d.pos:d.pos+int(n)])
d.pos += int(n)
out := make(ByteArray, count)
copy(out, d.b[d.pos:d.pos+count])
d.pos += count
return out, nil
case TagString:
s, err := d.str()
@ -150,7 +155,11 @@ func (d *decoder) payload(id byte) (Tag, error) {
if err != nil {
return nil, err
}
out := make(IntArray, int32(n))
count, err := d.count(n, 4)
if err != nil {
return nil, err
}
out := make(IntArray, count)
for i := range out {
v, err := d.u32()
if err != nil {
@ -164,7 +173,11 @@ func (d *decoder) payload(id byte) (Tag, error) {
if err != nil {
return nil, err
}
out := make(LongArray, int32(n))
count, err := d.count(n, 8)
if err != nil {
return nil, err
}
out := make(LongArray, count)
for i := range out {
v, err := d.u64()
if err != nil {
@ -174,14 +187,34 @@ func (d *decoder) payload(id byte) (Tag, error) {
}
return out, nil
case TagList:
return d.list()
return d.container(d.list)
case TagCompound:
return d.compound()
return d.container(d.compound)
default:
return nil, errBadTag
}
}
func (d *decoder) count(n uint32, width int) (int, error) {
count := int64(int32(n))
if count < 0 {
return 0, errNegativeLen
}
if count*int64(width) > int64(len(d.b)-d.pos) {
return 0, errTruncated
}
return int(count), nil
}
func (d *decoder) container(decode func() (Tag, error)) (Tag, error) {
if d.depth >= maxDecodeDepth {
return nil, errTooDeep
}
d.depth++
defer func() { d.depth-- }()
return decode()
}
func (d *decoder) list() (Tag, error) {
elemID, err := d.u8()
if err != nil {
@ -191,9 +224,12 @@ func (d *decoder) list() (Tag, error) {
if err != nil {
return nil, err
}
count := int(int32(n))
if count < 0 {
return nil, errNegativeLen
count, err := d.count(n, 1) // every non-empty payload consumes at least one byte
if err != nil {
return nil, err
}
if elemID == TagEnd && count != 0 {
return nil, errBadTag
}
l := List{ElemID: elemID, Elems: make([]Tag, 0, count)}
for i := 0; i < count; i++ {

View file

@ -2,6 +2,7 @@ package nbt
import (
"bytes"
"encoding/binary"
"reflect"
"testing"
)
@ -29,10 +30,10 @@ func TestGoldenHelloWorld(t *testing.T) {
func TestNetworkRootHasNoName(t *testing.T) {
got := Marshal(NewCompound().Set("a", Byte(1)))
want := []byte{
0x0a, // TAG_Compound (no name)
0x01, 0x00, 0x01, 'a', // TAG_Byte, name len 1
0x01, // value 1
0x00, // TAG_End
0x0a, // TAG_Compound (no name)
0x01, 0x00, 0x01, 'a', // TAG_Byte, name len 1
0x01, // value 1
0x00, // TAG_End
}
if !bytes.Equal(got, want) {
t.Fatalf("network root mismatch:\n got=%x\nwant=%x", got, want)
@ -117,3 +118,39 @@ func TestTruncatedInput(t *testing.T) {
}
}
}
func TestDecodeRejectsOversizedArraysWithoutPanicking(t *testing.T) {
for _, id := range []byte{TagByteArray, TagIntArray, TagLongArray} {
input := []byte{id, 0, 0, 0, 0}
binary.BigEndian.PutUint32(input[1:], ^uint32(0))
if _, err := Unmarshal(input); err == nil {
t.Errorf("tag %d: accepted negative array length", id)
}
}
}
func TestDecodeRejectsArrayLargerThanInput(t *testing.T) {
input := []byte{TagLongArray, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 1}
if _, err := Unmarshal(input); err == nil {
t.Fatal("accepted two-long array with one long of input")
}
}
func TestDecodeNestingLimit(t *testing.T) {
input := []byte{TagList}
for i := 0; i <= maxDecodeDepth; i++ {
input = append(input, TagList, 0, 0, 0, 1)
}
input = append(input, TagByte, 0, 0, 0, 1, 0)
if _, err := Unmarshal(input); err == nil {
t.Fatal("accepted NBT deeper than the decoder limit")
}
}
func FuzzUnmarshalNeverPanics(f *testing.F) {
f.Add(Marshal(NewCompound().Set("value", Int(42))))
f.Add([]byte{TagLongArray, 0xff, 0xff, 0xff, 0xff})
f.Fuzz(func(t *testing.T, input []byte) {
_, _ = Unmarshal(input)
})
}

View file

@ -0,0 +1,67 @@
package network
import (
"log/slog"
"math"
"testing"
"regionio/internal/protocol"
"regionio/internal/server"
"regionio/internal/world"
)
func TestValidPlayerName(t *testing.T) {
for _, name := range []string{"Steve", "player_123", "A"} {
if !validPlayerName(name) {
t.Errorf("validPlayerName(%q) = false", name)
}
}
for _, name := range []string{"", "seventeen_chars_1", "player-name", "имя"} {
if validPlayerName(name) {
t.Errorf("validPlayerName(%q) = true", name)
}
}
}
func TestPlayerMoveRejectsCoordinatesOutsideWorld(t *testing.T) {
cfg := server.DefaultConfig()
cfg.WorldDir = ""
srv, err := server.NewWithCache(cfg, world.NewCache(-1, func(cx, cz int32) *world.Chunk {
return world.GenerateFlat(cx, cz)
}))
if err != nil {
t.Fatal(err)
}
session, err := srv.RegisterPlayer(server.Profile{Name: "Steve"}, nil)
if err != nil {
t.Fatal(err)
}
h := &handler{srv: srv, session: session, log: slog.Default()}
for _, position := range [][3]float64{
{maxPlayerXZ + 1, 0, 0},
{0, maxPlayerY + 1, 0},
{0, 0, -maxPlayerXZ - 1},
{math.NaN(), 0, 0},
} {
if err := h.onPlayerMove(position[0], position[1], position[2], 0, 0, true); err == nil {
t.Errorf("accepted position %v", position)
}
}
}
func TestKeepAliveRequiresPendingMatchingID(t *testing.T) {
h := &handler{log: slog.Default()}
w := protocol.NewWriter(8).Int64(42)
pkt := protocol.Packet{ID: protocol.PlayKeepAliveServer, Data: w.Bytes()}
if err := h.handlePlay(pkt); err == nil {
t.Fatal("accepted keep-alive response without a pending challenge")
}
h.keepAlivePending = true
h.keepAliveID = 42
if err := h.handlePlay(pkt); err != nil {
t.Fatalf("matching response: %v", err)
}
if h.keepAlivePending {
t.Fatal("matching response did not clear pending challenge")
}
}

View file

@ -4,6 +4,7 @@ package network
import (
"bufio"
"io"
"net"
"sync"
"time"
@ -12,6 +13,8 @@ import (
"regionio/internal/server"
)
const networkWriteTimeout = 30 * time.Second
// Conn wraps a TCP connection with buffered reads and tracks protocol state.
type Conn struct {
raw net.Conn
@ -70,6 +73,10 @@ func (c *Conn) ReadPacket() (protocol.Packet, error) {
func (c *Conn) Send(id int32, body []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := c.raw.SetWriteDeadline(time.Now().Add(networkWriteTimeout)); err != nil {
return err
}
defer c.raw.SetWriteDeadline(time.Time{})
return protocol.WritePacket(c.raw, c.compressionThreshold, id, body)
}
@ -84,8 +91,21 @@ func (c *Conn) SendWriter(id int32, w *protocol.Writer) error {
func (c *Conn) SendFramed(frame []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
_, err := c.raw.Write(frame)
return err
if err := c.raw.SetWriteDeadline(time.Now().Add(networkWriteTimeout)); err != nil {
return err
}
defer c.raw.SetWriteDeadline(time.Time{})
for len(frame) > 0 {
n, err := c.raw.Write(frame)
if err != nil {
return err
}
if n <= 0 || n > len(frame) {
return io.ErrShortWrite
}
frame = frame[n:]
}
return nil
}
// CompressionThreshold returns the active threshold (-1 if disabled).

View file

@ -6,6 +6,8 @@ import (
"io"
"log/slog"
"net"
"sync"
"time"
"regionio/internal/protocol"
"regionio/internal/server"
@ -27,11 +29,17 @@ type handler struct {
streamer *streamer
// viewDistance is the client's requested view distance (from
// client_information), clamped; used to size the streamer.
viewDistance int
session *server.PlayerSession
knownPlayers map[[16]byte]bool
knownEntities map[int32]visibleEntity
spawnY float64
viewDistance int
session *server.PlayerSession
knownPlayers map[[16]byte]bool
knownEntities map[int32]visibleEntity
spawnY float64
protocolVersion int32
keepAliveMu sync.Mutex
keepAlivePending bool
keepAliveID int64
keepAliveSent time.Time
// Creative inventory state for block placement.
heldSlot int32 // selected hotbar index (0-8)
@ -111,6 +119,7 @@ func (h *handler) handleHandshake(pkt protocol.Packet) error {
h.log.Debug("handshake",
"protocol", protoVer, "addr", addr, "port", port, "next", next)
h.protocolVersion = protoVer
switch next {
case protocol.NextStateStatus:

View file

@ -2,6 +2,7 @@ package network
import (
"errors"
"fmt"
"regionio/internal/protocol"
"regionio/internal/server"
@ -32,12 +33,15 @@ func (h *handler) handleLogin(pkt protocol.Packet) error {
}
func (h *handler) handleLoginStart(pkt protocol.Packet) error {
if h.protocolVersion != protocol.ProtocolVersion {
return fmt.Errorf("unsupported protocol %d, want %d", h.protocolVersion, protocol.ProtocolVersion)
}
r := pkt.Body()
name, err := r.String()
if err != nil {
return err
}
if name == "" || len(name) > 16 {
if !validPlayerName(name) {
return errors.New("invalid login name")
}
// The client also sends a UUID, but in offline mode we derive our own so it
@ -65,6 +69,18 @@ func (h *handler) handleLoginStart(pkt protocol.Packet) error {
return h.sendLoginSuccess()
}
func validPlayerName(name string) bool {
if len(name) == 0 || len(name) > 16 {
return false
}
for _, r := range name {
if r != '_' && (r < '0' || r > '9') && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
return false
}
}
return true
}
// sendLoginSuccess writes the Login Success packet. For protocol 775 the body
// is: UUID, Username, then a VarInt-prefixed array of profile properties (none
// in offline mode).

View file

@ -15,8 +15,10 @@ import (
// Spawn column. The feet-level Y is resolved from the generated surface when
// the player enters the play phase.
const (
spawnX = 8.5
spawnZ = 8.5
spawnX = 8.5
spawnZ = 8.5
maxPlayerXZ = 30_000_000.0
maxPlayerY = 20_000_000.0
)
// beginPlay sends the join sequence once the client enters the Play phase and
@ -107,6 +109,9 @@ func (h *handler) onPlayerMove(x, y, z float64, yaw, pitch float32, onGround boo
math.IsInf(float64(yaw), 0) || math.IsInf(float64(pitch), 0) {
return errors.New("invalid player position")
}
if math.Abs(x) > maxPlayerXZ || math.Abs(z) > maxPlayerXZ || math.Abs(y) > maxPlayerY {
return errors.New("player position outside world bounds")
}
h.srv.SetPlayerTransform(h.session, x, y, z, yaw, pitch, onGround)
cx := int32(int64(math.Floor(x)) >> 4)
cz := int32(int64(math.Floor(z)) >> 4)
@ -203,7 +208,21 @@ func (h *handler) keepAliveLoop() {
case <-h.ctx.Done():
return
case <-ticker.C:
h.keepAliveMu.Lock()
if h.keepAlivePending {
timedOut := time.Since(h.keepAliveSent) >= 30*time.Second
h.keepAliveMu.Unlock()
if timedOut {
_ = h.conn.Close()
return
}
continue
}
id := time.Now().UnixMilli()
h.keepAlivePending = true
h.keepAliveID = id
h.keepAliveSent = time.Now()
h.keepAliveMu.Unlock()
w := protocol.NewWriter(8)
w.Int64(id)
if err := h.conn.SendWriter(protocol.PlayKeepAliveCB, w); err != nil {
@ -434,8 +453,20 @@ func (h *handler) handlePlay(pkt protocol.Packet) error {
return nil
case protocol.PlayKeepAliveServer:
// A response to our keep-alive; presence is enough for liveness.
h.log.Debug("keep-alive ack")
id, err := pkt.Body().Int64()
if err != nil {
return err
}
h.keepAliveMu.Lock()
valid := h.keepAlivePending && id == h.keepAliveID
if valid {
h.keepAlivePending = false
}
h.keepAliveMu.Unlock()
if !valid {
return errors.New("unexpected keep-alive response")
}
h.log.Debug("keep-alive ack", "id", id)
return nil
case protocol.PlayPlayerLoaded:

View file

@ -5,6 +5,7 @@ import (
"errors"
"io"
"math"
"unicode/utf8"
)
// ErrShortBuffer is returned when a read would exceed the buffer's contents.
@ -119,6 +120,12 @@ func (r *Reader) String() (string, error) {
if err != nil {
return "", err
}
if !utf8.Valid(b) {
return "", ErrInvalidString
}
if utf8.RuneCount(b) > MaxStringLen {
return "", ErrStringTooLong
}
return string(b), nil
}

View file

@ -46,11 +46,11 @@ func ReadPacket(br *bufio.Reader, threshold int32) (Packet, error) {
if threshold < 0 {
return parseIDBody(frame)
}
return parseCompressed(frame)
return parseCompressed(frame, threshold)
}
// parseCompressed handles a frame that begins with a Data Length VarInt.
func parseCompressed(frame []byte) (Packet, error) {
func parseCompressed(frame []byte, threshold int32) (Packet, error) {
r := NewReader(frame)
dataLen, err := r.VarInt()
if err != nil {
@ -60,22 +60,40 @@ func parseCompressed(frame []byte) (Packet, error) {
if dataLen == 0 {
// Stored uncompressed.
if len(payload) >= int(threshold) {
return Packet{}, ErrBadCompression
}
return parseIDBody(payload)
}
if dataLen < 0 || int(dataLen) > MaxPacketSize {
return Packet{}, ErrPacketTooLarge
}
if dataLen < threshold {
return Packet{}, ErrBadCompression
}
zr, err := zlib.NewReader(bytes.NewReader(payload))
compressed := bytes.NewReader(payload)
zr, err := zlib.NewReader(compressed)
if err != nil {
return Packet{}, err
}
defer zr.Close()
if multistream, ok := zr.(interface{ Multistream(bool) }); ok {
multistream.Multistream(false)
}
out := make([]byte, dataLen)
if _, err := io.ReadFull(zr, out); err != nil {
zr.Close()
return Packet{}, err
}
var extra [1]byte
if n, err := zr.Read(extra[:]); n != 0 || err != io.EOF {
zr.Close()
return Packet{}, ErrBadCompression
}
if err := zr.Close(); err != nil || compressed.Len() != 0 {
return Packet{}, ErrBadCompression
}
return parseIDBody(out)
}
@ -92,8 +110,18 @@ func parseIDBody(buf []byte) (Packet, error) {
// WritePacket writes one frame to w with the given ID and body, using the
// uncompressed format when threshold < 0 and the compressed format otherwise.
func WritePacket(w io.Writer, threshold int32, id int32, body []byte) error {
_, err := w.Write(AppendPacket(nil, threshold, id, body))
return err
frame := AppendPacket(nil, threshold, id, body)
for len(frame) > 0 {
n, err := w.Write(frame)
if err != nil {
return err
}
if n <= 0 || n > len(frame) {
return io.ErrShortWrite
}
frame = frame[n:]
}
return nil
}
// AppendPacket appends one fully-framed packet to dst and returns the result.

View file

@ -0,0 +1,99 @@
package protocol
import (
"bufio"
"bytes"
"errors"
"io"
"testing"
)
func TestReadPacketCompressionThreshold(t *testing.T) {
for _, tc := range []struct {
name string
writeAt int32
readAt int32
wantError error
}{
{name: "compressed at threshold", writeAt: 4, readAt: 4},
{name: "compressed below threshold", writeAt: 4, readAt: 9, wantError: ErrBadCompression},
{name: "uncompressed below threshold", writeAt: 16, readAt: 16},
{name: "uncompressed at threshold", writeAt: 16, readAt: 4, wantError: ErrBadCompression},
} {
t.Run(tc.name, func(t *testing.T) {
frame := AppendPacket(nil, tc.writeAt, 3, []byte("payload"))
pkt, err := ReadPacket(bufio.NewReader(bytes.NewReader(frame)), tc.readAt)
if !errors.Is(err, tc.wantError) {
t.Fatalf("ReadPacket error = %v, want %v", err, tc.wantError)
}
if tc.wantError == nil && (pkt.ID != 3 || string(pkt.Data) != "payload") {
t.Fatalf("packet = id %d data %q", pkt.ID, pkt.Data)
}
})
}
}
func TestReadPacketRejectsWrongDecompressedLength(t *testing.T) {
frame := AppendPacket(nil, 1, 3, []byte("payload"))
r := NewReader(frame)
length, err := r.VarInt()
if err != nil {
t.Fatal(err)
}
payload := append([]byte(nil), frame[len(frame)-int(length):]...)
payload[0]++
bad := AppendVarInt(nil, int32(len(payload)))
bad = append(bad, payload...)
if _, err := ReadPacket(bufio.NewReader(bytes.NewReader(bad)), 1); err == nil {
t.Fatal("accepted compressed payload shorter than its declared length")
}
}
func TestReadPacketRejectsTrailingCompressedData(t *testing.T) {
frame := AppendPacket(nil, 1, 3, []byte("payload"))
r := NewReader(frame)
length, err := r.VarInt()
if err != nil {
t.Fatal(err)
}
payload := append([]byte(nil), frame[len(frame)-int(length):]...)
payload = append(payload, 0)
bad := AppendVarInt(nil, int32(len(payload)))
bad = append(bad, payload...)
if _, err := ReadPacket(bufio.NewReader(bytes.NewReader(bad)), 1); !errors.Is(err, ErrBadCompression) {
t.Fatalf("error = %v, want ErrBadCompression", err)
}
}
type shortWriter struct{ buf bytes.Buffer }
func (w *shortWriter) Write(p []byte) (int, error) {
if len(p) > 2 {
p = p[:2]
}
return w.buf.Write(p)
}
func TestWritePacketCompletesShortWrites(t *testing.T) {
w := new(shortWriter)
if err := WritePacket(w, -1, 7, []byte("body")); err != nil {
t.Fatal(err)
}
pkt, err := ReadPacket(bufio.NewReader(bytes.NewReader(w.buf.Bytes())), -1)
if err != nil {
t.Fatal(err)
}
if pkt.ID != 7 || string(pkt.Data) != "body" {
t.Fatalf("packet = id %d data %q", pkt.ID, pkt.Data)
}
}
type zeroWriter struct{}
func (zeroWriter) Write([]byte) (int, error) { return 0, nil }
func TestWritePacketRejectsNoProgress(t *testing.T) {
if err := WritePacket(zeroWriter{}, -1, 1, nil); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("error = %v, want io.ErrShortWrite", err)
}
}

View file

@ -0,0 +1,16 @@
package protocol
import (
"bufio"
"bytes"
"testing"
)
func FuzzReadPacketNeverPanics(f *testing.F) {
f.Add(AppendPacket(nil, -1, 0, nil))
f.Add(AppendPacket(nil, 1, 42, []byte("payload")))
f.Fuzz(func(t *testing.T, input []byte) {
_, _ = ReadPacket(bufio.NewReader(bytes.NewReader(input)), 256)
_, _ = ReadPacket(bufio.NewReader(bytes.NewReader(input)), -1)
})
}

View file

@ -64,8 +64,13 @@ var (
ErrVarIntTooBig = errors.New("protocol: varint is too big")
// ErrStringTooLong is returned when a string exceeds MaxStringLen.
ErrStringTooLong = errors.New("protocol: string too long")
// ErrInvalidString is returned for protocol strings that are not UTF-8.
ErrInvalidString = errors.New("protocol: invalid UTF-8 string")
// ErrPacketTooLarge is returned when a packet length exceeds MaxPacketSize.
ErrPacketTooLarge = errors.New("protocol: packet too large")
// ErrBadCompression is returned when a frame violates the negotiated
// compression threshold or its stream does not match the declared length.
ErrBadCompression = errors.New("protocol: invalid compressed packet")
)
// ReadVarInt reads a 32-bit VarInt from r, returning the value and the number

View file

@ -153,6 +153,9 @@ func validateConfig(cfg Config) error {
if cfg.MaxViewDistance < 2 || cfg.MaxViewDistance > 16 {
return fmt.Errorf("server: max view distance must be between 2 and 16")
}
if cfg.CompressionThreshold < -1 || cfg.CompressionThreshold > protocol.MaxPacketSize {
return fmt.Errorf("server: compression threshold must be -1 or between 0 and %d", protocol.MaxPacketSize)
}
return nil
}

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.

View file

@ -9,16 +9,13 @@ import "math"
// vanilla fitDistance metric.
//
// Coordinates are quantized to long via Math.round(v * 10000.0) exactly as the
// vanilla Climate.quantizeCoord does, and fitDistance is the sum of squared
// coordinate differences (no per-axis weighting) — matching the vanilla
// TargetPoint/ParameterPoint fitness. Range membership uses the inclusive-lower
// / exclusive-upper half-open convention vanilla applies to each axis band.
// vanilla Climate.quantizeCoord does. ParameterPoint fitness is the sum of the
// squared distance to each inclusive axis range and the squared offset.
// quantize converts a climate coordinate to its long representation. Vanilla's
// Climate.quantizeCoord is Math.round(v * 10000.0); Go's math.Round halves
// away from zero, matching Java for these inputs.
// quantize converts a climate coordinate to its long representation. Java's
// Math.round is floor(x+0.5), unlike Go's math.Round for negative half values.
func quantize(v float64) int64 {
return int64(math.Round(v * 10000.0))
return int64(math.Floor(v*10000.0 + 0.5))
}
// Quantize is the exported form of quantize, for the biome table builder in the
@ -38,39 +35,42 @@ type TargetPoint struct {
// NewTargetPoint quantizes six float climate coordinates into a TargetPoint.
func NewTargetPoint(temp, humid, cont, ero, weird, depth float64) TargetPoint {
return TargetPoint{
Temperature: quantize(temp),
Humidity: quantize(humid),
Temperature: quantize(temp),
Humidity: quantize(humid),
Continentalness: quantize(cont),
Erosion: quantize(ero),
Weirdness: quantize(weird),
Depth: quantize(depth),
Erosion: quantize(ero),
Weirdness: quantize(weird),
Depth: quantize(depth),
}
}
// fitDistance is the vanilla Climate.fitness metric: the sum of squared
// differences between two points across all six axes. The squared sum is the
// comparison key; smaller is a better match.
func fitDistance(a, b TargetPoint) int64 {
dx := a.Temperature - b.Temperature
dh := a.Humidity - b.Humidity
dc := a.Continentalness - b.Continentalness
de := a.Erosion - b.Erosion
dw := a.Weirdness - b.Weirdness
dd := a.Depth - b.Depth
return dx*dx + dh*dh + dc*dc + de*de + dw*dw + dd*dd
// fitDistance is the vanilla distance from a point to a parameter range. A
// coordinate inside a range contributes zero; offset is applied separately.
func fitDistance(point TargetPoint, ranges [AxisCount]ClimateRange, offset int64) int64 {
values := [AxisCount]int64{point.Temperature, point.Humidity, point.Continentalness, point.Erosion, point.Weirdness, point.Depth}
var total int64
for i, value := range values {
r := ranges[i]
var distance int64
if value < r.Min {
distance = r.Min - value
} else if value > r.Max {
distance = value - r.Max
}
total += distance * distance
}
return total + offset*offset
}
// ClimateRange is one axis's [min, max] half-open band on a biome parameter.
// ClimateRange is one axis's inclusive [min, max] band on a biome parameter.
type ClimateRange struct {
Min, Max int64
}
// contains reports whether the quantized coordinate v falls in [min, max).
func (r ClimateRange) contains(v int64) bool { return v >= r.Min && v < r.Max }
// contains reports whether the quantized coordinate v falls in [min, max].
func (r ClimateRange) contains(v int64) bool { return v >= r.Min && v <= r.Max }
// BiomeParameter is one biome entry's full climate signature plus its name.
// Each axis is a half-open range; offset is the extra depth offset (always 0 in
// the overworld surface table, but kept for parity/future cave biomes).
type BiomeParameter struct {
Name string
// ranges[0..5] = temperature, humidity, continentalness, erosion, weirdness, depth.
@ -78,73 +78,38 @@ type BiomeParameter struct {
Offset int64
}
// paramCentre returns the centre of the entry's climate ranges as a TargetPoint
// (depth centre folded in). Pre-computing this once lets the finder compare by
// distance to the centre, then verify range membership — mirroring how the
// vanilla finder prunes by fitness then tests the band.
func (p *BiomeParameter) centre() TargetPoint {
mid := func(r ClimateRange) int64 { return (r.Min + r.Max) / 2 }
return TargetPoint{
Temperature: mid(p.Ranges[0]),
Humidity: mid(p.Ranges[1]),
Continentalness: mid(p.Ranges[2]),
Erosion: mid(p.Ranges[3]),
Weirdness: mid(p.Ranges[4]),
Depth: mid(p.Ranges[5]),
}
}
// ParameterTable is the set of biome parameters the finder searches.
type ParameterTable struct {
entries []tableEntry
}
// tableEntry pairs a parameter with its precomputed centre for fast pruning.
type tableEntry struct {
param BiomeParameter
centre TargetPoint
param BiomeParameter
}
// NewParameterTable builds a searchable table from raw biome parameters.
func NewParameterTable(params []BiomeParameter) *ParameterTable {
t := &ParameterTable{entries: make([]tableEntry, len(params))}
for i, p := range params {
t.entries[i] = tableEntry{param: p, centre: p.centre()}
t.entries[i] = tableEntry{param: p}
}
return t
}
// FindBiome returns the name of the biome whose range best matches point, by
// the vanilla fitDistance metric among entries whose ranges all contain point.
// If no entry's ranges contain point (should not happen for the overworld table,
// which tiles climate space), it falls back to the nearest centre.
// FindBiome returns the parameter with the lowest vanilla fitness. Table order
// is the deterministic tie breaker because equal fitness never replaces best.
func (t *ParameterTable) FindBiome(point TargetPoint) string {
var best string
bestDist := int64(math.MaxInt64)
var fallback string
fallbackDist := int64(math.MaxInt64)
for _, e := range t.entries {
// Distance to centre is the pruning key (precomputed). Track it always
// so we have a fallback if no range contains the point.
d := fitDistance(point, e.centre)
if d < fallbackDist {
fallbackDist = d
fallback = e.param.Name
}
// Only consider entries whose ranges actually contain the point.
if !containsAll(e.param.Ranges, point) {
continue
}
d := fitDistance(point, e.param.Ranges, e.param.Offset)
if d < bestDist {
bestDist = d
best = e.param.Name
}
}
if best != "" {
return best
}
return fallback
return best
}
// containsAll reports whether every range contains its corresponding coordinate.

View file

@ -15,6 +15,8 @@ func TestQuantize(t *testing.T) {
{1.0, 10000},
{-0.15, -1500},
{0.55, 5500},
{0.00005, 1},
{-0.00005, 0},
}
for _, c := range cases {
if got := quantize(c.v); got != c.want {
@ -23,17 +25,41 @@ func TestQuantize(t *testing.T) {
}
}
func TestParameterTableDistanceOffsetAndTies(t *testing.T) {
pointRange := func(value int64) [AxisCount]ClimateRange {
var ranges [AxisCount]ClimateRange
for i := range ranges {
ranges[i] = ClimateRange{Min: 0, Max: 0}
}
ranges[0] = ClimateRange{Min: value, Max: value}
return ranges
}
point := TargetPoint{Temperature: 5}
table := NewParameterTable([]BiomeParameter{
{Name: "offset-wins", Ranges: pointRange(0), Offset: 0}, // fitness 25
{Name: "range-loses", Ranges: pointRange(5), Offset: 10}, // fitness 100
{Name: "same-fitness-later", Ranges: pointRange(10), Offset: 0}, // fitness 25
})
if got := table.FindBiome(point); got != "offset-wins" {
t.Fatalf("FindBiome = %q, want first minimum-fitness entry", got)
}
if got := fitDistance(point, pointRange(5), 0); got != 0 {
t.Fatalf("point inside exact range has fitness %d", got)
}
}
// TestFitDistanceZero confirms identical points are zero-distance and distinct
// points are positive; the exact value is not asserted to stay robust to
// representation choices.
func TestFitDistance(t *testing.T) {
a := NewTargetPoint(0, 0, 0, 0, 0, 0)
if got := fitDistance(a, a); got != 0 {
ranges := [AxisCount]ClimateRange{}
if got := fitDistance(a, ranges, 0); got != 0 {
t.Errorf("fitDistance(a,a) = %d, want 0", got)
}
b := NewTargetPoint(1, 0, 0, 0, 0, 0)
// 10000^2 per axis of difference.
if got := fitDistance(a, b); got != 10000*10000 {
if got := fitDistance(b, ranges, 0); got != 10000*10000 {
t.Errorf("fitDistance for 1.0 temp diff = %d, want %d", got, int64(10000*10000))
}
}
@ -44,8 +70,8 @@ func TestRangeContains(t *testing.T) {
if !r.contains(0) {
t.Error("min should be inclusive")
}
if r.contains(100) {
t.Error("max should be exclusive")
if !r.contains(100) {
t.Error("max should be inclusive")
}
if !r.contains(50) {
t.Error("interior should contain")