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 ./...
|
go build ./... && go vet ./...
|
||||||
make test # go test ./...
|
make test # go test ./...
|
||||||
make test-race # the race-sensitive subset
|
make test-race # go test -race ./...
|
||||||
make verify # both
|
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 # 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/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/gendump # client-free generator diagnostics
|
||||||
|
go run ./cmd/vanillacapture # regenerate vanilla block parity fixture (Java 25)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Hard rules
|
## 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
|
The whole `noise_router` is parsed. `preliminary_surface_level` is reachable through
|
||||||
`od.PreliminarySurfaceLevelAt`, which quart-aligns and memoises across chunks the way `NoiseChunk`
|
`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
|
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
|
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:
|
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
|
- **No generic placed/configured feature system.** Configured caves, canyons, and noise-router ore
|
||||||
not extracted and the `OreVeinifier` over the parsed `vein_*` keys is not written.
|
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
|
- **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
|
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`,
|
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
|
`go test -race` needs cgo and a C toolchain; on a Windows box without gcc, `make test-race` cannot
|
||||||
run at all.
|
run at all.
|
||||||
|
|
||||||
`internal/world/vanilla_parity_test.go` compares surface heights against a capture from the official
|
`cmd/vanillacapture` runs the official bundler jar in an isolated temporary world, force-loads fixed
|
||||||
server and skips when the capture is absent. Note it reads a hardcoded `/tmp` path, so on Windows it
|
chunks, reads their region files, and writes `internal/world/testdata/vanilla_overworld_12345.bin`.
|
||||||
never runs. A capture is produced by running the vanilla server headless at a known seed and reading
|
The fixture contains every block state and 4x4x4 biome cell. Java 25 is required. `make parity`
|
||||||
its region files back with our own `regionfile.go` + `nbt`.
|
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:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
test-race:
|
test-race:
|
||||||
go test -race ./internal/network ./internal/server ./internal/world \
|
go test -race ./...
|
||||||
-run 'Test(Integration|BoundaryEdit|PlayerInfo|PlayerRegistry|Concurrent|Incremental|EncodeLight|Cache|Store|Eviction|Region|Ticket|Streamer|LoadSixteen)'
|
|
||||||
|
|
||||||
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)'
|
-run 'Test(Integration|BoundaryEdit|PlayerInfo|PlayerRegistry|Concurrent|Incremental|EncodeLight|Cache|Store|Eviction|Region|Ticket|Streamer|LoadSixteen)'
|
||||||
# or run both gates:
|
# or run both gates:
|
||||||
make verify
|
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:
|
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
|
interrupted halfway. Unowned clean chunks remain as an LRU warm cache until
|
||||||
capacity pressure evicts them. Structures, placed features, mob AI,
|
capacity pressure evicts them. Structures, placed features, mob AI,
|
||||||
authentication, inventory, and survival mechanics remain intentionally partial.
|
authentication, inventory, and survival mechanics remain intentionally partial.
|
||||||
The density router is vanilla-derived, while biome/surface/decoration layers
|
The density router, configured carvers, and noise-router ore veins are
|
||||||
still contain approximations and require stricter parity fixtures.
|
vanilla-derived. Surface and biome selection are ported but still need broader
|
||||||
|
runtime captures; ordinary decoration and structures remain approximations.
|
||||||
|
|
||||||
## Project layout
|
## 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")
|
errTruncated = errors.New("nbt: truncated input")
|
||||||
errBadTag = errors.New("nbt: unknown tag id")
|
errBadTag = errors.New("nbt: unknown tag id")
|
||||||
errNegativeLen = errors.New("nbt: negative length")
|
errNegativeLen = errors.New("nbt: negative length")
|
||||||
|
errTooDeep = errors.New("nbt: nesting exceeds limit")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxDecodeDepth = 512
|
||||||
|
|
||||||
// decoder walks a byte slice, tracking a cursor.
|
// decoder walks a byte slice, tracking a cursor.
|
||||||
type decoder struct {
|
type decoder struct {
|
||||||
b []byte
|
b []byte
|
||||||
pos int
|
pos int
|
||||||
|
depth int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unmarshal decodes a network-format payload (unnamed root) into a Tag.
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := d.need(int(int32(n))); err != nil {
|
count, err := d.count(n, 1)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make(ByteArray, n)
|
out := make(ByteArray, count)
|
||||||
copy(out, d.b[d.pos:d.pos+int(n)])
|
copy(out, d.b[d.pos:d.pos+count])
|
||||||
d.pos += int(n)
|
d.pos += count
|
||||||
return out, nil
|
return out, nil
|
||||||
case TagString:
|
case TagString:
|
||||||
s, err := d.str()
|
s, err := d.str()
|
||||||
|
|
@ -150,7 +155,11 @@ func (d *decoder) payload(id byte) (Tag, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
for i := range out {
|
||||||
v, err := d.u32()
|
v, err := d.u32()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -164,7 +173,11 @@ func (d *decoder) payload(id byte) (Tag, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
for i := range out {
|
||||||
v, err := d.u64()
|
v, err := d.u64()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -174,14 +187,34 @@ func (d *decoder) payload(id byte) (Tag, error) {
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
case TagList:
|
case TagList:
|
||||||
return d.list()
|
return d.container(d.list)
|
||||||
case TagCompound:
|
case TagCompound:
|
||||||
return d.compound()
|
return d.container(d.compound)
|
||||||
default:
|
default:
|
||||||
return nil, errBadTag
|
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) {
|
func (d *decoder) list() (Tag, error) {
|
||||||
elemID, err := d.u8()
|
elemID, err := d.u8()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -191,9 +224,12 @@ func (d *decoder) list() (Tag, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
count := int(int32(n))
|
count, err := d.count(n, 1) // every non-empty payload consumes at least one byte
|
||||||
if count < 0 {
|
if err != nil {
|
||||||
return nil, errNegativeLen
|
return nil, err
|
||||||
|
}
|
||||||
|
if elemID == TagEnd && count != 0 {
|
||||||
|
return nil, errBadTag
|
||||||
}
|
}
|
||||||
l := List{ElemID: elemID, Elems: make([]Tag, 0, count)}
|
l := List{ElemID: elemID, Elems: make([]Tag, 0, count)}
|
||||||
for i := 0; i < count; i++ {
|
for i := 0; i < count; i++ {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package nbt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
@ -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 (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -12,6 +13,8 @@ import (
|
||||||
"regionio/internal/server"
|
"regionio/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const networkWriteTimeout = 30 * time.Second
|
||||||
|
|
||||||
// Conn wraps a TCP connection with buffered reads and tracks protocol state.
|
// Conn wraps a TCP connection with buffered reads and tracks protocol state.
|
||||||
type Conn struct {
|
type Conn struct {
|
||||||
raw net.Conn
|
raw net.Conn
|
||||||
|
|
@ -70,6 +73,10 @@ func (c *Conn) ReadPacket() (protocol.Packet, error) {
|
||||||
func (c *Conn) Send(id int32, body []byte) error {
|
func (c *Conn) Send(id int32, body []byte) error {
|
||||||
c.writeMu.Lock()
|
c.writeMu.Lock()
|
||||||
defer c.writeMu.Unlock()
|
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)
|
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 {
|
func (c *Conn) SendFramed(frame []byte) error {
|
||||||
c.writeMu.Lock()
|
c.writeMu.Lock()
|
||||||
defer c.writeMu.Unlock()
|
defer c.writeMu.Unlock()
|
||||||
_, err := c.raw.Write(frame)
|
if err := c.raw.SetWriteDeadline(time.Now().Add(networkWriteTimeout)); err != nil {
|
||||||
return err
|
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).
|
// CompressionThreshold returns the active threshold (-1 if disabled).
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"regionio/internal/protocol"
|
"regionio/internal/protocol"
|
||||||
"regionio/internal/server"
|
"regionio/internal/server"
|
||||||
|
|
@ -32,6 +34,12 @@ type handler struct {
|
||||||
knownPlayers map[[16]byte]bool
|
knownPlayers map[[16]byte]bool
|
||||||
knownEntities map[int32]visibleEntity
|
knownEntities map[int32]visibleEntity
|
||||||
spawnY float64
|
spawnY float64
|
||||||
|
protocolVersion int32
|
||||||
|
|
||||||
|
keepAliveMu sync.Mutex
|
||||||
|
keepAlivePending bool
|
||||||
|
keepAliveID int64
|
||||||
|
keepAliveSent time.Time
|
||||||
|
|
||||||
// Creative inventory state for block placement.
|
// Creative inventory state for block placement.
|
||||||
heldSlot int32 // selected hotbar index (0-8)
|
heldSlot int32 // selected hotbar index (0-8)
|
||||||
|
|
@ -111,6 +119,7 @@ func (h *handler) handleHandshake(pkt protocol.Packet) error {
|
||||||
|
|
||||||
h.log.Debug("handshake",
|
h.log.Debug("handshake",
|
||||||
"protocol", protoVer, "addr", addr, "port", port, "next", next)
|
"protocol", protoVer, "addr", addr, "port", port, "next", next)
|
||||||
|
h.protocolVersion = protoVer
|
||||||
|
|
||||||
switch next {
|
switch next {
|
||||||
case protocol.NextStateStatus:
|
case protocol.NextStateStatus:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"regionio/internal/protocol"
|
"regionio/internal/protocol"
|
||||||
"regionio/internal/server"
|
"regionio/internal/server"
|
||||||
|
|
@ -32,12 +33,15 @@ func (h *handler) handleLogin(pkt protocol.Packet) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) handleLoginStart(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()
|
r := pkt.Body()
|
||||||
name, err := r.String()
|
name, err := r.String()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if name == "" || len(name) > 16 {
|
if !validPlayerName(name) {
|
||||||
return errors.New("invalid login name")
|
return errors.New("invalid login name")
|
||||||
}
|
}
|
||||||
// The client also sends a UUID, but in offline mode we derive our own so it
|
// 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()
|
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
|
// sendLoginSuccess writes the Login Success packet. For protocol 775 the body
|
||||||
// is: UUID, Username, then a VarInt-prefixed array of profile properties (none
|
// is: UUID, Username, then a VarInt-prefixed array of profile properties (none
|
||||||
// in offline mode).
|
// in offline mode).
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import (
|
||||||
const (
|
const (
|
||||||
spawnX = 8.5
|
spawnX = 8.5
|
||||||
spawnZ = 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
|
// 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) {
|
math.IsInf(float64(yaw), 0) || math.IsInf(float64(pitch), 0) {
|
||||||
return errors.New("invalid player position")
|
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)
|
h.srv.SetPlayerTransform(h.session, x, y, z, yaw, pitch, onGround)
|
||||||
cx := int32(int64(math.Floor(x)) >> 4)
|
cx := int32(int64(math.Floor(x)) >> 4)
|
||||||
cz := int32(int64(math.Floor(z)) >> 4)
|
cz := int32(int64(math.Floor(z)) >> 4)
|
||||||
|
|
@ -203,7 +208,21 @@ func (h *handler) keepAliveLoop() {
|
||||||
case <-h.ctx.Done():
|
case <-h.ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
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()
|
id := time.Now().UnixMilli()
|
||||||
|
h.keepAlivePending = true
|
||||||
|
h.keepAliveID = id
|
||||||
|
h.keepAliveSent = time.Now()
|
||||||
|
h.keepAliveMu.Unlock()
|
||||||
w := protocol.NewWriter(8)
|
w := protocol.NewWriter(8)
|
||||||
w.Int64(id)
|
w.Int64(id)
|
||||||
if err := h.conn.SendWriter(protocol.PlayKeepAliveCB, w); err != nil {
|
if err := h.conn.SendWriter(protocol.PlayKeepAliveCB, w); err != nil {
|
||||||
|
|
@ -434,8 +453,20 @@ func (h *handler) handlePlay(pkt protocol.Packet) error {
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case protocol.PlayKeepAliveServer:
|
case protocol.PlayKeepAliveServer:
|
||||||
// A response to our keep-alive; presence is enough for liveness.
|
id, err := pkt.Body().Int64()
|
||||||
h.log.Debug("keep-alive ack")
|
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
|
return nil
|
||||||
|
|
||||||
case protocol.PlayPlayerLoaded:
|
case protocol.PlayPlayerLoaded:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrShortBuffer is returned when a read would exceed the buffer's contents.
|
// 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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if !utf8.Valid(b) {
|
||||||
|
return "", ErrInvalidString
|
||||||
|
}
|
||||||
|
if utf8.RuneCount(b) > MaxStringLen {
|
||||||
|
return "", ErrStringTooLong
|
||||||
|
}
|
||||||
return string(b), nil
|
return string(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,11 @@ func ReadPacket(br *bufio.Reader, threshold int32) (Packet, error) {
|
||||||
if threshold < 0 {
|
if threshold < 0 {
|
||||||
return parseIDBody(frame)
|
return parseIDBody(frame)
|
||||||
}
|
}
|
||||||
return parseCompressed(frame)
|
return parseCompressed(frame, threshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCompressed handles a frame that begins with a Data Length VarInt.
|
// 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)
|
r := NewReader(frame)
|
||||||
dataLen, err := r.VarInt()
|
dataLen, err := r.VarInt()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -60,22 +60,40 @@ func parseCompressed(frame []byte) (Packet, error) {
|
||||||
|
|
||||||
if dataLen == 0 {
|
if dataLen == 0 {
|
||||||
// Stored uncompressed.
|
// Stored uncompressed.
|
||||||
|
if len(payload) >= int(threshold) {
|
||||||
|
return Packet{}, ErrBadCompression
|
||||||
|
}
|
||||||
return parseIDBody(payload)
|
return parseIDBody(payload)
|
||||||
}
|
}
|
||||||
if dataLen < 0 || int(dataLen) > MaxPacketSize {
|
if dataLen < 0 || int(dataLen) > MaxPacketSize {
|
||||||
return Packet{}, ErrPacketTooLarge
|
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 {
|
if err != nil {
|
||||||
return Packet{}, err
|
return Packet{}, err
|
||||||
}
|
}
|
||||||
defer zr.Close()
|
if multistream, ok := zr.(interface{ Multistream(bool) }); ok {
|
||||||
|
multistream.Multistream(false)
|
||||||
|
}
|
||||||
|
|
||||||
out := make([]byte, dataLen)
|
out := make([]byte, dataLen)
|
||||||
if _, err := io.ReadFull(zr, out); err != nil {
|
if _, err := io.ReadFull(zr, out); err != nil {
|
||||||
|
zr.Close()
|
||||||
return Packet{}, err
|
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)
|
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
|
// WritePacket writes one frame to w with the given ID and body, using the
|
||||||
// uncompressed format when threshold < 0 and the compressed format otherwise.
|
// uncompressed format when threshold < 0 and the compressed format otherwise.
|
||||||
func WritePacket(w io.Writer, threshold int32, id int32, body []byte) error {
|
func WritePacket(w io.Writer, threshold int32, id int32, body []byte) error {
|
||||||
_, err := w.Write(AppendPacket(nil, threshold, id, body))
|
frame := AppendPacket(nil, threshold, id, body)
|
||||||
|
for len(frame) > 0 {
|
||||||
|
n, err := w.Write(frame)
|
||||||
|
if err != nil {
|
||||||
return err
|
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.
|
// 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")
|
ErrVarIntTooBig = errors.New("protocol: varint is too big")
|
||||||
// ErrStringTooLong is returned when a string exceeds MaxStringLen.
|
// ErrStringTooLong is returned when a string exceeds MaxStringLen.
|
||||||
ErrStringTooLong = errors.New("protocol: string too long")
|
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 is returned when a packet length exceeds MaxPacketSize.
|
||||||
ErrPacketTooLarge = errors.New("protocol: packet too large")
|
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
|
// 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 {
|
if cfg.MaxViewDistance < 2 || cfg.MaxViewDistance > 16 {
|
||||||
return fmt.Errorf("server: max view distance must be between 2 and 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ var biomeParametersJSON []byte
|
||||||
// rawParameter mirrors one entry of biome_parameters.json: a biome name plus its
|
// 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
|
// 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,
|
// 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 {
|
type rawParameter struct {
|
||||||
Biome string `json:"biome"`
|
Biome string `json:"biome"`
|
||||||
Param struct {
|
Param struct {
|
||||||
|
|
@ -31,21 +31,20 @@ type rawParameter struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// depthRange extracts a depth band from a raw entry. It accepts a JSON number
|
// 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
|
// (mapped to the exact inclusive range [v,v]), a single-element [v] array, or a
|
||||||
// integer depth layer), a single-element [v] array (same as the scalar), or a
|
|
||||||
// two-element [min, max] range (used by cave biomes like lush/dripstone_caves
|
// 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.
|
// whose depth is [0.2, 0.9]). Returns ok=false only for malformed input.
|
||||||
func depthRange(v any) (worldgen.ClimateRange, bool) {
|
func depthRange(v any) (worldgen.ClimateRange, bool) {
|
||||||
switch d := v.(type) {
|
switch d := v.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
q := worldgen.Quantize(d)
|
q := worldgen.Quantize(d)
|
||||||
return worldgen.ClimateRange{Min: q, Max: q + 1}, true
|
return worldgen.ClimateRange{Min: q, Max: q}, true
|
||||||
case []any:
|
case []any:
|
||||||
switch len(d) {
|
switch len(d) {
|
||||||
case 1:
|
case 1:
|
||||||
if f, ok := d[0].(float64); ok {
|
if f, ok := d[0].(float64); ok {
|
||||||
q := worldgen.Quantize(f)
|
q := worldgen.Quantize(f)
|
||||||
return worldgen.ClimateRange{Min: q, Max: q + 1}, true
|
return worldgen.ClimateRange{Min: q, Max: q}, true
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
lo, ok1 := d[0].(float64)
|
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 +
|
// biomeTable is the full biome parameter table (surface + underground twins +
|
||||||
// cave biomes), built once at init. The finder's range-contains check on the
|
// cave biomes), built once at init.
|
||||||
// depth axis selects the correct layer per cell.
|
|
||||||
var (
|
var (
|
||||||
biomeTable *worldgen.ParameterTable
|
biomeTable *worldgen.ParameterTable
|
||||||
biomeTableOnce sync.Once
|
biomeTableOnce sync.Once
|
||||||
|
|
@ -92,7 +90,7 @@ func loadBiomeTable() *worldgen.ParameterTable {
|
||||||
|
|
||||||
// makeBiomeParameter converts a raw JSON entry into a BiomeParameter, mapping
|
// makeBiomeParameter converts a raw JSON entry into a BiomeParameter, mapping
|
||||||
// the [min,max] ranges to quantized ClimateRanges. depth is a ClimateRange
|
// 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 {
|
func makeBiomeParameter(e rawParameter, depth worldgen.ClimateRange) worldgen.BiomeParameter {
|
||||||
qr := func(a [2]float64) worldgen.ClimateRange {
|
qr := func(a [2]float64) worldgen.ClimateRange {
|
||||||
return worldgen.ClimateRange{Min: worldgen.Quantize(a[0]), Max: worldgen.Quantize(a[1])}
|
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.Continentalness),
|
||||||
qr(e.Param.Erosion),
|
qr(e.Param.Erosion),
|
||||||
qr(e.Param.Weirdness),
|
qr(e.Param.Weirdness),
|
||||||
depth, // half-open band (scalar) or explicit range (cave biomes)
|
depth,
|
||||||
},
|
},
|
||||||
Offset: worldgen.Quantize(e.Param.Offset),
|
Offset: worldgen.Quantize(e.Param.Offset),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,26 +147,9 @@ func (r *RegionFile) WriteChunk(localX, localZ int, nbt []byte) error {
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
idx := locationIndex(localX, localZ)
|
idx := locationIndex(localX, localZ)
|
||||||
old := r.offsets[idx]
|
// Always use copy-on-write. Reusing the published allocation would let a
|
||||||
oldSectors := 0
|
// crash during WriteAt corrupt the only readable copy of the chunk.
|
||||||
if old != 0 {
|
offset := r.endSectorLocked()
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the on-disk record: length + compression byte + compressed data,
|
// Build the on-disk record: length + compression byte + compressed data,
|
||||||
// zero-padded to a sector boundary.
|
// 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 {
|
if _, err := r.f.WriteAt(rec, off); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Publish the new location only after the complete record is durable. A
|
||||||
// Update the offset table and timestamp, then persist both tables.
|
// crash before this sync leaves an unreachable tail and the old slot intact.
|
||||||
r.offsets[idx] = uint32(offset<<8) | uint32(sectorsNeeded)
|
if err := r.f.Sync(); err != nil {
|
||||||
if err := r.writeTablesLocked(); err != nil {
|
|
||||||
return err
|
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.
|
// 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.
|
// i.e. where new chunk data can be appended. Caller holds r.mu.
|
||||||
func (r *RegionFile) endSectorLocked() int {
|
func (r *RegionFile) endSectorLocked() int {
|
||||||
maxUsed := headerSectors
|
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 {
|
for _, loc := range r.offsets {
|
||||||
if loc == 0 {
|
if loc == 0 {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -32,7 +33,7 @@ const dataVersion26 = 4790
|
||||||
// first time it ran: chunkAt prefers the store over the generator, so the
|
// 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
|
// 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.
|
// 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
|
// generatorVersionTag is the NBT key holding generatorVersion. It is namespaced
|
||||||
// because it is ours, not part of the vanilla chunk format.
|
// 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
|
// LoadChunk reads and decodes the chunk at (cx, cz). It returns ErrChunkNotFound
|
||||||
// when the chunk is not stored.
|
// when the chunk is not stored.
|
||||||
func (s *Store) LoadChunk(cx, cz int32) (*Chunk, error) {
|
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)
|
rx, rz, lx, lz := regionIndex(cx, cz)
|
||||||
rf, err := s.regionFor(cx, cz)
|
rf, err := s.regionFor(cx, cz)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -250,7 +262,7 @@ func (s *Store) LoadChunk(cx, cz int32) (*Chunk, error) {
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("world: chunk (%d,%d) root is not a compound", cx, cz)
|
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.
|
// 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);
|
// absolute coordinates are derived from the on-disk xPos/zPos (authoritative);
|
||||||
// the region/local coords passed in are used only to validate.
|
// the region/local coords passed in are used only to validate.
|
||||||
func nbtToChunk(root *nbt.Compound, regionX, regionZ, localX, localZ int) (*Chunk, error) {
|
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
|
// Reject anything the current generator did not produce so the caller
|
||||||
// regenerates instead of serving stale terrain. Chunks written before the
|
// regenerates instead of serving stale terrain. Chunks written before the
|
||||||
// stamp existed have no tag and decode as 0, so they are invalidated too.
|
// 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,
|
// 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
|
// which is a hard mismatch, while a generator change is routine and should
|
||||||
// quietly regenerate rather than refuse to open the world.
|
// 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
|
return nil, ErrChunkNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,28 +524,40 @@ func nbtToChunk(root *nbt.Compound, regionX, regionZ, localX, localZ int) (*Chun
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sections.
|
secTag, ok := root.Get("sections")
|
||||||
if secTag, ok := root.Get("sections"); ok {
|
if !ok {
|
||||||
if secList, ok := secTag.(nbt.List); ok && secList.ElemID == nbt.TagCompound {
|
return nil, errors.New("world: chunk NBT missing sections")
|
||||||
for _, st := range secList.Elems {
|
}
|
||||||
|
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)
|
sc, ok := st.(*nbt.Compound)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return nil, fmt.Errorf("world: section %d is not a compound", index)
|
||||||
}
|
}
|
||||||
yIdx, ok := nbtAsSectionY(sc, "Y")
|
yIdx, ok := nbtAsSectionY(sc, "Y")
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return nil, fmt.Errorf("world: section %d has no valid Y", index)
|
||||||
}
|
}
|
||||||
si := yIdx - minYSection
|
si := yIdx - minYSection
|
||||||
if si < 0 || si >= SectionCount {
|
if si < 0 || si >= SectionCount {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
readBlockStates(c, si, sc)
|
if seenSections[si] {
|
||||||
readBiomes(c, si, sc)
|
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)
|
readLightSection(c, si, sc)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
return c, nil
|
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
|
// 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
|
// chunk's section array. A palette of size 1 fills the whole section; otherwise
|
||||||
// the packed data array is unpacked.
|
// 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")
|
bsTag, ok := sc.Get("block_states")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return errors.New("missing block_states")
|
||||||
}
|
}
|
||||||
bs, ok := bsTag.(*nbt.Compound)
|
bs, ok := bsTag.(*nbt.Compound)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return errors.New("block_states is not a compound")
|
||||||
}
|
}
|
||||||
palTag, ok := bs.Get("palette")
|
palTag, ok := bs.Get("palette")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return errors.New("missing palette")
|
||||||
}
|
}
|
||||||
pal, ok := palTag.(nbt.List)
|
pal, ok := palTag.(nbt.List)
|
||||||
if !ok || pal.ElemID != nbt.TagCompound {
|
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.
|
// Decode palette entries to state IDs.
|
||||||
ids := make([]uint16, len(pal.Elems))
|
ids := make([]uint16, len(pal.Elems))
|
||||||
for i, e := range pal.Elems {
|
for i, e := range pal.Elems {
|
||||||
ec, ok := e.(*nbt.Compound)
|
ec, ok := e.(*nbt.Compound)
|
||||||
if !ok {
|
if !ok {
|
||||||
ids[i] = StateAir
|
return fmt.Errorf("palette entry %d is not a compound", i)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
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)
|
props := readProps(ec)
|
||||||
// An unknown block name decodes to air rather than to a neighbour's
|
var resolved bool
|
||||||
// state; that loses the block but does not corrupt the column.
|
ids[i], resolved = nameToStateID(name, props)
|
||||||
ids[i], _ = nameToStateID(name, props)
|
if !resolved {
|
||||||
|
return fmt.Errorf("unknown block state %q", name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.section(si) // ensure allocated
|
c.section(si) // ensure allocated
|
||||||
s := c.sections[si]
|
s := c.sections[si]
|
||||||
|
|
@ -594,36 +634,55 @@ func readBlockStates(c *Chunk, si int, sc *nbt.Compound) {
|
||||||
fill[i] = ids[0]
|
fill[i] = ids[0]
|
||||||
}
|
}
|
||||||
c.sections[si] = &fill
|
c.sections[si] = &fill
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
if dataTag, ok := bs.Get("data"); ok {
|
dataTag, ok := bs.Get("data")
|
||||||
if data, ok := dataTag.(nbt.LongArray); ok {
|
if !ok {
|
||||||
unpackIndices(s[:], ids, data, blockStorageBits(len(ids)))
|
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.
|
// 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")
|
bTag, ok := sc.Get("biomes")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return errors.New("missing biomes")
|
||||||
}
|
}
|
||||||
bc, ok := bTag.(*nbt.Compound)
|
bc, ok := bTag.(*nbt.Compound)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return errors.New("biomes is not a compound")
|
||||||
}
|
}
|
||||||
palTag, ok := bc.Get("palette")
|
palTag, ok := bc.Get("palette")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return errors.New("missing palette")
|
||||||
}
|
}
|
||||||
pal, ok := palTag.(nbt.List)
|
pal, ok := palTag.(nbt.List)
|
||||||
if !ok || pal.ElemID != nbt.TagString {
|
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))
|
ids := make([]uint16, len(pal.Elems))
|
||||||
for i, e := range 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 {
|
if len(ids) == 1 {
|
||||||
cells := new([biomeCellsPerSection]uint16)
|
cells := new([biomeCellsPerSection]uint16)
|
||||||
|
|
@ -631,15 +690,26 @@ func readBiomes(c *Chunk, si int, sc *nbt.Compound) {
|
||||||
cells[i] = ids[0]
|
cells[i] = ids[0]
|
||||||
}
|
}
|
||||||
c.biomes[si] = cells
|
c.biomes[si] = cells
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
if dataTag, ok := bc.Get("data"); ok {
|
dataTag, ok := bc.Get("data")
|
||||||
if data, ok := dataTag.(nbt.LongArray); ok {
|
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)
|
cells := new([biomeCellsPerSection]uint16)
|
||||||
unpackIndices(cells[:], ids, data, biomeStorageBits(len(ids)))
|
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
|
c.biomes[si] = cells
|
||||||
}
|
return nil
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readProps(c *nbt.Compound) map[string]string {
|
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
|
// unpackIndices reverses packIndices: fills dst with palette IDs using the
|
||||||
// packed long array.
|
// 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 {
|
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
|
perLong := 64 / bits
|
||||||
mask := int64(1)<<uint(bits) - 1
|
mask := int64(1)<<uint(bits) - 1
|
||||||
for i := range dst {
|
for i := range dst {
|
||||||
longIdx := i / perLong
|
longIdx := i / perLong
|
||||||
bitOff := (i % perLong) * bits
|
bitOff := (i % perLong) * bits
|
||||||
if longIdx >= len(data) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
idx := int((data[longIdx] >> uint(bitOff)) & mask)
|
idx := int((data[longIdx] >> uint(bitOff)) & mask)
|
||||||
if idx >= 0 && idx < len(ids) {
|
if idx < 0 || idx >= len(ids) {
|
||||||
|
return fmt.Errorf("palette index %d out of range %d", idx, len(ids))
|
||||||
|
}
|
||||||
dst[i] = ids[idx]
|
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
|
// TestStoreChunkRoundTrip encodes a chunk to NBT, decodes it back, and confirms
|
||||||
// the blocks/biomes match. This validates the chunkToNBT/nbtToChunk bridge.
|
// the blocks/biomes match. This validates the chunkToNBT/nbtToChunk bridge.
|
||||||
func TestStoreChunkRoundTrip(t *testing.T) {
|
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) {
|
func TestStoreLightRoundTrip(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
store, err := NewStore(dir)
|
store, err := NewStore(dir)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package world
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -9,6 +11,73 @@ import (
|
||||||
"testing"
|
"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
|
// TestVanillaParity compares our generated surface heights against heights
|
||||||
// captured from the official server (seed 12345, normal terrain). Requires
|
// captured from the official server (seed 12345, normal terrain). Requires
|
||||||
// /tmp/vanilla_ground.json from the capture step.
|
// /tmp/vanilla_ground.json from the capture step.
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,13 @@ import "math"
|
||||||
// vanilla fitDistance metric.
|
// vanilla fitDistance metric.
|
||||||
//
|
//
|
||||||
// Coordinates are quantized to long via Math.round(v * 10000.0) exactly as the
|
// 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
|
// vanilla Climate.quantizeCoord does. ParameterPoint fitness is the sum of the
|
||||||
// coordinate differences (no per-axis weighting) — matching the vanilla
|
// squared distance to each inclusive axis range and the squared offset.
|
||||||
// TargetPoint/ParameterPoint fitness. Range membership uses the inclusive-lower
|
|
||||||
// / exclusive-upper half-open convention vanilla applies to each axis band.
|
|
||||||
|
|
||||||
// quantize converts a climate coordinate to its long representation. Vanilla's
|
// quantize converts a climate coordinate to its long representation. Java's
|
||||||
// Climate.quantizeCoord is Math.round(v * 10000.0); Go's math.Round halves
|
// Math.round is floor(x+0.5), unlike Go's math.Round for negative half values.
|
||||||
// away from zero, matching Java for these inputs.
|
|
||||||
func quantize(v float64) int64 {
|
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
|
// Quantize is the exported form of quantize, for the biome table builder in the
|
||||||
|
|
@ -47,30 +44,33 @@ func NewTargetPoint(temp, humid, cont, ero, weird, depth float64) TargetPoint {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fitDistance is the vanilla Climate.fitness metric: the sum of squared
|
// fitDistance is the vanilla distance from a point to a parameter range. A
|
||||||
// differences between two points across all six axes. The squared sum is the
|
// coordinate inside a range contributes zero; offset is applied separately.
|
||||||
// comparison key; smaller is a better match.
|
func fitDistance(point TargetPoint, ranges [AxisCount]ClimateRange, offset int64) int64 {
|
||||||
func fitDistance(a, b TargetPoint) int64 {
|
values := [AxisCount]int64{point.Temperature, point.Humidity, point.Continentalness, point.Erosion, point.Weirdness, point.Depth}
|
||||||
dx := a.Temperature - b.Temperature
|
var total int64
|
||||||
dh := a.Humidity - b.Humidity
|
for i, value := range values {
|
||||||
dc := a.Continentalness - b.Continentalness
|
r := ranges[i]
|
||||||
de := a.Erosion - b.Erosion
|
var distance int64
|
||||||
dw := a.Weirdness - b.Weirdness
|
if value < r.Min {
|
||||||
dd := a.Depth - b.Depth
|
distance = r.Min - value
|
||||||
return dx*dx + dh*dh + dc*dc + de*de + dw*dw + dd*dd
|
} 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 {
|
type ClimateRange struct {
|
||||||
Min, Max int64
|
Min, Max int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// contains reports whether the quantized coordinate v falls in [min, 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 }
|
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.
|
// 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 {
|
type BiomeParameter struct {
|
||||||
Name string
|
Name string
|
||||||
// ranges[0..5] = temperature, humidity, continentalness, erosion, weirdness, depth.
|
// ranges[0..5] = temperature, humidity, continentalness, erosion, weirdness, depth.
|
||||||
|
|
@ -78,73 +78,38 @@ type BiomeParameter struct {
|
||||||
Offset int64
|
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.
|
// ParameterTable is the set of biome parameters the finder searches.
|
||||||
type ParameterTable struct {
|
type ParameterTable struct {
|
||||||
entries []tableEntry
|
entries []tableEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// tableEntry pairs a parameter with its precomputed centre for fast pruning.
|
|
||||||
type tableEntry struct {
|
type tableEntry struct {
|
||||||
param BiomeParameter
|
param BiomeParameter
|
||||||
centre TargetPoint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewParameterTable builds a searchable table from raw biome parameters.
|
// NewParameterTable builds a searchable table from raw biome parameters.
|
||||||
func NewParameterTable(params []BiomeParameter) *ParameterTable {
|
func NewParameterTable(params []BiomeParameter) *ParameterTable {
|
||||||
t := &ParameterTable{entries: make([]tableEntry, len(params))}
|
t := &ParameterTable{entries: make([]tableEntry, len(params))}
|
||||||
for i, p := range params {
|
for i, p := range params {
|
||||||
t.entries[i] = tableEntry{param: p, centre: p.centre()}
|
t.entries[i] = tableEntry{param: p}
|
||||||
}
|
}
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindBiome returns the name of the biome whose range best matches point, by
|
// FindBiome returns the parameter with the lowest vanilla fitness. Table order
|
||||||
// the vanilla fitDistance metric among entries whose ranges all contain point.
|
// is the deterministic tie breaker because equal fitness never replaces best.
|
||||||
// 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.
|
|
||||||
func (t *ParameterTable) FindBiome(point TargetPoint) string {
|
func (t *ParameterTable) FindBiome(point TargetPoint) string {
|
||||||
var best string
|
var best string
|
||||||
bestDist := int64(math.MaxInt64)
|
bestDist := int64(math.MaxInt64)
|
||||||
var fallback string
|
|
||||||
fallbackDist := int64(math.MaxInt64)
|
|
||||||
|
|
||||||
for _, e := range t.entries {
|
for _, e := range t.entries {
|
||||||
// Distance to centre is the pruning key (precomputed). Track it always
|
d := fitDistance(point, e.param.Ranges, e.param.Offset)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
if d < bestDist {
|
if d < bestDist {
|
||||||
bestDist = d
|
bestDist = d
|
||||||
best = e.param.Name
|
best = e.param.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if best != "" {
|
|
||||||
return best
|
return best
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// containsAll reports whether every range contains its corresponding coordinate.
|
// containsAll reports whether every range contains its corresponding coordinate.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ func TestQuantize(t *testing.T) {
|
||||||
{1.0, 10000},
|
{1.0, 10000},
|
||||||
{-0.15, -1500},
|
{-0.15, -1500},
|
||||||
{0.55, 5500},
|
{0.55, 5500},
|
||||||
|
{0.00005, 1},
|
||||||
|
{-0.00005, 0},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := quantize(c.v); got != c.want {
|
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
|
// TestFitDistanceZero confirms identical points are zero-distance and distinct
|
||||||
// points are positive; the exact value is not asserted to stay robust to
|
// points are positive; the exact value is not asserted to stay robust to
|
||||||
// representation choices.
|
// representation choices.
|
||||||
func TestFitDistance(t *testing.T) {
|
func TestFitDistance(t *testing.T) {
|
||||||
a := NewTargetPoint(0, 0, 0, 0, 0, 0)
|
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)
|
t.Errorf("fitDistance(a,a) = %d, want 0", got)
|
||||||
}
|
}
|
||||||
b := NewTargetPoint(1, 0, 0, 0, 0, 0)
|
b := NewTargetPoint(1, 0, 0, 0, 0, 0)
|
||||||
// 10000^2 per axis of difference.
|
// 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))
|
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) {
|
if !r.contains(0) {
|
||||||
t.Error("min should be inclusive")
|
t.Error("min should be inclusive")
|
||||||
}
|
}
|
||||||
if r.contains(100) {
|
if !r.contains(100) {
|
||||||
t.Error("max should be exclusive")
|
t.Error("max should be inclusive")
|
||||||
}
|
}
|
||||||
if !r.contains(50) {
|
if !r.contains(50) {
|
||||||
t.Error("interior should contain")
|
t.Error("interior should contain")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue