RegionIO/cmd/vanillacapture/main.go

282 lines
7.3 KiB
Go

// 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)
}