diff --git a/cmd/vanillacapture/main.go b/cmd/vanillacapture/main.go index aee4ada..8bc2b2d 100644 --- a/cmd/vanillacapture/main.go +++ b/cmd/vanillacapture/main.go @@ -3,9 +3,12 @@ package main import ( + "archive/zip" "bufio" + "bytes" "context" "encoding/binary" + "encoding/json" "errors" "flag" "fmt" @@ -20,7 +23,10 @@ import ( "regionio/internal/world" ) -const fixtureMagic = "RIOPAR02" +const ( + fixtureMagic = "RIOPAR02" + baseFixtureMagic = "RIOBASE1" +) type chunkPos struct{ x, z int32 } @@ -31,6 +37,9 @@ func main() { 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") + featureless := flag.Bool("featureless", false, "install a derived datapack that empties biome features and structure sets, capturing base terrain only") + blocksOnly := flag.Bool("blocks-only", false, "write a blocks-only fixture (RIOBASE1) without biome cells or heightmaps") + port := flag.Int("port", 25565, "server listen port; change it when 25565 is taken on this host") flag.Parse() chunks, err := parseChunks(*chunksFlag) @@ -57,15 +66,27 @@ func main() { } else { fmt.Fprintf(os.Stderr, "vanilla workspace: %s\n", work) } - if err := prepareServer(work, *seed); err != nil { + if *featureless { + packs := filepath.Join(work, "world", "datapacks") + if err := installFeaturelessDatapack(jar, packs); err != nil { + fatal(err) + } + } + if err := prepareServer(work, *seed, *port); err != nil { fatal(err) } if err := runServer(*java, jar, work, chunks); err != nil { fatal(err) } overworld := filepath.Join(work, "world", "dimensions", "minecraft", "overworld") - if err := writeFixture(overworld, *output, *seed, chunks); err != nil { - fatal(err) + if *blocksOnly { + if err := writeBaseFixture(overworld, *output, *seed, chunks); err != nil { + fatal(err) + } + } else { + if err := writeFixture(overworld, *output, *seed, chunks); err != nil { + fatal(err) + } } fmt.Printf("wrote %s: seed %d, %d chunks\n", *output, *seed, len(chunks)) } @@ -83,11 +104,11 @@ func requireJava25(java string) error { return nil } -func prepareServer(dir string, seed int64) error { +func prepareServer(dir string, seed int64, port int) 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) + properties := fmt.Sprintf("level-seed=%d\nonline-mode=false\nspawn-protection=0\nview-distance=2\nsimulation-distance=2\nmax-tick-time=-1\nserver-port=%d\n", seed, port) return os.WriteFile(filepath.Join(dir, "server.properties"), []byte(properties), 0o644) } @@ -260,6 +281,197 @@ func writeFixture(worldDir, output string, seed int64, chunks []chunkPos) error return nil } +func writeBaseFixture(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 [16]byte + copy(header[:8], baseFixtureMagic) + binary.BigEndian.PutUint64(header[8:16], uint64(seed)) + 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 + } + } + } + } + } + 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 +} + +// installFeaturelessDatapack derives a world datapack from the server jar's own +// worldgen data: every biome keeps its carvers but loses its feature stages, +// and every structure set loses its structures. A server started with the pack +// installed therefore generates noise terrain with surface rules, carvers, +// aquifers, and noise-router veins — and nothing else — which is exactly the +// ground truth the undecorated side of our pipeline should reproduce. +// +// Both jar shapes work: the deobfuscated inner server, whose data/ entries are +// read directly, and the official bundler, which stores that same inner server +// under META-INF/versions/. +func installFeaturelessDatapack(jar, packsDir string) error { + r, err := zip.OpenReader(jar) + if err != nil { + return err + } + defer r.Close() + + type source struct { + biomes map[string][]byte + sets map[string][]byte + } + read := func(entries []*zip.File) (map[string][]byte, map[string][]byte, error) { + biomes := make(map[string][]byte) + sets := make(map[string][]byte) + for _, entry := range entries { + dir, name := filepath.Split(filepath.ToSlash(entry.Name)) + if !strings.HasSuffix(name, ".json") { + continue + } + raw, err := readZipEntry(entry) + if err != nil { + return nil, nil, err + } + switch dir { + case "data/minecraft/worldgen/biome/": + biomes[name] = raw + case "data/minecraft/worldgen/structure_set/": + sets[name] = raw + } + } + return biomes, sets, nil + } + + topBiomes, topSets, err := read(r.File) + if err != nil { + return err + } + biomes, sets := topBiomes, topSets + if len(biomes) == 0 { + for _, entry := range r.File { + if !strings.HasPrefix(entry.Name, "META-INF/versions/") || !strings.HasSuffix(entry.Name, ".jar") { + continue + } + nestedRaw, err := readZipEntry(entry) + if err != nil { + return err + } + nested, err := zip.NewReader(bytes.NewReader(nestedRaw), int64(len(nestedRaw))) + if err != nil { + return fmt.Errorf("%s: %w", entry.Name, err) + } + nestedBiomes, nestedSets, err := read(nested.File) + if err != nil { + return err + } + if len(nestedBiomes) > 0 { + biomes, sets = nestedBiomes, nestedSets + break + } + } + } + if len(biomes) == 0 { + return errors.New("no biome JSONs found in server jar; is this a Mojang server jar?") + } + + root := filepath.Join(packsDir, "regionio_featureless") + biomeDir := filepath.Join(root, "data", "minecraft", "worldgen", "biome") + setDir := filepath.Join(root, "data", "minecraft", "worldgen", "structure_set") + if err := os.MkdirAll(biomeDir, 0o755); err != nil { + return err + } + if err := os.MkdirAll(setDir, 0o755); err != nil { + return err + } + mcmeta := `{"pack":{"description":"RegionIO base-terrain capture: features and structure sets removed","min_format":[101,1],"max_format":101}}` + if err := os.WriteFile(filepath.Join(root, "pack.mcmeta"), []byte(mcmeta), 0o644); err != nil { + return err + } + for name, raw := range biomes { + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("biome %s: %w", name, err) + } + doc["features"] = []any{} + out, err := json.Marshal(doc) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(biomeDir, name), out, 0o644); err != nil { + return err + } + } + for name, raw := range sets { + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("structure set %s: %w", name, err) + } + doc["structures"] = []any{} + out, err := json.Marshal(doc) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(setDir, name), out, 0o644); err != nil { + return err + } + } + fmt.Fprintf(os.Stderr, "featureless datapack: %d biomes, %d structure sets\n", len(biomes), len(sets)) + return nil +} + +func readZipEntry(entry *zip.File) ([]byte, error) { + rc, err := entry.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) +} + func parseChunks(raw string) ([]chunkPos, error) { parts := strings.Split(raw, ";") chunks := make([]chunkPos, 0, len(parts))