Pin feature ordering to vanilla runtime

This commit is contained in:
Daniar Mannanov 2026-08-11 09:29:17 +03:00
parent a73b689449
commit e9fe526300
5 changed files with 264 additions and 1 deletions

View file

@ -62,6 +62,7 @@ func depthRange(v any) (worldgen.ClimateRange, bool) {
var (
biomeTable *worldgen.ParameterTable
biomeTableOnce sync.Once
biomeOrder []string
)
// loadBiomeTable parses the embedded biome parameters once and returns the full
@ -76,18 +77,28 @@ func loadBiomeTable() *worldgen.ParameterTable {
panic(fmt.Sprintf("world: parsing embedded biome_parameters.json: %v", err))
}
params := make([]worldgen.BiomeParameter, 0, len(raw.Biomes))
seen := make(map[string]bool)
for _, e := range raw.Biomes {
dp, ok := depthRange(e.Param.Depth)
if !ok {
continue // malformed depth; skip defensively
}
params = append(params, makeBiomeParameter(e, dp))
if !seen[e.Biome] {
seen[e.Biome] = true
biomeOrder = append(biomeOrder, e.Biome)
}
}
biomeTable = worldgen.NewParameterTable(params)
})
return biomeTable
}
func possibleBiomeOrder() []string {
loadBiomeTable()
return biomeOrder
}
// makeBiomeParameter converts a raw JSON entry into a BiomeParameter, mapping
// the [min,max] ranges to quantized ClimateRanges. depth is a ClimateRange
// (exact range for scalar depths, explicit range for cave biomes).

View file

@ -0,0 +1,28 @@
package world
import (
"slices"
"testing"
"regionio/internal/worldgen"
)
func TestOverworldFeatureStepsBuildWithoutCycles(t *testing.T) {
set, err := worldgen.LoadFeatureSet()
if err != nil {
t.Fatal(err)
}
steps, err := set.FeatureSteps(possibleBiomeOrder())
if err != nil {
t.Fatal(err)
}
if len(steps) != 11 {
t.Fatalf("feature steps = %d, want 11", len(steps))
}
ores := steps[undergroundOresStage]
for _, name := range []string{"minecraft:ore_clay", "minecraft:ore_diamond", "minecraft:ore_tuff"} {
if !slices.Contains(ores, name) {
t.Errorf("underground ores missing %s", name)
}
}
}

View file

