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
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue