Add vanilla parity harness and harden server boundaries
This commit is contained in:
parent
1924cb5591
commit
ca019756ec
25 changed files with 1118 additions and 217 deletions
31
.github/workflows/verify.yml
vendored
Normal file
31
.github/workflows/verify.yml
vendored
Normal 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 ./...
|
||||
23
CLAUDE.md
23
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
17
Makefile
17
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
282
cmd/vanillacapture/main.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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++ {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
67
internal/network/boundary_test.go
Normal file
67
internal/network/boundary_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
99
internal/protocol/frame_test.go
Normal file
99
internal/protocol/frame_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
16
internal/protocol/fuzz_test.go
Normal file
16
internal/protocol/fuzz_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue