diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..7c33996 --- /dev/null +++ b/.github/workflows/verify.yml @@ -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 ./... diff --git a/CLAUDE.md b/CLAUDE.md index 1710f4a..1524d87 100644 --- a/CLAUDE.md +++ b/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. diff --git a/Makefile b/Makefile index 20b9287..b53f982 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index 503c883..2c5005c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/vanillacapture/main.go b/cmd/vanillacapture/main.go new file mode 100644 index 0000000..51f3dec --- /dev/null +++ b/cmd/vanillacapture/main.go @@ -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) +} diff --git a/internal/nbt/decode.go b/internal/nbt/decode.go index 41c6eb2..f8f5aba 100644 --- a/internal/nbt/decode.go +++ b/internal/nbt/decode.go @@ -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++ { diff --git a/internal/nbt/nbt_test.go b/internal/nbt/nbt_test.go index ea767f4..4fce800 100644 --- a/internal/nbt/nbt_test.go +++ b/internal/nbt/nbt_test.go @@ -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) + }) +} diff --git a/internal/network/boundary_test.go b/internal/network/boundary_test.go new file mode 100644 index 0000000..2a8b988 --- /dev/null +++ b/internal/network/boundary_test.go @@ -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") + } +} diff --git a/internal/network/conn.go b/internal/network/conn.go index b0a3f81..b24d143 100644 --- a/internal/network/conn.go +++ b/internal/network/conn.go @@ -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). diff --git a/internal/network/handler.go b/internal/network/handler.go index 765ddb1..8d6e4ed 100644 --- a/internal/network/handler.go +++ b/internal/network/handler.go @@ -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: diff --git a/internal/network/login.go b/internal/network/login.go index 2428a40..3d6b915 100644 --- a/internal/network/login.go +++ b/internal/network/login.go @@ -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). diff --git a/internal/network/play.go b/internal/network/play.go index cb763dc..5a49973 100644 --- a/internal/network/play.go +++ b/internal/network/play.go @@ -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: diff --git a/internal/protocol/buffer.go b/internal/protocol/buffer.go index e3035cd..f62e6b6 100644 --- a/internal/protocol/buffer.go +++ b/internal/protocol/buffer.go @@ -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 } diff --git a/internal/protocol/frame.go b/internal/protocol/frame.go index bcdde6b..69046e1 100644 --- a/internal/protocol/frame.go +++ b/internal/protocol/frame.go @@ -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. diff --git a/internal/protocol/frame_test.go b/internal/protocol/frame_test.go new file mode 100644 index 0000000..a639f19 --- /dev/null +++ b/internal/protocol/frame_test.go @@ -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) + } +} diff --git a/internal/protocol/fuzz_test.go b/internal/protocol/fuzz_test.go new file mode 100644 index 0000000..a0593d7 --- /dev/null +++ b/internal/protocol/fuzz_test.go @@ -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) + }) +} diff --git a/internal/protocol/types.go b/internal/protocol/types.go index e1528ed..0827099 100644 --- a/internal/protocol/types.go +++ b/internal/protocol/types.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index a8ab016..6d60817 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 } diff --git a/internal/world/biome_lookup.go b/internal/world/biome_lookup.go index dd7c85f..0c61627 100644 --- a/internal/world/biome_lookup.go +++ b/internal/world/biome_lookup.go @@ -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), } diff --git a/internal/world/regionfile.go b/internal/world/regionfile.go index 7773f74..c8db5db 100644 --- a/internal/world/regionfile.go +++ b/internal/world/regionfile.go @@ -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 diff --git a/internal/world/store.go b/internal/world/store.go index a3d1a3d..b8617a4 100644 --- a/internal/world/store.go +++ b/internal/world/store.go @@ -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)<= 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 } diff --git a/internal/world/store_test.go b/internal/world/store_test.go index 8fcb96a..43742ed 100644 --- a/internal/world/store_test.go +++ b/internal/world/store_test.go @@ -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) diff --git a/internal/world/vanilla_parity_test.go b/internal/world/vanilla_parity_test.go index 65c5817..4f3b21c 100644 --- a/internal/world/vanilla_parity_test.go +++ b/internal/world/vanilla_parity_test.go @@ -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. diff --git a/internal/worldgen/biome.go b/internal/worldgen/biome.go index a708754..87c4afc 100644 --- a/internal/worldgen/biome.go +++ b/internal/worldgen/biome.go @@ -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. diff --git a/internal/worldgen/biome_test.go b/internal/worldgen/biome_test.go index 3d38f18..aa8f21d 100644 --- a/internal/worldgen/biome_test.go +++ b/internal/worldgen/biome_test.go @@ -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")