From c0eecfa8d84e34c76762f8f46f41b0c2bf7047e3 Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Thu, 10 Sep 2026 17:05:18 +0100
Subject: [PATCH 01/31] Add Realistic Deposits extension APIs
---
.../orespawn/testmod/SurfaceProbeTestMod.java | 34 ++++++++-
.../mc/orespawn/api/OreGenerationContext.java | 37 ++++++++++
.../mc/orespawn/api/OrePlacementContext.java | 14 ++--
.../moddev/mc/orespawn/api/OreSpawnApi.java | 29 +++++++-
.../orespawn/api/OreSpawnGeologySampler.java | 5 +-
.../api/client/WorldSettingsExtension.java | 32 ++++++++
.../WorldSettingsExtensionRegistry.java | 49 ++++++++++++
.../client/WorldSettingsScreenFactory.java | 12 +++
.../client/OreSpawnWorldSettingsScreen.java | 26 ++++++-
.../worldgen/OreSpawnOreGeneration.java | 60 ++++++++++++---
.../api/OreGenerationContextTest.java | 47 ++++++++++++
.../WorldSettingsExtensionRegistryTest.java | 74 +++++++++++++++++++
.../worldgen/OreSpawnOreGenerationTest.java | 12 +++
13 files changed, 407 insertions(+), 24 deletions(-)
create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java
diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java
index 32c4bb63..eef6b699 100644
--- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java
+++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java
@@ -13,6 +13,7 @@
import java.util.Random;
import java.util.Set;
import java.util.LinkedHashSet;
+import java.util.concurrent.atomic.AtomicInteger;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -27,6 +28,8 @@
import zone.moddev.mc.orespawn.api.BiomeRegionSize;
import zone.moddev.mc.orespawn.api.BiomeReplacementScope;
import zone.moddev.mc.orespawn.api.GeologyFamily;
+import zone.moddev.mc.orespawn.api.GeologyColumn;
+import zone.moddev.mc.orespawn.api.OreGenerationContext;
import zone.moddev.mc.orespawn.api.OrePatternType;
import zone.moddev.mc.orespawn.api.OreSpawnApi;
import zone.moddev.mc.orespawn.api.OreSpawnBiomes;
@@ -120,6 +123,7 @@ public final class SurfaceProbeTestMod {
private static final String RAW_CHEST_ITEM_NAME = "surfaceprobe raw block entity sentinel";
private static final Block WEATHER_SNOW_REPLACEMENT = Blocks.WOOL;
private static final Block WEATHER_ICE_REPLACEMENT = Blocks.PACKED_ICE;
+ private static final AtomicInteger EXTENDED_CONTEXT_INVOCATIONS = new AtomicInteger();
private static final ResourceLocation[] BUILT_IN_GEOMES = {
new ResourceLocation("orespawn", "stable_craton"),
new ResourceLocation("orespawn", "mountain_belt"),
@@ -132,7 +136,27 @@ public final class SurfaceProbeTestMod {
};
private static final OrePatternType EXTERNAL_PATTERN = OrePatternType.create(
- StandardPatternSettings.CODEC, settings -> context -> false)
+ StandardPatternSettings.CODEC, settings -> context -> {
+ if (!(context instanceof OreGenerationContext)) {
+ throw new IllegalStateException("External pattern did not receive OreGenerationContext");
+ }
+ OreGenerationContext generation = (OreGenerationContext) context;
+ if (generation.worldSeed() != 0L || !OVERWORLD.equals(generation.dimension())
+ || generation.chunkX() != (generation.originX() >> 4)
+ || generation.chunkZ() != (generation.originZ() >> 4)) {
+ throw new IllegalStateException("External pattern received unstable generation identity");
+ }
+ if (!generation.geologySampler().isPresent()) {
+ throw new IllegalStateException("Overworld pattern did not receive its geology sampler");
+ }
+ GeologyColumn column = generation.geologySampler().get().sampleColumn(
+ generation.originX(), generation.originZ(), 64);
+ if (!generation.dimension().equals(column.dimension())) {
+ throw new IllegalStateException("Context and sampler dimensions disagree");
+ }
+ EXTENDED_CONTEXT_INVOCATIONS.incrementAndGet();
+ return false;
+ })
.setRegistryName(MODID, "external_probe");
private final BiomeRegistrar registrar = OreSpawnBiomes.registrar(MODID);
@@ -216,6 +240,11 @@ public void init(FMLInitializationEvent event) {
provider.fluidDeposit(new ResourceLocation(MODID, "fluid_deposit/dynamic_tick_probe"),
id(DEPOSIT_FLUID), deposit -> deposit.dimension(OVERWORLD,
dimension -> dimension.hostBlock(id(Blocks.STONE))));
+ provider.ore(new ResourceLocation(MODID, "ore/generation_context_probe"),
+ id(Blocks.COAL_ORE), ore -> ore.retrogen(true).dimension(OVERWORLD,
+ dimension -> dimension.yRange(16, 48).attempts(1.0D).quantity(1)
+ .pattern(new ResourceLocation(MODID, "external_probe"), new JsonObject())
+ .hostBlock(id(Blocks.STONE))));
// This stable palette id makes seed zero select both fixture biomes on
// opposite sides of the 1,024-block Tiny-region boundary.
addPalette(provider, "end_palette_1", END, false);
@@ -333,6 +362,9 @@ public void serverStarted(FMLServerStartedEvent event) {
if (overworld == null || overworld.getSeed() != 0L) {
throw new IllegalStateException("surfaceprobe requires seed token zsjpxah (hash zero)");
}
+ if ("fresh".equals(phase) && EXTENDED_CONTEXT_INVOCATIONS.get() <= 0) {
+ throw new IllegalStateException("External generation context probe did not run");
+ }
Path marker = worldRoot(server).resolve(MARKER_NAME);
Properties previous = "reload".equals(phase) ? read(marker) : null;
if ("fresh".equals(phase) && Files.exists(marker)) {
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
new file mode 100644
index 00000000..a73da872
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
@@ -0,0 +1,37 @@
+package zone.moddev.mc.orespawn.api;
+
+import java.util.Optional;
+
+import net.minecraft.util.ResourceLocation;
+
+/**
+ * Extended placement context supplied by OreSpawn 4.1 and later.
+ *
+ * The identity values are stable for the current chunk in both ordinary
+ * generation and supported retrogen. Patterns should combine them with their
+ * own stable definition identifier and salt instead of relying on invocation
+ * order or the mutable state of {@link #random()}.
+ *
+ * This is a secondary interface so implementations compiled against the
+ * original {@link OrePlacementContext} contract remain binary compatible.
+ */
+public interface OreGenerationContext extends OrePlacementContext {
+ /** Stable seed of the world being generated. */
+ long worldSeed();
+
+ /** Stable namespaced identity of the dimension being generated. */
+ ResourceLocation dimension();
+
+ /** X coordinate of the current chunk, not its minimum block coordinate. */
+ int chunkX();
+
+ /** Z coordinate of the current chunk, not its minimum block coordinate. */
+ int chunkZ();
+
+ /**
+ * Returns the active allocation-light geology sampler when the dimension has
+ * an OreSpawn geology configuration. Pattern implementations should cache no
+ * world or implementation objects beyond the placement call.
+ */
+ Optional geologySampler();
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java
index 0b8f98ec..e0f17838 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OrePlacementContext.java
@@ -4,7 +4,11 @@
import net.minecraftforge.fluids.Fluid;
-/** Allocation-free view supplied to a compiled ore pattern for one attempt. */
+/**
+ * Allocation-free view supplied to a compiled ore pattern for one attempt.
+ * OreSpawn 4.1 contexts also implement {@link OreGenerationContext}; this base
+ * interface remains unchanged for binary compatibility with existing patterns.
+ */
public interface OrePlacementContext {
Random random();
@@ -19,10 +23,10 @@ public interface OrePlacementContext {
int nodeSize();
/**
- * Returns whether this attempt may inspect or replace the position. During initial
- * generation this includes Minecraft's already-loaded writable worldgen region, so
- * deposits can cross chunk borders. Retrogen deliberately limits it to the chunk
- * being updated.
+ * Returns whether this attempt may inspect or replace the position. Forge 1.10
+ * limits both ordinary generation and retrogen to the current chunk. A large
+ * deterministic pattern can still render the intersecting slice independently in
+ * every chunk.
*/
boolean inside(int x, int y, int z);
boolean isFluid(int x, int y, int z, Fluid fluid);
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java
index b3b88361..3c452b5d 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java
@@ -2,19 +2,21 @@
import java.util.Optional;
-import zone.moddev.mc.orespawn.OreSpawn;
import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager;
+import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager;
import zone.moddev.mc.orespawn.worldgen.GeomeConfig;
import zone.moddev.mc.orespawn.worldgen.WorldIds;
import net.minecraft.server.MinecraftServer;
+import net.minecraft.util.ResourceLocation;
import net.minecraft.world.WorldServer;
/** Entry point for OreSpawn API version 1. */
public final class OreSpawnApi {
public static final int API_VERSION = 1;
public static final String IMC_WORLDGEN_PROVIDER = "worldgen_provider_v1";
+ private static final ResourceLocation OVERWORLD = new ResourceLocation("minecraft", "overworld");
private OreSpawnApi() {
}
@@ -46,10 +48,31 @@ public static Optional getActiveProfile(MinecraftServer serv
}
public static Optional createSampler(WorldServer level) {
- if (level == null || WorldGeologyProfileManager.activeServer() != level.getMinecraftServer()
- || GeomeConfig.baked(WorldIds.dimension(level)) == null) {
+ if (!isActiveLevel(level) || samplerConfig(level) == null) {
return Optional.empty();
}
return Optional.of(OreSpawnGeologySampler.create(level));
}
+
+ static BakedGeomeConfig samplerConfig(WorldServer level) {
+ if (level == null) {
+ return null;
+ }
+ ResourceLocation dimension = WorldIds.dimension(level);
+ return OVERWORLD.equals(dimension) ? GeomeConfig.baked() : GeomeConfig.baked(dimension);
+ }
+
+ static boolean isActiveLevel(WorldServer level) {
+ MinecraftServer active = WorldGeologyProfileManager.activeServer();
+ if (level == null || active == null) {
+ return false;
+ }
+ if (active == level.getMinecraftServer()) {
+ return true;
+ }
+ // Forge 1.10 can populate the initial spawn before WorldServer has exposed
+ // its server through getMinecraftServer(). The server's dimension table is
+ // authoritative at that point and still rejects stale or foreign worlds.
+ return active.worldServerForDimension(level.provider.getDimension()) == level;
+ }
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java
index 5ba48d47..c53d1bd5 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java
@@ -6,7 +6,6 @@
import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode;
import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig;
import zone.moddev.mc.orespawn.worldgen.Geology;
-import zone.moddev.mc.orespawn.worldgen.GeomeConfig;
import zone.moddev.mc.orespawn.worldgen.GeomeGeology;
import zone.moddev.mc.orespawn.worldgen.RockFamily;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile;
@@ -33,7 +32,7 @@ final class OreSpawnGeologySampler implements GeologySampler {
private OreSpawnGeologySampler(WorldServer level) {
this.level = level;
dimension = WorldIds.dimension(level);
- config = GeomeConfig.baked(dimension);
+ config = OreSpawnApi.samplerConfig(level);
WorldGeologyProfile profile = WorldGeologyProfileManager.activeProfile();
mode = profile.geologyMode();
if (mode == GeologyMode.LEGACY) {
@@ -47,7 +46,7 @@ private OreSpawnGeologySampler(WorldServer level) {
}
static GeologySampler create(WorldServer level) {
- if (level == null || WorldGeologyProfileManager.activeServer() != level.getMinecraftServer()) {
+ if (!OreSpawnApi.isActiveLevel(level)) {
throw new IllegalStateException("The level is not part of OreSpawn's active server");
}
return new OreSpawnGeologySampler(level);
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java
new file mode 100644
index 00000000..66d7ba51
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java
@@ -0,0 +1,32 @@
+package zone.moddev.mc.orespawn.api.client;
+
+import java.util.Objects;
+
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.util.ResourceLocation;
+import net.minecraftforge.fml.relauncher.Side;
+import net.minecraftforge.fml.relauncher.SideOnly;
+
+/** Immutable registered action displayed by OreSpawn's world-settings screen. */
+@SideOnly(Side.CLIENT)
+public final class WorldSettingsExtension {
+ private final ResourceLocation id;
+ private final String buttonTranslationKey;
+ private final WorldSettingsScreenFactory screenFactory;
+
+ WorldSettingsExtension(ResourceLocation id, String buttonTranslationKey,
+ WorldSettingsScreenFactory screenFactory) {
+ this.id = Objects.requireNonNull(id, "id");
+ this.buttonTranslationKey = Objects.requireNonNull(buttonTranslationKey,
+ "buttonTranslationKey");
+ this.screenFactory = Objects.requireNonNull(screenFactory, "screenFactory");
+ }
+
+ public ResourceLocation id() { return id; }
+ public String buttonTranslationKey() { return buttonTranslationKey; }
+
+ /** Creates the extension screen with the current OreSpawn screen as its parent. */
+ public GuiScreen createScreen(GuiScreen parent) {
+ return screenFactory.create(Objects.requireNonNull(parent, "parent"));
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java
new file mode 100644
index 00000000..a7cbf0ef
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java
@@ -0,0 +1,49 @@
+package zone.moddev.mc.orespawn.api.client;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import net.minecraft.util.ResourceLocation;
+import net.minecraftforge.fml.relauncher.Side;
+import net.minecraftforge.fml.relauncher.SideOnly;
+
+/** Client-only registry for add-on actions in OreSpawn's world-settings screen. */
+@SideOnly(Side.CLIENT)
+public final class WorldSettingsExtensionRegistry {
+ private static final Map EXTENSIONS =
+ new LinkedHashMap<>();
+
+ private WorldSettingsExtensionRegistry() {
+ }
+
+ /**
+ * Registers one translated world-settings action in deterministic registration
+ * order. Registration must occur during client initialization.
+ *
+ * @throws IllegalStateException if the identifier is already registered
+ */
+ public static synchronized WorldSettingsExtension register(ResourceLocation id,
+ String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) {
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(screenFactory, "screenFactory");
+ if (buttonTranslationKey == null || buttonTranslationKey.trim().isEmpty()) {
+ throw new IllegalArgumentException("buttonTranslationKey cannot be blank");
+ }
+ if (EXTENSIONS.containsKey(id)) {
+ throw new IllegalStateException("Duplicate OreSpawn world-settings extension: " + id);
+ }
+ WorldSettingsExtension extension = new WorldSettingsExtension(id,
+ buttonTranslationKey, screenFactory);
+ EXTENSIONS.put(id, extension);
+ return extension;
+ }
+
+ /** Returns an immutable snapshot in deterministic registration order. */
+ public static synchronized List extensions() {
+ return Collections.unmodifiableList(new ArrayList<>(EXTENSIONS.values()));
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java
new file mode 100644
index 00000000..dcf2a199
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java
@@ -0,0 +1,12 @@
+package zone.moddev.mc.orespawn.api.client;
+
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraftforge.fml.relauncher.Side;
+import net.minecraftforge.fml.relauncher.SideOnly;
+
+/** Creates an add-on configuration screen whose Done action returns to OreSpawn. */
+@FunctionalInterface
+@SideOnly(Side.CLIENT)
+public interface WorldSettingsScreenFactory {
+ GuiScreen create(GuiScreen parent);
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
index 4e540796..9ab2e809 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
@@ -7,6 +7,8 @@
import java.util.Objects;
import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode;
+import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension;
+import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry;
import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager;
@@ -69,9 +71,10 @@ protected void init() {
int right = left + columnWidth + 5;
boolean terrain = session.hasTerrainRules();
boolean fluids = !session.fluidDepositIds().isEmpty();
+ List extensions = WorldSettingsExtensionRegistry.extensions();
int top = OreSpawnScreenLayout.mainTop(this.height);
int rows = (templateChoices.size() > 1 ? 1 : 0) + (terrain ? 8
- : 5 + (fluids ? 1 : 0));
+ : 5 + (fluids ? 1 : 0)) + extensions.size();
int available = OreSpawnScreenLayout.footerY(height) - top - BUTTON_HEIGHT - 4;
int row = Math.min(OreSpawnScreenLayout.mainRowSpacing(this.height),
rows <= 1 ? 24 : Math.max(BUTTON_HEIGHT, available / (rows - 1)));
@@ -106,7 +109,7 @@ columnWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(),
contentWidth, BUTTON_HEIGHT,
new TextComponentTranslation("button.orespawn.biomes_world_materials"),
button -> openBiomeWorldMaterials(), "tooltip.orespawn.main.biomes_materials"));
- addButton(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex),
+ addButton(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex++),
contentWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"),
button -> openHelp()));
} else {
@@ -153,10 +156,16 @@ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.advanc
addButton(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex++),
columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"),
button -> openHelp()));
- addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex),
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex++),
contentWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(),
"tooltip.orespawn.main.fluid_editor"));
}
+ for (WorldSettingsExtension extension : extensions) {
+ addButton(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex++),
+ contentWidth, BUTTON_HEIGHT,
+ new TextComponentTranslation(extension.buttonTranslationKey()),
+ button -> openExtension(extension)));
+ }
addButton(OreSpawnScreenLayout.button(this, font, left, this.height - 28, columnWidth, BUTTON_HEIGHT,
DialogTexts.GUI_DONE, button -> saveAndClose()));
addButton(OreSpawnScreenLayout.button(this, font, right, this.height - 28, columnWidth, BUTTON_HEIGHT,
@@ -322,6 +331,17 @@ private void openHelp() {
minecraft.displayGuiScreen(new OreSpawnGuideScreen(this));
}
+ private void openExtension(WorldSettingsExtension extension) {
+ syncSession();
+ GuiScreen screen = extension.createScreen(this);
+ if (screen == null) {
+ validationError = new TextComponentString(
+ "World-settings extension did not create a screen: " + extension.id());
+ return;
+ }
+ minecraft.displayGuiScreen(screen);
+ }
+
@Override
public void onClose() {
minecraft.displayGuiScreen(parent);
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
index 819cbfbb..ecc4cc85 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
@@ -9,6 +9,7 @@
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
+import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.function.Supplier;
@@ -18,8 +19,10 @@
import com.google.gson.JsonObject;
import zone.moddev.mc.orespawn.OreSpawn;
import zone.moddev.mc.orespawn.api.CompiledOrePattern;
+import zone.moddev.mc.orespawn.api.GeologySampler;
import zone.moddev.mc.orespawn.api.OreDimensionSelector;
-import zone.moddev.mc.orespawn.api.OrePlacementContext;
+import zone.moddev.mc.orespawn.api.OreGenerationContext;
+import zone.moddev.mc.orespawn.api.OreSpawnApi;
import zone.moddev.mc.orespawn.init.OreSpawnPatterns;
import net.minecraft.util.math.BlockPos;
@@ -112,7 +115,8 @@ boolean generate(World world, Chunk chunk, Random random) {
setCenter(scratch.cursor, chunk);
Biome biome = world.getBiome(scratch.cursor);
ResourceLocation biomeId = WorldIds.biome(biome);
- boolean changed = generateChunk(world, chunk, biome, biomeId, dimension,
+ WorldServer level = world instanceof WorldServer ? (WorldServer) world : null;
+ boolean changed = generateChunk(level, world, chunk, biome, biomeId, dimension,
world.getSeed(), random, ores, false, scratch);
OreRetrogenManager.markGenerated(dimension, ChunkAccessCompat.position(chunk));
return changed;
@@ -128,7 +132,7 @@ static boolean retrogen(WorldServer level, Chunk chunk) {
setCenter(scratch.cursor, chunk);
Biome biome = level.getBiome(scratch.cursor);
ResourceLocation biomeId = WorldIds.biome(biome);
- return generateChunk(null, chunk, biome, biomeId, dimension, level.getSeed(),
+ return generateChunk(level, null, chunk, biome, biomeId, dimension, level.getSeed(),
new Random(seed), ores, true, scratch);
}
@@ -137,13 +141,14 @@ private static void setCenter(BlockPos.MutableBlockPos cursor, Chunk chunk) {
cursor.setPos(chunkPos.getXStart() + 8, 0, chunkPos.getZStart() + 8);
}
- private static boolean generateChunk(World world, Chunk chunk, Biome biome,
+ private static boolean generateChunk(WorldServer level, World world, Chunk chunk, Biome biome,
ResourceLocation biomeId,
ResourceLocation dimension, long worldSeed, Random random, BakedOre[] ores,
boolean retrogenOnly, GenerationScratch scratch) {
ChunkPos chunkPos = ChunkAccessCompat.position(chunk);
int centerX = chunkPos.getXStart() + 8;
int centerZ = chunkPos.getZStart() + 8;
+ Optional geologySampler = scratch.geologySampler(level, dimension);
int geome = -1;
if (WorldIds.OVERWORLD.equals(dimension)) {
geome = classifier(worldSeed).classifyColumn(biome, biomeId, centerX, centerZ,
@@ -162,7 +167,8 @@ private static boolean generateChunk(World world, Chunk chunk, Biome biome,
}
int attempts = attemptsForFrequency(random, frequency);
for (int attempt = 0; attempt < attempts; attempt++) {
- changed |= placeAttempt(world, chunk, random, ore, geome, scratch);
+ changed |= placeAttempt(world, chunk, random, ore, geome, dimension,
+ worldSeed, geologySampler, scratch);
}
}
if (changed) {
@@ -181,7 +187,8 @@ private static long mix(long seed, long chunk, long revision) {
}
private static boolean placeAttempt(World world, Chunk chunk, Random random,
- BakedOre ore, int geome,
+ BakedOre ore, int geome, ResourceLocation dimension, long worldSeed,
+ Optional geologySampler,
GenerationScratch scratch) {
int minY = Math.max(ore.minY, 0);
int maxY = Math.min(ore.maxY, 256 - 1);
@@ -192,8 +199,8 @@ private static boolean placeAttempt(World world, Chunk chunk, Random random,
int y = ore.heightDistribution.sample(random, minY, maxY);
int z = ChunkAccessCompat.position(chunk).getZStart() + random.nextInt(16);
int quantity = sampleQuantity(random, ore.minQuantity, ore.maxQuantity);
- scratch.patternContext.initialize(world, chunk, random, ore, geome, x, y, z, minY, maxY,
- quantity);
+ scratch.patternContext.initialize(world, chunk, random, ore, geome, dimension, worldSeed,
+ geologySampler, x, y, z, minY, maxY, quantity);
return ore.pattern.place(scratch.patternContext);
}
@@ -774,6 +781,10 @@ private static final class GenerationScratch {
final BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
final PatternContext patternContext = new PatternContext(cursor);
private double[] geomeValues = new double[0];
+ private WorldServer samplerLevel;
+ private ResourceLocation samplerDimension;
+ private BakedGeomeConfig samplerConfig;
+ private Optional geologySampler = Optional.empty();
double[] geomeValues(int count) {
if (geomeValues.length != count) {
@@ -781,9 +792,22 @@ private static final class GenerationScratch {
}
return geomeValues;
}
+
+ Optional geologySampler(WorldServer level, ResourceLocation dimension) {
+ BakedGeomeConfig activeConfig = WorldIds.OVERWORLD.equals(dimension)
+ ? GeomeConfig.baked() : GeomeConfig.baked(dimension);
+ if (level != samplerLevel || !dimension.equals(samplerDimension)
+ || activeConfig != samplerConfig) {
+ samplerLevel = level;
+ samplerDimension = dimension;
+ samplerConfig = activeConfig;
+ geologySampler = level == null ? Optional.empty() : OreSpawnApi.createSampler(level);
+ }
+ return geologySampler;
+ }
}
- private static final class PatternContext implements OrePlacementContext {
+ private static final class PatternContext implements OreGenerationContext {
private final BlockPos.MutableBlockPos cursor;
private final BlockPos.MutableBlockPos airCursor = new BlockPos.MutableBlockPos();
private World world;
@@ -791,6 +815,11 @@ private static final class PatternContext implements OrePlacementContext {
private Random random;
private BakedOre ore;
private int geome;
+ private ResourceLocation dimension;
+ private long worldSeed;
+ private int chunkX;
+ private int chunkZ;
+ private Optional geologySampler = Optional.empty();
private int originX;
private int originY;
private int originZ;
@@ -803,12 +832,20 @@ private static final class PatternContext implements OrePlacementContext {
}
void initialize(World world, Chunk chunk, Random random, BakedOre ore, int geome,
+ ResourceLocation dimension, long worldSeed,
+ Optional geologySampler,
int originX, int originY, int originZ, int minY, int maxY, int quantity) {
this.world = world;
this.chunk = chunk;
this.random = random;
this.ore = ore;
this.geome = geome;
+ this.dimension = dimension;
+ this.worldSeed = worldSeed;
+ ChunkPos chunkPos = ChunkAccessCompat.position(chunk);
+ this.chunkX = chunkPos.chunkXPos;
+ this.chunkZ = chunkPos.chunkZPos;
+ this.geologySampler = geologySampler;
this.originX = originX;
this.originY = originY;
this.originZ = originZ;
@@ -827,6 +864,11 @@ void initialize(World world, Chunk chunk, Random random, BakedOre ore, int geome
@Override public int spread() { return ore.spread; }
@Override public int verticalSpread() { return ore.verticalSpread; }
@Override public int nodeSize() { return ore.nodeSize; }
+ @Override public long worldSeed() { return worldSeed; }
+ @Override public ResourceLocation dimension() { return dimension; }
+ @Override public int chunkX() { return chunkX; }
+ @Override public int chunkZ() { return chunkZ; }
+ @Override public Optional geologySampler() { return geologySampler; }
@Override
public boolean inside(int x, int y, int z) {
diff --git a/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java
new file mode 100644
index 00000000..6399b435
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java
@@ -0,0 +1,47 @@
+package zone.moddev.mc.orespawn.api;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+
+import net.minecraft.util.ResourceLocation;
+
+class OreGenerationContextTest {
+ @Test
+ void extendsTheOriginalPlacementContractWithoutChangingItsAbi() {
+ assertTrue(OrePlacementContext.class.isAssignableFrom(OreGenerationContext.class));
+ Set baseMethods = Arrays.stream(OrePlacementContext.class.getDeclaredMethods())
+ .map(Method::getName).collect(Collectors.toSet());
+ assertFalse(baseMethods.contains("worldSeed"));
+ assertFalse(baseMethods.contains("dimension"));
+ assertFalse(baseMethods.contains("chunkX"));
+ assertFalse(baseMethods.contains("chunkZ"));
+ assertFalse(baseMethods.contains("geologySampler"));
+ }
+
+ @Test
+ void exposesTheStableRegionDepositIdentityContract() throws Exception {
+ assertEquals(long.class, OreGenerationContext.class.getMethod("worldSeed").getReturnType());
+ assertEquals(ResourceLocation.class,
+ OreGenerationContext.class.getMethod("dimension").getReturnType());
+ assertEquals(int.class, OreGenerationContext.class.getMethod("chunkX").getReturnType());
+ assertEquals(int.class, OreGenerationContext.class.getMethod("chunkZ").getReturnType());
+ assertEquals(Optional.class,
+ OreGenerationContext.class.getMethod("geologySampler").getReturnType());
+ }
+
+ @Test
+ void oreSpawnPatternContextImplementsTheExtendedContract() throws Exception {
+ Class> implementation = Class.forName(
+ "zone.moddev.mc.orespawn.worldgen.OreSpawnOreGeneration$PatternContext");
+ assertTrue(OreGenerationContext.class.isAssignableFrom(implementation));
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java
new file mode 100644
index 00000000..55d090e9
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java
@@ -0,0 +1,74 @@
+package zone.moddev.mc.orespawn.api.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.util.ResourceLocation;
+
+class WorldSettingsExtensionRegistryTest {
+ @Test
+ void preservesRegistrationOrderAndReturnsAnImmutableSnapshot() {
+ WorldSettingsExtension first = register("ordered_first", parent -> new GuiScreen() { });
+ WorldSettingsExtension second = register("ordered_second", parent -> new GuiScreen() { });
+ List extensions = WorldSettingsExtensionRegistry.extensions();
+
+ assertTrue(extensions.indexOf(first) < extensions.indexOf(second));
+ assertThrows(UnsupportedOperationException.class, () -> extensions.add(first));
+ }
+
+ @Test
+ void rejectsDuplicateIdsAndBlankTranslationKeys() {
+ ResourceLocation id = id("duplicate");
+ WorldSettingsExtensionRegistry.register(id, "button.realisticdeposits.settings",
+ parent -> new GuiScreen() { });
+ assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register(id,
+ "button.realisticdeposits.other", parent -> new GuiScreen() { }));
+ assertThrows(IllegalArgumentException.class, () -> WorldSettingsExtensionRegistry.register(
+ id("blank"), " ", parent -> new GuiScreen() { }));
+ }
+
+ @Test
+ void passesTheCurrentOreSpawnScreenToTheFactory() {
+ AtomicReference receivedParent = new AtomicReference<>();
+ GuiScreen expectedParent = new GuiScreen() { };
+ GuiScreen expectedChild = new GuiScreen() { };
+ WorldSettingsExtension extension = register("parent", parent -> {
+ receivedParent.set(parent);
+ return expectedChild;
+ });
+
+ assertSame(expectedChild, extension.createScreen(expectedParent));
+ assertSame(expectedParent, receivedParent.get());
+ assertEquals("button.realisticdeposits.settings", extension.buttonTranslationKey());
+ }
+
+ @Test
+ void worldSettingsScreenOwnsExtensionButtonPlacementAndNavigation() throws Exception {
+ String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone",
+ "moddev", "mc", "orespawn", "client", "OreSpawnWorldSettingsScreen.java")),
+ StandardCharsets.UTF_8);
+ assertTrue(source.contains("WorldSettingsExtensionRegistry.extensions()"));
+ assertTrue(source.contains("new TextComponentTranslation(extension.buttonTranslationKey())"));
+ assertTrue(source.contains("extension.createScreen(this)"));
+ }
+
+ private static WorldSettingsExtension register(String path, WorldSettingsScreenFactory factory) {
+ return WorldSettingsExtensionRegistry.register(id(path),
+ "button.realisticdeposits.settings", factory);
+ }
+
+ private static ResourceLocation id(String path) {
+ return new ResourceLocation("realisticdeposits", path);
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java
index a14854d1..428e8240 100644
--- a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java
@@ -66,6 +66,18 @@ void managedWritesDoNotLeakReusablePositionsIntoForgeTickData() throws Exception
assertFalse(source.contains("world.setBlockState(cursor, output, GENERATION_WRITE_FLAGS)"));
}
+ @Test
+ void normalGenerationAndRetrogenShareStableExtendedIdentityInputs() throws Exception {
+ String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone",
+ "moddev", "mc", "orespawn", "worldgen", "OreSpawnOreGeneration.java")),
+ StandardCharsets.UTF_8);
+ assertTrue(source.contains("generateChunk(level, world, chunk"));
+ assertTrue(source.contains("generateChunk(level, null, chunk"));
+ assertTrue(source.contains("dimension, worldSeed,\n\t\t\t\tgeologySampler"));
+ assertTrue(source.contains("this.chunkX = chunkPos.chunkXPos"));
+ assertTrue(source.contains("this.chunkZ = chunkPos.chunkZPos"));
+ }
+
@Test
void fixedQuantityDoesNotConsumeRandomState() {
CountingRandom random = new CountingRandom(0);
From c46a9533b882434bb5ad54beb608aa96a168739c Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Thu, 10 Sep 2026 17:05:30 +0100
Subject: [PATCH 02/31] Prepare OreSpawn 4.1.0.110021
---
.github/workflows/ci.yml | 6 +--
CHANGELOG.txt | 12 +++++
README.md | 6 ++-
build.gradle | 8 ++--
docs/API.md | 44 ++++++++++++++++++-
docs/DEVELOPER_GUIDE.md | 36 ++++++++++++++-
docs/VERSIONS.md | 2 +-
gradle.properties | 2 +-
.../orespawn/compat/LegacyOs3Bridge.java | 2 +-
src/main/java/cyano/orespawn/OreSpawn.java | 2 +-
.../zone/moddev/mc/orespawn/OreSpawn.java | 2 +-
.../LegacyMineralogyProfileMigration.java | 2 +-
.../worldgen/LegacyOs3ProfileMigration.java | 2 +-
13 files changed, 109 insertions(+), 17 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6d030b92..8b541302 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -126,9 +126,9 @@ jobs:
if-no-files-found: error
retention-days: 30
path: |
- build/libs/OreSpawn-4.0.16.110021.jar
- build/libs/OreSpawn-4.0.16.110021-sources.jar
- build/libs/OreSpawn-4.0.16.110021-javadoc.jar
+ build/libs/OreSpawn-4.1.0.110021.jar
+ build/libs/OreSpawn-4.1.0.110021-sources.jar
+ build/libs/OreSpawn-4.1.0.110021-javadoc.jar
build/release/SHA256SUMS
CHANGELOG.txt
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 238baa2d..64980315 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -1,3 +1,15 @@
+Version 4.1.0.110021
+
+* Add an extended, binary-compatible ore generation context exposing stable
+ world seed, dimension and current-chunk identity to compiled pattern add-ons.
+* Expose the active geology sampler to patterns when the generated dimension
+ has OreSpawn geology, including identical identity during supported retrogen.
+* Add a deterministic client-only registry for translated add-on actions in
+ OreSpawn's world-settings screen, with parent-screen handoff and duplicate-ID
+ rejection.
+* Preserve API major 1, schemas 4/6/5, existing pattern binaries, profiles,
+ migration behaviour and generated chunks.
+
Version 4.0.16.110021
* Adopt the shared 4.0.16 release identity. Forge 1.10 has neither
diff --git a/README.md b/README.md
index c4419edb..6a3f8330 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,10 @@ It gives mods and modpacks one place to configure ores, deposit shapes, optional
rock strata and geomes, provider-owned underground fluid deposits, biome
palettes and world materials, flat bedrock, and bounded ore retrogen.
-This branch builds target-qualified version `4.0.16.110021`: the OreSpawn 4.0.16
-feature set for Minecraft 1.10.2 and Forge. See the
+This branch builds target-qualified version `4.1.0.110021`: the OreSpawn 4.1.0
+feature set for Minecraft 1.10.2 and Forge. It adds stable generation identity
+and a client world-settings extension point for add-ons such as Realistic
+Deposits while retaining API major 1. See the
[versioning policy](docs/VERSIONS.md) for the encoding and release convention.
Its deprecated compatibility layer imports OreSpawn 1 and OreSpawn 3
diff --git a/build.gradle b/build.gradle
index 3bf50c08..528639e2 100644
--- a/build.gradle
+++ b/build.gradle
@@ -960,7 +960,7 @@ tasks.register('verifyReleaseConfiguration') {
description = 'Validates the target-qualified release, API, schemas, reports, and publishing identity.'
doLast {
- if (project.mod_version != '4.0.16.110021'
+ if (project.mod_version != '4.1.0.110021'
|| project.mod_group != expectedMavenGroup) {
throw new GradleException("Unexpected OreSpawn release version: ${project.mod_version}")
}
@@ -988,8 +988,8 @@ tasks.register('verifyReleaseConfiguration') {
'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java',
'README.md', 'CHANGELOG.txt'
].each { path ->
- if (!file(path).getText('UTF-8').contains('4.0.16.110021')) {
- throw new GradleException("Authoritative release location does not contain 4.0.16.110021: ${path}")
+ if (!file(path).getText('UTF-8').contains('4.1.0.110021')) {
+ throw new GradleException("Authoritative release location does not contain 4.1.0.110021: ${path}")
}
}
if (!file('docs/API.md').getText('UTF-8').contains('orespawn@[4.0.6,5.0.0)')) {
@@ -1071,6 +1071,8 @@ tasks.register('verifyReleaseArtifacts') {
'mcmod.info',
'META-INF/accesstransformer.cfg',
'zone/moddev/mc/orespawn/api/OreSpawnApi.class',
+ 'zone/moddev/mc/orespawn/api/OreGenerationContext.class',
+ 'zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.class',
'META-INF/orespawn/docs/VERSIONS.md',
'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json',
'META-INF/orespawn/docs/schemas/orespawn-global.schema.json',
diff --git a/docs/API.md b/docs/API.md
index 5b21d46c..98b9fd85 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -152,7 +152,29 @@ OreSpawnApi.createSampler(server.overworld()).ifPresent(sampler -> {
`sampleColumn` performs one biome/geome classification and reuses it for every
Y query. Sampling is read-only and is intended for gameplay decisions,
diagnostics, and compatible generation outside OreSpawn's block loops.
-Callbacks inside OreSpawn generation loops are intentionally unsupported.
+
+OreSpawn 4.1 adds `OreGenerationContext` as a binary-compatible subtype of the
+original `OrePlacementContext`. Every context supplied by OreSpawn implements
+the extended type. A custom compiled pattern can obtain stable region-scale
+identity without retaining a world object:
+
+```java
+if (!(context instanceof OreGenerationContext)) return false;
+OreGenerationContext generation = (OreGenerationContext) context;
+long seed = generation.worldSeed();
+ResourceLocation dimension = generation.dimension();
+int chunkX = generation.chunkX();
+int chunkZ = generation.chunkZ();
+Optional geology = generation.geologySampler();
+```
+
+The world seed, dimension and chunk coordinates are identical for ordinary
+generation and supported retrogen. `geologySampler()` is empty when that
+dimension has no active OreSpawn geology configuration. It otherwise returns
+the already-prepared, allocation-light sampler for the active world. Large
+patterns must independently render only the slice intersecting the current
+chunk and continue to use `inside(...)` and `tryPlace(...)` for safe writes.
+Do not retain the sampler or placement context beyond the current call.
Forge 12 custom-pattern mods attach a generic
`RegistryEvent.Register` listener to their mod event bus and
@@ -163,5 +185,25 @@ register named values into `OreSpawnPatternRegistry.REGISTRY_NAME`. An
baking the profile; only the compiled placement function runs during
generation.
+## Client world-settings extensions
+
+An add-on may contribute a translated action to OreSpawn's world-settings
+screen without depending on OreSpawn implementation classes. Register from
+client initialization only:
+
+```java
+WorldSettingsExtensionRegistry.register(
+ new ResourceLocation("examplemod", "deposit_settings"),
+ "button.examplemod.deposit_settings",
+ parent -> new ExampleDepositSettingsScreen(parent));
+```
+
+OreSpawn owns layout and navigation and passes its current screen as the
+factory parent, so the add-on's Done action can return safely. Entries appear
+in deterministic registration order. Duplicate IDs, blank translation keys
+and null factories are rejected. The translation belongs in the add-on's own
+language resources. These types are client-only and must not be loaded from a
+dedicated-server initialization path.
+
`OreSpawnOreIntegration` remains as a deprecated facade for early ore-provider
integrations. New code should use `OreSpawnApi`.
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index 43f94896..0de6b9d3 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -13,6 +13,8 @@
| Add or place biomes without a framework dependency | Provider schema 4 biome palette |
| Replace surfaces, aquifers, snow, or ice | Provider schema 4 dimension materials |
| Inspect active geology at runtime | `GeologyProfileView` and `GeologySampler` |
+| Build a deterministic region-scale ore pattern | `OreGenerationContext` |
+| Add an add-on settings screen | Client-only `WorldSettingsExtensionRegistry` |
Strata are optional. If no enabled terrain dimension has eligible rocks,
OreSpawn skips terrain replacement and all formation/geome settings are inert.
@@ -119,6 +121,36 @@ needs different settings; the explicit rule overrides the selector there.
Ore dimension builders support the same exact-ID and biome-dictionary include
and exclude filters as provider JSON and fluid-deposit builders.
+## Region-scale custom patterns
+
+OreSpawn 4.1 supplies compiled patterns with `OreGenerationContext`, a subtype
+of the original placement context. Its world seed, dimension ID and current
+chunk coordinates let an add-on derive a stable region/deposit identity that
+does not depend on chunk generation order. `geologySampler()` provides the
+active OreSpawn geology where one is configured.
+
+Render only the part of a deterministic body that intersects `chunkX()` and
+`chunkZ()`. All reads and writes must still pass through `inside(...)`,
+`isFluid(...)` and `tryPlace(...)`; Forge 1.10 deliberately restricts those
+operations to the current chunk. Perform definition parsing and expensive
+setup in the registered pattern compiler, not its placement callback.
+
+## Add-on world settings
+
+Register optional client configuration screens during client initialization:
+
+```java
+WorldSettingsExtensionRegistry.register(
+ new ResourceLocation("examplemod", "deposit_settings"),
+ "button.examplemod.deposit_settings",
+ parent -> new ExampleDepositSettingsScreen(parent));
+```
+
+The registry is client-only. OreSpawn renders the translated button, controls
+its placement, and supplies the current OreSpawn screen as the parent. The
+extension owns its own screen and translations and must return to that parent
+from Done. Duplicate identifiers are rejected deterministically.
+
## Pack Override Quick Start
Copy `examples/examplemod-orespawn.json` to:
@@ -174,7 +206,9 @@ See `CONFIGURATION.md` and the JSON Schemas for every field and numeric range.
Provider files and API definitions freeze before generation. OreSpawn resolves
registry IDs, tags, dimensions, geomes, aliases, and block states while baking.
The generation loop must not contain provider callbacks, config reads, registry
-lookups, strings, logging, reflection, or avoidable allocation.
+lookups, strings, logging, reflection, or avoidable allocation. Compiled custom
+patterns are the intentional extension point; they receive only baked settings,
+the allocation-free placement context and cached generation identity/sampler.
Biome filters retain their exact registry IDs. Minecraft 1.10.2 uses a static
Forge-backed biome registry, so generation carries those stable IDs alongside
diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md
index 09f03e1a..2069a178 100644
--- a/docs/VERSIONS.md
+++ b/docs/VERSIONS.md
@@ -51,7 +51,7 @@ Examples:
| Minecraft | Loader | Target | Example full version |
| --- | --- | ---: | --- |
-| 1.10.2 | Forge | `110021` | `4.0.16.110021` |
+| 1.10.2 | Forge | `110021` | `4.1.0.110021` |
| 1.12.2 | Forge | `112021` | `4.0.6.112021` |
| 1.13.2 | Forge | `113021` | `4.0.6.113021` |
| 1.20.6 | Forge | `120061` | `4.0.6.120061` |
diff --git a/gradle.properties b/gradle.properties
index aacbd2ac..e4ae0128 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -26,7 +26,7 @@ curseforge_project_id=245586
mod_id=orespawn
mod_name=MMD OreSpawn
mod_license=LGPL-2.1
-mod_version=4.0.16.110021
+mod_version=4.1.0.110021
mod_group=zone.moddev.mc.orespawn
mod_authors=SkyBlade1978, dshadowwolf, the MMD Team
mod_description=Configurable, provider-driven terrain, ore, and deposit generation.
diff --git a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java
index 4e1093fd..7c7369ff 100644
--- a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java
+++ b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java
@@ -985,7 +985,7 @@ private static void writeHumanUpgradeReport(Path destination) throws IOException
}
}
List lines = new ArrayList<>();
- lines.add("OreSpawn 4.0.16.110021 Upgrade Report");
+ lines.add("OreSpawn 4.1.0.110021 Upgrade Report");
lines.add("================================");
lines.add("");
lines.add("RESULT: Legacy OreSpawn configuration was consumed and translated for OS4.");
diff --git a/src/main/java/cyano/orespawn/OreSpawn.java b/src/main/java/cyano/orespawn/OreSpawn.java
index b150c79f..18d9485d 100644
--- a/src/main/java/cyano/orespawn/OreSpawn.java
+++ b/src/main/java/cyano/orespawn/OreSpawn.java
@@ -19,7 +19,7 @@ public class OreSpawn {
public static OreSpawn INSTANCE;
public static final String MODID = "orespawn";
public static final String NAME = "OreSpawn";
- public static final String VERSION = "4.0.16.110021";
+ public static final String VERSION = "4.1.0.110021";
public static final List oreSpawnConfigFiles = new ArrayList<>();
public static final List additionalStoneBlocks = new ArrayList<>();
public static boolean disableVanillaOreGen;
diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java
index 761ada97..1c91df6f 100644
--- a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java
+++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java
@@ -52,7 +52,7 @@ public class OreSpawn {
public static final String MODID = "orespawn";
public static final String NAME = "OreSpawn";
- public static final String VERSION = "4.0.16.110021";
+ public static final String VERSION = "4.1.0.110021";
private static final Logger LOGGER = LogManager.getLogger();
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java
index 413115bc..145648cd 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java
@@ -125,7 +125,7 @@ private static void writeUpgradeReport(Path worldRoot, Path configDirectory,
}
List lines = new ArrayList<>();
- lines.add("OreSpawn 4.0.16.110021 Upgrade Report");
+ lines.add("OreSpawn 4.1.0.110021 Upgrade Report");
lines.add("================================");
lines.add("");
lines.add("RESULT: Existing Mineralogy " + mineralogyVersion
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java
index b36f5c09..f1113d92 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java
@@ -65,7 +65,7 @@ private static void writeInitialUpgradeReport(Path configDirectory,
boolean forceRetrogen, boolean flatBedrock, boolean retrogenBedrock,
int bedrockLayers) throws IOException {
String newline = System.lineSeparator();
- String text = "OreSpawn 4.0.16.110021 Upgrade Report" + newline
+ String text = "OreSpawn 4.1.0.110021 Upgrade Report" + newline
+ "================================" + newline + newline
+ "RESULT: Legacy OreSpawn settings were imported into the OS4 profile." + newline
+ "- Manage vanilla ores: " + manageVanilla + newline
From b7c59a6c77f24bd8826b153e9370c7b274cee691 Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Fri, 11 Sep 2026 09:56:10 +0100
Subject: [PATCH 03/31] Complete Realistic Deposits extension contracts
---
.../mc/orespawn/api/GeologySampler.java | 3 +-
.../orespawn/api/OreSpawnGeologySampler.java | 5 +-
.../mc/orespawn/api/WorldgenProvider.java | 57 ++++++++++-
.../WorldgenIntegrationManager.java | 31 +++++-
.../worldgen/OreSpawnOreGeneration.java | 98 +++++++++++++++++--
.../worldgen/OreSpawnWorldGenerator.java | 8 +-
.../worldgen/WorldGeologyProfileManager.java | 5 +-
.../api/GeologySamplerSourceContractTest.java | 21 ++++
.../mc/orespawn/api/WorldgenProviderTest.java | 39 ++++++++
.../WorldgenIntegrationManagerTest.java | 44 +++++++++
.../BackgroundGenerationScaleTest.java | 55 +++++++++++
.../BiomeSurfaceFeatureOrderTest.java | 2 +-
12 files changed, 346 insertions(+), 22 deletions(-)
create mode 100644 src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java b/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java
index f029d69f..a3d81856 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/GeologySampler.java
@@ -4,7 +4,8 @@
public interface GeologySampler {
/**
* Classifies one column. The returned column reuses that biome/geome
- * classification for all subsequent Y queries.
+ * classification for all subsequent Y queries. Sampling is read-only and
+ * does not load or generate the chunk containing the requested column.
*/
GeologyColumn sampleColumn(int blockX, int blockZ, int surfaceY);
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java
index c53d1bd5..47579873 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java
@@ -55,7 +55,10 @@ static GeologySampler create(WorldServer level) {
@Override
public GeologyColumn sampleColumn(int blockX, int blockZ, int surfaceY) {
BlockPos position = new BlockPos(blockX, surfaceY, blockZ);
- Biome biome = level.getBiome(position);
+ // Query the dimension's biome provider directly. World#getBiome may obtain a
+ // chunk and therefore load or generate neighbouring chunks when a large
+ // extension pattern samples a body crossing chunk boundaries.
+ Biome biome = level.provider.getBiomeProvider().getBiome(position);
ResourceLocation biomeId = WorldIds.biome(biome);
if (biomeId == null) biomeId = new ResourceLocation("orespawn", "unregistered_biome");
if (mode == GeologyMode.LEGACY) {
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java
index 736885ca..584a3b94 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java
@@ -11,6 +11,7 @@
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
+import java.util.OptionalDouble;
import java.util.Set;
import java.util.function.Consumer;
@@ -28,11 +29,14 @@
public final class WorldgenProvider {
private final String modId;
private final int revision;
+ private final boolean mergeNewEntriesIntoExistingWorlds;
private final JsonObject definition;
- private WorldgenProvider(String modId, int revision, JsonObject definition) {
+ private WorldgenProvider(String modId, int revision, boolean mergeNewEntriesIntoExistingWorlds,
+ JsonObject definition) {
this.modId = modId;
this.revision = revision;
+ this.mergeNewEntriesIntoExistingWorlds = mergeNewEntriesIntoExistingWorlds;
this.definition = JsonCopies.copy(definition);
}
@@ -48,6 +52,15 @@ public int revision() {
return revision;
}
+ /**
+ * Whether provider entries introduced after a world was created may be merged
+ * into that world's saved profile. Providers default to the historical
+ * merge-enabled behaviour.
+ */
+ public boolean mergeNewEntriesIntoExistingWorlds() {
+ return mergeNewEntriesIntoExistingWorlds;
+ }
+
/** Returns a defensive JSON representation matching provider schema 4. */
public JsonObject toJson() {
return JsonCopies.copy(definition);
@@ -56,6 +69,7 @@ public JsonObject toJson() {
public static final class Builder {
private final String modId;
private final int revision;
+ private boolean mergeNewEntriesIntoExistingWorlds = true;
private final LinkedHashMap rocks = new LinkedHashMap<>();
private final LinkedHashMap ores = new LinkedHashMap<>();
private final LinkedHashMap fluidDeposits =
@@ -78,6 +92,16 @@ private Builder(String modId, int revision) {
this.revision = revision;
}
+ /**
+ * Controls whether newly declared entries are added to already-created world
+ * profiles. Set this to false when definitions capture structural add-on
+ * configuration that must only take effect for new worlds.
+ */
+ public Builder mergeNewEntriesIntoExistingWorlds(boolean value) {
+ mergeNewEntriesIntoExistingWorlds = value;
+ return this;
+ }
+
public Builder rock(RockDefinition rock) {
putUnique(rocks, rock.id(), rock, "rock");
return this;
@@ -215,6 +239,8 @@ public WorldgenProvider build() {
root.addProperty("schema_version", 4);
root.addProperty("provider_modid", modId);
root.addProperty("provider_revision", revision);
+ root.addProperty("merge_new_entries_into_existing_worlds",
+ mergeNewEntriesIntoExistingWorlds);
root.add("rocks", object(rocks));
root.add("ores", object(ores));
root.add("fluid_deposits", object(fluidDeposits));
@@ -224,7 +250,7 @@ public WorldgenProvider build() {
root.add("biome_palettes", object(biomePalettes));
root.add("dimension_materials", object(dimensionMaterials));
root.add("templates", object(templates));
- return new WorldgenProvider(modId, revision, root);
+ return new WorldgenProvider(modId, revision, mergeNewEntriesIntoExistingWorlds, root);
}
private void requireOwned(Collection ids, String type) {
@@ -537,6 +563,7 @@ public static final class OreDimensionDefinition implements JsonDefinition {
private final Set excludedBiomeDictionary;
private final Map hostBlockWeights;
private final Map hostTagWeights;
+ private final Double backgroundGenerationScale;
private OreDimensionDefinition(Builder builder) {
dimension = builder.dimension;
@@ -565,6 +592,7 @@ private OreDimensionDefinition(Builder builder) {
new LinkedHashSet<>(builder.excludedBiomeDictionary));
hostBlockWeights = immutableMap(builder.hostBlockWeights);
hostTagWeights = immutableMap(builder.hostTagWeights);
+ backgroundGenerationScale = builder.backgroundGenerationScale;
}
public static Builder builder(ResourceLocation dimension) { return new Builder(dimension); }
@@ -595,6 +623,15 @@ private OreDimensionDefinition(Builder builder) {
public Set excludedBiomeDictionary() { return excludedBiomeDictionary; }
public Map hostBlockWeights() { return hostBlockWeights; }
public Map hostTagWeights() { return hostTagWeights; }
+ /**
+ * Optional scale applied to other OreSpawn-managed generation and vanilla
+ * generation for this rule's primary output in this dimension. The rule
+ * declaring the controller remains unscaled.
+ */
+ public OptionalDouble backgroundGenerationScale() {
+ return backgroundGenerationScale == null ? OptionalDouble.empty()
+ : OptionalDouble.of(backgroundGenerationScale.doubleValue());
+ }
@Override
public JsonObject toJson() {
@@ -632,6 +669,9 @@ public JsonObject toJson() {
json.add("excluded_biome_ids", ids(excludedBiomeIds));
json.add("biome_dictionary", strings(biomeDictionary));
json.add("excluded_biome_dictionary", strings(excludedBiomeDictionary));
+ if (backgroundGenerationScale != null) {
+ json.addProperty("background_generation_scale", backgroundGenerationScale);
+ }
return json;
}
@@ -661,6 +701,7 @@ public static final class Builder {
private final Set excludedBiomeDictionary = new LinkedHashSet<>();
private final Map hostBlockWeights = new LinkedHashMap<>();
private final Map hostTagWeights = new LinkedHashMap<>();
+ private Double backgroundGenerationScale;
private Builder(ResourceLocation dimension) { this.dimension = Objects.requireNonNull(dimension, "dimension"); }
public Builder enabled(boolean value) { enabled = value; return this; }
@@ -695,6 +736,14 @@ public Builder pattern(ResourceLocation type, JsonObject settings) {
public Builder excludeBiomeDictionary(String value) {
excludedBiomeDictionary.add(nonBlank(value)); return this;
}
+ /**
+ * Scales background generation for the same primary resource and dimension.
+ * The declaring rule is the controller and is not itself scaled.
+ */
+ public Builder backgroundGenerationScale(double value) {
+ backgroundGenerationScale = value;
+ return this;
+ }
public Builder hostBlock(ResourceLocation value, double weight) {
hostBlocks.add(value);
hostBlockWeights.put(value, replacementWeight(weight));
@@ -712,6 +761,10 @@ public OreDimensionDefinition build() {
|| minQuantity < 1 || minQuantity > maxQuantity || maxQuantity > 64
|| !Double.isFinite(discardChanceOnAirExposure)
|| discardChanceOnAirExposure < 0.0D || discardChanceOnAirExposure > 1.0D
+ || (backgroundGenerationScale != null
+ && (!Double.isFinite(backgroundGenerationScale)
+ || backgroundGenerationScale < 0.0D
+ || backgroundGenerationScale > 1.0D))
|| spread < 0 || spread > 64 || verticalSpread < 0 || verticalSpread > 64
|| nodeSize < 1 || nodeSize > 32) {
throw new IllegalStateException("Invalid ore placement values for " + dimension);
diff --git a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
index 0dde25e6..6f5e9a94 100644
--- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
+++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
@@ -168,13 +168,28 @@ public static synchronized Set activeProviderIds() {
/** Merge new provider-owned defaults without overwriting pack or world values. */
public static synchronized boolean mergeProviderDefinitions(JsonObject target) {
+ return mergeProviderDefinitions(target, false);
+ }
+
+ /**
+ * Merge provider definitions into an already-created world's saved profile.
+ * Providers may opt out so structural configuration is captured only when a
+ * new world is created.
+ */
+ public static synchronized boolean mergeProviderDefinitionsIntoExistingWorld(JsonObject target) {
+ return mergeProviderDefinitions(target, true);
+ }
+
+ private static boolean mergeProviderDefinitions(JsonObject target, boolean existingWorld) {
JsonObject manifests = object(target, "providers");
JsonObject legacyOreManifests = object(target, "ore_providers");
boolean changed = false;
for (ProviderDefinition provider : ACTIVE_PROVIDERS.values()) {
JsonObject manifest = object(manifests, provider.modId);
- if (!bool(manifest, "profile_defaults_applied", false)
+ boolean mergeNewEntries = !existingWorld
+ || bool(provider.root, "merge_new_entries_into_existing_worlds", true);
+ if (mergeNewEntries && !bool(manifest, "profile_defaults_applied", false)
&& provider.root.has("profile_defaults")
&& provider.root.get("profile_defaults").isJsonObject()) {
mergeOverlay(target, provider.root.getAsJsonObject("profile_defaults"));
@@ -194,7 +209,7 @@ public static synchronized boolean mergeProviderDefinitions(JsonObject target) {
for (Entry entry : providerSection.entrySet()) {
String id = entry.getKey();
- if (!targetSection.has(id) && !known.contains(id)) {
+ if (mergeNewEntries && !targetSection.has(id) && !known.contains(id)) {
JsonObject value = JsonCopies.copy(entry.getValue().getAsJsonObject());
if (!"biome_rules".equals(sectionName)) {
value.addProperty("source_provider", provider.modId);
@@ -202,7 +217,9 @@ public static synchronized boolean mergeProviderDefinitions(JsonObject target) {
targetSection.add(id, value);
changed = true;
}
- known.add(id);
+ if (targetSection.has(id)) {
+ known.add(id);
+ }
if (targetSection.has(id) && targetSection.get(id).isJsonObject()) {
targetSection.getAsJsonObject(id).remove("orphaned_provider");
}
@@ -447,6 +464,11 @@ static void validateProvider(String providerId, JsonObject root) {
if (integer(root, "provider_revision", -1) < 1) {
throw new JsonSyntaxException("provider_revision must be at least 1");
}
+ if (root.has("merge_new_entries_into_existing_worlds")
+ && (!root.get("merge_new_entries_into_existing_worlds").isJsonPrimitive()
+ || !root.getAsJsonPrimitive("merge_new_entries_into_existing_worlds").isBoolean())) {
+ throw new JsonSyntaxException("merge_new_entries_into_existing_worlds must be boolean");
+ }
if (schema == 1) {
for (String section : new String[] { "rocks", "geomes", "biome_rules",
"terrain_dimensions", "fluid_deposits", "biome_palettes",
@@ -571,12 +593,15 @@ private static void validateOreRule(String idText, Entry en
int maxY = integer(rule, "max_y", Integer.MIN_VALUE);
double frequency = decimal(rule, "frequency", -1.0D);
double discardChance = decimal(rule, "discard_chance_on_air_exposure", 0.0D);
+ double backgroundScale = decimal(rule, "background_generation_scale", 1.0D);
int[] quantities = validateQuantityRange(rule);
int minQuantity = quantities[0];
int maxQuantity = quantities[1];
if (minY < -2048 || maxY > 2048 || minY > maxY || frequency < 0.0D
|| frequency > 64.0D || !Double.isFinite(discardChance)
|| discardChance < 0.0D || discardChance > 1.0D
+ || !Double.isFinite(backgroundScale) || backgroundScale < 0.0D
+ || backgroundScale > 1.0D
|| minQuantity < 1 || minQuantity > maxQuantity || maxQuantity > 64) {
throw new JsonSyntaxException("invalid ore placement for " + idText + " in " + entry.getKey());
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
index ecc4cc85..fee7b6a5 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
@@ -58,8 +58,11 @@ public final class OreSpawnOreGeneration {
private static volatile Map oresByDimension = EMPTY_DIMENSIONS;
private static volatile Map> vanillaTakeoverOutputs = Collections.emptyMap();
+ private static volatile Map> backgroundGenerationScales =
+ Collections.emptyMap();
private static volatile BakedOre[] selectorOres = NO_ORES;
private static volatile Set selectorVanillaTakeoverOutputs = Collections.emptySet();
+ private static volatile Map selectorBackgroundGenerationScales = Collections.emptyMap();
private static volatile BakedGeomeConfig geomeConfig;
private static volatile GeomeGeology classifier;
private static volatile long classifierSeed = Long.MIN_VALUE;
@@ -78,8 +81,10 @@ public static void refreshWorldConfig() {
BakedOres baked = bakeOres(WorldGeologyProfileManager.activeProfile().rootCopy(), geomeConfig);
oresByDimension = baked.byDimension;
vanillaTakeoverOutputs = baked.vanillaOutputs;
+ backgroundGenerationScales = baked.backgroundGenerationScales;
selectorOres = baked.selectorOres;
selectorVanillaTakeoverOutputs = baked.selectorVanillaOutputs;
+ selectorBackgroundGenerationScales = baked.selectorBackgroundGenerationScales;
synchronized (CLASSIFIER_LOCK) {
classifier = null;
classifierSeed = Long.MIN_VALUE;
@@ -93,6 +98,28 @@ static boolean takesOverVanillaOre(ResourceLocation dimension, Block output) {
return !WorldgenBenchmark.isVanillaBaseline() && outputs != null && outputs.contains(output);
}
+ static boolean allowsVanillaOre(ResourceLocation dimension, Block output, long worldSeed,
+ int chunkX, int chunkZ) {
+ if (takesOverVanillaOre(dimension, output)) return false;
+ double scale = backgroundScale(dimension, output);
+ if (scale >= 1.0D) return true;
+ if (scale <= 0.0D) return false;
+ ResourceLocation outputId = ForgeRegistries.BLOCKS.getKey(output);
+ return passesBackgroundScale(dimension, outputId, worldSeed, chunkX, chunkZ, scale);
+ }
+
+ static boolean passesBackgroundScale(ResourceLocation dimension, ResourceLocation outputId,
+ long worldSeed, int chunkX, int chunkZ, double scale) {
+ if (scale >= 1.0D) return true;
+ if (scale <= 0.0D) return false;
+ long identity = ((long) dimension.toString().hashCode() << 32)
+ ^ (outputId == null ? 0L : outputId.toString().hashCode() & 0xFFFFFFFFL);
+ long chunk = ((long) chunkX & 0xFFFFFFFFL) | (((long) chunkZ & 0xFFFFFFFFL) << 32);
+ long value = mix(worldSeed, chunk, identity);
+ double sample = (double) (value >>> 11) * 0x1.0p-53;
+ return sample < scale;
+ }
+
static boolean hasManagedOres(ResourceLocation dimension) {
return oresForDimension(dimension).length != 0;
}
@@ -101,7 +128,8 @@ static boolean needsVanillaOreGate(ResourceLocation dimension) {
if (WorldGeologyProfileManager.activeProfile().suppressAllOreFeatures()) return true;
Set outputs = vanillaTakeoverOutputs.get(dimension);
if (outputs == null && selectorAllows(dimension)) outputs = selectorVanillaTakeoverOutputs;
- return outputs != null && !outputs.isEmpty();
+ Map scales = scalesForDimension(dimension);
+ return (outputs != null && !outputs.isEmpty()) || !scales.isEmpty();
}
boolean generate(World world, Chunk chunk, Random random) {
@@ -161,7 +189,8 @@ private static boolean generateChunk(WorldServer level, World world, Chunk chunk
if (!ore.acceptsBiome(biome, biomeId)) {
continue;
}
- double frequency = ore.frequency;
+ double frequency = scaledManagedFrequency(ore.frequency, ore.backgroundController,
+ backgroundScale(dimension, ore.output.getBlock()));
if (geome >= 0) {
frequency *= ore.geomeWeights[geome];
}
@@ -224,6 +253,17 @@ private static BakedOre[] oresForDimension(ResourceLocation dimension) {
return exact != null ? exact : selectorAllows(dimension) ? selectorOres : NO_ORES;
}
+ private static Map scalesForDimension(ResourceLocation dimension) {
+ Map exact = backgroundGenerationScales.get(dimension);
+ return exact != null ? exact
+ : selectorAllows(dimension) ? selectorBackgroundGenerationScales : Collections.emptyMap();
+ }
+
+ private static double backgroundScale(ResourceLocation dimension, Block output) {
+ Double scale = scalesForDimension(dimension).get(output);
+ return scale == null ? 1.0D : scale.doubleValue();
+ }
+
private static boolean insideChunk(Chunk chunk, int x, int y, int z) {
return insideChunk(ChunkAccessCompat.position(chunk), x, y, z);
}
@@ -323,26 +363,37 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
Map result = new HashMap<>();
Map> vanillaOutputs = new HashMap<>();
+ Map> generationScales = new HashMap<>();
for (ResourceLocation dimension : explicitDimensions) {
List combined = new ArrayList<>();
Set suppressed = Collections.newSetFromMap(new IdentityHashMap());
+ Map scales = new IdentityHashMap<>();
for (BakedOreRule rule : rules) {
BakedOre selected = selectRule(rule.explicit, rule.explicitDimensions,
rule.selector, dimension);
if (selected != null) {
combined.add(selected);
if (rule.suppressVanilla) suppressed.add(rule.output);
+ if (selected.backgroundController) {
+ mergeBackgroundScale(scales, rule.output, selected.backgroundGenerationScale);
+ }
}
}
result.put(dimension, combined.toArray(new BakedOre[combined.size()]));
vanillaOutputs.put(dimension, Collections.unmodifiableSet(suppressed));
+ generationScales.put(dimension, Collections.unmodifiableMap(scales));
}
List selectorList = new ArrayList<>();
Set selectorOutputs = Collections.newSetFromMap(new IdentityHashMap());
+ Map selectorScales = new IdentityHashMap<>();
for (BakedOreRule rule : rules) {
if (rule.selector == null) continue;
selectorList.add(rule.selector);
if (rule.suppressVanilla) selectorOutputs.add(rule.output);
+ if (rule.selector.backgroundController) {
+ mergeBackgroundScale(selectorScales, rule.output,
+ rule.selector.backgroundGenerationScale);
+ }
}
BakedOre[] selectorResult = selectorList.toArray(new BakedOre[selectorList.size()]);
LOGGER.info("Baked {} OreSpawn-managed ore definitions across {} dimensions",
@@ -352,8 +403,10 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
immutableVanillaOutputs.put(entry.getKey(), Collections.unmodifiableSet(entry.getValue()));
}
return new BakedOres(Collections.unmodifiableMap(result),
- Collections.unmodifiableMap(immutableVanillaOutputs), selectorResult,
- Collections.unmodifiableSet(selectorOutputs));
+ Collections.unmodifiableMap(immutableVanillaOutputs),
+ Collections.unmodifiableMap(generationScales), selectorResult,
+ Collections.unmodifiableSet(selectorOutputs),
+ Collections.unmodifiableMap(selectorScales));
}
private static void reportBakeProblem(String message, Object... arguments) {
@@ -368,6 +421,17 @@ private static void reportBakeProblem(String message, Object... arguments) {
}
}
+ static double scaledManagedFrequency(double frequency, boolean controller, double scale) {
+ return controller ? frequency : frequency * scale;
+ }
+
+ static void mergeBackgroundScale(Map scales, Block output, double scale) {
+ Double previous = scales.get(output);
+ if (previous == null || scale < previous.doubleValue()) {
+ scales.put(output, scale);
+ }
+ }
+
private static JsonObject objectOrEmpty(JsonObject root, String key) {
return root.has(key) && root.get(key).isJsonObject() ? root.getAsJsonObject(key) : new JsonObject();
}
@@ -421,6 +485,10 @@ private static BakedOre bakeOre(IBlockState output, IBlockState deepOutput, int
int nodeSize = boundedInteger(json, "node_size", 4, 1, 32);
double discardChanceOnAirExposure = Math.max(0.0D, Math.min(1.0D,
decimal(json, "discard_chance_on_air_exposure", 0.0D)));
+ boolean backgroundController = json.has("background_generation_scale");
+ double backgroundGenerationScale = backgroundController
+ ? Math.max(0.0D, Math.min(1.0D,
+ decimal(json, "background_generation_scale", 1.0D))) : 1.0D;
double[] geomeWeights = new double[config.geomeCount()];
java.util.Arrays.fill(geomeWeights, 1.0D);
@@ -442,7 +510,8 @@ private static BakedOre bakeOre(IBlockState output, IBlockState deepOutput, int
pattern, heightDistribution, discardChanceOnAirExposure,
spread, verticalSpread, nodeSize,
hostBlocks, hostStates, familyMask, geomeWeights, includedBiomeIds, excludedBiomeIds,
- includedDictionaryBiomes, excludedDictionaryBiomes, retrogen);
+ includedDictionaryBiomes, excludedDictionaryBiomes, retrogen,
+ backgroundController, backgroundGenerationScale);
}
static boolean hasHostTargets(Map blocks,
@@ -656,6 +725,8 @@ private static final class BakedOre {
final Set includedDictionaryBiomes;
final Set excludedDictionaryBiomes;
final boolean retrogen;
+ final boolean backgroundController;
+ final double backgroundGenerationScale;
BakedOre(IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs,
int minY, int maxY, double frequency, int minQuantity, int maxQuantity,
@@ -666,7 +737,8 @@ private static final class BakedOre {
int familyMask, double[] geomeWeights,
Set includedBiomeIds, Set excludedBiomeIds,
Set includedDictionaryBiomes, Set excludedDictionaryBiomes,
- boolean retrogen) {
+ boolean retrogen, boolean backgroundController,
+ double backgroundGenerationScale) {
this.output = output;
this.deepOutput = deepOutput;
this.deepOutputMaxY = deepOutputMaxY;
@@ -691,6 +763,8 @@ private static final class BakedOre {
this.includedDictionaryBiomes = includedDictionaryBiomes;
this.excludedDictionaryBiomes = excludedDictionaryBiomes;
this.retrogen = retrogen;
+ this.backgroundController = backgroundController;
+ this.backgroundGenerationScale = backgroundGenerationScale;
}
IBlockState outputAt(int y, Random random) {
@@ -760,20 +834,26 @@ boolean acceptsY(int y) {
private static final class BakedOres {
static final BakedOres EMPTY = new BakedOres(EMPTY_DIMENSIONS, Collections.emptyMap(),
- NO_ORES, Collections.emptySet());
+ Collections.emptyMap(), NO_ORES, Collections.emptySet(), Collections.emptyMap());
final Map byDimension;
final Map> vanillaOutputs;
+ final Map> backgroundGenerationScales;
final BakedOre[] selectorOres;
final Set selectorVanillaOutputs;
+ final Map selectorBackgroundGenerationScales;
BakedOres(Map byDimension,
- Map> vanillaOutputs, BakedOre[] selectorOres,
- Set selectorVanillaOutputs) {
+ Map> vanillaOutputs,
+ Map> backgroundGenerationScales,
+ BakedOre[] selectorOres, Set selectorVanillaOutputs,
+ Map selectorBackgroundGenerationScales) {
this.byDimension = byDimension;
this.vanillaOutputs = vanillaOutputs;
+ this.backgroundGenerationScales = backgroundGenerationScales;
this.selectorOres = selectorOres;
this.selectorVanillaOutputs = selectorVanillaOutputs;
+ this.selectorBackgroundGenerationScales = selectorBackgroundGenerationScales;
}
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java
index bd234d47..bbd7de17 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnWorldGenerator.java
@@ -65,8 +65,12 @@ public void filterVanillaOre(OreGenEvent.GenerateMinable event) {
return;
}
Block output = vanillaOutput(event.getType());
- if (output != null && OreSpawnOreGeneration.takesOverVanillaOre(dimension, output)) {
- event.setResult(Event.Result.DENY);
+ if (output != null) {
+ ChunkPos chunk = new ChunkPos(event.getPos());
+ if (!OreSpawnOreGeneration.allowsVanillaOre(dimension, output,
+ event.getWorld().getSeed(), chunk.chunkXPos, chunk.chunkZPos)) {
+ event.setResult(Event.Result.DENY);
+ }
}
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java
index 597e9384..02139ebe 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java
@@ -18,7 +18,6 @@
import com.google.gson.JsonSyntaxException;
import zone.moddev.mc.orespawn.OreSpawnConfig;
import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode;
-import zone.moddev.mc.orespawn.api.OreSpawnOreIntegration;
import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager;
import net.minecraft.server.MinecraftServer;
@@ -105,7 +104,7 @@ public static synchronized boolean reloadActiveProfile() {
.resolve("serverconfig").resolve(PROFILE_FILE_NAME);
WorldGeologyProfile profile = readProfile(profilePath, globalProfile());
JsonObject merged = profile.rootCopy();
- if (OreSpawnOreIntegration.mergeProviderOres(merged)) {
+ if (WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged)) {
profile = profile.withRoot(merged);
writeProfile(profilePath, profile);
}
@@ -127,7 +126,7 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) {
profile = readProfile(profilePath, fallback);
JsonObject merged = profile.rootCopy();
String beforeMerge = merged.toString();
- OreSpawnOreIntegration.mergeProviderOres(merged);
+ WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged);
if (!beforeMerge.equals(merged.toString())) {
profile = profile.withRoot(merged);
writeProfile(profilePath, profile);
diff --git a/src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java b/src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java
new file mode 100644
index 00000000..699eb3a7
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/api/GeologySamplerSourceContractTest.java
@@ -0,0 +1,21 @@
+package zone.moddev.mc.orespawn.api;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import org.junit.jupiter.api.Test;
+
+class GeologySamplerSourceContractTest {
+ @Test
+ void samplingUsesTheBiomeProviderWithoutLoadingAChunk() throws Exception {
+ String source = new String(Files.readAllBytes(Paths.get(
+ "src/main/java/zone/moddev/mc/orespawn/api/OreSpawnGeologySampler.java")),
+ StandardCharsets.UTF_8);
+ assertTrue(source.contains("level.provider.getBiomeProvider().getBiome(position)"));
+ assertFalse(source.contains("level.getBiome(position)"));
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java
index c4bc6d13..68f57d10 100644
--- a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java
@@ -194,6 +194,45 @@ void serializesCodecBackedPatternWithoutLeakingMutableSettings() {
assertEquals(12, pattern.getAsJsonObject("settings").get("radius").getAsInt());
}
+ @Test
+ void serializesBackgroundControllerAndExistingWorldMergePolicy() {
+ WorldgenProvider.OreDimensionDefinition placement = WorldgenProvider.OreDimensionDefinition
+ .builder(id("minecraft:overworld"))
+ .hostBlock(id("minecraft:stone"))
+ .backgroundGenerationScale(0.25D)
+ .build();
+ WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1)
+ .mergeNewEntriesIntoExistingWorlds(false)
+ .ore(id("examplemod:iron_controller"), id("minecraft:iron_ore"), ore -> ore
+ .retrogen(false).dimension(placement))
+ .build();
+
+ assertFalse(provider.mergeNewEntriesIntoExistingWorlds());
+ assertEquals(0.25D, placement.backgroundGenerationScale().getAsDouble());
+ JsonObject json = provider.toJson();
+ assertFalse(json.get("merge_new_entries_into_existing_worlds").getAsBoolean());
+ assertEquals(0.25D, json.getAsJsonObject("ores")
+ .getAsJsonObject("examplemod:iron_controller")
+ .getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld")
+ .get("background_generation_scale").getAsDouble());
+ }
+
+ @Test
+ void backgroundControllerDefaultsAreNonControllingAndBoundsAreValidated() {
+ WorldgenProvider.OreDimensionDefinition ordinary = WorldgenProvider.OreDimensionDefinition
+ .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone")).build();
+ assertFalse(ordinary.backgroundGenerationScale().isPresent());
+ assertTrue(WorldgenProvider.builder("examplemod", 1)
+ .ore(id("examplemod:ordinary"), id("minecraft:iron_ore"), ore -> ore.dimension(ordinary))
+ .build().mergeNewEntriesIntoExistingWorlds());
+ assertThrows(IllegalStateException.class, () -> WorldgenProvider.OreDimensionDefinition
+ .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone"))
+ .backgroundGenerationScale(-0.01D).build());
+ assertThrows(IllegalStateException.class, () -> WorldgenProvider.OreDimensionDefinition
+ .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone"))
+ .backgroundGenerationScale(1.01D).build());
+ }
+
@Test
void rejectsDefinitionsOutsideProviderNamespace() {
assertThrows(IllegalStateException.class, () -> WorldgenProvider.builder("examplemod", 1)
diff --git a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java
index d9f3929d..14976eab 100644
--- a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java
@@ -7,7 +7,10 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.Map;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
@@ -113,6 +116,47 @@ void validatesBiomePaletteAndDimensionMaterialsOnlyInSchemaFour() {
() -> WorldgenIntegrationManager.validateProvider("examplemod", solidFluid));
}
+ @Test
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ void providerCanDeferNewEntriesForExistingWorldsWithoutChangingNewWorldDefaults()
+ throws Exception {
+ JsonObject root = new JsonObject();
+ root.addProperty("schema_version", 4);
+ root.addProperty("provider_modid", "realisticdeposits");
+ root.addProperty("provider_revision", 1);
+ root.addProperty("merge_new_entries_into_existing_worlds", false);
+ JsonObject ores = new JsonObject();
+ ores.add("realisticdeposits:deposit/minecraft/iron_ore", new JsonObject());
+ root.add("ores", ores);
+
+ Class> definitionType = Class.forName(
+ "zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager$ProviderDefinition");
+ Constructor> constructor = definitionType.getDeclaredConstructor(
+ String.class, int.class, JsonObject.class);
+ constructor.setAccessible(true);
+ Object provider = constructor.newInstance("realisticdeposits", 1, root);
+ Field activeField = WorldgenIntegrationManager.class.getDeclaredField("ACTIVE_PROVIDERS");
+ activeField.setAccessible(true);
+ Map active = (Map) activeField.get(null);
+ Map saved = new LinkedHashMap(active);
+ try {
+ active.clear();
+ active.put("realisticdeposits", provider);
+ JsonObject existingWorld = new JsonObject();
+ WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(existingWorld);
+ assertFalse(existingWorld.getAsJsonObject("ores")
+ .has("realisticdeposits:deposit/minecraft/iron_ore"));
+
+ JsonObject newWorld = new JsonObject();
+ WorldgenIntegrationManager.mergeProviderDefinitions(newWorld);
+ assertTrue(newWorld.getAsJsonObject("ores")
+ .has("realisticdeposits:deposit/minecraft/iron_ore"));
+ } finally {
+ active.clear();
+ active.putAll(saved);
+ }
+ }
+
private static JsonObject provider(String block, boolean withHost, int schema) {
JsonObject root = new JsonObject();
root.addProperty("schema_version", schema);
diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java
new file mode 100644
index 00000000..3fcf00ab
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java
@@ -0,0 +1,55 @@
+package zone.moddev.mc.orespawn.worldgen;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import net.minecraft.block.Block;
+import net.minecraft.block.material.Material;
+import net.minecraft.util.ResourceLocation;
+
+class BackgroundGenerationScaleTest {
+ private static final ResourceLocation OVERWORLD = new ResourceLocation("minecraft", "overworld");
+ private static final ResourceLocation IRON = new ResourceLocation("minecraft", "iron_ore");
+
+ @Test
+ void controllerRuleRemainsUnscaledWhileBackgroundRulesAreScaled() {
+ assertEquals(8.0D, OreSpawnOreGeneration.scaledManagedFrequency(8.0D, true, 0.25D));
+ assertEquals(2.0D, OreSpawnOreGeneration.scaledManagedFrequency(8.0D, false, 0.25D));
+ }
+
+ @Test
+ void lowestControllerScaleWinsForTheSamePrimaryResource() {
+ Map scales = new IdentityHashMap<>();
+ Block output = new Block(Material.ROCK);
+ OreSpawnOreGeneration.mergeBackgroundScale(scales, output, 0.75D);
+ OreSpawnOreGeneration.mergeBackgroundScale(scales, output, 0.25D);
+ OreSpawnOreGeneration.mergeBackgroundScale(scales, output, 0.5D);
+ assertEquals(0.25D, scales.get(output).doubleValue());
+ }
+
+ @Test
+ void vanillaScaleGateIsStableByWorldDimensionChunkAndResource() {
+ assertFalse(OreSpawnOreGeneration.passesBackgroundScale(
+ OVERWORLD, IRON, 42L, -3, 7, 0.0D));
+ assertTrue(OreSpawnOreGeneration.passesBackgroundScale(
+ OVERWORLD, IRON, 42L, -3, 7, 1.0D));
+ boolean first = OreSpawnOreGeneration.passesBackgroundScale(
+ OVERWORLD, IRON, 42L, -3, 7, 0.25D);
+ assertEquals(first, OreSpawnOreGeneration.passesBackgroundScale(
+ OVERWORLD, IRON, 42L, -3, 7, 0.25D));
+
+ int accepted = 0;
+ for (int chunk = -5000; chunk < 5000; chunk++) {
+ if (OreSpawnOreGeneration.passesBackgroundScale(
+ OVERWORLD, IRON, 42L, chunk, -chunk, 0.25D)) accepted++;
+ }
+ assertTrue(accepted > 2200 && accepted < 2800,
+ "quarter-scale gate should retain approximately one quarter of chunks: " + accepted);
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java
index 3a869dc6..7a82fa46 100644
--- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java
@@ -26,7 +26,7 @@ void coordinatorRunsSurfacesBeforeManagedOresAndLeavesStaticBiomeListsAlone() th
assertTrue(source.contains("@SubscribeEvent(priority = EventPriority.LOWEST)\r\n\tpublic void filterVanillaOre")
|| source.contains("@SubscribeEvent(priority = EventPriority.LOWEST)\n\tpublic void filterVanillaOre"),
"vanilla-ore suppression must make its decision at Forge's final standard event priority");
- assertTrue(source.contains("OreSpawnOreGeneration.takesOverVanillaOre(dimension, output)"));
+ assertTrue(source.contains("OreSpawnOreGeneration.allowsVanillaOre(dimension, output"));
assertTrue(source.contains("event.setResult(Event.Result.DENY)"));
assertTrue(source.contains("earlyComplete.add(key)") && source.contains("oreComplete.add(key)"),
"terrain and ore callbacks must be independently deduplicated");
From 60beadd10c02f70e125d9e6a3998cc9ef637c0ee Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Fri, 11 Sep 2026 09:56:19 +0100
Subject: [PATCH 04/31] Document OreSpawn 4.1 extension policies
---
CHANGELOG.txt | 6 ++++++
README.md | 7 ++++---
docs/API.md | 19 +++++++++++++++++--
docs/DEVELOPER_GUIDE.md | 1 +
docs/PROVIDERS.md | 9 ++++++---
docs/schemas/orespawn-provider.schema.json | 1 +
docs/schemas/profile-common.schema.json | 5 +++++
7 files changed, 40 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 64980315..a2ead528 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -7,6 +7,12 @@ Version 4.1.0.110021
* Add a deterministic client-only registry for translated add-on actions in
OreSpawn's world-settings screen, with parent-screen handoff and duplicate-ID
rejection.
+* Add optional per-resource background-generation scaling for controller
+ patterns, including deterministic vanilla gating and lowest-controller wins.
+* Let structural providers keep newly introduced entries out of existing world
+ profiles while retaining the historical merge behavior by default.
+* Guarantee geology column sampling does not load or generate neighbouring
+ chunks.
* Preserve API major 1, schemas 4/6/5, existing pattern binaries, profiles,
migration behaviour and generated chunks.
diff --git a/README.md b/README.md
index 6a3f8330..65a86449 100644
--- a/README.md
+++ b/README.md
@@ -11,9 +11,10 @@ rock strata and geomes, provider-owned underground fluid deposits, biome
palettes and world materials, flat bedrock, and bounded ore retrogen.
This branch builds target-qualified version `4.1.0.110021`: the OreSpawn 4.1.0
-feature set for Minecraft 1.10.2 and Forge. It adds stable generation identity
-and a client world-settings extension point for add-ons such as Realistic
-Deposits while retaining API major 1. See the
+feature set for Minecraft 1.10.2 and Forge. It adds stable generation identity,
+non-loading geology sampling, per-resource background control, existing-world
+provider merge policy and a client world-settings extension point for add-ons
+such as Realistic Deposits while retaining API major 1. See the
[versioning policy](docs/VERSIONS.md) for the encoding and release convention.
Its deprecated compatibility layer imports OreSpawn 1 and OreSpawn 3
diff --git a/docs/API.md b/docs/API.md
index 98b9fd85..5bbec638 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -150,8 +150,9 @@ OreSpawnApi.createSampler(server.overworld()).ifPresent(sampler -> {
```
`sampleColumn` performs one biome/geome classification and reuses it for every
-Y query. Sampling is read-only and is intended for gameplay decisions,
-diagnostics, and compatible generation outside OreSpawn's block loops.
+Y query. Sampling is read-only, does not load or generate the requested chunk,
+and is intended for gameplay decisions, diagnostics, and compatible generation
+outside OreSpawn's block loops.
OreSpawn 4.1 adds `OreGenerationContext` as a binary-compatible subtype of the
original `OrePlacementContext`. Every context supplied by OreSpawn implements
@@ -176,6 +177,20 @@ patterns must independently render only the slice intersecting the current
chunk and continue to use `inside(...)` and `tryPlace(...)` for safe writes.
Do not retain the sampler or placement context beyond the current call.
+Large-deposit add-ons may make one OreSpawn rule the controller for a resource
+in a dimension. Set `.backgroundGenerationScale(value)` on that rule to scale
+other OreSpawn-managed rules and vanilla generation for the same primary
+output. The controller rule remains unscaled, omitted values mean `1.0`, zero
+fully suppresses background generation, and the lowest value wins when several
+controllers target the same resource. Vanilla decisions use a stable hash of
+world, dimension, chunk and resource rather than mutable event order.
+
+Providers whose entries capture structural configuration can call
+`.mergeNewEntriesIntoExistingWorlds(false)`. New worlds still receive the
+complete provider, while an existing saved world profile does not silently gain
+definitions introduced after that world was created. The historical default is
+`true`.
+
Forge 12 custom-pattern mods attach a generic
`RegistryEvent.Register` listener to their mod event bus and
register named values into `OreSpawnPatternRegistry.REGISTRY_NAME`. An
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index 0de6b9d3..24004910 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -14,6 +14,7 @@
| Replace surfaces, aquifers, snow, or ice | Provider schema 4 dimension materials |
| Inspect active geology at runtime | `GeologyProfileView` and `GeologySampler` |
| Build a deterministic region-scale ore pattern | `OreGenerationContext` |
+| Replace or blend background ore generation | `backgroundGenerationScale` |
| Add an add-on settings screen | Client-only `WorldSettingsExtensionRegistry` |
Strata are optional. If no enabled terrain dimension has eligible rocks,
diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md
index 2bf28838..533803b3 100644
--- a/docs/PROVIDERS.md
+++ b/docs/PROVIDERS.md
@@ -101,9 +101,12 @@ Only suppress a provider mod's native ore generation when
`OreSpawnApi.isOreTakeoverActive(modid)` returns true. `PENDING` means discovery
has not frozen. `INACTIVE` is the fail-safe and native generation must remain.
-Existing worlds merge newly introduced provider rule IDs but do not overwrite
-world edits. Disabled and unassigned rules remain tombstones; removed provider
-rules remain in the self-contained snapshot.
+Existing worlds normally merge newly introduced provider rule IDs but do not
+overwrite world edits. A Java provider may opt out with
+`mergeNewEntriesIntoExistingWorlds(false)` when its rules capture structural
+add-on configuration that must begin only in newly created worlds. Disabled and
+unassigned rules remain tombstones; removed provider rules remain in the
+self-contained snapshot.
Biome providers can add Forge biomes normally, then declare where those biomes
belong through `biome_palettes`. The overlay wraps the dimension's existing
diff --git a/docs/schemas/orespawn-provider.schema.json b/docs/schemas/orespawn-provider.schema.json
index 83f67f7b..8fa0f191 100644
--- a/docs/schemas/orespawn-provider.schema.json
+++ b/docs/schemas/orespawn-provider.schema.json
@@ -7,6 +7,7 @@
"schema_version": { "enum": [1, 2, 3, 4] },
"provider_modid": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]+$" },
"provider_revision": { "type": "integer", "minimum": 1 },
+ "merge_new_entries_into_existing_worlds": { "type": "boolean" },
"rocks": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/rock" } },
"ores": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/ore" } },
"fluid_deposits": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/fluidDeposit" } },
diff --git a/docs/schemas/profile-common.schema.json b/docs/schemas/profile-common.schema.json
index 87cefe57..d643ded5 100644
--- a/docs/schemas/profile-common.schema.json
+++ b/docs/schemas/profile-common.schema.json
@@ -148,6 +148,11 @@
"minimum": 0.0,
"maximum": 1.0
},
+ "background_generation_scale": {
+ "type": "number",
+ "minimum": 0.0,
+ "maximum": 1.0
+ },
"spread": { "type": "integer", "minimum": 0, "maximum": 64 },
"vertical_spread": { "type": "integer", "minimum": 0, "maximum": 64 },
"node_size": { "type": "integer", "minimum": 1, "maximum": 32 },
From 7c4a7c8d956cdffb69b5091a7aed25806c9200bf Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Sat, 12 Sep 2026 15:48:13 +0100
Subject: [PATCH 05/31] Add OreSpawn mod integration directory
---
CHANGELOG.txt | 11 +-
README.md | 7 +-
build.gradle | 2 +
docs/API.md | 30 ++-
docs/DEVELOPER_GUIDE.md | 24 ++-
docs/PLAYER_GUIDE.md | 11 +-
.../orespawn/client/ClientProbeTestMod.java | 62 ++++++
.../orespawn/compat/LegacyOs3Bridge.java | 31 +++
.../api/client/WorldSettingsExtension.java | 2 +-
.../WorldSettingsExtensionRegistry.java | 50 ++++-
.../client/WorldSettingsScreenFactory.java | 2 +-
.../mc/orespawn/client/ClientSetup.java | 1 +
.../moddev/mc/orespawn/client/CogButton.java | 27 +++
.../client/OreSpawnModDirectoryModel.java | 123 ++++++++++++
.../orespawn/client/OreSpawnModsScreen.java | 189 ++++++++++++++++++
.../client/OreSpawnWorldSettingsScreen.java | 43 ++--
.../WorldSettingsExtensionNavigation.java | 39 ++++
.../WorldgenIntegrationManager.java | 101 +++++++++-
.../worldgen/LegacyConfigMigrator.java | 11 +
.../resources/assets/orespawn/lang/de_AU.lang | 23 +++
.../resources/assets/orespawn/lang/de_DE.lang | 23 +++
.../resources/assets/orespawn/lang/en_CA.lang | 23 +++
.../resources/assets/orespawn/lang/en_EN.lang | 23 +++
.../resources/assets/orespawn/lang/en_GB.lang | 23 +++
.../resources/assets/orespawn/lang/en_PT.lang | 23 +++
.../resources/assets/orespawn/lang/en_US.lang | 23 +++
.../resources/assets/orespawn/lang/es_ES.lang | 23 +++
.../resources/assets/orespawn/lang/es_MX.lang | 23 +++
.../resources/assets/orespawn/lang/fr_CA.lang | 23 +++
.../resources/assets/orespawn/lang/fr_FR.lang | 23 +++
.../resources/assets/orespawn/lang/ja_JP.lang | 23 +++
.../resources/assets/orespawn/lang/ko_KR.lang | 23 +++
.../resources/assets/orespawn/lang/pt_BR.lang | 23 +++
.../resources/assets/orespawn/lang/ru_RU.lang | 23 +++
.../resources/assets/orespawn/lang/zh_CN.lang | 23 +++
.../assets/orespawn/textures/gui/cog.png | Bin 0 -> 321 bytes
.../mc/orespawn/LocalizationParityTest.java | 2 +-
.../mc/orespawn/TooltipAlignmentTest.java | 1 +
.../WorldSettingsExtensionRegistryTest.java | 80 ++++++--
.../client/OreSpawnModDirectoryModelTest.java | 111 ++++++++++
.../OreSpawnModsScreenContractTest.java | 75 +++++++
.../client/OreSpawnScreenLayoutTest.java | 9 +-
42 files changed, 1328 insertions(+), 84 deletions(-)
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/CogButton.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java
create mode 100644 src/main/resources/assets/orespawn/textures/gui/cog.png
create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index a2ead528..a745dbba 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -4,9 +4,14 @@ Version 4.1.0.110021
world seed, dimension and current-chunk identity to compiled pattern add-ons.
* Expose the active geology sampler to patterns when the generated dimension
has OreSpawn geology, including identical identity during supported retrogen.
-* Add a deterministic client-only registry for translated add-on actions in
- OreSpawn's world-settings screen, with parent-screen handoff and duplicate-ID
- rejection.
+* Replace per-add-on world-settings buttons with one fixed Mods directory that
+ lists loaded native OS4, legacy OS1-OS3 and UI-only integrations with their
+ mod version, provider lineage, revision and status.
+* Add a packaged cog action for add-on-owned configuration screens, including
+ pagination, disabled-state explanations, safe failure handling and a return
+ path through the Mods directory.
+* Preserve the original three-argument client registration descriptor while
+ adding one-screen-per-mod `registerConfigScreen` registration.
* Add optional per-resource background-generation scaling for controller
patterns, including deterministic vanilla gating and lowest-controller wins.
* Let structural providers keep newly introduced entries out of existing world
diff --git a/README.md b/README.md
index 65a86449..81738d29 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,12 @@ TerraBlender.
When a provider exposes world settings, use **OreSpawn...** on the Create World
screen. **Recommended Defaults** restores the settings supplied by the
-installed mods and pack. The in-game **Help & Guide** explains the controls.
+installed mods and pack. **Mods** opens a directory of every loaded mod that
+OreSpawn recognizes through a current provider, legacy compatibility path, or
+client configuration hook. It shows each mod's version, integration lineage,
+provider revision and status; its cog opens an add-on-owned configuration
+screen when one is available. The in-game **Help & Guide** explains the other
+controls.
Important files:
diff --git a/build.gradle b/build.gradle
index 528639e2..99b03906 100644
--- a/build.gradle
+++ b/build.gradle
@@ -673,6 +673,8 @@ task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegratio
Properties result = new Properties(); marker.withInputStream { result.load(it) }
if (result.getProperty('reload_rendered') != 'true'
|| result.getProperty('world_settings_opened') != 'true'
+ || result.getProperty('mods_directory_rendered') != 'true'
+ || result.getProperty('directory_stack_validated') != 'true'
|| result.getProperty('long_editor_roundtrip') != 'true'
|| Integer.parseInt(result.getProperty('editor_routes', '0')) < 5) {
throw new GradleException("Client integration result is incomplete: ${marker}")
diff --git a/docs/API.md b/docs/API.md
index 5bbec638..ed2a10c6 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -202,9 +202,18 @@ generation.
## Client world-settings extensions
-An add-on may contribute a translated action to OreSpawn's world-settings
-screen without depending on OreSpawn implementation classes. Register from
-client initialization only:
+An add-on may contribute one configuration screen to OreSpawn's **Mods**
+directory without depending on OreSpawn implementation classes. Prefer the
+owning-mod registration form during client initialization:
+
+```java
+WorldSettingsExtensionRegistry.registerConfigScreen(
+ "examplemod",
+ parent -> new ExampleDepositSettingsScreen(parent));
+```
+
+The original three-argument form remains binary compatible. Its resource
+namespace is treated as the owning mod ID:
```java
WorldSettingsExtensionRegistry.register(
@@ -213,12 +222,15 @@ WorldSettingsExtensionRegistry.register(
parent -> new ExampleDepositSettingsScreen(parent));
```
-OreSpawn owns layout and navigation and passes its current screen as the
-factory parent, so the add-on's Done action can return safely. Entries appear
-in deterministic registration order. Duplicate IDs, blank translation keys
-and null factories are rejected. The translation belongs in the add-on's own
-language resources. These types are client-only and must not be loaded from a
-dedicated-server initialization path.
+OreSpawn owns directory layout and passes the Mods directory as the factory
+parent. Add-ons should use that parent for Done; OreSpawn also redirects the
+inherited vanilla Escape action there before the player returns to the main
+geology screen. Exactly one screen may be owned by
+each mod; duplicate ownership, blank legacy translation keys and null factories
+are rejected deterministically. The legacy translation key and extension
+accessors remain available to existing binaries, although the directory now
+uses its packaged cog action. These types are client-only and must not be
+loaded from a dedicated-server initialization path.
`OreSpawnOreIntegration` remains as a deprecated facade for early ore-provider
integrations. New code should use `OreSpawnApi`.
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index 24004910..8fbf88eb 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -138,7 +138,17 @@ setup in the registered pattern compiler, not its placement callback.
## Add-on world settings
-Register optional client configuration screens during client initialization:
+Register one optional client configuration screen during client
+initialization. The preferred form identifies the owning mod directly:
+
+```java
+WorldSettingsExtensionRegistry.registerConfigScreen(
+ "examplemod",
+ parent -> new ExampleDepositSettingsScreen(parent));
+```
+
+The existing form remains available to already-compiled add-ons, and its
+resource namespace becomes the owner:
```java
WorldSettingsExtensionRegistry.register(
@@ -147,10 +157,14 @@ WorldSettingsExtensionRegistry.register(
parent -> new ExampleDepositSettingsScreen(parent));
```
-The registry is client-only. OreSpawn renders the translated button, controls
-its placement, and supplies the current OreSpawn screen as the parent. The
-extension owns its own screen and translations and must return to that parent
-from Done. Duplicate identifiers are rejected deterministically.
+The registry is client-only. OreSpawn lists the loaded mod once in its
+paginated **Mods** directory, renders a packaged cog, and supplies that
+directory as the parent. The extension owns its complete screen and should
+return to that parent from Done; OreSpawn redirects inherited vanilla Escape
+there as well. OreSpawn synchronizes its pending editor session before opening
+the directory. Only one configuration screen may be
+registered per owning mod, so two resource paths in the same namespace are a
+deterministic duplicate rather than extra main-screen rows.
## Pack Override Quick Start
diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md
index 2bd031c2..c0682804 100644
--- a/docs/PLAYER_GUIDE.md
+++ b/docs/PLAYER_GUIDE.md
@@ -9,8 +9,11 @@ For a normal game:
1. Open **OreSpawn...** while creating the world.
2. Choose **Recommended Defaults** unless you want to customise geology.
-3. Open **Help & Guide** for a plain-language tour of the controls.
-4. Press **Done**, then create the world normally.
+3. Open **Mods** to see which installed mods OreSpawn recognized and whether
+ their provider is active. A lit cog opens that mod's own settings; a dim cog
+ explains that the mod did not supply a screen.
+4. Open **Help & Guide** for a plain-language tour of the controls.
+5. Press **Done**, then create the world normally.
Hover over unfamiliar controls for a short explanation. The same explanations
are collected in **Help & Guide**, so a setting can be learned either while
@@ -43,6 +46,10 @@ safely instead of breaking world creation.
region.
- **Manage Vanilla Ores** lets OreSpawn replace vanilla ore features with the
configured OreSpawn rules. Leave it off to keep normal Minecraft placement.
+- **Mods** opens a paginated directory of loaded OreSpawn integrations. It
+ reports the Forge mod version, native OS4 or legacy OS1-OS3 lineage, provider
+ schema/revision and current status. Historical profile owners that are not
+ loaded are not shown.
- **Fluid Deposits** appears after strata are enabled or when a mod or pack
supplies a rule. Press **Add** to choose water, lava, or a fluid block from an
installed mod. These are covered underground deposits, not exposed vanilla
diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
index 1a9003a6..5fac2bf7 100644
--- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
+++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
@@ -3,6 +3,9 @@
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Properties;
@@ -45,6 +48,9 @@ public final class ClientProbeTestMod {
private int reloadWorldFrames;
private int editorFrames;
private boolean worldSettingsOpened;
+ private boolean modsDirectoryRendered;
+ private boolean directoryStackValidated =
+ System.getProperty("clientprobe.expectedMods", "").trim().isEmpty();
private boolean longEditorRoundTrip;
@Mod.EventHandler
@@ -64,6 +70,7 @@ public void onScreenInitialized(GuiScreenEvent.InitGuiEvent.Post event) {
@SubscribeEvent
public void onScreenDrawn(GuiScreenEvent.DrawScreenEvent.Post event) {
if (event.getGui() instanceof OreSpawnScreen) editorFrames++;
+ if (event.getGui() instanceof OreSpawnModsScreen) modsDirectoryRendered = true;
}
@SubscribeEvent
@@ -100,6 +107,7 @@ public void onClientTick(TickEvent.ClientTickEvent event) {
if (minecraft.currentScreen instanceof OreSpawnWorldSettingsScreen && editorFrames >= 2) {
worldSettingsOpened = true;
validateCaptions((OreSpawnWorldSettingsScreen) minecraft.currentScreen);
+ validateExpectedDirectory(minecraft.currentScreen);
validateLongEditorRoundTrip(minecraft, minecraft.currentScreen);
nextState(3);
}
@@ -174,6 +182,57 @@ public void onClientTick(TickEvent.ClientTickEvent event) {
}
}
+ private void validateExpectedDirectory(GuiScreen root) {
+ String configured = System.getProperty("clientprobe.expectedMods", "").trim();
+ if (configured.isEmpty()) return;
+ List expected = Arrays.asList(configured.split(","));
+ List entries = OreSpawnModDirectoryModel.snapshot();
+ List actual = new ArrayList<>();
+ for (OreSpawnModDirectoryModel.Entry entry : entries) actual.add(entry.modId);
+ if (!expected.equals(actual)) {
+ throw new IllegalStateException("Unexpected OreSpawn Mods directory order: expected "
+ + expected + " but found " + actual);
+ }
+ for (OreSpawnModDirectoryModel.Entry entry : entries) {
+ if (entry.version == null || entry.version.trim().isEmpty() || "?".equals(entry.version)) {
+ throw new IllegalStateException("Missing Forge mod version for " + entry.modId);
+ }
+ if ("basemetals".equals(entry.modId)) {
+ if (entry.nativeOs4() || entry.legacyLineages().isEmpty()) {
+ throw new IllegalStateException("Base Metals did not retain its legacy lineage");
+ }
+ } else if (!entry.nativeOs4() || entry.schemaVersion() != 4
+ || entry.providerRevision() < 1) {
+ throw new IllegalStateException("Missing native OS4 schema/revision for " + entry.modId);
+ }
+ boolean shouldConfigure = "realisticdeposits".equals(entry.modId);
+ if (entry.configurable() != shouldConfigure) {
+ throw new IllegalStateException("Unexpected cog availability for " + entry.modId);
+ }
+ if (shouldConfigure) {
+ OreSpawnModsScreen directory = new OreSpawnModsScreen(root, entries);
+ GuiScreen child = entry.extension.createScreen(directory);
+ if (child == null || !child.getClass().getName().endsWith("RealisticDepositsConfigScreen")) {
+ throw new IllegalStateException("Realistic Deposits did not create its config screen");
+ }
+ try {
+ Minecraft minecraft = Minecraft.getMinecraft();
+ WorldSettingsExtensionNavigation.open(directory, child);
+ Method escape = GuiScreen.class.getDeclaredMethod("keyTyped", char.class, int.class);
+ escape.setAccessible(true);
+ escape.invoke(child, '\0', org.lwjgl.input.Keyboard.KEY_ESCAPE);
+ if (minecraft.currentScreen != directory) {
+ throw new IllegalStateException("Add-on Escape did not return to the Mods directory");
+ }
+ minecraft.displayGuiScreen(root);
+ } catch (ReflectiveOperationException failure) {
+ throw new IllegalStateException("Could not exercise add-on Escape", failure);
+ }
+ }
+ }
+ directoryStackValidated = true;
+ }
+
private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) {
for (GuiButton widget : root.buttons) {
if (!(widget instanceof Button) || widget instanceof CycleButton) continue;
@@ -189,6 +248,7 @@ private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) {
private static void validateCaptions(OreSpawnScreen screen) {
for (GuiButton widget : screen.buttons) {
+ if (widget instanceof CogButton) continue;
String caption = TextFormatting.getTextWithoutFormattingCodes(widget.displayString);
if (caption == null || caption.trim().isEmpty()
|| caption.contains("options.generic_value")
@@ -325,6 +385,8 @@ private static void stopIntegratedServer(Minecraft minecraft) {
private void writeMarker() throws IOException {
Properties values = new Properties();
values.setProperty("world_settings_opened", Boolean.toString(worldSettingsOpened));
+ values.setProperty("mods_directory_rendered", Boolean.toString(modsDirectoryRendered));
+ values.setProperty("directory_stack_validated", Boolean.toString(directoryStackValidated));
values.setProperty("long_editor_roundtrip", Boolean.toString(longEditorRoundTrip));
values.setProperty("editor_routes", Integer.toString(editorRoutes.size()));
values.setProperty("editor_classes", editorRoutes.toString());
diff --git a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java
index 7c7369ff..b5a60e59 100644
--- a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java
+++ b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java
@@ -84,6 +84,7 @@
import net.minecraftforge.common.DimensionManager;
import net.minecraftforge.oredict.OreDictionary;
import net.minecraft.item.ItemStack;
+import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager;
import zone.moddev.mc.orespawn.worldgen.LegacyOs3ProfileMigration;
/**
@@ -315,6 +316,7 @@ private static void migrateConfigDirectory(Path configDirectory) {
LOGGER.error("Could not migrate OS3 global world-generation settings", failure);
}
Map programmaticSources = API.programmaticSources();
+ recordLegacyLineages(legacyDirectory, os1Directory, programmaticSources);
if (Files.isRegularFile(bridgeMarker)) {
LOGGER.info("OS3 provider migration already completed; retaining migrated files unchanged");
return;
@@ -406,6 +408,35 @@ private static void migrateConfigDirectory(Path configDirectory) {
}
}
+ private static void recordLegacyLineages(Path legacyDirectory, Path os1Directory,
+ Map programmaticSources) {
+ for (String owner : OS1_PROGRAMMATIC.keySet()) {
+ WorldgenIntegrationManager.recordLegacyLineage(owner, 1);
+ }
+ for (String owner : programmaticSources.keySet()) {
+ WorldgenIntegrationManager.recordLegacyLineage(owner, 3);
+ }
+ for (String owner : API.embedded.keySet()) {
+ WorldgenIntegrationManager.recordLegacyLineage(owner, 3);
+ }
+ recordLegacyFiles(legacyDirectory, 3);
+ recordLegacyFiles(os1Directory, 1);
+ }
+
+ private static void recordLegacyFiles(Path directory, int generation) {
+ if (!Files.isDirectory(directory)) return;
+ try (DirectoryStream files = Files.newDirectoryStream(directory, "*.json")) {
+ for (Path file : files) {
+ String owner = file.getFileName().toString().replaceFirst("\\.json$", "");
+ if (validModId(owner)) {
+ WorldgenIntegrationManager.recordLegacyLineage(owner, generation);
+ }
+ }
+ } catch (IOException failure) {
+ REPORT.add("lineage_scan_failed=" + directory + ":" + failure.getClass().getSimpleName());
+ }
+ }
+
static JsonObject translateOs1ForTests(String owner, JsonObject source) {
return convertOs1Source(owner, source);
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java
index 66d7ba51..b632a033 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java
@@ -25,7 +25,7 @@ public final class WorldSettingsExtension {
public ResourceLocation id() { return id; }
public String buttonTranslationKey() { return buttonTranslationKey; }
- /** Creates the extension screen with the current OreSpawn screen as its parent. */
+ /** Creates the extension screen with OreSpawn's Mods directory as its parent. */
public GuiScreen createScreen(GuiScreen parent) {
return screenFactory.create(Objects.requireNonNull(parent, "parent"));
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java
index a7cbf0ef..c55b9936 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java
@@ -6,42 +6,72 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.regex.Pattern;
import net.minecraft.util.ResourceLocation;
import net.minecraftforge.fml.relauncher.Side;
import net.minecraftforge.fml.relauncher.SideOnly;
-/** Client-only registry for add-on actions in OreSpawn's world-settings screen. */
+/** Client-only registry for add-on screens in OreSpawn's Mods directory. */
@SideOnly(Side.CLIENT)
public final class WorldSettingsExtensionRegistry {
- private static final Map EXTENSIONS =
+ private static final Pattern MOD_ID = Pattern.compile("^[a-z][a-z0-9_.-]{1,63}$");
+ private static final Map EXTENSIONS =
new LinkedHashMap<>();
private WorldSettingsExtensionRegistry() {
}
/**
- * Registers one translated world-settings action in deterministic registration
- * order. Registration must occur during client initialization.
+ * Registers the one optional OreSpawn-linked configuration screen owned by a
+ * loaded mod. Registration must occur during client initialization.
+ *
+ * @throws IllegalArgumentException if the mod identifier is not canonical
+ * @throws IllegalStateException if the mod already registered a screen
+ */
+ public static synchronized WorldSettingsExtension registerConfigScreen(String modId,
+ WorldSettingsScreenFactory screenFactory) {
+ validateModId(modId);
+ return registerOwned(modId, new ResourceLocation(modId, "configuration"),
+ "button.orespawn.mod.configure", screenFactory);
+ }
+
+ /**
+ * Retains the original translated world-settings registration descriptor for
+ * existing add-ons. The identifier namespace owns the one permitted screen.
+ * Registration must occur during client initialization.
*
* @throws IllegalStateException if the identifier is already registered
*/
public static synchronized WorldSettingsExtension register(ResourceLocation id,
String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) {
Objects.requireNonNull(id, "id");
- Objects.requireNonNull(screenFactory, "screenFactory");
if (buttonTranslationKey == null || buttonTranslationKey.trim().isEmpty()) {
throw new IllegalArgumentException("buttonTranslationKey cannot be blank");
}
- if (EXTENSIONS.containsKey(id)) {
- throw new IllegalStateException("Duplicate OreSpawn world-settings extension: " + id);
+ String modId = id.getResourceDomain();
+ validateModId(modId);
+ return registerOwned(modId, id, buttonTranslationKey, screenFactory);
+ }
+
+ private static WorldSettingsExtension registerOwned(String modId, ResourceLocation id,
+ String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) {
+ Objects.requireNonNull(screenFactory, "screenFactory");
+ if (EXTENSIONS.containsKey(modId)) {
+ throw new IllegalStateException("Duplicate OreSpawn configuration screen for mod: " + modId);
}
- WorldSettingsExtension extension = new WorldSettingsExtension(id,
- buttonTranslationKey, screenFactory);
- EXTENSIONS.put(id, extension);
+ WorldSettingsExtension extension = new WorldSettingsExtension(id, buttonTranslationKey,
+ screenFactory);
+ EXTENSIONS.put(modId, extension);
return extension;
}
+ private static void validateModId(String modId) {
+ if (modId == null || !MOD_ID.matcher(modId).matches()) {
+ throw new IllegalArgumentException("modId must be a canonical lower-case mod identifier");
+ }
+ }
+
/** Returns an immutable snapshot in deterministic registration order. */
public static synchronized List extensions() {
return Collections.unmodifiableList(new ArrayList<>(EXTENSIONS.values()));
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java
index dcf2a199..4ee3841b 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java
@@ -4,7 +4,7 @@
import net.minecraftforge.fml.relauncher.Side;
import net.minecraftforge.fml.relauncher.SideOnly;
-/** Creates an add-on configuration screen whose Done action returns to OreSpawn. */
+/** Creates an add-on configuration screen whose Done action returns to OreSpawn's mod directory. */
@FunctionalInterface
@SideOnly(Side.CLIENT)
public interface WorldSettingsScreenFactory {
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/ClientSetup.java b/src/main/java/zone/moddev/mc/orespawn/client/ClientSetup.java
index e3d0f494..3d391969 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/ClientSetup.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/ClientSetup.java
@@ -16,5 +16,6 @@ public static synchronized void initialize() {
// class is registered as an instance. Both world-creation hooks are static,
// so the class object is required for the OreSpawn button to be installed.
MinecraftForge.EVENT_BUS.register(WorldCreationScreenHandler.class);
+ MinecraftForge.EVENT_BUS.register(WorldSettingsExtensionNavigation.class);
}
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java
new file mode 100644
index 00000000..250319fb
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java
@@ -0,0 +1,27 @@
+package zone.moddev.mc.orespawn.client;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.renderer.GlStateManager;
+import net.minecraft.util.ResourceLocation;
+
+/** Small target-native icon button used by the OreSpawn Mods directory. */
+final class CogButton extends Button {
+ private static final ResourceLocation COG =
+ new ResourceLocation("orespawn", "textures/gui/cog.png");
+
+ CogButton(int x, int y, IPressable onPress, Tooltip tooltip) {
+ super(x, y, 22, 20, "", onPress, tooltip);
+ }
+
+ @Override
+ public void drawButton(Minecraft minecraft, int mouseX, int mouseY) {
+ super.drawButton(minecraft, mouseX, mouseY);
+ if (!visible) return;
+ minecraft.getTextureManager().bindTexture(COG);
+ if (enabled) GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F);
+ else GlStateManager.color(0.45F, 0.45F, 0.45F, 0.8F);
+ drawModalRectWithCustomSizedTexture(xPosition + 3, yPosition + 2,
+ 0.0F, 0.0F, 16, 16, 16.0F, 16.0F);
+ GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F);
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java
new file mode 100644
index 00000000..f47767a3
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java
@@ -0,0 +1,123 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import zone.moddev.mc.orespawn.OreSpawn;
+import zone.moddev.mc.orespawn.api.ProviderStatus;
+import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension;
+import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry;
+import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager;
+import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.ProviderIntegrationInfo;
+
+import net.minecraftforge.fml.common.Loader;
+import net.minecraftforge.fml.common.ModContainer;
+
+/** Builds the immutable, loaded-only model rendered by the OreSpawn Mods screen. */
+final class OreSpawnModDirectoryModel {
+ private OreSpawnModDirectoryModel() {
+ }
+
+ static List snapshot() {
+ List loaded = new ArrayList<>();
+ for (ModContainer mod : Loader.instance().getActiveModList()) {
+ loaded.add(new LoadedMod(mod.getModId(), mod.getName(), mod.getVersion()));
+ }
+ return assemble(loaded, WorldgenIntegrationManager.providerIntegrations(),
+ WorldSettingsExtensionRegistry.extensions());
+ }
+
+ static List assemble(List loadedMods,
+ List providers, List extensions) {
+ Map loaded = new LinkedHashMap<>();
+ for (LoadedMod mod : loadedMods) {
+ if (mod != null && mod.modId != null) loaded.put(mod.modId, mod);
+ }
+ Map integrations = new LinkedHashMap<>();
+ for (ProviderIntegrationInfo provider : providers) {
+ if (isVisible(provider.modId(), loaded)) {
+ integrations.computeIfAbsent(provider.modId(), ignored -> new MutableEntry())
+ .provider = provider;
+ }
+ }
+ for (WorldSettingsExtension extension : extensions) {
+ String modId = extension.id().getResourceDomain();
+ if (isVisible(modId, loaded)) {
+ integrations.computeIfAbsent(modId, ignored -> new MutableEntry())
+ .extension = extension;
+ }
+ }
+
+ List result = new ArrayList<>();
+ for (Map.Entry integration : integrations.entrySet()) {
+ LoadedMod mod = loaded.get(integration.getKey());
+ result.add(new Entry(mod.modId, nonBlank(mod.displayName, mod.modId),
+ nonBlank(mod.version, "?"), integration.getValue().provider,
+ integration.getValue().extension));
+ }
+ result.sort(Comparator.comparing((Entry entry) -> entry.displayName.toLowerCase(Locale.ROOT))
+ .thenComparing(entry -> entry.modId));
+ return Collections.unmodifiableList(result);
+ }
+
+ private static boolean isVisible(String modId, Map loaded) {
+ return modId != null && !OreSpawn.MODID.equals(modId) && loaded.containsKey(modId);
+ }
+
+ private static String nonBlank(String value, String fallback) {
+ return value == null || value.trim().isEmpty() ? fallback : value;
+ }
+
+ static final class LoadedMod {
+ final String modId;
+ final String displayName;
+ final String version;
+
+ LoadedMod(String modId, String displayName, String version) {
+ this.modId = modId;
+ this.displayName = displayName;
+ this.version = version;
+ }
+ }
+
+ static final class Entry {
+ final String modId;
+ final String displayName;
+ final String version;
+ final ProviderIntegrationInfo provider;
+ final WorldSettingsExtension extension;
+
+ Entry(String modId, String displayName, String version,
+ ProviderIntegrationInfo provider, WorldSettingsExtension extension) {
+ this.modId = modId;
+ this.displayName = displayName;
+ this.version = version;
+ this.provider = provider;
+ this.extension = extension;
+ }
+
+ boolean configurable() { return extension != null; }
+ boolean nativeOs4() { return provider != null && provider.nativeOs4(); }
+ int schemaVersion() { return provider == null ? -1 : provider.schemaVersion(); }
+ int providerRevision() { return provider == null ? -1 : provider.providerRevision(); }
+ List legacyLineages() {
+ return provider == null ? Collections.emptyList() : provider.legacyLineages();
+ }
+ String statusKey() {
+ if (provider == null) return "config_only";
+ if (provider.rejected()) return "rejected";
+ ProviderStatus status = provider.status();
+ return status == null ? "inactive" : status.name().toLowerCase(Locale.ROOT);
+ }
+ }
+
+ private static final class MutableEntry {
+ ProviderIntegrationInfo provider;
+ WorldSettingsExtension extension;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java
new file mode 100644
index 00000000..67d80c8c
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java
@@ -0,0 +1,189 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.Entry;
+
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.client.resources.I18n;
+import net.minecraft.util.text.TextComponentString;
+import net.minecraft.util.text.TextComponentTranslation;
+
+/** Paginated directory of loaded mods known to OreSpawn. */
+final class OreSpawnModsScreen extends OreSpawnScreen {
+ private static final int ROW_HEIGHT = 42;
+ private final GuiScreen parent;
+ private final List entries;
+ private int page;
+ private String error;
+ private int contentLeft;
+ private int contentWidth;
+ private int listTop;
+ private int pageSize;
+
+ OreSpawnModsScreen(GuiScreen parent) {
+ this(parent, OreSpawnModDirectoryModel.snapshot());
+ }
+
+ OreSpawnModsScreen(GuiScreen parent, List entries) {
+ super(new TextComponentTranslation("screen.orespawn.mods"));
+ this.parent = parent;
+ this.entries = Collections.unmodifiableList(new ArrayList<>(entries));
+ }
+
+ @Override
+ protected void init() {
+ contentWidth = Math.min(390, Math.max(280, width - 24));
+ contentLeft = (width - contentWidth) / 2;
+ listTop = 38;
+ int controlsY = height - 52;
+ pageSize = pageSize(height);
+ int pageCount = pageCount(entries.size(), pageSize);
+ page = Math.max(0, Math.min(page, pageCount - 1));
+ int start = page * pageSize;
+ for (int index = 0; index < pageSize && start + index < entries.size(); index++) {
+ Entry entry = entries.get(start + index);
+ CogButton cog = addButton(new CogButton(contentLeft + contentWidth - 26,
+ listTop + index * ROW_HEIGHT + 9, button -> open(entry),
+ (button, mouseX, mouseY) -> renderStringTooltip(tooltip(entry), mouseX, mouseY)));
+ cog.enabled = entry.configurable();
+ }
+
+ Button previous = addButton(new Button(contentLeft, controlsY, 80, 20,
+ new TextComponentTranslation("button.orespawn.previous"),
+ button -> { page--; rebuildWidgets(); }));
+ Button next = addButton(new Button(contentLeft + contentWidth - 80, controlsY, 80, 20,
+ new TextComponentTranslation("button.orespawn.next"),
+ button -> { page++; rebuildWidgets(); }));
+ previous.enabled = page > 0;
+ next.enabled = page + 1 < pageCount;
+ addButton(new Button(width / 2 - 75, height - 28, 150, 20,
+ DialogTexts.GUI_DONE, button -> onClose()));
+ }
+
+ private void open(Entry entry) {
+ if (!entry.configurable()) return;
+ try {
+ GuiScreen screen = entry.extension.createScreen(this);
+ if (screen == null) {
+ error = I18n.format("error.orespawn.mod.screen_missing", entry.modId);
+ return;
+ }
+ WorldSettingsExtensionNavigation.open(this, screen);
+ } catch (RuntimeException failure) {
+ error = I18n.format("error.orespawn.mod.screen_failed", entry.modId,
+ failure.getClass().getSimpleName());
+ }
+ }
+
+ private List tooltip(Entry entry) {
+ List lines = new ArrayList<>();
+ lines.add(I18n.format(entry.configurable()
+ ? "tooltip.orespawn.mod.configure" : "tooltip.orespawn.mod.unavailable",
+ entry.displayName));
+ lines.add(I18n.format("label.orespawn.mod.id_version", entry.modId, entry.version));
+ lines.add(integration(entry));
+ for (Integer lineage : entry.legacyLineages()) {
+ if (!entry.nativeOs4() && lineage.intValue() == primaryLegacy(entry)) continue;
+ lines.add(I18n.format("label.orespawn.mod.additional_lineage", lineage));
+ }
+ lines.add(I18n.format("label.orespawn.mod.status",
+ I18n.format("status.orespawn.mod." + entry.statusKey())));
+ return lines;
+ }
+
+ private String integration(Entry entry) {
+ if (entry.nativeOs4()) {
+ if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) {
+ return I18n.format("label.orespawn.mod.os4_version",
+ entry.schemaVersion(), entry.providerRevision());
+ }
+ return I18n.format("label.orespawn.mod.os4");
+ }
+ int legacy = primaryLegacy(entry);
+ if (legacy > 0) {
+ if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) {
+ return I18n.format("label.orespawn.mod.legacy_version", legacy,
+ entry.schemaVersion(), entry.providerRevision());
+ }
+ return I18n.format("label.orespawn.mod.legacy", legacy);
+ }
+ return I18n.format("label.orespawn.mod.config_only");
+ }
+
+ private static int primaryLegacy(Entry entry) {
+ List lineages = entry.legacyLineages();
+ return lineages.isEmpty() ? 0 : lineages.get(lineages.size() - 1);
+ }
+
+ private void rebuildWidgets() {
+ buttons.clear();
+ children.clear();
+ init();
+ }
+
+ static int pageSize(int height) {
+ return Math.max(1, ((height - 52) - 38) / ROW_HEIGHT);
+ }
+
+ static int pageCount(int entries, int pageSize) {
+ return Math.max(1, (Math.max(0, entries) + Math.max(1, pageSize) - 1)
+ / Math.max(1, pageSize));
+ }
+
+ @Override
+ public void onClose() {
+ minecraft.displayGuiScreen(parent);
+ }
+
+ @Override
+ public void render(int mouseX, int mouseY, float partialTick) {
+ renderBackground();
+ drawCenteredString(font, title, width / 2, 14, 0xFFFFFF);
+ if (entries.isEmpty()) {
+ drawCenteredString(font, new TextComponentTranslation("label.orespawn.mod.none"),
+ width / 2, height / 2 - 6, 0xA0A0A0);
+ }
+ int start = page * pageSize;
+ for (int index = 0; index < pageSize && start + index < entries.size(); index++) {
+ drawEntry(entries.get(start + index), listTop + index * ROW_HEIGHT);
+ }
+ int pages = pageCount(entries.size(), pageSize);
+ drawCenteredString(font, new TextComponentString((page + 1) + " / " + pages),
+ width / 2, height - 46, 0xA0A0A0);
+ if (error != null) {
+ drawCenteredString(font, new TextComponentString(error), width / 2, 27, 0xFF5555);
+ }
+ super.render(mouseX, mouseY, partialTick);
+ }
+
+ private void drawEntry(Entry entry, int y) {
+ drawRect(contentLeft, y, contentLeft + contentWidth, y + 38, 0x70000000);
+ int textWidth = contentWidth - 36;
+ drawString(font, new TextComponentString(trim(entry.displayName, textWidth)),
+ contentLeft + 6, y + 3, 0xFFFFFF);
+ drawString(font, new TextComponentString(trim(
+ I18n.format("label.orespawn.mod.id_version", entry.modId, entry.version), textWidth)),
+ contentLeft + 6, y + 14, 0xA0A0A0);
+ String details = I18n.format("label.orespawn.mod.integration_status", integration(entry),
+ I18n.format("status.orespawn.mod." + entry.statusKey()));
+ drawString(font, new TextComponentString(trim(details, textWidth)),
+ contentLeft + 6, y + 25, statusColor(entry.statusKey()));
+ }
+
+ private String trim(String value, int maximumWidth) {
+ if (font.getStringWidth(value) <= maximumWidth) return value;
+ String suffix = "...";
+ int width = maximumWidth - font.getStringWidth(suffix);
+ return font.trimStringToWidth(value, Math.max(0, width)) + suffix;
+ }
+
+ private static int statusColor(String status) {
+ if ("active".equals(status)) return 0x55FF55;
+ if ("pending".equals(status)) return 0xFFFF55;
+ if ("rejected".equals(status)) return 0xFF5555;
+ return 0xA0A0A0;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
index 9ab2e809..89268289 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
@@ -7,8 +7,6 @@
import java.util.Objects;
import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode;
-import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension;
-import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry;
import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile;
import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager;
@@ -71,10 +69,9 @@ protected void init() {
int right = left + columnWidth + 5;
boolean terrain = session.hasTerrainRules();
boolean fluids = !session.fluidDepositIds().isEmpty();
- List extensions = WorldSettingsExtensionRegistry.extensions();
int top = OreSpawnScreenLayout.mainTop(this.height);
- int rows = (templateChoices.size() > 1 ? 1 : 0) + (terrain ? 8
- : 5 + (fluids ? 1 : 0)) + extensions.size();
+ int rows = (templateChoices.size() > 1 ? 1 : 0) + (terrain ? 9
+ : 5 + (fluids ? 1 : 0));
int available = OreSpawnScreenLayout.footerY(height) - top - BUTTON_HEIGHT - 4;
int row = Math.min(OreSpawnScreenLayout.mainRowSpacing(this.height),
rows <= 1 ? 24 : Math.max(BUTTON_HEIGHT, available / (rows - 1)));
@@ -109,8 +106,11 @@ columnWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(),
contentWidth, BUTTON_HEIGHT,
new TextComponentTranslation("button.orespawn.biomes_world_materials"),
button -> openBiomeWorldMaterials(), "tooltip.orespawn.main.biomes_materials"));
- addButton(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex++),
- contentWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"),
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex),
+ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.mods"),
+ button -> openMods(), "tooltip.orespawn.main.mods"));
+ addButton(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex++),
+ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"),
button -> openHelp()));
} else {
geologyModeButton = OreSpawnScreenLayout.explain(this,
@@ -150,21 +150,18 @@ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.materi
columnWidth, BUTTON_HEIGHT,
new TextComponentTranslation("button.orespawn.biomes_world_materials"),
button -> openBiomeWorldMaterials(), "tooltip.orespawn.main.biomes_materials"));
- addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex),
- columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.advanced"),
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex++),
+ contentWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.advanced"),
button -> openAdvanced(), "tooltip.orespawn.main.advanced"));
- addButton(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex++),
- columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"),
- button -> openHelp()));
addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex++),
contentWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(),
"tooltip.orespawn.main.fluid_editor"));
- }
- for (WorldSettingsExtension extension : extensions) {
- addButton(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex++),
- contentWidth, BUTTON_HEIGHT,
- new TextComponentTranslation(extension.buttonTranslationKey()),
- button -> openExtension(extension)));
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex),
+ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.mods"),
+ button -> openMods(), "tooltip.orespawn.main.mods"));
+ addButton(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex++),
+ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.help"),
+ button -> openHelp()));
}
addButton(OreSpawnScreenLayout.button(this, font, left, this.height - 28, columnWidth, BUTTON_HEIGHT,
DialogTexts.GUI_DONE, button -> saveAndClose()));
@@ -331,15 +328,9 @@ private void openHelp() {
minecraft.displayGuiScreen(new OreSpawnGuideScreen(this));
}
- private void openExtension(WorldSettingsExtension extension) {
+ private void openMods() {
syncSession();
- GuiScreen screen = extension.createScreen(this);
- if (screen == null) {
- validationError = new TextComponentString(
- "World-settings extension did not create a screen: " + extension.id());
- return;
- }
- minecraft.displayGuiScreen(screen);
+ minecraft.displayGuiScreen(new OreSpawnModsScreen(this));
}
@Override
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java b/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java
new file mode 100644
index 00000000..ffb6032d
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java
@@ -0,0 +1,39 @@
+package zone.moddev.mc.orespawn.client;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiMainMenu;
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraftforge.client.event.GuiOpenEvent;
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+
+/** Keeps an add-on's inherited Escape action inside OreSpawn's screen stack. */
+public final class WorldSettingsExtensionNavigation {
+ private static GuiScreen directory;
+ private static GuiScreen extensionScreen;
+
+ private WorldSettingsExtensionNavigation() {
+ }
+
+ static synchronized void open(GuiScreen parentDirectory, GuiScreen screen) {
+ directory = parentDirectory;
+ extensionScreen = screen;
+ Minecraft.getMinecraft().displayGuiScreen(screen);
+ }
+
+ @SubscribeEvent
+ public static synchronized void onScreenOpen(GuiOpenEvent event) {
+ if (extensionScreen == null) return;
+ // This is the initial transition from the directory to the add-on.
+ if (event.getGui() == extensionScreen) return;
+ Minecraft minecraft = Minecraft.getMinecraft();
+ boolean inheritedEscape = event.getGui() == null
+ || (minecraft.world == null && event.getGui() instanceof GuiMainMenu);
+ if (minecraft.currentScreen == extensionScreen && inheritedEscape) {
+ event.setGui(directory);
+ }
+ // Done already names the directory as its destination. Other non-null
+ // transitions belong to the add-on and are deliberately left untouched.
+ directory = null;
+ extensionScreen = null;
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
index 6f5e9a94..316d592a 100644
--- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
+++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
@@ -68,6 +68,7 @@ public final class WorldgenIntegrationManager {
private static final Map FILE_PROVIDERS = new LinkedHashMap<>();
private static final Map ACTIVE_PROVIDERS = new LinkedHashMap<>();
private static final Map TEMPLATES = new LinkedHashMap<>();
+ private static final Map> LEGACY_LINEAGES = new LinkedHashMap<>();
private static final Set INVALID_PROVIDERS = new HashSet<>();
private static final Set FILE_PROVIDER_IDS = new HashSet<>();
private static boolean initialized;
@@ -166,6 +167,53 @@ public static synchronized Set activeProviderIds() {
return Collections.unmodifiableSet(new LinkedHashSet<>(ACTIVE_PROVIDERS.keySet()));
}
+ /** Records a loaded legacy integration at the point where its source is identified. */
+ public static synchronized void recordLegacyLineage(String providerModId, int orespawnGeneration) {
+ if (providerModId == null || !MOD_ID.matcher(providerModId).matches()
+ || orespawnGeneration < 1 || orespawnGeneration > 3) {
+ return;
+ }
+ LEGACY_LINEAGES.computeIfAbsent(providerModId, ignored -> new LinkedHashSet<>())
+ .add(orespawnGeneration);
+ }
+
+ /** Returns immutable provider metadata for the client-side mod directory. */
+ public static synchronized List providerIntegrations() {
+ Set providerIds = new LinkedHashSet<>();
+ providerIds.addAll(API_PROVIDERS.keySet());
+ providerIds.addAll(RESOURCE_PROVIDERS.keySet());
+ providerIds.addAll(FILE_PROVIDER_IDS);
+ providerIds.addAll(ACTIVE_PROVIDERS.keySet());
+ providerIds.addAll(INVALID_PROVIDERS);
+ providerIds.addAll(LEGACY_LINEAGES.keySet());
+ List sorted = new ArrayList<>(providerIds);
+ Collections.sort(sorted);
+ List result = new ArrayList<>();
+ for (String providerId : sorted) {
+ JsonObject root = FILE_PROVIDERS.get(providerId);
+ if (root == null) root = RESOURCE_PROVIDERS.get(providerId);
+ ProviderDefinition active = ACTIVE_PROVIDERS.get(providerId);
+ if (root == null && active != null) root = active.root;
+ WorldgenProvider api = API_PROVIDERS.get(providerId);
+ List legacy = new ArrayList<>(LEGACY_LINEAGES.getOrDefault(
+ providerId, Collections.emptySet()));
+ Collections.sort(legacy);
+ boolean hasApiOrResource = api != null
+ || RESOURCE_PROVIDERS.containsKey(providerId);
+ boolean nativeOs4 = hasApiOrResource
+ || (FILE_PROVIDER_IDS.contains(providerId) && legacy.isEmpty());
+ boolean hasProvider = root != null || api != null
+ || RESOURCE_PROVIDERS.containsKey(providerId)
+ || FILE_PROVIDER_IDS.contains(providerId);
+ result.add(new ProviderIntegrationInfo(providerId, nativeOs4, hasProvider,
+ root == null ? (api == null ? -1 : 4) : integer(root, "schema_version", -1),
+ root == null ? (api == null ? -1 : api.revision())
+ : integer(root, "provider_revision", -1),
+ getProviderStatus(providerId), INVALID_PROVIDERS.contains(providerId), legacy));
+ }
+ return Collections.unmodifiableList(result);
+ }
+
/** Merge new provider-owned defaults without overwriting pack or world values. */
public static synchronized boolean mergeProviderDefinitions(JsonObject target) {
return mergeProviderDefinitions(target, false);
@@ -428,14 +476,7 @@ private static void rebuildActiveProviders() {
if (INVALID_PROVIDERS.contains(providerId)) {
continue;
}
- JsonObject root = FILE_PROVIDERS.get(providerId);
- if (root == null) {
- root = RESOURCE_PROVIDERS.get(providerId);
- }
- if (root == null) {
- WorldgenProvider apiProvider = API_PROVIDERS.get(providerId);
- root = apiProvider == null ? null : apiProvider.toJson();
- }
+ JsonObject root = selectedProviderRoot(providerId);
if (root == null || !Loader.isModLoaded(providerId)) {
continue;
}
@@ -453,6 +494,16 @@ private static void rebuildActiveProviders() {
}
}
+ private static JsonObject selectedProviderRoot(String providerId) {
+ JsonObject root = FILE_PROVIDERS.get(providerId);
+ if (root == null) root = RESOURCE_PROVIDERS.get(providerId);
+ if (root == null) {
+ WorldgenProvider apiProvider = API_PROVIDERS.get(providerId);
+ root = apiProvider == null ? null : apiProvider.toJson();
+ }
+ return root;
+ }
+
static void validateProvider(String providerId, JsonObject root) {
int schema = integer(root, "schema_version", -1);
if (schema != 1 && schema != 2 && schema != 3 && schema != 4) {
@@ -1077,6 +1128,40 @@ JsonObject section(String name) {
}
}
+ /** Immutable internal-facing metadata used to describe one OreSpawn integration. */
+ public static final class ProviderIntegrationInfo {
+ private final String modId;
+ private final boolean nativeOs4;
+ private final boolean provider;
+ private final int schemaVersion;
+ private final int providerRevision;
+ private final ProviderStatus status;
+ private final boolean rejected;
+ private final List legacyLineages;
+
+ public ProviderIntegrationInfo(String modId, boolean nativeOs4, boolean provider,
+ int schemaVersion, int providerRevision, ProviderStatus status,
+ boolean rejected, List legacyLineages) {
+ this.modId = modId;
+ this.nativeOs4 = nativeOs4;
+ this.provider = provider;
+ this.schemaVersion = schemaVersion;
+ this.providerRevision = providerRevision;
+ this.status = status;
+ this.rejected = rejected;
+ this.legacyLineages = Collections.unmodifiableList(new ArrayList<>(legacyLineages));
+ }
+
+ public String modId() { return modId; }
+ public boolean nativeOs4() { return nativeOs4; }
+ public boolean hasProvider() { return provider; }
+ public int schemaVersion() { return schemaVersion; }
+ public int providerRevision() { return providerRevision; }
+ public ProviderStatus status() { return status; }
+ public boolean rejected() { return rejected; }
+ public List legacyLineages() { return legacyLineages; }
+ }
+
public static final class TemplateDefinition {
private final ResourceLocation id;
private final String nameKey;
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java
index 953e0516..aab3b5d4 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java
@@ -46,6 +46,7 @@ static JsonObject migrateIfNeeded(Path target, JsonObject defaults) {
static JsonObject migrateIfNeeded(Path target, JsonObject defaults,
BiFunction> providerRules) {
+ recordLegacyOwners(target.getParent());
if (Files.exists(target)) return null;
Path config = target.getParent();
Path mineralogy = config.resolve("mineralogy-geomes.json");
@@ -102,6 +103,16 @@ static JsonObject migrateIfNeeded(Path target, JsonObject defaults,
return migrated;
}
+ private static void recordLegacyOwners(Path config) {
+ if (config == null) return;
+ for (Path path : legacyFiles(config)) {
+ JsonObject root = readObject(path);
+ if (root == null || !"2.0".equals(string(root, "version", ""))) continue;
+ String owner = safe(path.getFileName().toString().replaceFirst("\\.json$", ""));
+ WorldgenIntegrationManager.recordLegacyLineage(owner, 2);
+ }
+ }
+
private static String migratedRuleId(String owner, String legacyName, JsonObject converted,
JsonObject existingRules,
BiFunction> providerRules, List report) {
diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang
index 28319a7a..63b71fed 100644
--- a/src/main/resources/assets/orespawn/lang/de_AU.lang
+++ b/src/main/resources/assets/orespawn/lang/de_AU.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Weltmaterialien
guide.orespawn.materials.1=Weltmaterialien ändern Hintergrundmaterialien in einer ganzen Dimension. Verwende Flüssigkeitsvorkommen, wenn du nur gelegentliche unterirdische Taschen möchtest.
guide.orespawn.materials.2=Aquiferflüssigkeit ändert die normale Flüssigkeit unter dem Meeresspiegel. Tiefer Aquifer verwendet unterhalb der gewählten Y-Höhe eine zweite Flüssigkeit.
guide.orespawn.materials.3=Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert.
+button.orespawn.mods=Mod-Verzeichnis
+button.orespawn.mod.configure=Konfigurieren
+tooltip.orespawn.main.mods=Geladene Mods anzeigen, die in OreSpawn integriert sind.
+screen.orespawn.mods=OreSpawn-Mods
+tooltip.orespawn.mod.configure=Konfiguration von %s öffnen.
+tooltip.orespawn.mod.unavailable=%s stellt keinen mit OreSpawn verknüpften Konfigurationsbildschirm bereit.
+label.orespawn.mod.id_version=%s - Mod-Version %s
+label.orespawn.mod.additional_lineage=Auch über OreSpawn-%s-Kompatibilität erkannt.
+label.orespawn.mod.status=Zustand: %s
+label.orespawn.mod.os4_version=Nativer OS4-Anbieter - Schema %s, Anbieterrevision %s
+label.orespawn.mod.os4=Nativer OS4-Anbieter
+label.orespawn.mod.legacy_version=OreSpawn-%s-Kompatibilität -> OS4-Schema %s, Anbieterrevision %s
+label.orespawn.mod.legacy=OreSpawn-%s-Kompatibilität -> OS4
+label.orespawn.mod.config_only=Client-Konfigurationsintegration
+label.orespawn.mod.none=Keine geladenen OreSpawn-Integrationen gefunden.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Aktiv
+status.orespawn.mod.pending=Ausstehend
+status.orespawn.mod.inactive=Inaktiv
+status.orespawn.mod.rejected=Abgelehnt
+status.orespawn.mod.config_only=Nur Konfigurationsbildschirm
+error.orespawn.mod.screen_missing=%s hat keinen Konfigurationsbildschirm erstellt.
+error.orespawn.mod.screen_failed=Die Konfiguration von %s konnte nicht geöffnet werden (%s).
diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang
index 28319a7a..63b71fed 100644
--- a/src/main/resources/assets/orespawn/lang/de_DE.lang
+++ b/src/main/resources/assets/orespawn/lang/de_DE.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Weltmaterialien
guide.orespawn.materials.1=Weltmaterialien ändern Hintergrundmaterialien in einer ganzen Dimension. Verwende Flüssigkeitsvorkommen, wenn du nur gelegentliche unterirdische Taschen möchtest.
guide.orespawn.materials.2=Aquiferflüssigkeit ändert die normale Flüssigkeit unter dem Meeresspiegel. Tiefer Aquifer verwendet unterhalb der gewählten Y-Höhe eine zweite Flüssigkeit.
guide.orespawn.materials.3=Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert.
+button.orespawn.mods=Mod-Verzeichnis
+button.orespawn.mod.configure=Konfigurieren
+tooltip.orespawn.main.mods=Geladene Mods anzeigen, die in OreSpawn integriert sind.
+screen.orespawn.mods=OreSpawn-Mods
+tooltip.orespawn.mod.configure=Konfiguration von %s öffnen.
+tooltip.orespawn.mod.unavailable=%s stellt keinen mit OreSpawn verknüpften Konfigurationsbildschirm bereit.
+label.orespawn.mod.id_version=%s - Mod-Version %s
+label.orespawn.mod.additional_lineage=Auch über OreSpawn-%s-Kompatibilität erkannt.
+label.orespawn.mod.status=Zustand: %s
+label.orespawn.mod.os4_version=Nativer OS4-Anbieter - Schema %s, Anbieterrevision %s
+label.orespawn.mod.os4=Nativer OS4-Anbieter
+label.orespawn.mod.legacy_version=OreSpawn-%s-Kompatibilität -> OS4-Schema %s, Anbieterrevision %s
+label.orespawn.mod.legacy=OreSpawn-%s-Kompatibilität -> OS4
+label.orespawn.mod.config_only=Client-Konfigurationsintegration
+label.orespawn.mod.none=Keine geladenen OreSpawn-Integrationen gefunden.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Aktiv
+status.orespawn.mod.pending=Ausstehend
+status.orespawn.mod.inactive=Inaktiv
+status.orespawn.mod.rejected=Abgelehnt
+status.orespawn.mod.config_only=Nur Konfigurationsbildschirm
+error.orespawn.mod.screen_missing=%s hat keinen Konfigurationsbildschirm erstellt.
+error.orespawn.mod.screen_failed=Die Konfiguration von %s konnte nicht geöffnet werden (%s).
diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang
index ca45e95f..e1fe984d 100644
--- a/src/main/resources/assets/orespawn/lang/en_CA.lang
+++ b/src/main/resources/assets/orespawn/lang/en_CA.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=World Materials
guide.orespawn.materials.1=World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.
guide.orespawn.materials.2=Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.
guide.orespawn.materials.3=Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.
+button.orespawn.mods=Mods
+button.orespawn.mod.configure=Configure
+tooltip.orespawn.main.mods=View loaded mods integrated with OreSpawn.
+screen.orespawn.mods=OreSpawn Mods
+tooltip.orespawn.mod.configure=Open %s configuration.
+tooltip.orespawn.mod.unavailable=%s does not provide an OreSpawn-linked configuration screen.
+label.orespawn.mod.id_version=%s - Mod version %s
+label.orespawn.mod.additional_lineage=Also detected through OreSpawn %s compatibility.
+label.orespawn.mod.status=Status: %s
+label.orespawn.mod.os4_version=Native OS4 - schema %s, provider revision %s
+label.orespawn.mod.os4=Native OS4 provider
+label.orespawn.mod.legacy_version=OreSpawn %s compatibility -> OS4 schema %s, provider revision %s
+label.orespawn.mod.legacy=OreSpawn %s compatibility -> OS4
+label.orespawn.mod.config_only=Client configuration integration
+label.orespawn.mod.none=No loaded OreSpawn integrations were found.
+label.orespawn.mod.integration_status=%s - %s
+status.orespawn.mod.active=Active
+status.orespawn.mod.pending=Pending
+status.orespawn.mod.inactive=Inactive
+status.orespawn.mod.rejected=Rejected
+status.orespawn.mod.config_only=Configuration screen only
+error.orespawn.mod.screen_missing=%s did not create a configuration screen.
+error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang
index ca45e95f..e1fe984d 100644
--- a/src/main/resources/assets/orespawn/lang/en_EN.lang
+++ b/src/main/resources/assets/orespawn/lang/en_EN.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=World Materials
guide.orespawn.materials.1=World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.
guide.orespawn.materials.2=Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.
guide.orespawn.materials.3=Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.
+button.orespawn.mods=Mods
+button.orespawn.mod.configure=Configure
+tooltip.orespawn.main.mods=View loaded mods integrated with OreSpawn.
+screen.orespawn.mods=OreSpawn Mods
+tooltip.orespawn.mod.configure=Open %s configuration.
+tooltip.orespawn.mod.unavailable=%s does not provide an OreSpawn-linked configuration screen.
+label.orespawn.mod.id_version=%s - Mod version %s
+label.orespawn.mod.additional_lineage=Also detected through OreSpawn %s compatibility.
+label.orespawn.mod.status=Status: %s
+label.orespawn.mod.os4_version=Native OS4 - schema %s, provider revision %s
+label.orespawn.mod.os4=Native OS4 provider
+label.orespawn.mod.legacy_version=OreSpawn %s compatibility -> OS4 schema %s, provider revision %s
+label.orespawn.mod.legacy=OreSpawn %s compatibility -> OS4
+label.orespawn.mod.config_only=Client configuration integration
+label.orespawn.mod.none=No loaded OreSpawn integrations were found.
+label.orespawn.mod.integration_status=%s - %s
+status.orespawn.mod.active=Active
+status.orespawn.mod.pending=Pending
+status.orespawn.mod.inactive=Inactive
+status.orespawn.mod.rejected=Rejected
+status.orespawn.mod.config_only=Configuration screen only
+error.orespawn.mod.screen_missing=%s did not create a configuration screen.
+error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang
index ca45e95f..e1fe984d 100644
--- a/src/main/resources/assets/orespawn/lang/en_GB.lang
+++ b/src/main/resources/assets/orespawn/lang/en_GB.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=World Materials
guide.orespawn.materials.1=World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.
guide.orespawn.materials.2=Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.
guide.orespawn.materials.3=Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.
+button.orespawn.mods=Mods
+button.orespawn.mod.configure=Configure
+tooltip.orespawn.main.mods=View loaded mods integrated with OreSpawn.
+screen.orespawn.mods=OreSpawn Mods
+tooltip.orespawn.mod.configure=Open %s configuration.
+tooltip.orespawn.mod.unavailable=%s does not provide an OreSpawn-linked configuration screen.
+label.orespawn.mod.id_version=%s - Mod version %s
+label.orespawn.mod.additional_lineage=Also detected through OreSpawn %s compatibility.
+label.orespawn.mod.status=Status: %s
+label.orespawn.mod.os4_version=Native OS4 - schema %s, provider revision %s
+label.orespawn.mod.os4=Native OS4 provider
+label.orespawn.mod.legacy_version=OreSpawn %s compatibility -> OS4 schema %s, provider revision %s
+label.orespawn.mod.legacy=OreSpawn %s compatibility -> OS4
+label.orespawn.mod.config_only=Client configuration integration
+label.orespawn.mod.none=No loaded OreSpawn integrations were found.
+label.orespawn.mod.integration_status=%s - %s
+status.orespawn.mod.active=Active
+status.orespawn.mod.pending=Pending
+status.orespawn.mod.inactive=Inactive
+status.orespawn.mod.rejected=Rejected
+status.orespawn.mod.config_only=Configuration screen only
+error.orespawn.mod.screen_missing=%s did not create a configuration screen.
+error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang
index ca45e95f..e1fe984d 100644
--- a/src/main/resources/assets/orespawn/lang/en_PT.lang
+++ b/src/main/resources/assets/orespawn/lang/en_PT.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=World Materials
guide.orespawn.materials.1=World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.
guide.orespawn.materials.2=Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.
guide.orespawn.materials.3=Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.
+button.orespawn.mods=Mods
+button.orespawn.mod.configure=Configure
+tooltip.orespawn.main.mods=View loaded mods integrated with OreSpawn.
+screen.orespawn.mods=OreSpawn Mods
+tooltip.orespawn.mod.configure=Open %s configuration.
+tooltip.orespawn.mod.unavailable=%s does not provide an OreSpawn-linked configuration screen.
+label.orespawn.mod.id_version=%s - Mod version %s
+label.orespawn.mod.additional_lineage=Also detected through OreSpawn %s compatibility.
+label.orespawn.mod.status=Status: %s
+label.orespawn.mod.os4_version=Native OS4 - schema %s, provider revision %s
+label.orespawn.mod.os4=Native OS4 provider
+label.orespawn.mod.legacy_version=OreSpawn %s compatibility -> OS4 schema %s, provider revision %s
+label.orespawn.mod.legacy=OreSpawn %s compatibility -> OS4
+label.orespawn.mod.config_only=Client configuration integration
+label.orespawn.mod.none=No loaded OreSpawn integrations were found.
+label.orespawn.mod.integration_status=%s - %s
+status.orespawn.mod.active=Active
+status.orespawn.mod.pending=Pending
+status.orespawn.mod.inactive=Inactive
+status.orespawn.mod.rejected=Rejected
+status.orespawn.mod.config_only=Configuration screen only
+error.orespawn.mod.screen_missing=%s did not create a configuration screen.
+error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang
index 847f14dc..ad792079 100644
--- a/src/main/resources/assets/orespawn/lang/en_US.lang
+++ b/src/main/resources/assets/orespawn/lang/en_US.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=World Materials
guide.orespawn.materials.1=World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.
guide.orespawn.materials.2=Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.
guide.orespawn.materials.3=Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.
+button.orespawn.mods=Mods
+button.orespawn.mod.configure=Configure
+tooltip.orespawn.main.mods=View loaded mods integrated with OreSpawn.
+screen.orespawn.mods=OreSpawn Mods
+tooltip.orespawn.mod.configure=Open %s configuration.
+tooltip.orespawn.mod.unavailable=%s does not provide an OreSpawn-linked configuration screen.
+label.orespawn.mod.id_version=%s - Mod version %s
+label.orespawn.mod.additional_lineage=Also detected through OreSpawn %s compatibility.
+label.orespawn.mod.status=Status: %s
+label.orespawn.mod.os4_version=Native OS4 - schema %s, provider revision %s
+label.orespawn.mod.os4=Native OS4 provider
+label.orespawn.mod.legacy_version=OreSpawn %s compatibility -> OS4 schema %s, provider revision %s
+label.orespawn.mod.legacy=OreSpawn %s compatibility -> OS4
+label.orespawn.mod.config_only=Client configuration integration
+label.orespawn.mod.none=No loaded OreSpawn integrations were found.
+label.orespawn.mod.integration_status=%s - %s
+status.orespawn.mod.active=Active
+status.orespawn.mod.pending=Pending
+status.orespawn.mod.inactive=Inactive
+status.orespawn.mod.rejected=Rejected
+status.orespawn.mod.config_only=Configuration screen only
+error.orespawn.mod.screen_missing=%s did not create a configuration screen.
+error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang
index c314efd0..4e6ca25e 100644
--- a/src/main/resources/assets/orespawn/lang/es_ES.lang
+++ b/src/main/resources/assets/orespawn/lang/es_ES.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Materiales del mundo
guide.orespawn.materials.1=Materiales del mundo cambia materiales de fondo en toda una dimensión. Usa Depósitos de fluidos si solo quieres bolsas subterráneas ocasionales.
guide.orespawn.materials.2=Fluido de acuífero cambia el fluido normal bajo el nivel del mar. Acuífero profundo usa un segundo fluido por debajo del nivel Y elegido.
guide.orespawn.materials.3=Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft.
+button.orespawn.mods=Directorio de mods
+button.orespawn.mod.configure=Configurar
+tooltip.orespawn.main.mods=Ver los mods cargados integrados con OreSpawn.
+screen.orespawn.mods=Mods de OreSpawn
+tooltip.orespawn.mod.configure=Abrir la configuración de %s.
+tooltip.orespawn.mod.unavailable=%s no proporciona una pantalla de configuración vinculada con OreSpawn.
+label.orespawn.mod.id_version=%s - Versión del mod %s
+label.orespawn.mod.additional_lineage=También detectado mediante compatibilidad con OreSpawn %s.
+label.orespawn.mod.status=Estado: %s
+label.orespawn.mod.os4_version=Proveedor OS4 nativo - esquema %s, revisión del proveedor %s
+label.orespawn.mod.os4=Proveedor OS4 nativo
+label.orespawn.mod.legacy_version=Compatibilidad con OreSpawn %s -> esquema OS4 %s, revisión del proveedor %s
+label.orespawn.mod.legacy=Compatibilidad con OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Integración de configuración del cliente
+label.orespawn.mod.none=No se encontraron integraciones de OreSpawn cargadas.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Activo
+status.orespawn.mod.pending=Pendiente
+status.orespawn.mod.inactive=Inactivo
+status.orespawn.mod.rejected=Rechazado
+status.orespawn.mod.config_only=Solo pantalla de configuración
+error.orespawn.mod.screen_missing=%s no creó una pantalla de configuración.
+error.orespawn.mod.screen_failed=No se pudo abrir la configuración de %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang
index c314efd0..4e6ca25e 100644
--- a/src/main/resources/assets/orespawn/lang/es_MX.lang
+++ b/src/main/resources/assets/orespawn/lang/es_MX.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Materiales del mundo
guide.orespawn.materials.1=Materiales del mundo cambia materiales de fondo en toda una dimensión. Usa Depósitos de fluidos si solo quieres bolsas subterráneas ocasionales.
guide.orespawn.materials.2=Fluido de acuífero cambia el fluido normal bajo el nivel del mar. Acuífero profundo usa un segundo fluido por debajo del nivel Y elegido.
guide.orespawn.materials.3=Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft.
+button.orespawn.mods=Directorio de mods
+button.orespawn.mod.configure=Configurar
+tooltip.orespawn.main.mods=Ver los mods cargados integrados con OreSpawn.
+screen.orespawn.mods=Mods de OreSpawn
+tooltip.orespawn.mod.configure=Abrir la configuración de %s.
+tooltip.orespawn.mod.unavailable=%s no proporciona una pantalla de configuración vinculada con OreSpawn.
+label.orespawn.mod.id_version=%s - Versión del mod %s
+label.orespawn.mod.additional_lineage=También detectado mediante compatibilidad con OreSpawn %s.
+label.orespawn.mod.status=Estado: %s
+label.orespawn.mod.os4_version=Proveedor OS4 nativo - esquema %s, revisión del proveedor %s
+label.orespawn.mod.os4=Proveedor OS4 nativo
+label.orespawn.mod.legacy_version=Compatibilidad con OreSpawn %s -> esquema OS4 %s, revisión del proveedor %s
+label.orespawn.mod.legacy=Compatibilidad con OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Integración de configuración del cliente
+label.orespawn.mod.none=No se encontraron integraciones de OreSpawn cargadas.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Activo
+status.orespawn.mod.pending=Pendiente
+status.orespawn.mod.inactive=Inactivo
+status.orespawn.mod.rejected=Rechazado
+status.orespawn.mod.config_only=Solo pantalla de configuración
+error.orespawn.mod.screen_missing=%s no creó una pantalla de configuración.
+error.orespawn.mod.screen_failed=No se pudo abrir la configuración de %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang
index e6401a63..632f79fc 100644
--- a/src/main/resources/assets/orespawn/lang/fr_CA.lang
+++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Matériaux du monde
guide.orespawn.materials.1=Matériaux du monde change les matériaux de fond dans toute une dimension. Utilisez Gisements de fluides pour de simples poches souterraines occasionnelles.
guide.orespawn.materials.2=Fluide d'aquifère change le fluide normal sous le niveau de la mer. Aquifère profond utilise un second fluide sous le niveau Y choisi.
guide.orespawn.materials.3=Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft.
+button.orespawn.mods=Répertoire des mods
+button.orespawn.mod.configure=Configurer
+tooltip.orespawn.main.mods=Afficher les mods chargés intégrés à OreSpawn.
+screen.orespawn.mods=Mods OreSpawn
+tooltip.orespawn.mod.configure=Ouvrir la configuration de %s.
+tooltip.orespawn.mod.unavailable=%s ne fournit pas d'écran de configuration lié à OreSpawn.
+label.orespawn.mod.id_version=%s - Version du mod %s
+label.orespawn.mod.additional_lineage=Également détecté via la compatibilité OreSpawn %s.
+label.orespawn.mod.status=État : %s
+label.orespawn.mod.os4_version=Fournisseur OS4 natif - schéma %s, révision du fournisseur %s
+label.orespawn.mod.os4=Fournisseur OS4 natif
+label.orespawn.mod.legacy_version=Compatibilité OreSpawn %s -> schéma OS4 %s, révision du fournisseur %s
+label.orespawn.mod.legacy=Compatibilité OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Intégration de configuration client
+label.orespawn.mod.none=Aucune intégration OreSpawn chargée n'a été trouvée.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Actif
+status.orespawn.mod.pending=En attente
+status.orespawn.mod.inactive=Inactif
+status.orespawn.mod.rejected=Rejeté
+status.orespawn.mod.config_only=Écran de configuration uniquement
+error.orespawn.mod.screen_missing=%s n'a pas créé d'écran de configuration.
+error.orespawn.mod.screen_failed=Impossible d'ouvrir la configuration de %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang
index e6401a63..632f79fc 100644
--- a/src/main/resources/assets/orespawn/lang/fr_FR.lang
+++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Matériaux du monde
guide.orespawn.materials.1=Matériaux du monde change les matériaux de fond dans toute une dimension. Utilisez Gisements de fluides pour de simples poches souterraines occasionnelles.
guide.orespawn.materials.2=Fluide d'aquifère change le fluide normal sous le niveau de la mer. Aquifère profond utilise un second fluide sous le niveau Y choisi.
guide.orespawn.materials.3=Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft.
+button.orespawn.mods=Répertoire des mods
+button.orespawn.mod.configure=Configurer
+tooltip.orespawn.main.mods=Afficher les mods chargés intégrés à OreSpawn.
+screen.orespawn.mods=Mods OreSpawn
+tooltip.orespawn.mod.configure=Ouvrir la configuration de %s.
+tooltip.orespawn.mod.unavailable=%s ne fournit pas d'écran de configuration lié à OreSpawn.
+label.orespawn.mod.id_version=%s - Version du mod %s
+label.orespawn.mod.additional_lineage=Également détecté via la compatibilité OreSpawn %s.
+label.orespawn.mod.status=État : %s
+label.orespawn.mod.os4_version=Fournisseur OS4 natif - schéma %s, révision du fournisseur %s
+label.orespawn.mod.os4=Fournisseur OS4 natif
+label.orespawn.mod.legacy_version=Compatibilité OreSpawn %s -> schéma OS4 %s, révision du fournisseur %s
+label.orespawn.mod.legacy=Compatibilité OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Intégration de configuration client
+label.orespawn.mod.none=Aucune intégration OreSpawn chargée n'a été trouvée.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Actif
+status.orespawn.mod.pending=En attente
+status.orespawn.mod.inactive=Inactif
+status.orespawn.mod.rejected=Rejeté
+status.orespawn.mod.config_only=Écran de configuration uniquement
+error.orespawn.mod.screen_missing=%s n'a pas créé d'écran de configuration.
+error.orespawn.mod.screen_failed=Impossible d'ouvrir la configuration de %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang
index 14410380..0309bfc8 100644
--- a/src/main/resources/assets/orespawn/lang/ja_JP.lang
+++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=ワールド素材
guide.orespawn.materials.1=ワールド素材はディメンション全体の基本素材を変更します。ときどき現れる地下ポケットだけが欲しい場合は流体鉱床を使います。
guide.orespawn.materials.2=帯水層の流体は海面より下で使う通常の流体を変更します。深部帯水層は選んだYより下で2種類目の流体を使います。
guide.orespawn.materials.3=雪と氷は地表付近のバニラの雪と通常の氷を置き換えます。「未設定」ならMinecraft標準の素材を変えません。
+button.orespawn.mods=MOD一覧
+button.orespawn.mod.configure=設定
+tooltip.orespawn.main.mods=OreSpawnと連携している読み込み済みMODを表示します。
+screen.orespawn.mods=OreSpawn MOD
+tooltip.orespawn.mod.configure=%sの設定を開きます。
+tooltip.orespawn.mod.unavailable=%sにはOreSpawn連携設定画面がありません。
+label.orespawn.mod.id_version=%s - MODバージョン %s
+label.orespawn.mod.additional_lineage=OreSpawn %s互換としても検出されました。
+label.orespawn.mod.status=状態:%s
+label.orespawn.mod.os4_version=ネイティブOS4 - スキーマ%s、プロバイダーリビジョン%s
+label.orespawn.mod.os4=ネイティブOS4プロバイダー
+label.orespawn.mod.legacy_version=OreSpawn %s互換 -> OS4スキーマ%s、プロバイダーリビジョン%s
+label.orespawn.mod.legacy=OreSpawn %s互換 -> OS4
+label.orespawn.mod.config_only=クライアント設定連携
+label.orespawn.mod.none=読み込み済みのOreSpawn連携は見つかりませんでした。
+label.orespawn.mod.integration_status=%s|%s
+status.orespawn.mod.active=有効
+status.orespawn.mod.pending=保留中
+status.orespawn.mod.inactive=無効
+status.orespawn.mod.rejected=拒否
+status.orespawn.mod.config_only=設定画面のみ
+error.orespawn.mod.screen_missing=%sは設定画面を作成しませんでした。
+error.orespawn.mod.screen_failed=%sの設定を開けませんでした(%s)。
diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang
index 4160ffb7..925602ac 100644
--- a/src/main/resources/assets/orespawn/lang/ko_KR.lang
+++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=월드 재료
guide.orespawn.materials.1=월드 재료는 차원 전체의 기본 재료를 바꿉니다. 가끔 나타나는 지하 주머니만 원한다면 유체 광상을 사용하세요.
guide.orespawn.materials.2=대수층 유체는 해수면 아래에서 쓰는 기본 유체를 바꿉니다. 깊은 대수층은 선택한 Y 아래에서 두 번째 유체를 사용합니다.
guide.orespawn.materials.3=눈과 얼음은 지표 근처의 바닐라 눈과 일반 얼음을 바꿉니다. 설정 안 함은 Minecraft 기본 재료를 그대로 둡니다.
+button.orespawn.mods=모드 목록
+button.orespawn.mod.configure=설정
+tooltip.orespawn.main.mods=OreSpawn과 연동된 불러온 모드를 표시합니다.
+screen.orespawn.mods=OreSpawn 모드
+tooltip.orespawn.mod.configure=%s 설정을 엽니다.
+tooltip.orespawn.mod.unavailable=%s 모드는 OreSpawn 연동 설정 화면을 제공하지 않습니다.
+label.orespawn.mod.id_version=%s - 모드 버전 %s
+label.orespawn.mod.additional_lineage=OreSpawn %s 호환으로도 감지되었습니다.
+label.orespawn.mod.status=상태: %s
+label.orespawn.mod.os4_version=기본 OS4 공급자 - 스키마 %s, 공급자 리비전 %s
+label.orespawn.mod.os4=기본 OS4 공급자
+label.orespawn.mod.legacy_version=OreSpawn %s 호환 -> OS4 스키마 %s, 공급자 리비전 %s
+label.orespawn.mod.legacy=OreSpawn %s 호환 -> OS4
+label.orespawn.mod.config_only=클라이언트 설정 연동
+label.orespawn.mod.none=불러온 OreSpawn 연동 모드를 찾지 못했습니다.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=활성
+status.orespawn.mod.pending=대기 중
+status.orespawn.mod.inactive=비활성
+status.orespawn.mod.rejected=거부됨
+status.orespawn.mod.config_only=설정 화면만 제공
+error.orespawn.mod.screen_missing=%s 모드가 설정 화면을 만들지 않았습니다.
+error.orespawn.mod.screen_failed=%s 설정을 열 수 없습니다(%s).
diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang
index e8daf02f..c242bc55 100644
--- a/src/main/resources/assets/orespawn/lang/pt_BR.lang
+++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Materiais do mundo
guide.orespawn.materials.1=Materiais do mundo altera materiais de fundo em uma dimensão inteira. Use Depósitos de fluidos quando quiser apenas bolsas subterrâneas ocasionais.
guide.orespawn.materials.2=Fluido do aquífero altera o fluido normal abaixo do nível do mar. Aquífero profundo usa um segundo fluido abaixo do nível Y escolhido.
guide.orespawn.materials.3=Neve e Gelo substituem a neve vanilla e o gelo comum perto da superfície. Não definido mantém o material normal do Minecraft.
+button.orespawn.mods=Diretório de mods
+button.orespawn.mod.configure=Configurar
+tooltip.orespawn.main.mods=Ver os mods carregados integrados ao OreSpawn.
+screen.orespawn.mods=Mods do OreSpawn
+tooltip.orespawn.mod.configure=Abrir a configuração de %s.
+tooltip.orespawn.mod.unavailable=%s não fornece uma tela de configuração vinculada ao OreSpawn.
+label.orespawn.mod.id_version=%s - Versão do mod %s
+label.orespawn.mod.additional_lineage=Também detectado pela compatibilidade com OreSpawn %s.
+label.orespawn.mod.status=Situação: %s
+label.orespawn.mod.os4_version=Provedor OS4 nativo - esquema %s, revisão do provedor %s
+label.orespawn.mod.os4=Provedor OS4 nativo
+label.orespawn.mod.legacy_version=Compatibilidade com OreSpawn %s -> esquema OS4 %s, revisão do provedor %s
+label.orespawn.mod.legacy=Compatibilidade com OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Integração de configuração do cliente
+label.orespawn.mod.none=Nenhuma integração carregada do OreSpawn foi encontrada.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Ativo
+status.orespawn.mod.pending=Pendente
+status.orespawn.mod.inactive=Inativo
+status.orespawn.mod.rejected=Rejeitado
+status.orespawn.mod.config_only=Apenas tela de configuração
+error.orespawn.mod.screen_missing=%s não criou uma tela de configuração.
+error.orespawn.mod.screen_failed=Não foi possível abrir a configuração de %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang
index 4c19ffe7..0b1def7c 100644
--- a/src/main/resources/assets/orespawn/lang/ru_RU.lang
+++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=Материалы мира
guide.orespawn.materials.1=Материалы мира меняют фоновые материалы во всём измерении. Для редких подземных карманов используйте Месторождения жидкостей.
guide.orespawn.materials.2=Жидкость водоносного слоя меняет обычную жидкость ниже уровня моря. Глубокий слой использует вторую жидкость ниже выбранной Y.
guide.orespawn.materials.3=Снег и лёд заменяют ванильный снег и обычный лёд у поверхности. Не задано сохраняет обычный материал Minecraft.
+button.orespawn.mods=Каталог модов
+button.orespawn.mod.configure=Настроить
+tooltip.orespawn.main.mods=Показать загруженные моды, интегрированные с OreSpawn.
+screen.orespawn.mods=Моды OreSpawn
+tooltip.orespawn.mod.configure=Открыть настройки %s.
+tooltip.orespawn.mod.unavailable=%s не предоставляет экран настроек для OreSpawn.
+label.orespawn.mod.id_version=%s - Версия мода %s
+label.orespawn.mod.additional_lineage=Также обнаружена совместимость с OreSpawn %s.
+label.orespawn.mod.status=Состояние: %s
+label.orespawn.mod.os4_version=Встроенный поставщик OS4 - схема %s, ревизия поставщика %s
+label.orespawn.mod.os4=Встроенный поставщик OS4
+label.orespawn.mod.legacy_version=Совместимость с OreSpawn %s -> схема OS4 %s, ревизия поставщика %s
+label.orespawn.mod.legacy=Совместимость с OreSpawn %s -> OS4
+label.orespawn.mod.config_only=Интеграция клиентских настроек
+label.orespawn.mod.none=Загруженные интеграции OreSpawn не найдены.
+label.orespawn.mod.integration_status=%s | %s
+status.orespawn.mod.active=Активно
+status.orespawn.mod.pending=Ожидание
+status.orespawn.mod.inactive=Неактивно
+status.orespawn.mod.rejected=Отклонено
+status.orespawn.mod.config_only=Только экран настроек
+error.orespawn.mod.screen_missing=%s не создал экран настроек.
+error.orespawn.mod.screen_failed=Не удалось открыть настройки %s (%s).
diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang
index b57e863f..10f23d61 100644
--- a/src/main/resources/assets/orespawn/lang/zh_CN.lang
+++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang
@@ -355,3 +355,26 @@ guide.orespawn.materials.title=世界材料
guide.orespawn.materials.1=世界材料会改变整个维度的基础材料。若只想要偶尔出现的地下囊体,请使用流体矿床。
guide.orespawn.materials.2=含水层流体会改变海平面以下使用的普通流体。深层含水层会在所选Y值以下使用第二种流体。
guide.orespawn.materials.3=雪和冰会替换地表附近的原版雪与普通冰。“未设置”会保留Minecraft的普通材料。
+button.orespawn.mods=模组目录
+button.orespawn.mod.configure=配置
+tooltip.orespawn.main.mods=查看已加载并与OreSpawn集成的模组。
+screen.orespawn.mods=OreSpawn模组
+tooltip.orespawn.mod.configure=打开%s的配置。
+tooltip.orespawn.mod.unavailable=%s未提供与OreSpawn关联的配置界面。
+label.orespawn.mod.id_version=%s - 模组版本%s
+label.orespawn.mod.additional_lineage=同时检测到OreSpawn %s兼容方式。
+label.orespawn.mod.status=状态:%s
+label.orespawn.mod.os4_version=原生OS4提供方 - 架构%s,提供方修订%s
+label.orespawn.mod.os4=原生OS4提供方
+label.orespawn.mod.legacy_version=OreSpawn %s兼容 -> OS4架构%s,提供方修订%s
+label.orespawn.mod.legacy=OreSpawn %s兼容 -> OS4
+label.orespawn.mod.config_only=客户端配置集成
+label.orespawn.mod.none=未找到已加载的OreSpawn集成。
+label.orespawn.mod.integration_status=%s|%s
+status.orespawn.mod.active=已启用
+status.orespawn.mod.pending=待处理
+status.orespawn.mod.inactive=未启用
+status.orespawn.mod.rejected=已拒绝
+status.orespawn.mod.config_only=仅配置界面
+error.orespawn.mod.screen_missing=%s未创建配置界面。
+error.orespawn.mod.screen_failed=无法打开%s的配置(%s)。
diff --git a/src/main/resources/assets/orespawn/textures/gui/cog.png b/src/main/resources/assets/orespawn/textures/gui/cog.png
new file mode 100644
index 0000000000000000000000000000000000000000..e056ab0e803a1aa2de3fcc38e232ecbc6dc0daa6
GIT binary patch
literal 321
zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|SkfJR9T^xl
z_H+M9WCij$3p^r=85sBugD~Uq{1quc!E2r_jv*HQZ!b7qD>N_CNk{_+IteYxkprPI-#7&inN*a9*mq
zSEf$OiaQTCXqnBPAy5>wTTN=|l=YuwA|^SQ%s-s9lchy~ox!&^eoE@g_e=H|Pit8B
z+P&v*@+pZN*FtxWpIQHvxSaJBo=iJpn9y_n`st^8Jc1>5zB_Bs^ZwmIOZ`{zuQgwp
zE^}m{Naic|mq
literal 0
HcmV?d00001
diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java
index 3c95d137..117fd804 100644
--- a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java
@@ -84,7 +84,7 @@ class LocalizationParityTest {
void everyLocaleMatchesEnglishKeysAndFormatting() throws Exception {
JsonObject english = read(LANG_DIR.resolve("en_US.lang"));
Set englishKeys = JsonCopies.keys(english);
- assertEquals(357, englishKeys.size(),
+ assertEquals(380, englishKeys.size(),
"The target locale contract changed; review every shipped translation");
Set localeFiles = new HashSet<>();
Set observedIntentionalEnglishValues = new HashSet<>();
diff --git a/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java b/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java
index b0d95cfc..4a6e7c86 100644
--- a/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/TooltipAlignmentTest.java
@@ -111,6 +111,7 @@ class TooltipAlignmentTest {
"tooltip.orespawn.main.biomes_materials",
"tooltip.orespawn.main.advanced",
"tooltip.orespawn.main.fluid_editor",
+ "tooltip.orespawn.main.mods",
"tooltip.orespawn.manage_vanilla_ores",
"tooltip.orespawn.fluid_deposits",
"tooltip.orespawn.advanced.formations",
diff --git a/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java
index 55d090e9..2b5c078a 100644
--- a/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java
@@ -1,6 +1,7 @@
package zone.moddev.mc.orespawn.api.client;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -19,8 +20,8 @@
class WorldSettingsExtensionRegistryTest {
@Test
void preservesRegistrationOrderAndReturnsAnImmutableSnapshot() {
- WorldSettingsExtension first = register("ordered_first", parent -> new GuiScreen() { });
- WorldSettingsExtension second = register("ordered_second", parent -> new GuiScreen() { });
+ WorldSettingsExtension first = register("order_first", "settings", parent -> new GuiScreen() { });
+ WorldSettingsExtension second = register("order_second", "settings", parent -> new GuiScreen() { });
List extensions = WorldSettingsExtensionRegistry.extensions();
assertTrue(extensions.indexOf(first) < extensions.indexOf(second));
@@ -28,14 +29,31 @@ void preservesRegistrationOrderAndReturnsAnImmutableSnapshot() {
}
@Test
- void rejectsDuplicateIdsAndBlankTranslationKeys() {
- ResourceLocation id = id("duplicate");
+ void rejectsDuplicateOwnershipAndInvalidRegistrations() {
+ ResourceLocation id = id("duplicate_owner", "first");
WorldSettingsExtensionRegistry.register(id, "button.realisticdeposits.settings",
parent -> new GuiScreen() { });
- assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register(id,
+ assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register(
+ id("duplicate_owner", "second"),
"button.realisticdeposits.other", parent -> new GuiScreen() { }));
assertThrows(IllegalArgumentException.class, () -> WorldSettingsExtensionRegistry.register(
- id("blank"), " ", parent -> new GuiScreen() { }));
+ id("blank_key", "settings"), " ", parent -> new GuiScreen() { }));
+ assertThrows(IllegalArgumentException.class, () ->
+ WorldSettingsExtensionRegistry.registerConfigScreen("Bad Mod", parent -> parent));
+ assertThrows(NullPointerException.class, () ->
+ WorldSettingsExtensionRegistry.registerConfigScreen("null_factory", null));
+ }
+
+ @Test
+ void preferredRegistrationOwnsOneConventionalScreenPerMod() {
+ WorldSettingsExtension extension = WorldSettingsExtensionRegistry.registerConfigScreen(
+ "preferred_form", parent -> parent);
+
+ assertEquals("preferred_form", extension.id().getResourceDomain());
+ assertEquals(new ResourceLocation("preferred_form", "configuration"), extension.id());
+ assertEquals("button.orespawn.mod.configure", extension.buttonTranslationKey());
+ assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register(
+ id("preferred_form", "legacy_form"), "button.example.settings", parent -> parent));
}
@Test
@@ -43,7 +61,7 @@ void passesTheCurrentOreSpawnScreenToTheFactory() {
AtomicReference receivedParent = new AtomicReference<>();
GuiScreen expectedParent = new GuiScreen() { };
GuiScreen expectedChild = new GuiScreen() { };
- WorldSettingsExtension extension = register("parent", parent -> {
+ WorldSettingsExtension extension = register("parent_handoff", "settings", parent -> {
receivedParent.set(parent);
return expectedChild;
});
@@ -54,21 +72,53 @@ void passesTheCurrentOreSpawnScreenToTheFactory() {
}
@Test
- void worldSettingsScreenOwnsExtensionButtonPlacementAndNavigation() throws Exception {
+ void screenResultsMayBeNullOrThrowForTheDirectoryToHandle() {
+ GuiScreen parent = new GuiScreen() { };
+ WorldSettingsExtension missing = register("missing_screen", "settings", ignored -> null);
+ WorldSettingsExtension failing = register("throwing_screen", "settings", ignored -> {
+ throw new IllegalArgumentException("fixture");
+ });
+
+ assertSame(null, missing.createScreen(parent));
+ assertThrows(IllegalArgumentException.class, () -> failing.createScreen(parent));
+ }
+
+ @Test
+ void oldAndPreferredJvmDescriptorsRemainAvailable() throws Exception {
+ assertNotNull(WorldSettingsExtensionRegistry.class.getDeclaredMethod("register",
+ ResourceLocation.class, String.class, WorldSettingsScreenFactory.class));
+ assertNotNull(WorldSettingsExtensionRegistry.class.getDeclaredMethod("registerConfigScreen",
+ String.class, WorldSettingsScreenFactory.class));
+ }
+
+ @Test
+ void mainScreenUsesOneBoundedDirectoryEntryPointInsteadOfExtensionRows() throws Exception {
String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone",
"moddev", "mc", "orespawn", "client", "OreSpawnWorldSettingsScreen.java")),
StandardCharsets.UTF_8);
- assertTrue(source.contains("WorldSettingsExtensionRegistry.extensions()"));
- assertTrue(source.contains("new TextComponentTranslation(extension.buttonTranslationKey())"));
- assertTrue(source.contains("extension.createScreen(this)"));
+ assertTrue(!source.contains("WorldSettingsExtensionRegistry.extensions()"));
+ assertEquals(2, occurrences(source, "button.orespawn.mods"),
+ "Each mutually exclusive main-screen layout owns exactly one Mods button");
+ assertTrue(source.contains("new TextComponentTranslation(\"button.orespawn.mods\")"));
+ assertTrue(source.contains("new OreSpawnModsScreen(this)"));
+ assertTrue(source.contains("syncSession();"));
+ }
+
+ private static int occurrences(String source, String value) {
+ int count = 0;
+ for (int index = 0; (index = source.indexOf(value, index)) >= 0; index += value.length()) {
+ count++;
+ }
+ return count;
}
- private static WorldSettingsExtension register(String path, WorldSettingsScreenFactory factory) {
- return WorldSettingsExtensionRegistry.register(id(path),
+ private static WorldSettingsExtension register(String modId, String path,
+ WorldSettingsScreenFactory factory) {
+ return WorldSettingsExtensionRegistry.register(id(modId, path),
"button.realisticdeposits.settings", factory);
}
- private static ResourceLocation id(String path) {
- return new ResourceLocation("realisticdeposits", path);
+ private static ResourceLocation id(String modId, String path) {
+ return new ResourceLocation(modId, path);
}
}
diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java
new file mode 100644
index 00000000..06f080cc
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java
@@ -0,0 +1,111 @@
+package zone.moddev.mc.orespawn.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.util.ResourceLocation;
+import zone.moddev.mc.orespawn.api.ProviderStatus;
+import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension;
+import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry;
+import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.Entry;
+import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.LoadedMod;
+import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.ProviderIntegrationInfo;
+
+class OreSpawnModDirectoryModelTest {
+ @Test
+ void discoversLoadedNativeLegacyUiOnlyInactiveAndRejectedIntegrations() {
+ WorldSettingsExtension realisticScreen = WorldSettingsExtensionRegistry.register(
+ new ResourceLocation("directory_realistic", "settings"),
+ "button.realisticdeposits.settings", parent -> new GuiScreen() { });
+ WorldSettingsExtension uiOnlyScreen = WorldSettingsExtensionRegistry.registerConfigScreen(
+ "directory_ui_only", parent -> new GuiScreen() { });
+
+ List loaded = Arrays.asList(
+ new LoadedMod("orespawn", "MMD OreSpawn", "4.1.0.110021"),
+ new LoadedMod("directory_realistic", "Realistic Deposits", "0.1.0.110021"),
+ new LoadedMod("directory_base", "Base Metals", "2.5.0-beta5"),
+ new LoadedMod("directory_mineralogy", "Mineralogy", "6.0.2.110021"),
+ new LoadedMod("directory_ui_only", "Client Tweaks", "1.2.3"),
+ new LoadedMod("directory_inactive", "Inactive Rocks", "2.0"),
+ new LoadedMod("directory_rejected", "Rejected Ores", "3.0"));
+ List providers = Arrays.asList(
+ provider("directory_mineralogy", true, true, 4, 3,
+ ProviderStatus.ACTIVE, false),
+ provider("directory_base", false, true, 4, 2,
+ ProviderStatus.ACTIVE, false, 3),
+ provider("directory_realistic", true, true, 4, 1,
+ ProviderStatus.ACTIVE, false, 3),
+ provider("directory_inactive", true, true, 4, 1,
+ ProviderStatus.INACTIVE, false),
+ provider("directory_rejected", true, true, -1, -1,
+ ProviderStatus.INACTIVE, true),
+ provider("directory_unloaded", true, true, 4, 9,
+ ProviderStatus.ACTIVE, false),
+ provider("orespawn", true, true, 4, 1,
+ ProviderStatus.ACTIVE, false));
+
+ List result = OreSpawnModDirectoryModel.assemble(loaded, providers,
+ Arrays.asList(realisticScreen, uiOnlyScreen));
+
+ assertEquals(Arrays.asList("directory_base", "directory_ui_only", "directory_inactive",
+ "directory_mineralogy", "directory_realistic", "directory_rejected"),
+ result.stream().map(entry -> entry.modId).collect(java.util.stream.Collectors.toList()));
+ assertEquals(6, result.size());
+ assertEquals("config_only", find(result, "directory_ui_only").statusKey());
+ assertEquals("inactive", find(result, "directory_inactive").statusKey());
+ assertEquals("rejected", find(result, "directory_rejected").statusKey());
+ assertFalse(find(result, "directory_base").nativeOs4());
+ assertEquals(Collections.singletonList(3), find(result, "directory_base").legacyLineages());
+ assertTrue(find(result, "directory_realistic").nativeOs4());
+ assertTrue(find(result, "directory_realistic").configurable());
+ assertThrows(UnsupportedOperationException.class, () -> result.clear());
+ }
+
+ @Test
+ void deduplicatesByModIdAndSortsEqualNamesByModId() {
+ List loaded = Arrays.asList(
+ new LoadedMod("directory_sort_b", "Same Name", "1"),
+ new LoadedMod("directory_sort_a", "Same Name", "2"));
+ List providers = Arrays.asList(
+ provider("directory_sort_b", false, true, 4, 1, ProviderStatus.ACTIVE, false, 1, 2, 3),
+ provider("directory_sort_a", true, true, 4, 2, ProviderStatus.PENDING, false));
+
+ List result = OreSpawnModDirectoryModel.assemble(loaded, providers,
+ Collections.emptyList());
+
+ assertEquals("directory_sort_a", result.get(0).modId);
+ assertEquals("directory_sort_b", result.get(1).modId);
+ assertEquals(Arrays.asList(1, 2, 3), result.get(1).legacyLineages());
+ assertThrows(UnsupportedOperationException.class,
+ () -> result.get(1).legacyLineages().add(2));
+ }
+
+ @Test
+ void paginationRemainsBoundedAtMinimumResolution() {
+ assertEquals(3, OreSpawnModsScreen.pageSize(240));
+ assertEquals(1, OreSpawnModsScreen.pageCount(0, 3));
+ assertEquals(1, OreSpawnModsScreen.pageCount(3, 3));
+ assertEquals(2, OreSpawnModsScreen.pageCount(4, 3));
+ }
+
+ private static Entry find(List entries, String modId) {
+ return entries.stream().filter(entry -> modId.equals(entry.modId)).findFirst().orElseThrow(
+ () -> new AssertionError("Missing " + modId));
+ }
+
+ private static ProviderIntegrationInfo provider(String modId, boolean nativeOs4,
+ boolean hasProvider, int schema, int revision, ProviderStatus status,
+ boolean rejected, Integer... lineages) {
+ return new ProviderIntegrationInfo(modId, nativeOs4, hasProvider, schema, revision,
+ status, rejected, Arrays.asList(lineages));
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java
new file mode 100644
index 00000000..234543cb
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java
@@ -0,0 +1,75 @@
+package zone.moddev.mc.orespawn.client;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.image.BufferedImage;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import javax.imageio.ImageIO;
+
+import org.junit.jupiter.api.Test;
+
+class OreSpawnModsScreenContractTest {
+ private static final Path CLIENT = Paths.get("src", "main", "java", "zone", "moddev", "mc",
+ "orespawn", "client");
+
+ @Test
+ void packagedCogIsARealSixteenPixelPngRatherThanAFontGlyph() throws Exception {
+ Path cog = Paths.get("src", "main", "resources", "assets", "orespawn", "textures", "gui",
+ "cog.png");
+ byte[] bytes = Files.readAllBytes(cog);
+ assertArrayEquals(new byte[] {(byte) 0x89, 0x50, 0x4e, 0x47},
+ java.util.Arrays.copyOf(bytes, 4));
+ BufferedImage image = ImageIO.read(cog.toFile());
+ assertEquals(16, image.getWidth());
+ assertEquals(16, image.getHeight());
+ String source = read(CLIENT.resolve("CogButton.java"));
+ assertTrue(source.contains("textures/gui/cog.png"));
+ assertFalse(source.contains("\\u2699"));
+ }
+
+ @Test
+ void directoryOwnsPaginationClippingTooltipsAndFailureContainment() throws Exception {
+ String source = read(CLIENT.resolve("OreSpawnModsScreen.java"));
+ assertTrue(source.contains("pageCount(entries.size(), pageSize)"));
+ assertTrue(source.contains("font.trimStringToWidth"));
+ assertTrue(source.contains("cog.enabled = entry.configurable()"));
+ assertTrue(source.contains("tooltip.orespawn.mod.unavailable"));
+ assertTrue(source.contains("entry.extension.createScreen(this)"));
+ assertTrue(source.contains("if (screen == null)"));
+ assertTrue(source.contains("catch (RuntimeException failure)"));
+ assertTrue(source.contains("minecraft.displayGuiScreen(parent)"));
+ assertTrue(source.contains("WorldSettingsExtensionNavigation.open(this, screen)"));
+ String navigation = read(CLIENT.resolve("WorldSettingsExtensionNavigation.java"));
+ assertTrue(navigation.contains("event.getGui() == null"));
+ assertTrue(navigation.contains("event.getGui() instanceof GuiMainMenu"));
+ assertTrue(navigation.contains("event.setGui(directory)"));
+ }
+
+ @Test
+ void discoveryConsumesRecordedBoundariesRatherThanScanningProfilesInTheGui() throws Exception {
+ String model = read(CLIENT.resolve("OreSpawnModDirectoryModel.java"));
+ String legacyThree = read(Paths.get("src", "main", "java", "com", "mcmoddev", "orespawn",
+ "compat", "LegacyOs3Bridge.java"));
+ String legacyTwo = read(Paths.get("src", "main", "java", "zone", "moddev", "mc", "orespawn",
+ "worldgen", "LegacyConfigMigrator.java"));
+ assertTrue(model.contains("Loader.instance().getActiveModList()"));
+ assertTrue(model.contains("mod.getName()"));
+ assertTrue(model.contains("mod.getVersion()"));
+ assertFalse(model.contains("Files."));
+ assertFalse(model.contains("JsonParser"));
+ assertTrue(legacyThree.contains("recordLegacyLineage(owner, 1)"));
+ assertTrue(legacyThree.contains("recordLegacyLineage(owner, 3)"));
+ assertTrue(legacyTwo.contains("recordLegacyLineage(owner, 2)"));
+ }
+
+ private static String read(Path path) throws Exception {
+ return new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
index 7fea8b86..4bc51ee3 100644
--- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
@@ -26,7 +26,7 @@ void everyConcreteScreenClearsThePreviousFrameBeforeDrawingWidgets() throws Exce
.sorted()
.collect(Collectors.toList());
}
- assertEquals(24, screens.size(), "Review this render-order gate when screens are added or removed");
+ assertEquals(25, screens.size(), "Review this render-order gate when screens are added or removed");
for (Path screen : screens) {
String source = new String(Files.readAllBytes(screen), StandardCharsets.UTF_8);
int render = source.indexOf(
@@ -61,8 +61,13 @@ void compactOrePlacementRowsStayAboveFooterAtMinimumTestHeight() {
}
private static void assertRowsClearFooter(int height) {
+ int rows = 9;
+ int top = OreSpawnScreenLayout.mainTop(height);
+ int available = OreSpawnScreenLayout.footerY(height) - top - 20 - 4;
+ int spacing = Math.min(OreSpawnScreenLayout.mainRowSpacing(height),
+ Math.max(20, available / (rows - 1)));
int lastRowBottom = OreSpawnScreenLayout.mainTop(height)
- + (OreSpawnScreenLayout.mainRowSpacing(height) * 7) + 20;
+ + (spacing * (rows - 1)) + 20;
assertTrue(lastRowBottom < OreSpawnScreenLayout.footerY(height));
}
From 6275b4f3c64d8c25d4e818ffb304b2ae5a772ac3 Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Mon, 14 Sep 2026 13:47:09 +0100
Subject: [PATCH 06/31] Polish OreSpawn mod directory layout
---
.../moddev/mc/orespawn/client/CogButton.java | 12 +++++++++++-
.../client/OreSpawnWorldSettingsScreen.java | 10 +++++-----
.../client/OreSpawnModsScreenContractTest.java | 2 ++
.../client/OreSpawnScreenLayoutTest.java | 18 +++++++++++++++++-
4 files changed, 35 insertions(+), 7 deletions(-)
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java
index 250319fb..bd6c25b5 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java
@@ -20,8 +20,18 @@ public void drawButton(Minecraft minecraft, int mouseX, int mouseY) {
minecraft.getTextureManager().bindTexture(COG);
if (enabled) GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F);
else GlStateManager.color(0.45F, 0.45F, 0.45F, 0.8F);
- drawModalRectWithCustomSizedTexture(xPosition + 3, yPosition + 2,
+ GlStateManager.pushMatrix();
+ GlStateManager.translate(xPosition + 11.0F, yPosition + 10.0F, 0.0F);
+ GlStateManager.scale(1.5F, 1.5F, 1.0F);
+ drawModalRectWithCustomSizedTexture(-8, -8,
0.0F, 0.0F, 16, 16, 16.0F, 16.0F);
+ if (enabled) {
+ // The source pixels deliberately retain soft edges. A second pass makes
+ // the enabled icon read clearly against the legacy button texture.
+ drawModalRectWithCustomSizedTexture(-8, -8,
+ 0.0F, 0.0F, 16, 16, 16.0F, 16.0F);
+ }
+ GlStateManager.popMatrix();
GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F);
}
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
index 89268289..8f599ccb 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java
@@ -70,7 +70,7 @@ protected void init() {
boolean terrain = session.hasTerrainRules();
boolean fluids = !session.fluidDepositIds().isEmpty();
int top = OreSpawnScreenLayout.mainTop(this.height);
- int rows = (templateChoices.size() > 1 ? 1 : 0) + (terrain ? 9
+ int rows = (templateChoices.size() > 1 ? 1 : 0) + (terrain ? 8
: 5 + (fluids ? 1 : 0));
int available = OreSpawnScreenLayout.footerY(height) - top - BUTTON_HEIGHT - 4;
int row = Math.min(OreSpawnScreenLayout.mainRowSpacing(this.height),
@@ -150,11 +150,11 @@ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.materi
columnWidth, BUTTON_HEIGHT,
new TextComponentTranslation("button.orespawn.biomes_world_materials"),
button -> openBiomeWorldMaterials(), "tooltip.orespawn.main.biomes_materials"));
- addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex++),
- contentWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.advanced"),
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex),
+ columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.advanced"),
button -> openAdvanced(), "tooltip.orespawn.main.advanced"));
- addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex++),
- contentWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(),
+ addButton(OreSpawnScreenLayout.explainedButton(this, font, right, top + (row * rowIndex++),
+ columnWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(),
"tooltip.orespawn.main.fluid_editor"));
addButton(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex),
columnWidth, BUTTON_HEIGHT, new TextComponentTranslation("button.orespawn.mods"),
diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java
index 234543cb..b0187b1f 100644
--- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreenContractTest.java
@@ -32,6 +32,8 @@ void packagedCogIsARealSixteenPixelPngRatherThanAFontGlyph() throws Exception {
String source = read(CLIENT.resolve("CogButton.java"));
assertTrue(source.contains("textures/gui/cog.png"));
assertFalse(source.contains("\\u2699"));
+ assertTrue(source.contains("GlStateManager.scale(1.5F, 1.5F, 1.0F)"));
+ assertTrue(source.contains("if (enabled)"));
}
@Test
diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
index 4bc51ee3..04a02059 100644
--- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
@@ -60,8 +60,24 @@ void compactOrePlacementRowsStayAboveFooterAtMinimumTestHeight() {
assertCompactOrePlacementClearsFooter(240);
}
+ @Test
+ void advancedAndFluidEditorsShareTheRecoveredTerrainRow() throws Exception {
+ Path screen = Paths.get("src", "main", "java", "zone", "moddev", "mc",
+ "orespawn", "client", "OreSpawnWorldSettingsScreen.java");
+ String source = new String(Files.readAllBytes(screen), StandardCharsets.UTF_8);
+ int advanced = source.indexOf("new TextComponentTranslation(\"button.orespawn.advanced\")");
+ int advancedCall = source.lastIndexOf("addButton", advanced);
+ int fluid = source.indexOf("fluidEditorLabel()", advanced);
+ int fluidCall = source.lastIndexOf("addButton", fluid);
+ assertTrue(advanced > 0 && fluid > advanced);
+ assertTrue(source.substring(advancedCall, advanced)
+ .contains("font, left, top + (row * rowIndex)"));
+ assertTrue(source.substring(fluidCall, fluid)
+ .contains("font, right, top + (row * rowIndex++)"));
+ }
+
private static void assertRowsClearFooter(int height) {
- int rows = 9;
+ int rows = 8;
int top = OreSpawnScreenLayout.mainTop(height);
int available = OreSpawnScreenLayout.footerY(height) - top - 20 - 4;
int spacing = Math.min(OreSpawnScreenLayout.mainRowSpacing(height),
From e0850d3e3de12ca6321352217b801004f8740aa4 Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Mon, 14 Sep 2026 15:59:11 +0100
Subject: [PATCH 07/31] Add material source arbitration contracts
---
docs/examples/examplemod-orespawn.json | 4 +-
docs/schemas/orespawn-provider.schema.json | 2 +-
.../mc/orespawn/api/OreGenerationContext.java | 9 +++++
.../mc/orespawn/api/WorldgenProvider.java | 26 ++++++++++++-
.../WorldgenIntegrationManager.java | 24 ++++++++----
.../api/OreGenerationContextTest.java | 9 +++++
.../mc/orespawn/api/WorldgenProviderTest.java | 26 ++++++++++++-
.../WorldgenIntegrationManagerTest.java | 37 +++++++++++++++++++
8 files changed, 124 insertions(+), 13 deletions(-)
diff --git a/docs/examples/examplemod-orespawn.json b/docs/examples/examplemod-orespawn.json
index d850d2ef..6590127c 100644
--- a/docs/examples/examplemod-orespawn.json
+++ b/docs/examples/examplemod-orespawn.json
@@ -1,5 +1,5 @@
{
- "schema_version": 4,
+ "schema_version": 5,
"provider_modid": "examplemod",
"provider_revision": 1,
"rocks": {
@@ -32,6 +32,7 @@
"ores": {
"examplemod:ore/tin": {
"block": "examplemod:tin_ore",
+ "material": "orespawn:tin",
"outputs": [
{ "block": "examplemod:tin_ore", "weight": 90 },
{ "block": "examplemod:rich_tin_ore", "weight": 10, "min_y": 0, "max_y": 24 }
@@ -44,6 +45,7 @@
"min_y": 0,
"max_y": 96,
"frequency": 6.0,
+ "placement_channel": "orespawn:standard",
"quantity": 8,
"pattern": {
"type": "orespawn:clusters",
diff --git a/docs/schemas/orespawn-provider.schema.json b/docs/schemas/orespawn-provider.schema.json
index 8fa0f191..41739283 100644
--- a/docs/schemas/orespawn-provider.schema.json
+++ b/docs/schemas/orespawn-provider.schema.json
@@ -4,7 +4,7 @@
"type": "object",
"required": ["schema_version", "provider_modid", "provider_revision"],
"properties": {
- "schema_version": { "enum": [1, 2, 3, 4] },
+ "schema_version": { "enum": [1, 2, 3, 4, 5] },
"provider_modid": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]+$" },
"provider_revision": { "type": "integer", "minimum": 1 },
"merge_new_entries_into_existing_worlds": { "type": "boolean" },
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
index a73da872..72a6080b 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java
@@ -16,6 +16,15 @@
* original {@link OrePlacementContext} contract remain binary compatible.
*/
public interface OreGenerationContext extends OrePlacementContext {
+ /**
+ * Attempts placement using a stable identity shared by every slice of one
+ * logical deposit body. Implementations compiled before this overload retain
+ * the original placement behaviour.
+ */
+ default boolean tryPlace(int x, int y, int z, long outputIdentity) {
+ return tryPlace(x, y, z);
+ }
+
/** Stable seed of the world being generated. */
long worldSeed();
diff --git a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java
index 584a3b94..a62bf209 100644
--- a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java
+++ b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java
@@ -11,6 +11,7 @@
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
+import java.util.Optional;
import java.util.OptionalDouble;
import java.util.Set;
import java.util.function.Consumer;
@@ -61,7 +62,7 @@ public boolean mergeNewEntriesIntoExistingWorlds() {
return mergeNewEntriesIntoExistingWorlds;
}
- /** Returns a defensive JSON representation matching provider schema 4. */
+ /** Returns a defensive JSON representation matching provider schema 5. */
public JsonObject toJson() {
return JsonCopies.copy(definition);
}
@@ -236,7 +237,7 @@ public WorldgenProvider build() {
requireOwned(dimensionMaterials.keySet(), "dimension materials");
requireOwned(templates.keySet(), "template");
JsonObject root = new JsonObject();
- root.addProperty("schema_version", 4);
+ root.addProperty("schema_version", 5);
root.addProperty("provider_modid", modId);
root.addProperty("provider_revision", revision);
root.addProperty("merge_new_entries_into_existing_worlds",
@@ -376,6 +377,7 @@ public RockDefinition build() {
public static final class OreDefinition implements JsonDefinition {
private final ResourceLocation id;
private final ResourceLocation block;
+ private final ResourceLocation material;
private final boolean enabled;
private final boolean nativeGeneration;
private final ResourceLocation deepOutput;
@@ -389,6 +391,7 @@ public static final class OreDefinition implements JsonDefinition {
private OreDefinition(Builder builder) {
id = builder.id;
block = builder.block;
+ material = builder.material;
enabled = builder.enabled;
nativeGeneration = builder.nativeGeneration;
deepOutput = builder.deepOutput;
@@ -404,6 +407,8 @@ private OreDefinition(Builder builder) {
public static Builder builder(ResourceLocation id, ResourceLocation block) { return new Builder(id, block); }
public ResourceLocation id() { return id; }
public ResourceLocation block() { return block; }
+ /** Stable semantic material shared by interchangeable ore outputs. */
+ public Optional material() { return Optional.ofNullable(material); }
public boolean enabled() { return enabled; }
public List outputs() { return outputs; }
public boolean suppressVanilla() { return suppressVanilla; }
@@ -415,6 +420,7 @@ private OreDefinition(Builder builder) {
public JsonObject toJson() {
JsonObject json = new JsonObject();
json.addProperty("block", block.toString());
+ if (material != null) json.addProperty("material", material.toString());
json.addProperty("enabled", enabled);
json.addProperty("native_generation", nativeGeneration);
json.addProperty("suppress_vanilla", suppressVanilla);
@@ -440,6 +446,7 @@ public JsonObject toJson() {
public static final class Builder {
private final ResourceLocation id;
private final ResourceLocation block;
+ private ResourceLocation material;
private boolean enabled = true;
private boolean nativeGeneration;
private ResourceLocation deepOutput;
@@ -456,6 +463,8 @@ private Builder(ResourceLocation id, ResourceLocation block) {
this.block = Objects.requireNonNull(block, "block");
}
public Builder enabled(boolean value) { enabled = value; return this; }
+ /** Declares the semantic material used for source arbitration. */
+ public Builder material(ResourceLocation value) { material = Objects.requireNonNull(value); return this; }
public Builder nativeGeneration(boolean value) { nativeGeneration = value; return this; }
public Builder suppressVanilla(boolean value) { suppressVanilla = value; return this; }
public Builder retrogen(boolean value) { retrogen = value; return this; }
@@ -547,6 +556,7 @@ public static final class OreDimensionDefinition implements JsonDefinition {
private final int maxQuantity;
private final OrePattern pattern;
private final ResourceLocation patternType;
+ private final ResourceLocation placementChannel;
private final JsonObject patternSettings;
private final OreHeightDistribution heightDistribution;
private final double discardChanceOnAirExposure;
@@ -575,6 +585,9 @@ private OreDimensionDefinition(Builder builder) {
maxQuantity = builder.maxQuantity;
pattern = builder.pattern;
patternType = builder.patternType;
+ placementChannel = builder.placementChannel != null ? builder.placementChannel
+ : builder.patternType != null ? builder.patternType
+ : new ResourceLocation("orespawn", "standard");
patternSettings = JsonCopies.copy(builder.patternSettings);
heightDistribution = builder.heightDistribution;
discardChanceOnAirExposure = builder.discardChanceOnAirExposure;
@@ -607,6 +620,8 @@ private OreDimensionDefinition(Builder builder) {
public int maxQuantity() { return maxQuantity; }
public OrePattern pattern() { return pattern; }
public ResourceLocation patternType() { return patternType; }
+ /** Stable channel whose placement budget is arbitrated independently. */
+ public ResourceLocation placementChannel() { return placementChannel; }
public JsonObject patternSettings() { return JsonCopies.copy(patternSettings); }
public OreHeightDistribution heightDistribution() { return heightDistribution; }
public double discardChanceOnAirExposure() { return discardChanceOnAirExposure; }
@@ -654,6 +669,7 @@ public JsonObject toJson() {
configuredPattern.add("settings", JsonCopies.copy(patternSettings));
json.add("pattern", configuredPattern);
}
+ json.addProperty("placement_channel", placementChannel.toString());
json.addProperty("height_distribution", heightDistribution.configName());
json.addProperty("discard_chance_on_air_exposure", discardChanceOnAirExposure);
json.addProperty("spread", spread);
@@ -685,6 +701,7 @@ public static final class Builder {
private int maxQuantity = 8;
private OrePattern pattern = OrePattern.VEIN;
private ResourceLocation patternType;
+ private ResourceLocation placementChannel;
private JsonObject patternSettings = new JsonObject();
private OreHeightDistribution heightDistribution = OreHeightDistribution.UNIFORM;
private double discardChanceOnAirExposure;
@@ -722,6 +739,11 @@ public Builder pattern(ResourceLocation type, JsonObject settings) {
patternSettings = JsonCopies.copy(Objects.requireNonNull(settings, "settings"));
return this;
}
+ /** Overrides the placement-budget channel for this dimension rule. */
+ public Builder placementChannel(ResourceLocation value) {
+ placementChannel = Objects.requireNonNull(value);
+ return this;
+ }
public Builder heightDistribution(OreHeightDistribution value) { heightDistribution = Objects.requireNonNull(value); return this; }
public Builder discardChanceOnAirExposure(double value) { discardChanceOnAirExposure = value; return this; }
public Builder spread(int horizontal, int vertical) { spread = horizontal; verticalSpread = vertical; return this; }
diff --git a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
index 316d592a..72046d43 100644
--- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
+++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java
@@ -206,7 +206,7 @@ public static synchronized List providerIntegrations()
|| RESOURCE_PROVIDERS.containsKey(providerId)
|| FILE_PROVIDER_IDS.contains(providerId);
result.add(new ProviderIntegrationInfo(providerId, nativeOs4, hasProvider,
- root == null ? (api == null ? -1 : 4) : integer(root, "schema_version", -1),
+ root == null ? (api == null ? -1 : 5) : integer(root, "schema_version", -1),
root == null ? (api == null ? -1 : api.revision())
: integer(root, "provider_revision", -1),
getProviderStatus(providerId), INVALID_PROVIDERS.contains(providerId), legacy));
@@ -506,7 +506,7 @@ private static JsonObject selectedProviderRoot(String providerId) {
static void validateProvider(String providerId, JsonObject root) {
int schema = integer(root, "schema_version", -1);
- if (schema != 1 && schema != 2 && schema != 3 && schema != 4) {
+ if (schema != 1 && schema != 2 && schema != 3 && schema != 4 && schema != 5) {
throw new JsonSyntaxException("unsupported schema_version");
}
if (!providerId.equals(string(root, "provider_modid", ""))) {
@@ -555,7 +555,7 @@ static void validateProvider(String providerId, JsonObject root) {
if ("rocks".equals(section)) {
validateRock(entry.getKey(), entry.getValue().getAsJsonObject());
} else if ("ores".equals(section)) {
- validateOre(entry.getKey(), entry.getValue().getAsJsonObject());
+ validateOre(entry.getKey(), entry.getValue().getAsJsonObject(), schema);
} else if ("geomes".equals(section)) {
validateGeome(entry.getKey(), entry.getValue().getAsJsonObject());
} else if ("biome_rules".equals(section)) {
@@ -610,7 +610,11 @@ private static void validateRock(String idText, JsonObject rock) {
validateWeights(optionalObject(rock, "geomes"));
}
- private static void validateOre(String idText, JsonObject ore) {
+ private static void validateOre(String idText, JsonObject ore, int schema) {
+ if (schema < 5 && ore.has("material")) {
+ throw new JsonSyntaxException("ore material requires provider schema 5");
+ }
+ if (ore.has("material")) new ResourceLocation(string(ore, "material", ""));
String blockId = string(ore, "block", idText);
Block output = block(blockId);
if (output == null || output == Blocks.AIR) {
@@ -626,19 +630,25 @@ private static void validateOre(String idText, JsonObject ore) {
}
for (Entry entry : dimensions.entrySet()) {
new ResourceLocation(entry.getKey());
- validateOreRule(idText, entry);
+ validateOreRule(idText, entry, schema);
}
for (Entry entry : selectors.entrySet()) {
OreDimensionSelector.fromId(new ResourceLocation(entry.getKey()));
- validateOreRule(idText, entry);
+ validateOreRule(idText, entry, schema);
}
}
- private static void validateOreRule(String idText, Entry entry) {
+ private static void validateOreRule(String idText, Entry entry, int schema) {
if (!entry.getValue().isJsonObject()) {
throw new JsonSyntaxException("ore dimension is not an object: " + entry.getKey());
}
JsonObject rule = entry.getValue().getAsJsonObject();
+ if (schema < 5 && rule.has("placement_channel")) {
+ throw new JsonSyntaxException("ore placement_channel requires provider schema 5");
+ }
+ if (rule.has("placement_channel")) {
+ new ResourceLocation(string(rule, "placement_channel", ""));
+ }
if (!bool(rule, "enabled", true)) return;
int minY = integer(rule, "min_y", Integer.MIN_VALUE);
int maxY = integer(rule, "max_y", Integer.MIN_VALUE);
diff --git a/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java
index 6399b435..e7ff2f18 100644
--- a/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java
@@ -25,6 +25,13 @@ void extendsTheOriginalPlacementContractWithoutChangingItsAbi() {
assertFalse(baseMethods.contains("chunkX"));
assertFalse(baseMethods.contains("chunkZ"));
assertFalse(baseMethods.contains("geologySampler"));
+ assertEquals(boolean.class, method(OrePlacementContext.class, "tryPlace",
+ int.class, int.class, int.class).getReturnType());
+ }
+
+ private static Method method(Class> owner, String name, Class>... parameters) {
+ try { return owner.getMethod(name, parameters); }
+ catch (ReflectiveOperationException missing) { throw new AssertionError(missing); }
}
@Test
@@ -36,6 +43,8 @@ void exposesTheStableRegionDepositIdentityContract() throws Exception {
assertEquals(int.class, OreGenerationContext.class.getMethod("chunkZ").getReturnType());
assertEquals(Optional.class,
OreGenerationContext.class.getMethod("geologySampler").getReturnType());
+ assertTrue(OreGenerationContext.class.getMethod("tryPlace",
+ int.class, int.class, int.class, long.class).isDefault());
}
@Test
diff --git a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java
index 68f57d10..611e6074 100644
--- a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java
@@ -37,18 +37,20 @@ void terrainHostContractRetainsLegacyNaturalSourceOrder() {
}
@Test
- void serializesTypedSchemaFourProvider() {
+ void serializesTypedSchemaFiveProvider() {
ResourceLocation overworld = id("minecraft:overworld");
WorldgenProvider provider = WorldgenProvider.builder("examplemod", 4)
.rock(id("examplemod:slate"), GeologyFamily.METAMORPHIC, rock -> rock
.depth(20, 36).weight(1.25D).oreReplaceable(true))
.ore(id("examplemod:tin_ore"), ore -> ore
+ .material(id("orespawn:tin"))
.output(id("examplemod:tin_ore"), 9.0D)
.output(id("examplemod:rich_tin_ore"), 1.0D, -64, 24)
.suppressVanilla(true).retrogen(false)
.dimension(overworld, dimension -> dimension
.yRange(-16, 96).attempts(6.5D).quantity(8)
.pattern(OrePattern.CLUSTER)
+ .placementChannel(id("examplemod:ordinary_veins"))
.heightDistribution(OreHeightDistribution.BOTTOM_TRIANGLE)
.discardChanceOnAirExposure(0.75D)
.hostFamily(GeologyFamily.METAMORPHIC)
@@ -58,19 +60,22 @@ void serializesTypedSchemaFourProvider() {
.build();
JsonObject json = provider.toJson();
- assertEquals(4, json.get("schema_version").getAsInt());
+ assertEquals(5, json.get("schema_version").getAsInt());
assertEquals("examplemod", json.get("provider_modid").getAsString());
assertTrue(json.getAsJsonObject("rocks").has("examplemod:rock/examplemod/slate"));
assertEquals("examplemod:slate", json.getAsJsonObject("rocks")
.getAsJsonObject("examplemod:rock/examplemod/slate").get("block").getAsString());
assertTrue(json.getAsJsonObject("ores").has("examplemod:ore/examplemod/tin_ore"));
JsonObject ore = json.getAsJsonObject("ores").getAsJsonObject("examplemod:ore/examplemod/tin_ore");
+ assertEquals("orespawn:tin", ore.get("material").getAsString());
assertEquals(2, ore.getAsJsonArray("outputs").size());
assertFalse(ore.get("retrogen").getAsBoolean());
assertEquals(0.75D, ore.getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld")
.getAsJsonArray("host_blocks").get(0).getAsJsonObject().get("weight").getAsDouble());
assertEquals("bottom_triangle", ore.getAsJsonObject("dimensions")
.getAsJsonObject("minecraft:overworld").get("height_distribution").getAsString());
+ assertEquals("examplemod:ordinary_veins", ore.getAsJsonObject("dimensions")
+ .getAsJsonObject("minecraft:overworld").get("placement_channel").getAsString());
assertEquals(0.75D, ore.getAsJsonObject("dimensions")
.getAsJsonObject("minecraft:overworld")
.get("discard_chance_on_air_exposure").getAsDouble());
@@ -81,6 +86,23 @@ void serializesTypedSchemaFourProvider() {
.has("examplemod:rock/examplemod/slate"));
}
+ @Test
+ void derivesStablePlacementChannelsAndExposesImmutableMaterial() {
+ WorldgenProvider.OreDimensionDefinition standard = WorldgenProvider.OreDimensionDefinition
+ .builder(id("minecraft:overworld")).hostBlock(id("minecraft:stone")).build();
+ WorldgenProvider.OreDimensionDefinition custom = WorldgenProvider.OreDimensionDefinition
+ .builder(id("minecraft:overworld"))
+ .pattern(id("realisticdeposits:stratiform"), new JsonObject())
+ .hostBlock(id("minecraft:stone")).build();
+ WorldgenProvider.OreDefinition ore = WorldgenProvider.OreDefinition
+ .builder(id("examplemod:iron"), id("examplemod:iron_ore"))
+ .material(id("orespawn:iron")).dimension(standard).build();
+
+ assertEquals(id("orespawn:iron"), ore.material().get());
+ assertEquals(id("orespawn:standard"), standard.placementChannel());
+ assertEquals(id("realisticdeposits:stratiform"), custom.placementChannel());
+ }
+
@Test
void serializesCompleteDeclarativeProviderSurface() {
ResourceLocation dimension = id("minecraft:the_end");
diff --git a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java
index 14976eab..a5f4b991 100644
--- a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java
@@ -116,6 +116,14 @@ void validatesBiomePaletteAndDimensionMaterialsOnlyInSchemaFour() {
() -> WorldgenIntegrationManager.validateProvider("examplemod", solidFluid));
}
+ @Test
+ void sourceArbitrationFieldsRequireProviderSchemaFive() {
+ assertDoesNotThrow(() -> WorldgenIntegrationManager.validateProvider(
+ "examplemod", oreSourceProvider(5)));
+ assertThrows(JsonSyntaxException.class, () -> WorldgenIntegrationManager.validateProvider(
+ "examplemod", oreSourceProvider(4)));
+ }
+
@Test
@SuppressWarnings({ "rawtypes", "unchecked" })
void providerCanDeferNewEntriesForExistingWorldsWithoutChangingNewWorldDefaults()
@@ -227,4 +235,33 @@ private static JsonObject biomeProvider(int schema, String fluid) {
root.add("dimension_materials", materials);
return root;
}
+
+ private static JsonObject oreSourceProvider(int schema) {
+ JsonObject root = new JsonObject();
+ root.addProperty("schema_version", schema);
+ root.addProperty("provider_modid", "examplemod");
+ root.addProperty("provider_revision", 1);
+ JsonObject rule = new JsonObject();
+ // This test exercises only the schema gate. Pattern decoding is covered by
+ // the provider validation tests that install the OreSpawn pattern registry.
+ rule.addProperty("enabled", false);
+ rule.addProperty("min_y", 0);
+ rule.addProperty("max_y", 64);
+ rule.addProperty("frequency", 1.0D);
+ rule.addProperty("quantity", 8);
+ rule.addProperty("placement_channel", "orespawn:standard");
+ JsonArray hosts = new JsonArray();
+ hosts.add(new JsonPrimitive("minecraft:stone"));
+ rule.add("host_blocks", hosts);
+ JsonObject ore = new JsonObject();
+ ore.addProperty("block", "minecraft:iron_ore");
+ ore.addProperty("material", "orespawn:iron");
+ JsonObject dimensions = new JsonObject();
+ dimensions.add("minecraft:overworld", rule);
+ ore.add("dimensions", dimensions);
+ JsonObject ores = new JsonObject();
+ ores.add("examplemod:iron", ore);
+ root.add("ores", ores);
+ return root;
+ }
}
From 33f00840339663c79f579058eb79e2a1b211123b Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Mon, 14 Sep 2026 15:59:20 +0100
Subject: [PATCH 08/31] Resolve duplicate ore sources deterministically
---
build.gradle | 10 +-
docs/examples/orespawn-global.json | 3 +-
docs/examples/orespawn-world.json | 3 +-
docs/schemas/orespawn-global.schema.json | 2 +-
docs/schemas/orespawn-world.schema.json | 2 +-
docs/schemas/profile-common.schema.json | 41 +-
.../mc/orespawn/worldgen/GeomeConfig.java | 15 +-
.../orespawn/worldgen/OreSourcePolicies.java | 700 ++++++++++++++++++
.../worldgen/OreSpawnOreGeneration.java | 325 +++++++-
.../worldgen/WorldGeologyProfile.java | 17 +-
.../worldgen/WorldGeologyProfileManager.java | 24 +-
.../BackgroundGenerationScaleTest.java | 11 +
.../worldgen/OreSourcePoliciesTest.java | 254 +++++++
.../worldgen/OreSpawnOreGenerationTest.java | 121 ++-
.../worldgen/WorldGeologyProfileTest.java | 78 ++
15 files changed, 1564 insertions(+), 42 deletions(-)
create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java
create mode 100644 src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java
diff --git a/build.gradle b/build.gradle
index 99b03906..2e1acd2a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1002,14 +1002,14 @@ tasks.register('verifyReleaseConfiguration') {
throw new GradleException('OreSpawn API major must remain 1')
}
if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java')
- .getText('UTF-8').contains('SCHEMA_VERSION = 6')
+ .getText('UTF-8').contains('SCHEMA_VERSION = 7')
|| !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java')
- .getText('UTF-8').contains('SCHEMA_VERSION = 5')) {
- throw new GradleException('Global/world schema versions must remain 6/5')
+ .getText('UTF-8').contains('SCHEMA_VERSION = 6')) {
+ throw new GradleException('Global/world schema versions must remain 7/6')
}
def providerSchema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json'))
- if (!(providerSchema.properties.schema_version.enum as List).contains(4)) {
- throw new GradleException('Provider schema must continue to support schema version 4')
+ if (!(providerSchema.properties.schema_version.enum as List).containsAll([4, 5])) {
+ throw new GradleException('Provider schema must support schema versions 4 and 5')
}
}
}
diff --git a/docs/examples/orespawn-global.json b/docs/examples/orespawn-global.json
index cabcfee1..dd3740a5 100644
--- a/docs/examples/orespawn-global.json
+++ b/docs/examples/orespawn-global.json
@@ -1,5 +1,5 @@
{
- "schema_version": 6,
+ "schema_version": 7,
"geology_mode": "geome",
"place_fluid_deposits": true,
"manage_vanilla_ores": false,
@@ -32,6 +32,7 @@
"biome_palettes": {},
"dimension_materials": {},
"ores": {},
+ "ore_source_policies": {},
"fluid_deposits": {},
"retrogen": {
"enabled": false,
diff --git a/docs/examples/orespawn-world.json b/docs/examples/orespawn-world.json
index 93f51e30..ca099c74 100644
--- a/docs/examples/orespawn-world.json
+++ b/docs/examples/orespawn-world.json
@@ -1,5 +1,5 @@
{
- "schema_version": 5,
+ "schema_version": 6,
"geology_mode": "geome",
"place_fluid_deposits": true,
"manage_vanilla_ores": false,
@@ -70,6 +70,7 @@
"biome_palettes": {},
"dimension_materials": {},
"ores": {},
+ "ore_source_policies": {},
"fluid_deposits": {},
"retrogen": {
"enabled": false,
diff --git a/docs/schemas/orespawn-global.schema.json b/docs/schemas/orespawn-global.schema.json
index e20e5774..df82afa9 100644
--- a/docs/schemas/orespawn-global.schema.json
+++ b/docs/schemas/orespawn-global.schema.json
@@ -6,7 +6,7 @@
{
"type": "object",
"required": ["schema_version"],
- "properties": { "schema_version": { "const": 6 } }
+ "properties": { "schema_version": { "const": 7 } }
}
]
}
diff --git a/docs/schemas/orespawn-world.schema.json b/docs/schemas/orespawn-world.schema.json
index f23b7970..aebd4bde 100644
--- a/docs/schemas/orespawn-world.schema.json
+++ b/docs/schemas/orespawn-world.schema.json
@@ -6,7 +6,7 @@
{
"type": "object",
"required": ["schema_version"],
- "properties": { "schema_version": { "const": 5 } }
+ "properties": { "schema_version": { "const": 6 } }
}
]
}
diff --git a/docs/schemas/profile-common.schema.json b/docs/schemas/profile-common.schema.json
index d643ded5..9e3827e9 100644
--- a/docs/schemas/profile-common.schema.json
+++ b/docs/schemas/profile-common.schema.json
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://mcmoddev.com/orespawn/schemas/profile-common.schema.json",
"type": "object",
- "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "fluid_deposits"],
+ "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "ore_source_policies", "fluid_deposits"],
"properties": {
"geology_mode": { "enum": ["geome", "legacy"] },
"place_fluid_deposits": { "type": "boolean" },
@@ -19,6 +19,7 @@
"biome_palettes": { "type": "object", "additionalProperties": { "$ref": "#/$defs/biomePalette" } },
"dimension_materials": { "type": "object", "additionalProperties": { "$ref": "#/$defs/dimensionMaterials" } },
"ores": { "type": "object", "additionalProperties": { "$ref": "#/$defs/ore" } },
+ "ore_source_policies": { "type": "object", "additionalProperties": { "$ref": "#/$defs/oreSourcePolicy" } },
"fluid_deposits": { "type": "object", "additionalProperties": { "$ref": "#/$defs/fluidDeposit" } },
"worldgen_aliases": { "type": "object", "additionalProperties": { "$ref": "#/$defs/registryId" } },
"providers": { "type": "object" },
@@ -153,6 +154,7 @@
"minimum": 0.0,
"maximum": 1.0
},
+ "placement_channel": { "$ref": "#/$defs/registryId" },
"spread": { "type": "integer", "minimum": 0, "maximum": 64 },
"vertical_spread": { "type": "integer", "minimum": 0, "maximum": 64 },
"node_size": { "type": "integer", "minimum": 1, "maximum": 32 },
@@ -189,6 +191,7 @@
"required": ["enabled"],
"properties": {
"block": { "$ref": "#/$defs/registryId" },
+ "material": { "$ref": "#/$defs/registryId" },
"metadata": { "$ref": "#/$defs/metadata" },
"enabled": { "type": "boolean" },
"source_mod": { "type": "string" },
@@ -228,6 +231,42 @@
],
"additionalProperties": true
},
+ "oreSourceCandidate": {
+ "type": "object",
+ "required": ["source_id", "owner", "registry_id", "material", "domain", "placement_channel", "loaded", "external"],
+ "properties": {
+ "source_id": { "type": "string", "minLength": 1 },
+ "owner": { "type": "string", "minLength": 1 },
+ "owner_name": { "type": "string" },
+ "owner_version": { "type": "string" },
+ "registry_id": { "$ref": "#/$defs/registryId" },
+ "metadata": { "$ref": "#/$defs/metadata" },
+ "material": { "$ref": "#/$defs/registryId" },
+ "domain": { "$ref": "#/$defs/registryId" },
+ "placement_channel": { "$ref": "#/$defs/registryId" },
+ "ore_dictionary": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
+ "loaded": { "type": "boolean" },
+ "external": { "type": "boolean" },
+ "enrichment": { "type": "boolean" },
+ "review_required": { "type": "boolean" }
+ },
+ "additionalProperties": false
+ },
+ "oreSourcePolicy": {
+ "type": "object",
+ "required": ["material", "domain", "mode", "outputs", "placement_sources", "candidates"],
+ "properties": {
+ "material": { "$ref": "#/$defs/registryId" },
+ "domain": { "$ref": "#/$defs/registryId" },
+ "mode": { "enum": ["consolidated", "keep_separate"] },
+ "outputs": { "type": "object", "additionalProperties": { "type": "number", "exclusiveMinimum": 0 } },
+ "placement_sources": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "type": "string", "minLength": 1 } },
+ "candidates": { "type": "array", "items": { "$ref": "#/$defs/oreSourceCandidate" } },
+ "review_required": { "type": "boolean" },
+ "status": { "enum": ["consolidated", "separate", "review_required", "missing_source", "external_generation"] }
+ },
+ "additionalProperties": false
+ },
"fluidDepositDimension": {
"type": "object",
"required": ["enabled", "min_y", "max_y", "frequency", "min_radius", "max_radius", "min_vertical_radius", "max_vertical_radius", "max_lobes", "min_solid_cover"],
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java
index 02642706..76f786be 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java
@@ -63,6 +63,7 @@ public final class GeomeConfig {
private static final Path CONFIG_V3_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v3.bak");
private static final Path CONFIG_V4_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v4.bak");
private static final Path CONFIG_V5_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v5.bak");
+ private static final Path CONFIG_V6_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v6.bak");
private static final Path BIOME_DEFAULTS_BACKUP_PATH = Paths.get("config",
"orespawn-worldgen.pre-biome-revision-3.bak");
private static final Path WORLDGEN_ALIAS_DEFAULTS_BACKUP_PATH = Paths.get("config",
@@ -72,7 +73,7 @@ public final class GeomeConfig {
private static final Path CONFIG_TEMP_PATH = Paths.get("config", "orespawn-worldgen.json.tmp");
private static final Path PROVIDER_DEFAULTS_BACKUP_PATH = Paths.get("config",
"orespawn-worldgen.pre-provider-defaults.bak");
- public static final int SCHEMA_VERSION = 6;
+ public static final int SCHEMA_VERSION = 7;
private static final int BIOME_DEFAULTS_REVISION = 3;
private static final int WORLDGEN_ALIAS_DEFAULTS_REVISION = 1;
private static final int ORE_DEFAULTS_REVISION = 10;
@@ -98,6 +99,7 @@ public static synchronized BakedGeomeConfig bake() {
JsonObject root = loadConfig();
boolean changed = OreSpawnOreIntegration.mergeProviderOres(root);
changed |= FluidDepositMigration.normalize(root);
+ changed |= OreSourcePolicies.initialize(root, false);
if (changed) {
writeProviderMerge(root);
}
@@ -195,13 +197,14 @@ private static JsonObject loadConfig() {
int schemaVersion = getInt(root, "schema_version", 1);
if (schemaVersion < SCHEMA_VERSION) {
JsonObject migrated = schemaVersion <= 1 ? migrateV1(root) : JsonCopies.copy(root);
- migrated = migrateToV6(migrated, defaults);
+ migrated = migrateToV7(migrated, defaults);
writeMigratedConfig(migrated,
schemaVersion <= 1 ? CONFIG_BACKUP_PATH
: schemaVersion == 2 ? CONFIG_V2_BACKUP_PATH
: schemaVersion == 3 ? CONFIG_V3_BACKUP_PATH
: schemaVersion == 4 ? CONFIG_V4_BACKUP_PATH
- : CONFIG_V5_BACKUP_PATH);
+ : schemaVersion == 5 ? CONFIG_V5_BACKUP_PATH
+ : CONFIG_V6_BACKUP_PATH);
return migrated;
}
if (schemaVersion > SCHEMA_VERSION) {
@@ -664,14 +667,15 @@ private static JsonObject migrateV1(JsonObject original) {
return refreshBiomeDefaults(migrated, defaultConfig());
}
- private static JsonObject migrateToV6(JsonObject original, JsonObject defaults) {
+ private static JsonObject migrateToV7(JsonObject original, JsonObject defaults) {
JsonObject migrated = getInt(original, "biome_defaults_revision", 0) < BIOME_DEFAULTS_REVISION
? refreshBiomeDefaults(original, defaults) : JsonCopies.copy(original);
FluidDepositMigration.normalize(migrated);
for (String key : new String[] { "geology_mode", "place_fluid_deposits", "fluid_deposits",
"manage_vanilla_ores", "ore_defaults_revision", "cyano", "ores",
"ore_providers", "providers", "worldgen_aliases", "default_template",
- "terrain_dimensions", "biome_palettes", "dimension_materials" }) {
+ "terrain_dimensions", "biome_palettes", "dimension_materials",
+ OreSourcePolicies.SECTION }) {
if (!migrated.has(key)) {
migrated.add(key, JsonCopies.copy(defaults.get(key)));
}
@@ -1415,6 +1419,7 @@ private static JsonObject defaultConfig() {
root.add("cyano", defaultCyanoConfig());
root.add("fluid_deposits", new JsonObject());
root.add("ores", defaultOreConfig());
+ root.add(OreSourcePolicies.SECTION, new JsonObject());
root.add("ore_providers", new JsonObject());
root.add("providers", new JsonObject());
root.add("terrain_dimensions", new JsonObject());
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java
new file mode 100644
index 00000000..89292d3c
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java
@@ -0,0 +1,700 @@
+package zone.moddev.mc.orespawn.worldgen;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import zone.moddev.mc.orespawn.util.JsonCopies;
+
+import net.minecraft.block.Block;
+import net.minecraft.init.Blocks;
+import net.minecraft.item.ItemStack;
+import net.minecraft.util.ResourceLocation;
+import net.minecraftforge.fml.common.Loader;
+import net.minecraftforge.fml.common.ModContainer;
+import net.minecraftforge.fml.common.registry.ForgeRegistries;
+import net.minecraftforge.oredict.OreDictionary;
+
+/** Bake-time discovery and persistence for equivalent managed ore sources. */
+final class OreSourcePolicies {
+ static final ResourceLocation STANDARD = new ResourceLocation("orespawn", "standard");
+ static final String SECTION = "ore_source_policies";
+ private static final String MODE_CONSOLIDATED = "consolidated";
+ private static final String MODE_SEPARATE = "keep_separate";
+ private static final ResourceLocation REVIEW = new ResourceLocation("orespawn", "review_required");
+ private static final Map> PRIORITIES = priorities();
+ private static final Set ORDINARY_MMD = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(
+ "basemetals", "modernmetals", "basegems", "baseminerals", "fantasymetals",
+ "electricadvantage", "steamadvantage", "poweradvantage", "mineralogy")));
+
+ private OreSourcePolicies() { }
+
+ /**
+ * Discovers currently available sources and creates only missing policies.
+ * Existing selections are never discarded, which lets missing mods return.
+ */
+ static boolean initialize(JsonObject root, boolean existingWorld) {
+ boolean hadPolicies = root.has(SECTION) && root.get(SECTION).isJsonObject();
+ JsonObject policies = object(root, SECTION);
+ Map groups = discover(root);
+ boolean changed = !hadPolicies;
+ Set refreshedKeys = new LinkedHashSet<>();
+ for (Group group : groups.values()) {
+ if (!group.needsPolicy()) continue;
+ String key = key(group.material, group.domain);
+ JsonObject previous = policies.has(key) && policies.get(key).isJsonObject()
+ ? policies.getAsJsonObject(key) : null;
+ JsonObject refreshed = previous == null
+ ? createPolicy(group, existingWorld) : refreshPolicy(previous, group);
+ if (previous == null || !previous.toString().equals(refreshed.toString())) {
+ policies.add(key, refreshed);
+ changed = true;
+ }
+ refreshedKeys.add(key);
+ }
+ for (Entry entry : new ArrayList<>(policies.entrySet())) {
+ if (refreshedKeys.contains(entry.getKey()) || !entry.getValue().isJsonObject()) continue;
+ JsonObject previous = entry.getValue().getAsJsonObject();
+ ResourceLocation material = resource(string(previous, "material", ""));
+ ResourceLocation domain = resource(string(previous, "domain", ""));
+ if (material == null || domain == null) continue;
+ Group group = groups.get(key(material, domain));
+ if (group == null) group = new Group(material, domain);
+ JsonObject refreshed = refreshPolicy(previous, group);
+ if (!previous.toString().equals(refreshed.toString())) {
+ policies.add(entry.getKey(), refreshed);
+ changed = true;
+ }
+ }
+ root.add(SECTION, policies);
+ return changed;
+ }
+
+ static Snapshot snapshot(JsonObject root) {
+ Map discovered = discover(root);
+ JsonObject policies = object(root, SECTION);
+ List views = new ArrayList<>();
+ Set seen = new LinkedHashSet<>();
+ for (Entry entry : policies.entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ JsonObject policy = entry.getValue().getAsJsonObject();
+ ResourceLocation material = resource(string(policy, "material", ""));
+ ResourceLocation domain = resource(string(policy, "domain", ""));
+ if (material == null || domain == null) continue;
+ Group current = discovered.get(key(material, domain));
+ List candidates = readCandidates(policy, current);
+ views.add(new GroupView(entry.getKey(), material, domain,
+ string(policy, "mode", MODE_SEPARATE), status(policy, candidates), candidates,
+ decimalMap(policy, "outputs"), stringMap(policy, "placement_sources")));
+ seen.add(entry.getKey());
+ }
+ for (Group group : discovered.values()) {
+ String key = key(group.material, group.domain);
+ if (seen.contains(key) || !group.needsPolicy()) continue;
+ JsonObject policy = createPolicy(group, true);
+ views.add(new GroupView(key, group.material, group.domain, MODE_SEPARATE,
+ status(policy, group.candidates), immutableCandidates(group.candidates),
+ decimalMap(policy, "outputs"), stringMap(policy, "placement_sources")));
+ }
+ views.sort(Comparator.comparing((GroupView view) -> view.material.toString())
+ .thenComparing(view -> view.domain.toString()));
+ return new Snapshot(views);
+ }
+
+ static ResourceLocation material(JsonObject ore, ItemStack output) {
+ ResourceLocation explicit = resource(string(ore, "material", ""));
+ if (explicit != null) return explicit;
+ return inferMaterial(oreNames(output)).material;
+ }
+
+ static ResourceLocation placementChannel(JsonObject rule) {
+ ResourceLocation explicit = resource(string(rule, "placement_channel", ""));
+ if (explicit != null) return explicit;
+ JsonObject pattern = rule.has("pattern") && rule.get("pattern").isJsonObject()
+ ? rule.getAsJsonObject("pattern") : null;
+ if (pattern != null) {
+ ResourceLocation type = resource(string(pattern, "type", ""));
+ if (type != null) return type;
+ }
+ String legacy = string(rule, "pattern", "");
+ ResourceLocation type = legacy.indexOf(':') >= 0 ? resource(legacy) : null;
+ return type != null ? type : STANDARD;
+ }
+
+ static Inference inferMaterial(Iterable oreNames) {
+ Set materials = new LinkedHashSet<>();
+ List exact = new ArrayList<>();
+ for (String name : oreNames) {
+ if (name == null || name.length() <= 3 || !name.startsWith("ore")
+ || !Character.isUpperCase(name.charAt(3))) continue;
+ exact.add(name);
+ String token = name.substring(3).toLowerCase(Locale.ROOT);
+ if ("sulphur".equals(token)) token = "sulfur";
+ if ("aluminium".equals(token)) token = "aluminum";
+ materials.add(token);
+ }
+ Collections.sort(exact);
+ if (materials.size() == 1) {
+ String token = materials.iterator().next();
+ if (!token.matches("[a-z0-9_./-]+")) return new Inference(REVIEW, true, exact);
+ try {
+ return new Inference(new ResourceLocation("orespawn", token), false, exact);
+ } catch (RuntimeException invalidDictionaryName) {
+ return new Inference(REVIEW, true, exact);
+ }
+ }
+ return new Inference(materials.size() > 1 ? REVIEW : null, materials.size() > 1, exact);
+ }
+
+ static String key(ResourceLocation material, ResourceLocation domain) {
+ return material.toString() + "|" + domain.toString();
+ }
+
+ private static Map discover(JsonObject root) {
+ Map groups = new LinkedHashMap<>();
+ JsonObject ores = object(root, "ores");
+ for (Entry oreEntry : ores.entrySet()) {
+ if (!oreEntry.getValue().isJsonObject()) continue;
+ JsonObject ore = oreEntry.getValue().getAsJsonObject();
+ if (!bool(ore, "enabled", true)) continue;
+ ResourceLocation blockId = resource(string(ore, "block", oreEntry.getKey()));
+ Block block = blockId == null ? null : ForgeRegistries.BLOCKS.getValue(blockId);
+ if (block == null || block == Blocks.AIR) continue;
+ int metadata = integer(ore, "metadata", 0);
+ ItemStack stack = new ItemStack(block, 1, metadata);
+ Inference inferred = inferMaterial(oreNames(stack));
+ ResourceLocation material = resource(string(ore, "material", ""));
+ if (material == null) material = inferred.material;
+ if (material == null) continue;
+ String owner = owner(ore, blockId);
+ Role role = role(owner);
+ if (role == Role.NONE) continue;
+ for (Entry dimension : object(ore, "dimensions").entrySet()) {
+ addConfigured(groups, oreEntry.getKey(), owner, blockId, metadata, material,
+ resource(dimension.getKey()), dimension.getValue(), inferred, role);
+ }
+ for (Entry selector : object(ore, "dimension_selectors").entrySet()) {
+ addConfigured(groups, oreEntry.getKey(), owner, blockId, metadata, material,
+ resource(selector.getKey()), selector.getValue(), inferred, role);
+ }
+ }
+ addExternalDictionaryCandidates(groups);
+ return groups;
+ }
+
+ private static void addConfigured(Map groups, String sourceId, String owner,
+ ResourceLocation blockId, int metadata, ResourceLocation material, ResourceLocation domain,
+ JsonElement ruleElement, Inference inferred, Role role) {
+ if (domain == null || !ruleElement.isJsonObject()
+ || !bool(ruleElement.getAsJsonObject(), "enabled", true)) return;
+ if (role == Role.NETHER && !"minecraft:the_nether".equals(domain.toString())) return;
+ if (role == Role.END && !"minecraft:the_end".equals(domain.toString())) return;
+ JsonObject rule = ruleElement.getAsJsonObject();
+ Candidate candidate = new Candidate(sourceId, owner, modName(owner), modVersion(owner), blockId,
+ metadata, material, domain, placementChannel(rule), inferred.names, true, false,
+ role == Role.ENRICHMENT, inferred.reviewRequired);
+ group(groups, material, domain).add(candidate);
+ }
+
+ private static void addExternalDictionaryCandidates(Map groups) {
+ if (groups.isEmpty()) return;
+ Map> namesByMaterial = new HashMap<>();
+ for (String name : safeOreNames()) {
+ Inference inference = inferMaterial(Collections.singletonList(name));
+ if (inference.material != null && !inference.reviewRequired) {
+ namesByMaterial.computeIfAbsent(inference.material, ignored -> new ArrayList<>()).add(name);
+ }
+ }
+ for (Group group : groups.values()) {
+ List names = namesByMaterial.get(group.material);
+ if (names == null) continue;
+ for (String oreName : names) {
+ for (ItemStack stack : safeOres(oreName)) {
+ Block block = stack == null ? null : Block.getBlockFromItem(stack.getItem());
+ ResourceLocation id = block == null ? null : ForgeRegistries.BLOCKS.getKey(block);
+ if (id == null || block == Blocks.AIR || group.hasBlock(id, stack.getMetadata())) continue;
+ String owner = id.getResourceDomain();
+ Role role = role(owner);
+ if (role == Role.NONE || role == Role.ENRICHMENT) continue;
+ if (role == Role.NETHER && !"minecraft:the_nether".equals(group.domain.toString())) continue;
+ if (role == Role.END && !"minecraft:the_end".equals(group.domain.toString())) continue;
+ String sourceId = "external/" + id.toString() + "/" + stack.getMetadata();
+ group.add(new Candidate(sourceId, owner, modName(owner), modVersion(owner), id,
+ stack.getMetadata(), group.material, group.domain, STANDARD,
+ Collections.singletonList(oreName), loaded(owner), true, false, false));
+ }
+ }
+ }
+ }
+
+ private static JsonObject createPolicy(Group group, boolean existingWorld) {
+ JsonObject policy = new JsonObject();
+ policy.addProperty("material", group.material.toString());
+ policy.addProperty("domain", group.domain.toString());
+ Candidate preferred = preferred(group);
+ boolean automatic = !existingWorld && preferred != null && highConfidence(group);
+ policy.addProperty("mode", automatic ? MODE_CONSOLIDATED : MODE_SEPARATE);
+ JsonObject outputs = new JsonObject();
+ if (automatic) outputs.addProperty(preferred.sourceId, 1.0D);
+ else for (Candidate candidate : group.configured()) outputs.addProperty(candidate.sourceId, 1.0D);
+ policy.add("outputs", outputs);
+ JsonObject placements = new JsonObject();
+ for (ResourceLocation channel : group.channels()) {
+ Candidate selected = preferred(group, channel);
+ if (selected != null) placements.addProperty(channel.toString(), selected.sourceId);
+ }
+ policy.add("placement_sources", placements);
+ policy.addProperty("review_required", !automatic && group.reviewRequired());
+ policy.addProperty("status", automatic ? "consolidated"
+ : group.hasExternal() ? "external_generation"
+ : group.reviewRequired() ? "review_required" : "separate");
+ policy.add("candidates", candidates(group.candidates));
+ return policy;
+ }
+
+ private static JsonObject refreshPolicy(JsonObject previous, Group group) {
+ JsonObject policy = JsonCopies.copy(previous);
+ policy.addProperty("material", group.material.toString());
+ policy.addProperty("domain", group.domain.toString());
+ if (!MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE))) {
+ policy.addProperty("mode", MODE_SEPARATE);
+ }
+ if (!policy.has("outputs") || !policy.get("outputs").isJsonObject()) {
+ JsonObject outputs = new JsonObject();
+ for (Candidate candidate : group.configured()) outputs.addProperty(candidate.sourceId, 1.0D);
+ policy.add("outputs", outputs);
+ }
+ if (!policy.has("placement_sources") || !policy.get("placement_sources").isJsonObject()) {
+ policy.add("placement_sources", new JsonObject());
+ }
+ List merged = readCandidates(previous, group);
+ policy.add("candidates", candidates(merged));
+ String status = status(policy, merged);
+ policy.addProperty("status", status);
+ policy.addProperty("review_required", "review_required".equals(status)
+ || bool(previous, "review_required", false));
+ return policy;
+ }
+
+ private static String status(JsonObject policy, List candidates) {
+ Map current = new HashMap<>();
+ boolean external = false;
+ boolean review = false;
+ for (Candidate candidate : candidates) {
+ current.put(candidate.sourceId, candidate);
+ external |= candidate.external;
+ review |= candidate.reviewRequired;
+ }
+ Map outputs = stringMap(policy, "outputs");
+ Map placements = stringMap(policy, "placement_sources");
+ if (MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE))
+ && (outputs.isEmpty() || placements.isEmpty())) return "missing_source";
+ for (String selected : outputs.keySet()) {
+ Candidate candidate = current.get(selected);
+ if (candidate == null || !candidate.loaded) return "missing_source";
+ }
+ for (String selected : placements.values()) {
+ Candidate candidate = current.get(selected);
+ if (candidate == null || !candidate.loaded) return "missing_source";
+ }
+ if (external) return "external_generation";
+ if (review || bool(policy, "review_required", false)) return "review_required";
+ return MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE))
+ ? "consolidated" : "separate";
+ }
+
+ private static List readCandidates(JsonObject policy, Group current) {
+ Map merged = new LinkedHashMap<>();
+ if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ Candidate candidate = candidate(element, false);
+ if (candidate != null) merged.put(candidate.identity(), candidate);
+ }
+ }
+ if (current != null) {
+ for (Candidate candidate : current.candidates) merged.put(candidate.identity(), candidate);
+ }
+ List result = new ArrayList<>();
+ Set currentIds = new LinkedHashSet<>();
+ if (current != null) for (Candidate candidate : current.candidates) currentIds.add(candidate.identity());
+ for (Candidate candidate : merged.values()) {
+ result.add(currentIds.contains(candidate.identity()) ? candidate : candidate.missing());
+ }
+ result.sort(CANDIDATE_ORDER);
+ return Collections.unmodifiableList(result);
+ }
+
+ private static JsonArray candidates(List values) {
+ JsonArray result = new JsonArray();
+ List sorted = new ArrayList<>(values);
+ sorted.sort(CANDIDATE_ORDER);
+ for (Candidate candidate : sorted) result.add(candidate.toJson());
+ return result;
+ }
+
+ private static List immutableCandidates(List values) {
+ List copy = new ArrayList<>(values);
+ copy.sort(CANDIDATE_ORDER);
+ return Collections.unmodifiableList(copy);
+ }
+
+ private static final Comparator CANDIDATE_ORDER = Comparator
+ .comparing((Candidate candidate) -> candidate.owner)
+ .thenComparing(candidate -> candidate.sourceId)
+ .thenComparing(candidate -> candidate.channel.toString());
+
+ private static Candidate candidate(JsonElement element, boolean defaultLoaded) {
+ if (element == null || !element.isJsonObject()) return null;
+ JsonObject json = element.getAsJsonObject();
+ ResourceLocation block = resource(string(json, "registry_id", ""));
+ ResourceLocation material = resource(string(json, "material", ""));
+ ResourceLocation domain = resource(string(json, "domain", ""));
+ ResourceLocation channel = resource(string(json, "placement_channel", ""));
+ if (block == null || material == null || domain == null || channel == null) return null;
+ List names = strings(json.get("ore_dictionary"));
+ return new Candidate(string(json, "source_id", ""), string(json, "owner", block.getResourceDomain()),
+ string(json, "owner_name", ""), string(json, "owner_version", ""), block,
+ integer(json, "metadata", 0), material, domain, channel, names,
+ bool(json, "loaded", defaultLoaded), bool(json, "external", false),
+ bool(json, "enrichment", false), bool(json, "review_required", false));
+ }
+
+ private static boolean highConfidence(Group group) {
+ List priority = PRIORITIES.get(group.material.getResourcePath());
+ if (priority == null || group.configured().size() < 2 || group.hasExternal()) return false;
+ for (Candidate candidate : group.configured()) if (!priority.contains(candidate.owner)) return false;
+ return true;
+ }
+
+ private static Candidate preferred(Group group) {
+ List priority = PRIORITIES.get(group.material.getResourcePath());
+ if (priority != null) {
+ for (String owner : priority) {
+ for (Candidate candidate : group.configured()) if (owner.equals(candidate.owner)) return candidate;
+ }
+ }
+ return group.configured().isEmpty() ? null : group.configured().get(0);
+ }
+
+ private static Candidate preferred(Group group, ResourceLocation channel) {
+ Candidate preferred = preferred(group);
+ if (preferred != null && channel.equals(preferred.channel)) return preferred;
+ for (Candidate candidate : group.configured()) if (channel.equals(candidate.channel)) return candidate;
+ return null;
+ }
+
+ private static Map> priorities() {
+ Map> result = new HashMap<>();
+ result.put("sulfur", Collections.unmodifiableList(Arrays.asList(
+ "mineralogy", "baseminerals", "electricadvantage")));
+ result.put("lithium", Collections.unmodifiableList(Arrays.asList(
+ "baseminerals", "electricadvantage")));
+ return Collections.unmodifiableMap(result);
+ }
+
+ private static Role role(String owner) {
+ if ("densemetals".equals(owner)) return Role.ENRICHMENT;
+ if ("basesciences".equals(owner)) return Role.NONE;
+ if ("nethermetals".equals(owner)) return Role.NETHER;
+ if ("endmetals".equals(owner)) return Role.END;
+ if (ORDINARY_MMD.contains(owner) || "minecraft".equals(owner) || "orespawn".equals(owner)) {
+ return Role.ORDINARY;
+ }
+ return Role.ORDINARY;
+ }
+
+ private static Group group(Map groups, ResourceLocation material, ResourceLocation domain) {
+ String key = key(material, domain);
+ return groups.computeIfAbsent(key, ignored -> new Group(material, domain));
+ }
+
+ private static String owner(JsonObject ore, ResourceLocation block) {
+ String owner = string(ore, "source_provider", string(ore, "source_mod", block.getResourceDomain()));
+ return owner.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static boolean loaded(String owner) {
+ if ("minecraft".equals(owner) || "orespawn".equals(owner)) return true;
+ try { return Loader.isModLoaded(owner); }
+ catch (Throwable ignored) { return false; }
+ }
+
+ private static String modName(String owner) {
+ ModContainer container = container(owner);
+ return container == null ? owner : container.getName();
+ }
+
+ private static String modVersion(String owner) {
+ ModContainer container = container(owner);
+ return container == null ? "" : container.getVersion();
+ }
+
+ private static ModContainer container(String owner) {
+ try { return Loader.instance().getIndexedModList().get(owner); }
+ catch (Throwable ignored) { return null; }
+ }
+
+ private static List oreNames(ItemStack stack) {
+ List result = new ArrayList<>();
+ try {
+ for (int id : OreDictionary.getOreIDs(stack)) result.add(OreDictionary.getOreName(id));
+ } catch (RuntimeException ignored) { }
+ return result;
+ }
+
+ private static String[] safeOreNames() {
+ try { return OreDictionary.getOreNames(); }
+ catch (RuntimeException ignored) { return new String[0]; }
+ }
+
+ private static List safeOres(String name) {
+ try { return OreDictionary.getOres(name, false); }
+ catch (RuntimeException ignored) { return Collections.emptyList(); }
+ }
+
+ private static JsonObject object(JsonObject root, String key) {
+ if (root.has(key) && root.get(key).isJsonObject()) return root.getAsJsonObject(key);
+ JsonObject result = new JsonObject();
+ root.add(key, result);
+ return result;
+ }
+
+ private static ResourceLocation resource(String value) {
+ try { return value == null || value.trim().isEmpty() ? null : new ResourceLocation(value); }
+ catch (RuntimeException ignored) { return null; }
+ }
+
+ private static Map stringMap(JsonObject root, String key) {
+ if (!root.has(key) || !root.get(key).isJsonObject()) return Collections.emptyMap();
+ Map result = new LinkedHashMap<>();
+ for (Entry entry : root.getAsJsonObject(key).entrySet()) {
+ try { result.put(entry.getKey(), entry.getValue().getAsString()); }
+ catch (RuntimeException ignored) { }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ private static Map decimalMap(JsonObject root, String key) {
+ if (!root.has(key) || !root.get(key).isJsonObject()) return Collections.emptyMap();
+ Map result = new LinkedHashMap<>();
+ for (Entry entry : root.getAsJsonObject(key).entrySet()) {
+ try {
+ double value = entry.getValue().getAsDouble();
+ if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value);
+ } catch (RuntimeException ignored) { }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ private static List strings(JsonElement element) {
+ if (element == null || !element.isJsonArray()) return Collections.emptyList();
+ List result = new ArrayList<>();
+ for (JsonElement value : element.getAsJsonArray()) {
+ try { result.add(value.getAsString()); }
+ catch (RuntimeException ignored) { }
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ private static String string(JsonObject root, String key, String fallback) {
+ try { return root.has(key) ? root.get(key).getAsString() : fallback; }
+ catch (RuntimeException ignored) { return fallback; }
+ }
+
+ private static boolean bool(JsonObject root, String key, boolean fallback) {
+ try { return root.has(key) ? root.get(key).getAsBoolean() : fallback; }
+ catch (RuntimeException ignored) { return fallback; }
+ }
+
+ private static int integer(JsonObject root, String key, int fallback) {
+ try { return root.has(key) ? root.get(key).getAsInt() : fallback; }
+ catch (RuntimeException ignored) { return fallback; }
+ }
+
+ private enum Role { ORDINARY, NETHER, END, ENRICHMENT, NONE }
+
+ static final class Inference {
+ final ResourceLocation material;
+ final boolean reviewRequired;
+ final List names;
+
+ Inference(ResourceLocation material, boolean reviewRequired, List names) {
+ this.material = material;
+ this.reviewRequired = reviewRequired;
+ this.names = Collections.unmodifiableList(new ArrayList<>(names));
+ }
+ }
+
+ static final class Snapshot {
+ private final List groups;
+
+ Snapshot(List groups) {
+ this.groups = Collections.unmodifiableList(new ArrayList<>(groups));
+ }
+
+ List groups() { return groups; }
+ }
+
+ static final class GroupView {
+ final String key;
+ final ResourceLocation material;
+ final ResourceLocation domain;
+ final String mode;
+ final String status;
+ final List candidates;
+ final Map outputs;
+ final Map placements;
+
+ GroupView(String key, ResourceLocation material, ResourceLocation domain, String mode,
+ String status, List candidates, Map outputs,
+ Map placements) {
+ this.key = key;
+ this.material = material;
+ this.domain = domain;
+ this.mode = mode;
+ this.status = status;
+ this.candidates = Collections.unmodifiableList(new ArrayList<>(candidates));
+ this.outputs = Collections.unmodifiableMap(new LinkedHashMap<>(outputs));
+ this.placements = Collections.unmodifiableMap(new LinkedHashMap<>(placements));
+ }
+ }
+
+ static final class Candidate {
+ final String sourceId;
+ final String owner;
+ final String ownerName;
+ final String ownerVersion;
+ final ResourceLocation block;
+ final int metadata;
+ final ResourceLocation material;
+ final ResourceLocation domain;
+ final ResourceLocation channel;
+ final List oreNames;
+ final boolean loaded;
+ final boolean external;
+ final boolean enrichment;
+ final boolean reviewRequired;
+
+ Candidate(String sourceId, String owner, String ownerName, String ownerVersion,
+ ResourceLocation block, int metadata, ResourceLocation material, ResourceLocation domain,
+ ResourceLocation channel, List oreNames, boolean loaded, boolean external,
+ boolean enrichment, boolean reviewRequired) {
+ this.sourceId = sourceId;
+ this.owner = owner;
+ this.ownerName = ownerName;
+ this.ownerVersion = ownerVersion;
+ this.block = block;
+ this.metadata = metadata;
+ this.material = material;
+ this.domain = domain;
+ this.channel = channel;
+ this.oreNames = Collections.unmodifiableList(new ArrayList<>(oreNames));
+ this.loaded = loaded;
+ this.external = external;
+ this.enrichment = enrichment;
+ this.reviewRequired = reviewRequired;
+ }
+
+ String identity() { return sourceId + '|' + channel.toString(); }
+
+ Candidate missing() {
+ return new Candidate(sourceId, owner, ownerName, ownerVersion, block, metadata, material,
+ domain, channel, oreNames, false, external, enrichment, reviewRequired);
+ }
+
+ JsonObject toJson() {
+ JsonObject json = new JsonObject();
+ json.addProperty("source_id", sourceId);
+ json.addProperty("owner", owner);
+ json.addProperty("owner_name", ownerName);
+ json.addProperty("owner_version", ownerVersion);
+ json.addProperty("registry_id", block.toString());
+ json.addProperty("metadata", metadata);
+ json.addProperty("material", material.toString());
+ json.addProperty("domain", domain.toString());
+ json.addProperty("placement_channel", channel.toString());
+ JsonArray dictionary = new JsonArray();
+ for (String name : oreNames) dictionary.add(new JsonPrimitive(name));
+ json.add("ore_dictionary", dictionary);
+ json.addProperty("loaded", loaded);
+ json.addProperty("external", external);
+ json.addProperty("enrichment", enrichment);
+ json.addProperty("review_required", reviewRequired);
+ return json;
+ }
+ }
+
+ private static final class Group {
+ final ResourceLocation material;
+ final ResourceLocation domain;
+ final List candidates = new ArrayList<>();
+
+ Group(ResourceLocation material, ResourceLocation domain) {
+ this.material = material;
+ this.domain = domain;
+ }
+
+ void add(Candidate candidate) {
+ for (Candidate value : candidates) if (value.identity().equals(candidate.identity())) return;
+ candidates.add(candidate);
+ }
+
+ boolean hasBlock(ResourceLocation block, int metadata) {
+ for (Candidate value : candidates) {
+ if (value.block.equals(block) && value.metadata == metadata) return true;
+ }
+ return false;
+ }
+
+ List configured() {
+ List result = new ArrayList<>();
+ for (Candidate value : candidates) if (!value.external && !value.enrichment) result.add(value);
+ result.sort(CANDIDATE_ORDER);
+ return result;
+ }
+
+ Set channels() {
+ Set result = new LinkedHashSet<>();
+ for (Candidate value : configured()) result.add(value.channel);
+ return result;
+ }
+
+ boolean hasExternal() {
+ for (Candidate value : candidates) if (value.external) return true;
+ return false;
+ }
+
+ boolean reviewRequired() {
+ for (Candidate value : candidates) if (value.reviewRequired) return true;
+ return configured().size() > 1 && !highConfidence(this);
+ }
+
+ boolean needsPolicy() {
+ return configured().size() > 1 || hasExternal() || hasEnrichment()
+ || reviewRequiredCandidate();
+ }
+
+ private boolean hasEnrichment() {
+ for (Candidate value : candidates) if (value.enrichment) return true;
+ return false;
+ }
+
+ private boolean reviewRequiredCandidate() {
+ for (Candidate value : candidates) if (value.reviewRequired) return true;
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
index fee7b6a5..053e1809 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java
@@ -60,9 +60,13 @@ public final class OreSpawnOreGeneration {
private static volatile Map> vanillaTakeoverOutputs = Collections.emptyMap();
private static volatile Map> backgroundGenerationScales =
Collections.emptyMap();
+ private static volatile Map> materialBackgroundGenerationScales =
+ Collections.emptyMap();
private static volatile BakedOre[] selectorOres = NO_ORES;
private static volatile Set selectorVanillaTakeoverOutputs = Collections.emptySet();
private static volatile Map selectorBackgroundGenerationScales = Collections.emptyMap();
+ private static volatile Map selectorMaterialBackgroundGenerationScales =
+ Collections.emptyMap();
private static volatile BakedGeomeConfig geomeConfig;
private static volatile GeomeGeology classifier;
private static volatile long classifierSeed = Long.MIN_VALUE;
@@ -82,9 +86,11 @@ public static void refreshWorldConfig() {
oresByDimension = baked.byDimension;
vanillaTakeoverOutputs = baked.vanillaOutputs;
backgroundGenerationScales = baked.backgroundGenerationScales;
+ materialBackgroundGenerationScales = baked.materialBackgroundGenerationScales;
selectorOres = baked.selectorOres;
selectorVanillaTakeoverOutputs = baked.selectorVanillaOutputs;
selectorBackgroundGenerationScales = baked.selectorBackgroundGenerationScales;
+ selectorMaterialBackgroundGenerationScales = baked.selectorMaterialBackgroundGenerationScales;
synchronized (CLASSIFIER_LOCK) {
classifier = null;
classifierSeed = Long.MIN_VALUE;
@@ -189,15 +195,18 @@ private static boolean generateChunk(WorldServer level, World world, Chunk chunk
if (!ore.acceptsBiome(biome, biomeId)) {
continue;
}
- double frequency = scaledManagedFrequency(ore.frequency, ore.backgroundController,
+ ResourceLocation scalingMaterial = ore.materialDeclared ? ore.material : null;
+ double scale = channelBackgroundScale(scalingMaterial, ore.channel,
+ materialBackgroundScale(dimension, scalingMaterial),
backgroundScale(dimension, ore.output.getBlock()));
+ double frequency = scaledManagedFrequency(ore.frequency, ore.backgroundController, scale);
if (geome >= 0) {
frequency *= ore.geomeWeights[geome];
}
int attempts = attemptsForFrequency(random, frequency);
for (int attempt = 0; attempt < attempts; attempt++) {
changed |= placeAttempt(world, chunk, random, ore, geome, dimension,
- worldSeed, geologySampler, scratch);
+ worldSeed, attempt, geologySampler, scratch);
}
}
if (changed) {
@@ -215,8 +224,23 @@ private static long mix(long seed, long chunk, long revision) {
return value ^ (value >>> 31);
}
+ static long ordinaryOutputIdentity(ChunkPos chunk, int attemptIndex,
+ ResourceLocation ruleId, ResourceLocation channel) {
+ return mix(chunkKey(chunk), attemptIndex,
+ ((long) ruleId.hashCode() << 32) ^ (channel.hashCode() & 0xFFFFFFFFL));
+ }
+
+ static double outputSelectionSample(long worldSeed, ResourceLocation dimension,
+ ResourceLocation material, ResourceLocation channel, long outputIdentity) {
+ long value = mix(worldSeed, outputIdentity,
+ ((long) material.hashCode() << 32)
+ ^ (channel.hashCode() & 0xFFFFFFFFL) ^ dimension.hashCode());
+ return (double) (value >>> 11) * 0x1.0p-53;
+ }
+
private static boolean placeAttempt(World world, Chunk chunk, Random random,
BakedOre ore, int geome, ResourceLocation dimension, long worldSeed,
+ int attemptIndex,
Optional geologySampler,
GenerationScratch scratch) {
int minY = Math.max(ore.minY, 0);
@@ -229,7 +253,7 @@ private static boolean placeAttempt(World world, Chunk chunk, Random random,
int z = ChunkAccessCompat.position(chunk).getZStart() + random.nextInt(16);
int quantity = sampleQuantity(random, ore.minQuantity, ore.maxQuantity);
scratch.patternContext.initialize(world, chunk, random, ore, geome, dimension, worldSeed,
- geologySampler, x, y, z, minY, maxY, quantity);
+ attemptIndex, geologySampler, x, y, z, minY, maxY, quantity);
return ore.pattern.place(scratch.patternContext);
}
@@ -264,6 +288,14 @@ private static double backgroundScale(ResourceLocation dimension, Block output)
return scale == null ? 1.0D : scale.doubleValue();
}
+ private static double materialBackgroundScale(ResourceLocation dimension, ResourceLocation material) {
+ if (material == null) return 1.0D;
+ Map exact = materialBackgroundGenerationScales.get(dimension);
+ if (exact == null && selectorAllows(dimension)) exact = selectorMaterialBackgroundGenerationScales;
+ Double scale = exact == null ? null : exact.get(material);
+ return scale == null ? 1.0D : scale.doubleValue();
+ }
+
private static boolean insideChunk(Chunk chunk, int x, int y, int z) {
return insideChunk(ChunkAccessCompat.position(chunk), x, y, z);
}
@@ -313,7 +345,13 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
}
int deepOutputMaxY = integer(oreJson, "deep_output_max_y", -1);
BakedOutput[] outputs = bakeOutputs(oreJson, outputState);
- BakedOreRule rule = new BakedOreRule(output, manageVanillaOres && suppressVanilla);
+ ResourceLocation ruleId = resource(oreEntry.getKey());
+ if (ruleId == null) ruleId = oreId;
+ ResourceLocation material = OreSourcePolicies.material(oreJson,
+ new ItemStack(output, 1, integer(oreJson, "metadata", 0)));
+ boolean materialDeclared = resource(string(oreJson, "material", "")) != null;
+ BakedOreRule rule = new BakedOreRule(ruleId, output, material, materialDeclared,
+ manageVanillaOres && suppressVanilla);
for (Entry dimensionEntry : dimensions.entrySet()) {
ResourceLocation dimensionId = resource(dimensionEntry.getKey());
@@ -331,8 +369,9 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
if (!bool(dimension, "enabled", true)) {
continue;
}
- BakedOre baked = bakeOre(outputState, deepOutput, deepOutputMaxY, outputs,
- dimension, config, resolvedTags, retrogen);
+ BakedOre baked = bakeOre(ruleId, material, materialDeclared, dimensionId,
+ outputState, deepOutput,
+ deepOutputMaxY, outputs, dimension, config, resolvedTags, retrogen);
if (baked != null) {
rule.explicit.put(dimensionId, baked);
} else {
@@ -351,8 +390,9 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
if (!selectorEntry.getValue().isJsonObject()) continue;
JsonObject selector = selectorEntry.getValue().getAsJsonObject();
if (!bool(selector, "enabled", true)) continue;
- rule.selector = bakeOre(outputState, deepOutput, deepOutputMaxY, outputs,
- selector, config, resolvedTags, retrogen);
+ rule.selector = bakeOre(ruleId, material, materialDeclared, selectorId,
+ outputState, deepOutput,
+ deepOutputMaxY, outputs, selector, config, resolvedTags, retrogen);
if (rule.selector == null) {
reportBakeProblem("Ignoring invalid selector rule for OreSpawn-managed ore '{}'",
oreEntry.getKey());
@@ -364,10 +404,12 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
Map result = new HashMap<>();
Map> vanillaOutputs = new HashMap<>();
Map> generationScales = new HashMap<>();
+ Map> materialScales = new HashMap<>();
for (ResourceLocation dimension : explicitDimensions) {
List combined = new ArrayList<>();
Set suppressed = Collections.newSetFromMap(new IdentityHashMap());
Map scales = new IdentityHashMap<>();
+ Map semanticScales = new HashMap<>();
for (BakedOreRule rule : rules) {
BakedOre selected = selectRule(rule.explicit, rule.explicitDimensions,
rule.selector, dimension);
@@ -376,16 +418,21 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
if (rule.suppressVanilla) suppressed.add(rule.output);
if (selected.backgroundController) {
mergeBackgroundScale(scales, rule.output, selected.backgroundGenerationScale);
+ if (rule.materialDeclared) mergeBackgroundScale(semanticScales,
+ rule.material, selected.backgroundGenerationScale);
}
}
}
+ combined = arbitrate(profile, combined);
result.put(dimension, combined.toArray(new BakedOre[combined.size()]));
vanillaOutputs.put(dimension, Collections.unmodifiableSet(suppressed));
generationScales.put(dimension, Collections.unmodifiableMap(scales));
+ materialScales.put(dimension, Collections.unmodifiableMap(semanticScales));
}
List selectorList = new ArrayList<>();
Set selectorOutputs = Collections.newSetFromMap(new IdentityHashMap());
Map selectorScales = new IdentityHashMap<>();
+ Map selectorMaterialScales = new HashMap<>();
for (BakedOreRule rule : rules) {
if (rule.selector == null) continue;
selectorList.add(rule.selector);
@@ -393,8 +440,11 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
if (rule.selector.backgroundController) {
mergeBackgroundScale(selectorScales, rule.output,
rule.selector.backgroundGenerationScale);
+ if (rule.materialDeclared) mergeBackgroundScale(selectorMaterialScales,
+ rule.material, rule.selector.backgroundGenerationScale);
}
}
+ selectorList = arbitrate(profile, selectorList);
BakedOre[] selectorResult = selectorList.toArray(new BakedOre[selectorList.size()]);
LOGGER.info("Baked {} OreSpawn-managed ore definitions across {} dimensions",
rules.size(), result.size());
@@ -404,9 +454,127 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) {
}
return new BakedOres(Collections.unmodifiableMap(result),
Collections.unmodifiableMap(immutableVanillaOutputs),
- Collections.unmodifiableMap(generationScales), selectorResult,
+ Collections.unmodifiableMap(generationScales),
+ Collections.unmodifiableMap(materialScales), selectorResult,
Collections.unmodifiableSet(selectorOutputs),
- Collections.unmodifiableMap(selectorScales));
+ Collections.unmodifiableMap(selectorScales),
+ Collections.unmodifiableMap(selectorMaterialScales));
+ }
+
+ private static List arbitrate(JsonObject profile, List original) {
+ if (!profile.has(OreSourcePolicies.SECTION)
+ || !profile.get(OreSourcePolicies.SECTION).isJsonObject()) return original;
+ JsonObject policies = profile.getAsJsonObject(OreSourcePolicies.SECTION);
+ Map> groups = new LinkedHashMap<>();
+ for (BakedOre ore : original) {
+ if (ore.material != null) groups.computeIfAbsent(
+ OreSourcePolicies.key(ore.material, ore.policyDomain),
+ ignored -> new ArrayList<>()).add(ore);
+ }
+ Map> replacements = new HashMap<>();
+ for (Entry> entry : groups.entrySet()) {
+ BakedOre first = entry.getValue().get(0);
+ String key = entry.getKey();
+ if (!policies.has(key) || !policies.get(key).isJsonObject()) continue;
+ JsonObject policy = policies.getAsJsonObject(key);
+ if (!"consolidated".equals(string(policy, "mode", "keep_separate"))) continue;
+ Map weights = positiveWeights(objectOrEmpty(policy, "outputs"));
+ List sources = new ArrayList<>();
+ Set sourceIds = new HashSet<>();
+ for (BakedOre candidate : entry.getValue()) {
+ Double weight = weights.get(candidate.ruleId.toString());
+ if (weight != null && sourceIds.add(candidate.ruleId)) {
+ sources.add(new BakedSource(candidate, weight.doubleValue()));
+ }
+ }
+ if (sources.isEmpty()) {
+ replacements.put(key, Collections.emptyList());
+ continue;
+ }
+ Map available = new LinkedHashMap<>();
+ for (BakedOre candidate : entry.getValue()) available.put(candidate.ruleId, candidate.channel);
+ Map placements = selectedPlacementRules(
+ profile, first.material, first.policyDomain, available);
+ List selected = new ArrayList<>();
+ for (Entry placement : placements.entrySet()) {
+ ResourceLocation channel = placement.getKey();
+ ResourceLocation source = placement.getValue();
+ for (BakedOre candidate : entry.getValue()) {
+ if (channel.equals(candidate.channel) && source.equals(candidate.ruleId)) {
+ selected.add(candidate.withSources(sources));
+ break;
+ }
+ }
+ }
+ replacements.put(key, selected);
+ }
+ if (replacements.isEmpty()) return original;
+ List result = new ArrayList<>();
+ Set emitted = new HashSet<>();
+ for (BakedOre ore : original) {
+ String groupKey = ore.material == null ? null
+ : OreSourcePolicies.key(ore.material, ore.policyDomain);
+ List replacement = groupKey == null ? null : replacements.get(groupKey);
+ if (replacement == null) result.add(ore);
+ else if (emitted.add(groupKey)) result.addAll(replacement);
+ }
+ return result;
+ }
+
+ /** Returns null for the behaviour-preserving separate mode. */
+ static Map selectedPlacementRules(JsonObject profile,
+ ResourceLocation material, ResourceLocation domain,
+ Map availableSourceChannels) {
+ if (!profile.has(OreSourcePolicies.SECTION)
+ || !profile.get(OreSourcePolicies.SECTION).isJsonObject()) return null;
+ JsonObject policies = profile.getAsJsonObject(OreSourcePolicies.SECTION);
+ String key = OreSourcePolicies.key(material, domain);
+ if (!policies.has(key) || !policies.get(key).isJsonObject()) return null;
+ JsonObject policy = policies.getAsJsonObject(key);
+ if (!"consolidated".equals(string(policy, "mode", "keep_separate"))) return null;
+ Map result = new LinkedHashMap<>();
+ for (Entry placement : objectOrEmpty(
+ policy, "placement_sources").entrySet()) {
+ ResourceLocation channel = resource(placement.getKey());
+ ResourceLocation source;
+ try { source = resource(placement.getValue().getAsString()); }
+ catch (RuntimeException invalid) { continue; }
+ if (channel != null && source != null && channel.equals(availableSourceChannels.get(source))) {
+ result.put(channel, source);
+ }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ static int weightedIndex(double sample, double... weights) {
+ double total = 0.0D;
+ for (double weight : weights) if (Double.isFinite(weight) && weight > 0.0D) total += weight;
+ if (total <= 0.0D) return -1;
+ double choice = weightedChoice(sample, total);
+ int last = -1;
+ for (int index = 0; index < weights.length; index++) {
+ double weight = weights[index];
+ if (!Double.isFinite(weight) || weight <= 0.0D) continue;
+ last = index;
+ choice -= weight;
+ if (choice < 0.0D) return index;
+ }
+ return last;
+ }
+
+ private static double weightedChoice(double sample, double total) {
+ return Math.max(0.0D, Math.min(Math.nextDown(1.0D), sample)) * total;
+ }
+
+ private static Map positiveWeights(JsonObject values) {
+ Map result = new LinkedHashMap<>();
+ for (Entry entry : values.entrySet()) {
+ try {
+ double value = entry.getValue().getAsDouble();
+ if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value);
+ } catch (RuntimeException ignored) { }
+ }
+ return result;
}
private static void reportBakeProblem(String message, Object... arguments) {
@@ -425,18 +593,24 @@ static double scaledManagedFrequency(double frequency, boolean controller, doubl
return controller ? frequency : frequency * scale;
}
- static void mergeBackgroundScale(Map scales, Block output, double scale) {
+ static double channelBackgroundScale(ResourceLocation material, ResourceLocation channel,
+ double materialScale, double legacyBlockScale) {
+ if (material == null) return legacyBlockScale;
+ return OreSourcePolicies.STANDARD.equals(channel) ? materialScale : 1.0D;
+ }
+
+ static void mergeBackgroundScale(Map scales, T output, double scale) {
Double previous = scales.get(output);
- if (previous == null || scale < previous.doubleValue()) {
- scales.put(output, scale);
- }
+ if (previous == null || scale < previous.doubleValue()) scales.put(output, scale);
}
private static JsonObject objectOrEmpty(JsonObject root, String key) {
return root.has(key) && root.get(key).isJsonObject() ? root.getAsJsonObject(key) : new JsonObject();
}
- private static BakedOre bakeOre(IBlockState output, IBlockState deepOutput, int deepOutputMaxY,
+ private static BakedOre bakeOre(ResourceLocation ruleId, ResourceLocation material,
+ boolean materialDeclared, ResourceLocation policyDomain,
+ IBlockState output, IBlockState deepOutput, int deepOutputMaxY,
BakedOutput[] outputs,
JsonObject json, BakedGeomeConfig config, Map> resolvedTags,
boolean retrogen) {
@@ -505,7 +679,9 @@ private static BakedOre bakeOre(IBlockState output, IBlockState deepOutput, int
Set includedDictionaryBiomes = resolveBiomeDictionary(json, "biome_dictionary");
Set excludedDictionaryBiomes = resolveBiomeDictionary(json,
"excluded_biome_dictionary");
- return new BakedOre(output, deepOutput, deepOutputMaxY, outputs,
+ return new BakedOre(ruleId, material, materialDeclared, policyDomain,
+ OreSourcePolicies.placementChannel(json),
+ output, deepOutput, deepOutputMaxY, outputs,
minY, maxY, Math.min(64.0D, frequency), minQuantity, maxQuantity,
pattern, heightDistribution, discardChanceOnAirExposure,
spread, verticalSpread, nodeSize,
@@ -701,6 +877,11 @@ private static IBlockState state(Block block, int metadata) {
}
private static final class BakedOre {
+ final ResourceLocation ruleId;
+ final ResourceLocation material;
+ final boolean materialDeclared;
+ final ResourceLocation policyDomain;
+ final ResourceLocation channel;
final IBlockState output;
final IBlockState deepOutput;
final int deepOutputMaxY;
@@ -727,8 +908,11 @@ private static final class BakedOre {
final boolean retrogen;
final boolean backgroundController;
final double backgroundGenerationScale;
+ final BakedSource[] arbitrationSources;
- BakedOre(IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs,
+ BakedOre(ResourceLocation ruleId, ResourceLocation material, boolean materialDeclared,
+ ResourceLocation policyDomain, ResourceLocation channel,
+ IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs,
int minY, int maxY, double frequency, int minQuantity, int maxQuantity,
CompiledOrePattern pattern, OreHeightDistribution heightDistribution,
double discardChanceOnAirExposure,
@@ -739,6 +923,33 @@ private static final class BakedOre {
Set includedDictionaryBiomes, Set excludedDictionaryBiomes,
boolean retrogen, boolean backgroundController,
double backgroundGenerationScale) {
+ this(ruleId, material, materialDeclared, policyDomain, channel,
+ output, deepOutput, deepOutputMaxY, outputs,
+ minY, maxY, frequency, minQuantity, maxQuantity, pattern, heightDistribution,
+ discardChanceOnAirExposure, spread, verticalSpread, nodeSize, hostBlocks, hostStates,
+ familyMask, geomeWeights, includedBiomeIds, excludedBiomeIds,
+ includedDictionaryBiomes, excludedDictionaryBiomes, retrogen, backgroundController,
+ backgroundGenerationScale, new BakedSource[0]);
+ }
+
+ private BakedOre(ResourceLocation ruleId, ResourceLocation material, boolean materialDeclared,
+ ResourceLocation policyDomain, ResourceLocation channel,
+ IBlockState output, IBlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs,
+ int minY, int maxY, double frequency, int minQuantity, int maxQuantity,
+ CompiledOrePattern pattern, OreHeightDistribution heightDistribution,
+ double discardChanceOnAirExposure,
+ int spread, int verticalSpread, int nodeSize,
+ Map hostBlocks, Map hostStates,
+ int familyMask, double[] geomeWeights,
+ Set includedBiomeIds, Set excludedBiomeIds,
+ Set includedDictionaryBiomes, Set excludedDictionaryBiomes,
+ boolean retrogen, boolean backgroundController,
+ double backgroundGenerationScale, BakedSource[] arbitrationSources) {
+ this.ruleId = ruleId;
+ this.material = material;
+ this.materialDeclared = materialDeclared;
+ this.policyDomain = policyDomain;
+ this.channel = channel;
this.output = output;
this.deepOutput = deepOutput;
this.deepOutputMaxY = deepOutputMaxY;
@@ -765,6 +976,30 @@ private static final class BakedOre {
this.retrogen = retrogen;
this.backgroundController = backgroundController;
this.backgroundGenerationScale = backgroundGenerationScale;
+ this.arbitrationSources = arbitrationSources;
+ }
+
+ BakedOre withSources(List sources) {
+ return new BakedOre(ruleId, material, materialDeclared, policyDomain, channel,
+ output, deepOutput, deepOutputMaxY, outputs,
+ minY, maxY, frequency, minQuantity, maxQuantity, pattern, heightDistribution,
+ discardChanceOnAirExposure, spread, verticalSpread, nodeSize, hostBlocks, hostStates,
+ familyMask, geomeWeights, includedBiomeIds, excludedBiomeIds,
+ includedDictionaryBiomes, excludedDictionaryBiomes, retrogen, backgroundController,
+ backgroundGenerationScale, sources.toArray(new BakedSource[sources.size()]));
+ }
+
+ BakedOre outputSource(long worldSeed, ResourceLocation dimension, long identity) {
+ if (arbitrationSources.length == 0) return this;
+ double total = 0.0D;
+ for (BakedSource source : arbitrationSources) total += source.weight;
+ double choice = weightedChoice(outputSelectionSample(
+ worldSeed, dimension, material, channel, identity), total);
+ for (BakedSource source : arbitrationSources) {
+ choice -= source.weight;
+ if (choice < 0.0D) return source.ore;
+ }
+ return arbitrationSources[arbitrationSources.length - 1].ore;
}
IBlockState outputAt(int y, Random random) {
@@ -802,18 +1037,36 @@ boolean acceptsBiome(Biome biome, ResourceLocation biomeId) {
}
private static final class BakedOreRule {
+ final ResourceLocation ruleId;
final Block output;
+ final ResourceLocation material;
+ final boolean materialDeclared;
final boolean suppressVanilla;
final Map explicit = new HashMap<>();
final Set explicitDimensions = new HashSet<>();
BakedOre selector;
- BakedOreRule(Block output, boolean suppressVanilla) {
+ BakedOreRule(ResourceLocation ruleId, Block output, ResourceLocation material,
+ boolean materialDeclared,
+ boolean suppressVanilla) {
+ this.ruleId = ruleId;
this.output = output;
+ this.material = material;
+ this.materialDeclared = materialDeclared;
this.suppressVanilla = suppressVanilla;
}
}
+ private static final class BakedSource {
+ final BakedOre ore;
+ final double weight;
+
+ BakedSource(BakedOre ore, double weight) {
+ this.ore = ore;
+ this.weight = weight;
+ }
+ }
+
private static final class BakedOutput {
final IBlockState state;
final double weight;
@@ -834,26 +1087,33 @@ boolean acceptsY(int y) {
private static final class BakedOres {
static final BakedOres EMPTY = new BakedOres(EMPTY_DIMENSIONS, Collections.emptyMap(),
- Collections.emptyMap(), NO_ORES, Collections.emptySet(), Collections.emptyMap());
+ Collections.emptyMap(), Collections.emptyMap(), NO_ORES, Collections.emptySet(),
+ Collections.emptyMap(), Collections.emptyMap());
final Map byDimension;
final Map> vanillaOutputs;
final Map> backgroundGenerationScales;
+ final Map> materialBackgroundGenerationScales;
final BakedOre[] selectorOres;
final Set selectorVanillaOutputs;
final Map selectorBackgroundGenerationScales;
+ final Map selectorMaterialBackgroundGenerationScales;
BakedOres(Map byDimension,
Map> vanillaOutputs,
Map> backgroundGenerationScales,
+ Map> materialBackgroundGenerationScales,
BakedOre[] selectorOres, Set selectorVanillaOutputs,
- Map selectorBackgroundGenerationScales) {
+ Map selectorBackgroundGenerationScales,
+ Map selectorMaterialBackgroundGenerationScales) {
this.byDimension = byDimension;
this.vanillaOutputs = vanillaOutputs;
this.backgroundGenerationScales = backgroundGenerationScales;
+ this.materialBackgroundGenerationScales = materialBackgroundGenerationScales;
this.selectorOres = selectorOres;
this.selectorVanillaOutputs = selectorVanillaOutputs;
this.selectorBackgroundGenerationScales = selectorBackgroundGenerationScales;
+ this.selectorMaterialBackgroundGenerationScales = selectorMaterialBackgroundGenerationScales;
}
}
@@ -906,6 +1166,10 @@ private static final class PatternContext implements OreGenerationContext {
private int minY;
private int maxY;
private int quantity;
+ private BakedOre ordinaryOutput;
+ private BakedOre identifiedOutput;
+ private long identifiedOutputIdentity;
+ private boolean hasIdentifiedOutput;
PatternContext(BlockPos.MutableBlockPos cursor) {
this.cursor = cursor;
@@ -913,6 +1177,7 @@ private static final class PatternContext implements OreGenerationContext {
void initialize(World world, Chunk chunk, Random random, BakedOre ore, int geome,
ResourceLocation dimension, long worldSeed,
+ int attemptIndex,
Optional geologySampler,
int originX, int originY, int originZ, int minY, int maxY, int quantity) {
this.world = world;
@@ -932,6 +1197,10 @@ void initialize(World world, Chunk chunk, Random random, BakedOre ore, int geome
this.minY = minY;
this.maxY = maxY;
this.quantity = quantity;
+ long attemptIdentity = ordinaryOutputIdentity(chunkPos, attemptIndex, ore.ruleId, ore.channel);
+ this.ordinaryOutput = ore.outputSource(worldSeed, dimension, attemptIdentity);
+ this.identifiedOutput = null;
+ this.hasIdentifiedOutput = false;
}
@Override public Random random() { return random; }
@@ -973,6 +1242,20 @@ public boolean isFluid(int x, int y, int z, Fluid fluid) {
@Override
public boolean tryPlace(int x, int y, int z) {
+ return tryPlaceWithOutput(x, y, z, ordinaryOutput);
+ }
+
+ @Override
+ public boolean tryPlace(int x, int y, int z, long outputIdentity) {
+ if (!hasIdentifiedOutput || identifiedOutputIdentity != outputIdentity) {
+ identifiedOutputIdentity = outputIdentity;
+ identifiedOutput = ore.outputSource(worldSeed, dimension, outputIdentity);
+ hasIdentifiedOutput = true;
+ }
+ return tryPlaceWithOutput(x, y, z, identifiedOutput);
+ }
+
+ private boolean tryPlaceWithOutput(int x, int y, int z, BakedOre outputSource) {
if (!inside(x, y, z)) return false;
cursor.setPos(x, y, z);
IBlockState existing = world == null ? chunk.getBlockState(cursor) : world.getBlockState(cursor);
@@ -982,7 +1265,7 @@ public boolean tryPlace(int x, int y, int z) {
&& isAdjacentToAir(x, y, z)) {
return false;
}
- IBlockState output = ore.outputAt(y, random);
+ IBlockState output = outputSource.outputAt(y, random);
if (world == null) chunk.setBlockState(cursor, output);
else world.setBlockState(cursor.toImmutable(), output, GENERATION_WRITE_FLAGS);
return true;
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java
index 35be9a40..c958b094 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java
@@ -22,7 +22,7 @@
/** A complete, self-contained snapshot of the geology settings for one world. */
public final class WorldGeologyProfile {
- public static final int SCHEMA_VERSION = 5;
+ public static final int SCHEMA_VERSION = 6;
private static final Logger LOGGER = LogManager.getLogger();
@@ -39,6 +39,10 @@ public final class WorldGeologyProfile {
private WorldGeologyProfile(JsonObject root, GeologyMode fallbackMode, boolean fallbackFluidDeposits) {
this.root = JsonCopies.copy(root);
FluidDepositMigration.normalize(this.root);
+ if (!this.root.has(OreSourcePolicies.SECTION)
+ || !this.root.get(OreSourcePolicies.SECTION).isJsonObject()) {
+ this.root.add(OreSourcePolicies.SECTION, new JsonObject());
+ }
this.root.addProperty("schema_version", SCHEMA_VERSION);
geologyMode = enumValue(this.root, "geology_mode", GeologyMode.class, fallbackMode);
placeFluidDeposits = booleanValue(this.root, "place_fluid_deposits", fallbackFluidDeposits);
@@ -67,6 +71,7 @@ public static WorldGeologyProfile recommended(boolean placeFluidDeposits) {
public static WorldGeologyProfile fromGlobalConfig(JsonObject globalRoot,
GeologyMode geologyMode, boolean placeFluidDeposits) {
JsonObject root = JsonCopies.copy(globalRoot);
+ OreSourcePolicies.initialize(root, false);
if (!root.has("geology_mode")) {
root.addProperty("geology_mode", geologyMode.name().toLowerCase(Locale.ROOT));
}
@@ -81,7 +86,7 @@ public static WorldGeologyProfile fromJson(JsonObject json, WorldGeologyProfile
if (schema >= SCHEMA_VERSION) {
return new WorldGeologyProfile(json, fallback.geologyMode, fallback.placeFluidDeposits);
}
- if (schema == 2 || schema == 3 || schema == 4) {
+ if (schema >= 2) {
JsonObject migrated = JsonCopies.copy(json);
for (String key : new String[] { "terrain_dimensions", "providers",
"biome_palettes", "dimension_materials" }) {
@@ -89,6 +94,7 @@ public static WorldGeologyProfile fromJson(JsonObject json, WorldGeologyProfile
migrated.add(key, JsonCopies.copy(fallback.root.get(key)));
}
}
+ OreSourcePolicies.initialize(migrated, true);
return new WorldGeologyProfile(migrated, fallback.geologyMode, fallback.placeFluidDeposits);
}
@@ -98,6 +104,10 @@ public static WorldGeologyProfile fromJson(JsonObject json, WorldGeologyProfile
copyIfPresent(json, migrated, "geology_mode");
copyIfPresent(json, migrated, "place_crude_oil");
copyIfPresent(json, migrated, "formations");
+ // A schema-one file belongs to an existing world. Do not inherit a fresh
+ // install's automatic consolidation choices from the fallback snapshot.
+ migrated.remove(OreSourcePolicies.SECTION);
+ OreSourcePolicies.initialize(migrated, true);
return new WorldGeologyProfile(migrated, fallback.geologyMode, fallback.placeFluidDeposits);
}
@@ -260,7 +270,8 @@ public int generationRevision() {
return configured;
}
int hash = 17;
- for (String key : new String[] { "ores", "fluid_deposits", "flat_bedrock", "providers" }) {
+ for (String key : new String[] { "ores", "ore_source_policies", "fluid_deposits",
+ "flat_bedrock", "providers" }) {
if (root.has(key)) {
hash = (31 * hash) + root.get(key).toString().hashCode();
}
diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java
index 02139ebe..94cd5b18 100644
--- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java
+++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java
@@ -104,7 +104,9 @@ public static synchronized boolean reloadActiveProfile() {
.resolve("serverconfig").resolve(PROFILE_FILE_NAME);
WorldGeologyProfile profile = readProfile(profilePath, globalProfile());
JsonObject merged = profile.rootCopy();
- if (WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged)) {
+ boolean changed = WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged);
+ changed |= OreSourcePolicies.initialize(merged, true);
+ if (changed) {
profile = profile.withRoot(merged);
writeProfile(profilePath, profile);
}
@@ -127,6 +129,7 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) {
JsonObject merged = profile.rootCopy();
String beforeMerge = merged.toString();
WorldgenIntegrationManager.mergeProviderDefinitionsIntoExistingWorld(merged);
+ OreSourcePolicies.initialize(merged, true);
if (!beforeMerge.equals(merged.toString())) {
profile = profile.withRoot(merged);
writeProfile(profilePath, profile);
@@ -157,6 +160,7 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) {
profile = fallback.copy();
source = "installed-pack fresh-world";
}
+ profile = initializeNewWorldPolicies(profile, generatedWorld);
writeProfile(profilePath, profile);
LOGGER.info("Created OreSpawn world geology profile '{}' from {} settings",
profilePath, source);
@@ -171,6 +175,17 @@ public static void onServerAboutToStart(FMLServerAboutToStartEvent event) {
profile.enabledFluidDepositCount(), profile.fluidDepositCount());
}
+ static WorldGeologyProfile initializeNewWorldPolicies(WorldGeologyProfile profile,
+ boolean generatedWorld) {
+ JsonObject initialized = profile.rootCopy();
+ // A generated world without an OS4 profile is an upgrade. Its installed-pack
+ // fallback may already contain fresh-world consolidation defaults, which must
+ // not alter the world's historical ore frequency implicitly.
+ if (generatedWorld) initialized.remove(OreSourcePolicies.SECTION);
+ OreSourcePolicies.initialize(initialized, generatedWorld);
+ return profile.withRoot(initialized);
+ }
+
public static void onServerStopped(FMLServerStoppedEvent event) {
activeServer = null;
activeProfile = null;
@@ -264,8 +279,10 @@ static WorldGeologyProfile readProfile(Path path, WorldGeologyProfile fallback)
if (oreDefaultsRefreshed) {
root = GeomeConfig.refreshWorldOreDefaults(root);
}
+ boolean sourcePoliciesRefreshed = OreSourcePolicies.initialize(root, true);
WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, fallback);
- if (schema < WorldGeologyProfile.SCHEMA_VERSION || oreDefaultsRefreshed) {
+ if (schema < WorldGeologyProfile.SCHEMA_VERSION || oreDefaultsRefreshed
+ || sourcePoliciesRefreshed) {
boolean canWrite = schema >= WorldGeologyProfile.SCHEMA_VERSION
|| preserveBeforeSchemaMigration(path, schema);
canWrite &= !oreDefaultsRefreshed || preserveBeforeOreDefaultsRefresh(path);
@@ -278,6 +295,9 @@ static WorldGeologyProfile readProfile(Path path, WorldGeologyProfile fallback)
LOGGER.info("Updated untouched managed-ore rules in world geology profile '{}' to revision {}",
path, GeomeConfig.oreDefaultsRevision());
}
+ if (sourcePoliciesRefreshed) {
+ LOGGER.info("Refreshed OreSpawn ore-source policies in world geology profile '{}'", path);
+ }
} else if (oreDefaultsRefreshed) {
LOGGER.warn("Could not persist updated OreSpawn ore defaults for world profile '{}'; "
+ "using refreshed settings in memory", path);
diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java
index 3fcf00ab..1d94048b 100644
--- a/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BackgroundGenerationScaleTest.java
@@ -23,6 +23,17 @@ void controllerRuleRemainsUnscaledWhileBackgroundRulesAreScaled() {
assertEquals(2.0D, OreSpawnOreGeneration.scaledManagedFrequency(8.0D, false, 0.25D));
}
+ @Test
+ void declaredMaterialsScaleOnlyTheStandardPlacementChannel() {
+ ResourceLocation material = new ResourceLocation("orespawn:iron");
+ assertEquals(0.15D, OreSpawnOreGeneration.channelBackgroundScale(material,
+ new ResourceLocation("orespawn:standard"), 0.15D, 0.75D));
+ assertEquals(1.0D, OreSpawnOreGeneration.channelBackgroundScale(material,
+ new ResourceLocation("realisticdeposits:district"), 0.15D, 0.75D));
+ assertEquals(0.75D, OreSpawnOreGeneration.channelBackgroundScale(null,
+ new ResourceLocation("orespawn:standard"), 0.15D, 0.75D));
+ }
+
@Test
void lowestControllerScaleWinsForTheSamePrimaryResource() {
Map scales = new IdentityHashMap<>();
diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java
new file mode 100644
index 00000000..79d4a6c9
--- /dev/null
+++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java
@@ -0,0 +1,254 @@
+package zone.moddev.mc.orespawn.worldgen;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import zone.moddev.mc.orespawn.test.Forge12TestBootstrap;
+
+class OreSourcePoliciesTest {
+ @BeforeAll
+ static void bootstrapVanilla() {
+ Forge12TestBootstrap.registerVanilla();
+ }
+
+ @Test
+ void exactOreDictionaryNamesAndCuratedSpellingsProduceCanonicalMaterials() {
+ OreSourcePolicies.Inference sulfur = OreSourcePolicies.inferMaterial(
+ Arrays.asList("oreSulphur", "oreSulfur", "dustSulfur"));
+ OreSourcePolicies.Inference aluminum = OreSourcePolicies.inferMaterial(
+ Arrays.asList("oreAluminium", "oreAluminum"));
+
+ assertEquals("orespawn:sulfur", sulfur.material.toString());
+ assertEquals(Arrays.asList("oreSulfur", "oreSulphur"), sulfur.names);
+ assertFalse(sulfur.reviewRequired);
+ assertEquals("orespawn:aluminum", aluminum.material.toString());
+ }
+
+ @Test
+ void niterAndSaltpeterRemainDistinctAndAmbiguousMembershipRequiresReview() {
+ assertEquals("orespawn:niter", OreSourcePolicies.inferMaterial(
+ Collections.singletonList("oreNiter")).material.toString());
+ assertEquals("orespawn:saltpeter", OreSourcePolicies.inferMaterial(
+ Collections.singletonList("oreSaltpeter")).material.toString());
+ OreSourcePolicies.Inference ambiguous = OreSourcePolicies.inferMaterial(
+ Arrays.asList("oreNiter", "oreSaltpeter"));
+ assertEquals("orespawn:review_required", ambiguous.material.toString());
+ assertTrue(ambiguous.reviewRequired);
+ assertEquals(null, OreSourcePolicies.inferMaterial(
+ Arrays.asList("dustSulfur", "crushedSulfur")).material);
+ OreSourcePolicies.Inference invalid = OreSourcePolicies.inferMaterial(
+ Collections.singletonList("oreBad Path"));
+ assertEquals("orespawn:review_required", invalid.material.toString());
+ assertTrue(invalid.reviewRequired);
+ }
+
+ @Test
+ void freshCataloguedMmdConflictConsolidatesUsingReviewedPriority() {
+ JsonObject root = rootWithSulfurConflict();
+ assertTrue(OreSourcePolicies.initialize(root, false));
+ JsonObject policy = policy(root);
+
+ assertEquals("consolidated", policy.get("mode").getAsString());
+ assertEquals("consolidated", policy.get("status").getAsString());
+ assertEquals(1, policy.getAsJsonObject("outputs").entrySet().size());
+ assertTrue(policy.getAsJsonObject("outputs").has("mineralogy:sulfur"));
+ assertEquals("mineralogy:sulfur", policy.getAsJsonObject("placement_sources")
+ .get("orespawn:standard").getAsString());
+ }
+
+ @Test
+ void upgradedWorldConflictPreservesEverySourceUntilUserChooses() {
+ JsonObject root = rootWithSulfurConflict();
+ OreSourcePolicies.initialize(root, true);
+ JsonObject policy = policy(root);
+
+ assertEquals("keep_separate", policy.get("mode").getAsString());
+ assertEquals(2, policy.getAsJsonObject("outputs").entrySet().size());
+ }
+
+ @Test
+ void thirdPartyConflictIsNotAutomaticallyConsolidated() {
+ JsonObject root = root();
+ addOre(root, "otherone:sulfur", "otherone", "minecraft:iron_ore", "minecraft:overworld");
+ addOre(root, "othertwo:sulfur", "othertwo", "minecraft:gold_ore", "minecraft:overworld");
+ OreSourcePolicies.initialize(root, false);
+
+ JsonObject policy = policy(root);
+ assertEquals("keep_separate", policy.get("mode").getAsString());
+ assertEquals("review_required", policy.get("status").getAsString());
+ }
+
+ @Test
+ void missingSelectionsRemainPersistedAndSnapshotsAreImmutable() {
+ JsonObject root = rootWithSulfurConflict();
+ OreSourcePolicies.initialize(root, false);
+ root.getAsJsonObject("ores").remove("mineralogy:sulfur");
+ OreSourcePolicies.initialize(root, false);
+
+ JsonObject policy = policy(root);
+ assertTrue(policy.getAsJsonObject("outputs").has("mineralogy:sulfur"));
+ assertEquals("missing_source", policy.get("status").getAsString());
+ boolean missing = false;
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ JsonObject candidate = element.getAsJsonObject();
+ if ("mineralogy:sulfur".equals(candidate.get("source_id").getAsString())) {
+ missing = !candidate.get("loaded").getAsBoolean();
+ }
+ }
+ assertTrue(missing);
+ OreSourcePolicies.Snapshot snapshot = OreSourcePolicies.snapshot(root);
+ assertThrows(UnsupportedOperationException.class, () -> snapshot.groups().clear());
+ assertThrows(UnsupportedOperationException.class,
+ () -> snapshot.groups().get(0).outputs.put("replacement", 2.0D));
+
+ addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore",
+ "minecraft:overworld");
+ OreSourcePolicies.initialize(root, false);
+ for (JsonElement element : policy(root).getAsJsonArray("candidates")) {
+ JsonObject candidate = element.getAsJsonObject();
+ if ("mineralogy:sulfur".equals(candidate.get("source_id").getAsString())) {
+ assertTrue(candidate.get("loaded").getAsBoolean());
+ }
+ }
+ }
+
+ @Test
+ void lithiumUsesItsReviewedMmdPriority() {
+ JsonObject root = root();
+ addOre(root, "electricadvantage:lithium", "electricadvantage",
+ "minecraft:gold_ore", "orespawn:lithium", "minecraft:overworld", null);
+ addOre(root, "baseminerals:lithium", "baseminerals",
+ "minecraft:iron_ore", "orespawn:lithium", "minecraft:overworld", null);
+ OreSourcePolicies.initialize(root, false);
+ JsonObject policy = policy(root);
+ assertEquals("consolidated", policy.get("mode").getAsString());
+ assertTrue(policy.getAsJsonObject("outputs").has("baseminerals:lithium"));
+ }
+
+ @Test
+ void placementChannelsStayIndependentWhileOutputsRemainMaterialWide() {
+ JsonObject root = rootWithSulfurConflict();
+ addOre(root, "electricadvantage:sulfur_district", "electricadvantage",
+ "minecraft:coal_ore", "orespawn:sulfur", "minecraft:overworld",
+ "realisticdeposits:district");
+ OreSourcePolicies.initialize(root, false);
+ JsonObject policy = policy(root);
+ assertEquals(2, policy.getAsJsonObject("placement_sources").entrySet().size());
+ assertEquals("mineralogy:sulfur", policy.getAsJsonObject("placement_sources")
+ .get("orespawn:standard").getAsString());
+ assertEquals("electricadvantage:sulfur_district", policy.getAsJsonObject("placement_sources")
+ .get("realisticdeposits:district").getAsString());
+ assertEquals(1, policy.getAsJsonObject("outputs").entrySet().size());
+ }
+
+ @Test
+ void catalogExcludesScienceAndSeparatesDenseEnrichment() {
+ JsonObject root = root();
+ addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore",
+ "minecraft:overworld");
+ addOre(root, "densemetals:dense_sulfur", "densemetals", "minecraft:gold_ore",
+ "minecraft:overworld");
+ addOre(root, "basesciences:sulfur", "basesciences", "minecraft:coal_ore",
+ "minecraft:overworld");
+ OreSourcePolicies.initialize(root, false);
+ JsonObject policy = policy(root);
+ assertEquals("keep_separate", policy.get("mode").getAsString());
+ assertEquals(2, policy.getAsJsonArray("candidates").size());
+ assertFalse(policy.getAsJsonObject("outputs").has("densemetals:dense_sulfur"));
+ boolean enrichment = false;
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ JsonObject candidate = element.getAsJsonObject();
+ enrichment |= "densemetals:dense_sulfur".equals(
+ candidate.get("source_id").getAsString())
+ && candidate.get("enrichment").getAsBoolean();
+ }
+ assertTrue(enrichment);
+ }
+
+ @Test
+ void netherAndEndCatalogCandidatesCannotLeakIntoOtherDomains() {
+ JsonObject root = root();
+ addOre(root, "nethermetals:sulfur", "nethermetals", "minecraft:gold_ore",
+ "orespawn:sulfur", "minecraft:overworld", null);
+ addOre(root, "endmetals:sulfur", "endmetals", "minecraft:coal_ore",
+ "orespawn:sulfur", "minecraft:overworld", null);
+ OreSourcePolicies.initialize(root, false);
+ assertTrue(root.getAsJsonObject(OreSourcePolicies.SECTION).entrySet().isEmpty());
+
+ addOre(root, "nethermetals:sulfur", "nethermetals", "minecraft:gold_ore",
+ "orespawn:sulfur", "minecraft:the_nether", null);
+ addOre(root, "baseminerals:sulfur", "baseminerals", "minecraft:iron_ore",
+ "orespawn:sulfur", "minecraft:the_nether", null);
+ OreSourcePolicies.initialize(root, false);
+ JsonObject policy = policy(root);
+ assertEquals("minecraft:the_nether", policy.get("domain").getAsString());
+ }
+
+ @Test
+ void customPatternsDefaultToTheirOwnPlacementChannel() {
+ JsonObject custom = new JsonObject();
+ JsonObject pattern = new JsonObject();
+ pattern.addProperty("type", "realisticdeposits:district");
+ pattern.add("settings", new JsonObject());
+ custom.add("pattern", pattern);
+ assertEquals("realisticdeposits:district",
+ OreSourcePolicies.placementChannel(custom).toString());
+ pattern.addProperty("type", "orespawn:registered_custom");
+ assertEquals("orespawn:registered_custom",
+ OreSourcePolicies.placementChannel(custom).toString());
+
+ JsonObject builtIn = new JsonObject();
+ builtIn.addProperty("pattern", "vein");
+ assertEquals("orespawn:standard", OreSourcePolicies.placementChannel(builtIn).toString());
+ }
+
+ private static JsonObject rootWithSulfurConflict() {
+ JsonObject root = root();
+ addOre(root, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore", "minecraft:overworld");
+ addOre(root, "baseminerals:sulfur", "baseminerals", "minecraft:gold_ore", "minecraft:overworld");
+ return root;
+ }
+
+ private static JsonObject root() {
+ JsonObject root = new JsonObject();
+ root.add("ores", new JsonObject());
+ return root;
+ }
+
+ private static void addOre(JsonObject root, String id, String owner, String block, String dimension) {
+ addOre(root, id, owner, block, "orespawn:sulfur", dimension, null);
+ }
+
+ private static void addOre(JsonObject root, String id, String owner, String block,
+ String material, String dimension, String channel) {
+ JsonObject ore = new JsonObject();
+ ore.addProperty("enabled", true);
+ ore.addProperty("block", block);
+ ore.addProperty("material", material);
+ ore.addProperty("source_provider", owner);
+ JsonObject dimensions = new JsonObject();
+ JsonObject rule = new JsonObject();
+ rule.addProperty("enabled", true);
+ rule.addProperty("pattern", "vein");
+ if (channel != null) rule.addProperty("placement_channel", channel);
+ dimensions.add(dimension, rule);
+ ore.add("dimensions", dimensions);
+ root.getAsJsonObject("ores").add(id, ore);
+ }
+
+ private static JsonObject policy(JsonObject root) {
+ JsonObject policies = root.getAsJsonObject(OreSourcePolicies.SECTION);
+ assertEquals(1, policies.entrySet().size());
+ return policies.entrySet().iterator().next().getValue().getAsJsonObject();
+ }
+}
diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java
index 428e8240..45d9187a 100644
--- a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java
@@ -7,12 +7,15 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
+import java.util.Collections;
import java.util.Random;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
+import com.google.gson.JsonObject;
import org.junit.jupiter.api.Test;
import net.minecraft.util.ResourceLocation;
@@ -73,11 +76,127 @@ void normalGenerationAndRetrogenShareStableExtendedIdentityInputs() throws Excep
StandardCharsets.UTF_8);
assertTrue(source.contains("generateChunk(level, world, chunk"));
assertTrue(source.contains("generateChunk(level, null, chunk"));
- assertTrue(source.contains("dimension, worldSeed,\n\t\t\t\tgeologySampler"));
+ assertTrue(source.contains("dimension, worldSeed,\n\t\t\t\tattemptIndex, geologySampler"));
assertTrue(source.contains("this.chunkX = chunkPos.chunkXPos"));
assertTrue(source.contains("this.chunkZ = chunkPos.chunkZPos"));
}
+ @Test
+ void wholeBodyOutputSelectionIgnoresChunkOrder() {
+ ResourceLocation material = new ResourceLocation("orespawn:sulfur");
+ ResourceLocation channel = new ResourceLocation("realisticdeposits:district");
+ long body = 0x1234ABCD5678EF90L;
+ double first = OreSpawnOreGeneration.outputSelectionSample(42L, WorldIds.OVERWORLD,
+ material, channel, body);
+ double fromAnotherChunk = OreSpawnOreGeneration.outputSelectionSample(42L, WorldIds.OVERWORLD,
+ material, channel, body);
+ assertEquals(first, fromAnotherChunk);
+ }
+
+ @Test
+ void ordinaryAttemptsIncludeChunkRuleChannelAndAttemptIdentity() {
+ ResourceLocation rule = new ResourceLocation("examplemod:sulfur");
+ ResourceLocation channel = new ResourceLocation("orespawn:standard");
+ long first = OreSpawnOreGeneration.ordinaryOutputIdentity(new ChunkPos(1, 2), 0, rule, channel);
+ assertEquals(first, OreSpawnOreGeneration.ordinaryOutputIdentity(
+ new ChunkPos(1, 2), 0, rule, channel));
+ assertFalse(first == OreSpawnOreGeneration.ordinaryOutputIdentity(
+ new ChunkPos(2, 1), 0, rule, channel));
+ assertFalse(first == OreSpawnOreGeneration.ordinaryOutputIdentity(
+ new ChunkPos(1, 2), 1, rule, channel));
+ }
+
+ @Test
+ void placementChannelsAreArbitratedIndependentlyWithoutFallback() {
+ ResourceLocation material = new ResourceLocation("orespawn:iron");
+ ResourceLocation standard = new ResourceLocation("orespawn:standard");
+ ResourceLocation district = new ResourceLocation("realisticdeposits:district");
+ ResourceLocation ordinary = new ResourceLocation("basemetals:iron");
+ ResourceLocation large = new ResourceLocation("realisticdeposits:iron_district");
+ JsonObject root = new JsonObject();
+ JsonObject policies = new JsonObject();
+ JsonObject policy = new JsonObject();
+ policy.addProperty("mode", "consolidated");
+ JsonObject placements = new JsonObject();
+ placements.addProperty(standard.toString(), ordinary.toString());
+ placements.addProperty(district.toString(), large.toString());
+ placements.addProperty("missing:channel", "missing:rule");
+ policy.add("placement_sources", placements);
+ policies.add(OreSourcePolicies.key(material, WorldIds.OVERWORLD), policy);
+ root.add(OreSourcePolicies.SECTION, policies);
+ Map available = new LinkedHashMap<>();
+ available.put(ordinary, standard);
+ available.put(large, district);
+
+ Map selected = OreSpawnOreGeneration
+ .selectedPlacementRules(root, material, WorldIds.OVERWORLD, available);
+ assertEquals(2, selected.size());
+ assertEquals(ordinary, selected.get(standard));
+ assertEquals(large, selected.get(district));
+ assertFalse(selected.containsKey(new ResourceLocation("missing:channel")));
+ }
+
+ @Test
+ void selectorPoliciesRemainKeyedByTheirSelectorDomain() {
+ ResourceLocation material = new ResourceLocation("orespawn:copper");
+ ResourceLocation standard = new ResourceLocation("orespawn:standard");
+ ResourceLocation source = new ResourceLocation("basemetals:copper");
+ ResourceLocation selector = new ResourceLocation("orespawn:all_except_nether_end");
+ JsonObject root = new JsonObject();
+ JsonObject policies = new JsonObject();
+ JsonObject policy = new JsonObject();
+ policy.addProperty("mode", "consolidated");
+ JsonObject placements = new JsonObject();
+ placements.addProperty(standard.toString(), source.toString());
+ policy.add("placement_sources", placements);
+ policies.add(OreSourcePolicies.key(material, selector), policy);
+ root.add(OreSourcePolicies.SECTION, policies);
+ Map available = new LinkedHashMap<>();
+ available.put(source, standard);
+
+ assertEquals(source, OreSpawnOreGeneration.selectedPlacementRules(
+ root, material, selector, available).get(standard));
+ assertEquals(null, OreSpawnOreGeneration.selectedPlacementRules(
+ root, material, WorldIds.OVERWORLD, available));
+ }
+
+ @Test
+ void keepSeparatePolicyLeavesEveryPlacementRuleUntouched() {
+ ResourceLocation material = new ResourceLocation("orespawn:sulfur");
+ JsonObject root = new JsonObject();
+ JsonObject policies = new JsonObject();
+ JsonObject policy = new JsonObject();
+ policy.addProperty("mode", "keep_separate");
+ policies.add(OreSourcePolicies.key(material, WorldIds.OVERWORLD), policy);
+ root.add(OreSourcePolicies.SECTION, policies);
+
+ assertEquals(null, OreSpawnOreGeneration.selectedPlacementRules(root, material,
+ WorldIds.OVERWORLD, Collections.emptyMap()));
+ }
+
+ @Test
+ void weightedOutputSelectionUsesPositiveRelativeWeights() {
+ assertEquals(0, OreSpawnOreGeneration.weightedIndex(0.0D, 1.0D, 3.0D));
+ assertEquals(0, OreSpawnOreGeneration.weightedIndex(0.249999D, 1.0D, 3.0D));
+ assertEquals(1, OreSpawnOreGeneration.weightedIndex(0.25D, 1.0D, 3.0D));
+ assertEquals(1, OreSpawnOreGeneration.weightedIndex(0.999999D, 1.0D, 3.0D));
+ assertEquals(-1, OreSpawnOreGeneration.weightedIndex(0.5D, 0.0D, -1.0D));
+ }
+
+ @Test
+ void stableOutputSelectionRespectsConfiguredWeightDistribution() {
+ ResourceLocation material = new ResourceLocation("orespawn:sulfur");
+ ResourceLocation channel = new ResourceLocation("orespawn:standard");
+ int first = 0;
+ for (long identity = 0; identity < 10000; identity++) {
+ double sample = OreSpawnOreGeneration.outputSelectionSample(
+ 42L, WorldIds.OVERWORLD, material, channel, identity);
+ if (OreSpawnOreGeneration.weightedIndex(sample, 1.0D, 3.0D) == 0) first++;
+ }
+ assertTrue(first > 2200 && first < 2800,
+ "one-to-three weights should select the first source about one quarter of the time: " + first);
+ }
+
@Test
void fixedQuantityDoesNotConsumeRandomState() {
CountingRandom random = new CountingRandom(0);
diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java
index 286327e7..929b0b78 100644
--- a/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileTest.java
@@ -18,11 +18,18 @@
import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode;
import zone.moddev.mc.orespawn.worldgen.FormationSettings.Algorithm;
import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset;
+import zone.moddev.mc.orespawn.test.Forge12TestBootstrap;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class WorldGeologyProfileTest {
+ @BeforeAll
+ static void bootstrapMinecraft() {
+ Forge12TestBootstrap.registerVanilla();
+ }
+
@Test
void schemaThreeOilMigratesToNamedFluidDeposit() {
JsonObject legacy = completeGlobalFixture();
@@ -122,6 +129,52 @@ void schemaOneMigrationPreservesShapeAndSnapshotsGlobalGeology() {
assertFalse(result.has("oil"));
}
+ @Test
+ void freshProfilesConsolidateReviewedConflictsButUpgradesStaySeparate() {
+ JsonObject global = completeGlobalFixture();
+ addSulfurConflict(global);
+ WorldGeologyProfile fresh = WorldGeologyProfile.fromGlobalConfig(
+ global, GeologyMode.GEOME, false);
+ JsonObject freshPolicy = onlyPolicy(fresh.toJson());
+ assertEquals("consolidated", freshPolicy.get("mode").getAsString());
+
+ JsonObject legacy = JsonCopies.copy(global);
+ legacy.addProperty("schema_version", 5);
+ legacy.remove(OreSourcePolicies.SECTION);
+ WorldGeologyProfile upgraded = WorldGeologyProfile.fromJson(legacy, fresh);
+ JsonObject upgradedPolicy = onlyPolicy(upgraded.toJson());
+ assertEquals("keep_separate", upgradedPolicy.get("mode").getAsString());
+ assertEquals(2, upgradedPolicy.getAsJsonObject("outputs").entrySet().size());
+ }
+
+ @Test
+ void schemaOneUpgradeDoesNotInheritFreshWorldConsolidation() {
+ JsonObject global = completeGlobalFixture();
+ addSulfurConflict(global);
+ WorldGeologyProfile fallback = WorldGeologyProfile.fromGlobalConfig(
+ global, GeologyMode.GEOME, false);
+ assertEquals("consolidated", onlyPolicy(fallback.toJson()).get("mode").getAsString());
+ JsonObject schemaOne = new JsonObject();
+ schemaOne.addProperty("schema_version", 1);
+ schemaOne.addProperty("geology_mode", "geome");
+
+ WorldGeologyProfile upgraded = WorldGeologyProfile.fromJson(schemaOne, fallback);
+ assertEquals("keep_separate", onlyPolicy(upgraded.toJson()).get("mode").getAsString());
+ }
+
+ @Test
+ void existingUnprofiledWorldDoesNotInheritFreshWorldConsolidation() {
+ JsonObject global = completeGlobalFixture();
+ addSulfurConflict(global);
+ WorldGeologyProfile fresh = WorldGeologyProfile.fromGlobalConfig(
+ global, GeologyMode.GEOME, false);
+ assertEquals("consolidated", onlyPolicy(fresh.toJson()).get("mode").getAsString());
+
+ WorldGeologyProfile upgraded = WorldGeologyProfileManager
+ .initializeNewWorldPolicies(fresh, true);
+ assertEquals("keep_separate", onlyPolicy(upgraded.toJson()).get("mode").getAsString());
+ }
+
@Test
void schemaTwoRoundTripKeepsExternalAndProviderData() {
JsonObject root = completeGlobalFixture();
@@ -525,6 +578,31 @@ private static JsonObject oreDefaultsFixture(double frequency) {
return objectWith("ores", objectWith("minecraft:coal_ore", coal));
}
+ private static void addSulfurConflict(JsonObject root) {
+ JsonObject ores = root.getAsJsonObject("ores");
+ addSulfurOre(ores, "mineralogy:sulfur", "mineralogy", "minecraft:iron_ore");
+ addSulfurOre(ores, "baseminerals:sulfur", "baseminerals", "minecraft:gold_ore");
+ }
+
+ private static void addSulfurOre(JsonObject ores, String id, String owner, String block) {
+ JsonObject ore = new JsonObject();
+ ore.addProperty("enabled", true);
+ ore.addProperty("block", block);
+ ore.addProperty("material", "orespawn:sulfur");
+ ore.addProperty("source_provider", owner);
+ JsonObject rule = new JsonObject();
+ rule.addProperty("enabled", true);
+ rule.addProperty("pattern", "vein");
+ ore.add("dimensions", objectWith("minecraft:overworld", rule));
+ ores.add(id, ore);
+ }
+
+ private static JsonObject onlyPolicy(JsonObject root) {
+ JsonObject policies = root.getAsJsonObject(OreSourcePolicies.SECTION);
+ assertEquals(1, policies.entrySet().size());
+ return policies.entrySet().iterator().next().getValue().getAsJsonObject();
+ }
+
private static JsonObject oreRule(int minY, int maxY, double frequency, int quantity,
String pattern, String distribution, double discardChance) {
JsonObject rule = new JsonObject();
From 0cc3341fb9789a1399432ca82192f3ef726111bf Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Mon, 14 Sep 2026 15:59:28 +0100
Subject: [PATCH 09/31] Add Ore Sources configuration screens
---
build.gradle | 3 +-
.../orespawn/client/ClientProbeTestMod.java | 10 +-
.../orespawn/client/GeologyEditorSession.java | 181 ++++++++++++++
.../client/GeologyMaterialsScreen.java | 12 +-
.../client/OreSourceDetailScreen.java | 233 ++++++++++++++++++
.../orespawn/client/OreSourceListScreen.java | 103 ++++++++
.../resources/assets/orespawn/lang/de_AU.lang | 34 +++
.../resources/assets/orespawn/lang/de_DE.lang | 34 +++
.../resources/assets/orespawn/lang/en_CA.lang | 34 +++
.../resources/assets/orespawn/lang/en_EN.lang | 34 +++
.../resources/assets/orespawn/lang/en_GB.lang | 34 +++
.../resources/assets/orespawn/lang/en_PT.lang | 34 +++
.../resources/assets/orespawn/lang/en_US.lang | 34 +++
.../resources/assets/orespawn/lang/es_ES.lang | 34 +++
.../resources/assets/orespawn/lang/es_MX.lang | 34 +++
.../resources/assets/orespawn/lang/fr_CA.lang | 34 +++
.../resources/assets/orespawn/lang/fr_FR.lang | 34 +++
.../resources/assets/orespawn/lang/ja_JP.lang | 34 +++
.../resources/assets/orespawn/lang/ko_KR.lang | 34 +++
.../resources/assets/orespawn/lang/pt_BR.lang | 34 +++
.../resources/assets/orespawn/lang/ru_RU.lang | 34 +++
.../resources/assets/orespawn/lang/zh_CN.lang | 34 +++
.../mc/orespawn/LocalizationParityTest.java | 2 +-
.../client/GeologyEditorSessionTest.java | 56 +++++
.../client/OreSpawnScreenLayoutTest.java | 35 ++-
25 files changed, 1172 insertions(+), 7 deletions(-)
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceDetailScreen.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java
diff --git a/build.gradle b/build.gradle
index 2e1acd2a..7a3bcca2 100644
--- a/build.gradle
+++ b/build.gradle
@@ -688,7 +688,8 @@ task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegratio
'The binary patch set is missing',
'FML appears to be missing any signature data',
'Unable to read a class file correctly',
- 'problem reading the entry META-INF/versions/11/'
+ 'problem reading the entry META-INF/versions/11/',
+ 'Trying to activate the universal filled bucket too late. Call it statically in your Mods class. Mod: Base Metals'
]
List unaccountedErrors = latest.readLines('UTF-8').findAll { line ->
(line.contains('/ERROR]') || line.contains('/FATAL]'))
diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
index 5fac2bf7..d91b5695 100644
--- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
+++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
@@ -201,9 +201,15 @@ private void validateExpectedDirectory(GuiScreen root) {
if (entry.nativeOs4() || entry.legacyLineages().isEmpty()) {
throw new IllegalStateException("Base Metals did not retain its legacy lineage");
}
- } else if (!entry.nativeOs4() || entry.schemaVersion() != 4
+ } else if ("mineralogy".equals(entry.modId)) {
+ if (!entry.nativeOs4() || entry.schemaVersion() != 4 || entry.providerRevision() != 3) {
+ throw new IllegalStateException("Missing Mineralogy OS4 schema 4/provider revision 3: schema="
+ + entry.schemaVersion() + ", revision=" + entry.providerRevision());
+ }
+ } else if (!entry.nativeOs4() || entry.schemaVersion() != 5
|| entry.providerRevision() < 1) {
- throw new IllegalStateException("Missing native OS4 schema/revision for " + entry.modId);
+ throw new IllegalStateException("Missing native OS4 schema 5/revision for " + entry.modId
+ + ": schema=" + entry.schemaVersion() + ", revision=" + entry.providerRevision());
}
boolean shouldConfigure = "realisticdeposits".equals(entry.modId);
if (entry.configurable() != shouldConfigure) {
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java
index 3e035bad..c7cd89fb 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java
@@ -345,6 +345,187 @@ void disableOrRemoveOre(String id) {
}
}
+ List oreSourceGroups() {
+ List result = new ArrayList<>();
+ for (Entry entry : section("ore_source_policies").entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ JsonObject policy = entry.getValue().getAsJsonObject();
+ String material = string(policy, "material", "");
+ String domain = string(policy, "domain", "");
+ if (!validResource(material) || !validResource(domain)) continue;
+ List candidates = new ArrayList<>();
+ if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ String sourceId = string(candidate, "source_id", "");
+ String channel = string(candidate, "placement_channel", "orespawn:standard");
+ String registryId = string(candidate, "registry_id", "");
+ if (sourceId.isEmpty() || !validResource(channel) || !validResource(registryId)) continue;
+ List dictionary = new ArrayList<>();
+ if (candidate.has("ore_dictionary") && candidate.get("ore_dictionary").isJsonArray()) {
+ for (JsonElement name : candidate.getAsJsonArray("ore_dictionary")) {
+ try { dictionary.add(name.getAsString()); }
+ catch (RuntimeException ignored) { }
+ }
+ }
+ candidates.add(new OreSourceCandidate(sourceId,
+ string(candidate, "owner", ""), string(candidate, "owner_name", ""),
+ string(candidate, "owner_version", ""), registryId,
+ integer(candidate, "metadata", 0), channel, dictionary,
+ bool(candidate, "loaded", false), bool(candidate, "external", false),
+ bool(candidate, "enrichment", false)));
+ }
+ }
+ Collections.sort(candidates, (left, right) -> {
+ int owner = left.owner.compareTo(right.owner);
+ return owner != 0 ? owner : left.sourceId.compareTo(right.sourceId);
+ });
+ result.add(new OreSourceGroup(entry.getKey(), material, domain,
+ string(policy, "mode", "keep_separate"), string(policy, "status", "review_required"),
+ candidates, decimalMap(policy, "outputs"), stringMap(policy, "placement_sources")));
+ }
+ Collections.sort(result, (left, right) -> {
+ int material = left.material.compareTo(right.material);
+ return material != 0 ? material : left.domain.compareTo(right.domain);
+ });
+ return Collections.unmodifiableList(result);
+ }
+
+ void setOreSourceMode(String key, boolean consolidated) {
+ JsonObject policy = objectEntry(section("ore_source_policies"), key);
+ policy.addProperty("mode", consolidated ? "consolidated" : "keep_separate");
+ policy.addProperty("review_required", false);
+ refreshOreSourceStatus(policy);
+ }
+
+ void setOreSourcePlacement(String key, String channel, String sourceId) {
+ if (!validResource(channel)) return;
+ JsonObject policy = objectEntry(section("ore_source_policies"), key);
+ objectEntry(policy, "placement_sources").addProperty(channel, sourceId);
+ refreshOreSourceStatus(policy);
+ }
+
+ void setOreSourceOutput(String key, String sourceId, boolean enabled, double weight) {
+ JsonObject policy = objectEntry(section("ore_source_policies"), key);
+ JsonObject outputs = objectEntry(policy, "outputs");
+ if (!enabled) outputs.remove(sourceId);
+ else outputs.addProperty(sourceId, Math.max(0.001D, Math.min(1000000.0D, weight)));
+ refreshOreSourceStatus(policy);
+ }
+
+ private static void refreshOreSourceStatus(JsonObject policy) {
+ Set missing = new HashSet<>();
+ boolean external = false;
+ boolean review = bool(policy, "review_required", false);
+ if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ if (!bool(candidate, "loaded", false)) missing.add(string(candidate, "source_id", ""));
+ external |= bool(candidate, "external", false);
+ }
+ }
+ Map outputs = decimalMap(policy, "outputs");
+ Map placements = stringMap(policy, "placement_sources");
+ if ("consolidated".equals(string(policy, "mode", "keep_separate"))
+ && (outputs.isEmpty() || placements.isEmpty())) {
+ policy.addProperty("status", "missing_source");
+ return;
+ }
+ for (String id : outputs.keySet()) {
+ if (missing.contains(id)) { policy.addProperty("status", "missing_source"); return; }
+ }
+ for (String id : placements.values()) {
+ if (missing.contains(id)) { policy.addProperty("status", "missing_source"); return; }
+ }
+ policy.addProperty("status", external ? "external_generation" : review ? "review_required"
+ : "consolidated".equals(string(policy, "mode", "keep_separate"))
+ ? "consolidated" : "separate");
+ }
+
+ private static Map decimalMap(JsonObject parent, String key) {
+ if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap();
+ Map result = new LinkedHashMap<>();
+ for (Entry entry : parent.getAsJsonObject(key).entrySet()) {
+ try {
+ double value = entry.getValue().getAsDouble();
+ if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value);
+ } catch (RuntimeException ignored) { }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ private static Map stringMap(JsonObject parent, String key) {
+ if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap();
+ Map result = new LinkedHashMap<>();
+ for (Entry entry : parent.getAsJsonObject(key).entrySet()) {
+ try { result.put(entry.getKey(), entry.getValue().getAsString()); }
+ catch (RuntimeException ignored) { }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ static final class OreSourceGroup {
+ final String key;
+ final String material;
+ final String domain;
+ final String mode;
+ final String status;
+ final List candidates;
+ final Map outputs;
+ final Map placements;
+
+ OreSourceGroup(String key, String material, String domain, String mode, String status,
+ List candidates, Map outputs,
+ Map placements) {
+ this.key = key;
+ this.material = material;
+ this.domain = domain;
+ this.mode = mode;
+ this.status = status;
+ this.candidates = Collections.unmodifiableList(new ArrayList<>(candidates));
+ this.outputs = outputs;
+ this.placements = placements;
+ }
+
+ List channels() {
+ Set result = new TreeSet<>();
+ for (OreSourceCandidate candidate : candidates) if (!candidate.external) result.add(candidate.channel);
+ return Collections.unmodifiableList(new ArrayList<>(result));
+ }
+ }
+
+ static final class OreSourceCandidate {
+ final String sourceId;
+ final String owner;
+ final String ownerName;
+ final String ownerVersion;
+ final String registryId;
+ final int metadata;
+ final String channel;
+ final List oreDictionary;
+ final boolean loaded;
+ final boolean external;
+ final boolean enrichment;
+
+ OreSourceCandidate(String sourceId, String owner, String ownerName, String ownerVersion,
+ String registryId, int metadata, String channel, List oreDictionary,
+ boolean loaded, boolean external, boolean enrichment) {
+ this.sourceId = sourceId;
+ this.owner = owner;
+ this.ownerName = ownerName;
+ this.ownerVersion = ownerVersion;
+ this.registryId = registryId;
+ this.metadata = metadata;
+ this.channel = channel;
+ this.oreDictionary = Collections.unmodifiableList(new ArrayList<>(oreDictionary));
+ this.loaded = loaded;
+ this.external = external;
+ this.enrichment = enrichment;
+ }
+ }
+
void resetEntry(String section, String id) {
JsonObject originalSection = object(original, section);
if (originalSection.has(id)) {
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java
index 47c28c42..4a5a8870 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java
@@ -100,9 +100,17 @@ protected void init() {
previous.enabled = page > 0;
next.enabled = page + 1 < pageCount;
- int addWidth = Math.min(150, contentWidth - 105);
+ int addWidth = tab == MaterialTab.ORES ? Math.max(50, (contentWidth - 105) / 2)
+ : Math.min(150, contentWidth - 105);
+ int addX = contentLeft + contentWidth - addWidth;
+ Button sources = addButton(OreSpawnScreenLayout.button(this, font,
+ addX - addWidth - 5, controlsY, addWidth, 20,
+ new TextComponentTranslation("button.orespawn.ore_sources"),
+ button -> minecraft.displayGuiScreen(new OreSourceListScreen(this, session))));
+ sources.visible = tab == MaterialTab.ORES;
+ OreSpawnScreenLayout.explain(this, sources, "tooltip.orespawn.ore_sources");
Button add = addButton(OreSpawnScreenLayout.button(this, font,
- contentLeft + contentWidth - addWidth, controlsY, addWidth, 20,
+ addX, controlsY, addWidth, 20,
new TextComponentTranslation("button.orespawn.add_block"), button -> openBlockPicker()));
add.visible = tab != MaterialTab.UNASSIGNED;
OreSpawnScreenLayout.explain(this, add, "tooltip.orespawn.material.add_block");
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceDetailScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceDetailScreen.java
new file mode 100644
index 00000000..82aba479
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceDetailScreen.java
@@ -0,0 +1,233 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate;
+import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup;
+
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.client.resources.I18n;
+import net.minecraft.util.text.TextComponentString;
+import net.minecraft.util.text.TextComponentTranslation;
+
+/** Edits one material/domain policy inside the pending world-settings session. */
+final class OreSourceDetailScreen extends OreSpawnScreen {
+ private final GuiScreen parent;
+ private final GeologyEditorSession session;
+ private final String groupKey;
+ private final Map weights = new LinkedHashMap<>();
+ private int channelIndex;
+ private int page;
+ private int pageSize;
+ private String error;
+
+ OreSourceDetailScreen(GuiScreen parent, GeologyEditorSession session, String groupKey) {
+ super(new TextComponentTranslation("screen.orespawn.ore_source_detail"));
+ this.parent = parent;
+ this.session = session;
+ this.groupKey = groupKey;
+ }
+
+ @Override
+ protected void init() {
+ OreSpawnScreenLayout.beginHelp(this);
+ weights.clear();
+ OreSourceGroup group = group();
+ if (group == null) {
+ addButton(new Button(width / 2 - 75, height - 28, 150, 20,
+ DialogTexts.GUI_DONE, button -> onClose()));
+ return;
+ }
+ int contentWidth = Math.min(390, Math.max(240, width - 24));
+ int left = (width - contentWidth) / 2;
+ int half = (contentWidth - 5) / 2;
+ Button mode = addButton(OreSpawnScreenLayout.button(this, font, left, 34, half, 20,
+ new TextComponentTranslation("option.orespawn.ore_source.mode",
+ new TextComponentTranslation("mode.orespawn.ore_source." + group.mode).getFormattedText()),
+ button -> {
+ syncWeights();
+ session.setOreSourceMode(groupKey, !"consolidated".equals(group().mode));
+ rebuild();
+ }));
+ OreSpawnScreenLayout.explain(this, mode, "tooltip.orespawn.ore_source.mode");
+
+ List channels = group.channels();
+ if (channels.isEmpty()) channels = java.util.Collections.singletonList("orespawn:standard");
+ channelIndex = Math.max(0, Math.min(channelIndex, channels.size() - 1));
+ final List channelValues = channels;
+ Button channel = addButton(OreSpawnScreenLayout.button(this, font, left + half + 5, 34, half, 20,
+ new TextComponentTranslation("option.orespawn.ore_source.channel",
+ display(channelValues.get(channelIndex))), button -> {
+ syncWeights();
+ channelIndex = (channelIndex + 1) % channelValues.size();
+ rebuild();
+ }));
+ OreSpawnScreenLayout.explain(this, channel, "tooltip.orespawn.ore_source.channel");
+
+ String selectedChannel = channelValues.get(channelIndex);
+ List placementCandidates = candidatesForChannel(group, selectedChannel);
+ String selected = group.placements.get(selectedChannel);
+ OreSourceCandidate selectedCandidate = candidate(group.candidates, selected);
+ int selectedIndex = indexOf(placementCandidates, selected);
+ if (selectedIndex < 0 && !placementCandidates.isEmpty()) selectedIndex = 0;
+ final int currentSelection = selectedIndex;
+ Button placement = addButton(OreSpawnScreenLayout.button(this, font, left, 58, contentWidth, 20,
+ new TextComponentTranslation("option.orespawn.ore_source.placement",
+ selectedCandidate != null && !selectedCandidate.loaded
+ ? candidateLabel(selectedCandidate)
+ : selectedIndex < 0 ? I18n.format("label.orespawn.ore_source.missing")
+ : candidateLabel(placementCandidates.get(selectedIndex))), button -> {
+ syncWeights();
+ if (!placementCandidates.isEmpty()) {
+ OreSourceCandidate next = placementCandidates.get((currentSelection + 1)
+ % placementCandidates.size());
+ session.setOreSourcePlacement(groupKey, selectedChannel, next.sourceId);
+ rebuild();
+ }
+ }));
+ placement.enabled = !placementCandidates.isEmpty();
+ OreSpawnScreenLayout.explain(this, placement, "tooltip.orespawn.ore_source.placement");
+
+ int listTop = 86;
+ int controlsY = height - 52;
+ pageSize = Math.max(1, (controlsY - listTop) / 24);
+ int pages = OreSourceListScreen.pageCount(group.candidates.size(), pageSize);
+ page = Math.max(0, Math.min(page, pages - 1));
+ int start = page * pageSize;
+ for (int index = 0; index < pageSize && start + index < group.candidates.size(); index++) {
+ OreSourceCandidate candidate = group.candidates.get(start + index);
+ int y = listTop + index * 24;
+ boolean enabled = group.outputs.containsKey(candidate.sourceId);
+ Button toggle = addButton(new Button(left, y, contentWidth - 92, 20,
+ OreSpawnScreenLayout.fit(font, new TextComponentString(
+ (enabled ? "[x] " : "[ ] ") + candidateLabel(candidate)), contentWidth - 100),
+ button -> {
+ syncWeights();
+ double current = group().outputs.containsKey(candidate.sourceId)
+ ? group().outputs.get(candidate.sourceId) : 1.0D;
+ session.setOreSourceOutput(groupKey, candidate.sourceId,
+ !group().outputs.containsKey(candidate.sourceId), current);
+ rebuild();
+ }, (button, mouseX, mouseY) -> renderStringTooltip(candidateTooltip(candidate), mouseX, mouseY)));
+ toggle.enabled = !candidate.external && !candidate.enrichment && candidate.loaded;
+ TextFieldWidget weight = addButton(new TextFieldWidget(font, left + contentWidth - 87, y, 87, 20,
+ new TextComponentTranslation("option.orespawn.ore_source.weight")));
+ weight.setMaxLength(12);
+ weight.setValue(formatWeight(group.outputs.get(candidate.sourceId)));
+ weight.enabled = enabled && !candidate.external && !candidate.enrichment && candidate.loaded;
+ weights.put(candidate.sourceId, weight);
+ OreSpawnScreenLayout.explain(this, weight, "tooltip.orespawn.ore_source.weight");
+ }
+
+ Button previous = addButton(new Button(left, controlsY, 60, 20, new TextComponentString("<"),
+ button -> { syncWeights(); page--; rebuild(); }));
+ Button next = addButton(new Button(left + contentWidth - 60, controlsY, 60, 20,
+ new TextComponentString(">"), button -> { syncWeights(); page++; rebuild(); }));
+ previous.enabled = page > 0;
+ next.enabled = page + 1 < pages;
+ addButton(new Button(width / 2 - 75, height - 28, 150, 20,
+ DialogTexts.GUI_DONE, button -> onClose()));
+ }
+
+ private void syncWeights() {
+ OreSourceGroup group = group();
+ if (group == null) return;
+ error = null;
+ for (Map.Entry entry : weights.entrySet()) {
+ if (!group.outputs.containsKey(entry.getKey())) continue;
+ try {
+ double value = Double.parseDouble(entry.getValue().getValue().trim());
+ if (!Double.isFinite(value) || value <= 0.0D) throw new NumberFormatException();
+ session.setOreSourceOutput(groupKey, entry.getKey(), true, value);
+ } catch (NumberFormatException invalid) {
+ error = I18n.format("error.orespawn.ore_source.weight");
+ }
+ }
+ }
+
+ private OreSourceGroup group() {
+ for (OreSourceGroup value : session.oreSourceGroups()) if (groupKey.equals(value.key)) return value;
+ return null;
+ }
+
+ private static List candidatesForChannel(OreSourceGroup group, String channel) {
+ List result = new ArrayList<>();
+ for (OreSourceCandidate candidate : group.candidates) {
+ if (!candidate.external && !candidate.enrichment && candidate.loaded
+ && channel.equals(candidate.channel)) result.add(candidate);
+ }
+ return result;
+ }
+
+ private static OreSourceCandidate candidate(List candidates, String sourceId) {
+ if (sourceId == null) return null;
+ for (OreSourceCandidate candidate : candidates) {
+ if (sourceId.equals(candidate.sourceId)) return candidate;
+ }
+ return null;
+ }
+
+ private static int indexOf(List candidates, String sourceId) {
+ for (int index = 0; index < candidates.size(); index++) {
+ if (candidates.get(index).sourceId.equals(sourceId)) return index;
+ }
+ return -1;
+ }
+
+ private static String candidateLabel(OreSourceCandidate candidate) {
+ String owner = candidate.ownerName.isEmpty() ? candidate.owner : candidate.ownerName;
+ return owner + " - " + candidate.registryId + (candidate.loaded ? ""
+ : " (" + I18n.format("status.orespawn.ore_source.missing_source") + ")");
+ }
+
+ private static List candidateTooltip(OreSourceCandidate candidate) {
+ List result = new ArrayList<>();
+ result.add(I18n.format("label.orespawn.ore_source.owner", candidate.owner,
+ candidate.ownerVersion.isEmpty() ? "?" : candidate.ownerVersion));
+ result.add(I18n.format("label.orespawn.ore_source.registry", candidate.registryId,
+ candidate.metadata));
+ result.add(I18n.format("label.orespawn.ore_source.dictionary",
+ candidate.oreDictionary.isEmpty() ? "-" : String.join(", ", candidate.oreDictionary)));
+ result.add(I18n.format(candidate.external ? "tooltip.orespawn.ore_source.external"
+ : candidate.enrichment ? "tooltip.orespawn.ore_source.enrichment"
+ : candidate.loaded ? "tooltip.orespawn.ore_source.controlled"
+ : "tooltip.orespawn.ore_source.missing"));
+ return result;
+ }
+
+ private static String formatWeight(Double weight) {
+ if (weight == null) return "1";
+ long whole = weight.longValue();
+ return weight.doubleValue() == whole ? Long.toString(whole) : Double.toString(weight.doubleValue());
+ }
+
+ private static String display(String id) {
+ int split = id.indexOf(':');
+ return (split < 0 ? id : id.substring(split + 1)).replace('_', ' ');
+ }
+
+ private void rebuild() { buttons.clear(); children.clear(); init(); }
+
+ @Override
+ public void onClose() {
+ syncWeights();
+ if (error == null) minecraft.displayGuiScreen(parent);
+ }
+
+ @Override
+ public void render(int mouseX, int mouseY, float partialTick) {
+ renderBackground();
+ OreSourceGroup group = group();
+ drawCenteredString(font, title, width / 2, 8, 0xFFFFFF);
+ if (group != null) drawCenteredString(font,
+ new TextComponentString(display(group.material) + " - " + display(group.domain)),
+ width / 2, 20, 0xA0A0A0);
+ if (error != null) drawCenteredString(font, new TextComponentString(error), width / 2,
+ height - 64, 0xFF5555);
+ super.render(mouseX, mouseY, partialTick);
+ OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY);
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java
new file mode 100644
index 00000000..ee2582db
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java
@@ -0,0 +1,103 @@
+package zone.moddev.mc.orespawn.client;
+
+import java.util.List;
+
+import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup;
+
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.client.resources.I18n;
+import net.minecraft.util.text.TextComponentString;
+import net.minecraft.util.text.TextComponentTranslation;
+
+/** Height-aware directory of material/domain source conflicts. */
+final class OreSourceListScreen extends OreSpawnScreen {
+ private static final int ROW_HEIGHT = 38;
+ private final GuiScreen parent;
+ private final GeologyEditorSession session;
+ private int page;
+ private int pageSize;
+ private int contentLeft;
+ private int contentWidth;
+
+ OreSourceListScreen(GuiScreen parent, GeologyEditorSession session) {
+ super(new TextComponentTranslation("screen.orespawn.ore_sources"));
+ this.parent = parent;
+ this.session = session;
+ }
+
+ @Override
+ protected void init() {
+ OreSpawnScreenLayout.beginHelp(this);
+ List groups = session.oreSourceGroups();
+ contentWidth = Math.min(390, Math.max(240, width - 24));
+ contentLeft = (width - contentWidth) / 2;
+ int listTop = 34;
+ int controlsY = height - 52;
+ pageSize = Math.max(1, (controlsY - listTop) / ROW_HEIGHT);
+ int pages = pageCount(groups.size(), pageSize);
+ page = Math.max(0, Math.min(page, pages - 1));
+ int start = page * pageSize;
+ for (int index = 0; index < pageSize && start + index < groups.size(); index++) {
+ OreSourceGroup group = groups.get(start + index);
+ int y = listTop + index * ROW_HEIGHT;
+ addButton(new Button(contentLeft, y, contentWidth, 34,
+ OreSpawnScreenLayout.fit(font, new TextComponentString(rowLabel(group)), contentWidth - 8),
+ button -> minecraft.displayGuiScreen(new OreSourceDetailScreen(this, session, group.key)),
+ (button, mouseX, mouseY) -> renderStringTooltip(tooltip(group), mouseX, mouseY)));
+ }
+ Button previous = addButton(new Button(contentLeft, controlsY, 80, 20,
+ new TextComponentTranslation("button.orespawn.previous"),
+ button -> { page--; rebuild(); }));
+ Button next = addButton(new Button(contentLeft + contentWidth - 80, controlsY, 80, 20,
+ new TextComponentTranslation("button.orespawn.next"),
+ button -> { page++; rebuild(); }));
+ previous.enabled = page > 0;
+ next.enabled = page + 1 < pages;
+ addButton(new Button(width / 2 - 75, height - 28, 150, 20,
+ DialogTexts.GUI_DONE, button -> onClose()));
+ }
+
+ private String rowLabel(OreSourceGroup group) {
+ return I18n.format("label.orespawn.ore_source.row", display(group.material), display(group.domain),
+ group.candidates.size(), I18n.format("status.orespawn.ore_source." + group.status));
+ }
+
+ private java.util.List tooltip(OreSourceGroup group) {
+ java.util.List result = new java.util.ArrayList<>();
+ result.add(I18n.format("label.orespawn.ore_source.material", group.material));
+ result.add(I18n.format("label.orespawn.ore_source.domain", group.domain));
+ result.add(I18n.format("label.orespawn.ore_source.channels", group.channels().size()));
+ result.add(I18n.format("tooltip.orespawn.ore_source.open"));
+ return result;
+ }
+
+ private static String display(String id) {
+ int split = id.indexOf(':');
+ String value = split < 0 ? id : id.substring(split + 1);
+ return value.replace('_', ' ');
+ }
+
+ private void rebuild() {
+ buttons.clear(); children.clear(); init();
+ }
+
+ static int pageCount(int entries, int pageSize) {
+ return Math.max(1, (Math.max(0, entries) + Math.max(1, pageSize) - 1) / Math.max(1, pageSize));
+ }
+
+ @Override
+ public void onClose() { minecraft.displayGuiScreen(parent); }
+
+ @Override
+ public void render(int mouseX, int mouseY, float partialTick) {
+ renderBackground();
+ drawCenteredString(font, title, width / 2, 12, 0xFFFFFF);
+ List groups = session.oreSourceGroups();
+ if (groups.isEmpty()) drawCenteredString(font,
+ new TextComponentTranslation("label.orespawn.ore_source.none"), width / 2, height / 2, 0xA0A0A0);
+ drawCenteredString(font, new TextComponentString((page + 1) + " / " + pageCount(groups.size(), pageSize)),
+ width / 2, height - 46, 0xA0A0A0);
+ super.render(mouseX, mouseY, partialTick);
+ OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY);
+ }
+}
diff --git a/src/main/resources/assets/orespawn/lang/de_AU.lang b/src/main/resources/assets/orespawn/lang/de_AU.lang
index 63b71fed..25ef8316 100644
--- a/src/main/resources/assets/orespawn/lang/de_AU.lang
+++ b/src/main/resources/assets/orespawn/lang/de_AU.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Abgelehnt
status.orespawn.mod.config_only=Nur Konfigurationsbildschirm
error.orespawn.mod.screen_missing=%s hat keinen Konfigurationsbildschirm erstellt.
error.orespawn.mod.screen_failed=Die Konfiguration von %s konnte nicht geöffnet werden (%s).
+button.orespawn.ore_sources=Erzquellen...
+tooltip.orespawn.ore_sources=Gleichwertige Erzausgaben prüfen, ohne die Platzierungshäufigkeit zu vervielfachen.
+screen.orespawn.ore_sources=Erzquellen
+screen.orespawn.ore_source_detail=Erzquellenrichtlinie
+label.orespawn.ore_source.none=Keine gleichwertigen verwalteten Erzquellen gefunden.
+label.orespawn.ore_source.row=%s - %s - %s Kandidaten - %s
+label.orespawn.ore_source.material=Rohstoff: %s
+label.orespawn.ore_source.domain=Dimension/Bereich: %s
+label.orespawn.ore_source.channels=Platzierungskanäle: %s
+label.orespawn.ore_source.missing=Fehlende Platzierungsquelle
+label.orespawn.ore_source.owner=Besitzer: %s, Version %s
+label.orespawn.ore_source.registry=Registrierte Ausgabe: %s, Metadaten %s
+label.orespawn.ore_source.dictionary=Erzlexikon: %s
+option.orespawn.ore_source.mode=Modus: %s
+option.orespawn.ore_source.channel=Kanal: %s
+option.orespawn.ore_source.placement=Platzierungsquelle: %s
+option.orespawn.ore_source.weight=Gewicht
+mode.orespawn.ore_source.consolidated=Zusammengeführt
+mode.orespawn.ore_source.keep_separate=Getrennt halten
+status.orespawn.ore_source.consolidated=Zusammengeführt
+status.orespawn.ore_source.separate=Getrennt
+status.orespawn.ore_source.review_required=Prüfung erforderlich
+status.orespawn.ore_source.missing_source=Quelle fehlt
+status.orespawn.ore_source.external_generation=Externe Generierung
+tooltip.orespawn.ore_source.open=Diese Materialrichtlinie öffnen.
+tooltip.orespawn.ore_source.mode=Zusammengeführt verwendet ein Platzierungsbudget pro Kanal. Getrennt halten bewahrt jede konfigurierte Regel.
+tooltip.orespawn.ore_source.channel=Platzierungs-Engines werden unabhängig verwaltet.
+tooltip.orespawn.ore_source.placement=Regel auswählen, die Häufigkeit, Form, Wirte, Filter und Höhe dieses Kanals bestimmt.
+tooltip.orespawn.ore_source.weight=Positive relative Chance, diese Ausgabe für einen ganzen Erzgang oder Lagerstättenkörper zu wählen.
+tooltip.orespawn.ore_source.external=Dieser Mod registriert Generierung außerhalb von OreSpawn; OreSpawn meldet sie, kann sie aber nicht unterdrücken.
+tooltip.orespawn.ore_source.controlled=OreSpawn steuert diese konfigurierte Platzierung und Ausgabequelle.
+tooltip.orespawn.ore_source.enrichment=Dieser Eintrag ist eine Anreicherung und keine austauschbare Erzquelle.
+tooltip.orespawn.ore_source.missing=Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt.
+error.orespawn.ore_source.weight=Ausgabegewichte müssen positive Zahlen sein.
diff --git a/src/main/resources/assets/orespawn/lang/de_DE.lang b/src/main/resources/assets/orespawn/lang/de_DE.lang
index 63b71fed..25ef8316 100644
--- a/src/main/resources/assets/orespawn/lang/de_DE.lang
+++ b/src/main/resources/assets/orespawn/lang/de_DE.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Abgelehnt
status.orespawn.mod.config_only=Nur Konfigurationsbildschirm
error.orespawn.mod.screen_missing=%s hat keinen Konfigurationsbildschirm erstellt.
error.orespawn.mod.screen_failed=Die Konfiguration von %s konnte nicht geöffnet werden (%s).
+button.orespawn.ore_sources=Erzquellen...
+tooltip.orespawn.ore_sources=Gleichwertige Erzausgaben prüfen, ohne die Platzierungshäufigkeit zu vervielfachen.
+screen.orespawn.ore_sources=Erzquellen
+screen.orespawn.ore_source_detail=Erzquellenrichtlinie
+label.orespawn.ore_source.none=Keine gleichwertigen verwalteten Erzquellen gefunden.
+label.orespawn.ore_source.row=%s - %s - %s Kandidaten - %s
+label.orespawn.ore_source.material=Rohstoff: %s
+label.orespawn.ore_source.domain=Dimension/Bereich: %s
+label.orespawn.ore_source.channels=Platzierungskanäle: %s
+label.orespawn.ore_source.missing=Fehlende Platzierungsquelle
+label.orespawn.ore_source.owner=Besitzer: %s, Version %s
+label.orespawn.ore_source.registry=Registrierte Ausgabe: %s, Metadaten %s
+label.orespawn.ore_source.dictionary=Erzlexikon: %s
+option.orespawn.ore_source.mode=Modus: %s
+option.orespawn.ore_source.channel=Kanal: %s
+option.orespawn.ore_source.placement=Platzierungsquelle: %s
+option.orespawn.ore_source.weight=Gewicht
+mode.orespawn.ore_source.consolidated=Zusammengeführt
+mode.orespawn.ore_source.keep_separate=Getrennt halten
+status.orespawn.ore_source.consolidated=Zusammengeführt
+status.orespawn.ore_source.separate=Getrennt
+status.orespawn.ore_source.review_required=Prüfung erforderlich
+status.orespawn.ore_source.missing_source=Quelle fehlt
+status.orespawn.ore_source.external_generation=Externe Generierung
+tooltip.orespawn.ore_source.open=Diese Materialrichtlinie öffnen.
+tooltip.orespawn.ore_source.mode=Zusammengeführt verwendet ein Platzierungsbudget pro Kanal. Getrennt halten bewahrt jede konfigurierte Regel.
+tooltip.orespawn.ore_source.channel=Platzierungs-Engines werden unabhängig verwaltet.
+tooltip.orespawn.ore_source.placement=Regel auswählen, die Häufigkeit, Form, Wirte, Filter und Höhe dieses Kanals bestimmt.
+tooltip.orespawn.ore_source.weight=Positive relative Chance, diese Ausgabe für einen ganzen Erzgang oder Lagerstättenkörper zu wählen.
+tooltip.orespawn.ore_source.external=Dieser Mod registriert Generierung außerhalb von OreSpawn; OreSpawn meldet sie, kann sie aber nicht unterdrücken.
+tooltip.orespawn.ore_source.controlled=OreSpawn steuert diese konfigurierte Platzierung und Ausgabequelle.
+tooltip.orespawn.ore_source.enrichment=Dieser Eintrag ist eine Anreicherung und keine austauschbare Erzquelle.
+tooltip.orespawn.ore_source.missing=Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt.
+error.orespawn.ore_source.weight=Ausgabegewichte müssen positive Zahlen sein.
diff --git a/src/main/resources/assets/orespawn/lang/en_CA.lang b/src/main/resources/assets/orespawn/lang/en_CA.lang
index e1fe984d..8afdb8f2 100644
--- a/src/main/resources/assets/orespawn/lang/en_CA.lang
+++ b/src/main/resources/assets/orespawn/lang/en_CA.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Rejected
status.orespawn.mod.config_only=Configuration screen only
error.orespawn.mod.screen_missing=%s did not create a configuration screen.
error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
+button.orespawn.ore_sources=Ore Sources...
+tooltip.orespawn.ore_sources=Review equivalent ore outputs without multiplying placement frequency.
+screen.orespawn.ore_sources=Ore Sources
+screen.orespawn.ore_source_detail=Ore Source Policy
+label.orespawn.ore_source.none=No equivalent managed ore sources were found.
+label.orespawn.ore_source.row=%s - %s - %s candidates - %s
+label.orespawn.ore_source.material=Material: %s
+label.orespawn.ore_source.domain=Dimension/domain: %s
+label.orespawn.ore_source.channels=Placement channels: %s
+label.orespawn.ore_source.missing=Missing placement source
+label.orespawn.ore_source.owner=Owner: %s, version %s
+label.orespawn.ore_source.registry=Registry output: %s, metadata %s
+label.orespawn.ore_source.dictionary=Ore Dictionary: %s
+option.orespawn.ore_source.mode=Mode: %s
+option.orespawn.ore_source.channel=Channel: %s
+option.orespawn.ore_source.placement=Placement source: %s
+option.orespawn.ore_source.weight=Weight
+mode.orespawn.ore_source.consolidated=Consolidated
+mode.orespawn.ore_source.keep_separate=Keep Separate
+status.orespawn.ore_source.consolidated=Consolidated
+status.orespawn.ore_source.separate=Separate
+status.orespawn.ore_source.review_required=Review required
+status.orespawn.ore_source.missing_source=Missing source
+status.orespawn.ore_source.external_generation=External generation
+tooltip.orespawn.ore_source.open=Open this material policy.
+tooltip.orespawn.ore_source.mode=Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.
+tooltip.orespawn.ore_source.channel=Placement engines are arbitrated independently.
+tooltip.orespawn.ore_source.placement=Choose the rule that owns frequency, shape, hosts, filters and height for this channel.
+tooltip.orespawn.ore_source.weight=Positive relative chance of selecting this output for a whole vein or identified deposit body.
+tooltip.orespawn.ore_source.external=This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.
+tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placement and output source.
+tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source.
+tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns.
+error.orespawn.ore_source.weight=Output weights must be positive numbers.
diff --git a/src/main/resources/assets/orespawn/lang/en_EN.lang b/src/main/resources/assets/orespawn/lang/en_EN.lang
index e1fe984d..8afdb8f2 100644
--- a/src/main/resources/assets/orespawn/lang/en_EN.lang
+++ b/src/main/resources/assets/orespawn/lang/en_EN.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Rejected
status.orespawn.mod.config_only=Configuration screen only
error.orespawn.mod.screen_missing=%s did not create a configuration screen.
error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
+button.orespawn.ore_sources=Ore Sources...
+tooltip.orespawn.ore_sources=Review equivalent ore outputs without multiplying placement frequency.
+screen.orespawn.ore_sources=Ore Sources
+screen.orespawn.ore_source_detail=Ore Source Policy
+label.orespawn.ore_source.none=No equivalent managed ore sources were found.
+label.orespawn.ore_source.row=%s - %s - %s candidates - %s
+label.orespawn.ore_source.material=Material: %s
+label.orespawn.ore_source.domain=Dimension/domain: %s
+label.orespawn.ore_source.channels=Placement channels: %s
+label.orespawn.ore_source.missing=Missing placement source
+label.orespawn.ore_source.owner=Owner: %s, version %s
+label.orespawn.ore_source.registry=Registry output: %s, metadata %s
+label.orespawn.ore_source.dictionary=Ore Dictionary: %s
+option.orespawn.ore_source.mode=Mode: %s
+option.orespawn.ore_source.channel=Channel: %s
+option.orespawn.ore_source.placement=Placement source: %s
+option.orespawn.ore_source.weight=Weight
+mode.orespawn.ore_source.consolidated=Consolidated
+mode.orespawn.ore_source.keep_separate=Keep Separate
+status.orespawn.ore_source.consolidated=Consolidated
+status.orespawn.ore_source.separate=Separate
+status.orespawn.ore_source.review_required=Review required
+status.orespawn.ore_source.missing_source=Missing source
+status.orespawn.ore_source.external_generation=External generation
+tooltip.orespawn.ore_source.open=Open this material policy.
+tooltip.orespawn.ore_source.mode=Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.
+tooltip.orespawn.ore_source.channel=Placement engines are arbitrated independently.
+tooltip.orespawn.ore_source.placement=Choose the rule that owns frequency, shape, hosts, filters and height for this channel.
+tooltip.orespawn.ore_source.weight=Positive relative chance of selecting this output for a whole vein or identified deposit body.
+tooltip.orespawn.ore_source.external=This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.
+tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placement and output source.
+tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source.
+tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns.
+error.orespawn.ore_source.weight=Output weights must be positive numbers.
diff --git a/src/main/resources/assets/orespawn/lang/en_GB.lang b/src/main/resources/assets/orespawn/lang/en_GB.lang
index e1fe984d..8afdb8f2 100644
--- a/src/main/resources/assets/orespawn/lang/en_GB.lang
+++ b/src/main/resources/assets/orespawn/lang/en_GB.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Rejected
status.orespawn.mod.config_only=Configuration screen only
error.orespawn.mod.screen_missing=%s did not create a configuration screen.
error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
+button.orespawn.ore_sources=Ore Sources...
+tooltip.orespawn.ore_sources=Review equivalent ore outputs without multiplying placement frequency.
+screen.orespawn.ore_sources=Ore Sources
+screen.orespawn.ore_source_detail=Ore Source Policy
+label.orespawn.ore_source.none=No equivalent managed ore sources were found.
+label.orespawn.ore_source.row=%s - %s - %s candidates - %s
+label.orespawn.ore_source.material=Material: %s
+label.orespawn.ore_source.domain=Dimension/domain: %s
+label.orespawn.ore_source.channels=Placement channels: %s
+label.orespawn.ore_source.missing=Missing placement source
+label.orespawn.ore_source.owner=Owner: %s, version %s
+label.orespawn.ore_source.registry=Registry output: %s, metadata %s
+label.orespawn.ore_source.dictionary=Ore Dictionary: %s
+option.orespawn.ore_source.mode=Mode: %s
+option.orespawn.ore_source.channel=Channel: %s
+option.orespawn.ore_source.placement=Placement source: %s
+option.orespawn.ore_source.weight=Weight
+mode.orespawn.ore_source.consolidated=Consolidated
+mode.orespawn.ore_source.keep_separate=Keep Separate
+status.orespawn.ore_source.consolidated=Consolidated
+status.orespawn.ore_source.separate=Separate
+status.orespawn.ore_source.review_required=Review required
+status.orespawn.ore_source.missing_source=Missing source
+status.orespawn.ore_source.external_generation=External generation
+tooltip.orespawn.ore_source.open=Open this material policy.
+tooltip.orespawn.ore_source.mode=Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.
+tooltip.orespawn.ore_source.channel=Placement engines are arbitrated independently.
+tooltip.orespawn.ore_source.placement=Choose the rule that owns frequency, shape, hosts, filters and height for this channel.
+tooltip.orespawn.ore_source.weight=Positive relative chance of selecting this output for a whole vein or identified deposit body.
+tooltip.orespawn.ore_source.external=This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.
+tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placement and output source.
+tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source.
+tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns.
+error.orespawn.ore_source.weight=Output weights must be positive numbers.
diff --git a/src/main/resources/assets/orespawn/lang/en_PT.lang b/src/main/resources/assets/orespawn/lang/en_PT.lang
index e1fe984d..8afdb8f2 100644
--- a/src/main/resources/assets/orespawn/lang/en_PT.lang
+++ b/src/main/resources/assets/orespawn/lang/en_PT.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Rejected
status.orespawn.mod.config_only=Configuration screen only
error.orespawn.mod.screen_missing=%s did not create a configuration screen.
error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
+button.orespawn.ore_sources=Ore Sources...
+tooltip.orespawn.ore_sources=Review equivalent ore outputs without multiplying placement frequency.
+screen.orespawn.ore_sources=Ore Sources
+screen.orespawn.ore_source_detail=Ore Source Policy
+label.orespawn.ore_source.none=No equivalent managed ore sources were found.
+label.orespawn.ore_source.row=%s - %s - %s candidates - %s
+label.orespawn.ore_source.material=Material: %s
+label.orespawn.ore_source.domain=Dimension/domain: %s
+label.orespawn.ore_source.channels=Placement channels: %s
+label.orespawn.ore_source.missing=Missing placement source
+label.orespawn.ore_source.owner=Owner: %s, version %s
+label.orespawn.ore_source.registry=Registry output: %s, metadata %s
+label.orespawn.ore_source.dictionary=Ore Dictionary: %s
+option.orespawn.ore_source.mode=Mode: %s
+option.orespawn.ore_source.channel=Channel: %s
+option.orespawn.ore_source.placement=Placement source: %s
+option.orespawn.ore_source.weight=Weight
+mode.orespawn.ore_source.consolidated=Consolidated
+mode.orespawn.ore_source.keep_separate=Keep Separate
+status.orespawn.ore_source.consolidated=Consolidated
+status.orespawn.ore_source.separate=Separate
+status.orespawn.ore_source.review_required=Review required
+status.orespawn.ore_source.missing_source=Missing source
+status.orespawn.ore_source.external_generation=External generation
+tooltip.orespawn.ore_source.open=Open this material policy.
+tooltip.orespawn.ore_source.mode=Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.
+tooltip.orespawn.ore_source.channel=Placement engines are arbitrated independently.
+tooltip.orespawn.ore_source.placement=Choose the rule that owns frequency, shape, hosts, filters and height for this channel.
+tooltip.orespawn.ore_source.weight=Positive relative chance of selecting this output for a whole vein or identified deposit body.
+tooltip.orespawn.ore_source.external=This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.
+tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placement and output source.
+tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source.
+tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns.
+error.orespawn.ore_source.weight=Output weights must be positive numbers.
diff --git a/src/main/resources/assets/orespawn/lang/en_US.lang b/src/main/resources/assets/orespawn/lang/en_US.lang
index ad792079..97b67a21 100644
--- a/src/main/resources/assets/orespawn/lang/en_US.lang
+++ b/src/main/resources/assets/orespawn/lang/en_US.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Rejected
status.orespawn.mod.config_only=Configuration screen only
error.orespawn.mod.screen_missing=%s did not create a configuration screen.
error.orespawn.mod.screen_failed=Could not open %s configuration (%s).
+button.orespawn.ore_sources=Ore Sources...
+tooltip.orespawn.ore_sources=Review equivalent ore outputs without multiplying placement frequency.
+screen.orespawn.ore_sources=Ore Sources
+screen.orespawn.ore_source_detail=Ore Source Policy
+label.orespawn.ore_source.none=No equivalent managed ore sources were found.
+label.orespawn.ore_source.row=%s - %s - %s candidates - %s
+label.orespawn.ore_source.material=Material: %s
+label.orespawn.ore_source.domain=Dimension/domain: %s
+label.orespawn.ore_source.channels=Placement channels: %s
+label.orespawn.ore_source.missing=Missing placement source
+label.orespawn.ore_source.owner=Owner: %s, version %s
+label.orespawn.ore_source.registry=Registry output: %s, metadata %s
+label.orespawn.ore_source.dictionary=Ore Dictionary: %s
+option.orespawn.ore_source.mode=Mode: %s
+option.orespawn.ore_source.channel=Channel: %s
+option.orespawn.ore_source.placement=Placement source: %s
+option.orespawn.ore_source.weight=Weight
+mode.orespawn.ore_source.consolidated=Consolidated
+mode.orespawn.ore_source.keep_separate=Keep Separate
+status.orespawn.ore_source.consolidated=Consolidated
+status.orespawn.ore_source.separate=Separate
+status.orespawn.ore_source.review_required=Review required
+status.orespawn.ore_source.missing_source=Missing source
+status.orespawn.ore_source.external_generation=External generation
+tooltip.orespawn.ore_source.open=Open this material policy.
+tooltip.orespawn.ore_source.mode=Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.
+tooltip.orespawn.ore_source.channel=Placement engines are arbitrated independently.
+tooltip.orespawn.ore_source.placement=Choose the rule that owns frequency, shape, hosts, filters and height for this channel.
+tooltip.orespawn.ore_source.weight=Positive relative chance of selecting this output for a whole vein or identified deposit body.
+tooltip.orespawn.ore_source.external=This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.
+tooltip.orespawn.ore_source.controlled=OreSpawn controls this configured placement and output source.
+tooltip.orespawn.ore_source.enrichment=This entry is enrichment, not an interchangeable ore source.
+tooltip.orespawn.ore_source.missing=This source is unavailable. Its saved selection will be restored if the owning mod returns.
+error.orespawn.ore_source.weight=Output weights must be positive numbers.
diff --git a/src/main/resources/assets/orespawn/lang/es_ES.lang b/src/main/resources/assets/orespawn/lang/es_ES.lang
index 4e6ca25e..f5d37926 100644
--- a/src/main/resources/assets/orespawn/lang/es_ES.lang
+++ b/src/main/resources/assets/orespawn/lang/es_ES.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Rechazado
status.orespawn.mod.config_only=Solo pantalla de configuración
error.orespawn.mod.screen_missing=%s no creó una pantalla de configuración.
error.orespawn.mod.screen_failed=No se pudo abrir la configuración de %s (%s).
+button.orespawn.ore_sources=Fuentes de mineral...
+tooltip.orespawn.ore_sources=Revisa salidas de mineral equivalentes sin multiplicar la frecuencia de colocación.
+screen.orespawn.ore_sources=Fuentes de mineral
+screen.orespawn.ore_source_detail=Política de fuente de mineral
+label.orespawn.ore_source.none=No se encontraron fuentes de mineral equivalentes gestionadas.
+label.orespawn.ore_source.row=%s - %s - %s candidatos - %s
+label.orespawn.ore_source.material=Materia: %s
+label.orespawn.ore_source.domain=Dimensión/dominio: %s
+label.orespawn.ore_source.channels=Canales de colocación: %s
+label.orespawn.ore_source.missing=Falta la fuente de colocación
+label.orespawn.ore_source.owner=Propietario: %s, versión %s
+label.orespawn.ore_source.registry=Salida registrada: %s, metadatos %s
+label.orespawn.ore_source.dictionary=Diccionario de minerales: %s
+option.orespawn.ore_source.mode=Modo: %s
+option.orespawn.ore_source.channel=Canal: %s
+option.orespawn.ore_source.placement=Fuente de colocación: %s
+option.orespawn.ore_source.weight=Peso
+mode.orespawn.ore_source.consolidated=Consolidado
+mode.orespawn.ore_source.keep_separate=Mantener separado
+status.orespawn.ore_source.consolidated=Consolidado
+status.orespawn.ore_source.separate=Separado
+status.orespawn.ore_source.review_required=Revisión necesaria
+status.orespawn.ore_source.missing_source=Falta la fuente
+status.orespawn.ore_source.external_generation=Generación externa
+tooltip.orespawn.ore_source.open=Abre esta política de material.
+tooltip.orespawn.ore_source.mode=Consolidado usa un presupuesto de colocación por canal. Mantener separado conserva cada regla configurada.
+tooltip.orespawn.ore_source.channel=Los motores de colocación se arbitran de forma independiente.
+tooltip.orespawn.ore_source.placement=Elige la regla que controla frecuencia, forma, huéspedes, filtros y altura de este canal.
+tooltip.orespawn.ore_source.weight=Probabilidad relativa positiva de elegir esta salida para una veta completa o un depósito identificado.
+tooltip.orespawn.ore_source.external=Este mod registra generación fuera de OreSpawn; OreSpawn la muestra, pero no puede impedirla.
+tooltip.orespawn.ore_source.controlled=OreSpawn controla esta colocación y fuente de salida configuradas.
+tooltip.orespawn.ore_source.enrichment=Esta entrada es enriquecimiento, no una fuente de mineral intercambiable.
+tooltip.orespawn.ore_source.missing=Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario.
+error.orespawn.ore_source.weight=Los pesos de salida deben ser números positivos.
diff --git a/src/main/resources/assets/orespawn/lang/es_MX.lang b/src/main/resources/assets/orespawn/lang/es_MX.lang
index 4e6ca25e..f5d37926 100644
--- a/src/main/resources/assets/orespawn/lang/es_MX.lang
+++ b/src/main/resources/assets/orespawn/lang/es_MX.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Rechazado
status.orespawn.mod.config_only=Solo pantalla de configuración
error.orespawn.mod.screen_missing=%s no creó una pantalla de configuración.
error.orespawn.mod.screen_failed=No se pudo abrir la configuración de %s (%s).
+button.orespawn.ore_sources=Fuentes de mineral...
+tooltip.orespawn.ore_sources=Revisa salidas de mineral equivalentes sin multiplicar la frecuencia de colocación.
+screen.orespawn.ore_sources=Fuentes de mineral
+screen.orespawn.ore_source_detail=Política de fuente de mineral
+label.orespawn.ore_source.none=No se encontraron fuentes de mineral equivalentes gestionadas.
+label.orespawn.ore_source.row=%s - %s - %s candidatos - %s
+label.orespawn.ore_source.material=Materia: %s
+label.orespawn.ore_source.domain=Dimensión/dominio: %s
+label.orespawn.ore_source.channels=Canales de colocación: %s
+label.orespawn.ore_source.missing=Falta la fuente de colocación
+label.orespawn.ore_source.owner=Propietario: %s, versión %s
+label.orespawn.ore_source.registry=Salida registrada: %s, metadatos %s
+label.orespawn.ore_source.dictionary=Diccionario de minerales: %s
+option.orespawn.ore_source.mode=Modo: %s
+option.orespawn.ore_source.channel=Canal: %s
+option.orespawn.ore_source.placement=Fuente de colocación: %s
+option.orespawn.ore_source.weight=Peso
+mode.orespawn.ore_source.consolidated=Consolidado
+mode.orespawn.ore_source.keep_separate=Mantener separado
+status.orespawn.ore_source.consolidated=Consolidado
+status.orespawn.ore_source.separate=Separado
+status.orespawn.ore_source.review_required=Revisión necesaria
+status.orespawn.ore_source.missing_source=Falta la fuente
+status.orespawn.ore_source.external_generation=Generación externa
+tooltip.orespawn.ore_source.open=Abre esta política de material.
+tooltip.orespawn.ore_source.mode=Consolidado usa un presupuesto de colocación por canal. Mantener separado conserva cada regla configurada.
+tooltip.orespawn.ore_source.channel=Los motores de colocación se arbitran de forma independiente.
+tooltip.orespawn.ore_source.placement=Elige la regla que controla frecuencia, forma, huéspedes, filtros y altura de este canal.
+tooltip.orespawn.ore_source.weight=Probabilidad relativa positiva de elegir esta salida para una veta completa o un depósito identificado.
+tooltip.orespawn.ore_source.external=Este mod registra generación fuera de OreSpawn; OreSpawn la muestra, pero no puede impedirla.
+tooltip.orespawn.ore_source.controlled=OreSpawn controla esta colocación y fuente de salida configuradas.
+tooltip.orespawn.ore_source.enrichment=Esta entrada es enriquecimiento, no una fuente de mineral intercambiable.
+tooltip.orespawn.ore_source.missing=Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario.
+error.orespawn.ore_source.weight=Los pesos de salida deben ser números positivos.
diff --git a/src/main/resources/assets/orespawn/lang/fr_CA.lang b/src/main/resources/assets/orespawn/lang/fr_CA.lang
index 632f79fc..608bceee 100644
--- a/src/main/resources/assets/orespawn/lang/fr_CA.lang
+++ b/src/main/resources/assets/orespawn/lang/fr_CA.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Rejeté
status.orespawn.mod.config_only=Écran de configuration uniquement
error.orespawn.mod.screen_missing=%s n'a pas créé d'écran de configuration.
error.orespawn.mod.screen_failed=Impossible d'ouvrir la configuration de %s (%s).
+button.orespawn.ore_sources=Sources de minerai...
+tooltip.orespawn.ore_sources=Examinez les sorties de minerai équivalentes sans multiplier la fréquence de placement.
+screen.orespawn.ore_sources=Sources de minerai
+screen.orespawn.ore_source_detail=Politique de source de minerai
+label.orespawn.ore_source.none=Aucune source de minerai gérée équivalente n'a été trouvée.
+label.orespawn.ore_source.row=%s - %s - %s candidats - %s
+label.orespawn.ore_source.material=Matériau : %s
+label.orespawn.ore_source.domain=Dimension/domaine : %s
+label.orespawn.ore_source.channels=Canaux de placement : %s
+label.orespawn.ore_source.missing=Source de placement manquante
+label.orespawn.ore_source.owner=Propriétaire : %s, version %s
+label.orespawn.ore_source.registry=Sortie enregistrée : %s, métadonnées %s
+label.orespawn.ore_source.dictionary=Dictionnaire des minerais : %s
+option.orespawn.ore_source.mode=Mode : %s
+option.orespawn.ore_source.channel=Canal : %s
+option.orespawn.ore_source.placement=Source de placement : %s
+option.orespawn.ore_source.weight=Poids
+mode.orespawn.ore_source.consolidated=Consolidé
+mode.orespawn.ore_source.keep_separate=Garder séparé
+status.orespawn.ore_source.consolidated=Consolidé
+status.orespawn.ore_source.separate=Séparé
+status.orespawn.ore_source.review_required=Révision requise
+status.orespawn.ore_source.missing_source=Source manquante
+status.orespawn.ore_source.external_generation=Génération externe
+tooltip.orespawn.ore_source.open=Ouvrir cette politique de matériau.
+tooltip.orespawn.ore_source.mode=Consolidé utilise un budget de placement par canal. Garder séparé conserve chaque règle configurée.
+tooltip.orespawn.ore_source.channel=Les moteurs de placement sont arbitrés indépendamment.
+tooltip.orespawn.ore_source.placement=Choisissez la règle qui contrôle fréquence, forme, hôtes, filtres et hauteur pour ce canal.
+tooltip.orespawn.ore_source.weight=Chance relative positive de choisir cette sortie pour une veine entière ou un gisement identifié.
+tooltip.orespawn.ore_source.external=Ce mod enregistre une génération hors d'OreSpawn ; OreSpawn la signale sans pouvoir la supprimer.
+tooltip.orespawn.ore_source.controlled=OreSpawn contrôle cette source de placement et de sortie configurée.
+tooltip.orespawn.ore_source.enrichment=Cette entrée est un enrichissement, pas une source de minerai interchangeable.
+tooltip.orespawn.ore_source.missing=Cette source est indisponible. Le choix enregistré sera restauré si son mod revient.
+error.orespawn.ore_source.weight=Les poids de sortie doivent être des nombres positifs.
diff --git a/src/main/resources/assets/orespawn/lang/fr_FR.lang b/src/main/resources/assets/orespawn/lang/fr_FR.lang
index 632f79fc..608bceee 100644
--- a/src/main/resources/assets/orespawn/lang/fr_FR.lang
+++ b/src/main/resources/assets/orespawn/lang/fr_FR.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Rejeté
status.orespawn.mod.config_only=Écran de configuration uniquement
error.orespawn.mod.screen_missing=%s n'a pas créé d'écran de configuration.
error.orespawn.mod.screen_failed=Impossible d'ouvrir la configuration de %s (%s).
+button.orespawn.ore_sources=Sources de minerai...
+tooltip.orespawn.ore_sources=Examinez les sorties de minerai équivalentes sans multiplier la fréquence de placement.
+screen.orespawn.ore_sources=Sources de minerai
+screen.orespawn.ore_source_detail=Politique de source de minerai
+label.orespawn.ore_source.none=Aucune source de minerai gérée équivalente n'a été trouvée.
+label.orespawn.ore_source.row=%s - %s - %s candidats - %s
+label.orespawn.ore_source.material=Matériau : %s
+label.orespawn.ore_source.domain=Dimension/domaine : %s
+label.orespawn.ore_source.channels=Canaux de placement : %s
+label.orespawn.ore_source.missing=Source de placement manquante
+label.orespawn.ore_source.owner=Propriétaire : %s, version %s
+label.orespawn.ore_source.registry=Sortie enregistrée : %s, métadonnées %s
+label.orespawn.ore_source.dictionary=Dictionnaire des minerais : %s
+option.orespawn.ore_source.mode=Mode : %s
+option.orespawn.ore_source.channel=Canal : %s
+option.orespawn.ore_source.placement=Source de placement : %s
+option.orespawn.ore_source.weight=Poids
+mode.orespawn.ore_source.consolidated=Consolidé
+mode.orespawn.ore_source.keep_separate=Garder séparé
+status.orespawn.ore_source.consolidated=Consolidé
+status.orespawn.ore_source.separate=Séparé
+status.orespawn.ore_source.review_required=Révision requise
+status.orespawn.ore_source.missing_source=Source manquante
+status.orespawn.ore_source.external_generation=Génération externe
+tooltip.orespawn.ore_source.open=Ouvrir cette politique de matériau.
+tooltip.orespawn.ore_source.mode=Consolidé utilise un budget de placement par canal. Garder séparé conserve chaque règle configurée.
+tooltip.orespawn.ore_source.channel=Les moteurs de placement sont arbitrés indépendamment.
+tooltip.orespawn.ore_source.placement=Choisissez la règle qui contrôle fréquence, forme, hôtes, filtres et hauteur pour ce canal.
+tooltip.orespawn.ore_source.weight=Chance relative positive de choisir cette sortie pour une veine entière ou un gisement identifié.
+tooltip.orespawn.ore_source.external=Ce mod enregistre une génération hors d'OreSpawn ; OreSpawn la signale sans pouvoir la supprimer.
+tooltip.orespawn.ore_source.controlled=OreSpawn contrôle cette source de placement et de sortie configurée.
+tooltip.orespawn.ore_source.enrichment=Cette entrée est un enrichissement, pas une source de minerai interchangeable.
+tooltip.orespawn.ore_source.missing=Cette source est indisponible. Le choix enregistré sera restauré si son mod revient.
+error.orespawn.ore_source.weight=Les poids de sortie doivent être des nombres positifs.
diff --git a/src/main/resources/assets/orespawn/lang/ja_JP.lang b/src/main/resources/assets/orespawn/lang/ja_JP.lang
index 0309bfc8..d4359c43 100644
--- a/src/main/resources/assets/orespawn/lang/ja_JP.lang
+++ b/src/main/resources/assets/orespawn/lang/ja_JP.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=拒否
status.orespawn.mod.config_only=設定画面のみ
error.orespawn.mod.screen_missing=%sは設定画面を作成しませんでした。
error.orespawn.mod.screen_failed=%sの設定を開けませんでした(%s)。
+button.orespawn.ore_sources=鉱石ソース...
+tooltip.orespawn.ore_sources=配置頻度を増やさずに同等の鉱石出力を確認します。
+screen.orespawn.ore_sources=鉱石ソース
+screen.orespawn.ore_source_detail=鉱石ソース方針
+label.orespawn.ore_source.none=同等の管理対象鉱石ソースは見つかりませんでした。
+label.orespawn.ore_source.row=%s - %s - 候補 %s 件 - %s
+label.orespawn.ore_source.material=素材: %s
+label.orespawn.ore_source.domain=ディメンション/領域: %s
+label.orespawn.ore_source.channels=配置チャンネル: %s
+label.orespawn.ore_source.missing=配置ソースがありません
+label.orespawn.ore_source.owner=所有元: %s、バージョン %s
+label.orespawn.ore_source.registry=登録出力: %s、メタデータ %s
+label.orespawn.ore_source.dictionary=鉱石辞書: %s
+option.orespawn.ore_source.mode=モード: %s
+option.orespawn.ore_source.channel=チャンネル: %s
+option.orespawn.ore_source.placement=配置ソース: %s
+option.orespawn.ore_source.weight=重み
+mode.orespawn.ore_source.consolidated=統合
+mode.orespawn.ore_source.keep_separate=個別に保持
+status.orespawn.ore_source.consolidated=統合
+status.orespawn.ore_source.separate=個別
+status.orespawn.ore_source.review_required=確認が必要
+status.orespawn.ore_source.missing_source=ソースがありません
+status.orespawn.ore_source.external_generation=外部生成
+tooltip.orespawn.ore_source.open=この素材の方針を開きます。
+tooltip.orespawn.ore_source.mode=統合はチャンネルごとに1つの配置枠を使います。個別に保持は設定済みの各ルールを維持します。
+tooltip.orespawn.ore_source.channel=配置エンジンは個別に調整されます。
+tooltip.orespawn.ore_source.placement=このチャンネルの頻度、形状、母岩、フィルター、高さを決めるルールを選びます。
+tooltip.orespawn.ore_source.weight=鉱脈全体または識別済み鉱床全体でこの出力を選ぶ正の相対確率です。
+tooltip.orespawn.ore_source.external=このModはOreSpawn外で生成を登録しています。OreSpawnは表示できますが抑止できません。
+tooltip.orespawn.ore_source.controlled=OreSpawnがこの設定済み配置と出力ソースを制御します。
+tooltip.orespawn.ore_source.enrichment=この項目は富化用であり、交換可能な鉱石ソースではありません。
+tooltip.orespawn.ore_source.missing=このソースは利用できません。所有Modが戻ると保存済み選択が復元されます。
+error.orespawn.ore_source.weight=出力の重みは正の数である必要があります。
diff --git a/src/main/resources/assets/orespawn/lang/ko_KR.lang b/src/main/resources/assets/orespawn/lang/ko_KR.lang
index 925602ac..7b668840 100644
--- a/src/main/resources/assets/orespawn/lang/ko_KR.lang
+++ b/src/main/resources/assets/orespawn/lang/ko_KR.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=거부됨
status.orespawn.mod.config_only=설정 화면만 제공
error.orespawn.mod.screen_missing=%s 모드가 설정 화면을 만들지 않았습니다.
error.orespawn.mod.screen_failed=%s 설정을 열 수 없습니다(%s).
+button.orespawn.ore_sources=광석 소스...
+tooltip.orespawn.ore_sources=배치 빈도를 늘리지 않고 동등한 광석 출력을 검토합니다.
+screen.orespawn.ore_sources=광석 소스
+screen.orespawn.ore_source_detail=광석 소스 정책
+label.orespawn.ore_source.none=동등한 관리 광석 소스를 찾지 못했습니다.
+label.orespawn.ore_source.row=%s - %s - 후보 %s개 - %s
+label.orespawn.ore_source.material=재료: %s
+label.orespawn.ore_source.domain=차원/영역: %s
+label.orespawn.ore_source.channels=배치 채널: %s
+label.orespawn.ore_source.missing=배치 소스 없음
+label.orespawn.ore_source.owner=소유자: %s, 버전 %s
+label.orespawn.ore_source.registry=등록 출력: %s, 메타데이터 %s
+label.orespawn.ore_source.dictionary=광석 사전: %s
+option.orespawn.ore_source.mode=모드: %s
+option.orespawn.ore_source.channel=채널: %s
+option.orespawn.ore_source.placement=배치 소스: %s
+option.orespawn.ore_source.weight=가중치
+mode.orespawn.ore_source.consolidated=통합
+mode.orespawn.ore_source.keep_separate=분리 유지
+status.orespawn.ore_source.consolidated=통합
+status.orespawn.ore_source.separate=분리
+status.orespawn.ore_source.review_required=검토 필요
+status.orespawn.ore_source.missing_source=소스 없음
+status.orespawn.ore_source.external_generation=외부 생성
+tooltip.orespawn.ore_source.open=이 재료 정책을 엽니다.
+tooltip.orespawn.ore_source.mode=통합은 채널마다 하나의 배치 예산을 사용합니다. 분리 유지는 설정된 모든 규칙을 보존합니다.
+tooltip.orespawn.ore_source.channel=배치 엔진은 서로 독립적으로 조정됩니다.
+tooltip.orespawn.ore_source.placement=이 채널의 빈도, 모양, 모암, 필터 및 높이를 제어하는 규칙을 선택합니다.
+tooltip.orespawn.ore_source.weight=전체 광맥 또는 식별된 광상에 이 출력을 선택할 양의 상대 확률입니다.
+tooltip.orespawn.ore_source.external=이 Mod는 OreSpawn 밖에서 생성을 등록합니다. OreSpawn은 표시할 수 있지만 억제할 수 없습니다.
+tooltip.orespawn.ore_source.controlled=OreSpawn이 이 설정된 배치와 출력 소스를 제어합니다.
+tooltip.orespawn.ore_source.enrichment=이 항목은 농축용이며 교환 가능한 광석 소스가 아닙니다.
+tooltip.orespawn.ore_source.missing=이 소스를 사용할 수 없습니다. 소유 Mod가 돌아오면 저장된 선택이 복원됩니다.
+error.orespawn.ore_source.weight=출력 가중치는 양수여야 합니다.
diff --git a/src/main/resources/assets/orespawn/lang/pt_BR.lang b/src/main/resources/assets/orespawn/lang/pt_BR.lang
index c242bc55..18b8f311 100644
--- a/src/main/resources/assets/orespawn/lang/pt_BR.lang
+++ b/src/main/resources/assets/orespawn/lang/pt_BR.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Rejeitado
status.orespawn.mod.config_only=Apenas tela de configuração
error.orespawn.mod.screen_missing=%s não criou uma tela de configuração.
error.orespawn.mod.screen_failed=Não foi possível abrir a configuração de %s (%s).
+button.orespawn.ore_sources=Fontes de minério...
+tooltip.orespawn.ore_sources=Revise saídas de minério equivalentes sem multiplicar a frequência de colocação.
+screen.orespawn.ore_sources=Fontes de minério
+screen.orespawn.ore_source_detail=Política de fonte de minério
+label.orespawn.ore_source.none=Nenhuma fonte de minério gerenciada equivalente foi encontrada.
+label.orespawn.ore_source.row=%s - %s - %s candidatos - %s
+label.orespawn.ore_source.material=Matéria-prima: %s
+label.orespawn.ore_source.domain=Dimensão/domínio: %s
+label.orespawn.ore_source.channels=Canais de colocação: %s
+label.orespawn.ore_source.missing=Fonte de colocação ausente
+label.orespawn.ore_source.owner=Proprietário: %s, versão %s
+label.orespawn.ore_source.registry=Saída registrada: %s, metadados %s
+label.orespawn.ore_source.dictionary=Dicionário de minérios: %s
+option.orespawn.ore_source.mode=Modo: %s
+option.orespawn.ore_source.channel=Canal: %s
+option.orespawn.ore_source.placement=Fonte de colocação: %s
+option.orespawn.ore_source.weight=Peso
+mode.orespawn.ore_source.consolidated=Consolidado
+mode.orespawn.ore_source.keep_separate=Manter separado
+status.orespawn.ore_source.consolidated=Consolidado
+status.orespawn.ore_source.separate=Separado
+status.orespawn.ore_source.review_required=Revisão necessária
+status.orespawn.ore_source.missing_source=Fonte ausente
+status.orespawn.ore_source.external_generation=Geração externa
+tooltip.orespawn.ore_source.open=Abrir esta política de material.
+tooltip.orespawn.ore_source.mode=Consolidado usa um orçamento de colocação por canal. Manter separado preserva cada regra configurada.
+tooltip.orespawn.ore_source.channel=Os mecanismos de colocação são arbitrados separadamente.
+tooltip.orespawn.ore_source.placement=Escolha a regra que controla frequência, forma, hospedeiros, filtros e altura neste canal.
+tooltip.orespawn.ore_source.weight=Chance relativa positiva de escolher esta saída para um veio inteiro ou corpo de depósito identificado.
+tooltip.orespawn.ore_source.external=Este mod registra geração fora do OreSpawn; o OreSpawn a informa, mas não pode impedi-la.
+tooltip.orespawn.ore_source.controlled=O OreSpawn controla esta colocação e fonte de saída configuradas.
+tooltip.orespawn.ore_source.enrichment=Esta entrada é um enriquecimento, não uma fonte de minério intercambiável.
+tooltip.orespawn.ore_source.missing=Esta fonte está indisponível. A seleção salva será restaurada se o mod proprietário retornar.
+error.orespawn.ore_source.weight=Os pesos de saída devem ser números positivos.
diff --git a/src/main/resources/assets/orespawn/lang/ru_RU.lang b/src/main/resources/assets/orespawn/lang/ru_RU.lang
index 0b1def7c..7d009862 100644
--- a/src/main/resources/assets/orespawn/lang/ru_RU.lang
+++ b/src/main/resources/assets/orespawn/lang/ru_RU.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=Отклонено
status.orespawn.mod.config_only=Только экран настроек
error.orespawn.mod.screen_missing=%s не создал экран настроек.
error.orespawn.mod.screen_failed=Не удалось открыть настройки %s (%s).
+button.orespawn.ore_sources=Источники руд...
+tooltip.orespawn.ore_sources=Проверить равнозначные руды без умножения частоты размещения.
+screen.orespawn.ore_sources=Источники руд
+screen.orespawn.ore_source_detail=Правило источника руды
+label.orespawn.ore_source.none=Равнозначные управляемые источники руд не найдены.
+label.orespawn.ore_source.row=%s - %s - кандидатов: %s - %s
+label.orespawn.ore_source.material=Материал: %s
+label.orespawn.ore_source.domain=Измерение/область: %s
+label.orespawn.ore_source.channels=Каналы размещения: %s
+label.orespawn.ore_source.missing=Нет источника размещения
+label.orespawn.ore_source.owner=Владелец: %s, версия %s
+label.orespawn.ore_source.registry=Зарегистрированный выход: %s, метаданные %s
+label.orespawn.ore_source.dictionary=Словарь руд: %s
+option.orespawn.ore_source.mode=Режим: %s
+option.orespawn.ore_source.channel=Канал: %s
+option.orespawn.ore_source.placement=Источник размещения: %s
+option.orespawn.ore_source.weight=Вес
+mode.orespawn.ore_source.consolidated=Объединено
+mode.orespawn.ore_source.keep_separate=Хранить отдельно
+status.orespawn.ore_source.consolidated=Объединено
+status.orespawn.ore_source.separate=Раздельно
+status.orespawn.ore_source.review_required=Требуется проверка
+status.orespawn.ore_source.missing_source=Источник отсутствует
+status.orespawn.ore_source.external_generation=Внешняя генерация
+tooltip.orespawn.ore_source.open=Открыть правило этого материала.
+tooltip.orespawn.ore_source.mode=Объединение использует один бюджет размещения на канал. Раздельный режим сохраняет каждое настроенное правило.
+tooltip.orespawn.ore_source.channel=Механизмы размещения регулируются независимо.
+tooltip.orespawn.ore_source.placement=Выберите правило частоты, формы, вмещающих пород, фильтров и высоты для этого канала.
+tooltip.orespawn.ore_source.weight=Положительный относительный шанс выбрать этот выход для всей жилы или определённого рудного тела.
+tooltip.orespawn.ore_source.external=Этот мод регистрирует генерацию вне OreSpawn; OreSpawn показывает её, но не может отключить.
+tooltip.orespawn.ore_source.controlled=OreSpawn управляет этим настроенным размещением и источником выхода.
+tooltip.orespawn.ore_source.enrichment=Эта запись является обогащением, а не взаимозаменяемым источником руды.
+tooltip.orespawn.ore_source.missing=Источник недоступен. Сохранённый выбор восстановится, когда вернётся его мод.
+error.orespawn.ore_source.weight=Веса выходов должны быть положительными числами.
diff --git a/src/main/resources/assets/orespawn/lang/zh_CN.lang b/src/main/resources/assets/orespawn/lang/zh_CN.lang
index 10f23d61..62ec7359 100644
--- a/src/main/resources/assets/orespawn/lang/zh_CN.lang
+++ b/src/main/resources/assets/orespawn/lang/zh_CN.lang
@@ -378,3 +378,37 @@ status.orespawn.mod.rejected=已拒绝
status.orespawn.mod.config_only=仅配置界面
error.orespawn.mod.screen_missing=%s未创建配置界面。
error.orespawn.mod.screen_failed=无法打开%s的配置(%s)。
+button.orespawn.ore_sources=矿石来源...
+tooltip.orespawn.ore_sources=检查等价矿石输出,同时避免生成频率叠加。
+screen.orespawn.ore_sources=矿石来源
+screen.orespawn.ore_source_detail=矿石来源策略
+label.orespawn.ore_source.none=未找到等价的受管理矿石来源。
+label.orespawn.ore_source.row=%s - %s - %s 个候选 - %s
+label.orespawn.ore_source.material=材料:%s
+label.orespawn.ore_source.domain=维度/范围:%s
+label.orespawn.ore_source.channels=放置通道:%s
+label.orespawn.ore_source.missing=缺少放置来源
+label.orespawn.ore_source.owner=所有者:%s,版本 %s
+label.orespawn.ore_source.registry=注册输出:%s,元数据 %s
+label.orespawn.ore_source.dictionary=矿石词典:%s
+option.orespawn.ore_source.mode=模式:%s
+option.orespawn.ore_source.channel=通道:%s
+option.orespawn.ore_source.placement=放置来源:%s
+option.orespawn.ore_source.weight=权重
+mode.orespawn.ore_source.consolidated=合并
+mode.orespawn.ore_source.keep_separate=保持独立
+status.orespawn.ore_source.consolidated=已合并
+status.orespawn.ore_source.separate=独立
+status.orespawn.ore_source.review_required=需要检查
+status.orespawn.ore_source.missing_source=来源缺失
+status.orespawn.ore_source.external_generation=外部生成
+tooltip.orespawn.ore_source.open=打开此材料策略。
+tooltip.orespawn.ore_source.mode=合并模式每个通道只使用一份放置预算。保持独立会保留每条已配置规则。
+tooltip.orespawn.ore_source.channel=各放置引擎会被独立协调。
+tooltip.orespawn.ore_source.placement=选择控制此通道频率、形状、宿主、筛选和高度的规则。
+tooltip.orespawn.ore_source.weight=为整条矿脉或已识别矿体选择此输出的正相对概率。
+tooltip.orespawn.ore_source.external=此模组在 OreSpawn 外部注册生成;OreSpawn 可以报告但无法抑制。
+tooltip.orespawn.ore_source.controlled=OreSpawn 控制此已配置的放置与输出来源。
+tooltip.orespawn.ore_source.enrichment=此条目属于富集矿,不是可互换的矿石来源。
+tooltip.orespawn.ore_source.missing=此来源不可用。其所属模组恢复后将自动恢复已保存的选择。
+error.orespawn.ore_source.weight=输出权重必须为正数。
diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java
index 117fd804..7e54c49c 100644
--- a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java
@@ -84,7 +84,7 @@ class LocalizationParityTest {
void everyLocaleMatchesEnglishKeysAndFormatting() throws Exception {
JsonObject english = read(LANG_DIR.resolve("en_US.lang"));
Set englishKeys = JsonCopies.keys(english);
- assertEquals(380, englishKeys.size(),
+ assertEquals(414, englishKeys.size(),
"The target locale contract changed; review every shipped translation");
Set localeFiles = new HashSet<>();
Set observedIntentionalEnglishValues = new HashSet<>();
diff --git a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java
index 89579826..0f08c1a4 100644
--- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java
@@ -2,6 +2,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Arrays;
@@ -179,4 +180,59 @@ void namespacedGeomesCanBeAddedValidatedAndRoundTripped() {
assertFalse(invalidSession.section("geomes").has("bad:upper"));
assertTrue(invalidSession.section("geomes").has(geomeId));
}
+
+ @Test
+ void oreSourceEditsStayInsideThePendingEditorSession() {
+ WorldGeologyProfile original = profileWithOreSourcePolicy();
+ JsonObject before = original.rootCopy();
+ GeologyEditorSession session = new GeologyEditorSession(original);
+ GeologyEditorSession.OreSourceGroup group = session.oreSourceGroups().get(0);
+ assertEquals("keep_separate", group.mode);
+ assertThrows(UnsupportedOperationException.class, () -> group.candidates.clear());
+ assertThrows(UnsupportedOperationException.class,
+ () -> group.outputs.put("example:other", 2.0D));
+
+ session.setOreSourceMode(group.key, true);
+ session.setOreSourcePlacement(group.key, "orespawn:standard", "mineralogy:sulfur");
+ session.setOreSourceOutput(group.key, "mineralogy:sulfur", true, 3.5D);
+ GeologyEditorSession.OreSourceGroup edited = session.oreSourceGroups().get(0);
+ assertEquals("consolidated", edited.mode);
+ assertEquals("mineralogy:sulfur", edited.placements.get("orespawn:standard"));
+ assertEquals(3.5D, edited.outputs.get("mineralogy:sulfur").doubleValue());
+ assertEquals(before, original.rootCopy(), "editing must not persist the profile early");
+ assertFalse(before.equals(session.profile().rootCopy()));
+ }
+
+ private static WorldGeologyProfile profileWithOreSourcePolicy() {
+ JsonObject root = WorldGeologyProfile.recommended(false).toJson();
+ JsonObject policy = new JsonObject();
+ policy.addProperty("material", "orespawn:sulfur");
+ policy.addProperty("domain", "minecraft:overworld");
+ policy.addProperty("mode", "keep_separate");
+ policy.addProperty("status", "review_required");
+ policy.addProperty("review_required", true);
+ JsonObject outputs = new JsonObject();
+ outputs.addProperty("mineralogy:sulfur", 1.0D);
+ policy.add("outputs", outputs);
+ policy.add("placement_sources", new JsonObject());
+ JsonObject candidate = new JsonObject();
+ candidate.addProperty("source_id", "mineralogy:sulfur");
+ candidate.addProperty("owner", "mineralogy");
+ candidate.addProperty("owner_name", "Mineralogy");
+ candidate.addProperty("owner_version", "6.0.0");
+ candidate.addProperty("registry_id", "minecraft:iron_ore");
+ candidate.addProperty("metadata", 0);
+ candidate.addProperty("placement_channel", "orespawn:standard");
+ candidate.addProperty("loaded", true);
+ candidate.addProperty("external", false);
+ candidate.addProperty("enrichment", false);
+ candidate.add("ore_dictionary", new JsonArray());
+ JsonArray candidates = new JsonArray();
+ candidates.add(candidate);
+ policy.add("candidates", candidates);
+ JsonObject policies = new JsonObject();
+ policies.add("orespawn:sulfur|minecraft:overworld", policy);
+ root.add("ore_source_policies", policies);
+ return WorldGeologyProfile.fromJson(root, WorldGeologyProfile.recommended(false));
+ }
}
diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
index 04a02059..33621a0b 100644
--- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
+++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java
@@ -26,7 +26,7 @@ void everyConcreteScreenClearsThePreviousFrameBeforeDrawingWidgets() throws Exce
.sorted()
.collect(Collectors.toList());
}
- assertEquals(25, screens.size(), "Review this render-order gate when screens are added or removed");
+ assertEquals(27, screens.size(), "Review this render-order gate when screens are added or removed");
for (Path screen : screens) {
String source = new String(Files.readAllBytes(screen), StandardCharsets.UTF_8);
int render = source.indexOf(
@@ -76,6 +76,39 @@ void advancedAndFluidEditorsShareTheRecoveredTerrainRow() throws Exception {
.contains("font, right, top + (row * rowIndex++)"));
}
+ @Test
+ void oreSourcesAndAddBlockShareTheCompactOreFooter() throws Exception {
+ String source = screenSource("GeologyMaterialsScreen.java");
+ int width = source.indexOf("int addWidth = tab == MaterialTab.ORES");
+ int sources = source.indexOf("button.orespawn.ore_sources", width);
+ int add = source.indexOf("button.orespawn.add_block", sources);
+ assertTrue(width >= 0 && sources > width && add > sources);
+ assertTrue(source.substring(width, add).contains("addX - addWidth - 5"));
+ assertTrue(source.substring(width, add).contains("addX, controlsY, addWidth, 20"));
+ }
+
+ @Test
+ void oreSourceDirectoryPaginatesClipsAndPreservesParentNavigation() throws Exception {
+ assertEquals(1, OreSourceListScreen.pageCount(0, 4));
+ assertEquals(1, OreSourceListScreen.pageCount(4, 4));
+ assertEquals(2, OreSourceListScreen.pageCount(5, 4));
+ String list = screenSource("OreSourceListScreen.java");
+ String detail = screenSource("OreSourceDetailScreen.java");
+ assertTrue(list.contains("pageSize = Math.max(1"));
+ assertTrue(detail.contains("pageSize = Math.max(1"));
+ assertTrue(list.contains("OreSpawnScreenLayout.fit"));
+ assertTrue(detail.contains("OreSpawnScreenLayout.fit"));
+ assertTrue(list.contains("minecraft.displayGuiScreen(parent)"));
+ assertTrue(detail.contains("syncWeights();\n\t\tif (error == null) minecraft.displayGuiScreen(parent)"));
+ assertTrue(detail.contains("toggle.enabled = !candidate.external && !candidate.enrichment"));
+ }
+
+ private static String screenSource(String name) throws Exception {
+ Path screen = Paths.get("src", "main", "java", "zone", "moddev", "mc",
+ "orespawn", "client", name);
+ return new String(Files.readAllBytes(screen), StandardCharsets.UTF_8);
+ }
+
private static void assertRowsClearFooter(int height) {
int rows = 8;
int top = OreSpawnScreenLayout.mainTop(height);
From ba9c7a5cfc8d32c6b0630b9380cc323dbeb56f51 Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Mon, 14 Sep 2026 15:59:35 +0100
Subject: [PATCH 10/31] Document OreSpawn 4.1 ore source policies
---
CHANGELOG.txt | 15 +++++++++--
README.md | 13 ++++++++--
docs/API.md | 42 +++++++++++++++++++++++++++++--
docs/CONFIGURATION.md | 56 ++++++++++++++++++++++++++++++++++-------
docs/DEVELOPER_GUIDE.md | 22 ++++++++++++++--
docs/MIGRATION.md | 12 +++++++--
docs/PLAYER_GUIDE.md | 22 ++++++++++++++++
docs/PROVIDERS.md | 38 +++++++++++++++++++++++++---
8 files changed, 198 insertions(+), 22 deletions(-)
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index a745dbba..5f537830 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -1,5 +1,15 @@
Version 4.1.0.110021
+* Add Ore Sources policies that group equivalent configured outputs by material
+ and dimension while keeping ordinary and custom placement channels independent.
+* Consolidate only reviewed high-confidence MMD conflicts for new worlds;
+ upgraded worlds retain their previous separate placement and output behaviour.
+* Select one weighted output for an entire ordinary vein or custom deposit body
+ using stable world/deposit identity, including safe missing-mod restoration.
+* Add paginated Ore Sources list and detail screens for mode, placement-source
+ and output-weight control without persisting changes until the main editor's Done.
+* Add provider schema 5 material and placement-channel declarations, global
+ schema 7 and world-profile schema 6 while retaining older readable schemas.
* Add an extended, binary-compatible ore generation context exposing stable
world seed, dimension and current-chunk identity to compiled pattern add-ons.
* Expose the active geology sampler to patterns when the generated dimension
@@ -18,8 +28,9 @@ Version 4.1.0.110021
profiles while retaining the historical merge behavior by default.
* Guarantee geology column sampling does not load or generate neighbouring
chunks.
-* Preserve API major 1, schemas 4/6/5, existing pattern binaries, profiles,
- migration behaviour and generated chunks.
+* Preserve API major 1, provider schemas 1-4, existing pattern binaries,
+ migration behaviour and generated chunks while advancing active schemas to
+ provider 5, global 7 and world profile 6.
Version 4.0.16.110021
diff --git a/README.md b/README.md
index 81738d29..ae4f82b2 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,9 @@ palettes and world materials, flat bedrock, and bounded ore retrogen.
This branch builds target-qualified version `4.1.0.110021`: the OreSpawn 4.1.0
feature set for Minecraft 1.10.2 and Forge. It adds stable generation identity,
non-loading geology sampling, per-resource background control, existing-world
-provider merge policy and a client world-settings extension point for add-ons
-such as Realistic Deposits while retaining API major 1. See the
+provider merge policy, an Ore Sources arbiter for equivalent provider ores and
+a client world-settings extension point for add-ons such as Realistic Deposits
+while retaining API major 1. See the
[versioning policy](docs/VERSIONS.md) for the encoding and release convention.
Its deprecated compatibility layer imports OreSpawn 1 and OreSpawn 3
@@ -57,6 +58,14 @@ provider revision and status; its cog opens an add-on-owned configuration
screen when one is available. The in-game **Help & Guide** explains the other
controls.
+On the ORES tab, **Ore Sources...** identifies configured ores that represent
+the same material. New worlds consolidate only reviewed, high-confidence MMD
+conflicts, so several sulfur providers do not automatically create several
+independent sulfur budgets. Existing worlds start in **Keep Separate** mode and
+retain their old frequency and output behaviour until their owner explicitly
+changes a policy. Third-party generators remain outside OreSpawn's control and
+are reported rather than disabled.
+
Important files:
| Location | Purpose |
diff --git a/docs/API.md b/docs/API.md
index ed2a10c6..95084448 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -52,8 +52,9 @@ coverage with `OreDefinition.Builder.dimensionSelector(...)` and
`OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END`. Explicit dimensions
override that selector and prevent duplicate placement.
-The builder emits provider schema 4. Legacy provider schemas 1-3 remain
-readable. Schema 4 is required for biome palettes and dimension materials.
+The builder emits provider schema 5. Legacy provider schemas 1-4 remain
+readable. Schema 4 is required for biome palettes and dimension materials;
+schema 5 adds ore material identity and independent placement channels.
Provider-owned fluid deposits are declarative and may target several dimensions:
@@ -91,6 +92,32 @@ exclusions. These methods work on both explicit `.dimension(...)` rules and
`.dimensionSelector(...)` fallbacks; built definitions and their returned
filter sets are immutable.
+Declare the canonical material separately from the output block when equivalent
+ores may be supplied by several mods:
+
+```java
+ResourceLocation sulfur = new ResourceLocation("orespawn", "sulfur");
+ResourceLocation standard = new ResourceLocation("orespawn", "standard");
+
+WorldgenProvider provider = WorldgenProvider.builder("examplemod", 2)
+ .ore(new ResourceLocation("examplemod", "ore/sulfur"), ore -> ore
+ .material(sulfur)
+ .dimension(new ResourceLocation("minecraft", "overworld"), placement -> placement
+ .placementChannel(standard)
+ .yRange(0, 48)
+ .attempts(4.0)
+ .quantity(8)
+ .pattern(OrePattern.VEIN)
+ .hostTag(new ResourceLocation("forge", "stone"))))
+ .build();
+```
+
+`OreDefinition.material()` is optional. Built-in patterns default their
+`OreDimensionDefinition.placementChannel()` to `orespawn:standard`; a custom
+pattern defaults to its registered pattern-type ID. Explicit channels keep an
+ordinary vein budget independent from a region-scale deposit budget while the
+material group may still share the same selectable outputs.
+
Create one `BiomeRegistrar` during normal mod construction. It attaches to the
calling mod's event bus and defers biome factories until Forge's biome registry
event. `OreSpawnBiomes.copyAndRegister` clones a known biome without adding a
@@ -177,6 +204,12 @@ patterns must independently render only the slice intersecting the current
chunk and continue to use `inside(...)` and `tryPlace(...)` for safe writes.
Do not retain the sampler or placement context beyond the current call.
+For a body that intersects several chunks, call
+`tryPlace(x, y, z, stableBodyIdentity)` instead. This Java 8 default overload
+preserves the original three-coordinate method and therefore existing pattern
+binaries. OreSpawn uses the supplied identity to choose one output source for
+the complete body rather than independently selecting an output per chunk.
+
Large-deposit add-ons may make one OreSpawn rule the controller for a resource
in a dimension. Set `.backgroundGenerationScale(value)` on that rule to scale
other OreSpawn-managed rules and vanilla generation for the same primary
@@ -185,6 +218,11 @@ fully suppresses background generation, and the lowest value wins when several
controllers target the same resource. Vanilla decisions use a stable hash of
world, dimension, chunk and resource rather than mutable event order.
+When a rule declares `material`, background scaling applies only to that
+material's `orespawn:standard` channel; it does not scale another custom
+placement engine. Undeclared legacy rules retain the existing block-based
+background behaviour.
+
Providers whose entries capture structural configuration can call
`.mergeNewEntriesIntoExistingWorlds(false)`. New worlds still receive the
complete provider, while an existing saved world profile does not silently gain
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 2649879a..061e31d8 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -4,13 +4,14 @@ OreSpawn uses three JSON contracts:
| File | Schema | Purpose |
|---|---:|---|
-| `config/orespawn-worldgen.json` | 6 | Installed-pack defaults for new worlds |
-| `/serverconfig/orespawn-worldgen.json` | 5 | Self-contained snapshot for one world |
-| `config/-orespawn.json` | 4 | Optional authoritative provider override |
+| `config/orespawn-worldgen.json` | 7 | Installed-pack defaults for new worlds |
+| `/serverconfig/orespawn-worldgen.json` | 6 | Self-contained snapshot for one world |
+| `config/-orespawn.json` | 5 | Optional authoritative provider override |
-A provider may package schema 4 at `assets//orespawn/provider.json`.
-Legacy provider schemas 1-3 remain accepted. Fluid deposits require schema 3;
-biome palettes and dimension materials require schema 4.
+A provider may package schema 5 at `assets//orespawn/provider.json`.
+Legacy provider schemas 1-4 remain accepted. Fluid deposits require schema 3;
+biome palettes and dimension materials require schema 4; ore `material` and
+`placement_channel` declarations require schema 5.
The profile for a new world is merged in this order: passive OreSpawn defaults,
packaged or API providers, provider override files, the global configuration,
@@ -21,7 +22,7 @@ world. Restart after editing JSON by hand.
| Field | Values | Meaning |
|---|---|---|
-| `schema_version` | Contract-specific integer | Global 6, world 5, provider 4 |
+| `schema_version` | Contract-specific integer | Global 7, world 6, provider 5 |
| `geology_mode` | `geome`, `legacy` | Sky/geome engine or Cyano legacy engine |
| `place_fluid_deposits` | boolean | Master switch for configured fluid-deposit rules |
| `manage_vanilla_ores` | boolean | Lets OreSpawn suppress and replace claimed vanilla ore features |
@@ -36,6 +37,7 @@ world. Restart after editing JSON by hand.
| `biome_palettes` | object keyed by provider-owned rule ID | Optional native-biome overlays and surfaces |
| `dimension_materials` | object keyed by provider-owned rule ID | Aquifer fluid, snow, and ice substitutions |
| `ores` | object keyed by rule ID | Ore outputs and per-dimension placement |
+| `ore_source_policies` | object keyed by material and dimension/domain | Persisted output and placement arbitration choices |
| `fluid_deposits` | object keyed by rule ID | Provider-owned fluids and per-dimension placement |
| `retrogen` | object | Bounded ore retrogen controls |
| `flat_bedrock` | object | Opt-in flat bedrock controls |
@@ -161,8 +163,11 @@ See `BIOMES.md` for complete examples and practical guidance.
## Ore Fields
An ore has `enabled`, one output `block` or weighted `outputs`, and at least one
-entry in `dimensions` or `dimension_selectors`. Optional fields include `native_generation`,
-`suppress_vanilla`, `retrogen`, `deep_output`, and `deep_output_max_y`.
+entry in `dimensions` or `dimension_selectors`. Optional fields include
+`material`, `native_generation`, `suppress_vanilla`, `retrogen`, `deep_output`,
+and `deep_output_max_y`. Schema 5 `material` is a canonical material ID such as
+`orespawn:sulfur`; it lets several rules share outputs without sharing placement
+budgets.
Each enabled ore dimension uses:
@@ -173,6 +178,7 @@ Each enabled ore dimension uses:
| `quantity` | 1-64 | Fixed block budget for each attempt |
| `min_quantity`, `max_quantity` | 1-64 | Inclusive random block-budget range; both fields are required |
| `pattern` | pattern name or codec object | Deposit shape |
+| `placement_channel` | registry ID | Independent placement engine; built-ins default to `orespawn:standard`, custom patterns to their pattern-type ID |
| `height_distribution` | one of four values | Vertical probability curve |
| `discard_chance_on_air_exposure` | 0-1 | Chance to omit candidates touching cave air |
| `spread` | 0-64 | Horizontal pattern reach |
@@ -203,6 +209,38 @@ The selector `orespawn:all_except_nether_end` covers every dimension except
the vanilla Nether and End. Explicit rules in `dimensions` override selector
rules for the same ore and dimension, including explicit disabled rules.
+## Ore Source Policies
+
+OreSpawn builds one immutable source catalog while providers and the Forge Ore
+Dictionary are being baked. Explicit `material` declarations win. Otherwise,
+only exact Ore Dictionary names of the form `oreX` are inferred; curated
+spelling aliases include sulfur/sulphur and aluminum/aluminium. Niter and
+Saltpeter remain distinct, and ambiguous entries are marked **Review required**
+instead of being guessed. Registry, dictionary and policy work never runs in a
+chunk-generation loop.
+
+Each `ore_source_policies` key combines the canonical material with an exact
+dimension or existing dimension-selector ID. A policy stores `mode` as
+`consolidated` or `keep_separate`, selected output rule IDs with positive
+weights, and one placement-source rule ID per placement channel. Consolidation
+runs the chosen placement rule's pattern, hosts, filters, height and frequency
+once; selected candidates contribute output bundles only. Output choice is
+stable for a whole ordinary vein, and custom patterns can supply a stable body
+identity so one deposit uses one source across chunk boundaries.
+
+New worlds consolidate only reviewed high-confidence MMD catalog conflicts.
+Upgraded worlds initialize discovered conflicts as `keep_separate`, preserving
+their historical placement frequency and outputs until changed through **Ore
+Sources...**. Policy edits affect newly generated chunks only and never cause
+retrogen. Missing selected candidates remain in the profile; remaining outputs
+are reweighted, while a channel with no remaining selected placement source is
+disabled and reported rather than silently reassigned.
+
+DenseMetals entries are shown as enrichment rather than interchangeable output.
+BaseSciences has no ordinary ore-source role. An Ore Dictionary member whose
+generator is not controlled by OreSpawn is reported as external generation and
+is never suppressed, so total abundance may still be increased by that mod.
+
## Fluid Deposits, Retrogen, And Bedrock
Each `fluid_deposits` entry has a stable provider-namespaced rule ID, an
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index 8fbf88eb..b435417a 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -15,6 +15,7 @@
| Inspect active geology at runtime | `GeologyProfileView` and `GeologySampler` |
| Build a deterministic region-scale ore pattern | `OreGenerationContext` |
| Replace or blend background ore generation | `backgroundGenerationScale` |
+| Share equivalent ore outputs without multiplying placement | Provider schema 5 `material` and `placement_channel` |
| Add an add-on settings screen | Client-only `WorldSettingsExtensionRegistry` |
Strata are optional. If no enabled terrain dimension has eligible rocks,
@@ -24,7 +25,7 @@ blocks or tags.
## Provider JSON Quick Start
-Put a schema-4 file in your mod jar at:
+Put a schema-5 file in your mod jar at:
```text
src/main/resources/assets/examplemod/orespawn/provider.json
@@ -36,12 +37,13 @@ stone without enabling strata:
```json
{
- "schema_version": 4,
+ "schema_version": 5,
"provider_modid": "examplemod",
"provider_revision": 1,
"ores": {
"examplemod:ore/tin": {
"block": "examplemod:tin_ore",
+ "material": "orespawn:tin",
"enabled": true,
"source_mod": "examplemod",
"dimensions": {
@@ -53,6 +55,7 @@ stone without enabling strata:
"min_quantity": 4,
"max_quantity": 11,
"pattern": "vein",
+ "placement_channel": "orespawn:standard",
"height_distribution": "triangle",
"host_tags": ["forge:stone"]
}
@@ -93,6 +96,7 @@ public void init(FMLInitializationEvent event) {
ResourceLocation tin = new ResourceLocation("examplemod", "tin_ore");
WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1)
.ore(tin, ore -> ore
+ .material(new ResourceLocation("orespawn", "tin"))
.retrogen(false)
.dimensionSelector(OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END,
placement -> placement
@@ -100,6 +104,7 @@ public void init(FMLInitializationEvent event) {
.attempts(6.0)
.quantityRange(4, 11)
.pattern(OrePattern.VEIN)
+ .placementChannel(new ResourceLocation("orespawn", "standard"))
.heightDistribution(OreHeightDistribution.TRIANGLE)
.biome(new ResourceLocation("minecraft", "plains"))
.biomeDictionary("FOREST")
@@ -122,6 +127,13 @@ needs different settings; the explicit rule overrides the selector there.
Ore dimension builders support the same exact-ID and biome-dictionary include
and exclude filters as provider JSON and fluid-deposit builders.
+Give equivalent ores the same canonical `.material(...)`. A placement channel
+identifies an independent generation engine: built-in patterns default to
+`orespawn:standard`, while a custom pattern defaults to its pattern-type ID.
+OreSpawn can then consolidate one placement budget while retaining weighted
+whole-vein output choice. Do not use a block ID as a material merely because
+the names happen to match; use a stable semantic material ID.
+
## Region-scale custom patterns
OreSpawn 4.1 supplies compiled patterns with `OreGenerationContext`, a subtype
@@ -136,6 +148,12 @@ Render only the part of a deterministic body that intersects `chunkX()` and
operations to the current chunk. Perform definition parsing and expensive
setup in the registered pattern compiler, not its placement callback.
+When placing a multi-chunk body, call
+`tryPlace(x, y, z, stableBodyIdentity)`. The overload is binary-compatible with
+existing patterns and makes OreSpawn select one output source for the entire
+body. The identity must be stable for the deposit and independent of chunk
+generation order.
+
## Add-on world settings
Register one optional client configuration screen during client
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
index 1d691406..0e1cc101 100644
--- a/docs/MIGRATION.md
+++ b/docs/MIGRATION.md
@@ -117,17 +117,25 @@ replacement, retrogen, forced retrogen, flat bedrock, and bedrock thickness.
Unknown numeric dimensions and obsolete block states are reported instead of
guessed. Review `config/orespawn-migration/migration-report.txt` after import.
-Global schemas 1-5 and world schemas 1-4 are upgraded in memory and persisted
+Global schemas 1-6 and world schemas 1-5 are upgraded in memory and persisted
where safe. A schema-1 world held only mode/oil/formation choices and is
overlaid on the effective installed-pack profile. A schema-2 world is already
a full snapshot and preserves its geology. Existing terrain is not rewritten.
-Provider schemas 1-3 remain readable. Schema 4 adds `biome_palettes`,
+Provider schemas 1-4 remain readable. Schema 4 adds `biome_palettes`,
`dimension_materials`, and automatic fresh-world template metadata. Older
profiles receive empty biome/material sections, so migration cannot change
their terrain or biome output. Auto-selected templates are never applied to an
existing world profile during migration.
+Provider schema 5 adds optional canonical ore `material` and per-rule
+`placement_channel` fields. Global schema 7 and world schema 6 persist
+`ore_source_policies`. A new world may consolidate only reviewed MMD conflicts;
+every discovered conflict in an upgraded world is initialized as
+`keep_separate`, preserving prior placement frequency, ordering and output
+behaviour. Missing selected sources remain recorded. Policy changes affect new
+chunks only and do not add retrogen.
+
The old `place_crude_oil` and singleton `oil` fields migrate to
`place_fluid_deposits` and a provider-owned rule. A valid block becomes
`:fluid_deposit/`; `minecraft:air` creates no rule. Mineralogy
diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md
index c0682804..d0e7c9ee 100644
--- a/docs/PLAYER_GUIDE.md
+++ b/docs/PLAYER_GUIDE.md
@@ -46,6 +46,9 @@ safely instead of breaking world creation.
region.
- **Manage Vanilla Ores** lets OreSpawn replace vanilla ore features with the
configured OreSpawn rules. Leave it off to keep normal Minecraft placement.
+- **Ore Sources** finds configured ores that represent the same material and
+ lets you consolidate their placement without losing the installed block
+ choices. It is available from the ORES tab beside **Add Block**.
- **Mods** opens a paginated directory of loaded OreSpawn integrations. It
reports the Forge mod version, native OS4 or legacy OS1-OS3 lineage, provider
schema/revision and current status. Historical profile owners that are not
@@ -80,6 +83,25 @@ whether a deposit is compact, vein-like, clustered, cloud-like, or below a
fluid. Hosts decide which blocks, tags, or configured rock families it may
replace.
+When two or more OreSpawn-managed rules represent one material, open **Ore
+Sources...**. The list reports the material, dimension, candidate count and
+whether it is Consolidated, Separate, Review required, Missing source, or also
+has External generation. Open a material to choose one placement source for
+each independent channel and enable one or more weighted output sources.
+
+Consolidated mode uses one placement budget, so three sulfur blocks do not mean
+three times as much sulfur. Each vein uses one selected output instead of a
+salt-and-pepper mixture. Custom region-scale deposit engines remain on their
+own placement channel and can keep one output across every chunk of a deposit.
+External native generators are explained but never disabled by OreSpawn.
+
+New worlds automatically consolidate only reviewed high-confidence MMD
+conflicts. Existing worlds start with conflicts Separate so their established
+frequency remains unchanged. Missing selected mods stay visible and are
+restored automatically if reinstalled. These edits stay pending until the main
+OreSpawn editor's **Done** is pressed; its **Cancel** discards them. Changes
+affect only newly generated chunks and never retrogen existing terrain.
+
Removing a rock from generation does not unregister its block or recipes. It
only prevents that rock appearing in newly generated terrain.
diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md
index 533803b3..fe7eedd3 100644
--- a/docs/PROVIDERS.md
+++ b/docs/PROVIDERS.md
@@ -6,12 +6,13 @@ Provider mods may contribute through Forge IMC, a packaged resource at
present malformed override leaves that provider inactive instead of silently
falling back.
-Provider schema 4 supports `profile_defaults`, `rocks`, `ores`,
+Provider schema 5 supports `profile_defaults`, `rocks`, `ores`,
`fluid_deposits`, `geomes`, `biome_rules`, `terrain_dimensions`, and
`templates`, plus `biome_palettes` and `dimension_materials`. Each file requires a
matching `provider_modid`, a positive `provider_revision`, and at least one
-contribution. Legacy schemas 1-3 remain accepted; schema 3 introduced fluid
-deposits and schema 4 introduces biome and world-material controls.
+contribution. Legacy schemas 1-4 remain accepted; schema 3 introduced fluid
+deposits, schema 4 introduced biome and world-material controls, and schema 5
+adds canonical ore materials and independent placement channels.
An ore-only provider does not need rocks, geomes, or terrain dimensions. Give
each ore explicit host blocks or tags and OreSpawn will leave vanilla terrain,
@@ -26,6 +27,7 @@ ownership keys, not necessarily block IDs. Set `block` for one output or
```json
"examplemod:ore/tin": {
"block": "examplemod:tin_ore",
+ "material": "orespawn:tin",
"outputs": [
{ "block": "examplemod:tin_ore", "weight": 90 },
{ "block": "examplemod:rich_tin_ore", "weight": 10 }
@@ -40,12 +42,20 @@ ownership keys, not necessarily block IDs. Set `block` for one output or
"min_quantity": 4,
"max_quantity": 11,
"pattern": "default",
+ "placement_channel": "orespawn:standard",
"host_tags": ["forge:stone"]
}
}
}
```
+`material` identifies what an ore represents; it is not an output registry ID.
+Rules for one material and dimension share selectable output sources, while
+`placement_channel` keeps ordinary veins independent from custom deposit
+engines. Built-in patterns default to `orespawn:standard`; custom patterns
+default to their registered pattern-type ID. A schema-5 provider should declare
+these fields explicitly when it expects Ore Sources arbitration.
+
Fluid-deposit IDs also use the provider namespace. Their `block` may belong to
any installed mod, but it must be a real fluid block. Every enabled dimension
needs hosts and can independently set depth, attempts, lobe geometry, cover,
@@ -101,6 +111,28 @@ Only suppress a provider mod's native ore generation when
`OreSpawnApi.isOreTakeoverActive(modid)` returns true. `PENDING` means discovery
has not frozen. `INACTIVE` is the fail-safe and native generation must remain.
+## Reviewed MMD Ore Sources
+
+OreSpawn ships a deliberately narrow compatibility catalog. BaseMetals,
+ModernMetals, BaseGems, BaseMinerals, FantasyMetals and Advantage-family ores
+may participate as ordinary interchangeable outputs when their material is
+unambiguous. NetherMetals and EndMetals stay in their respective dimension
+domains. DenseMetals is enrichment, not an interchangeable output, and
+BaseSciences has no ordinary ore-source role.
+
+The reviewed automatic priorities are Mineralogy, then BaseMinerals, then
+ElectricAdvantage for sulfur; and BaseMinerals, then ElectricAdvantage for
+lithium. No other conflict is automatically consolidated without an explicit
+catalog row. Exact Ore Dictionary `oreX` inference recognizes the curated
+sulfur/sulphur and aluminum/aluminium aliases but deliberately keeps Niter and
+Saltpeter distinct. Multiple unrelated entries are marked for review rather
+than fuzzy-matched from block names.
+
+Ore Dictionary membership does not prove that OreSpawn controls a mod's native
+generator. Such candidates are displayed as external generation and are never
+disabled. Providers should keep native generation enabled until takeover is
+active, then let their OreSpawn rules participate normally.
+
Existing worlds normally merge newly introduced provider rule IDs but do not
overwrite world edits. A Java provider may opt out with
`mergeNewEntriesIntoExistingWorlds(false)` when its rules capture structural
From 4cbe3b592e2192049dfa91f4a8650a89c0cc42b2 Mon Sep 17 00:00:00 2001
From: JohnBraham
Date: Wed, 16 Sep 2026 15:15:20 +0100
Subject: [PATCH 11/31] Redesign Ore Sources around material groups
---
CHANGELOG.txt | 18 +-
README.md | 17 +-
build.gradle | 7 +-
docs/API.md | 14 +
docs/CONFIGURATION.md | 43 +-
docs/MIGRATION.md | 17 +-
docs/PLAYER_GUIDE.md | 11 +-
docs/PROVIDERS.md | 15 +-
docs/examples/orespawn-global.json | 12 +-
docs/examples/orespawn-world.json | 12 +-
docs/schemas/orespawn-global.schema.json | 2 +-
docs/schemas/orespawn-world.schema.json | 2 +-
docs/schemas/profile-common.schema.json | 22 +-
.../orespawn/client/ClientProbeTestMod.java | 57 +++
.../orespawn/client/GeologyEditorSession.java | 457 +++++++++++++++++-
.../client/OreSourceDetailScreen.java | 233 ---------
.../orespawn/client/OreSourceListScreen.java | 415 +++++++++++++---
.../client/OreSpawnWorldSettingsScreen.java | 6 +
.../mc/orespawn/worldgen/GeomeConfig.java | 32 +-
.../orespawn/worldgen/OreMaterialGroups.java | 251 ++++++++++
.../orespawn/worldgen/OreSourcePolicies.java | 207 +++++---
.../worldgen/OreSpawnOreGeneration.java | 118 +++--
.../worldgen/WorldGeologyProfile.java | 8 +-
.../resources/assets/orespawn/lang/de_AU.lang | 32 ++
.../resources/assets/orespawn/lang/de_DE.lang | 32 ++
.../resources/assets/orespawn/lang/en_CA.lang | 32 ++
.../resources/assets/orespawn/lang/en_EN.lang | 32 ++
.../resources/assets/orespawn/lang/en_GB.lang | 32 ++
.../resources/assets/orespawn/lang/en_PT.lang | 32 ++
.../resources/assets/orespawn/lang/en_US.lang | 32 ++
.../resources/assets/orespawn/lang/es_ES.lang | 32 ++
.../resources/assets/orespawn/lang/es_MX.lang | 32 ++
.../resources/assets/orespawn/lang/fr_CA.lang | 32 ++
.../resources/assets/orespawn/lang/fr_FR.lang | 32 ++
.../resources/assets/orespawn/lang/ja_JP.lang | 32 ++
.../resources/assets/orespawn/lang/ko_KR.lang | 32 ++
.../resources/assets/orespawn/lang/pt_BR.lang | 32 ++
.../resources/assets/orespawn/lang/ru_RU.lang | 32 ++
.../resources/assets/orespawn/lang/zh_CN.lang | 32 ++
.../mc/orespawn/LocalizationParityTest.java | 2 +-
.../client/GeologyEditorSessionTest.java | 123 +++++
.../client/OreSpawnScreenLayoutTest.java | 36 +-
.../worldgen/OreSourcePoliciesTest.java | 165 ++++++-
.../worldgen/WorldGeologyProfileTest.java | 23 +
44 files changed, 2363 insertions(+), 474 deletions(-)
delete mode 100644 src/main/java/zone/moddev/mc/orespawn/client/OreSourceDetailScreen.java
create mode 100644 src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 5f537830..e10ba3e4 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -6,10 +6,20 @@ Version 4.1.0.110021
upgraded worlds retain their previous separate placement and output behaviour.
* Select one weighted output for an entire ordinary vein or custom deposit body
using stable world/deposit identity, including safe missing-mod restoration.
-* Add paginated Ore Sources list and detail screens for mode, placement-source
- and output-weight control without persisting changes until the main editor's Done.
+* Replace the technical Ore Sources list/detail flow with one responsive
+ material-group editor for friendly names, exact Ore Dictionary aliases and
+ selectable ore blocks. It uses side-by-side panes when wide and list/detail
+ pages at compact resolutions.
+* Add Keep Original, Balanced, Single and Custom output modes. Reviewed fresh
+ MMD conflicts balance every eligible output through one placement budget;
+ inactive provider definitions remain selectable outputs without contributing
+ another placement rule.
+* Ship curated Sulfur/Sulphur and Aluminum/Aluminium groups, keep Niter and
+ Saltpeter distinct, deduplicate identical registry states, and allow confirmed
+ alias reassignment plus stable custom groups. Group edits are saved to the
+ world and future-world defaults only when the main editor's Done is accepted.
* Add provider schema 5 material and placement-channel declarations, global
- schema 7 and world-profile schema 6 while retaining older readable schemas.
+ schema 8 and world-profile schema 7 while retaining older readable schemas.
* Add an extended, binary-compatible ore generation context exposing stable
world seed, dimension and current-chunk identity to compiled pattern add-ons.
* Expose the active geology sampler to patterns when the generated dimension
@@ -30,7 +40,7 @@ Version 4.1.0.110021
chunks.
* Preserve API major 1, provider schemas 1-4, existing pattern binaries,
migration behaviour and generated chunks while advancing active schemas to
- provider 5, global 7 and world profile 6.
+ provider 5, global 8 and world profile 7.
Version 4.0.16.110021
diff --git a/README.md b/README.md
index ae4f82b2..d2bbfc2f 100644
--- a/README.md
+++ b/README.md
@@ -58,13 +58,16 @@ provider revision and status; its cog opens an add-on-owned configuration
screen when one is available. The in-game **Help & Guide** explains the other
controls.
-On the ORES tab, **Ore Sources...** identifies configured ores that represent
-the same material. New worlds consolidate only reviewed, high-confidence MMD
-conflicts, so several sulfur providers do not automatically create several
-independent sulfur budgets. Existing worlds start in **Keep Separate** mode and
-retain their old frequency and output behaviour until their owner explicitly
-changes a policy. Third-party generators remain outside OreSpawn's control and
-are reported rather than disabled.
+On the ORES tab, **Ore Sources...** opens a material-group editor. Its initial
+**Needs Attention** list keeps ordinary one-source materials out of the way;
+**All Groups** reveals everything. A group has a friendly name, exact Ore
+Dictionary aliases, and the installed ore blocks that may represent it. New
+worlds balance all eligible outputs in reviewed MMD conflicts while running one
+placement budget, so sulfur/sulphur providers do not multiply abundance.
+**Single** chooses one output, **Custom** allows a weighted subset, and **Keep
+Original** preserves every active rule. Existing worlds start in Keep Original
+until their owner explicitly changes a group. Third-party generators remain
+outside OreSpawn's control and are clearly reported rather than disabled.
Important files:
diff --git a/build.gradle b/build.gradle
index 7a3bcca2..ee87396d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -675,6 +675,7 @@ task clientIntegrationProcess(type: JavaExec, dependsOn: prepareClientIntegratio
|| result.getProperty('world_settings_opened') != 'true'
|| result.getProperty('mods_directory_rendered') != 'true'
|| result.getProperty('directory_stack_validated') != 'true'
+ || result.getProperty('ore_sources_validated') != 'true'
|| result.getProperty('long_editor_roundtrip') != 'true'
|| Integer.parseInt(result.getProperty('editor_routes', '0')) < 5) {
throw new GradleException("Client integration result is incomplete: ${marker}")
@@ -1003,10 +1004,10 @@ tasks.register('verifyReleaseConfiguration') {
throw new GradleException('OreSpawn API major must remain 1')
}
if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java')
- .getText('UTF-8').contains('SCHEMA_VERSION = 7')
+ .getText('UTF-8').contains('SCHEMA_VERSION = 8')
|| !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java')
- .getText('UTF-8').contains('SCHEMA_VERSION = 6')) {
- throw new GradleException('Global/world schema versions must remain 7/6')
+ .getText('UTF-8').contains('SCHEMA_VERSION = 7')) {
+ throw new GradleException('Global/world schema versions must remain 8/7')
}
def providerSchema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json'))
if (!(providerSchema.properties.schema_version.enum as List).containsAll([4, 5])) {
diff --git a/docs/API.md b/docs/API.md
index 95084448..accc84f0 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -118,6 +118,20 @@ pattern defaults to its registered pattern-type ID. Explicit channels keep an
ordinary vein budget independent from a region-scale deposit budget while the
material group may still share the same selectable outputs.
+Provider definitions are also the authoritative managed-output inventory. A
+disabled provider ore remains available as an output-only candidate, but it
+cannot own frequency, shape or placement. This is how a sibling mod can offer
+its sulfur block without multiplying sulfur generation. Ore Dictionary-only
+third-party blocks may be selected as outputs, but OreSpawn never claims to
+disable their independent generators.
+
+Global schema 8 and world-profile schema 7 persist `ore_material_groups` and
+the output mode chosen for each policy. These are profile configuration, not
+additional public Java descriptors. Existing provider and pattern binaries
+continue to use the same API-major-1 methods. A compiled custom pattern should
+pass its stable deposit identity to `tryPlace(x, y, z, outputIdentity)` so the
+selected material output remains constant throughout a body and across chunks.
+
Create one `BiomeRegistrar` during normal mod construction. It attaches to the
calling mod's event bus and defers biome factories until Forge's biome registry
event. `OreSpawnBiomes.copyAndRegister` clones a known biome without adding a
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 061e31d8..9801d284 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -4,8 +4,8 @@ OreSpawn uses three JSON contracts:
| File | Schema | Purpose |
|---|---:|---|
-| `config/orespawn-worldgen.json` | 7 | Installed-pack defaults for new worlds |
-| `/serverconfig/orespawn-worldgen.json` | 6 | Self-contained snapshot for one world |
+| `config/orespawn-worldgen.json` | 8 | Installed-pack defaults for new worlds |
+| `/serverconfig/orespawn-worldgen.json` | 7 | Self-contained snapshot for one world |
| `config/-orespawn.json` | 5 | Optional authoritative provider override |
A provider may package schema 5 at `assets//orespawn/provider.json`.
@@ -22,7 +22,7 @@ world. Restart after editing JSON by hand.
| Field | Values | Meaning |
|---|---|---|
-| `schema_version` | Contract-specific integer | Global 7, world 6, provider 5 |
+| `schema_version` | Contract-specific integer | Global 8, world 7, provider 5 |
| `geology_mode` | `geome`, `legacy` | Sky/geome engine or Cyano legacy engine |
| `place_fluid_deposits` | boolean | Master switch for configured fluid-deposit rules |
| `manage_vanilla_ores` | boolean | Lets OreSpawn suppress and replace claimed vanilla ore features |
@@ -37,6 +37,7 @@ world. Restart after editing JSON by hand.
| `biome_palettes` | object keyed by provider-owned rule ID | Optional native-biome overlays and surfaces |
| `dimension_materials` | object keyed by provider-owned rule ID | Aquifer fluid, snow, and ice substitutions |
| `ores` | object keyed by rule ID | Ore outputs and per-dimension placement |
+| `ore_material_groups` | object keyed by stable group ID | Friendly material names and exact Ore Dictionary aliases |
| `ore_source_policies` | object keyed by material and dimension/domain | Persisted output and placement arbitration choices |
| `fluid_deposits` | object keyed by rule ID | Provider-owned fluids and per-dimension placement |
| `retrogen` | object | Bounded ore retrogen controls |
@@ -219,16 +220,32 @@ Saltpeter remain distinct, and ambiguous entries are marked **Review required**
instead of being guessed. Registry, dictionary and policy work never runs in a
chunk-generation loop.
-Each `ore_source_policies` key combines the canonical material with an exact
-dimension or existing dimension-selector ID. A policy stores `mode` as
-`consolidated` or `keep_separate`, selected output rule IDs with positive
-weights, and one placement-source rule ID per placement channel. Consolidation
-runs the chosen placement rule's pattern, hosts, filters, height and frequency
-once; selected candidates contribute output bundles only. Output choice is
-stable for a whole ordinary vein, and custom patterns can supply a stable body
-identity so one deposit uses one source across chunk boundaries.
-
-New worlds consolidate only reviewed high-confidence MMD catalog conflicts.
+Each `ore_material_groups` entry has a persistent registry-style group ID, a
+`display_name`, and `ore_dictionary_entries`. Entries must be exact `oreX`
+names and may belong to only one group. Renaming a group does not change its
+ID. OreSpawn supplies Sulfur (`oreSulfur`, `oreSulphur`) and Aluminum
+(`oreAluminum`, `oreAluminium`) groups; Niter and Saltpeter deliberately remain
+separate. The UI can create and delete custom groups, confirm moving an alias
+from another group, or reset a curated group to its shipped defaults.
+
+Each `ore_source_policies` key combines the group ID with an exact dimension or
+existing dimension-selector ID. `mode` is `consolidated` or `keep_separate`.
+Consolidated policies also store `output_mode` as `balanced`, `single`, or
+`custom`, selected output rule IDs with positive weights, and one active
+placement-source rule per placement channel. Balanced uses equal weights,
+Single has exactly one output, and Custom accepts any non-empty positively
+weighted subset. The UI calls `keep_separate` **Keep Original**.
+
+Consolidation runs the chosen placement rule's pattern, hosts, filters, height
+and frequency once; selected candidates contribute output bundles only. An
+inactive managed provider may therefore supply an output block without adding
+a placement budget. Output choice is stable for a whole ordinary vein, and
+custom patterns can supply a stable body identity so one deposit uses one
+source across chunk boundaries. External Ore Dictionary members can be chosen
+as outputs, but their independent native generators remain uncontrolled.
+
+New worlds consolidate only reviewed high-confidence MMD catalog conflicts and
+start those groups in Balanced mode with all eligible outputs selected.
Upgraded worlds initialize discovered conflicts as `keep_separate`, preserving
their historical placement frequency and outputs until changed through **Ore
Sources...**. Policy edits affect newly generated chunks only and never cause
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
index 0e1cc101..c2ee05c7 100644
--- a/docs/MIGRATION.md
+++ b/docs/MIGRATION.md
@@ -117,7 +117,7 @@ replacement, retrogen, forced retrogen, flat bedrock, and bedrock thickness.
Unknown numeric dimensions and obsolete block states are reported instead of
guessed. Review `config/orespawn-migration/migration-report.txt` after import.
-Global schemas 1-6 and world schemas 1-5 are upgraded in memory and persisted
+Global schemas 1-7 and world schemas 1-6 are upgraded in memory and persisted
where safe. A schema-1 world held only mode/oil/formation choices and is
overlaid on the effective installed-pack profile. A schema-2 world is already
a full snapshot and preserves its geology. Existing terrain is not rewritten.
@@ -129,12 +129,15 @@ their terrain or biome output. Auto-selected templates are never applied to an
existing world profile during migration.
Provider schema 5 adds optional canonical ore `material` and per-rule
-`placement_channel` fields. Global schema 7 and world schema 6 persist
-`ore_source_policies`. A new world may consolidate only reviewed MMD conflicts;
-every discovered conflict in an upgraded world is initialized as
-`keep_separate`, preserving prior placement frequency, ordering and output
-behaviour. Missing selected sources remain recorded. Policy changes affect new
-chunks only and do not add retrogen.
+`placement_channel` fields. Global schema 8 and world schema 7 persist
+`ore_material_groups`, each policy's `output_mode`, and the existing
+`ore_source_policies`. Existing consolidated policies infer Single, Balanced,
+or Custom from their selected outputs and weights. A new world may consolidate
+only reviewed MMD conflicts and begins those groups Balanced; every discovered
+conflict in an upgraded world is initialized as `keep_separate` (shown as Keep
+Original), preserving prior placement frequency, ordering and output behaviour.
+Missing selected sources and dormant policies remain recorded. Policy changes
+affect new chunks only and do not add retrogen.
The old `place_crude_oil` and singleton `oil` fields migrate to
`place_fluid_deposits` and a provider-owned rule. A valid block becomes
diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md
index d0e7c9ee..ecbadff1 100644
--- a/docs/PLAYER_GUIDE.md
+++ b/docs/PLAYER_GUIDE.md
@@ -46,9 +46,14 @@ safely instead of breaking world creation.
region.
- **Manage Vanilla Ores** lets OreSpawn replace vanilla ore features with the
configured OreSpawn rules. Leave it off to keep normal Minecraft placement.
-- **Ore Sources** finds configured ores that represent the same material and
- lets you consolidate their placement without losing the installed block
- choices. It is available from the ORES tab beside **Add Block**.
+- **Ore Sources** opens the material-group editor beside **Add Block**. It
+ starts on **Needs Attention**; use **All Groups** for harmless single-source
+ materials. The left side edits the friendly group name and exact Ore
+ Dictionary aliases. The right side selects installed ore blocks: **Balanced**
+ shares them equally, **Single** uses one, **Custom** enables a weighted
+ subset, and **Keep Original** preserves every old placement rule. At narrow
+ resolutions the same editor uses separate list and selected-group pages.
+ Advanced placement-channel choices stay collapsed unless needed.
- **Mods** opens a paginated directory of loaded OreSpawn integrations. It
reports the Forge mod version, native OS4 or legacy OS1-OS3 lineage, provider
schema/revision and current status. Historical profile owners that are not
diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md
index fe7eedd3..46ee066c 100644
--- a/docs/PROVIDERS.md
+++ b/docs/PROVIDERS.md
@@ -120,14 +120,21 @@ unambiguous. NetherMetals and EndMetals stay in their respective dimension
domains. DenseMetals is enrichment, not an interchangeable output, and
BaseSciences has no ordinary ore-source role.
-The reviewed automatic priorities are Mineralogy, then BaseMinerals, then
-ElectricAdvantage for sulfur; and BaseMinerals, then ElectricAdvantage for
-lithium. No other conflict is automatically consolidated without an explicit
-catalog row. Exact Ore Dictionary `oreX` inference recognizes the curated
+Reviewed fresh-world conflicts start Balanced with all eligible output blocks.
+Mineralogy, then BaseMinerals, then ElectricAdvantage is the initial placement
+and Single-mode priority for sulfur; BaseMinerals, then ElectricAdvantage is the
+corresponding lithium priority. No other conflict is automatically consolidated
+without an explicit catalog row. Exact Ore Dictionary `oreX` inference recognizes the curated
sulfur/sulphur and aluminum/aluminium aliases but deliberately keeps Niter and
Saltpeter distinct. Multiple unrelated entries are marked for review rather
than fuzzy-matched from block names.
+Provider definitions that are disabled for placement remain managed
+output-only candidates. This permits a sibling mod's block to participate in a
+Balanced, Single, or Custom material group without contributing a second
+frequency budget. Duplicate rule or alias references to the same registry ID
+and metadata are displayed once.
+
Ore Dictionary membership does not prove that OreSpawn controls a mod's native
generator. Such candidates are displayed as external generation and are never
disabled. Providers should keep native generation enabled until takeover is
diff --git a/docs/examples/orespawn-global.json b/docs/examples/orespawn-global.json
index dd3740a5..a6b9f2d9 100644
--- a/docs/examples/orespawn-global.json
+++ b/docs/examples/orespawn-global.json
@@ -1,5 +1,5 @@
{
- "schema_version": 7,
+ "schema_version": 8,
"geology_mode": "geome",
"place_fluid_deposits": true,
"manage_vanilla_ores": false,
@@ -32,6 +32,16 @@
"biome_palettes": {},
"dimension_materials": {},
"ores": {},
+ "ore_material_groups": {
+ "orespawn:sulfur": {
+ "display_name": "Sulfur",
+ "ore_dictionary_entries": ["oreSulfur", "oreSulphur"]
+ },
+ "orespawn:aluminum": {
+ "display_name": "Aluminum",
+ "ore_dictionary_entries": ["oreAluminum", "oreAluminium"]
+ }
+ },
"ore_source_policies": {},
"fluid_deposits": {},
"retrogen": {
diff --git a/docs/examples/orespawn-world.json b/docs/examples/orespawn-world.json
index ca099c74..f23002ca 100644
--- a/docs/examples/orespawn-world.json
+++ b/docs/examples/orespawn-world.json
@@ -1,5 +1,5 @@
{
- "schema_version": 6,
+ "schema_version": 7,
"geology_mode": "geome",
"place_fluid_deposits": true,
"manage_vanilla_ores": false,
@@ -70,6 +70,16 @@
"biome_palettes": {},
"dimension_materials": {},
"ores": {},
+ "ore_material_groups": {
+ "orespawn:sulfur": {
+ "display_name": "Sulfur",
+ "ore_dictionary_entries": ["oreSulfur", "oreSulphur"]
+ },
+ "orespawn:aluminum": {
+ "display_name": "Aluminum",
+ "ore_dictionary_entries": ["oreAluminum", "oreAluminium"]
+ }
+ },
"ore_source_policies": {},
"fluid_deposits": {},
"retrogen": {
diff --git a/docs/schemas/orespawn-global.schema.json b/docs/schemas/orespawn-global.schema.json
index df82afa9..49022e13 100644
--- a/docs/schemas/orespawn-global.schema.json
+++ b/docs/schemas/orespawn-global.schema.json
@@ -6,7 +6,7 @@
{
"type": "object",
"required": ["schema_version"],
- "properties": { "schema_version": { "const": 7 } }
+ "properties": { "schema_version": { "const": 8 } }
}
]
}
diff --git a/docs/schemas/orespawn-world.schema.json b/docs/schemas/orespawn-world.schema.json
index aebd4bde..497cc4a0 100644
--- a/docs/schemas/orespawn-world.schema.json
+++ b/docs/schemas/orespawn-world.schema.json
@@ -6,7 +6,7 @@
{
"type": "object",
"required": ["schema_version"],
- "properties": { "schema_version": { "const": 6 } }
+ "properties": { "schema_version": { "const": 7 } }
}
]
}
diff --git a/docs/schemas/profile-common.schema.json b/docs/schemas/profile-common.schema.json
index 9e3827e9..9584dad2 100644
--- a/docs/schemas/profile-common.schema.json
+++ b/docs/schemas/profile-common.schema.json
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://mcmoddev.com/orespawn/schemas/profile-common.schema.json",
"type": "object",
- "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "ore_source_policies", "fluid_deposits"],
+ "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "ore_material_groups", "ore_source_policies", "fluid_deposits"],
"properties": {
"geology_mode": { "enum": ["geome", "legacy"] },
"place_fluid_deposits": { "type": "boolean" },
@@ -19,6 +19,7 @@
"biome_palettes": { "type": "object", "additionalProperties": { "$ref": "#/$defs/biomePalette" } },
"dimension_materials": { "type": "object", "additionalProperties": { "$ref": "#/$defs/dimensionMaterials" } },
"ores": { "type": "object", "additionalProperties": { "$ref": "#/$defs/ore" } },
+ "ore_material_groups": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "$ref": "#/$defs/oreMaterialGroup" } },
"ore_source_policies": { "type": "object", "additionalProperties": { "$ref": "#/$defs/oreSourcePolicy" } },
"fluid_deposits": { "type": "object", "additionalProperties": { "$ref": "#/$defs/fluidDeposit" } },
"worldgen_aliases": { "type": "object", "additionalProperties": { "$ref": "#/$defs/registryId" } },
@@ -246,24 +247,37 @@
"placement_channel": { "$ref": "#/$defs/registryId" },
"ore_dictionary": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } },
"loaded": { "type": "boolean" },
+ "placement_active": { "type": "boolean" },
"external": { "type": "boolean" },
"enrichment": { "type": "boolean" },
- "review_required": { "type": "boolean" }
+ "review_required": { "type": "boolean" },
+ "material_declared": { "type": "boolean" }
},
"additionalProperties": false
},
"oreSourcePolicy": {
"type": "object",
- "required": ["material", "domain", "mode", "outputs", "placement_sources", "candidates"],
+ "required": ["material", "domain", "mode", "output_mode", "outputs", "placement_sources", "candidates"],
"properties": {
"material": { "$ref": "#/$defs/registryId" },
"domain": { "$ref": "#/$defs/registryId" },
"mode": { "enum": ["consolidated", "keep_separate"] },
+ "output_mode": { "enum": ["balanced", "single", "custom"] },
"outputs": { "type": "object", "additionalProperties": { "type": "number", "exclusiveMinimum": 0 } },
"placement_sources": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "type": "string", "minLength": 1 } },
"candidates": { "type": "array", "items": { "$ref": "#/$defs/oreSourceCandidate" } },
"review_required": { "type": "boolean" },
- "status": { "enum": ["consolidated", "separate", "review_required", "missing_source", "external_generation"] }
+ "status": { "enum": ["consolidated", "separate", "review_required", "missing_source", "external_generation"] },
+ "dormant": { "type": "boolean" }
+ },
+ "additionalProperties": false
+ },
+ "oreMaterialGroup": {
+ "type": "object",
+ "required": ["display_name", "ore_dictionary_entries"],
+ "properties": {
+ "display_name": { "type": "string", "minLength": 1, "maxLength": 64 },
+ "ore_dictionary_entries": { "type": "array", "uniqueItems": true, "items": { "type": "string", "pattern": "^ore[A-Z][A-Za-z0-9_]*$", "maxLength": 128 } }
},
"additionalProperties": false
},
diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
index d91b5695..513d6585 100644
--- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
+++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/client/ClientProbeTestMod.java
@@ -3,6 +3,7 @@
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
@@ -51,6 +52,8 @@ public final class ClientProbeTestMod {
private boolean modsDirectoryRendered;
private boolean directoryStackValidated =
System.getProperty("clientprobe.expectedMods", "").trim().isEmpty();
+ private boolean oreSourcesValidated =
+ System.getProperty("clientprobe.expectedMods", "").trim().isEmpty();
private boolean longEditorRoundTrip;
@Mod.EventHandler
@@ -236,9 +239,62 @@ private void validateExpectedDirectory(GuiScreen root) {
}
}
}
+ if (expected.contains("mineralogy") && expected.contains("electricadvantage")) {
+ validateExpectedOreSources((OreSpawnWorldSettingsScreen) root);
+ }
directoryStackValidated = true;
}
+ private void validateExpectedOreSources(OreSpawnWorldSettingsScreen root) {
+ GeologyEditorSession.OreSourceGroup sulfur = null;
+ for (GeologyEditorSession.OreSourceGroup group : editorSession(root).oreSourceGroups()) {
+ if ("orespawn:sulfur".equals(group.material)
+ && "minecraft:overworld".equals(group.domain)) sulfur = group;
+ }
+ if (sulfur == null) throw new IllegalStateException("Missing curated Sulfur material group");
+ if (!"Sulfur".equals(sulfur.displayName)
+ || !sulfur.oreDictionaryEntries.contains("oreSulfur")
+ || !sulfur.oreDictionaryEntries.contains("oreSulphur")) {
+ throw new IllegalStateException("Sulfur aliases were not grouped correctly: "
+ + sulfur.oreDictionaryEntries);
+ }
+ if (!"consolidated".equals(sulfur.mode) || !"balanced".equals(sulfur.outputMode)) {
+ throw new IllegalStateException("Fresh Sulfur policy was not Balanced: mode="
+ + sulfur.mode + ", outputMode=" + sulfur.outputMode);
+ }
+ boolean mineralogy = false;
+ boolean electricOutputOnly = false;
+ for (GeologyEditorSession.OreSourceCandidate candidate : sulfur.outputCandidates()) {
+ if ("mineralogy:sulfur_ore".equals(candidate.registryId)) {
+ mineralogy = candidate.loaded && candidate.active && !candidate.external;
+ }
+ if ("electricadvantage:sulfur_ore".equals(candidate.registryId)) {
+ electricOutputOnly = candidate.loaded && !candidate.active && !candidate.external;
+ }
+ if (("mineralogy:sulfur_ore".equals(candidate.registryId)
+ || "electricadvantage:sulfur_ore".equals(candidate.registryId))
+ && !sulfur.outputs.containsKey(candidate.sourceId)) {
+ throw new IllegalStateException("Balanced Sulfur output is not selected: "
+ + candidate.sourceId);
+ }
+ }
+ if (!mineralogy || !electricOutputOnly) {
+ throw new IllegalStateException("Sulfur outputs were not classified correctly: mineralogy="
+ + mineralogy + ", electricOutputOnly=" + electricOutputOnly);
+ }
+ oreSourcesValidated = true;
+ }
+
+ private static GeologyEditorSession editorSession(OreSpawnWorldSettingsScreen root) {
+ try {
+ Field field = OreSpawnWorldSettingsScreen.class.getDeclaredField("session");
+ field.setAccessible(true);
+ return (GeologyEditorSession) field.get(root);
+ } catch (ReflectiveOperationException failure) {
+ throw new IllegalStateException("Could not inspect the active Ore Sources editor session", failure);
+ }
+ }
+
private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) {
for (GuiButton widget : root.buttons) {
if (!(widget instanceof Button) || widget instanceof CycleButton) continue;
@@ -393,6 +449,7 @@ private void writeMarker() throws IOException {
values.setProperty("world_settings_opened", Boolean.toString(worldSettingsOpened));
values.setProperty("mods_directory_rendered", Boolean.toString(modsDirectoryRendered));
values.setProperty("directory_stack_validated", Boolean.toString(directoryStackValidated));
+ values.setProperty("ore_sources_validated", Boolean.toString(oreSourcesValidated));
values.setProperty("long_editor_roundtrip", Boolean.toString(longEditorRoundTrip));
values.setProperty("editor_routes", Integer.toString(editorRoutes.size()));
values.setProperty("editor_classes", editorRoutes.toString());
diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java
index c7cd89fb..b0d8bf62 100644
--- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java
+++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java
@@ -5,7 +5,9 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
@@ -353,6 +355,13 @@ List oreSourceGroups() {
String material = string(policy, "material", "");
String domain = string(policy, "domain", "");
if (!validResource(material) || !validResource(domain)) continue;
+ JsonObject groupDefinitions = section("ore_material_groups");
+ JsonObject groupDefinition = groupDefinitions.has(material)
+ && groupDefinitions.get(material).isJsonObject()
+ ? groupDefinitions.getAsJsonObject(material) : new JsonObject();
+ String displayName = string(groupDefinition, "display_name", displayName(material));
+ List groupDictionary = stringList(groupDefinition, "ore_dictionary_entries");
+ boolean curated = "orespawn:sulfur".equals(material) || "orespawn:aluminum".equals(material);
List candidates = new ArrayList<>();
if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
for (JsonElement element : policy.getAsJsonArray("candidates")) {
@@ -373,25 +382,219 @@ List oreSourceGroups() {
string(candidate, "owner", ""), string(candidate, "owner_name", ""),
string(candidate, "owner_version", ""), registryId,
integer(candidate, "metadata", 0), channel, dictionary,
- bool(candidate, "loaded", false), bool(candidate, "external", false),
- bool(candidate, "enrichment", false)));
+ bool(candidate, "loaded", false), bool(candidate, "placement_active", true),
+ bool(candidate, "external", false),
+ bool(candidate, "enrichment", false),
+ bool(candidate, "review_required", false)));
}
}
Collections.sort(candidates, (left, right) -> {
int owner = left.owner.compareTo(right.owner);
return owner != 0 ? owner : left.sourceId.compareTo(right.sourceId);
});
- result.add(new OreSourceGroup(entry.getKey(), material, domain,
- string(policy, "mode", "keep_separate"), string(policy, "status", "review_required"),
- candidates, decimalMap(policy, "outputs"), stringMap(policy, "placement_sources")));
+ OreSourceGroup group = new OreSourceGroup(entry.getKey(), material, domain,
+ displayName, groupDictionary, curated,
+ string(policy, "mode", "keep_separate"), outputMode(policy),
+ string(policy, "status", "review_required"),
+ candidates, decimalMap(policy, "outputs"), stringMap(policy, "placement_sources"));
+ if (!bool(policy, "dormant", false)) result.add(group);
}
Collections.sort(result, (left, right) -> {
+ int name = left.displayName.compareToIgnoreCase(right.displayName);
+ if (name != 0) return name;
int material = left.material.compareTo(right.material);
return material != 0 ? material : left.domain.compareTo(right.domain);
});
return Collections.unmodifiableList(result);
}
+ String addOreMaterialGroup() {
+ JsonObject groups = section("ore_material_groups");
+ JsonObject policies = section("ore_source_policies");
+ String base = "orespawn:custom/custom_group";
+ String id = base;
+ for (int suffix = 2; groups.has(id)
+ || policies.has(id + "|minecraft:overworld"); suffix++) id = base + '_' + suffix;
+ JsonObject definition = new JsonObject();
+ definition.addProperty("display_name", "Custom Group");
+ definition.add("ore_dictionary_entries", new JsonArray());
+ groups.add(id, definition);
+ String key = id + "|minecraft:overworld";
+ JsonObject policy = objectEntry(policies, key);
+ policy.remove("dormant");
+ policy.addProperty("material", id);
+ policy.addProperty("domain", "minecraft:overworld");
+ policy.addProperty("mode", "keep_separate");
+ policy.addProperty("output_mode", "custom");
+ policy.addProperty("status", "separate");
+ policy.addProperty("review_required", false);
+ policy.add("outputs", new JsonObject());
+ policy.add("placement_sources", new JsonObject());
+ policy.add("candidates", new JsonArray());
+ return key;
+ }
+
+ void renameOreMaterialGroup(String material, String displayName) {
+ if (!validResource(material)) return;
+ String value = displayName == null ? "" : displayName.trim();
+ if (value.isEmpty()) return;
+ objectEntry(section("ore_material_groups"), material)
+ .addProperty("display_name", value.length() > 64 ? value.substring(0, 64) : value);
+ }
+
+ String oreDictionaryOwner(String alias) {
+ for (Entry entry : section("ore_material_groups").entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ if (stringList(entry.getValue().getAsJsonObject(), "ore_dictionary_entries").contains(alias)) {
+ return entry.getKey();
+ }
+ }
+ return null;
+ }
+
+ boolean addOreMaterialAlias(String material, String alias, boolean moveExisting) {
+ if (!validResource(material) || !validOreDictionaryName(alias)) return false;
+ String owner = oreDictionaryOwner(alias);
+ if (owner != null && !owner.equals(material)) {
+ if (!moveExisting) return false;
+ removeOreMaterialAlias(owner, alias);
+ }
+ JsonObject definition = objectEntry(section("ore_material_groups"), material);
+ List names = stringList(definition, "ore_dictionary_entries");
+ if (!names.contains(alias)) names.add(alias);
+ Collections.sort(names);
+ definition.add("ore_dictionary_entries", strings(names));
+ rebuildOreSourcePolicies();
+ return true;
+ }
+
+ void removeOreMaterialAlias(String material, String alias) {
+ if (!validResource(material)) return;
+ JsonObject definition = objectEntry(section("ore_material_groups"), material);
+ List names = stringList(definition, "ore_dictionary_entries");
+ if (names.remove(alias)) {
+ definition.add("ore_dictionary_entries", strings(names));
+ rebuildOreSourcePolicies();
+ }
+ }
+
+ void resetOreMaterialGroup(String material) {
+ if ("orespawn:sulfur".equals(material)) {
+ setOreMaterialGroup(material, "Sulfur", Arrays.asList("oreSulfur", "oreSulphur"));
+ } else if ("orespawn:aluminum".equals(material)) {
+ setOreMaterialGroup(material, "Aluminum", Arrays.asList("oreAluminum", "oreAluminium"));
+ }
+ rebuildOreSourcePolicies();
+ }
+
+ void deleteOreMaterialGroup(String material) {
+ if (!validResource(material) || "orespawn:sulfur".equals(material)
+ || "orespawn:aluminum".equals(material)) return;
+ section("ore_material_groups").remove(material);
+ for (Entry entry : section("ore_source_policies").entrySet()) {
+ if (entry.getValue().isJsonObject()
+ && material.equals(string(entry.getValue().getAsJsonObject(), "material", ""))) {
+ entry.getValue().getAsJsonObject().addProperty("dormant", true);
+ }
+ }
+ rebuildOreSourcePolicies();
+ }
+
+ boolean oreMaterialGroupsChanged() {
+ return !sectionCopy(original, "ore_material_groups").equals(sectionCopy(root, "ore_material_groups"));
+ }
+
+ JsonObject oreMaterialGroupsCopy() {
+ return sectionCopy(root, "ore_material_groups");
+ }
+
+ private void setOreMaterialGroup(String material, String displayName, List aliases) {
+ JsonObject definition = objectEntry(section("ore_material_groups"), material);
+ definition.addProperty("display_name", displayName);
+ definition.add("ore_dictionary_entries", strings(aliases));
+ }
+
+ private void rebuildOreSourcePolicies() {
+ JsonObject policies = section("ore_source_policies");
+ Map candidates = new LinkedHashMap<>();
+ for (Entry entry : policies.entrySet()) {
+ if (!entry.getValue().isJsonObject()) continue;
+ JsonObject policy = entry.getValue().getAsJsonObject();
+ if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) continue;
+ boolean hasCandidates = policy.getAsJsonArray("candidates").size() > 0;
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = JsonCopies.copy(element.getAsJsonObject());
+ String identity = string(candidate, "source_id", "") + '|'
+ + string(candidate, "placement_channel", "orespawn:standard") + '|'
+ + string(candidate, "domain", string(policy, "domain", ""));
+ candidates.put(identity, candidate);
+ }
+ String material = string(policy, "material", "");
+ if (!hasCandidates && section("ore_material_groups").has(material)) {
+ policy.remove("dormant");
+ } else {
+ policy.addProperty("dormant", true);
+ }
+ }
+ Map> regrouped = new LinkedHashMap<>();
+ for (JsonObject candidate : candidates.values()) {
+ String material = string(candidate, "material", "");
+ if (!bool(candidate, "material_declared", false)) {
+ material = materialForAliases(stringList(candidate, "ore_dictionary"));
+ if (!material.isEmpty()) candidate.addProperty("material", material);
+ }
+ String domain = string(candidate, "domain", "minecraft:overworld");
+ if (!validResource(material) || !validResource(domain)) continue;
+ regrouped.computeIfAbsent(material + '|' + domain, ignored -> new ArrayList<>()).add(candidate);
+ }
+ for (Entry> entry : regrouped.entrySet()) {
+ int split = entry.getKey().indexOf('|');
+ String material = entry.getKey().substring(0, split);
+ String domain = entry.getKey().substring(split + 1);
+ JsonObject policy = policies.has(entry.getKey()) && policies.get(entry.getKey()).isJsonObject()
+ ? policies.getAsJsonObject(entry.getKey()) : new JsonObject();
+ policy.remove("dormant");
+ policy.addProperty("material", material);
+ policy.addProperty("domain", domain);
+ if (!policy.has("mode")) policy.addProperty("mode", "keep_separate");
+ if (!policy.has("output_mode")) policy.addProperty("output_mode", "custom");
+ JsonArray array = new JsonArray();
+ for (JsonObject candidate : entry.getValue()) array.add(candidate);
+ policy.add("candidates", array);
+ JsonObject outputs = objectEntry(policy, "outputs");
+ if (outputs.entrySet().isEmpty()) for (JsonObject candidate : outputCandidateJson(policy)) {
+ if (!bool(candidate, "external", false)) {
+ outputs.addProperty(string(candidate, "source_id", ""), 1.0D);
+ }
+ }
+ JsonObject placements = objectEntry(policy, "placement_sources");
+ for (JsonObject candidate : entry.getValue()) {
+ if (bool(candidate, "placement_active", false) && !bool(candidate, "external", false)) {
+ String channel = string(candidate, "placement_channel", "orespawn:standard");
+ if (!placements.has(channel)) placements.addProperty(channel,
+ string(candidate, "source_id", ""));
+ }
+ }
+ policy.addProperty("review_required", outputCandidateJson(policy).size() > 1);
+ refreshOreSourceStatus(policy);
+ policies.add(entry.getKey(), policy);
+ }
+ }
+
+ private String materialForAliases(List aliases) {
+ Set materials = new LinkedHashSet<>();
+ for (String alias : aliases) {
+ String owner = oreDictionaryOwner(alias);
+ if (owner != null) materials.add(owner);
+ else if (validOreDictionaryName(alias)) {
+ materials.add("orespawn:" + alias.substring(3).toLowerCase(Locale.ROOT));
+ }
+ }
+ return materials.size() == 1 ? materials.iterator().next()
+ : materials.size() > 1 ? "orespawn:review_required" : "";
+ }
+
void setOreSourceMode(String key, boolean consolidated) {
JsonObject policy = objectEntry(section("ore_source_policies"), key);
policy.addProperty("mode", consolidated ? "consolidated" : "keep_separate");
@@ -399,6 +602,47 @@ void setOreSourceMode(String key, boolean consolidated) {
refreshOreSourceStatus(policy);
}
+ void setOreSourceOutputMode(String key, String outputMode) {
+ if (!"balanced".equals(outputMode) && !"single".equals(outputMode)
+ && !"custom".equals(outputMode)) return;
+ JsonObject policy = objectEntry(section("ore_source_policies"), key);
+ policy.addProperty("mode", "consolidated");
+ policy.addProperty("output_mode", outputMode);
+ JsonObject outputs = objectEntry(policy, "outputs");
+ if ("balanced".equals(outputMode)) {
+ if (outputs.entrySet().isEmpty()) {
+ for (JsonObject candidate : outputCandidateJson(policy)) {
+ outputs.addProperty(string(candidate, "source_id", ""), 1.0D);
+ }
+ } else {
+ for (String sourceId : new ArrayList<>(JsonCopies.keys(outputs))) {
+ outputs.addProperty(sourceId, 1.0D);
+ }
+ }
+ } else if ("single".equals(outputMode)) {
+ String selected = preferredSingleOutput(policy, outputs);
+ if (!loadedOutput(policy, selected)) {
+ List candidates = outputCandidateJson(policy);
+ selected = candidates.isEmpty() ? "" : string(candidates.get(0), "source_id", "");
+ }
+ outputs.entrySet().clear();
+ if (!selected.isEmpty()) outputs.addProperty(selected, 1.0D);
+ }
+ policy.addProperty("review_required", false);
+ refreshOreSourceStatus(policy);
+ }
+
+ private static String preferredSingleOutput(JsonObject policy, JsonObject outputs) {
+ JsonObject placements = objectEntry(policy, "placement_sources");
+ for (Entry entry : placements.entrySet()) {
+ try {
+ String sourceId = entry.getValue().getAsString();
+ if (outputs.has(sourceId) && loadedOutput(policy, sourceId)) return sourceId;
+ } catch (RuntimeException ignored) { }
+ }
+ return outputs.entrySet().isEmpty() ? "" : outputs.entrySet().iterator().next().getKey();
+ }
+
void setOreSourcePlacement(String key, String channel, String sourceId) {
if (!validResource(channel)) return;
JsonObject policy = objectEntry(section("ore_source_policies"), key);
@@ -409,20 +653,56 @@ void setOreSourcePlacement(String key, String channel, String sourceId) {
void setOreSourceOutput(String key, String sourceId, boolean enabled, double weight) {
JsonObject policy = objectEntry(section("ore_source_policies"), key);
JsonObject outputs = objectEntry(policy, "outputs");
+ String outputMode = outputMode(policy);
if (!enabled) outputs.remove(sourceId);
- else outputs.addProperty(sourceId, Math.max(0.001D, Math.min(1000000.0D, weight)));
+ else if ("single".equals(outputMode)) {
+ outputs.entrySet().clear();
+ outputs.addProperty(sourceId, 1.0D);
+ } else outputs.addProperty(sourceId, "balanced".equals(outputMode) ? 1.0D
+ : Math.max(0.001D, Math.min(1000000.0D, weight)));
refreshOreSourceStatus(policy);
}
+ private static List outputCandidateJson(JsonObject policy) {
+ Map unique = new LinkedHashMap<>();
+ if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
+ for (JsonElement element : policy.getAsJsonArray("candidates")) {
+ if (!element.isJsonObject()) continue;
+ JsonObject candidate = element.getAsJsonObject();
+ if (!bool(candidate, "loaded", false) || bool(candidate, "enrichment", false)) continue;
+ String identity = string(candidate, "registry_id", "") + '|'
+ + integer(candidate, "metadata", 0);
+ JsonObject previous = unique.get(identity);
+ if (previous == null || (!bool(previous, "placement_active", false)
+ && bool(candidate, "placement_active", false))
+ || (bool(previous, "external", false) && !bool(candidate, "external", false))) {
+ unique.put(identity, candidate);
+ }
+ }
+ }
+ return new ArrayList<>(unique.values());
+ }
+
+ private static boolean loadedOutput(JsonObject policy, String sourceId) {
+ if (sourceId == null || sourceId.isEmpty()) return false;
+ for (JsonObject candidate : outputCandidateJson(policy)) {
+ if (sourceId.equals(string(candidate, "source_id", ""))) return true;
+ }
+ return false;
+ }
+
private static void refreshOreSourceStatus(JsonObject policy) {
Set missing = new HashSet<>();
+ Set inactivePlacements = new HashSet<>();
boolean external = false;
boolean review = bool(policy, "review_required", false);
if (policy.has("candidates") && policy.get("candidates").isJsonArray()) {
for (JsonElement element : policy.getAsJsonArray("candidates")) {
if (!element.isJsonObject()) continue;
JsonObject candidate = element.getAsJsonObject();
- if (!bool(candidate, "loaded", false)) missing.add(string(candidate, "source_id", ""));
+ String sourceId = string(candidate, "source_id", "");
+ if (!bool(candidate, "loaded", false)) missing.add(sourceId);
+ if (!bool(candidate, "placement_active", false)) inactivePlacements.add(sourceId);
external |= bool(candidate, "external", false);
}
}
@@ -437,7 +717,9 @@ private static void refreshOreSourceStatus(JsonObject policy) {
if (missing.contains(id)) { policy.addProperty("status", "missing_source"); return; }
}
for (String id : placements.values()) {
- if (missing.contains(id)) { policy.addProperty("status", "missing_source"); return; }
+ if (missing.contains(id) || inactivePlacements.contains(id)) {
+ policy.addProperty("status", "missing_source"); return;
+ }
}
policy.addProperty("status", external ? "external_generation" : review ? "review_required"
: "consolidated".equals(string(policy, "mode", "keep_separate"))
@@ -470,19 +752,28 @@ static final class OreSourceGroup {
final String key;
final String material;
final String domain;
+ final String displayName;
+ final List oreDictionaryEntries;
+ final boolean curated;
final String mode;
+ final String outputMode;
final String status;
final List candidates;
final Map outputs;
final Map placements;
- OreSourceGroup(String key, String material, String domain, String mode, String status,
+ OreSourceGroup(String key, String material, String domain, String displayName,
+ List oreDictionaryEntries, boolean curated, String mode, String outputMode, String status,
List candidates, Map outputs,
Map placements) {
this.key = key;
this.material = material;
this.domain = domain;
+ this.displayName = displayName;
+ this.oreDictionaryEntries = Collections.unmodifiableList(new ArrayList<>(oreDictionaryEntries));
+ this.curated = curated;
this.mode = mode;
+ this.outputMode = outputMode;
this.status = status;
this.candidates = Collections.unmodifiableList(new ArrayList<>(candidates));
this.outputs = outputs;
@@ -491,9 +782,44 @@ static final class OreSourceGroup {
List channels() {
Set result = new TreeSet<>();
- for (OreSourceCandidate candidate : candidates) if (!candidate.external) result.add(candidate.channel);
+ for (OreSourceCandidate candidate : candidates) {
+ if (!candidate.external && candidate.active) result.add(candidate.channel);
+ }
return Collections.unmodifiableList(new ArrayList<>(result));
}
+
+ List outputCandidates() {
+ Map unique = new LinkedHashMap<>();
+ for (OreSourceCandidate candidate : candidates) {
+ if (candidate.enrichment) continue;
+ String identity = candidate.registryId + '|' + candidate.metadata;
+ OreSourceCandidate previous = unique.get(identity);
+ if (previous == null || (!previous.active && candidate.active)
+ || (previous.external && !candidate.external)) unique.put(identity, candidate);
+ }
+ return Collections.unmodifiableList(new ArrayList<>(unique.values()));
+ }
+
+ boolean needsAttention() {
+ return "review_required".equals(status) || "missing_source".equals(status)
+ || "external_generation".equals(status) || outputCandidates().size() > 1;
+ }
+
+ boolean isRedundantSeparatePolicy() {
+ if (!"keep_separate".equals(mode) || candidates.isEmpty()) return false;
+ Set