@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"path"
"sort"
"strings"
"sync"
)
@ -22,6 +23,11 @@ type FeatureSet struct {
BlockTags map[string][]string
}
type IndexedFeature struct {
Name string
Index int
}
type ConfiguredFeature struct {
Type string
Config json.RawMessage
@ -210,6 +216,105 @@ func LoadFeatureSet() (*FeatureSet, error) {
return featureSet, featureSetErr
}
// FeatureSteps mirrors FeatureSorter.buildFeaturesPerStep. biomeOrder must be
// BiomeSource.possibleBiomes encounter order because it assigns the stable
// identity used to break otherwise unconstrained graph ties.
func (s *FeatureSet) FeatureSteps(biomeOrder []string) ([][]string, error) {
type node struct {
stage, identity int
name string
}
identities := make(map[string]int)
nodes := make(map[[2]int]node)
edges := make(map[[2]int]map[[2]int]bool)
maxStages := 0
for _, biomeName := range biomeOrder {
biome, ok := s.Biomes[biomeName]
if !ok {
continue
}
if len(biome.Features) > maxStages {
maxStages = len(biome.Features)
}
var previous [2]int
hasPrevious := false
for stage, names := range biome.Features {
for _, name := range names {
identity, ok := identities[name]
if !ok {
identity = len(identities)
identities[name] = identity
}
key := [2]int{stage, identity}
nodes[key] = node{stage: stage, identity: identity, name: name}
if hasPrevious {
if edges[previous] == nil {
edges[previous] = make(map[[2]int]bool)
}
edges[previous][key] = true
}
previous, hasPrevious = key, true
}
}
}
less := func(a, b [2]int) bool {
return a[0] < b[0] || a[0] == b[0] && a[1] < b[1]
}
keys := make([][2]int, 0, len(nodes))
for key := range nodes {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) })
visited := make(map[[2]int]bool)
visiting := make(map[[2]int]bool)
ordered := make([][2]int, 0, len(keys))
var visit func([2]int) error
visit = func(key [2]int) error {
if visited[key] {
return nil
}
if visiting[key] {
return fmt.Errorf("worldgen: feature order cycle")
}
visiting[key] = true
children := make([][2]int, 0, len(edges[key]))
for child := range edges[key] {
children = append(children, child)
}
sort.Slice(children, func(i, j int) bool { return less(children[i], children[j]) })
for _, child := range children {
if err := visit(child); err != nil {
return err
}
}
delete(visiting, key)
visited[key] = true
ordered = append(ordered, key)
return nil
}
for _, key := range keys {
if err := visit(key); err != nil {
return nil, err
}
}
steps := make([][]string, maxStages)
for i := len(ordered) - 1; i >= 0; i-- {
n := nodes[ordered[i]]
steps[n.stage] = append(steps[n.stage], n.name)
}
return steps, nil
}
func IndexedFeatures(step []string, wanted map[string]bool) []IndexedFeature {
result := make([]IndexedFeature, 0, len(wanted))
for index, name := range step {
if wanted[name] {
result = append(result, IndexedFeature{Name: name, Index: index})
}
}
return result
}
func (s *FeatureSet) Ore(name string) (OreFeatureConfig, error) {
configured, ok := s.Configured[name]
if !ok || configured.Type != "minecraft:ore" {

View file

@ -1,6 +1,9 @@
package worldgen
import "testing"
import (
"reflect"
"testing"
)
func TestFeatureDatapackLoadsAndLinks(t *testing.T) {
set, err := LoadFeatureSet()
@ -42,3 +45,38 @@ func TestFeatureDatapackLoadsAndLinks(t *testing.T) {
t.Fatalf("spring lava placement = %+v, err=%v", lavaPlan, err)
}
}
func TestFeatureStepsAgainstVanillaRuntimeVectors(t *testing.T) {
set := &FeatureSet{Biomes: map[string]BiomeGeneration{
"a": {Features: [][]string{{"f1"}, {"f3", "f4"}}},
"b": {Features: [][]string{{"f2", "f1"}, {"f5", "f4"}}},
}}
for _, test := range []struct {
name string
order []string
want [][]string
}{
{"ab", []string{"a", "b"}, [][]string{{"f2", "f1"}, {"f5", "f3", "f4"}}},
{"ba", []string{"b", "a"}, [][]string{{"f2", "f1"}, {"f3", "f5", "f4"}}},
} {
t.Run(test.name, func(t *testing.T) {
got, err := set.FeatureSteps(test.order)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("steps = %v, want %v", got, test.want)
}
})
}
}
func TestFeatureStepsRejectsCycles(t *testing.T) {
set := &FeatureSet{Biomes: map[string]BiomeGeneration{
"a": {Features: [][]string{{"f1", "f2"}}},
"b": {Features: [][]string{{"f2", "f1"}}},
}}
if _, err := set.FeatureSteps([]string{"a", "b"}); err == nil {
t.Fatal("cyclic feature order succeeded")
}
}

View file

@ -0,0 +1,81 @@
import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import net.minecraft.SharedConstants;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.biome.BiomeGenerationSettings;
import net.minecraft.world.level.biome.FeatureSorter;
import net.minecraft.world.level.levelgen.feature.ConfiguredFeature;
import net.minecraft.world.level.levelgen.feature.Feature;
import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration;
import net.minecraft.world.level.levelgen.placement.CountPlacement;
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
// Emits FeatureSorter vectors from the official 26.1.2 runtime. The graph is
// synthetic so this helper verifies ordering and identity without booting a
// world or depending on registry datapack loading.
public final class VanillaFeatureSorterVectors {
private static final Map<PlacedFeature, String> NAMES = new IdentityHashMap<>();
public static void main(String[] args) throws Exception {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
PlacedFeature f1 = feature("f1", 1);
PlacedFeature f2 = feature("f2", 2);
PlacedFeature f3 = feature("f3", 3);
PlacedFeature f4 = feature("f4", 4);
PlacedFeature f5 = feature("f5", 5);
BiomeGenerationSettings a = settings(
List.of(List.of(f1), List.of(f3, f4)));
BiomeGenerationSettings b = settings(
List.of(List.of(f2, f1), List.of(f5, f4)));
dump("ab", List.of(a, b));
dump("ba", List.of(b, a));
}
private static PlacedFeature feature(String name, int count) {
ConfiguredFeature<NoneFeatureConfiguration, Feature<NoneFeatureConfiguration>> configured =
new ConfiguredFeature<>(Feature.NO_OP, NoneFeatureConfiguration.INSTANCE);
PlacedFeature placed = new PlacedFeature(Holder.direct(configured), List.of(CountPlacement.of(count)));
NAMES.put(placed, name);
return placed;
}
@SuppressWarnings("unchecked")
private static BiomeGenerationSettings settings(List<List<PlacedFeature>> stages) throws Exception {
List<HolderSet<PlacedFeature>> holders = new ArrayList<>();
for (List<PlacedFeature> stage : stages) {
Holder<PlacedFeature>[] values = stage.stream().map(Holder::direct).toArray(Holder[]::new);
holders.add(HolderSet.direct(values));
}
Constructor<BiomeGenerationSettings> constructor = BiomeGenerationSettings.class
.getDeclaredConstructor(HolderSet.class, List.class);
constructor.setAccessible(true);
return constructor.newInstance(HolderSet.empty(), holders);
}
private static void dump(String label, List<BiomeGenerationSettings> biomes) {
Function<BiomeGenerationSettings, List<HolderSet<PlacedFeature>>> stages =
BiomeGenerationSettings::features;
List<FeatureSorter.StepFeatureData> steps =
FeatureSorter.buildFeaturesPerStep(biomes, stages, true);
for (int stage = 0; stage < steps.size(); stage++) {
FeatureSorter.StepFeatureData step = steps.get(stage);
List<String> values = new ArrayList<>();
for (int index = 0; index < step.features().size(); index++) {
PlacedFeature feature = step.features().get(index);
values.add(NAMES.get(feature) + "@" + step.indexMapping().applyAsInt(feature));
}
System.out.println(label + ".stage" + stage + "=" + String.join(",", values));
}
}
}