mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-22 16:17:44 +00:00
Merged generation history
- Immutable generation epochs now coexist with dimension stacks - Historical terrain, biomes, structures, and locate behavior remain stable across updates - Versioned generation kernels preserve the combined runtime contract
This commit is contained in:
commit
56b5f4dd0b
263 files changed
+39624
-1849
No files matched your search
@@ -9,8 +9,14 @@ import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.engine.history.GenerationEpoch;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContract;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContract.PhysicalResourceKey;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContractFactory;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.engine.object.IrisCustomBiomeAliasResolver;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
@@ -40,13 +46,14 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class IrisDatapackCompiler {
|
||||
private static final int INPUT_FINGERPRINT_SCHEMA = 2;
|
||||
private static final int INPUT_FINGERPRINT_SCHEMA = 3;
|
||||
private static final int INPUT_BUFFER_BYTES = 64 * 1024;
|
||||
private static final int WORLD_PACK_SCAN_DEPTH = 8;
|
||||
private static final Pattern REGISTRY_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
|
||||
@@ -90,6 +97,7 @@ public final class IrisDatapackCompiler {
|
||||
) throws IOException {
|
||||
LinkedHashMap<Path, File> roots = new LinkedHashMap<>();
|
||||
collectInstalledPackRoots(dataDirectory.resolve("packs"), roots, validateWholePack);
|
||||
collectCanonicalWorldPackRoot(serverRoot, roots, validateWholePack);
|
||||
collectWorldPackRoots(serverRoot.resolve("dimensions"), roots, validateWholePack);
|
||||
return new ArrayList<>(roots.values());
|
||||
}
|
||||
@@ -179,14 +187,18 @@ public final class IrisDatapackCompiler {
|
||||
Objects.requireNonNull(packRoots, "packRoots");
|
||||
Objects.requireNonNull(fixer, "fixer");
|
||||
LinkedHashMap<String, String> requirements = new LinkedHashMap<>();
|
||||
Set<String> installedBiomeKeys = new LinkedHashSet<>();
|
||||
Map<String, Set<String>> biomeTags = new TreeMap<>();
|
||||
for (File packRoot : packRoots) {
|
||||
PackDirectoryResolver.requireSafePackTree(packRoot);
|
||||
if (!hasDimensions(packRoot.toPath())) {
|
||||
continue;
|
||||
}
|
||||
BiomeAliasContext aliasContext = biomeAliasContext(packRoot.toPath());
|
||||
IrisData data = IrisData.openDatapackCompiler(packRoot);
|
||||
try {
|
||||
if (aliasContext.retainedContract() != null) {
|
||||
data.bindGenerationRegistryContract(aliasContext.retainedContract());
|
||||
}
|
||||
ResourceLoader<IrisDimension> loader = data.getDimensionLoader();
|
||||
String[] possibleKeys = loader.getPossibleKeys();
|
||||
if (possibleKeys == null || possibleKeys.length == 0) {
|
||||
@@ -199,14 +211,16 @@ public final class IrisDatapackCompiler {
|
||||
}
|
||||
collectRegistryRequirements(
|
||||
requirements,
|
||||
installedBiomeKeys,
|
||||
biomeTags,
|
||||
dimension,
|
||||
fixer);
|
||||
fixer,
|
||||
aliasContext);
|
||||
}
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
addBiomeTagRequirements(requirements, biomeTags);
|
||||
return Map.copyOf(requirements);
|
||||
}
|
||||
|
||||
@@ -216,23 +230,35 @@ public final class IrisDatapackCompiler {
|
||||
) throws IOException {
|
||||
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
||||
IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer");
|
||||
IrisData data = Objects.requireNonNull(
|
||||
requiredDimension.getLoader(),
|
||||
"Iris dimension loader"
|
||||
);
|
||||
LinkedHashMap<String, String> requirements = new LinkedHashMap<>();
|
||||
Map<String, Set<String>> biomeTags = new TreeMap<>();
|
||||
BiomeAliasContext aliasContext = BiomeAliasContext.contentAddressedOnly();
|
||||
collectRegistryRequirements(
|
||||
requirements,
|
||||
new LinkedHashSet<>(),
|
||||
biomeTags,
|
||||
requiredDimension,
|
||||
requiredFixer);
|
||||
requiredFixer,
|
||||
aliasContext);
|
||||
collectDimensionStackRegistryRequirements(
|
||||
requirements,
|
||||
biomeTags,
|
||||
requiredDimension,
|
||||
requiredFixer);
|
||||
requiredFixer,
|
||||
aliasContext);
|
||||
addBiomeTagRequirements(requirements, biomeTags);
|
||||
return Map.copyOf(requirements);
|
||||
}
|
||||
|
||||
private static void collectDimensionStackRegistryRequirements(
|
||||
Map<String, String> requirements,
|
||||
Map<String, Set<String>> biomeTags,
|
||||
IrisDimension hostDimension,
|
||||
IDataFixer fixer
|
||||
IDataFixer fixer,
|
||||
BiomeAliasContext aliasContext
|
||||
) throws IOException {
|
||||
if (!hostDimension.hasDimensionStack()) {
|
||||
return;
|
||||
@@ -255,21 +281,31 @@ public final class IrisDatapackCompiler {
|
||||
}
|
||||
collectRegistryRequirements(
|
||||
requirements,
|
||||
new LinkedHashSet<>(),
|
||||
biomeTags,
|
||||
sourceDimension,
|
||||
fixer);
|
||||
fixer,
|
||||
aliasContext);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectRegistryRequirements(
|
||||
Map<String, String> requirements,
|
||||
Set<String> installedBiomeKeys,
|
||||
Map<String, Set<String>> biomeTags,
|
||||
IrisDimension dimension,
|
||||
IDataFixer fixer
|
||||
) {
|
||||
requirements.put(
|
||||
"dimension_type/iris:" + dimension.getDimensionTypeKey(),
|
||||
fingerprintContent(dimension.getDimensionType().toJson(fixer)));
|
||||
IDataFixer fixer,
|
||||
BiomeAliasContext aliasContext
|
||||
) throws IOException {
|
||||
String dimensionTypeResourceKey = "iris:" + dimension.getDimensionTypeKey();
|
||||
String dimensionTypeSource = aliasContext.generatedSource(
|
||||
GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY,
|
||||
dimensionTypeResourceKey,
|
||||
dimension.getDimensionType().toJson(fixer),
|
||||
fixer
|
||||
);
|
||||
putRegistryRequirement(
|
||||
requirements,
|
||||
"dimension_type/" + dimensionTypeResourceKey,
|
||||
fingerprintContent(dimensionTypeSource));
|
||||
|
||||
ContentGate contentGate = dimension.getLoader() == null ? null : dimension.getLoader().getContentGate();
|
||||
for (IrisBiome biome : dimension.getAllBiomes(dimension::getLoader)) {
|
||||
@@ -281,13 +317,25 @@ public final class IrisDatapackCompiler {
|
||||
if (customBiome == null) {
|
||||
continue;
|
||||
}
|
||||
String biomeKey = dimension.getCustomBiomeKey(customBiome.getId());
|
||||
if (!installedBiomeKeys.add(biomeKey)) {
|
||||
continue;
|
||||
String json = customBiome.generateJson(fixer, contentGate);
|
||||
String currentPhysicalKey = GenerationRegistryContractFactory.customBiomeResourceKey(
|
||||
dimension.getLoadKey(),
|
||||
customBiome,
|
||||
contentGate
|
||||
);
|
||||
String physicalKey = aliasContext.physicalResourceKey(
|
||||
dimension,
|
||||
biome,
|
||||
customBiome,
|
||||
currentPhysicalKey
|
||||
);
|
||||
LinkedHashSet<String> resourceKeys = new LinkedHashSet<>();
|
||||
resourceKeys.add(physicalKey);
|
||||
Collection<String> aliases = aliasContext.aliases(dimension, biome, customBiome, physicalKey);
|
||||
if (!aliases.isEmpty() && physicalKey.equals(dimension.getLoader()
|
||||
.customBiomeResourceKey(dimension, customBiome.getId()))) {
|
||||
resourceKeys.addAll(aliases);
|
||||
}
|
||||
requirements.put(
|
||||
"worldgen/biome/" + biomeKey,
|
||||
fingerprintContent(customBiome.generateJson(fixer, contentGate)));
|
||||
TreeSet<String> tags = new TreeSet<>();
|
||||
for (String tag : customBiome.getEffectiveTags(derivativeKey)) {
|
||||
String normalizedTag = normalizeRegistryKey(tag);
|
||||
@@ -295,13 +343,48 @@ public final class IrisDatapackCompiler {
|
||||
tags.add(normalizedTag);
|
||||
}
|
||||
}
|
||||
requirements.put(
|
||||
"worldgen/biome_tags/" + biomeKey,
|
||||
fingerprintContent(String.join("\n", tags)));
|
||||
for (String resourceKey : resourceKeys) {
|
||||
String emittedJson = aliasContext.generatedSource(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
resourceKey,
|
||||
json,
|
||||
fixer
|
||||
);
|
||||
putRegistryRequirement(
|
||||
requirements,
|
||||
"worldgen/biome/" + resourceKey,
|
||||
fingerprintContent(emittedJson)
|
||||
);
|
||||
biomeTags.computeIfAbsent(resourceKey, ignored -> new TreeSet<>()).addAll(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addBiomeTagRequirements(
|
||||
Map<String, String> requirements,
|
||||
Map<String, Set<String>> biomeTags
|
||||
) throws IOException {
|
||||
for (Map.Entry<String, Set<String>> entry : biomeTags.entrySet()) {
|
||||
putRegistryRequirement(
|
||||
requirements,
|
||||
"worldgen/biome_tags/" + entry.getKey(),
|
||||
fingerprintContent(String.join("\n", entry.getValue()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static void putRegistryRequirement(
|
||||
Map<String, String> requirements,
|
||||
String resourceKey,
|
||||
String contentFingerprint
|
||||
) throws IOException {
|
||||
String previous = requirements.putIfAbsent(resourceKey, contentFingerprint);
|
||||
if (previous != null && !previous.equals(contentFingerprint)) {
|
||||
throw new IOException("Conflicting Iris datapack registry resource '" + resourceKey + "'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeRegistryKey(String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
return null;
|
||||
@@ -359,8 +442,12 @@ public final class IrisDatapackCompiler {
|
||||
if (!hasDimensions(packRoot.toPath())) {
|
||||
continue;
|
||||
}
|
||||
BiomeAliasContext aliasContext = biomeAliasContext(packRoot.toPath());
|
||||
IrisData data = IrisData.openDatapackCompiler(packRoot);
|
||||
try {
|
||||
if (aliasContext.retainedContract() != null) {
|
||||
data.bindGenerationRegistryContract(aliasContext.retainedContract());
|
||||
}
|
||||
ResourceLoader<IrisDimension> loader = data.getDimensionLoader();
|
||||
String[] possibleKeys = loader.getPossibleKeys();
|
||||
if (possibleKeys == null || possibleKeys.length == 0) {
|
||||
@@ -379,11 +466,22 @@ public final class IrisDatapackCompiler {
|
||||
.add(new DimensionCandidate(
|
||||
dimension,
|
||||
packRoot.toPath().toAbsolutePath().normalize(),
|
||||
dimension.getDimensionType().toJson(fixer)
|
||||
aliasContext.generatedSource(
|
||||
GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY,
|
||||
"iris:" + dimension.getDimensionTypeKey(),
|
||||
dimension.getDimensionType().toJson(fixer),
|
||||
fixer
|
||||
)
|
||||
));
|
||||
KSet<String> seenBiomes = biomes.computeIfAbsent(dimension.getLoadKey(), ignored -> new KSet<>());
|
||||
dimension.installBiomes(fixer, dimension::getLoader, datapackRoots, seenBiomes);
|
||||
dimension.installDimensionType(fixer, datapackRoots);
|
||||
dimension.installBiomes(
|
||||
fixer,
|
||||
dimension::getLoader,
|
||||
datapackRoots,
|
||||
seenBiomes,
|
||||
aliasContext
|
||||
);
|
||||
dimension.installDimensionType(fixer, datapackRoots, aliasContext);
|
||||
installedDimensions++;
|
||||
dimensionCount++;
|
||||
}
|
||||
@@ -501,6 +599,23 @@ public final class IrisDatapackCompiler {
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectCanonicalWorldPackRoot(
|
||||
Path worldRoot,
|
||||
Map<Path, File> roots,
|
||||
boolean validateWholePack
|
||||
) throws IOException {
|
||||
if (Files.isSymbolicLink(worldRoot)
|
||||
|| !Files.isDirectory(worldRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
List<Path> candidates = new ArrayList<>();
|
||||
collectWorldRootPackRoots(worldRoot, candidates);
|
||||
candidates.sort(Comparator.comparing(Path::toString));
|
||||
for (Path candidate : candidates) {
|
||||
addPackRoot(candidate, roots, validateWholePack);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectNamespaceWorldPackRoots(
|
||||
Path namespace,
|
||||
List<Path> candidates
|
||||
@@ -510,18 +625,12 @@ public final class IrisDatapackCompiler {
|
||||
public FileVisitResult preVisitDirectory(
|
||||
Path directory,
|
||||
BasicFileAttributes attributes
|
||||
) {
|
||||
) throws IOException {
|
||||
if (!directory.equals(namespace)
|
||||
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
Path irisRoot = directory.resolve("iris");
|
||||
Path candidate = irisRoot.resolve("pack");
|
||||
if (!Files.isSymbolicLink(irisRoot)
|
||||
&& !Files.isSymbolicLink(candidate)
|
||||
&& Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)
|
||||
&& hasDimensions(candidate)) {
|
||||
candidates.add(candidate);
|
||||
if (collectWorldRootPackRoots(directory, candidates)) {
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
@@ -529,6 +638,35 @@ public final class IrisDatapackCompiler {
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean collectWorldRootPackRoots(
|
||||
Path worldRoot,
|
||||
List<Path> candidates
|
||||
) throws IOException {
|
||||
Path irisRoot = worldRoot.resolve("iris");
|
||||
Path generationRoot = irisRoot.resolve("generation");
|
||||
if (!Files.isSymbolicLink(irisRoot)
|
||||
&& (Files.exists(generationRoot, LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.isSymbolicLink(generationRoot))) {
|
||||
GenerationHistory history = GenerationHistory.open(worldRoot);
|
||||
for (GenerationEpoch epoch : history.manifest().epochs()) {
|
||||
Path retainedPack = history.paths().packRoot(epoch.epochId());
|
||||
if (hasDimensions(retainedPack)) {
|
||||
candidates.add(retainedPack);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Path legacyPack = irisRoot.resolve("pack");
|
||||
if (!Files.isSymbolicLink(irisRoot)
|
||||
&& !Files.isSymbolicLink(legacyPack)
|
||||
&& Files.isDirectory(legacyPack, LinkOption.NOFOLLOW_LINKS)
|
||||
&& hasDimensions(legacyPack)) {
|
||||
candidates.add(legacyPack);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<Path> visibleDirectories(Path root) throws IOException {
|
||||
try (Stream<Path> entries = Files.list(root)) {
|
||||
return entries
|
||||
@@ -562,6 +700,43 @@ public final class IrisDatapackCompiler {
|
||||
roots.putIfAbsent(identity, normalized.toFile());
|
||||
}
|
||||
|
||||
private static BiomeAliasContext biomeAliasContext(Path packRoot) throws IOException {
|
||||
Path normalizedRoot = packRoot.toAbsolutePath().normalize();
|
||||
Path rootParent = normalizedRoot.getParent();
|
||||
if (rootParent == null || !"pack".equals(normalizedRoot.getFileName().toString())) {
|
||||
return BiomeAliasContext.contentAddressedOnly();
|
||||
}
|
||||
if ("iris".equals(rootParent.getFileName().toString())) {
|
||||
return BiomeAliasContext.legacyPack();
|
||||
}
|
||||
|
||||
Path epochsRoot = rootParent.getParent();
|
||||
Path generationRoot = epochsRoot == null ? null : epochsRoot.getParent();
|
||||
Path irisRoot = generationRoot == null ? null : generationRoot.getParent();
|
||||
Path dimensionRoot = irisRoot == null ? null : irisRoot.getParent();
|
||||
if (epochsRoot == null
|
||||
|| generationRoot == null
|
||||
|| irisRoot == null
|
||||
|| dimensionRoot == null
|
||||
|| !"epochs".equals(epochsRoot.getFileName().toString())
|
||||
|| !"generation".equals(generationRoot.getFileName().toString())
|
||||
|| !"iris".equals(irisRoot.getFileName().toString())) {
|
||||
return BiomeAliasContext.contentAddressedOnly();
|
||||
}
|
||||
|
||||
String epochId = rootParent.getFileName().toString();
|
||||
GenerationHistory history = GenerationHistory.open(dimensionRoot);
|
||||
GenerationEpoch epoch = history.manifest().epoch(epochId).orElseThrow(
|
||||
() -> new IOException("Retained Iris pack is not referenced by its generation history: "
|
||||
+ normalizedRoot)
|
||||
);
|
||||
Path expectedRoot = history.paths().packRoot(epochId).toAbsolutePath().normalize();
|
||||
if (!expectedRoot.equals(normalizedRoot)) {
|
||||
throw new IOException("Retained Iris pack does not match its generation history: " + normalizedRoot);
|
||||
}
|
||||
return BiomeAliasContext.retained(epoch.registryContract());
|
||||
}
|
||||
|
||||
private static boolean hasDimensions(Path root) {
|
||||
Path dimensions = root.resolve("dimensions");
|
||||
if (Files.isSymbolicLink(dimensions)
|
||||
@@ -704,6 +879,75 @@ public final class IrisDatapackCompiler {
|
||||
) {
|
||||
}
|
||||
|
||||
private record BiomeAliasContext(
|
||||
boolean retainAllLegacyAliases,
|
||||
GenerationRegistryContract retainedContract
|
||||
) implements IrisCustomBiomeAliasResolver {
|
||||
private static BiomeAliasContext contentAddressedOnly() {
|
||||
return new BiomeAliasContext(false, null);
|
||||
}
|
||||
|
||||
private static BiomeAliasContext legacyPack() {
|
||||
return new BiomeAliasContext(true, null);
|
||||
}
|
||||
|
||||
private static BiomeAliasContext retained(GenerationRegistryContract registryContract) {
|
||||
return new BiomeAliasContext(
|
||||
false,
|
||||
Objects.requireNonNull(registryContract, "registryContract")
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String physicalResourceKey(
|
||||
IrisDimension dimension,
|
||||
IrisBiome biome,
|
||||
IrisBiomeCustom customBiome,
|
||||
String currentPhysicalResourceKey
|
||||
) throws IOException {
|
||||
if (retainedContract == null) {
|
||||
return currentPhysicalResourceKey;
|
||||
}
|
||||
return dimension.getLoader().customBiomeResourceKey(dimension, customBiome);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<String> aliases(
|
||||
IrisDimension dimension,
|
||||
IrisBiome biome,
|
||||
IrisBiomeCustom customBiome,
|
||||
String physicalResourceKey
|
||||
) {
|
||||
String alias = dimension.getLoadKey().toLowerCase(Locale.ROOT)
|
||||
+ ":" + customBiome.getId().toLowerCase(Locale.ROOT);
|
||||
if (retainAllLegacyAliases) {
|
||||
return List.of(alias);
|
||||
}
|
||||
if (retainedContract == null) {
|
||||
return List.of();
|
||||
}
|
||||
PhysicalResourceKey key = new PhysicalResourceKey(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
alias
|
||||
);
|
||||
return retainedContract.definitions().containsKey(key) ? List.of(alias) : List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatedSource(
|
||||
String registryKey,
|
||||
String resourceKey,
|
||||
String currentSource,
|
||||
IDataFixer fixer
|
||||
) throws IOException {
|
||||
if (retainedContract == null) {
|
||||
return currentSource;
|
||||
}
|
||||
PhysicalResourceKey key = new PhysicalResourceKey(registryKey, resourceKey);
|
||||
return GenerationRegistryContractFactory.renderGeneratedSource(retainedContract, key, fixer);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DimensionHeight {
|
||||
private final IDataFixer fixer;
|
||||
private final AtomicIntegerArray[] dimensions = new AtomicIntegerArray[3];
|
||||
|
||||
@@ -349,6 +349,11 @@ public class IrisSettings {
|
||||
public static class IrisSettingsGenerator {
|
||||
public String defaultWorldType = "overworld";
|
||||
public boolean preventLeafDecay = true;
|
||||
public int generationTransitionWidthBlocks = 256;
|
||||
|
||||
public int getGenerationTransitionWidthBlocks() {
|
||||
return Math.max(16, Math.min(generationTransitionWidthBlocks, 8_192));
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
@@ -404,10 +405,6 @@ public final class IrisWorldStorage {
|
||||
return Optional.ofNullable(NamespacedKey.fromString(namespace + ":" + key));
|
||||
}
|
||||
|
||||
public static File packRoot(NamespacedKey key) {
|
||||
return new File(dimensionRoot(key), "iris/pack");
|
||||
}
|
||||
|
||||
public static File requireFrozenDimensionRoot(
|
||||
File worldContainer,
|
||||
File levelRoot,
|
||||
@@ -485,26 +482,28 @@ public final class IrisWorldStorage {
|
||||
return configuredRoot;
|
||||
}
|
||||
|
||||
public static File requireFrozenPackRoot(File dimensionRoot) {
|
||||
public static File requireActiveGenerationPackRoot(File dimensionRoot) {
|
||||
Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot")
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
Path irisRoot = root.resolve("iris");
|
||||
Path packRoot = irisRoot.resolve("pack");
|
||||
for (Path path : new Path[]{root, irisRoot, packRoot}) {
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new IllegalStateException("Frozen Iris pack path contains a symbolic link: " + path);
|
||||
}
|
||||
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalStateException("Frozen Iris pack path is not a directory: " + path);
|
||||
}
|
||||
try {
|
||||
return GenerationHistory.open(root).activePackRoot().toFile();
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Iris generation history is unusable at " + root + ".", failure);
|
||||
}
|
||||
if (!Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalStateException("Frozen Iris pack snapshot is missing: " + packRoot);
|
||||
}
|
||||
|
||||
public static File requireActiveGenerationPackRoot(File dimensionRoot, long expectedWorldSeed) {
|
||||
Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot")
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
try {
|
||||
return GenerationHistory.open(root, expectedWorldSeed).activePackRoot().toFile();
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Iris generation history is unusable at " + root + ".", failure);
|
||||
}
|
||||
return packRoot.toFile();
|
||||
}
|
||||
|
||||
private static boolean isExistingSafeDimensionRoot(Path storageRoot, Path dimensionRoot) {
|
||||
|
||||
@@ -367,7 +367,7 @@ public class IrisWorlds {
|
||||
configuredWorldName,
|
||||
worldKey
|
||||
);
|
||||
return dimensionRoot.map(IrisWorldStorage::requireFrozenPackRoot);
|
||||
return dimensionRoot.map(IrisWorldStorage::requireActiveGenerationPackRoot);
|
||||
}
|
||||
|
||||
private static void warnUnusableStorage(String worldIdentity, IllegalStateException failure) {
|
||||
@@ -376,7 +376,7 @@ public class IrisWorlds {
|
||||
}
|
||||
IrisLogging.error("Iris world %s has unusable world storage and is excluded: %s",
|
||||
worldIdentity, failure.getMessage());
|
||||
IrisLogging.error("Restore its iris/pack snapshot, or drop the world with /iris remove world=%s delete=true.",
|
||||
IrisLogging.error("Restore its iris/generation history, or drop the world with /iris remove world=%s delete=true.",
|
||||
worldIdentity);
|
||||
}
|
||||
|
||||
|
||||
@@ -1121,7 +1121,7 @@ public class ServerConfigurator {
|
||||
for (IrisBiome i : dimension.getAllBiomes(dimension::getLoader)) {
|
||||
if (i.isCustom()) {
|
||||
for (IrisBiomeCustom j : i.getCustomDerivitives()) {
|
||||
keys.add(dimension.getCustomBiomeKey(j.getId()));
|
||||
keys.add(dimension.getLoader().customBiomeResourceKey(dimension, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package art.arcane.iris.core.lifecycle;
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.WorldSlotKey;
|
||||
import art.arcane.iris.engine.IrisEngineMantle;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryPaths;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
@@ -229,8 +230,20 @@ public final class BukkitWorldConfiguration {
|
||||
}
|
||||
Path resolvedRoot = dimensionRoot.toPath().toAbsolutePath().normalize();
|
||||
try {
|
||||
IrisWorldStorage.requireFrozenPackRoot(dimensionRoot);
|
||||
IrisWorldStorage.requireActiveGenerationPackRoot(dimensionRoot);
|
||||
} catch (IllegalStateException unusablePack) {
|
||||
GenerationHistoryPaths historyPaths = GenerationHistoryPaths.forDimension(resolvedRoot);
|
||||
boolean generationHistoryExists = Files.exists(
|
||||
historyPaths.generationRoot(),
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
) || Files.isSymbolicLink(historyPaths.generationRoot());
|
||||
boolean adoptableLegacyPack = !generationHistoryExists
|
||||
&& Files.isDirectory(historyPaths.legacyPackRoot(), LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isSymbolicLink(historyPaths.legacyPackRoot());
|
||||
if (adoptableLegacyPack) {
|
||||
return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot,
|
||||
IrisWorldStorageState.PRESENT, null);
|
||||
}
|
||||
// Only a folder that owns something can be corrupted by a vanilla generator writing into it.
|
||||
return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot,
|
||||
hasWorldData(resolvedRoot)
|
||||
|
||||
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
|
||||
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -94,7 +95,7 @@ public final class WorldReplacementFilesystem {
|
||||
State state = inspect(requiredPaths);
|
||||
if (state.stagePresent()) {
|
||||
requireSafeTree(requiredPaths.stage(), "replacement stage");
|
||||
requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint);
|
||||
requireFingerprint(activePackRoot(requiredPaths.stage()), expectedFingerprint);
|
||||
if (originalTargetPresent) {
|
||||
Path retainedWorld = state.targetPresent()
|
||||
? requiredPaths.target()
|
||||
@@ -118,7 +119,7 @@ public final class WorldReplacementFilesystem {
|
||||
throw new IOException("Replacement backup state does not match the original target state.");
|
||||
}
|
||||
requireSafeTree(requiredPaths.stage(), "replacement stage");
|
||||
requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint);
|
||||
requireFingerprint(activePackRoot(requiredPaths.stage()), expectedFingerprint);
|
||||
move(requiredPaths.stage(), requiredPaths.target());
|
||||
state = inspect(requiredPaths);
|
||||
}
|
||||
@@ -130,11 +131,11 @@ public final class WorldReplacementFilesystem {
|
||||
throw new IOException("Replacement publication lost or unexpectedly created its backup.");
|
||||
}
|
||||
requireSafeTree(requiredPaths.target(), "replacement target");
|
||||
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
|
||||
requireFingerprint(activePackRoot(requiredPaths.target()), expectedFingerprint);
|
||||
if (originalTargetPresent) {
|
||||
preservePaperWorldMetadata(requiredPaths.backup(), requiredPaths.target());
|
||||
requireSafeTree(requiredPaths.target(), "replacement target");
|
||||
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
|
||||
requireFingerprint(activePackRoot(requiredPaths.target()), expectedFingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +225,7 @@ public final class WorldReplacementFilesystem {
|
||||
throw new IOException("Committed replacement storage does not contain one exact target directory.");
|
||||
}
|
||||
requireSafeTree(requiredPaths.target(), "replacement target");
|
||||
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
|
||||
requireFingerprint(activePackRoot(requiredPaths.target()), expectedFingerprint);
|
||||
}
|
||||
|
||||
public static void validatePublishedTarget(
|
||||
@@ -240,7 +241,7 @@ public final class WorldReplacementFilesystem {
|
||||
throw new IOException("Published replacement storage does not match its retained-world contract.");
|
||||
}
|
||||
requireSafeTree(requiredPaths.target(), "replacement target");
|
||||
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
|
||||
requireFingerprint(activePackRoot(requiredPaths.target()), expectedFingerprint);
|
||||
}
|
||||
|
||||
public static String fingerprintPack(Path packRoot) throws IOException {
|
||||
@@ -279,6 +280,10 @@ public final class WorldReplacementFilesystem {
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static Path activePackRoot(Path worldRoot) throws IOException {
|
||||
return GenerationHistory.open(worldRoot).activePackRoot();
|
||||
}
|
||||
|
||||
private static FingerprintEntry fingerprintEntry(Path root, Path path) {
|
||||
Path relative = root.relativize(path);
|
||||
return new FingerprintEntry(path, relative, relative.toString());
|
||||
|
||||
@@ -39,9 +39,14 @@ import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.engine.framework.structure.StructureGraphCatalog;
|
||||
import art.arcane.iris.engine.history.GenerationPackFingerprint;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContract;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContractFactory;
|
||||
import art.arcane.iris.core.structure.authoring.StructureRecoveryResult;
|
||||
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||
import art.arcane.iris.engine.object.IrisBlockData;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisEntity;
|
||||
@@ -74,6 +79,7 @@ import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@@ -87,7 +93,9 @@ import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -137,6 +145,17 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
private final transient List<Engine> engines = new ArrayList<>();
|
||||
private final transient Map<IrisDimension, Map<String, String>> dimensionCustomBiomeResourceKeys =
|
||||
Collections.synchronizedMap(new IdentityHashMap<>());
|
||||
private transient volatile String generationPackFingerprint;
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
@EqualsAndHashCode.Exclude
|
||||
private transient volatile GenerationRegistryContract generationRegistryContract;
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
@EqualsAndHashCode.Exclude
|
||||
private transient volatile GenerationRegistryContractFactory.CustomBiomeResourceResolver customBiomeResourceResolver;
|
||||
private transient volatile Engine soleEngine;
|
||||
|
||||
private IrisData(File dataFolder) {
|
||||
@@ -170,6 +189,90 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
return Optional.ofNullable(dataLoaders.get(dataFolder));
|
||||
}
|
||||
|
||||
public String customBiomeResourceKey(IrisDimension dimension, IrisBiomeCustom customBiome) {
|
||||
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
||||
IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome");
|
||||
GenerationRegistryContractFactory.CustomBiomeResourceResolver resolver = customBiomeResourceResolver;
|
||||
if (resolver != null) {
|
||||
try {
|
||||
return resolver.resolve(
|
||||
requiredDimension.getLoadKey(),
|
||||
requiredBiome
|
||||
);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Immutable Iris epoch has no registry mapping for custom biome '"
|
||||
+ requiredDimension.getLoadKey() + ":" + requiredBiome.getId() + "'.", failure);
|
||||
}
|
||||
}
|
||||
return GenerationRegistryContractFactory.customBiomeResourceKey(
|
||||
requiredDimension.getLoadKey(),
|
||||
requiredBiome,
|
||||
getContentGate()
|
||||
);
|
||||
}
|
||||
|
||||
public String customBiomeResourceKey(IrisDimension dimension, String customBiomeId) {
|
||||
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
||||
String logicalKey = requiredDimension.getLoadKey().toLowerCase(Locale.ROOT)
|
||||
+ ":" + Objects.requireNonNull(customBiomeId, "customBiomeId").toLowerCase(Locale.ROOT);
|
||||
String physicalKey = customBiomeResourceKeys(requiredDimension).get(logicalKey);
|
||||
if (physicalKey == null) {
|
||||
throw new IllegalArgumentException("Unknown custom biome '" + logicalKey + "'.");
|
||||
}
|
||||
return physicalKey;
|
||||
}
|
||||
|
||||
public String physicalBiomeResourceKey(IrisDimension dimension, String resourceKey) {
|
||||
if (resourceKey == null || resourceKey.isBlank()) {
|
||||
return resourceKey;
|
||||
}
|
||||
String normalizedKey = resourceKey.trim().toLowerCase(Locale.ROOT);
|
||||
if (!normalizedKey.contains(":")) {
|
||||
normalizedKey = "minecraft:" + normalizedKey;
|
||||
}
|
||||
return customBiomeResourceKeys(Objects.requireNonNull(dimension, "dimension"))
|
||||
.getOrDefault(normalizedKey, normalizedKey);
|
||||
}
|
||||
|
||||
public String generationPackFingerprint() {
|
||||
String fingerprint = generationPackFingerprint;
|
||||
if (fingerprint != null) {
|
||||
return fingerprint;
|
||||
}
|
||||
synchronized (this) {
|
||||
fingerprint = generationPackFingerprint;
|
||||
if (fingerprint != null) {
|
||||
return fingerprint;
|
||||
}
|
||||
try {
|
||||
fingerprint = GenerationPackFingerprint.compute(
|
||||
dataFolder.toPath(),
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Unable to fingerprint Iris generation pack at "
|
||||
+ dataFolder + ".", failure);
|
||||
}
|
||||
generationPackFingerprint = fingerprint;
|
||||
return fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void bindGenerationRegistryContract(GenerationRegistryContract contract) {
|
||||
GenerationRegistryContract required = Objects.requireNonNull(contract, "contract");
|
||||
if (generationRegistryContract != null && !generationRegistryContract.equals(required)) {
|
||||
throw new IllegalStateException("Iris data is already bound to another generation registry contract.");
|
||||
}
|
||||
generationRegistryContract = required;
|
||||
customBiomeResourceResolver = new GenerationRegistryContractFactory.CustomBiomeResourceResolver(
|
||||
required,
|
||||
PlatformGenerationRegistry::contentAddressedCustomBiomeResourceKey
|
||||
);
|
||||
synchronized (dimensionCustomBiomeResourceKeys) {
|
||||
dimensionCustomBiomeResourceKeys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean invalidateLoadedStructureResources(File dataFolder) {
|
||||
Path requested = dataFolderIdentity(Objects.requireNonNull(
|
||||
dataFolder,
|
||||
@@ -273,7 +376,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
try {
|
||||
if (nearest != null) {
|
||||
T t = nearest.load(type, key, false);
|
||||
if (t != null) {
|
||||
if (t != null || nearest.generationRegistryContract != null) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -516,6 +619,10 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
StructureGraphCatalog.invalidate(this);
|
||||
IrisObjectScale.invalidate(this);
|
||||
contentGate = null;
|
||||
generationPackFingerprint = null;
|
||||
synchronized (dimensionCustomBiomeResourceKeys) {
|
||||
dimensionCustomBiomeResourceKeys.clear();
|
||||
}
|
||||
compatReport.clear();
|
||||
closed = false;
|
||||
possibleSnippets = new KMap<>();
|
||||
@@ -831,4 +938,34 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
}
|
||||
IrisLogging.debug("Loaded Prefetch Cache to reduce generation disk use.");
|
||||
}
|
||||
|
||||
private Map<String, String> customBiomeResourceKeys(IrisDimension dimension) {
|
||||
synchronized (dimensionCustomBiomeResourceKeys) {
|
||||
Map<String, String> cached = dimensionCustomBiomeResourceKeys.get(dimension);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
String namespace = dimension.getLoadKey().toLowerCase(Locale.ROOT);
|
||||
Map<String, String> resolved = new LinkedHashMap<>();
|
||||
List<IrisBiome> registryBiomes = new ArrayList<>(dimension.getAllBiomes(() -> this));
|
||||
registryBiomes.addAll(dimension.getReachableBiomes(() -> this));
|
||||
for (IrisBiome biome : registryBiomes) {
|
||||
if (biome == null || !biome.isCustom()) {
|
||||
continue;
|
||||
}
|
||||
for (IrisBiomeCustom customBiome : biome.getCustomDerivitives()) {
|
||||
if (customBiome == null || customBiome.getId() == null || customBiome.getId().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String logicalKey = namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT);
|
||||
String physicalKey = customBiomeResourceKey(dimension, customBiome);
|
||||
resolved.merge(logicalKey, physicalKey,
|
||||
(first, second) -> first.compareTo(second) <= 0 ? first : second);
|
||||
}
|
||||
}
|
||||
Map<String, String> immutable = Map.copyOf(resolved);
|
||||
dimensionCustomBiomeResourceKeys.put(dimension, immutable);
|
||||
return immutable;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,15 +172,13 @@ public final class BukkitRuntimeMessages {
|
||||
+ " (dimension blockFallbacks, block backup)."
|
||||
);
|
||||
public static final LinesKey COMMAND_DEVELOPER_UPDATE_WORLD_WARNING = LinesKey.of(
|
||||
"iris.bukkit.runtime.commanddeveloper.update_world_warning",
|
||||
C.RED + "You should always make a backup before using this",
|
||||
C.YELLOW + "Issues caused by this can be, but are not limited to:",
|
||||
C.YELLOW + " - Broken chunks (cut-offs) between old and new chunks (before & after the update)",
|
||||
C.YELLOW + " - Regenerated chunks that do not fit in with the old chunks",
|
||||
C.YELLOW + " - Structures not spawning again when regenerating",
|
||||
C.YELLOW + " - Caves not lining up",
|
||||
C.YELLOW + " - Terrain layers not lining up",
|
||||
C.RED + "Now that you are aware of the risks, and have made a back-up:",
|
||||
"iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning",
|
||||
C.RED + "Back up the complete world before staging this update.",
|
||||
C.YELLOW + "Existing chunks will not be regenerated or rewritten.",
|
||||
C.YELLOW + "New chunks use the staged pack after restart and blend across the frozen boundary.",
|
||||
C.YELLOW + "The world seed, height, environment, and dimension type cannot change.",
|
||||
C.YELLOW + "Every backup must retain the complete iris/generation directory.",
|
||||
C.RED + "To stage the update and request a clean restart:",
|
||||
C.RED + "/iris developer update-world {world} {pack} confirm=true"
|
||||
);
|
||||
public static final TextKey BULK_STRUCTURE_IMPORTER_IMPORTING_VANILLA_DATAPACK_STRUCTURES_MODE_INCLUDENONJIGSAW = TextKey.of(
|
||||
|
||||
@@ -60,9 +60,9 @@ public final class DirectorCommandMessages {
|
||||
"iris.director.commanddeveloper.param.center_chunk_z",
|
||||
"Center chunk Z"
|
||||
);
|
||||
public static final TextKey COMMAND_DEVELOPER_DIRECTOR_UPDATE_PACK_WORLD_UNSAFE = TextKey.of(
|
||||
"iris.director.commanddeveloper.director.update_pack_world_unsafe",
|
||||
"Update the pack of a world (UNSAFE!)"
|
||||
public static final TextKey COMMAND_DEVELOPER_DIRECTOR_STAGE_WORLD_GENERATION_UPDATE = TextKey.of(
|
||||
"iris.director.commanddeveloper.director.stage_world_generation_update",
|
||||
"Stage a world generation update"
|
||||
);
|
||||
public static final TextKey COMMAND_DEVELOPER_PARAM_WORLD_UPDATE = TextKey.of(
|
||||
"iris.director.commanddeveloper.param.world_update",
|
||||
@@ -889,7 +889,7 @@ public final class DirectorCommandMessages {
|
||||
COMMAND_DEVELOPER_PARAM_RADIUS_CHUNKS_AROUND_CENTER,
|
||||
COMMAND_DEVELOPER_PARAM_CENTER_CHUNK_X,
|
||||
COMMAND_DEVELOPER_PARAM_CENTER_CHUNK_Z,
|
||||
COMMAND_DEVELOPER_DIRECTOR_UPDATE_PACK_WORLD_UNSAFE,
|
||||
COMMAND_DEVELOPER_DIRECTOR_STAGE_WORLD_GENERATION_UPDATE,
|
||||
COMMAND_DEVELOPER_PARAM_WORLD_UPDATE,
|
||||
COMMAND_DEVELOPER_PARAM_PACK_INSTALL_INTO_WORLD,
|
||||
COMMAND_DEVELOPER_PARAM_MAKE_SURE_MAKE_BACKUP_READ_WARNINGS_FIRST,
|
||||
|
||||
@@ -31,6 +31,7 @@ import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.NativeStructureVolume;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
@@ -57,9 +58,14 @@ import java.awt.Color;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public interface INMSBinding {
|
||||
default PlatformGenerationRegistry generationRegistry() {
|
||||
throw new UnsupportedOperationException(
|
||||
"The active NMS binding does not expose generation registry definitions."
|
||||
);
|
||||
}
|
||||
|
||||
boolean hasTile(Material material);
|
||||
|
||||
boolean hasTile(Location l);
|
||||
@@ -273,8 +279,18 @@ public interface INMSBinding {
|
||||
default void uninjectBukkit() {
|
||||
}
|
||||
|
||||
default boolean awaitServerShutdownBoundary(long timeout, TimeUnit unit) {
|
||||
return true;
|
||||
default void deferPluginClassLoaderClose() {
|
||||
}
|
||||
|
||||
default void releasePluginClassLoaderClose() {
|
||||
}
|
||||
|
||||
default boolean isServerStopping() {
|
||||
return false;
|
||||
}
|
||||
|
||||
default ServerShutdownBoundary createServerShutdownBoundary() {
|
||||
return new ServerShutdownBoundary(() -> true, Thread.currentThread());
|
||||
}
|
||||
|
||||
KMap<Material, List<BlockProperty>> getBlockProperties();
|
||||
|
||||
@@ -7,33 +7,32 @@ import java.util.function.BooleanSupplier;
|
||||
public final class ServerShutdownBoundary {
|
||||
private static final long MAX_JOIN_SLICE_MILLIS = 1000L;
|
||||
|
||||
private ServerShutdownBoundary() {
|
||||
private final BooleanSupplier boundaryReached;
|
||||
private final Thread serverThread;
|
||||
|
||||
public ServerShutdownBoundary(BooleanSupplier boundaryReached, Thread serverThread) {
|
||||
this.boundaryReached = Objects.requireNonNull(boundaryReached, "Server shutdown boundary");
|
||||
this.serverThread = Objects.requireNonNull(serverThread, "Server thread");
|
||||
this.boundaryReached.getAsBoolean();
|
||||
}
|
||||
|
||||
public static boolean await(
|
||||
BooleanSupplier boundaryReached,
|
||||
Thread serverThread,
|
||||
long timeout,
|
||||
TimeUnit unit
|
||||
) {
|
||||
BooleanSupplier reached = Objects.requireNonNull(boundaryReached, "Server shutdown boundary");
|
||||
Thread activeServerThread = Objects.requireNonNull(serverThread, "Server thread");
|
||||
public boolean await(long timeout, TimeUnit unit) {
|
||||
TimeUnit activeUnit = Objects.requireNonNull(unit, "Server shutdown timeout unit");
|
||||
if (reached.getAsBoolean()) {
|
||||
if (boundaryReached.getAsBoolean()) {
|
||||
return true;
|
||||
}
|
||||
if (activeServerThread == Thread.currentThread()) {
|
||||
if (serverThread == Thread.currentThread()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long timeoutNanos = Math.max(0L, activeUnit.toNanos(timeout));
|
||||
long started = System.nanoTime();
|
||||
boolean interrupted = false;
|
||||
while (!reached.getAsBoolean()) {
|
||||
while (!boundaryReached.getAsBoolean()) {
|
||||
long remaining = timeoutNanos - (System.nanoTime() - started);
|
||||
if (remaining <= 0L || !activeServerThread.isAlive()) {
|
||||
if (remaining <= 0L || !serverThread.isAlive()) {
|
||||
restoreInterrupt(interrupted);
|
||||
return reached.getAsBoolean();
|
||||
return boundaryReached.getAsBoolean();
|
||||
}
|
||||
|
||||
long joinMillis = Math.max(
|
||||
@@ -41,7 +40,7 @@ public final class ServerShutdownBoundary {
|
||||
Math.min(MAX_JOIN_SLICE_MILLIS, TimeUnit.NANOSECONDS.toMillis(remaining))
|
||||
);
|
||||
try {
|
||||
activeServerThread.join(joinMillis);
|
||||
serverThread.join(joinMillis);
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.apache.logging.log4j.Marker;
|
||||
import org.apache.logging.log4j.core.Filter;
|
||||
import org.apache.logging.log4j.core.LogEvent;
|
||||
import org.apache.logging.log4j.core.Logger;
|
||||
import org.apache.logging.log4j.core.config.LoggerConfig;
|
||||
import org.apache.logging.log4j.message.Message;
|
||||
|
||||
import java.util.List;
|
||||
@@ -21,14 +22,15 @@ public class LogFilterSVC implements IrisService, Filter {
|
||||
// so a mutable static here was both a CME hazard and grew by three entries per enable.
|
||||
private static final List<String> FILTERS = List.of(HEIGHTMAP_MISMATCH, RAID_PERSISTENCE, DUPLICATE_ENTITY_UUID);
|
||||
|
||||
private boolean installed = false;
|
||||
private LoggerConfig installedLogger;
|
||||
|
||||
public void onEnable() {
|
||||
if (installed) {
|
||||
if (installedLogger != null) {
|
||||
return;
|
||||
}
|
||||
((Logger) LogManager.getRootLogger()).addFilter(this);
|
||||
installed = true;
|
||||
LoggerConfig logger = ((Logger) LogManager.getRootLogger()).get();
|
||||
logger.addFilter(this);
|
||||
installedLogger = logger;
|
||||
}
|
||||
|
||||
public void initialize() {
|
||||
@@ -41,11 +43,12 @@ public class LogFilterSVC implements IrisService, Filter {
|
||||
}
|
||||
|
||||
public void onDisable() {
|
||||
if (!installed) {
|
||||
LoggerConfig logger = installedLogger;
|
||||
if (logger == null) {
|
||||
return;
|
||||
}
|
||||
((Logger) LogManager.getRootLogger()).get().removeFilter(this);
|
||||
installed = false;
|
||||
logger.removeFilter(this);
|
||||
installedLogger = null;
|
||||
}
|
||||
|
||||
public boolean isStarted() {
|
||||
|
||||
@@ -69,10 +69,27 @@ public class PreservationSVC implements IrisService, PreservationRegistry {
|
||||
public void dereference() {
|
||||
IrisData.dereference();
|
||||
threads.removeIf((i) -> !i.isAlive());
|
||||
services.removeIf(ExecutorService::isShutdown);
|
||||
services.removeIf(ExecutorService::isTerminated);
|
||||
updateCaches();
|
||||
}
|
||||
|
||||
public boolean hasActiveResources() {
|
||||
if (dereferencer != null && dereferencer.isAlive()) {
|
||||
return true;
|
||||
}
|
||||
for (Thread thread : threads) {
|
||||
if (thread.isAlive()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (ExecutorService executor : services) {
|
||||
if (!executor.isTerminated()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
/*
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.IrisStartupValidation;
|
||||
@@ -46,6 +47,14 @@ import art.arcane.iris.core.runtime.WorldDeletionQueue;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.iris.engine.history.GenerationActivation;
|
||||
import art.arcane.iris.engine.history.GenerationEpoch;
|
||||
import art.arcane.iris.engine.history.GenerationEpochContractFactory;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryPaths;
|
||||
import art.arcane.iris.engine.history.GenerationPackFingerprint;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContract;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContractFactory;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
@@ -166,18 +175,197 @@ public class StudioSVC implements IrisService {
|
||||
queueStudioWorldDeletionOnStartup(worldNamesToDelete);
|
||||
}
|
||||
|
||||
public IrisDimension installIntoWorld(VolmitSender sender, IrisDimension dimension, File folder) {
|
||||
return installIntoDirectory(sender, dimension, new File(folder, "iris/pack"), false);
|
||||
public IrisDimension installIntoWorld(
|
||||
VolmitSender sender,
|
||||
IrisDimension dimension,
|
||||
File dimensionRoot,
|
||||
long worldSeed
|
||||
) {
|
||||
return publishGenerationHistory(sender, dimension, dimensionRoot, worldSeed, false);
|
||||
}
|
||||
|
||||
public IrisDimension replaceIntoWorld(VolmitSender sender, IrisDimension dimension, File folder) {
|
||||
return installIntoDirectory(sender, dimension, new File(folder, "iris/pack"), true);
|
||||
public IrisDimension replaceIntoWorld(
|
||||
VolmitSender sender,
|
||||
IrisDimension dimension,
|
||||
File dimensionRoot,
|
||||
long worldSeed
|
||||
) {
|
||||
return publishGenerationHistory(sender, dimension, dimensionRoot, worldSeed, true);
|
||||
}
|
||||
|
||||
public IrisDimension installIntoTransientWorld(
|
||||
VolmitSender sender,
|
||||
IrisDimension dimension,
|
||||
File dimensionRoot
|
||||
) {
|
||||
return installIntoDirectory(sender, dimension, new File(dimensionRoot, "iris/pack"), false);
|
||||
}
|
||||
|
||||
public IrisDimension replaceIntoPackDirectory(VolmitSender sender, IrisDimension dimension, File folder) {
|
||||
return installIntoDirectory(sender, dimension, folder, true);
|
||||
}
|
||||
|
||||
private IrisDimension publishGenerationHistory(
|
||||
VolmitSender sender,
|
||||
IrisDimension dimension,
|
||||
File dimensionRoot,
|
||||
long worldSeed,
|
||||
boolean stageUpdate
|
||||
) {
|
||||
if (J.isPrimaryThread()) {
|
||||
sender.sendMessage(IrisLanguage.text(
|
||||
BukkitRuntimeMessages.STUDIO_S_V_C_PACK_COPY_REQUIRES_ASYNC_THREAD
|
||||
));
|
||||
return null;
|
||||
}
|
||||
String dimensionKey = dimension.getLoadKey();
|
||||
try {
|
||||
Path source = resolveSafePackSource(dimension.getLoader().getDataFolder());
|
||||
GenerationCandidate candidate = captureGenerationCandidate(source, dimensionKey);
|
||||
Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot")
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
if (!stageUpdate && !Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Path parent = Objects.requireNonNull(root.getParent(), "dimensionRoot parent");
|
||||
Files.createDirectories(parent);
|
||||
Files.createDirectory(root);
|
||||
}
|
||||
GenerationHistory history;
|
||||
long previousActivationId = 0L;
|
||||
boolean restartRequired = false;
|
||||
if (stageUpdate) {
|
||||
history = openOrAdoptGenerationHistory(root, dimensionKey, worldSeed);
|
||||
previousActivationId = history.activeActivation().activationId();
|
||||
GenerationActivation activation = history.stageUpdate(
|
||||
source,
|
||||
candidate.packFingerprint(),
|
||||
candidate.dimensionContract(),
|
||||
candidate.registryContract(),
|
||||
IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()
|
||||
);
|
||||
restartRequired = activation.activationId() != previousActivationId;
|
||||
} else {
|
||||
history = GenerationHistory.create(
|
||||
root,
|
||||
source,
|
||||
candidate.packFingerprint(),
|
||||
worldSeed,
|
||||
candidate.dimensionContract(),
|
||||
candidate.registryContract()
|
||||
);
|
||||
}
|
||||
Path installedPack = stageUpdate && history.pendingActivation().isPresent()
|
||||
? history.packRoot(history.pendingActivation().orElseThrow().activationId())
|
||||
: history.activePackRoot();
|
||||
IrisDimension installed = loadInstalledDimension(installedPack, dimensionKey);
|
||||
if (restartRequired) {
|
||||
ServerConfigurator.restart("An Iris generation epoch update is pending activation.");
|
||||
}
|
||||
return installed;
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Failed to publish generation history for dimension '"
|
||||
+ dimensionKey + "' into " + dimensionRoot.getPath(), failure);
|
||||
sender.sendMessage(IrisLanguage.text(
|
||||
BukkitRuntimeMessages.STUDIO_S_V_C_PACK_INSTALL_FAILED,
|
||||
MessageArgument.untrusted("dimension", dimensionKey),
|
||||
MessageArgument.untrusted("error", errorDetail(failure))
|
||||
));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static GenerationHistory openOrAdoptGenerationHistory(
|
||||
Path dimensionRoot,
|
||||
String dimensionKey,
|
||||
long worldSeed
|
||||
) throws IOException {
|
||||
Optional<GenerationHistory> current = GenerationHistory.openIfPresent(dimensionRoot, worldSeed);
|
||||
if (current.isPresent()) {
|
||||
return current.get();
|
||||
}
|
||||
Path legacyPack = GenerationHistoryPaths.forDimension(dimensionRoot).legacyPackRoot();
|
||||
PackValidationResult validation = validatePublishedPack(legacyPack);
|
||||
if (!validation.isLoadable()) {
|
||||
throw new BrokenPackException(legacyPack.toString(), validation.getBlockingErrors());
|
||||
}
|
||||
GenerationCandidate legacy = captureGenerationCandidate(
|
||||
legacyPack,
|
||||
dimensionKey,
|
||||
GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES
|
||||
);
|
||||
return GenerationHistory.adoptLegacyPack(
|
||||
dimensionRoot,
|
||||
legacy.packFingerprint(),
|
||||
worldSeed,
|
||||
legacy.dimensionContract(),
|
||||
legacy.registryContract()
|
||||
);
|
||||
}
|
||||
|
||||
private static GenerationCandidate captureGenerationCandidate(Path packRoot, String dimensionKey)
|
||||
throws IOException {
|
||||
return captureGenerationCandidate(
|
||||
packRoot,
|
||||
dimensionKey,
|
||||
GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY
|
||||
);
|
||||
}
|
||||
|
||||
private static GenerationCandidate captureGenerationCandidate(
|
||||
Path packRoot,
|
||||
String dimensionKey,
|
||||
GenerationRegistryContractFactory.CustomBiomeAliasPolicy aliasPolicy
|
||||
) throws IOException {
|
||||
String fingerprint = GenerationPackFingerprint.compute(
|
||||
packRoot,
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
);
|
||||
IrisData data = IrisData.openDatapackCompiler(packRoot.toFile());
|
||||
try {
|
||||
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false);
|
||||
if (dimension == null) {
|
||||
throw new IOException("Generation pack does not contain a loadable dimension '"
|
||||
+ dimensionKey + "'.");
|
||||
}
|
||||
String dimensionTypeKey = IrisPlatforms.get()
|
||||
.registries()
|
||||
.generationRegistry()
|
||||
.dimensionTypeResourceKey(
|
||||
data.getDataFolder().getName(),
|
||||
dimension.getLoadKey(),
|
||||
dimension.getDimensionTypeKey()
|
||||
);
|
||||
GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
dimension.getLoadKey(),
|
||||
dimensionTypeKey
|
||||
);
|
||||
GenerationRegistryContract registryContract = GenerationRegistryContractFactory.create(
|
||||
data,
|
||||
dimension,
|
||||
fingerprint,
|
||||
aliasPolicy
|
||||
);
|
||||
return new GenerationCandidate(fingerprint, dimensionContract, registryContract);
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisDimension loadInstalledDimension(Path packRoot, String dimensionKey) throws IOException {
|
||||
PackValidationResult validation = validatePublishedPack(packRoot);
|
||||
if (!validation.isLoadable()) {
|
||||
throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors());
|
||||
}
|
||||
IrisDimension installed = IrisData.get(packRoot.toFile()).getDimensionLoader().load(dimensionKey, false);
|
||||
if (installed == null) {
|
||||
throw new IOException("Immutable generation pack does not contain dimension '"
|
||||
+ dimensionKey + "'.");
|
||||
}
|
||||
return installed;
|
||||
}
|
||||
|
||||
private IrisDimension installIntoDirectory(
|
||||
VolmitSender sender,
|
||||
IrisDimension dimension,
|
||||
@@ -1534,6 +1722,13 @@ public class StudioSVC implements IrisService {
|
||||
RESTART
|
||||
}
|
||||
|
||||
private record GenerationCandidate(
|
||||
String packFingerprint,
|
||||
GenerationEpoch.DimensionContract dimensionContract,
|
||||
GenerationRegistryContract registryContract
|
||||
) {
|
||||
}
|
||||
|
||||
public IrisProject getActiveProject() {
|
||||
return activeProject;
|
||||
}
|
||||
|
||||
@@ -263,8 +263,10 @@ public class IrisCreator {
|
||||
IrisDimension installedDimension = resolvedDimension;
|
||||
if (!studio() || benchmark) {
|
||||
reportCreationProgress(creationReporter, 0.26D, "prepare_world_pack");
|
||||
installedDimension = IrisServices.get(StudioSVC.class)
|
||||
.installIntoWorld(sender, resolvedDimension, dimensionRoot);
|
||||
StudioSVC studioService = IrisServices.get(StudioSVC.class);
|
||||
installedDimension = benchmark
|
||||
? studioService.installIntoTransientWorld(sender, resolvedDimension, dimensionRoot)
|
||||
: studioService.installIntoWorld(sender, resolvedDimension, dimensionRoot, seed);
|
||||
if (installedDimension == null) {
|
||||
throw new IrisException("Failed to install dimension pack for " + dimension());
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.gui.PregeneratorJob;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.pregenerator.PregenPerformanceProfile;
|
||||
import art.arcane.iris.core.pregenerator.PregenTask;
|
||||
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
|
||||
@@ -57,7 +58,6 @@ import org.jetbrains.annotations.ApiStatus;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -74,7 +74,6 @@ import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
public class IrisToolbelt {
|
||||
@ApiStatus.Internal
|
||||
public static Map<String, Boolean> toolbeltConfiguration = new HashMap<>();
|
||||
private static final Method BUKKIT_IS_STOPPING_METHOD = resolveBukkitIsStoppingMethod();
|
||||
|
||||
/**
|
||||
* Finds an installed dimension or returns null.
|
||||
@@ -582,26 +581,7 @@ public class IrisToolbelt {
|
||||
}
|
||||
|
||||
public static boolean isServerStopping() {
|
||||
Method method = BUKKIT_IS_STOPPING_METHOD;
|
||||
if (method != null) {
|
||||
try {
|
||||
Object value = method.invoke(null);
|
||||
if (value instanceof Boolean) {
|
||||
return (Boolean) value;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
return !BukkitPlatform.hasPlugin() || !BukkitPlatform.plugin().isEnabled();
|
||||
}
|
||||
|
||||
private static Method resolveBukkitIsStoppingMethod() {
|
||||
try {
|
||||
return Bukkit.class.getMethod("isStopping");
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
return INMS.isBound() && INMS.get().isServerStopping();
|
||||
}
|
||||
|
||||
public static void beginWorldMaintenance(World world, String reason) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import art.arcane.iris.core.WorldCreatorCompat;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
||||
import art.arcane.iris.platform.bukkit.BukkitEnvironment;
|
||||
import org.bukkit.NamespacedKey;
|
||||
@@ -31,6 +32,7 @@ import org.bukkit.WorldCreator;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
public class IrisWorldCreator {
|
||||
private String name;
|
||||
@@ -100,15 +102,42 @@ public class IrisWorldCreator {
|
||||
.seed(seed)
|
||||
.worldFolder(worldFolder)
|
||||
.build();
|
||||
ChunkGenerator g = new BukkitChunkGenerator(w, studio, studio
|
||||
? dim.getLoader().getDataFolder() :
|
||||
new File(w.worldFolder(), "iris/pack"), dimensionName);
|
||||
GenerationHistory generationHistory = persistent
|
||||
? requireGenerationHistory(w.worldFolder(), seed)
|
||||
: null;
|
||||
File packRoot = generationHistory != null
|
||||
? requireActivePack(generationHistory)
|
||||
: studio
|
||||
? dim.getLoader().getDataFolder()
|
||||
: new File(w.worldFolder(), "iris/pack");
|
||||
ChunkGenerator g = new BukkitChunkGenerator(
|
||||
w,
|
||||
studio,
|
||||
packRoot,
|
||||
dimensionName,
|
||||
generationHistory);
|
||||
|
||||
return creator.environment(environment)
|
||||
.generateStructures(true)
|
||||
.generator(g).seed(seed);
|
||||
}
|
||||
|
||||
private static GenerationHistory requireGenerationHistory(File dimensionRoot, long seed) {
|
||||
try {
|
||||
return GenerationHistory.open(dimensionRoot.toPath(), seed);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Iris generation history is unusable at " + dimensionRoot + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static File requireActivePack(GenerationHistory history) {
|
||||
try {
|
||||
return history.activePackRoot().toFile();
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Iris active generation pack is unusable.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private World.Environment findEnvironment() {
|
||||
IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension;
|
||||
return BukkitEnvironment.from(dim == null ? null : dim.getEnvironment());
|
||||
|
||||
@@ -121,10 +121,10 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
engine.getDimension(),
|
||||
engine.getData(),
|
||||
engine.getHeight(),
|
||||
natural ? complex.getNaturalHeightStream() : complex.getHeightStream(),
|
||||
natural ? complex.getUnblendedNaturalHeightStream() : complex.getHeightStream(),
|
||||
natural ? complex.getNaturalSlopeStream() : complex.getSlopeStream(),
|
||||
fluidHeightStream,
|
||||
natural ? complex.getNaturalTrueBiomeStream() : complex.getTrueBiomeStream(),
|
||||
natural ? complex.getUnblendedNaturalTrueBiomeStream() : complex.getTrueBiomeStream(),
|
||||
complex.getRegionStream(),
|
||||
complex.getRockStream(),
|
||||
fluidBlockSampler,
|
||||
|
||||
@@ -20,7 +20,7 @@ package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.protocol.IrisProtocolServer;
|
||||
import art.arcane.iris.engine.EngineRuntime.BiomeMaxes;
|
||||
import art.arcane.iris.engine.GenerationRuntime.BiomeMaxes;
|
||||
import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly;
|
||||
import art.arcane.iris.engine.IrisEngine.LifecycleState;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
@@ -53,32 +53,40 @@ final class EngineHotloader {
|
||||
void hotloadComplex() {
|
||||
synchronized (engine.lifecycleLock) {
|
||||
engine.requireRunning("rebuild the biome complex");
|
||||
requireMutableGenerationRuntime("rebuild the biome complex");
|
||||
engine.awaitNativeStructureBootstrap("complex hotload");
|
||||
engine.lifecycleState = LifecycleState.HOTLOADING;
|
||||
EngineRuntime previous = engine.runtime;
|
||||
GenerationRuntime previousGeneration = previous.generation();
|
||||
IrisComplex nextComplex = null;
|
||||
try {
|
||||
engine.sealForTransition("complex hotload", false);
|
||||
engine.prepareRuntimeHotload();
|
||||
RuntimeAssembly assembly = new RuntimeAssembly(RuntimeAssembly.nextRuntimeId(), previous.target());
|
||||
RuntimeAssembly assembly = new RuntimeAssembly(
|
||||
RuntimeAssembly.nextRuntimeId(),
|
||||
previousGeneration.target(),
|
||||
previousGeneration.mantleStorageDirectory(),
|
||||
previousGeneration.runtimeKernel(),
|
||||
previousGeneration.transitionPlan());
|
||||
engine.runtimeAssembly.set(assembly);
|
||||
EngineRuntime next;
|
||||
try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) {
|
||||
assembly.complex = new IrisComplex(engine);
|
||||
assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan);
|
||||
nextComplex = assembly.complex;
|
||||
assembly.dimensionStackContext = engine.runtimeBuilder.buildDimensionStackContext();
|
||||
assembly.upperContext = engine.runtimeBuilder.buildUpperContext();
|
||||
assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine);
|
||||
assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine);
|
||||
BiomeMaxes biomeMaxes = engine.runtimeBuilder.computeBiomeMaxes();
|
||||
next = previous.withComplex(
|
||||
GenerationRuntime nextGeneration = previousGeneration.withComplex(
|
||||
assembly.cacheId,
|
||||
assembly.complex,
|
||||
assembly.upperContext,
|
||||
assembly.dimensionStackContext,
|
||||
biomeMaxes);
|
||||
next = previous.withGeneration(nextGeneration);
|
||||
} finally {
|
||||
engine.runtimeAssembly.remove();
|
||||
}
|
||||
Throwable retirementFailure = runCleanup(null, previous.complex()::close);
|
||||
Throwable retirementFailure = runCleanup(null, previousGeneration.complex()::close);
|
||||
if (retirementFailure != null) {
|
||||
retirementFailure = runCleanup(retirementFailure, nextComplex::close);
|
||||
nextComplex = null;
|
||||
@@ -91,7 +99,7 @@ final class EngineHotloader {
|
||||
engine.getClosing().set(false);
|
||||
engine.backgroundTasks.openBackgroundTaskAdmission();
|
||||
} catch (Throwable e) {
|
||||
if (nextComplex != null && nextComplex != previous.complex()) {
|
||||
if (nextComplex != null && nextComplex != previousGeneration.complex()) {
|
||||
Throwable cleanupFailure = runCleanup(null, nextComplex::close);
|
||||
if (cleanupFailure != null) {
|
||||
e.addSuppressed(cleanupFailure);
|
||||
@@ -108,6 +116,7 @@ final class EngineHotloader {
|
||||
void hotloadSilently() {
|
||||
synchronized (engine.lifecycleLock) {
|
||||
engine.requireRunning("hotload");
|
||||
requireMutableGenerationRuntime("hotload");
|
||||
engine.awaitNativeStructureBootstrap("hotload");
|
||||
engine.lifecycleState = LifecycleState.HOTLOADING;
|
||||
EngineRuntime previousRuntime = engine.runtime;
|
||||
@@ -183,6 +192,13 @@ final class EngineHotloader {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireMutableGenerationRuntime(String operation) {
|
||||
if (engine.getGenerationHistoryRuntimeRouter().isPresent()) {
|
||||
throw new IllegalStateException("Cannot " + operation
|
||||
+ " while immutable Iris generation history is attached; stage a world update and restart instead.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Never throws. It is called from the hotload failure handler, where a frame-cap
|
||||
* {@code IllegalStateException} out of the codec (a long pack key or exception message overruns
|
||||
|
||||
@@ -19,48 +19,26 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.engine.framework.EngineEffects;
|
||||
import art.arcane.iris.engine.framework.EngineMode;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.framework.EngineWorldManager;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Immutable snapshot of everything an {@link IrisEngine} publishes as a single unit.
|
||||
* Instances are built by {@link EngineRuntimeBuilder} and retired by {@link EngineShutdownSequence}.
|
||||
*/
|
||||
record EngineRuntime(
|
||||
int cacheId,
|
||||
EngineTarget target,
|
||||
IrisComplex complex,
|
||||
UpperDimensionContext upperContext,
|
||||
DimensionStackContext dimensionStackContext,
|
||||
GenerationRuntime generation,
|
||||
EngineEffects effects,
|
||||
EngineMode mode,
|
||||
EngineWorldManager worldManager,
|
||||
CompletableFuture<Long> hash32,
|
||||
BiomeMaxes biomeMaxes
|
||||
EngineWorldManager worldManager
|
||||
) {
|
||||
EngineRuntime withComplex(
|
||||
int nextCacheId,
|
||||
IrisComplex nextComplex,
|
||||
UpperDimensionContext nextUpperContext,
|
||||
DimensionStackContext nextDimensionStackContext,
|
||||
BiomeMaxes nextBiomeMaxes
|
||||
) {
|
||||
return new EngineRuntime(
|
||||
nextCacheId,
|
||||
target,
|
||||
nextComplex,
|
||||
nextUpperContext,
|
||||
nextDimensionStackContext,
|
||||
effects,
|
||||
mode,
|
||||
worldManager,
|
||||
hash32,
|
||||
nextBiomeMaxes);
|
||||
EngineRuntime {
|
||||
Objects.requireNonNull(generation, "generation runtime");
|
||||
Objects.requireNonNull(effects, "engine effects");
|
||||
Objects.requireNonNull(worldManager, "engine world manager");
|
||||
}
|
||||
|
||||
record BiomeMaxes(double objectDensity, double layerDensity, double decoratorDensity) {
|
||||
EngineRuntime withGeneration(GenerationRuntime nextGeneration) {
|
||||
return new EngineRuntime(nextGeneration, effects, worldManager);
|
||||
}
|
||||
}
|
||||
@@ -20,24 +20,22 @@ package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.engine.EngineRuntime.BiomeMaxes;
|
||||
import art.arcane.iris.engine.GenerationRuntime.BiomeMaxes;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.IrisEngine.LifecycleState;
|
||||
import art.arcane.iris.engine.actuator.IrisDimensionStackActuator;
|
||||
import art.arcane.iris.engine.framework.EngineEffects;
|
||||
import art.arcane.iris.engine.framework.EngineEffectsProvider;
|
||||
import art.arcane.iris.engine.framework.EngineMode;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.framework.EngineWorldManager;
|
||||
import art.arcane.iris.engine.framework.EngineWorldManagerProvider;
|
||||
import art.arcane.iris.engine.modifier.IrisCustomModifier;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.history.GenerationKernelRegistry;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomePaletteLayer;
|
||||
import art.arcane.iris.engine.object.IrisDecorator;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionMode;
|
||||
import art.arcane.iris.engine.object.IrisDimensionModeType;
|
||||
import art.arcane.iris.engine.object.IrisObjectPlacement;
|
||||
import art.arcane.iris.engine.object.IrisStaticObjectLayer;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
@@ -45,6 +43,9 @@ import art.arcane.volmlib.util.io.IO;
|
||||
import art.arcane.volmlib.util.math.M;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@@ -64,22 +65,56 @@ final class EngineRuntimeBuilder {
|
||||
}
|
||||
|
||||
EngineRuntime buildRuntime() {
|
||||
return buildRuntime(engine.getTarget());
|
||||
return buildRuntime(
|
||||
engine.getTarget(),
|
||||
engine.getInitialMantleStorageDirectory(),
|
||||
engine.getInitialKernelVersion(),
|
||||
engine.getInitialTransitionPlan(),
|
||||
null);
|
||||
}
|
||||
|
||||
EngineRuntime buildRuntime(EngineTarget runtimeTarget) {
|
||||
RuntimeAssembly assembly = new RuntimeAssembly(RuntimeAssembly.nextRuntimeId(), runtimeTarget);
|
||||
GenerationRuntime active = requireRuntime("hotload the engine runtime").generation();
|
||||
return buildRuntime(
|
||||
runtimeTarget,
|
||||
active.mantleStorageDirectory(),
|
||||
active.kernelVersion(),
|
||||
active.transitionPlan(),
|
||||
active.mantle());
|
||||
}
|
||||
|
||||
private EngineRuntime buildRuntime(
|
||||
EngineTarget runtimeTarget,
|
||||
Path mantleStorageDirectory,
|
||||
GenerationKernelRegistry.Version kernelVersion,
|
||||
TransitionGenerationPlan transitionPlan,
|
||||
EngineMantle transferredMantle
|
||||
) {
|
||||
RuntimeAssembly assembly = new RuntimeAssembly(
|
||||
RuntimeAssembly.nextRuntimeId(),
|
||||
runtimeTarget,
|
||||
mantleStorageDirectory,
|
||||
selectRuntimeKernel(kernelVersion),
|
||||
transitionPlan);
|
||||
engine.runtimeAssembly.set(assembly);
|
||||
try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) {
|
||||
IrisLogging.debug("Setup Engine " + assembly.cacheId);
|
||||
long started = M.ms();
|
||||
assembly.complex = new IrisComplex(engine);
|
||||
if (transferredMantle == null) {
|
||||
assembly.mantle = assembly.runtimeKernel.createMantle(engine, assembly.mantleStorageDirectory);
|
||||
assembly.ownsMantle = true;
|
||||
} else {
|
||||
assembly.mantle = transferredMantle;
|
||||
}
|
||||
IrisLogging.debug("[IrisEngine timing] new IrisEngineMantle=" + (M.ms() - started) + "ms");
|
||||
started = M.ms();
|
||||
assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan);
|
||||
IrisLogging.debug("[IrisEngine timing] complex=" + (M.ms() - started) + "ms");
|
||||
started = M.ms();
|
||||
assembly.dimensionStackContext = buildDimensionStackContext();
|
||||
assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine);
|
||||
IrisLogging.debug("[IrisEngine timing] buildDimensionStackContext=" + (M.ms() - started) + "ms");
|
||||
started = M.ms();
|
||||
assembly.upperContext = buildUpperContext();
|
||||
assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine);
|
||||
IrisLogging.debug("[IrisEngine timing] buildUpperContext=" + (M.ms() - started) + "ms");
|
||||
started = M.ms();
|
||||
assembly.effects = IrisServices.get(EngineEffectsProvider.class).create(engine);
|
||||
@@ -89,21 +124,16 @@ final class EngineRuntimeBuilder {
|
||||
IrisLogging.debug("[IrisEngine timing] EngineEffects=" + (M.ms() - started) + "ms");
|
||||
assembly.hash32 = new CompletableFuture<>();
|
||||
started = M.ms();
|
||||
engine.getMantle().hotload();
|
||||
assembly.mantle.hotload();
|
||||
IrisLogging.debug("[IrisEngine timing] mantle.hotload=" + (M.ms() - started) + "ms");
|
||||
started = M.ms();
|
||||
assembly.mode = createMode();
|
||||
IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData());
|
||||
assembly.mode.registerStage((x, z, blocks, biomes, multicore, context) ->
|
||||
staticObjects.apply(engine, x, z, blocks));
|
||||
if (assembly.dimensionStackContext != null) {
|
||||
IrisDimensionStackActuator dimensionStack = new IrisDimensionStackActuator(engine);
|
||||
assembly.mode.registerStage((x, z, blocks, biomes, multicore, context) ->
|
||||
dimensionStack.actuate(x, z, blocks, multicore, context));
|
||||
IrisCustomModifier stackCustom = new IrisCustomModifier(engine);
|
||||
assembly.mode.registerStage((x, z, blocks, biomes, multicore, context) ->
|
||||
stackCustom.modify(x, z, blocks, multicore, context));
|
||||
}
|
||||
assembly.mode = assembly.runtimeKernel.createMode(engine);
|
||||
assembly.runtimeKernel.registerStaticObjects(engine, assembly.mode);
|
||||
assembly.runtimeKernel.registerDimensionStack(
|
||||
engine,
|
||||
assembly.mode,
|
||||
assembly.dimensionStackContext
|
||||
);
|
||||
IrisLogging.debug("[IrisEngine timing] setupMode=" + (M.ms() - started) + "ms");
|
||||
started = M.ms();
|
||||
assembly.worldManager = IrisServices.get(EngineWorldManagerProvider.class).create(engine);
|
||||
@@ -112,7 +142,7 @@ final class EngineRuntimeBuilder {
|
||||
}
|
||||
IrisLogging.debug("[IrisEngine timing] IrisWorldManager=" + (M.ms() - started) + "ms");
|
||||
BiomeMaxes biomeMaxes = computeBiomeMaxes();
|
||||
return assembly.freeze(biomeMaxes);
|
||||
return assembly.freezeRuntime(biomeMaxes);
|
||||
} catch (Throwable e) {
|
||||
Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e);
|
||||
if (cleanupFailure != e) {
|
||||
@@ -124,8 +154,60 @@ final class EngineRuntimeBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
GenerationRuntime buildDetachedGenerationRuntime(EngineTarget runtimeTarget, Path mantleStorageDirectory) {
|
||||
return buildDetachedGenerationRuntime(
|
||||
runtimeTarget,
|
||||
mantleStorageDirectory,
|
||||
GenerationKernelRegistry.standard().current(),
|
||||
null);
|
||||
}
|
||||
|
||||
GenerationRuntime buildDetachedGenerationRuntime(
|
||||
EngineTarget runtimeTarget,
|
||||
Path mantleStorageDirectory,
|
||||
GenerationKernelRegistry.Version kernelVersion,
|
||||
TransitionGenerationPlan transitionPlan
|
||||
) {
|
||||
RuntimeAssembly assembly = new RuntimeAssembly(
|
||||
RuntimeAssembly.nextRuntimeId(),
|
||||
runtimeTarget,
|
||||
mantleStorageDirectory,
|
||||
selectRuntimeKernel(kernelVersion),
|
||||
transitionPlan);
|
||||
engine.runtimeAssembly.set(assembly);
|
||||
try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) {
|
||||
IrisLogging.debug("Setup Detached Generation Runtime " + assembly.cacheId);
|
||||
assembly.mantle = assembly.runtimeKernel.createMantle(engine, assembly.mantleStorageDirectory);
|
||||
assembly.ownsMantle = true;
|
||||
assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan);
|
||||
assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine);
|
||||
assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine);
|
||||
assembly.hash32 = CompletableFuture.completedFuture(computePackHash(runtimeTarget.getData()));
|
||||
assembly.mantle.hotload();
|
||||
assembly.mode = assembly.runtimeKernel.createMode(engine);
|
||||
assembly.runtimeKernel.registerStaticObjects(engine, assembly.mode);
|
||||
assembly.runtimeKernel.registerDimensionStack(
|
||||
engine,
|
||||
assembly.mode,
|
||||
assembly.dimensionStackContext
|
||||
);
|
||||
return assembly.freezeGeneration(computeBiomeMaxes());
|
||||
} catch (Throwable e) {
|
||||
Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e);
|
||||
if (cleanupFailure != e) {
|
||||
e.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw new IllegalStateException("Failed to build a detached Iris generation runtime.", e);
|
||||
} finally {
|
||||
engine.runtimeAssembly.remove();
|
||||
}
|
||||
}
|
||||
|
||||
void publishRuntime(EngineRuntime next, EngineRuntime previous) {
|
||||
Throwable retirementFailure = engine.shutdownSequence.closeRuntime(previous, null);
|
||||
Throwable retirementFailure = engine.shutdownSequence.closeRuntime(
|
||||
previous,
|
||||
next.generation().mantle(),
|
||||
null);
|
||||
if (retirementFailure != null) {
|
||||
retirementFailure = engine.shutdownSequence.closeRuntime(next, retirementFailure);
|
||||
engine.lifecycleState = LifecycleState.FAILED;
|
||||
@@ -136,7 +218,7 @@ final class EngineRuntimeBuilder {
|
||||
}
|
||||
|
||||
engine.runtime = next;
|
||||
engine.publishedTarget = next.target();
|
||||
engine.publishedTarget = next.generation().target();
|
||||
engine.getGenerationSessions().activateNextSession();
|
||||
engine.lifecycleState = LifecycleState.RUNNING;
|
||||
engine.getClosing().set(false);
|
||||
@@ -163,33 +245,11 @@ final class EngineRuntimeBuilder {
|
||||
throw new IllegalStateException("Failed to start the Iris world manager.", e);
|
||||
}
|
||||
scheduleRuntimeTasks(next);
|
||||
IrisLogging.debug("Engine Setup Complete " + next.cacheId());
|
||||
IrisLogging.debug("Engine Setup Complete " + next.generation().cacheId());
|
||||
}
|
||||
|
||||
private void scheduleRuntimeTasks(EngineRuntime engineRuntime) {
|
||||
try {
|
||||
if (!engine.backgroundTasks.scheduleTrackedTask(() -> {
|
||||
try {
|
||||
File[] roots = engine.getData().getLoaders()
|
||||
.values()
|
||||
.stream()
|
||||
.map(ResourceLoader::getFolderName)
|
||||
.map(name -> new File(engine.getData().getDataFolder(), name))
|
||||
.filter(File::exists)
|
||||
.filter(File::isDirectory)
|
||||
.toArray(File[]::new);
|
||||
engineRuntime.hash32().complete(IO.hashRecursiveMeta(roots));
|
||||
} catch (Throwable e) {
|
||||
engineRuntime.hash32().completeExceptionally(e);
|
||||
throw propagate(e);
|
||||
}
|
||||
})) {
|
||||
throw new IllegalStateException("Iris background task admission closed before pack hashing.");
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
engineRuntime.hash32().completeExceptionally(e);
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
schedulePackHash(engineRuntime.generation());
|
||||
try {
|
||||
if (!engine.backgroundTasks.scheduleTrackedTask(() -> engine.getPlatformHooks().refreshDatapackWorkspace(engine))) {
|
||||
throw new IllegalStateException("Iris background task admission closed before datapack workspace refresh.");
|
||||
@@ -199,71 +259,34 @@ final class EngineRuntimeBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
UpperDimensionContext buildUpperContext() {
|
||||
IrisDimension dim = engine.getDimension();
|
||||
if (!dim.hasUpperDimension()) {
|
||||
return null;
|
||||
}
|
||||
String upperKey = dim.getUpperDimension();
|
||||
IrisDimension upperDim = upperKey.equals(dim.getLoadKey())
|
||||
? dim
|
||||
: IrisData.loadAnyDimension(upperKey, engine.getData());
|
||||
if (upperDim != null) {
|
||||
UpperDimensionContext ctx = UpperDimensionContext.create(engine, upperDim);
|
||||
IrisLogging.info("Upper dimension enabled: " + upperKey
|
||||
+ (ctx.isSelfReferencing() ? " (self-referencing)" : " (cross-referencing)"));
|
||||
return ctx;
|
||||
}
|
||||
IrisLogging.warn("Upper dimension '" + upperKey + "' could not be resolved, skipping upper terrain.");
|
||||
return null;
|
||||
}
|
||||
|
||||
DimensionStackContext buildDimensionStackContext() {
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
if (!dimension.hasDimensionStack()) {
|
||||
return null;
|
||||
}
|
||||
if (dimension.hasUpperDimension()) {
|
||||
throw new IllegalStateException("Dimension '" + dimension.getLoadKey()
|
||||
+ "' cannot enable both dimensionStack and upperDimension.");
|
||||
}
|
||||
DimensionStackContext context = DimensionStackContext.create(engine, dimension.getDimensionStack());
|
||||
IrisLogging.info("Dimension stack enabled: "
|
||||
+ String.join(" -> ", dimension.getDimensionStack().getDimensions()));
|
||||
return context;
|
||||
}
|
||||
|
||||
private EngineMode createMode() {
|
||||
Throwable configuredFailure = null;
|
||||
private void schedulePackHash(GenerationRuntime generationRuntime) {
|
||||
try {
|
||||
IrisDimensionMode configuredMode = engine.getDimension().getMode();
|
||||
if (configuredMode == null) {
|
||||
configuredMode = new IrisDimensionMode();
|
||||
engine.getDimension().setMode(configuredMode);
|
||||
if (!engine.backgroundTasks.scheduleTrackedTask(() -> {
|
||||
try {
|
||||
generationRuntime.hash32().complete(computePackHash(generationRuntime.data()));
|
||||
} catch (Throwable e) {
|
||||
generationRuntime.hash32().completeExceptionally(e);
|
||||
throw propagate(e);
|
||||
}
|
||||
})) {
|
||||
throw new IllegalStateException("Iris background task admission closed before pack hashing.");
|
||||
}
|
||||
EngineMode configured = configuredMode.create(engine);
|
||||
if (configured == null) {
|
||||
throw new IllegalStateException("Dimension mode factory returned null");
|
||||
}
|
||||
return configured;
|
||||
} catch (Throwable e) {
|
||||
configuredFailure = e;
|
||||
generationRuntime.hash32().completeExceptionally(e);
|
||||
IrisLogging.reportError(e);
|
||||
if (engine.getModeFallbackLogged().compareAndSet(false, true)) {
|
||||
IrisLogging.warn("Failed to initialize configured dimension mode for " + engine.getDimension().getLoadKey() + ", falling back to OVERWORLD mode.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
EngineMode fallback = IrisDimensionModeType.OVERWORLD.create(engine);
|
||||
if (fallback == null) {
|
||||
throw new IllegalStateException("OVERWORLD mode factory returned null");
|
||||
}
|
||||
return fallback;
|
||||
} catch (Throwable fallbackFailure) {
|
||||
fallbackFailure.addSuppressed(configuredFailure);
|
||||
throw new IllegalStateException("Both configured and fallback Iris engine modes failed.", fallbackFailure);
|
||||
}
|
||||
private long computePackHash(IrisData data) {
|
||||
File[] roots = data.getLoaders()
|
||||
.values()
|
||||
.stream()
|
||||
.map(ResourceLoader::getFolderName)
|
||||
.map(name -> new File(data.getDataFolder(), name))
|
||||
.filter(File::exists)
|
||||
.filter(File::isDirectory)
|
||||
.toArray(File[]::new);
|
||||
return IO.hashRecursiveMeta(roots);
|
||||
}
|
||||
|
||||
BiomeMaxes computeBiomeMaxes() {
|
||||
@@ -317,11 +340,27 @@ final class EngineRuntimeBuilder {
|
||||
return current;
|
||||
}
|
||||
|
||||
static GenerationKernelRegistry.RuntimeKernel selectRuntimeKernel(
|
||||
GenerationKernelRegistry.Version version
|
||||
) {
|
||||
try {
|
||||
return GenerationKernelRegistry.standard().select(version);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalArgumentException("Unsupported executable Iris generation kernel "
|
||||
+ version + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
static final class RuntimeAssembly {
|
||||
private static final AtomicInteger RUNTIME_IDS = new AtomicInteger();
|
||||
|
||||
final int cacheId;
|
||||
final EngineTarget target;
|
||||
final Path mantleStorageDirectory;
|
||||
final GenerationKernelRegistry.Version kernelVersion;
|
||||
final GenerationKernelRegistry.RuntimeKernel runtimeKernel;
|
||||
final SeedManager seedManager;
|
||||
final TransitionGenerationPlan transitionPlan;
|
||||
|
||||
static int nextRuntimeId() {
|
||||
return RUNTIME_IDS.incrementAndGet();
|
||||
@@ -331,29 +370,76 @@ final class EngineRuntimeBuilder {
|
||||
DimensionStackContext dimensionStackContext;
|
||||
EngineEffects effects;
|
||||
EngineMode mode;
|
||||
EngineMantle mantle;
|
||||
EngineWorldManager worldManager;
|
||||
CompletableFuture<Long> hash32;
|
||||
boolean ownsMantle;
|
||||
|
||||
RuntimeAssembly(int cacheId, EngineTarget target) {
|
||||
this.cacheId = cacheId;
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
EngineRuntime freeze(BiomeMaxes biomeMaxes) {
|
||||
if (complex == null || effects == null || mode == null || worldManager == null || hash32 == null) {
|
||||
throw new IllegalStateException("Cannot publish an incomplete Iris engine runtime.");
|
||||
}
|
||||
return new EngineRuntime(
|
||||
this(
|
||||
cacheId,
|
||||
target,
|
||||
null,
|
||||
selectRuntimeKernel(GenerationKernelRegistry.standard().current()),
|
||||
null);
|
||||
}
|
||||
|
||||
RuntimeAssembly(int cacheId, EngineTarget target, Path mantleStorageDirectory) {
|
||||
this(
|
||||
cacheId,
|
||||
target,
|
||||
mantleStorageDirectory,
|
||||
selectRuntimeKernel(GenerationKernelRegistry.standard().current()),
|
||||
null);
|
||||
}
|
||||
|
||||
RuntimeAssembly(
|
||||
int cacheId,
|
||||
EngineTarget target,
|
||||
Path mantleStorageDirectory,
|
||||
GenerationKernelRegistry.RuntimeKernel runtimeKernel,
|
||||
TransitionGenerationPlan transitionPlan
|
||||
) {
|
||||
this.cacheId = cacheId;
|
||||
this.target = target;
|
||||
this.mantleStorageDirectory = mantleStorageDirectory == null
|
||||
? null
|
||||
: IrisEngineMantle.normalizeStorageDirectory(mantleStorageDirectory);
|
||||
this.runtimeKernel = Objects.requireNonNull(runtimeKernel, "executable generation kernel");
|
||||
this.seedManager = this.runtimeKernel.createSeedManager(target.getWorld().getRawWorldSeed());
|
||||
this.kernelVersion = this.runtimeKernel.version();
|
||||
this.transitionPlan = transitionPlan;
|
||||
}
|
||||
|
||||
GenerationRuntime freezeGeneration(BiomeMaxes biomeMaxes) {
|
||||
if (complex == null || mode == null || mantle == null || mantleStorageDirectory == null || hash32 == null) {
|
||||
throw new IllegalStateException("Cannot publish an incomplete Iris generation runtime.");
|
||||
}
|
||||
return new GenerationRuntime(
|
||||
cacheId,
|
||||
target,
|
||||
target.getData(),
|
||||
target.getDimension(),
|
||||
kernelVersion,
|
||||
runtimeKernel,
|
||||
seedManager,
|
||||
transitionPlan,
|
||||
complex,
|
||||
upperContext,
|
||||
dimensionStackContext,
|
||||
effects,
|
||||
mode,
|
||||
worldManager,
|
||||
mantle,
|
||||
mantleStorageDirectory,
|
||||
hash32,
|
||||
biomeMaxes);
|
||||
}
|
||||
|
||||
EngineRuntime freezeRuntime(BiomeMaxes biomeMaxes) {
|
||||
if (effects == null || worldManager == null) {
|
||||
throw new IllegalStateException("Cannot publish an incomplete Iris engine runtime.");
|
||||
}
|
||||
return new EngineRuntime(freezeGeneration(biomeMaxes), effects, worldManager);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,10 @@ import art.arcane.iris.engine.EngineBackgroundTasks.BackgroundTaskDrain;
|
||||
import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly;
|
||||
import art.arcane.iris.engine.IrisEngine.LifecycleState;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
|
||||
import art.arcane.iris.engine.framework.PreservationRegistry;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
|
||||
@@ -38,7 +40,6 @@ final class EngineShutdownSequence {
|
||||
private final IrisEngine engine;
|
||||
private boolean runtimeReleased;
|
||||
private boolean targetReleased;
|
||||
private boolean mantleReleased;
|
||||
private boolean engineDataReleased;
|
||||
private boolean preservationReleased;
|
||||
|
||||
@@ -47,6 +48,17 @@ final class EngineShutdownSequence {
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (!engine.beginShutdown()) {
|
||||
return;
|
||||
}
|
||||
Throwable routerFailure = runCleanup(null, engine::closeAttachedGenerationHistoryRuntimeRouter);
|
||||
if (routerFailure != null) {
|
||||
synchronized (engine.lifecycleLock) {
|
||||
engine.lifecycleState = LifecycleState.FAILED;
|
||||
}
|
||||
reportIncompleteClose(routerFailure);
|
||||
return;
|
||||
}
|
||||
Throwable failure;
|
||||
synchronized (engine.lifecycleLock) {
|
||||
if (engine.closed) {
|
||||
@@ -122,14 +134,10 @@ final class EngineShutdownSequence {
|
||||
if (runtimeReleased) {
|
||||
failure = releaseTarget(failure);
|
||||
}
|
||||
if (targetReleased) {
|
||||
failure = releaseMantle(failure);
|
||||
}
|
||||
if (prefetchFailure == null
|
||||
&& engineDataFailure == null
|
||||
&& runtimeReleased
|
||||
&& targetReleased
|
||||
&& mantleReleased) {
|
||||
&& targetReleased) {
|
||||
failure = releaseEngineDataForShutdown(failure);
|
||||
}
|
||||
if (engineDataReleased) {
|
||||
@@ -140,7 +148,6 @@ final class EngineShutdownSequence {
|
||||
if (failure == null
|
||||
&& runtimeReleased
|
||||
&& targetReleased
|
||||
&& mantleReleased
|
||||
&& engineDataReleased
|
||||
&& preservationReleased) {
|
||||
engine.closed = true;
|
||||
@@ -185,10 +192,10 @@ final class EngineShutdownSequence {
|
||||
() -> NativeStructureOwnershipStore.close(engine));
|
||||
cleanupFailure = appendFailure(cleanupFailure, ownershipFailure);
|
||||
if (ownershipFailure == null) {
|
||||
cleanupFailure = closeDetachedGenerationRuntimes(cleanupFailure);
|
||||
cleanupFailure = closeRuntime(engine.runtime, cleanupFailure);
|
||||
engine.runtime = null;
|
||||
cleanupFailure = runCleanup(cleanupFailure, engine.getTarget()::close);
|
||||
cleanupFailure = runCleanup(cleanupFailure, engine.getMantle()::close);
|
||||
cleanupFailure = runCleanup(cleanupFailure, engine.publishedTarget::close);
|
||||
cleanupFailure = runCleanup(cleanupFailure, engine.engineDataStore::releaseEngineData);
|
||||
engine.closed = true;
|
||||
cleanupFailure = runCleanup(cleanupFailure, () -> {
|
||||
@@ -232,18 +239,69 @@ final class EngineShutdownSequence {
|
||||
assembly.hash32.cancel(true);
|
||||
}
|
||||
});
|
||||
if (assembly.mantle != null && assembly.ownsMantle) {
|
||||
assembly.ownsMantle = false;
|
||||
failure = runCleanup(failure, assembly.mantle::saveAllNow);
|
||||
failure = runCleanup(failure, assembly.mantle::close);
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
|
||||
Throwable closeRuntime(EngineRuntime engineRuntime, Throwable failure) {
|
||||
return closeRuntime(engineRuntime, null, failure);
|
||||
}
|
||||
|
||||
Throwable closeRuntime(
|
||||
EngineRuntime engineRuntime,
|
||||
EngineMantle retainedMantle,
|
||||
Throwable failure
|
||||
) {
|
||||
if (engineRuntime == null) {
|
||||
return failure;
|
||||
}
|
||||
failure = runCleanup(failure, engineRuntime.worldManager()::close);
|
||||
failure = runCleanup(failure, engineRuntime.effects()::close);
|
||||
failure = runCleanup(failure, engineRuntime.mode()::close);
|
||||
failure = runCleanup(failure, engineRuntime.complex()::close);
|
||||
failure = runCleanup(failure, () -> engineRuntime.hash32().cancel(true));
|
||||
return closeGenerationRuntime(engineRuntime.generation(), retainedMantle, failure);
|
||||
}
|
||||
|
||||
Throwable closeGenerationRuntime(GenerationRuntime generationRuntime, Throwable failure) {
|
||||
return closeGenerationRuntime(generationRuntime, null, failure);
|
||||
}
|
||||
|
||||
Throwable closeGenerationRuntime(
|
||||
GenerationRuntime generationRuntime,
|
||||
EngineMantle retainedMantle,
|
||||
Throwable failure
|
||||
) {
|
||||
if (generationRuntime == null) {
|
||||
return failure;
|
||||
}
|
||||
failure = runCleanup(failure, generationRuntime.mode()::close);
|
||||
failure = runCleanup(failure, generationRuntime.complex()::close);
|
||||
failure = runCleanup(failure, () -> generationRuntime.hash32().cancel(true));
|
||||
if (generationRuntime.mantle() != retainedMantle) {
|
||||
failure = runCleanup(failure, generationRuntime.mantle()::saveAllNow);
|
||||
failure = runCleanup(failure, generationRuntime.mantle()::close);
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
|
||||
Throwable closeDetachedGenerationRuntime(GenerationRuntime generationRuntime, Throwable failure) {
|
||||
IrisEngine.GenerationRuntimeBinding binding = new IrisEngine.GenerationRuntimeBinding(
|
||||
engine,
|
||||
generationRuntime);
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.generationRuntimeScopes.open(binding)) {
|
||||
failure = closeGenerationRuntime(generationRuntime, failure);
|
||||
} catch (Throwable scopeFailure) {
|
||||
failure = appendFailure(failure, scopeFailure);
|
||||
}
|
||||
return closeDetachedTarget(generationRuntime.target(), failure);
|
||||
}
|
||||
|
||||
Throwable closeDetachedTarget(EngineTarget target, Throwable failure) {
|
||||
failure = runCleanup(failure, () -> target.getData().unregisterEngine(engine));
|
||||
failure = runCleanup(failure, target::close);
|
||||
failure = runCleanup(failure, target.getData()::close);
|
||||
return failure;
|
||||
}
|
||||
|
||||
@@ -251,6 +309,10 @@ final class EngineShutdownSequence {
|
||||
if (runtimeReleased) {
|
||||
return failure;
|
||||
}
|
||||
Throwable detachedFailure = closeDetachedGenerationRuntimes(null);
|
||||
if (detachedFailure != null) {
|
||||
return appendFailure(failure, detachedFailure);
|
||||
}
|
||||
Throwable runtimeFailure = closeRuntime(engine.runtime, null);
|
||||
if (runtimeFailure != null) {
|
||||
return appendFailure(failure, runtimeFailure);
|
||||
@@ -260,11 +322,23 @@ final class EngineShutdownSequence {
|
||||
return failure;
|
||||
}
|
||||
|
||||
private Throwable closeDetachedGenerationRuntimes(Throwable failure) {
|
||||
GenerationRuntime[] detachedRuntimes = engine.detachedGenerationRuntimes.toArray(new GenerationRuntime[0]);
|
||||
for (GenerationRuntime detached : detachedRuntimes) {
|
||||
Throwable detachedFailure = closeDetachedGenerationRuntime(detached, null);
|
||||
failure = appendFailure(failure, detachedFailure);
|
||||
if (detachedFailure == null) {
|
||||
engine.detachedGenerationRuntimes.remove(detached);
|
||||
}
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
|
||||
private Throwable releaseTarget(Throwable failure) {
|
||||
if (targetReleased) {
|
||||
return failure;
|
||||
}
|
||||
Throwable targetFailure = runCleanup(null, engine.getTarget()::close);
|
||||
Throwable targetFailure = runCleanup(null, engine.publishedTarget::close);
|
||||
if (targetFailure != null) {
|
||||
return appendFailure(failure, targetFailure);
|
||||
}
|
||||
@@ -272,18 +346,6 @@ final class EngineShutdownSequence {
|
||||
return failure;
|
||||
}
|
||||
|
||||
private Throwable releaseMantle(Throwable failure) {
|
||||
if (mantleReleased) {
|
||||
return failure;
|
||||
}
|
||||
Throwable mantleFailure = runCleanup(null, engine.getMantle()::close);
|
||||
if (mantleFailure != null) {
|
||||
return appendFailure(failure, mantleFailure);
|
||||
}
|
||||
mantleReleased = true;
|
||||
return failure;
|
||||
}
|
||||
|
||||
private Throwable releaseEngineDataForShutdown(Throwable failure) {
|
||||
if (engineDataReleased) {
|
||||
return failure;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.EngineMode;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.history.GenerationKernelRegistry;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
record GenerationRuntime(
|
||||
int cacheId,
|
||||
EngineTarget target,
|
||||
IrisData data,
|
||||
IrisDimension dimension,
|
||||
GenerationKernelRegistry.Version kernelVersion,
|
||||
GenerationKernelRegistry.RuntimeKernel runtimeKernel,
|
||||
SeedManager seedManager,
|
||||
TransitionGenerationPlan transitionPlan,
|
||||
IrisComplex complex,
|
||||
UpperDimensionContext upperContext,
|
||||
DimensionStackContext dimensionStackContext,
|
||||
EngineMode mode,
|
||||
EngineMantle mantle,
|
||||
Path mantleStorageDirectory,
|
||||
CompletableFuture<Long> hash32,
|
||||
BiomeMaxes biomeMaxes
|
||||
) {
|
||||
GenerationRuntime {
|
||||
Objects.requireNonNull(target, "generation target");
|
||||
Objects.requireNonNull(data, "generation data");
|
||||
Objects.requireNonNull(dimension, "generation dimension");
|
||||
Objects.requireNonNull(kernelVersion, "generation kernel version");
|
||||
Objects.requireNonNull(runtimeKernel, "executable generation kernel");
|
||||
Objects.requireNonNull(seedManager, "generation seed manager");
|
||||
if (!kernelVersion.equals(runtimeKernel.version())) {
|
||||
throw new IllegalArgumentException("Generation kernel version does not match its executable runtime.");
|
||||
}
|
||||
Objects.requireNonNull(complex, "generation complex");
|
||||
Objects.requireNonNull(mode, "generation mode");
|
||||
Objects.requireNonNull(mantle, "generation mantle");
|
||||
mantleStorageDirectory = IrisEngineMantle.normalizeStorageDirectory(mantleStorageDirectory);
|
||||
Objects.requireNonNull(hash32, "generation pack hash");
|
||||
Objects.requireNonNull(biomeMaxes, "generation biome maximums");
|
||||
if (target.getData() != data || target.getDimension() != dimension) {
|
||||
throw new IllegalArgumentException("Generation target, data, and dimension must describe one runtime.");
|
||||
}
|
||||
}
|
||||
|
||||
GenerationRuntime withComplex(
|
||||
int nextCacheId,
|
||||
IrisComplex nextComplex,
|
||||
UpperDimensionContext nextUpperContext,
|
||||
DimensionStackContext nextDimensionStackContext,
|
||||
BiomeMaxes nextBiomeMaxes
|
||||
) {
|
||||
return new GenerationRuntime(
|
||||
nextCacheId,
|
||||
target,
|
||||
data,
|
||||
dimension,
|
||||
kernelVersion,
|
||||
runtimeKernel,
|
||||
seedManager,
|
||||
transitionPlan,
|
||||
nextComplex,
|
||||
nextUpperContext,
|
||||
nextDimensionStackContext,
|
||||
mode,
|
||||
mantle,
|
||||
mantleStorageDirectory,
|
||||
hash32,
|
||||
nextBiomeMaxes);
|
||||
}
|
||||
|
||||
record BiomeMaxes(double objectDensity, double layerDensity, double decoratorDensity) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.engine.IrisEngine.GenerationRuntimeBinding;
|
||||
import art.arcane.iris.engine.IrisEngine.GenerationRuntimeScope;
|
||||
|
||||
final class GenerationRuntimeScopeState {
|
||||
private final ThreadLocal<GenerationRuntimeBinding> current = new ThreadLocal<>();
|
||||
|
||||
GenerationRuntimeBinding current() {
|
||||
return current.get();
|
||||
}
|
||||
|
||||
GenerationRuntimeScope open(GenerationRuntimeBinding binding) {
|
||||
Thread owner = Thread.currentThread();
|
||||
GenerationRuntimeBinding previous = current.get();
|
||||
current.set(binding);
|
||||
return new GenerationRuntimeScope(this, owner, previous, binding);
|
||||
}
|
||||
|
||||
void close(
|
||||
Thread owner,
|
||||
GenerationRuntimeBinding previous,
|
||||
GenerationRuntimeBinding installed
|
||||
) {
|
||||
if (Thread.currentThread() != owner) {
|
||||
throw new IllegalStateException("Iris generation runtime scope closed from a different thread.");
|
||||
}
|
||||
if (current.get() != installed) {
|
||||
throw new IllegalStateException("Iris generation runtime scopes must close in LIFO order.");
|
||||
}
|
||||
if (previous == null) {
|
||||
current.remove();
|
||||
return;
|
||||
}
|
||||
current.set(previous);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.IrisRegistrant;
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.GenerationBlend;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
|
||||
@@ -66,6 +68,7 @@ import lombok.ToString;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumMap;
|
||||
@@ -75,6 +78,7 @@ import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
@@ -114,6 +118,7 @@ public class IrisComplex implements DataProvider {
|
||||
private final transient IrisGenerator[][] frozenGenerators;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final transient Map<IrisRegion, Map<InferredType, ProceduralStream<IrisBiome>>> inferredBiomeStreams;
|
||||
private final TransitionGenerationPlan transitionGenerationPlan;
|
||||
private RNG rng;
|
||||
private double fluidHeight;
|
||||
private IrisData data;
|
||||
@@ -130,9 +135,11 @@ public class IrisComplex implements DataProvider {
|
||||
private ProceduralStream<IrisBiome> baseBiomeStream;
|
||||
private ProceduralStream<UUID> baseBiomeIDStream;
|
||||
private ProceduralStream<IrisBiome> naturalTrueBiomeStream;
|
||||
private ProceduralStream<IrisBiome> unblendedNaturalTrueBiomeStream;
|
||||
private ProceduralStream<IrisBiome> trueBiomeStream;
|
||||
private ProceduralStream<PlatformBiome> trueBiomeDerivativeStream;
|
||||
private ProceduralStream<Double> naturalHeightStream;
|
||||
private ProceduralStream<Double> unblendedNaturalHeightStream;
|
||||
private ProceduralStream<Double> heightStream;
|
||||
private ProceduralStream<Integer> roundedHeighteightStream;
|
||||
private ProceduralStream<Double> maxHeightStream;
|
||||
@@ -168,10 +175,19 @@ public class IrisComplex implements DataProvider {
|
||||
private final Object childSelectionPlanLock = new Object();
|
||||
|
||||
public IrisComplex(Engine engine) {
|
||||
this(engine, false);
|
||||
this(engine, false, null);
|
||||
}
|
||||
|
||||
public IrisComplex(Engine engine, boolean simple) {
|
||||
this(engine, simple, null);
|
||||
}
|
||||
|
||||
public IrisComplex(Engine engine, TransitionGenerationPlan transitionGenerationPlan) {
|
||||
this(engine, false, transitionGenerationPlan);
|
||||
}
|
||||
|
||||
IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan) {
|
||||
this.transitionGenerationPlan = transitionGenerationPlan;
|
||||
int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize());
|
||||
IrisBiome emptyBiome = new IrisBiome().setInferredType(InferredType.CAVE);
|
||||
UUID focusUUID = UUID.nameUUIDFromBytes("focus".getBytes());
|
||||
@@ -294,10 +310,15 @@ public class IrisComplex implements DataProvider {
|
||||
return mapped == null ? biome : mapped;
|
||||
})
|
||||
.cache2D("imageMappedBaseBiomeStream", engine, cacheSize);
|
||||
naturalHeightStream = ProceduralStream.of(
|
||||
(x, z) -> sampleNaturalTerrainHeight(engine, x, z),
|
||||
unblendedNaturalHeightStream = ProceduralStream.of(
|
||||
(x, z) -> sampleUnblendedNaturalTerrainHeight(engine, x, z),
|
||||
Interpolated.DOUBLE
|
||||
).cache2DDouble("naturalHeightStream", engine, cacheSize);
|
||||
).cache2DDouble("unblendedNaturalHeightStream", engine, cacheSize);
|
||||
naturalHeightStream = transitionGenerationPlan == null ? unblendedNaturalHeightStream
|
||||
: unblendedNaturalHeightStream.convertAware2D((newHeight, x, z) -> blendNaturalTerrainHeight(
|
||||
newHeight,
|
||||
transitionGenerationPlan.terrainSampleAt(blockCoordinate(x), blockCoordinate(z))))
|
||||
.cache2DDouble("naturalHeightStream", engine, cacheSize);
|
||||
naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
|
||||
b -> focusBiome))
|
||||
.cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream
|
||||
@@ -308,6 +329,14 @@ public class IrisComplex implements DataProvider {
|
||||
: mapped;
|
||||
})
|
||||
.cache2D("naturalTrueBiomeStream", engine, cacheSize);
|
||||
unblendedNaturalTrueBiomeStream = transitionGenerationPlan == null || focusBiome != null
|
||||
? naturalTrueBiomeStream
|
||||
: unblendedNaturalHeightStream.convertAware2D((h, x, z) -> {
|
||||
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
|
||||
return mapped == null
|
||||
? fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, fluidHeight)
|
||||
: mapped;
|
||||
}).cache2D("unblendedNaturalTrueBiomeStream", engine, cacheSize);
|
||||
IrisHydrology configuredHydrology = engine.getDimension().getHydrology();
|
||||
if (hydrologyActive(configuredHydrology)) {
|
||||
hydrologyRuntime = new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(
|
||||
@@ -451,10 +480,35 @@ public class IrisComplex implements DataProvider {
|
||||
}
|
||||
|
||||
private double sampleNaturalTerrainHeight(Engine engine, double x, double z) {
|
||||
double newHeight = sampleUnblendedNaturalTerrainHeight(engine, x, z);
|
||||
if (transitionGenerationPlan == null) {
|
||||
return newHeight;
|
||||
}
|
||||
TransitionGenerationPlan.TerrainSample sample = transitionGenerationPlan.terrainSampleAt(
|
||||
blockCoordinate(x),
|
||||
blockCoordinate(z));
|
||||
return blendNaturalTerrainHeight(newHeight, sample);
|
||||
}
|
||||
|
||||
private double sampleUnblendedNaturalTerrainHeight(Engine engine, double x, double z) {
|
||||
double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight());
|
||||
return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight);
|
||||
}
|
||||
|
||||
static double blendNaturalTerrainHeight(
|
||||
double newHeight,
|
||||
TransitionGenerationPlan.TerrainSample sample
|
||||
) {
|
||||
Objects.requireNonNull(sample, "terrain transition sample");
|
||||
if (sample.newEpochWeight() == 1D || !sample.hasHistoricalSignature()) {
|
||||
return newHeight;
|
||||
}
|
||||
double historicalHeight = sample.nearestSignature().fluidHeight().isPresent()
|
||||
? sample.historicalOceanFloorHeight()
|
||||
: sample.historicalSurfaceHeight();
|
||||
return GenerationBlend.interpolate(historicalHeight, newHeight, sample.newEpochWeight());
|
||||
}
|
||||
|
||||
static double calculateNaturalSlope(double naturalHeight, double easternHeight, double southernHeight) {
|
||||
double deltaX = easternHeight - naturalHeight;
|
||||
double deltaZ = southernHeight - naturalHeight;
|
||||
@@ -687,7 +741,9 @@ public class IrisComplex implements DataProvider {
|
||||
if (layer == null) {
|
||||
return 0D;
|
||||
}
|
||||
return layer.feature().flowDeltaX() == 0 && layer.feature().flowDeltaZ() == 0 ? 0D : 1D;
|
||||
return layer.feature().flowDeltaX() == 0 && layer.feature().flowDeltaZ() == 0
|
||||
? 0D
|
||||
: transitionHydrologyWeight(x, z);
|
||||
}
|
||||
|
||||
private double resolveHydrologyCarveWeight(double x, double z) {
|
||||
@@ -695,10 +751,11 @@ public class IrisComplex implements DataProvider {
|
||||
if (layer == null) {
|
||||
return 0D;
|
||||
}
|
||||
double hydrologyWeight = transitionHydrologyWeight(x, z);
|
||||
if (layer.channel()) {
|
||||
return 1D;
|
||||
return hydrologyWeight;
|
||||
}
|
||||
return layer.shore() ? 0.75D : 0.5D;
|
||||
return hydrologyWeight * (layer.shore() ? 0.75D : 0.5D);
|
||||
}
|
||||
|
||||
private double resolveHydrologyFluidSurface(double x, double z) {
|
||||
@@ -706,13 +763,144 @@ public class IrisComplex implements DataProvider {
|
||||
return layer == null ? fluidHeight : layer.fluidHeadY();
|
||||
}
|
||||
|
||||
public HydrologyColumnSample sampleHydrologyColumn(double x, double z) {
|
||||
if (hydrologyRuntime == null) {
|
||||
return null;
|
||||
}
|
||||
if (transitionGenerationPlan == null) {
|
||||
return hydrologyRuntime.sample(x, z).orElse(null);
|
||||
}
|
||||
double hydrologyWeight = transitionHydrologyWeight(x, z);
|
||||
if (hydrologyWeight == 0D) {
|
||||
return null;
|
||||
}
|
||||
HydrologyColumnSample sample = hydrologyRuntime.sample(x, z).orElse(null);
|
||||
if (sample == null || hydrologyWeight == 1D) {
|
||||
return sample;
|
||||
}
|
||||
return taperHydrologySample(sample, hydrologyWeight);
|
||||
}
|
||||
|
||||
static HydrologyColumnSample taperHydrologySample(
|
||||
HydrologyColumnSample sample,
|
||||
double hydrologyWeight
|
||||
) {
|
||||
Objects.requireNonNull(sample, "hydrology sample");
|
||||
if (hydrologyWeight == 1D) {
|
||||
return sample;
|
||||
}
|
||||
ArrayList<HydrologyColumnLayer> taperedLayers = new ArrayList<>(sample.layers().size());
|
||||
for (HydrologyColumnLayer layer : sample.layers()) {
|
||||
taperedLayers.add(taperHydrologyLayer(layer, sample.naturalHeight(), hydrologyWeight));
|
||||
}
|
||||
return new HydrologyColumnSample(
|
||||
sample.x(),
|
||||
sample.z(),
|
||||
sample.naturalHeight(),
|
||||
sample.seaLevel(),
|
||||
sample.ocean(),
|
||||
sample.parentBiomeKey(),
|
||||
taperedLayers);
|
||||
}
|
||||
|
||||
private static HydrologyColumnLayer taperHydrologyLayer(
|
||||
HydrologyColumnLayer layer,
|
||||
int naturalHeight,
|
||||
double hydrologyWeight
|
||||
) {
|
||||
return new HydrologyColumnLayer(
|
||||
layer.feature(),
|
||||
GenerationBlend.interpolateHeight(naturalHeight, layer.bedY(), hydrologyWeight),
|
||||
GenerationBlend.interpolateHeight(naturalHeight, layer.fluidHeadY(), hydrologyWeight),
|
||||
GenerationBlend.interpolateHeight(naturalHeight, layer.ceilingY(), hydrologyWeight),
|
||||
layer.channel(),
|
||||
layer.shore(),
|
||||
layer.grading(),
|
||||
layer.connectedFluid(),
|
||||
layer.fallingFluid(),
|
||||
layer.receivingPool(),
|
||||
layer.terrainOwned(),
|
||||
layer.fluidOwned(),
|
||||
layer.oceanApron(),
|
||||
layer.profileKey(),
|
||||
layer.surfaceBiomeKey(),
|
||||
layer.mouthBiomeKey(),
|
||||
layer.shoreBiomeKey(),
|
||||
layer.bankBiomeKey(),
|
||||
layer.floodedCaveBiomeKey());
|
||||
}
|
||||
|
||||
private HydrologyColumnSample hydrologySample(double x, double z) {
|
||||
return hydrologyRuntime == null ? null : hydrologyRuntime.sample(x, z).orElse(null);
|
||||
return sampleHydrologyColumn(x, z);
|
||||
}
|
||||
|
||||
private double transitionHydrologyWeight(double x, double z) {
|
||||
return transitionGenerationPlan == null
|
||||
? 1D
|
||||
: transitionGenerationPlan.hydrologyWeightAt(blockCoordinate(x), blockCoordinate(z));
|
||||
}
|
||||
|
||||
/** Whether the column's hydrology can be sampled without waiting for a plan (see Engine.answersFromNaturalTerrain). */
|
||||
public boolean isHydrologyPlanned(int x, int z) {
|
||||
return hydrologyRuntime == null || hydrologyRuntime.isPlanned(x, z);
|
||||
return hydrologyRuntime == null
|
||||
|| transitionHydrologyWeight(x, z) == 0D
|
||||
|| hydrologyRuntime.isPlanned(x, z);
|
||||
}
|
||||
|
||||
public boolean isHistoricalChunk(int chunkX, int chunkZ) {
|
||||
return transitionGenerationPlan != null
|
||||
&& transitionGenerationPlan.boundary().isHistoricalChunk(chunkX, chunkZ);
|
||||
}
|
||||
|
||||
public boolean allowsMantleWrite(int blockX, int blockZ) {
|
||||
return transitionGenerationPlan == null
|
||||
|| !transitionGenerationPlan.isHistoricalBlock(blockX, blockZ);
|
||||
}
|
||||
|
||||
public boolean allowsMantleChunkWrite(int chunkX, int chunkZ) {
|
||||
return transitionGenerationPlan == null
|
||||
|| !transitionGenerationPlan.boundary().isHistoricalChunk(chunkX, chunkZ);
|
||||
}
|
||||
|
||||
public boolean allowsNewDiscreteContentAt(int blockX, int blockZ) {
|
||||
return transitionGenerationPlan == null
|
||||
|| transitionGenerationPlan.allowsNewDiscreteContentAt(blockX, blockZ);
|
||||
}
|
||||
|
||||
public boolean allowsNewGenerationChunk(int chunkX, int chunkZ) {
|
||||
if (transitionGenerationPlan == null) {
|
||||
return true;
|
||||
}
|
||||
int minimumX = Math.multiplyExact(chunkX, 16);
|
||||
int minimumZ = Math.multiplyExact(chunkZ, 16);
|
||||
return transitionGenerationPlan.allowsNewFootprint(
|
||||
minimumX,
|
||||
minimumZ,
|
||||
Math.addExact(minimumX, 15),
|
||||
Math.addExact(minimumZ, 15));
|
||||
}
|
||||
|
||||
public boolean allowsNewGenerationFootprint(
|
||||
int minimumX,
|
||||
int minimumZ,
|
||||
int maximumX,
|
||||
int maximumZ
|
||||
) {
|
||||
return transitionGenerationPlan == null
|
||||
|| transitionGenerationPlan.allowsNewFootprint(minimumX, minimumZ, maximumX, maximumZ);
|
||||
}
|
||||
|
||||
public Optional<String> historicalPhysicalBiomeKeyAt(int blockX, int blockY, int blockZ) {
|
||||
return transitionGenerationPlan == null
|
||||
? Optional.empty()
|
||||
: transitionGenerationPlan.historicalPhysicalBiomeKeyAt(blockX, blockY, blockZ);
|
||||
}
|
||||
|
||||
private static int blockCoordinate(double coordinate) {
|
||||
if (!Double.isFinite(coordinate)) {
|
||||
throw new IllegalArgumentException("Generation coordinate must be finite");
|
||||
}
|
||||
return (int) Math.floor(coordinate);
|
||||
}
|
||||
|
||||
/** The natural terrain height of a column, from the natural height stream only: never touches hydrology or its caches. */
|
||||
|
||||
@@ -20,6 +20,7 @@ package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.engine.EngineBackgroundTasks.BackgroundTaskDrain;
|
||||
import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineEffects;
|
||||
import art.arcane.iris.engine.framework.EngineMetrics;
|
||||
@@ -33,10 +34,16 @@ import art.arcane.iris.engine.framework.EngineWorldManager;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionManager;
|
||||
import art.arcane.iris.engine.history.GenerationBoundarySignatureSampler;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.engine.history.GenerationKernelRegistry;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.framework.WrongEngineBroException;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.engine.object.IrisEngineData;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.core.compat.PackCompatReport;
|
||||
@@ -59,6 +66,7 @@ import art.arcane.volmlib.util.atomics.AtomicRollingSequence;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
import art.arcane.volmlib.util.documentation.BlockCoordinates;
|
||||
import art.arcane.volmlib.util.documentation.ChunkCoordinates;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
|
||||
import art.arcane.volmlib.util.math.M;
|
||||
@@ -71,17 +79,23 @@ import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.IntConsumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@Data
|
||||
@@ -96,7 +110,9 @@ public class IrisEngine implements Engine {
|
||||
private final AtomicLong lastGPS;
|
||||
private final EngineTarget target;
|
||||
private final InitializationMode initializationMode;
|
||||
private final EngineMantle mantle;
|
||||
private final Path initialMantleStorageDirectory;
|
||||
private final GenerationKernelRegistry.Version initialKernelVersion;
|
||||
private final TransitionGenerationPlan initialTransitionPlan;
|
||||
private final ChronoLatch perSecondLatch;
|
||||
private final ChronoLatch perSecondBudLatch;
|
||||
private final EngineMetrics metrics;
|
||||
@@ -108,9 +124,26 @@ public class IrisEngine implements Engine {
|
||||
final Object lifecycleLock = new Object();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
final Object generationHistoryRuntimeRouterLock = new Object();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
final ThreadLocal<RuntimeAssembly> runtimeAssembly = new ThreadLocal<>();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
final GenerationRuntimeScopeState generationRuntimeScopes = new GenerationRuntimeScopeState();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
final Set<GenerationRuntime> detachedGenerationRuntimes = Collections.synchronizedSet(
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
final Set<GenerationRuntime> retiringGenerationRuntimes = Collections.synchronizedSet(
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
final Set<IntConsumer> generationRuntimeRetirementListeners = new CopyOnWriteArraySet<>();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
final EngineBackgroundTasks backgroundTasks = new EngineBackgroundTasks();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
@@ -147,6 +180,12 @@ public class IrisEngine implements Engine {
|
||||
volatile EngineRuntime runtime;
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
volatile GenerationHistoryRuntimeRouter generationHistoryRuntimeRouter;
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
private boolean generationHistoryRoutingRequired;
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
volatile EngineTarget publishedTarget;
|
||||
private volatile int parallelism;
|
||||
private volatile boolean failing;
|
||||
@@ -176,11 +215,57 @@ public class IrisEngine implements Engine {
|
||||
}
|
||||
|
||||
public IrisEngine(EngineTarget target, InitializationMode initializationMode) {
|
||||
this(
|
||||
target,
|
||||
initializationMode,
|
||||
IrisEngineMantle.legacyStorageDirectory(target),
|
||||
GenerationKernelRegistry.standard().current(),
|
||||
null);
|
||||
}
|
||||
|
||||
public IrisEngine(
|
||||
EngineTarget target,
|
||||
InitializationMode initializationMode,
|
||||
Path mantleStorageDirectory
|
||||
) {
|
||||
this(
|
||||
target,
|
||||
initializationMode,
|
||||
mantleStorageDirectory,
|
||||
GenerationKernelRegistry.standard().current(),
|
||||
null);
|
||||
}
|
||||
|
||||
public IrisEngine(
|
||||
EngineTarget target,
|
||||
InitializationMode initializationMode,
|
||||
Path mantleStorageDirectory,
|
||||
TransitionGenerationPlan transitionPlan
|
||||
) {
|
||||
this(
|
||||
target,
|
||||
initializationMode,
|
||||
mantleStorageDirectory,
|
||||
GenerationKernelRegistry.standard().current(),
|
||||
transitionPlan);
|
||||
}
|
||||
|
||||
public IrisEngine(
|
||||
EngineTarget target,
|
||||
InitializationMode initializationMode,
|
||||
Path mantleStorageDirectory,
|
||||
GenerationKernelRegistry.Version kernelVersion,
|
||||
TransitionGenerationPlan transitionPlan
|
||||
) {
|
||||
EngineTarget requiredTarget = Objects.requireNonNull(target, "engine target");
|
||||
InitializationMode requiredMode = Objects.requireNonNull(initializationMode, "initialization mode");
|
||||
this.initializationMode = requiredMode;
|
||||
this.studio = requiredMode.studio();
|
||||
this.target = target;
|
||||
this.publishedTarget = target;
|
||||
this.target = requiredTarget;
|
||||
this.publishedTarget = requiredTarget;
|
||||
this.initialMantleStorageDirectory = IrisEngineMantle.normalizeStorageDirectory(mantleStorageDirectory);
|
||||
this.initialKernelVersion = Objects.requireNonNull(kernelVersion, "generation kernel version");
|
||||
this.initialTransitionPlan = transitionPlan;
|
||||
this.platformHooks = IrisServices.get(EnginePlatformHooks.class);
|
||||
this.generationSessions = new GenerationSessionManager();
|
||||
this.closing = new AtomicBoolean(true);
|
||||
@@ -190,7 +275,8 @@ public class IrisEngine implements Engine {
|
||||
this.failing = false;
|
||||
getEngineData();
|
||||
verifySeed();
|
||||
this.seedManager = new SeedManager(target.getWorld().getRawWorldSeed());
|
||||
this.seedManager = EngineRuntimeBuilder.selectRuntimeKernel(initialKernelVersion)
|
||||
.createSeedManager(requiredTarget.getWorld().getRawWorldSeed());
|
||||
bud = new AtomicInteger(0);
|
||||
buds = new AtomicInteger(0);
|
||||
metrics = new EngineMetrics(32);
|
||||
@@ -203,9 +289,7 @@ public class IrisEngine implements Engine {
|
||||
wallClock = new AtomicRollingSequence(32);
|
||||
lastGPS = new AtomicLong(M.ms());
|
||||
generated = new AtomicInteger(0);
|
||||
long _t0 = M.ms();
|
||||
mantle = new IrisEngineMantle(this);
|
||||
IrisLogging.debug("[IrisEngine timing] new IrisEngineMantle=" + (M.ms() - _t0) + "ms");
|
||||
long _t0;
|
||||
cleaning = new AtomicBoolean(false);
|
||||
modeFallbackLogged = new AtomicBoolean(false);
|
||||
prefetchSaveStarted = new AtomicBoolean(false);
|
||||
@@ -235,7 +319,7 @@ public class IrisEngine implements Engine {
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
IrisLogging.notice("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed());
|
||||
IrisLogging.notice("Engine init: " + requiredTarget.getWorld().name() + "/" + requiredTarget.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed());
|
||||
_t0 = M.ms();
|
||||
phaseStartedAt = System.nanoTime();
|
||||
EngineRuntime initialRuntime = runtimeBuilder.buildRuntime();
|
||||
@@ -258,7 +342,7 @@ public class IrisEngine implements Engine {
|
||||
EngineTickRegistry.registerTicking(this);
|
||||
} catch (Throwable e) {
|
||||
shutdownSequence.cleanupFailedConstruction(e);
|
||||
throw new IllegalStateException("Failed to initialize Iris engine for world '" + target.getWorld().name() + "'.", e);
|
||||
throw new IllegalStateException("Failed to initialize Iris engine for world '" + requiredTarget.getWorld().name() + "'.", e);
|
||||
}
|
||||
IrisLogging.debug("Engine Initialized " + getCacheID());
|
||||
}
|
||||
@@ -349,7 +433,7 @@ public class IrisEngine implements Engine {
|
||||
IrisLogging.warn("Studio packs changed during runtime compilation; shared generation caches are disabled.");
|
||||
return;
|
||||
}
|
||||
runtime.complex().enableStudioHydrologyCache(initialIdentity, studioHydrologyCacheRoot());
|
||||
runtime.generation().complex().enableStudioHydrologyCache(initialIdentity, studioHydrologyCacheRoot());
|
||||
IrisLogging.debug("Enabled shared Studio hydrology cache identity="
|
||||
+ initialIdentity.substring(0, Math.min(12, initialIdentity.length())));
|
||||
}
|
||||
@@ -451,7 +535,9 @@ public class IrisEngine implements Engine {
|
||||
@Override
|
||||
public void generateMatter(int x, int z, boolean multicore, ChunkContext context) {
|
||||
awaitGenerationCacheWarm();
|
||||
try (GenerationSessionLease lease = acquireGenerationLease("matter_generate");
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope =
|
||||
openGenerationHistoryCoordinateScope(x << 4, z << 4);
|
||||
GenerationSessionLease lease = acquireGenerationLease("matter_generate");
|
||||
IrisContext.Scope ignored = IrisContext.open(this, lease.sessionId(), context)) {
|
||||
IrisComplex activeComplex = getComplex();
|
||||
if (context == null || context.getComplex() != activeComplex) {
|
||||
@@ -463,21 +549,114 @@ public class IrisEngine implements Engine {
|
||||
+ context.getGenerationSessionId() + " instead of " + lease.sessionId() + ".");
|
||||
}
|
||||
getMantle().generateMatter(x, z, multicore, context);
|
||||
} catch (GenerationSessionException e) {
|
||||
} catch (GenerationSessionException | IOException e) {
|
||||
throw new IllegalStateException("Matter generation was rejected by the Iris lifecycle.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public Set<String> getObjectsAt(int x, int z) {
|
||||
return getMantle().getObjectComponent().guess(x, z);
|
||||
public IrisRegion getRegion(int x, int z) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a region")) {
|
||||
return Engine.super.getRegion(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public Set<Pair<String, BlockPos>> getPOIsAt(int chunkX, int chunkY) {
|
||||
Set<Pair<String, BlockPos>> pois = new HashSet<>();
|
||||
getMantle().getMantle().iterateChunk(chunkX, chunkY, MatterStructurePOI.class, (x, y, z, d) -> pois.add(new Pair<>(d.getType(), new BlockPos(x, y, z))));
|
||||
return pois;
|
||||
public IrisBiome getCaveOrMantleBiome(int x, int y, int z) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a cave or mantle biome")) {
|
||||
return Engine.super.getCaveOrMantleBiome(x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public IrisBiome getCaveBiome(int x, int z) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a cave biome")) {
|
||||
return Engine.super.getCaveBiome(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public IrisBiome getCaveBiome(int x, int y, int z) {
|
||||
return getCaveBiome(x, y, z, null);
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public IrisBiome getCaveBiome(
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
IrisDimensionCarvingResolver.State state
|
||||
) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a vertical cave biome")) {
|
||||
return Engine.super.getCaveBiome(x, y, z, state);
|
||||
}
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public IrisBiome getSurfaceBiome(int x, int z) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a surface biome")) {
|
||||
return Engine.super.getSurfaceBiome(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public double getSlope(int x, int z) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve terrain slope")) {
|
||||
return Engine.super.getSlope(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public int getHeight(int x, int z) {
|
||||
return getHeight(x, z, true);
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public int getHeight(int x, int z, boolean ignoreFluid) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve terrain height")) {
|
||||
return Engine.super.getHeight(x, z, ignoreFluid);
|
||||
}
|
||||
}
|
||||
|
||||
@ChunkCoordinates
|
||||
@Override
|
||||
public Set<String> getObjectsAt(int x, int z) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x << 4, z << 4, "resolve chunk objects")) {
|
||||
return getMantle().getObjectComponent().guess(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
@ChunkCoordinates
|
||||
@Override
|
||||
public Set<Pair<String, BlockPos>> getPOIsAt(int chunkX, int chunkZ) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(
|
||||
chunkX << 4, chunkZ << 4, "resolve chunk points of interest")) {
|
||||
Set<Pair<String, BlockPos>> pois = new HashSet<>();
|
||||
getMantle().getMantle().iterateChunk(
|
||||
chunkX,
|
||||
chunkZ,
|
||||
MatterStructurePOI.class,
|
||||
(x, y, z, data) -> pois.add(new Pair<>(data.getType(), new BlockPos(x, y, z))));
|
||||
return pois;
|
||||
}
|
||||
}
|
||||
|
||||
private void warmupChunk(int x, int z) {
|
||||
@@ -514,7 +693,50 @@ public class IrisEngine implements Engine {
|
||||
if (!nativeStructureVolumeQueriesEnabled.get()) {
|
||||
return NativeStructureVolume.NONE;
|
||||
}
|
||||
return nativeStructureVolumeMemo.volumes(this, platformHooks, minX, minZ, maxX, maxZ);
|
||||
int minimumX = Math.min(minX, maxX);
|
||||
int maximumX = Math.max(minX, maxX);
|
||||
int minimumZ = Math.min(minZ, maxZ);
|
||||
int maximumZ = Math.max(minZ, maxZ);
|
||||
int fromChunkX = minimumX >> 4;
|
||||
int toChunkX = maximumX >> 4;
|
||||
int fromChunkZ = minimumZ >> 4;
|
||||
int toChunkZ = maximumZ >> 4;
|
||||
KList<NativeStructureVolume> matches = null;
|
||||
for (int chunkX = fromChunkX; chunkX <= toChunkX; chunkX++) {
|
||||
for (int chunkZ = fromChunkZ; chunkZ <= toChunkZ; chunkZ++) {
|
||||
int chunkMinimumX = chunkX << 4;
|
||||
int chunkMinimumZ = chunkZ << 4;
|
||||
int queryMinimumX = Math.max(minimumX, chunkMinimumX);
|
||||
int queryMaximumX = Math.min(maximumX, chunkMinimumX + 15);
|
||||
int queryMinimumZ = Math.max(minimumZ, chunkMinimumZ);
|
||||
int queryMaximumZ = Math.min(maximumZ, chunkMinimumZ + 15);
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(
|
||||
chunkMinimumX,
|
||||
chunkMinimumZ,
|
||||
"resolve native structure volumes")) {
|
||||
KList<NativeStructureVolume> resolved = nativeStructureVolumeMemo.volumes(
|
||||
this,
|
||||
platformHooks,
|
||||
queryMinimumX,
|
||||
queryMinimumZ,
|
||||
queryMaximumX,
|
||||
queryMaximumZ);
|
||||
for (NativeStructureVolume volume : resolved) {
|
||||
if (!volume.intersectsRect(minimumX, minimumZ, maximumX, maximumZ)) {
|
||||
continue;
|
||||
}
|
||||
if (matches == null) {
|
||||
matches = new KList<>();
|
||||
}
|
||||
if (!matches.contains(volume)) {
|
||||
matches.add(volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches == null ? NativeStructureVolume.NONE : matches;
|
||||
}
|
||||
|
||||
public void setNativeStructureVolumeQueriesEnabled(boolean enabled) {
|
||||
@@ -561,6 +783,312 @@ public class IrisEngine implements Engine {
|
||||
shutdownSequence.close();
|
||||
}
|
||||
|
||||
public GenerationHistoryRuntimeRouter attachGenerationHistory(
|
||||
GenerationHistory history,
|
||||
GenerationBoundarySignatureSampler signatureSampler
|
||||
) throws IOException {
|
||||
return GenerationHistoryRuntimeRouter.attachAndPromotePending(this, history, signatureSampler);
|
||||
}
|
||||
|
||||
public GenerationHistoryRuntimeRouter attachGenerationHistory(
|
||||
GenerationHistory history,
|
||||
GenerationBoundarySignatureSampler signatureSampler,
|
||||
int transitionWidthBlocks
|
||||
) throws IOException {
|
||||
return GenerationHistoryRuntimeRouter.attachAndPromotePending(
|
||||
this,
|
||||
history,
|
||||
signatureSampler,
|
||||
transitionWidthBlocks
|
||||
);
|
||||
}
|
||||
|
||||
public Optional<GenerationHistoryRuntimeRouter> getGenerationHistoryRuntimeRouter() {
|
||||
synchronized (generationHistoryRuntimeRouterLock) {
|
||||
return Optional.ofNullable(generationHistoryRuntimeRouter);
|
||||
}
|
||||
}
|
||||
|
||||
public GenerationHistoryRuntimeRouter.CoordinateScope openGenerationHistoryCoordinateScope(
|
||||
int blockX,
|
||||
int blockZ
|
||||
) throws IOException {
|
||||
GenerationHistoryRuntimeRouter router;
|
||||
synchronized (generationHistoryRuntimeRouterLock) {
|
||||
router = generationHistoryRuntimeRouter;
|
||||
if (router == null && generationHistoryRoutingRequired) {
|
||||
throw new IllegalStateException("Iris generation-history runtime router is detached.");
|
||||
}
|
||||
}
|
||||
return router == null ? null : router.openCoordinateScope(blockX, blockZ);
|
||||
}
|
||||
|
||||
public boolean hasGenerationRuntimeScope() {
|
||||
return generationRuntimeScopes.current() != null;
|
||||
}
|
||||
|
||||
private GenerationHistoryRuntimeRouter.CoordinateScope openGenerationHistoryCoordinateScopeUnchecked(
|
||||
int blockX,
|
||||
int blockZ,
|
||||
String operation
|
||||
) {
|
||||
try {
|
||||
return openGenerationHistoryCoordinateScope(blockX, blockZ);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Unable to " + operation + " through Iris generation history at "
|
||||
+ blockX + "," + blockZ + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
public void attachGenerationHistoryRuntimeRouter(GenerationHistoryRuntimeRouter router) {
|
||||
GenerationHistoryRuntimeRouter required = Objects.requireNonNull(router, "generation-history runtime router");
|
||||
if (required.engine() != this) {
|
||||
throw new IllegalArgumentException("Generation-history runtime router belongs to a different Iris engine.");
|
||||
}
|
||||
synchronized (lifecycleLock) {
|
||||
requireRunning("attach a generation-history runtime router");
|
||||
synchronized (generationHistoryRuntimeRouterLock) {
|
||||
if (generationHistoryRuntimeRouter != null) {
|
||||
throw new IllegalStateException("Iris engine already has a generation-history runtime router.");
|
||||
}
|
||||
generationHistoryRuntimeRouter = required;
|
||||
generationHistoryRoutingRequired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void detachGenerationHistoryRuntimeRouter(GenerationHistoryRuntimeRouter router) {
|
||||
synchronized (generationHistoryRuntimeRouterLock) {
|
||||
if (generationHistoryRuntimeRouter == router) {
|
||||
generationHistoryRuntimeRouter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void closeAttachedGenerationHistoryRuntimeRouter() {
|
||||
GenerationHistoryRuntimeRouter attached;
|
||||
synchronized (generationHistoryRuntimeRouterLock) {
|
||||
attached = generationHistoryRuntimeRouter;
|
||||
if (attached == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
attached.close();
|
||||
synchronized (generationHistoryRuntimeRouterLock) {
|
||||
if (generationHistoryRuntimeRouter == attached) {
|
||||
generationHistoryRuntimeRouter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boolean beginShutdown() {
|
||||
synchronized (lifecycleLock) {
|
||||
if (closed) {
|
||||
return false;
|
||||
}
|
||||
lifecycleState = LifecycleState.CLOSING;
|
||||
closing.set(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public GenerationRuntimeBinding getActiveGenerationRuntimeBinding() {
|
||||
EngineRuntime current = runtimeBuilder.requireRuntime("capture the active generation runtime");
|
||||
return new GenerationRuntimeBinding(this, current.generation());
|
||||
}
|
||||
|
||||
public GenerationRuntimeBinding captureGenerationRuntimeBinding() {
|
||||
GenerationRuntimeBinding scoped = generationRuntimeScopes.current();
|
||||
return scoped == null ? getActiveGenerationRuntimeBinding() : scoped;
|
||||
}
|
||||
|
||||
public GenerationRuntimeScope openGenerationRuntimeScope(GenerationRuntimeBinding binding) {
|
||||
GenerationRuntimeBinding required = Objects.requireNonNull(binding, "generation runtime binding");
|
||||
synchronized (lifecycleLock) {
|
||||
if (required.engine != this) {
|
||||
throw new IllegalArgumentException("Generation runtime binding belongs to a different Iris engine.");
|
||||
}
|
||||
if (retiringGenerationRuntimes.contains(required.runtime)) {
|
||||
throw new IllegalStateException("Iris generation runtime binding is retiring.");
|
||||
}
|
||||
if (!isGenerationRuntimeBindingLive(required)) {
|
||||
throw new IllegalStateException("Iris generation runtime binding is closed or no longer owned.");
|
||||
}
|
||||
return generationRuntimeScopes.open(required);
|
||||
}
|
||||
}
|
||||
|
||||
public GenerationRuntimeBinding buildDetachedGenerationRuntime(EngineTarget runtimeTarget) {
|
||||
return buildDetachedGenerationRuntime(
|
||||
runtimeTarget,
|
||||
IrisEngineMantle.legacyStorageDirectory(runtimeTarget));
|
||||
}
|
||||
|
||||
public GenerationRuntimeBinding buildDetachedGenerationRuntime(
|
||||
EngineTarget runtimeTarget,
|
||||
Path mantleStorageDirectory
|
||||
) {
|
||||
return buildDetachedGenerationRuntime(
|
||||
runtimeTarget,
|
||||
mantleStorageDirectory,
|
||||
GenerationKernelRegistry.standard().current(),
|
||||
null);
|
||||
}
|
||||
|
||||
public GenerationRuntimeBinding buildDetachedGenerationRuntime(
|
||||
EngineTarget runtimeTarget,
|
||||
Path mantleStorageDirectory,
|
||||
TransitionGenerationPlan transitionPlan
|
||||
) {
|
||||
return buildDetachedGenerationRuntime(
|
||||
runtimeTarget,
|
||||
mantleStorageDirectory,
|
||||
GenerationKernelRegistry.standard().current(),
|
||||
transitionPlan);
|
||||
}
|
||||
|
||||
public GenerationRuntimeBinding buildDetachedGenerationRuntime(
|
||||
EngineTarget runtimeTarget,
|
||||
Path mantleStorageDirectory,
|
||||
GenerationKernelRegistry.Version kernelVersion,
|
||||
TransitionGenerationPlan transitionPlan
|
||||
) {
|
||||
EngineTarget requiredTarget = Objects.requireNonNull(runtimeTarget, "detached generation target");
|
||||
Path requiredMantleStorageDirectory = IrisEngineMantle.normalizeStorageDirectory(mantleStorageDirectory);
|
||||
synchronized (lifecycleLock) {
|
||||
requireRunning("build a detached generation runtime");
|
||||
GenerationRuntime active = runtime.generation();
|
||||
if (requiredTarget.getWorld() != active.target().getWorld()) {
|
||||
throw new IllegalArgumentException("Detached generation runtime must target this Iris world.");
|
||||
}
|
||||
IrisData detachedData = requiredTarget.getData();
|
||||
if (detachedData == active.data()) {
|
||||
throw new IllegalArgumentException("Detached generation runtime requires a detached Iris data loader.");
|
||||
}
|
||||
if (requiredMantleStorageDirectory.equals(active.mantleStorageDirectory())) {
|
||||
throw new IllegalArgumentException("Detached generation runtime requires an exclusive mantle storage directory.");
|
||||
}
|
||||
for (GenerationRuntime detached : detachedGenerationRuntimes) {
|
||||
if (detached.data() == detachedData) {
|
||||
throw new IllegalArgumentException("Detached Iris data loader already belongs to a generation runtime.");
|
||||
}
|
||||
if (detached.mantleStorageDirectory().equals(requiredMantleStorageDirectory)) {
|
||||
throw new IllegalArgumentException("Mantle storage directory already belongs to a detached generation runtime.");
|
||||
}
|
||||
}
|
||||
GenerationRuntime detached = null;
|
||||
try {
|
||||
detachedData.registerEngine(this);
|
||||
GenerationKernelRegistry.Version requiredKernelVersion = Objects.requireNonNull(
|
||||
kernelVersion,
|
||||
"generation kernel version");
|
||||
detached = transitionPlan == null
|
||||
&& requiredKernelVersion.equals(GenerationKernelRegistry.standard().current())
|
||||
? runtimeBuilder.buildDetachedGenerationRuntime(
|
||||
requiredTarget,
|
||||
requiredMantleStorageDirectory)
|
||||
: runtimeBuilder.buildDetachedGenerationRuntime(
|
||||
requiredTarget,
|
||||
requiredMantleStorageDirectory,
|
||||
requiredKernelVersion,
|
||||
transitionPlan);
|
||||
detachedGenerationRuntimes.add(detached);
|
||||
return new GenerationRuntimeBinding(this, detached);
|
||||
} catch (Throwable e) {
|
||||
Throwable cleanupFailure = detached == null
|
||||
? shutdownSequence.closeDetachedTarget(requiredTarget, null)
|
||||
: shutdownSequence.closeDetachedGenerationRuntime(detached, null);
|
||||
if (cleanupFailure != null) {
|
||||
e.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw EngineShutdownSequence.propagate(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void closeDetachedGenerationRuntime(GenerationRuntimeBinding binding) {
|
||||
GenerationRuntimeBinding required = Objects.requireNonNull(binding, "detached generation runtime binding");
|
||||
GenerationRuntime retired;
|
||||
synchronized (lifecycleLock) {
|
||||
if (required.engine != this) {
|
||||
throw new IllegalArgumentException("Generation runtime binding belongs to a different Iris engine.");
|
||||
}
|
||||
EngineRuntime current = runtime;
|
||||
if (current != null && current.generation() == required.runtime) {
|
||||
throw new IllegalArgumentException("The active Iris generation runtime cannot be closed as detached.");
|
||||
}
|
||||
if (!detachedGenerationRuntimes.contains(required.runtime)) {
|
||||
return;
|
||||
}
|
||||
if (!retiringGenerationRuntimes.add(required.runtime)) {
|
||||
throw new IllegalStateException("Detached Iris generation runtime is already retiring.");
|
||||
}
|
||||
retired = required.runtime;
|
||||
}
|
||||
Throwable failure = retireGenerationRuntimeCaches(retired.cacheId(), null);
|
||||
try {
|
||||
failure = shutdownSequence.closeDetachedGenerationRuntime(retired, failure);
|
||||
} finally {
|
||||
synchronized (lifecycleLock) {
|
||||
detachedGenerationRuntimes.remove(retired);
|
||||
retiringGenerationRuntimes.remove(retired);
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new IllegalStateException("Failed to close a detached Iris generation runtime.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
public void addGenerationRuntimeRetirementListener(IntConsumer listener) {
|
||||
generationRuntimeRetirementListeners.add(Objects.requireNonNull(listener, "generation runtime retirement listener"));
|
||||
}
|
||||
|
||||
public void removeGenerationRuntimeRetirementListener(IntConsumer listener) {
|
||||
generationRuntimeRetirementListeners.remove(listener);
|
||||
}
|
||||
|
||||
Throwable retireGenerationRuntimeCaches(int runtimeId, Throwable failure) {
|
||||
nativeStructureVolumeMemo.evictRuntime(runtimeId);
|
||||
for (IntConsumer listener : generationRuntimeRetirementListeners) {
|
||||
try {
|
||||
listener.accept(runtimeId);
|
||||
} catch (Throwable listenerFailure) {
|
||||
if (failure == null) {
|
||||
failure = listenerFailure;
|
||||
} else if (failure != listenerFailure) {
|
||||
failure.addSuppressed(listenerFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
|
||||
public void setDefaultGenerationRuntime(GenerationRuntimeBinding binding) {
|
||||
GenerationRuntimeBinding required = Objects.requireNonNull(binding, "generation runtime binding");
|
||||
synchronized (lifecycleLock) {
|
||||
requireRunning("select the default generation runtime");
|
||||
if (required.engine != this) {
|
||||
throw new IllegalArgumentException("Generation runtime binding belongs to a different Iris engine.");
|
||||
}
|
||||
EngineRuntime current = runtime;
|
||||
GenerationRuntime previous = current.generation();
|
||||
if (previous == required.runtime) {
|
||||
return;
|
||||
}
|
||||
if (!detachedGenerationRuntimes.contains(required.runtime)) {
|
||||
throw new IllegalStateException("Default Iris generation runtime must be live and engine-owned.");
|
||||
}
|
||||
if (retiringGenerationRuntimes.contains(required.runtime)) {
|
||||
throw new IllegalStateException("Default Iris generation runtime cannot be retiring.");
|
||||
}
|
||||
detachedGenerationRuntimes.add(previous);
|
||||
detachedGenerationRuntimes.remove(required.runtime);
|
||||
runtime = current.withGeneration(required.runtime);
|
||||
publishedTarget = required.runtime.target();
|
||||
nativeStructureVolumeMemo.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPregeneratorActiveForThisWorld() {
|
||||
return platformHooks.isPregeneratorActive(this);
|
||||
}
|
||||
@@ -576,13 +1104,23 @@ public class IrisEngine implements Engine {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SeedManager getSeedManager() {
|
||||
RuntimeAssembly assembly = runtimeAssembly.get();
|
||||
if (assembly != null) {
|
||||
return assembly.seedManager;
|
||||
}
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? seedManager : current.seedManager();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IrisComplex getComplex() {
|
||||
RuntimeAssembly assembly = runtimeAssembly.get();
|
||||
if (assembly != null && assembly.complex != null) {
|
||||
return assembly.complex;
|
||||
}
|
||||
EngineRuntime current = runtime;
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? null : current.complex();
|
||||
}
|
||||
|
||||
@@ -592,17 +1130,47 @@ public class IrisEngine implements Engine {
|
||||
if (assembly != null) {
|
||||
return assembly.target;
|
||||
}
|
||||
EngineRuntime current = runtime;
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? publishedTarget : current.target();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IrisData getData() {
|
||||
RuntimeAssembly assembly = runtimeAssembly.get();
|
||||
if (assembly != null) {
|
||||
return assembly.target.getData();
|
||||
}
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? publishedTarget.getData() : current.data();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IrisDimension getDimension() {
|
||||
RuntimeAssembly assembly = runtimeAssembly.get();
|
||||
if (assembly != null) {
|
||||
return assembly.target.getDimension();
|
||||
}
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? publishedTarget.getDimension() : current.dimension();
|
||||
}
|
||||
|
||||
@Override
|
||||
public EngineMantle getMantle() {
|
||||
RuntimeAssembly assembly = runtimeAssembly.get();
|
||||
if (assembly != null && assembly.mantle != null) {
|
||||
return assembly.mantle;
|
||||
}
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? null : current.mantle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public EngineMode getMode() {
|
||||
RuntimeAssembly assembly = runtimeAssembly.get();
|
||||
if (assembly != null && assembly.mode != null) {
|
||||
return assembly.mode;
|
||||
}
|
||||
EngineRuntime current = runtimeBuilder.requireRuntime("access the engine mode");
|
||||
GenerationRuntime current = requireSelectedGenerationRuntime("access the engine mode");
|
||||
return current.mode();
|
||||
}
|
||||
|
||||
@@ -632,7 +1200,7 @@ public class IrisEngine implements Engine {
|
||||
if (assembly != null) {
|
||||
return assembly.upperContext;
|
||||
}
|
||||
EngineRuntime current = runtime;
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? null : current.upperContext();
|
||||
}
|
||||
|
||||
@@ -642,7 +1210,7 @@ public class IrisEngine implements Engine {
|
||||
if (assembly != null) {
|
||||
return assembly.dimensionStackContext;
|
||||
}
|
||||
EngineRuntime current = runtime;
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? null : current.dimensionStackContext();
|
||||
}
|
||||
|
||||
@@ -652,25 +1220,25 @@ public class IrisEngine implements Engine {
|
||||
if (assembly != null && assembly.hash32 != null) {
|
||||
return assembly.hash32;
|
||||
}
|
||||
EngineRuntime current = runtimeBuilder.requireRuntime("access the pack hash");
|
||||
GenerationRuntime current = requireSelectedGenerationRuntime("access the pack hash");
|
||||
return current.hash32();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getMaxBiomeObjectDensity() {
|
||||
EngineRuntime current = runtime;
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? 0D : current.biomeMaxes().objectDensity();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getMaxBiomeLayerDensity() {
|
||||
EngineRuntime current = runtime;
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? 0D : current.biomeMaxes().layerDensity();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getMaxBiomeDecoratorDensity() {
|
||||
EngineRuntime current = runtime;
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? 0D : current.biomeMaxes().decoratorDensity();
|
||||
}
|
||||
|
||||
@@ -717,7 +1285,9 @@ public class IrisEngine implements Engine {
|
||||
@Override
|
||||
public void generate(int x, int z, Hunk<PlatformBlockState> vblocks, Hunk<PlatformBiome> vbiomes, boolean multicore) throws WrongEngineBroException {
|
||||
awaitGenerationCacheWarm();
|
||||
try (GenerationSessionLease lease = acquireGenerationLease("chunk_generate");
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope =
|
||||
openGenerationHistoryCoordinateScope(x, z);
|
||||
GenerationSessionLease lease = acquireGenerationLease("chunk_generate");
|
||||
IrisContext.Scope generationScope = IrisContext.open(this, lease.sessionId(), null)) {
|
||||
getEngineData().getStatistics().generatedChunk();
|
||||
PrecisionStopwatch p = PrecisionStopwatch.start();
|
||||
@@ -810,10 +1380,58 @@ public class IrisEngine implements Engine {
|
||||
if (assembly != null) {
|
||||
return assembly.cacheId;
|
||||
}
|
||||
EngineRuntime current = runtime;
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
return current == null ? -1 : current.cacheId();
|
||||
}
|
||||
|
||||
private GenerationRuntime selectedGenerationRuntime() {
|
||||
GenerationRuntimeBinding scoped = generationRuntimeScopes.current();
|
||||
if (scoped != null && isGenerationRuntimeBindingLive(scoped)) {
|
||||
return scoped.runtime;
|
||||
}
|
||||
EngineRuntime current = runtime;
|
||||
return current == null ? null : current.generation();
|
||||
}
|
||||
|
||||
public GenerationKernelRegistry.Version getGenerationKernelVersion() {
|
||||
RuntimeAssembly assembly = runtimeAssembly.get();
|
||||
if (assembly != null) {
|
||||
return assembly.kernelVersion;
|
||||
}
|
||||
return requireSelectedGenerationRuntime("resolve the generation kernel version").kernelVersion();
|
||||
}
|
||||
|
||||
public GenerationKernelRegistry.RuntimeKernel getGenerationRuntimeKernel() {
|
||||
RuntimeAssembly assembly = runtimeAssembly.get();
|
||||
if (assembly != null) {
|
||||
return assembly.runtimeKernel;
|
||||
}
|
||||
return requireSelectedGenerationRuntime("resolve the executable generation kernel").runtimeKernel();
|
||||
}
|
||||
|
||||
public TransitionGenerationPlan getTransitionGenerationPlan() {
|
||||
RuntimeAssembly assembly = runtimeAssembly.get();
|
||||
if (assembly != null) {
|
||||
return assembly.transitionPlan;
|
||||
}
|
||||
return requireSelectedGenerationRuntime("resolve the generation transition plan").transitionPlan();
|
||||
}
|
||||
|
||||
private boolean isGenerationRuntimeBindingLive(GenerationRuntimeBinding binding) {
|
||||
EngineRuntime current = runtime;
|
||||
return (current != null && current.generation() == binding.runtime)
|
||||
|| detachedGenerationRuntimes.contains(binding.runtime);
|
||||
}
|
||||
|
||||
private GenerationRuntime requireSelectedGenerationRuntime(String operation) {
|
||||
GenerationRuntime current = selectedGenerationRuntime();
|
||||
if (current == null) {
|
||||
throw new IllegalStateException("Cannot " + operation + " without an active Iris runtime for "
|
||||
+ getWorld().name() + ".");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
void requireRunning(String operation) {
|
||||
if (closed || closing.get() || lifecycleState != LifecycleState.RUNNING || runtime == null) {
|
||||
throw new IllegalStateException("Cannot " + operation + " while Iris engine " + getWorld().name()
|
||||
@@ -867,4 +1485,71 @@ public class IrisEngine implements Engine {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GenerationRuntimeBinding {
|
||||
private final IrisEngine engine;
|
||||
private final GenerationRuntime runtime;
|
||||
|
||||
GenerationRuntimeBinding(IrisEngine engine, GenerationRuntime runtime) {
|
||||
this.engine = Objects.requireNonNull(engine, "Iris engine");
|
||||
this.runtime = Objects.requireNonNull(runtime, "generation runtime");
|
||||
}
|
||||
|
||||
GenerationRuntime generationRuntime() {
|
||||
return runtime;
|
||||
}
|
||||
|
||||
public TransitionGenerationPlan transitionPlan() {
|
||||
return runtime.transitionPlan();
|
||||
}
|
||||
|
||||
public GenerationKernelRegistry.Version kernelVersion() {
|
||||
return runtime.kernelVersion();
|
||||
}
|
||||
|
||||
public GenerationKernelRegistry.RuntimeKernel runtimeKernel() {
|
||||
return runtime.runtimeKernel();
|
||||
}
|
||||
|
||||
public EngineTarget target() {
|
||||
return runtime.target();
|
||||
}
|
||||
|
||||
public Path mantleStorageDirectory() {
|
||||
return runtime.mantleStorageDirectory();
|
||||
}
|
||||
|
||||
public int runtimeId() {
|
||||
return runtime.cacheId();
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GenerationRuntimeScope implements AutoCloseable {
|
||||
private final GenerationRuntimeScopeState state;
|
||||
private final Thread owner;
|
||||
private final GenerationRuntimeBinding previous;
|
||||
private final GenerationRuntimeBinding installed;
|
||||
private boolean closed;
|
||||
|
||||
GenerationRuntimeScope(
|
||||
GenerationRuntimeScopeState state,
|
||||
Thread owner,
|
||||
GenerationRuntimeBinding previous,
|
||||
GenerationRuntimeBinding installed
|
||||
) {
|
||||
this.state = state;
|
||||
this.owner = owner;
|
||||
this.previous = previous;
|
||||
this.installed = installed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
state.close(owner, previous, installed);
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.tools.WorldMaintenance;
|
||||
import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.mantle.MantleComponent;
|
||||
import art.arcane.iris.engine.mantle.MantlePass;
|
||||
@@ -63,11 +64,13 @@ import lombok.ToString;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@Data
|
||||
@@ -76,6 +79,9 @@ import java.util.function.Supplier;
|
||||
public class IrisEngineMantle implements EngineMantle {
|
||||
public static final String STORAGE_FOLDER_NAME = "mantle-hydrology";
|
||||
private final Engine engine;
|
||||
private final Path storageDirectory;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final AtomicReference<IrisData> runtimeData;
|
||||
private final Mantle<Matter> mantle;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final KMap<Integer, KList<MantleComponent>> components;
|
||||
@@ -85,15 +91,32 @@ public class IrisEngineMantle implements EngineMantle {
|
||||
private final MantleObjectComponent object;
|
||||
|
||||
public IrisEngineMantle(Engine engine) {
|
||||
this.engine = engine;
|
||||
this.mantle = createMantle(engine);
|
||||
this(engine, legacyStorageDirectory(engine.getTarget()));
|
||||
}
|
||||
|
||||
public IrisEngineMantle(Engine engine, Path storageDirectory) {
|
||||
this.engine = Objects.requireNonNull(engine, "Iris engine");
|
||||
this.storageDirectory = normalizeStorageDirectory(storageDirectory);
|
||||
this.runtimeData = new AtomicReference<>(Objects.requireNonNull(engine.getData(), "Iris mantle data"));
|
||||
this.mantle = createMantle(engine, this.storageDirectory, runtimeData::get);
|
||||
components = new KMap<>();
|
||||
registerComponent(new MantleCarvingComponent(this));
|
||||
registerComponent(new MantleHydrologyComponent(this));
|
||||
object = new MantleObjectComponent(this);
|
||||
registerComponent(object);
|
||||
registerComponent(new MantleFloatingObjectComponent(this));
|
||||
registerComponent(new IrisStructureComponent(this));
|
||||
MantleObjectComponent createdObject;
|
||||
try {
|
||||
registerComponent(new MantleCarvingComponent(this));
|
||||
registerComponent(new MantleHydrologyComponent(this));
|
||||
createdObject = new MantleObjectComponent(this);
|
||||
registerComponent(createdObject);
|
||||
registerComponent(new MantleFloatingObjectComponent(this));
|
||||
registerComponent(new IrisStructureComponent(this));
|
||||
} catch (Throwable failure) {
|
||||
try {
|
||||
mantle.close();
|
||||
} catch (Throwable closeFailure) {
|
||||
failure.addSuppressed(closeFailure);
|
||||
}
|
||||
throw EngineShutdownSequence.propagate(failure);
|
||||
}
|
||||
object = createdObject;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -163,6 +186,7 @@ public class IrisEngineMantle implements EngineMantle {
|
||||
|
||||
@Override
|
||||
public void hotload() {
|
||||
runtimeData.set(Objects.requireNonNull(engine.getData(), "Iris mantle data"));
|
||||
disabledFlags.reset();
|
||||
for (MantleComponent component : registeredComponents.values()) {
|
||||
component.hotload();
|
||||
@@ -195,11 +219,24 @@ public class IrisEngineMantle implements EngineMantle {
|
||||
return object;
|
||||
}
|
||||
|
||||
private static Mantle<Matter> createMantle(Engine engine) {
|
||||
static Path legacyStorageDirectory(EngineTarget target) {
|
||||
EngineTarget requiredTarget = Objects.requireNonNull(target, "Iris engine target");
|
||||
return normalizeStorageDirectory(requiredTarget.getWorld().worldFolder().toPath().resolve(STORAGE_FOLDER_NAME));
|
||||
}
|
||||
|
||||
static Path normalizeStorageDirectory(Path storageDirectory) {
|
||||
return Objects.requireNonNull(storageDirectory, "mantle storage directory").toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private static Mantle<Matter> createMantle(
|
||||
Engine engine,
|
||||
Path storageDirectory,
|
||||
Supplier<IrisData> dataSupplier
|
||||
) {
|
||||
IrisMatterSupport.ensureRegistered();
|
||||
File dataFolder = new File(engine.getWorld().worldFolder(), STORAGE_FOLDER_NAME);
|
||||
File dataFolder = storageDirectory.toFile();
|
||||
int worldHeight = engine.getTarget().getHeight();
|
||||
MantleDataAdapter<Matter> adapter = createDataAdapter(engine::getData);
|
||||
MantleDataAdapter<Matter> adapter = createDataAdapter(dataSupplier);
|
||||
MantleHooks hooks = createHooks(EnginePanic.scoped("world " + engine.getWorld().name()));
|
||||
art.arcane.volmlib.util.mantle.Mantle.RegionIO<TectonicPlate<Matter>> regionIO =
|
||||
createRegionIO(dataFolder, worldHeight, adapter, hooks);
|
||||
|
||||
@@ -20,26 +20,36 @@ package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.GenerationBlend;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.image.IrisImageMapRuntime;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.DataProvider;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
|
||||
public class UpperDimensionContext implements DataProvider {
|
||||
private final DimensionTerrainContext terrainContext;
|
||||
private final int chunkHeight;
|
||||
private final ProceduralStream<Double> lowerHeightStream;
|
||||
private final ProceduralStream<Double> unblendedLowerHeightStream;
|
||||
private final TransitionGenerationPlan transitionPlan;
|
||||
private final CeilingLayout ceilingLayout;
|
||||
|
||||
private UpperDimensionContext(DimensionTerrainContext terrainContext, int chunkHeight) {
|
||||
private UpperDimensionContext(DimensionTerrainContext terrainContext, Engine engine) {
|
||||
this.terrainContext = terrainContext;
|
||||
this.chunkHeight = chunkHeight;
|
||||
IrisComplex complex = engine.getComplex();
|
||||
lowerHeightStream = complex.getHeightStream();
|
||||
unblendedLowerHeightStream = complex.getUnblendedNaturalHeightStream();
|
||||
transitionPlan = complex.getTransitionGenerationPlan();
|
||||
ceilingLayout = new CeilingLayout(engine.getHeight(), engine.getDimension().getUpperDimensionGap());
|
||||
}
|
||||
|
||||
public static UpperDimensionContext create(Engine engine, IrisDimension upperDimension) {
|
||||
return new UpperDimensionContext(
|
||||
DimensionTerrainContext.forUpper(engine, upperDimension),
|
||||
engine.getHeight()
|
||||
engine
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,7 +94,47 @@ public class UpperDimensionContext implements DataProvider {
|
||||
}
|
||||
|
||||
public int getUpperSurfaceY(int x, int z) {
|
||||
return chunkHeight - 1 - (int) Math.round(terrainContext.getNormalTerrainHeight(x, z));
|
||||
return ceilingLayout.height() - 1
|
||||
- (int) Math.round(terrainContext.getNormalTerrainHeight(x, z));
|
||||
}
|
||||
|
||||
public int getEffectiveSurfaceY(int x, int z) {
|
||||
double depth = Math.max(
|
||||
0D,
|
||||
Math.min(ceilingLayout.height() - 1D, terrainContext.getNormalTerrainHeight(x, z))
|
||||
);
|
||||
if (transitionPlan != null) {
|
||||
TransitionGenerationPlan.TerrainSample sample = transitionPlan.terrainSampleAt(x, z);
|
||||
if (sample.newEpochWeight() < 1D && sample.hasHistoricalSignature()) {
|
||||
int targetLowerSurfaceY = (int) Math.min(
|
||||
ceilingLayout.height(),
|
||||
Math.round(unblendedLowerHeightStream.getDouble(x, z))
|
||||
);
|
||||
int targetSurfaceY = effectiveSurfaceY(depth, ceilingLayout, targetLowerSurfaceY);
|
||||
double targetDepth = Math.max(0D, ceilingLayout.height() - 1D - targetSurfaceY);
|
||||
depth = blendCeilingDepth(targetDepth, sample);
|
||||
}
|
||||
}
|
||||
int lowerSurfaceY = (int) Math.min(
|
||||
ceilingLayout.height(),
|
||||
Math.round(lowerHeightStream.getDouble(x, z))
|
||||
);
|
||||
return effectiveSurfaceY(depth, ceilingLayout, lowerSurfaceY);
|
||||
}
|
||||
|
||||
static double blendCeilingDepth(double newDepth, TransitionGenerationPlan.TerrainSample sample) {
|
||||
if (sample.newEpochWeight() == 1D || !sample.hasHistoricalSignature()) {
|
||||
return newDepth;
|
||||
}
|
||||
return GenerationBlend.interpolate(
|
||||
sample.historicalUpperCeilingDepth(), newDepth, sample.newEpochWeight());
|
||||
}
|
||||
|
||||
static int effectiveSurfaceY(double depth, CeilingLayout layout, int lowerSurfaceY) {
|
||||
long mirroredSurface = layout.height() - 1L - Math.round(depth);
|
||||
long gapSurface = (long) lowerSurfaceY + layout.minimumGap();
|
||||
long surface = Math.max(0L, Math.max(mirroredSurface, gapSurface));
|
||||
return surface >= layout.height() - 1L ? layout.height() : (int) surface;
|
||||
}
|
||||
|
||||
public IrisBiome getUpperBiome(int x, int z) {
|
||||
@@ -115,4 +165,12 @@ public class UpperDimensionContext implements DataProvider {
|
||||
public boolean isSelfReferencing() {
|
||||
return terrainContext.isSelfReferencing();
|
||||
}
|
||||
|
||||
record CeilingLayout(int height, int minimumGap) {
|
||||
CeilingLayout {
|
||||
if (height <= 0 || minimumGap < 0) {
|
||||
throw new IllegalArgumentException("Upper ceiling height must be positive and gap non-negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,21 @@ package art.arcane.iris.engine.actuator;
|
||||
|
||||
import art.arcane.iris.engine.DimensionStackContext;
|
||||
import art.arcane.iris.engine.DimensionStackLayout;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineAssignedActuator;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBiome;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.iris.util.project.context.ChunkedDataCache;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import art.arcane.volmlib.util.documentation.BlockCoordinates;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
@@ -37,16 +42,15 @@ import art.arcane.volmlib.util.matter.MatterBiomeInject;
|
||||
import art.arcane.volmlib.util.matter.slices.BiomeInjectMatter;
|
||||
import art.arcane.volmlib.util.scheduling.ChronoLatch;
|
||||
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBiome;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class IrisBiomeActuator extends EngineAssignedActuator<PlatformBiome> {
|
||||
private final RNG rng;
|
||||
private final ChronoLatch cl = new ChronoLatch(5000);
|
||||
private final KMap<String, ResolvedBiome> resolvedBiomes = new KMap<>();
|
||||
private final KMap<String, ResolvedBiome> resolvedPhysicalBiomes = new KMap<>();
|
||||
|
||||
public IrisBiomeActuator(Engine engine) {
|
||||
super(engine, "Biome");
|
||||
@@ -56,8 +60,6 @@ public class IrisBiomeActuator extends EngineAssignedActuator<PlatformBiome> {
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public void onActuate(int x, int z, Hunk<PlatformBiome> h, boolean multicore, ChunkContext context) {
|
||||
// No catch here: a partial biome write must fail the chunk (EngineAssignedActuator
|
||||
// rethrows), or the chunk is flagged REAL with default plains columns forever.
|
||||
PrecisionStopwatch p = PrecisionStopwatch.start();
|
||||
int width = h.getWidth();
|
||||
int depth = h.getDepth();
|
||||
@@ -65,12 +67,28 @@ public class IrisBiomeActuator extends EngineAssignedActuator<PlatformBiome> {
|
||||
Engine engine = getEngine();
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
ChunkedDataCache<IrisBiome> biomeCache = context.getBiome();
|
||||
IrisComplex complex = context.getComplex();
|
||||
DimensionStackContext dimensionStackContext = engine.getDimensionStackContext();
|
||||
|
||||
for (int xf = 0; xf < width; xf++) {
|
||||
for (int zf = 0; zf < depth; zf++) {
|
||||
int worldX = x + xf;
|
||||
int worldZ = z + zf;
|
||||
if (!complex.allowsNewDiscreteContentAt(worldX, worldZ)) {
|
||||
writeHistoricalColumn(
|
||||
h,
|
||||
mantle,
|
||||
xf,
|
||||
zf,
|
||||
worldX,
|
||||
worldZ,
|
||||
height,
|
||||
engine,
|
||||
complex
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
IrisBiome biome = biomeCache.get(xf, zf);
|
||||
ResolvedBiome resolved = resolve(biomeKey(
|
||||
biome,
|
||||
@@ -81,12 +99,8 @@ public class IrisBiomeActuator extends EngineAssignedActuator<PlatformBiome> {
|
||||
worldZ
|
||||
));
|
||||
PlatformBiome platformBiome = resolved.biome();
|
||||
writeColumn(h, mantle, xf, zf, worldX, worldZ, height, resolved);
|
||||
|
||||
if (platformBiome != null) {
|
||||
h.set(xf, 0, zf, xf, height - 1, zf, platformBiome);
|
||||
}
|
||||
|
||||
mantle.set(worldX, 0, worldZ, resolved.matter());
|
||||
if (dimensionStackContext != null) {
|
||||
PlatformBiome bottomBiome = platformBiome == null
|
||||
? h.getRaw(xf, 0, zf)
|
||||
@@ -108,6 +122,66 @@ public class IrisBiomeActuator extends EngineAssignedActuator<PlatformBiome> {
|
||||
engine.getMetrics().getBiome().put(p.getMilliseconds());
|
||||
}
|
||||
|
||||
private void writeHistoricalColumn(
|
||||
Hunk<PlatformBiome> output,
|
||||
Mantle<Matter> mantle,
|
||||
int localX,
|
||||
int localZ,
|
||||
int worldX,
|
||||
int worldZ,
|
||||
int height,
|
||||
Engine engine,
|
||||
IrisComplex complex
|
||||
) {
|
||||
TransitionGenerationPlan transitionPlan = complex.getTransitionGenerationPlan();
|
||||
if (transitionPlan == null) {
|
||||
return;
|
||||
}
|
||||
TransitionGenerationPlan.TerrainSample sample = transitionPlan.terrainSampleAt(worldX, worldZ);
|
||||
int minimumWorldY = engine.getMinHeight();
|
||||
String activeKey = sample.historicalPhysicalBiomeKeyAt(minimumWorldY).orElse(null);
|
||||
int rangeStart = 0;
|
||||
for (int internalY = 1; internalY <= height; internalY++) {
|
||||
String nextKey = internalY == height
|
||||
? null
|
||||
: sample.historicalPhysicalBiomeKeyAt(minimumWorldY + internalY).orElse(null);
|
||||
if (Objects.equals(activeKey, nextKey)) {
|
||||
continue;
|
||||
}
|
||||
if (activeKey != null) {
|
||||
applyBiomeRange(
|
||||
localX,
|
||||
localZ,
|
||||
worldX,
|
||||
worldZ,
|
||||
rangeStart,
|
||||
internalY - 1,
|
||||
output,
|
||||
mantle,
|
||||
resolvePhysicalKey(activeKey)
|
||||
);
|
||||
}
|
||||
activeKey = nextKey;
|
||||
rangeStart = internalY;
|
||||
}
|
||||
}
|
||||
|
||||
private void writeColumn(
|
||||
Hunk<PlatformBiome> output,
|
||||
Mantle<Matter> mantle,
|
||||
int localX,
|
||||
int localZ,
|
||||
int worldX,
|
||||
int worldZ,
|
||||
int height,
|
||||
ResolvedBiome resolved
|
||||
) {
|
||||
if (resolved.biome() != null) {
|
||||
output.set(localX, 0, localZ, localX, height - 1, localZ, resolved.biome());
|
||||
}
|
||||
mantle.set(worldX, 0, worldZ, resolved.matter());
|
||||
}
|
||||
|
||||
private void applyDimensionStackBiomes(
|
||||
int localX,
|
||||
int localZ,
|
||||
@@ -221,31 +295,39 @@ public class IrisBiomeActuator extends EngineAssignedActuator<PlatformBiome> {
|
||||
) {
|
||||
if (biome.isCustom()) {
|
||||
IrisBiomeCustom custom = biome.getCustomBiome(rng, engine, x, y, z);
|
||||
return dimension.getCustomBiomeKey(custom.getId());
|
||||
return engine.getData().customBiomeResourceKey(dimension, custom);
|
||||
}
|
||||
return biome.getSkyBiomeKey(rng, engine, x, y, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry lookup and the biome id only depend on the scatter resolved key, so cache the pair
|
||||
* instead of paying two string keyed lookups per column. Unresolved biomes are never cached, so a
|
||||
* biome registered later still resolves.
|
||||
*/
|
||||
private ResolvedBiome resolve(String key) {
|
||||
ResolvedBiome cached = key == null ? null : resolvedBiomes.get(key);
|
||||
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
IrisPlatform platform = IrisPlatforms.get();
|
||||
PlatformBiome biome = platform.registries().biome(key);
|
||||
ResolvedBiome resolved = new ResolvedBiome(biome, BiomeInjectMatter.get(platform.biomeWriter().biomeIdFor(key)));
|
||||
|
||||
ResolvedBiome resolved = new ResolvedBiome(
|
||||
biome,
|
||||
BiomeInjectMatter.get(platform.biomeWriter().biomeIdFor(key))
|
||||
);
|
||||
if (key != null && biome != null) {
|
||||
resolvedBiomes.put(key, resolved);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private ResolvedBiome resolvePhysicalKey(String key) {
|
||||
ResolvedBiome cached = resolvedPhysicalBiomes.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
PlatformBiome biome = IrisPlatforms.get().registries().biome(key);
|
||||
ResolvedBiome resolved = new ResolvedBiome(biome, BiomeInjectMatter.get(key));
|
||||
if (biome != null) {
|
||||
resolvedPhysicalBiomes.put(key, resolved);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
|
||||
@@ -163,12 +163,13 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
|
||||
int emptyFor = 0;
|
||||
int lastSolid = 0;
|
||||
realZ = Math.round(z + j);
|
||||
if (!getComplex().allowsNewDiscreteContentAt(realX, realZ)) {
|
||||
continue;
|
||||
}
|
||||
height = context.getRoundedHeight(i, j);
|
||||
biome = context.getBiome().get(i, j);
|
||||
cave = shouldRay ? context.getCave().get(i, j) : null;
|
||||
HydrologyColumnSample hydrology = getComplex().getHydrologyRuntime() == null
|
||||
? null
|
||||
: getComplex().getHydrologyRuntime().sample(realX, realZ).orElse(null);
|
||||
HydrologyColumnSample hydrology = getComplex().sampleHydrologyColumn(realX, realZ);
|
||||
if (isFallingWaterfallThroat(hydrology)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -134,9 +134,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
|
||||
}
|
||||
|
||||
int topY = Math.min(hf, chunkHeight - 1);
|
||||
HydrologyColumnSample hydrology = complex.getHydrologyRuntime() == null
|
||||
? null
|
||||
: complex.getHydrologyRuntime().sample(realX, realZ).orElse(null);
|
||||
HydrologyColumnSample hydrology = complex.sampleHydrologyColumn(realX, realZ);
|
||||
HydrologyColumnLayer hydrologyFluid = hydrology == null
|
||||
? null
|
||||
: hydrology.primarySurfaceFluidLayer().orElse(null);
|
||||
@@ -252,9 +250,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
|
||||
}
|
||||
|
||||
if (upperContext != null) {
|
||||
int rawUpperSurface = upperContext.getUpperSurfaceY(realX, realZ);
|
||||
int upperGap = dimension.getUpperDimensionGap();
|
||||
int upperSurfaceY = Math.max(rawUpperSurface, he + upperGap);
|
||||
int upperSurfaceY = upperContext.getEffectiveSurfaceY(realX, realZ);
|
||||
|
||||
if (upperSurfaceY < chunkHeight - 1) {
|
||||
IrisBiome upperBiome = upperContext.getUpperBiome(realX, realZ);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
@@ -40,6 +41,10 @@ public interface EngineMode extends Staged {
|
||||
|
||||
default EngineStage burst(EngineStage... stages) {
|
||||
return (x, z, blocks, biomes, multicore, ctx) -> {
|
||||
IrisEngine generationEngine = getEngine() instanceof IrisEngine irisEngine ? irisEngine : null;
|
||||
IrisEngine.GenerationRuntimeBinding generationRuntime = generationEngine == null
|
||||
? null
|
||||
: generationEngine.captureGenerationRuntimeBinding();
|
||||
BurstExecutor e = burst().burst(stages.length);
|
||||
e.setMulticore(multicore);
|
||||
// BurstExecutor.complete() logs-and-swallows stage failures; without re-propagation
|
||||
@@ -52,7 +57,10 @@ public interface EngineMode extends Staged {
|
||||
if (failure.get() != null) {
|
||||
return;
|
||||
}
|
||||
try (IrisContext.Scope stageScope = IrisContext.open(getEngine(), ctx.getGenerationSessionId(), ctx)) {
|
||||
try (IrisEngine.GenerationRuntimeScope runtimeScope = generationEngine == null
|
||||
? null
|
||||
: generationEngine.openGenerationRuntimeScope(generationRuntime);
|
||||
IrisContext.Scope stageScope = IrisContext.open(getEngine(), ctx.getGenerationSessionId(), ctx)) {
|
||||
i.generate(x, z, blocks, biomes, multicore, ctx);
|
||||
} catch (Throwable t) {
|
||||
failure.compareAndSet(null, t);
|
||||
@@ -98,8 +106,16 @@ public interface EngineMode extends Staged {
|
||||
inline.generate(x, z, blocks, biomes, false, ctx);
|
||||
return;
|
||||
}
|
||||
IrisEngine generationEngine = getEngine() instanceof IrisEngine irisEngine ? irisEngine : null;
|
||||
IrisEngine.GenerationRuntimeBinding generationRuntime = generationEngine == null
|
||||
? null
|
||||
: generationEngine.captureGenerationRuntimeBinding();
|
||||
java.util.concurrent.CompletableFuture<Void> background = burst().completeValueAsync(() -> {
|
||||
fanOut.generate(x, z, blocks, biomes, true, ctx);
|
||||
try (IrisEngine.GenerationRuntimeScope runtimeScope = generationEngine == null
|
||||
? null
|
||||
: generationEngine.openGenerationRuntimeScope(generationRuntime)) {
|
||||
fanOut.generate(x, z, blocks, biomes, true, ctx);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
Throwable inlineFailure = null;
|
||||
|
||||
@@ -36,6 +36,7 @@ import art.arcane.volmlib.util.math.Position2;
|
||||
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Comparator;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -108,86 +109,144 @@ public final class HintedLocator<T> implements Locator<T> {
|
||||
return exact.matches(engine, chunk);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matchesForSearch(Engine engine, Position2 chunk) {
|
||||
return exact.matchesForSearch(engine, chunk);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Position2 nearestRecorded(Engine engine, Position2 origin, int maxChunkRadius) {
|
||||
return exact.nearestRecorded(engine, origin, maxChunkRadius);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchCandidate nearestRecordedCandidate(Engine engine, Position2 origin, int maxChunkRadius) {
|
||||
return exact.nearestRecordedCandidate(engine, origin, maxChunkRadius);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchCandidate candidateForMatchedChunk(Engine engine, Position2 chunk) {
|
||||
return exact.candidateForMatchedChunk(engine, chunk);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Position2> find(Engine engine, Position2 pos, long timeout, Consumer<Integer> checks) throws WrongEngineBroException {
|
||||
if (engine.isClosed()) {
|
||||
throw new WrongEngineBroException();
|
||||
}
|
||||
|
||||
SearchCandidate recorded = nearestRecordedCandidate(engine, pos, MAX_SAMPLE_RADIUS_CHUNKS);
|
||||
int activeRadius = Locator.activeSearchRadius(pos, recorded, MAX_SAMPLE_RADIUS_CHUNKS);
|
||||
|
||||
AtomicBoolean stop = new AtomicBoolean(false);
|
||||
CompletableFuture<Position2> search = MultiBurst.burst.completeValueAsync(() -> {
|
||||
try (GenerationSessionLease lease = engine.acquireGenerationLease("hinted_locator_search");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
SearchPlan plan = planner.apply(engine);
|
||||
|
||||
if (!plan.isPossible()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return search(engine, plan, pos, timeout, checks, stop);
|
||||
SearchCandidate nearest = search(engine, plan, pos, activeRadius, recorded, timeout, checks, stop);
|
||||
return nearest == null ? null : nearest.chunk();
|
||||
}
|
||||
});
|
||||
return LocatorCanceller.requestScoped(search, stop);
|
||||
}
|
||||
|
||||
private Position2 search(Engine engine, SearchPlan plan, Position2 pos, long timeout, Consumer<Integer> checks, AtomicBoolean stop) {
|
||||
private SearchCandidate search(
|
||||
Engine engine,
|
||||
SearchPlan plan,
|
||||
Position2 pos,
|
||||
int maximumRadius,
|
||||
SearchCandidate recorded,
|
||||
long timeout,
|
||||
Consumer<Integer> checks,
|
||||
AtomicBoolean stop
|
||||
) {
|
||||
if (!plan.isPossible()
|
||||
&& !GenerationLocatorPolicy.hasHistoricalFallbackInSquare(engine, pos, maximumRadius)) {
|
||||
return recorded;
|
||||
}
|
||||
Locator<?> verifier = plan.getExactOverride() != null ? plan.getExactOverride() : exact;
|
||||
int stride = Math.max(1, plan.getStrideChunks());
|
||||
int batchTarget = IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) * 32;
|
||||
int maxRing = Math.max(1, MAX_SAMPLE_RADIUS_CHUNKS / stride);
|
||||
int batchTarget = Math.max(1, IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) * 32);
|
||||
int maxRing = Math.max(0, (maximumRadius + stride - 1) / stride + 1);
|
||||
PrecisionStopwatch stopwatch = PrecisionStopwatch.start();
|
||||
AtomicInteger covered = new AtomicInteger();
|
||||
AtomicReference<SearchCandidate> nearest = new AtomicReference<>(recorded);
|
||||
KList<Position2> batch = new KList<>();
|
||||
|
||||
for (int ring = 0; ring <= maxRing; ring++) {
|
||||
if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout) {
|
||||
return null;
|
||||
return nearest.get();
|
||||
}
|
||||
|
||||
appendRing(batch, pos, ring, stride);
|
||||
|
||||
if (batch.size() < batchTarget && ring < maxRing) {
|
||||
continue;
|
||||
int cells = ring == 0 ? 1 : ring * 8;
|
||||
for (int index = 0; index < cells; index++) {
|
||||
Position2 sample = ringPosition(pos, ring, stride, index);
|
||||
if (sample == null) {
|
||||
continue;
|
||||
}
|
||||
batch.add(sample);
|
||||
if (batch.size() < batchTarget) {
|
||||
continue;
|
||||
}
|
||||
processBatch(engine, plan, verifier, batch, pos, maximumRadius, nearest,
|
||||
stride, timeout, stopwatch, covered, checks, stop);
|
||||
batch.clear();
|
||||
if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout) {
|
||||
return nearest.get();
|
||||
}
|
||||
}
|
||||
|
||||
Position2 result = processBatch(engine, plan, verifier, batch, stride, timeout, stopwatch, covered, checks, stop);
|
||||
batch.clear();
|
||||
|
||||
if (result != null) {
|
||||
return result;
|
||||
int radius = Locator.activeSearchRadius(pos, nearest.get(), maximumRadius);
|
||||
if (ring == maxRing || nearest.get() != null && (long) ring * stride > radius) {
|
||||
if (!batch.isEmpty()) {
|
||||
processBatch(engine, plan, verifier, batch, pos, maximumRadius, nearest,
|
||||
stride, timeout, stopwatch, covered, checks, stop);
|
||||
}
|
||||
return nearest.get();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return nearest.get();
|
||||
}
|
||||
|
||||
private Position2 processBatch(Engine engine, SearchPlan plan, Locator<?> verifier, KList<Position2> batch, int stride, long timeout, PrecisionStopwatch stopwatch, AtomicInteger covered, Consumer<Integer> checks, AtomicBoolean stop) {
|
||||
private void processBatch(Engine engine, SearchPlan plan, Locator<?> verifier, KList<Position2> batch,
|
||||
Position2 origin, int maximumRadius, AtomicReference<SearchCandidate> nearest,
|
||||
int stride, long timeout, PrecisionStopwatch stopwatch, AtomicInteger covered,
|
||||
Consumer<Integer> checks, AtomicBoolean stop) {
|
||||
batch.sort(Comparator.comparingLong(sample -> chunkDistanceSquared(origin, sample)));
|
||||
int size = batch.size();
|
||||
boolean[] hits = new boolean[size];
|
||||
boolean fine = stride <= 1;
|
||||
CoarseSample coarse = plan.getCoarse();
|
||||
AtomicBoolean matched = new AtomicBoolean(false);
|
||||
int radius = Locator.activeSearchRadius(origin, nearest.get(), maximumRadius);
|
||||
BurstExecutor executor = MultiBurst.burst.burst(size);
|
||||
|
||||
for (int i = 0; i < size; i++) {
|
||||
int index = i;
|
||||
Position2 sample = batch.get(i);
|
||||
executor.queue(() -> {
|
||||
if (stop.get() || engine.isClosing() || (fine && matched.get())) {
|
||||
if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout
|
||||
|| !intersectsSearchRadius(origin, sample, fine ? 0 : stride, radius)) {
|
||||
return;
|
||||
}
|
||||
|
||||
int blockX = (sample.getX() << 4) + 8;
|
||||
int blockZ = (sample.getZ() << 4) + 8;
|
||||
boolean historicalFallback = GenerationLocatorPolicy.hasHistoricalFallbackInSquare(
|
||||
engine,
|
||||
sample,
|
||||
fine ? 0 : stride
|
||||
);
|
||||
|
||||
if (coarse != null && !coarse.test(blockX, blockZ)) {
|
||||
if (!historicalFallback && (!plan.isPossible()
|
||||
|| coarse != null && !coarse.test(blockX, blockZ))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fine) {
|
||||
if (verifier.matches(engine, sample)) {
|
||||
hits[index] = true;
|
||||
matched.set(true);
|
||||
if (verifier.matchesForSearch(engine, sample)) {
|
||||
recordCandidate(engine, sample, origin, nearest);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -198,86 +257,107 @@ public final class HintedLocator<T> implements Locator<T> {
|
||||
}
|
||||
|
||||
executor.complete();
|
||||
covered.addAndGet(size * stride * stride);
|
||||
covered.updateAndGet(value -> (int) Math.min(Integer.MAX_VALUE,
|
||||
(long) value + (long) size * stride * stride));
|
||||
checks.accept(covered.get());
|
||||
|
||||
if (fine) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < size; i++) {
|
||||
if (!hits[i]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fine) {
|
||||
return batch.get(i);
|
||||
}
|
||||
|
||||
if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
Position2 refined = refine(engine, verifier, batch.get(i), stride, timeout, stopwatch, stop);
|
||||
|
||||
if (refined != null) {
|
||||
return refined;
|
||||
int searchRadius = Locator.activeSearchRadius(origin, nearest.get(), maximumRadius);
|
||||
if (intersectsSearchRadius(origin, batch.get(i), stride, searchRadius)) {
|
||||
refine(engine, verifier, batch.get(i), origin, searchRadius, nearest,
|
||||
stride, timeout, stopwatch, stop);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Position2 refine(Engine engine, Locator<?> verifier, Position2 candidate, int stride, long timeout, PrecisionStopwatch stopwatch, AtomicBoolean stop) {
|
||||
private void refine(Engine engine, Locator<?> verifier, Position2 candidate, Position2 origin,
|
||||
int searchRadius, AtomicReference<SearchCandidate> nearest,
|
||||
int stride, long timeout, PrecisionStopwatch stopwatch, AtomicBoolean stop) {
|
||||
KList<Position2> cells = new KList<>();
|
||||
|
||||
for (int ring = 0; ring <= stride; ring++) {
|
||||
if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
cells.clear();
|
||||
appendRing(cells, candidate, ring, 1);
|
||||
AtomicReference<Position2> found = new AtomicReference<>();
|
||||
BurstExecutor executor = MultiBurst.burst.burst(cells.size());
|
||||
|
||||
for (Position2 cell : cells) {
|
||||
executor.queue(() -> {
|
||||
if (stop.get() || engine.isClosing() || found.get() != null) {
|
||||
if (stop.get() || engine.isClosing() || stopwatch.getMilliseconds() >= timeout
|
||||
|| !intersectsSearchRadius(origin, cell, 0, searchRadius)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (verifier.matches(engine, cell)) {
|
||||
found.compareAndSet(null, cell);
|
||||
if (verifier.matchesForSearch(engine, cell)) {
|
||||
recordCandidate(engine, cell, origin, nearest);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
executor.complete();
|
||||
|
||||
if (found.get() != null) {
|
||||
return found.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
private void recordCandidate(Engine engine, Position2 chunk, Position2 origin,
|
||||
AtomicReference<SearchCandidate> nearest) {
|
||||
SearchCandidate candidate = candidateForMatchedChunk(engine, chunk);
|
||||
if (candidate != null) {
|
||||
Locator.updateNearest(nearest, candidate, origin);
|
||||
}
|
||||
}
|
||||
|
||||
private static long chunkDistanceSquared(Position2 origin, Position2 sample) {
|
||||
long deltaX = (long) origin.getX() - sample.getX();
|
||||
long deltaZ = (long) origin.getZ() - sample.getZ();
|
||||
return deltaX * deltaX + deltaZ * deltaZ;
|
||||
}
|
||||
|
||||
private static boolean intersectsSearchRadius(Position2 origin, Position2 sample, int extent, int radius) {
|
||||
return Math.abs((long) sample.getX() - origin.getX()) <= (long) radius + extent
|
||||
&& Math.abs((long) sample.getZ() - origin.getZ()) <= (long) radius + extent;
|
||||
}
|
||||
|
||||
static void appendRing(KList<Position2> batch, Position2 origin, int ring, int stride) {
|
||||
int cells = ring == 0 ? 1 : ring * 8;
|
||||
for (int index = 0; index < cells; index++) {
|
||||
Position2 position = ringPosition(origin, ring, stride, index);
|
||||
if (position != null) {
|
||||
batch.add(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Position2 ringPosition(Position2 origin, int ring, int stride, int index) {
|
||||
if (ring == 0) {
|
||||
batch.add(origin);
|
||||
return;
|
||||
return origin;
|
||||
}
|
||||
|
||||
int offset = ring * stride;
|
||||
|
||||
for (int dx = -ring; dx <= ring; dx++) {
|
||||
int x = origin.getX() + (dx * stride);
|
||||
batch.add(new Position2(x, origin.getZ() - offset));
|
||||
batch.add(new Position2(x, origin.getZ() + offset));
|
||||
}
|
||||
|
||||
for (int dz = -ring + 1; dz <= ring - 1; dz++) {
|
||||
int z = origin.getZ() + (dz * stride);
|
||||
batch.add(new Position2(origin.getX() - offset, z));
|
||||
batch.add(new Position2(origin.getX() + offset, z));
|
||||
int horizontalCells = (ring * 2 + 1) * 2;
|
||||
int deltaX = index < horizontalCells ? index / 2 - ring : ((index & 1) == 0 ? -ring : ring);
|
||||
int deltaZ = index < horizontalCells ? ((index & 1) == 0 ? -ring : ring)
|
||||
: (index - horizontalCells) / 2 - ring + 1;
|
||||
long chunkX = (long) origin.getX() + (long) deltaX * stride;
|
||||
long chunkZ = (long) origin.getZ() + (long) deltaZ * stride;
|
||||
if (chunkX < (Integer.MIN_VALUE >> 4) || chunkX > (Integer.MAX_VALUE >> 4)
|
||||
|| chunkZ < (Integer.MIN_VALUE >> 4) || chunkZ > (Integer.MAX_VALUE >> 4)) {
|
||||
return null;
|
||||
}
|
||||
return new Position2((int) chunkX, (int) chunkZ);
|
||||
}
|
||||
|
||||
public static SearchPlan biomePlan(Engine engine, String biomeKey) {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.history.ChunkGenerationSemantics;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.engine.history.GenerationSemanticIndex;
|
||||
import art.arcane.volmlib.util.math.Position2;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
final class HistoryAwareLocator<T> implements Locator<T> {
|
||||
private final GenerationSemanticIndex.SemanticKind kind;
|
||||
private final String key;
|
||||
private final Locator<T> active;
|
||||
|
||||
HistoryAwareLocator(
|
||||
GenerationSemanticIndex.SemanticKind kind,
|
||||
String key,
|
||||
Locator<T> active
|
||||
) {
|
||||
this.kind = Objects.requireNonNull(kind, "kind");
|
||||
String requiredKey = Objects.requireNonNull(key, "key");
|
||||
if (requiredKey.isBlank()) {
|
||||
throw new IllegalArgumentException("Locator key cannot be blank.");
|
||||
}
|
||||
this.key = requiredKey;
|
||||
this.active = Objects.requireNonNull(active, "active locator");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matches(Engine engine, Position2 chunk) {
|
||||
Optional<ChunkGenerationSemantics> recorded = GenerationLocatorPolicy.recorded(engine, chunk);
|
||||
if (recorded.isPresent()) {
|
||||
return contains(recorded.get());
|
||||
}
|
||||
if (!GenerationLocatorPolicy.allowsProceduralPrediction(engine, chunk)) {
|
||||
return false;
|
||||
}
|
||||
return GenerationLocatorPolicy.evaluateScoped(
|
||||
engine,
|
||||
chunk,
|
||||
() -> active.matches(engine, chunk)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matchesForSearch(Engine engine, Position2 chunk) {
|
||||
if (GenerationLocatorPolicy.recorded(engine, chunk).isPresent()
|
||||
|| !GenerationLocatorPolicy.allowsProceduralPrediction(engine, chunk)) {
|
||||
return false;
|
||||
}
|
||||
return GenerationLocatorPolicy.evaluateScoped(
|
||||
engine,
|
||||
chunk,
|
||||
() -> active.matches(engine, chunk)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchCandidate nearestRecordedCandidate(Engine engine, Position2 origin, int maxChunkRadius) {
|
||||
GenerationHistory history = GenerationLocatorPolicy.history(engine).orElse(null);
|
||||
if (history == null) {
|
||||
return null;
|
||||
}
|
||||
GenerationSemanticIndex.Match match = history.findRecorded(
|
||||
GenerationSemanticIndex.Query.acrossActivations(
|
||||
kind,
|
||||
key,
|
||||
new ChunkGenerationSemantics.BlockPosition(
|
||||
blockCenter(origin.getX()),
|
||||
0,
|
||||
blockCenter(origin.getZ())
|
||||
),
|
||||
maxChunkRadius
|
||||
)
|
||||
).orElse(null);
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
ChunkGenerationSemantics.BlockPosition exactPosition = match.exactPosition().orElse(null);
|
||||
if (exactPosition != null) {
|
||||
return new SearchCandidate(
|
||||
new Position2(match.chunk().chunkX(), match.chunk().chunkZ()),
|
||||
exactPosition.x(),
|
||||
exactPosition.z()
|
||||
);
|
||||
}
|
||||
return SearchCandidate.atChunkCenter(
|
||||
new Position2(match.chunk().chunkX(), match.chunk().chunkZ())
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchCandidate candidateForMatchedChunk(Engine engine, Position2 chunk) {
|
||||
if (kind != GenerationSemanticIndex.SemanticKind.STRUCTURE) {
|
||||
return SearchCandidate.atChunkCenter(chunk);
|
||||
}
|
||||
IrisStructureLocator.LocateResult result = GenerationLocatorPolicy.evaluateValueScoped(
|
||||
engine,
|
||||
chunk,
|
||||
() -> IrisStructureLocator.locateInChunk(engine, key, chunk.getX(), chunk.getZ())
|
||||
);
|
||||
if (!result.found()) {
|
||||
return null;
|
||||
}
|
||||
return new SearchCandidate(chunk, result.originX(), result.originZ());
|
||||
}
|
||||
|
||||
private boolean contains(ChunkGenerationSemantics semantics) {
|
||||
return switch (kind) {
|
||||
case SURFACE_BIOME -> semantics.surfaceBiomeKeys().contains(key);
|
||||
case CAVE_BIOME -> semantics.caveBiomeKeys().contains(key);
|
||||
case REGION -> semantics.regionKeys().contains(key);
|
||||
case RIVER_PROFILE -> semantics.riverProfileKeys().contains(key);
|
||||
case OBJECT -> semantics.objectKeys().contains(key);
|
||||
case STRUCTURE -> semantics.structures().stream()
|
||||
.anyMatch(occurrence -> occurrence.key().equals(key));
|
||||
};
|
||||
}
|
||||
|
||||
private static int blockCenter(int chunkCoordinate) {
|
||||
long center = (long) chunkCoordinate * 16L + 8L;
|
||||
return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, center));
|
||||
}
|
||||
}
|
||||
|
||||
final class GenerationLocatorPolicy {
|
||||
private GenerationLocatorPolicy() {
|
||||
}
|
||||
|
||||
static Optional<GenerationHistory> history(Engine engine) {
|
||||
if (!(engine instanceof IrisEngine irisEngine)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return irisEngine.getGenerationHistoryRuntimeRouter()
|
||||
.map(GenerationHistoryRuntimeRouter::history);
|
||||
}
|
||||
|
||||
static Optional<ChunkGenerationSemantics> recorded(Engine engine, Position2 chunk) {
|
||||
GenerationHistory history = history(engine).orElse(null);
|
||||
if (history == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return history.semantics(chunk.getX(), chunk.getZ())
|
||||
.filter(ChunkGenerationSemantics::sealed);
|
||||
}
|
||||
|
||||
static boolean allowsActivePrediction(Engine engine, Position2 chunk) {
|
||||
if (!engine.getComplex().allowsNewGenerationChunk(chunk.getX(), chunk.getZ())) {
|
||||
return false;
|
||||
}
|
||||
GenerationHistory history = history(engine).orElse(null);
|
||||
if (history == null) {
|
||||
return true;
|
||||
}
|
||||
return history.isActiveUnowned(chunk.getX(), chunk.getZ())
|
||||
&& recorded(engine, chunk).isEmpty();
|
||||
}
|
||||
|
||||
static boolean allowsHistoricalFallback(Engine engine, Position2 chunk) {
|
||||
GenerationHistory history = history(engine).orElse(null);
|
||||
return history != null
|
||||
&& history.isHistoricallyOwned(chunk.getX(), chunk.getZ())
|
||||
&& recorded(engine, chunk).isEmpty();
|
||||
}
|
||||
|
||||
static boolean allowsProceduralPrediction(Engine engine, Position2 chunk) {
|
||||
return allowsHistoricalFallback(engine, chunk) || allowsActivePrediction(engine, chunk);
|
||||
}
|
||||
|
||||
static boolean hasHistoricalFallbackInSquare(
|
||||
Engine engine,
|
||||
Position2 center,
|
||||
int radius
|
||||
) {
|
||||
if (radius < 0) {
|
||||
throw new IllegalArgumentException("Historical fallback radius cannot be negative.");
|
||||
}
|
||||
GenerationHistory history = history(engine).orElse(null);
|
||||
return history != null && history.hasHistoricalFallbackInSquare(center.getX(), center.getZ(), radius);
|
||||
}
|
||||
|
||||
static boolean evaluateScoped(
|
||||
Engine engine,
|
||||
Position2 chunk,
|
||||
BooleanSupplier query
|
||||
) {
|
||||
BooleanSupplier requiredQuery = Objects.requireNonNull(query, "query");
|
||||
if (!(engine instanceof IrisEngine irisEngine)) {
|
||||
return requiredQuery.getAsBoolean();
|
||||
}
|
||||
int blockX = Math.multiplyExact(chunk.getX(), 16);
|
||||
int blockZ = Math.multiplyExact(chunk.getZ(), 16);
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
irisEngine.openGenerationHistoryCoordinateScope(blockX, blockZ)) {
|
||||
return requiredQuery.getAsBoolean();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Unable to scope an Iris locator query at chunk "
|
||||
+ chunk.getX() + "," + chunk.getZ() + ".", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static <T> T evaluateValueScoped(
|
||||
Engine engine,
|
||||
Position2 chunk,
|
||||
Supplier<T> query
|
||||
) {
|
||||
Supplier<T> requiredQuery = Objects.requireNonNull(query, "query");
|
||||
if (!(engine instanceof IrisEngine irisEngine)) {
|
||||
return requiredQuery.get();
|
||||
}
|
||||
int blockX = Math.multiplyExact(chunk.getX(), 16);
|
||||
int blockZ = Math.multiplyExact(chunk.getZ(), 16);
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
irisEngine.openGenerationHistoryCoordinateScope(blockX, blockZ)) {
|
||||
return requiredQuery.get();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Unable to scope an Iris locator query at chunk "
|
||||
+ chunk.getX() + "," + chunk.getZ() + ".", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,22 @@ public final class IrisStructureLocator {
|
||||
return resolveInChunk(engine, key, cx, cz) != null;
|
||||
}
|
||||
|
||||
public static LocateResult locateInChunk(Engine engine, String key, int chunkX, int chunkZ) {
|
||||
if (!isPlaced(engine, key)) {
|
||||
return NOT_FOUND_RESULT;
|
||||
}
|
||||
ResolvedStart resolved = resolveInChunk(engine, key, chunkX, chunkZ);
|
||||
if (resolved == null) {
|
||||
return NOT_FOUND_RESULT;
|
||||
}
|
||||
return new LocateResult(
|
||||
LocateStatus.FOUND,
|
||||
resolved.originX(),
|
||||
resolved.baseY(),
|
||||
resolved.originZ()
|
||||
);
|
||||
}
|
||||
|
||||
public static LocateResult locate(Engine engine, String key, int fromBlockX, int fromBlockZ, int maxRadiusChunks) {
|
||||
return locate(engine, key, fromBlockX, fromBlockZ, maxRadiusChunks, (chunkX, chunkZ) -> true);
|
||||
}
|
||||
@@ -567,9 +583,17 @@ public final class IrisStructureLocator {
|
||||
"assembled pieces exceed the configured structure or world bounds")) {
|
||||
return null;
|
||||
}
|
||||
if (!allowsResolvedFootprint(engine, placement, pieces)) {
|
||||
return null;
|
||||
}
|
||||
return new ResolvedPlacement(placement, selectedKey, structure, pieces, rng, originX, baseY, originZ, exactY);
|
||||
}
|
||||
|
||||
public static boolean allowsResolvedFootprint(Engine engine, ResolvedPlacement resolved) {
|
||||
Objects.requireNonNull(resolved, "resolved placement");
|
||||
return allowsResolvedFootprint(engine, resolved.placement(), resolved.pieces());
|
||||
}
|
||||
|
||||
public static boolean requirePlacementOutput(IrisStructurePlacement placement, String structureKey,
|
||||
int chunkX, int chunkZ, boolean outputPresent,
|
||||
String failureReason) {
|
||||
@@ -876,6 +900,34 @@ public final class IrisStructureLocator {
|
||||
return new int[]{minX, minY, minZ, maxX, maxY, maxZ};
|
||||
}
|
||||
|
||||
private static boolean allowsResolvedFootprint(
|
||||
Engine engine,
|
||||
IrisStructurePlacement placement,
|
||||
KList<PlacedStructurePiece> pieces
|
||||
) {
|
||||
int[] bounds = computeBounds(pieces);
|
||||
if (bounds == null) {
|
||||
return false;
|
||||
}
|
||||
IrisStructureTerrain terrain = placement.resolvedTerrain();
|
||||
IrisStructureTerrainMode mode = terrain.resolvedMode();
|
||||
int padding = mode == IrisStructureTerrainMode.FORCE_CARVE
|
||||
|| mode == IrisStructureTerrainMode.BORE
|
||||
? Math.max(0, terrain.getHorizontalPadding())
|
||||
: 0;
|
||||
return engine.getComplex().allowsNewGenerationFootprint(
|
||||
saturatedOffset(bounds[0], -padding),
|
||||
saturatedOffset(bounds[2], -padding),
|
||||
saturatedOffset(bounds[3], padding),
|
||||
saturatedOffset(bounds[5], padding)
|
||||
);
|
||||
}
|
||||
|
||||
private static int saturatedOffset(int coordinate, int offset) {
|
||||
long result = (long) coordinate + offset;
|
||||
return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, result));
|
||||
}
|
||||
|
||||
private static int randomInclusive(RNG rng, int minimum, int maximum) {
|
||||
if (minimum == maximum) {
|
||||
return minimum;
|
||||
|
||||
@@ -18,43 +18,69 @@
|
||||
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.nms.container.BlockPos;
|
||||
import art.arcane.iris.core.nms.container.Pair;
|
||||
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
|
||||
import art.arcane.iris.engine.history.GenerationSemanticIndex;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.volmlib.util.math.Position2;
|
||||
import art.arcane.volmlib.util.math.Spiraler;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import art.arcane.iris.util.common.parallel.BurstExecutor;
|
||||
import art.arcane.iris.util.common.parallel.MultiBurst;
|
||||
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Locator<T> {
|
||||
record SearchCandidate(Position2 chunk, int blockX, int blockZ) {
|
||||
public SearchCandidate {
|
||||
if (chunk == null) {
|
||||
throw new IllegalArgumentException("Locator candidate chunk cannot be null.");
|
||||
}
|
||||
}
|
||||
|
||||
public static SearchCandidate atChunkCenter(Position2 chunk) {
|
||||
long blockX = (long) chunk.getX() * 16L + 8L;
|
||||
long blockZ = (long) chunk.getZ() * 16L + 8L;
|
||||
return new SearchCandidate(
|
||||
chunk,
|
||||
(int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, blockX)),
|
||||
(int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, blockZ))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Locator<IrisRegion> region(String loadKey) {
|
||||
Locator<IrisRegion> exact = (e, c) -> e.getRegion((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey);
|
||||
Locator<IrisRegion> exact = new HistoryAwareLocator<>(
|
||||
GenerationSemanticIndex.SemanticKind.REGION,
|
||||
loadKey,
|
||||
(e, c) -> e.getRegion((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey)
|
||||
);
|
||||
return new HintedLocator<>(exact, (engine) -> HintedLocator.regionPlan(engine, loadKey));
|
||||
}
|
||||
|
||||
static Locator<IrisObject> object(String loadKey) {
|
||||
Locator<IrisObject> exact = (e, c) -> e.getObjectsAt(c.getX(), c.getZ()).contains(loadKey);
|
||||
Locator<IrisObject> exact = new HistoryAwareLocator<>(
|
||||
GenerationSemanticIndex.SemanticKind.OBJECT,
|
||||
loadKey,
|
||||
(e, c) -> e.getObjectsAt(c.getX(), c.getZ()).contains(loadKey)
|
||||
);
|
||||
return new HintedLocator<>(exact, (engine) -> HintedLocator.objectPlan(engine, loadKey));
|
||||
}
|
||||
|
||||
static Locator<IrisBiome> surfaceBiome(String loadKey) {
|
||||
Locator<IrisBiome> exact = (e, c) -> chunkContainsSurfaceBiome(e, c, loadKey);
|
||||
Locator<IrisBiome> exact = new HistoryAwareLocator<>(
|
||||
GenerationSemanticIndex.SemanticKind.SURFACE_BIOME,
|
||||
loadKey,
|
||||
(e, c) -> chunkContainsSurfaceBiome(e, c, loadKey)
|
||||
);
|
||||
return new HintedLocator<>(exact, (engine) -> HintedLocator.biomePlan(engine, loadKey));
|
||||
}
|
||||
|
||||
@@ -71,7 +97,11 @@ public interface Locator<T> {
|
||||
}
|
||||
|
||||
static Locator<art.arcane.iris.engine.object.IrisStructure> structure(String key) {
|
||||
return (e, c) -> IrisStructureLocator.startsInChunk(e, key, c.getX(), c.getZ());
|
||||
return new HistoryAwareLocator<>(
|
||||
GenerationSemanticIndex.SemanticKind.STRUCTURE,
|
||||
key,
|
||||
(e, c) -> IrisStructureLocator.startsInChunk(e, key, c.getX(), c.getZ())
|
||||
);
|
||||
}
|
||||
|
||||
static Locator<BlockPos> poi(String type) {
|
||||
@@ -82,12 +112,16 @@ public interface Locator<T> {
|
||||
}
|
||||
|
||||
static Locator<IrisBiome> caveBiome(String loadKey) {
|
||||
Locator<IrisBiome> exact = (e, c) -> e.getCaveBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey);
|
||||
Locator<IrisBiome> exact = new HistoryAwareLocator<>(
|
||||
GenerationSemanticIndex.SemanticKind.CAVE_BIOME,
|
||||
loadKey,
|
||||
(e, c) -> e.getCaveBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey)
|
||||
);
|
||||
return new HintedLocator<>(exact, (engine) -> HintedLocator.caveBiomePlan(engine, loadKey));
|
||||
}
|
||||
|
||||
static Locator<IrisBiome> caveOrMantleBiome(String loadKey) {
|
||||
return (e, c) -> {
|
||||
Locator<IrisBiome> active = (e, c) -> {
|
||||
AtomicBoolean found = new AtomicBoolean(false);
|
||||
try (GenerationSessionLease lease = e.acquireGenerationLease("locator_generate_matter")) {
|
||||
ChunkContext chunkContext = new ChunkContext(c.getX() << 4, c.getZ() << 4, e.getComplex(), lease.sessionId(), false, ChunkContext.PrefillPlan.NONE, null);
|
||||
@@ -109,58 +143,114 @@ public interface Locator<T> {
|
||||
|
||||
return found.get();
|
||||
};
|
||||
return new HistoryAwareLocator<>(
|
||||
GenerationSemanticIndex.SemanticKind.CAVE_BIOME,
|
||||
loadKey,
|
||||
active
|
||||
);
|
||||
}
|
||||
|
||||
boolean matches(Engine engine, Position2 chunk);
|
||||
|
||||
default CompletableFuture<Position2> find(Engine engine, Position2 pos, long timeout, Consumer<Integer> checks) throws WrongEngineBroException {
|
||||
if (engine.isClosed()) {
|
||||
throw new WrongEngineBroException();
|
||||
default boolean matchesForSearch(Engine engine, Position2 chunk) {
|
||||
if (!GenerationLocatorPolicy.allowsActivePrediction(engine, chunk)) {
|
||||
return false;
|
||||
}
|
||||
return GenerationLocatorPolicy.evaluateScoped(
|
||||
engine,
|
||||
chunk,
|
||||
() -> matches(engine, chunk)
|
||||
);
|
||||
}
|
||||
|
||||
AtomicBoolean stop = new AtomicBoolean(false);
|
||||
CompletableFuture<Position2> search = MultiBurst.burst.completeValueAsync(() -> {
|
||||
try (GenerationSessionLease lease = engine.acquireGenerationLease("locator_search");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
int tc = IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) * 32;
|
||||
MultiBurst burst = MultiBurst.burst;
|
||||
AtomicBoolean found = new AtomicBoolean(false);
|
||||
AtomicInteger searched = new AtomicInteger();
|
||||
AtomicReference<Position2> foundPos = new AtomicReference<>();
|
||||
PrecisionStopwatch px = PrecisionStopwatch.start();
|
||||
AtomicReference<Position2> next = new AtomicReference<>(pos);
|
||||
Spiraler s = new Spiraler(100000, 100000, (x, z) -> next.set(new Position2(x, z)));
|
||||
s.setOffset(pos.getX(), pos.getZ());
|
||||
s.next();
|
||||
while (!found.get() && !stop.get() && !engine.isClosing() && px.getMilliseconds() < timeout) {
|
||||
BurstExecutor e = burst.burst(tc);
|
||||
default Position2 nearestRecorded(Engine engine, Position2 origin, int maxChunkRadius) {
|
||||
SearchCandidate candidate = nearestRecordedCandidate(engine, origin, maxChunkRadius);
|
||||
return candidate == null ? null : candidate.chunk();
|
||||
}
|
||||
|
||||
for (int i = 0; i < tc; i++) {
|
||||
Position2 p = next.get();
|
||||
s.next();
|
||||
e.queue(() -> {
|
||||
if (found.get() || stop.get() || engine.isClosing()) {
|
||||
return;
|
||||
}
|
||||
if (matches(engine, p)) {
|
||||
foundPos.compareAndSet(null, p);
|
||||
found.set(true);
|
||||
}
|
||||
searched.incrementAndGet();
|
||||
});
|
||||
}
|
||||
default SearchCandidate nearestRecordedCandidate(Engine engine, Position2 origin, int maxChunkRadius) {
|
||||
return null;
|
||||
}
|
||||
|
||||
e.complete();
|
||||
checks.accept(searched.get());
|
||||
}
|
||||
default SearchCandidate candidateForMatchedChunk(Engine engine, Position2 chunk) {
|
||||
return SearchCandidate.atChunkCenter(chunk);
|
||||
}
|
||||
|
||||
if (found.get() && foundPos.get() != null) {
|
||||
return foundPos.get();
|
||||
}
|
||||
default CompletableFuture<Position2> find(Engine engine, Position2 pos, long timeout, Consumer<Integer> checks) throws WrongEngineBroException {
|
||||
return new HintedLocator<>(this, ignored -> HintedLocator.SearchPlan.unpruned())
|
||||
.find(engine, pos, timeout, checks);
|
||||
}
|
||||
|
||||
return null;
|
||||
static int activeSearchRadius(
|
||||
Position2 origin,
|
||||
SearchCandidate recorded,
|
||||
int maximumRadius
|
||||
) {
|
||||
if (recorded == null) {
|
||||
return maximumRadius;
|
||||
}
|
||||
BigInteger distance = distanceSquared(origin, recorded).sqrt();
|
||||
BigInteger chunks = distance.add(BigInteger.valueOf(15L)).divide(BigInteger.valueOf(16L));
|
||||
return Math.min(maximumRadius, chunks.min(BigInteger.valueOf(Integer.MAX_VALUE - 2L)).intValue() + 2);
|
||||
}
|
||||
|
||||
static void updateNearest(
|
||||
AtomicReference<SearchCandidate> nearest,
|
||||
SearchCandidate candidate,
|
||||
Position2 origin
|
||||
) {
|
||||
SearchCandidate current = nearest.get();
|
||||
while (current == null || compareCandidates(origin, candidate, current) < 0) {
|
||||
if (nearest.compareAndSet(current, candidate)) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
return LocatorCanceller.requestScoped(search, stop);
|
||||
current = nearest.get();
|
||||
}
|
||||
}
|
||||
|
||||
static SearchCandidate nearer(
|
||||
Position2 origin,
|
||||
SearchCandidate first,
|
||||
SearchCandidate second
|
||||
) {
|
||||
if (first == null) {
|
||||
return second;
|
||||
}
|
||||
if (second == null) {
|
||||
return first;
|
||||
}
|
||||
return compareCandidates(origin, first, second) <= 0 ? first : second;
|
||||
}
|
||||
|
||||
private static int compareCandidates(
|
||||
Position2 origin,
|
||||
SearchCandidate first,
|
||||
SearchCandidate second
|
||||
) {
|
||||
int distanceComparison = distanceSquared(origin, first).compareTo(distanceSquared(origin, second));
|
||||
if (distanceComparison != 0) {
|
||||
return distanceComparison;
|
||||
}
|
||||
int xComparison = Integer.compare(first.blockX(), second.blockX());
|
||||
if (xComparison != 0) {
|
||||
return xComparison;
|
||||
}
|
||||
int zComparison = Integer.compare(first.blockZ(), second.blockZ());
|
||||
if (zComparison != 0) {
|
||||
return zComparison;
|
||||
}
|
||||
int chunkXComparison = Integer.compare(first.chunk().getX(), second.chunk().getX());
|
||||
if (chunkXComparison != 0) {
|
||||
return chunkXComparison;
|
||||
}
|
||||
return Integer.compare(first.chunk().getZ(), second.chunk().getZ());
|
||||
}
|
||||
|
||||
private static BigInteger distanceSquared(Position2 origin, SearchCandidate candidate) {
|
||||
BigInteger originX = BigInteger.valueOf((long) origin.getX() * 16L + 8L);
|
||||
BigInteger originZ = BigInteger.valueOf((long) origin.getZ() * 16L + 8L);
|
||||
BigInteger deltaX = originX.subtract(BigInteger.valueOf(candidate.blockX()));
|
||||
BigInteger deltaZ = originZ.subtract(BigInteger.valueOf(candidate.blockZ()));
|
||||
return deltaX.multiply(deltaX).add(deltaZ.multiply(deltaZ));
|
||||
}
|
||||
}
|
||||
+136
-27
@@ -1,5 +1,8 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
@@ -8,13 +11,18 @@ import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
public final class NativeStructureOwnershipStore {
|
||||
private static final int MAX_CACHED_AUTHORITIES = 65_536;
|
||||
@@ -175,6 +183,7 @@ public final class NativeStructureOwnershipStore {
|
||||
}
|
||||
closed = true;
|
||||
authorities.invalidateAll();
|
||||
storage.close();
|
||||
} finally {
|
||||
lifecycleLock.writeLock().unlock();
|
||||
}
|
||||
@@ -224,21 +233,36 @@ public final class NativeStructureOwnershipStore {
|
||||
void remove(long target, String structureKey, int originChunkX, int originChunkZ);
|
||||
|
||||
boolean flush();
|
||||
|
||||
default void close() {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MantleStorage implements Storage {
|
||||
static final class MantleStorage implements Storage {
|
||||
private final WeakReference<Engine> engine;
|
||||
private final Map<Long, NativeStructureOwnershipBundle> pendingBundles;
|
||||
private final Map<PendingTarget, NativeStructureOwnershipBundle> pendingBundles;
|
||||
private final WeakReference<IrisEngine> retirementEngine;
|
||||
private final IntConsumer retirementListener;
|
||||
|
||||
private MantleStorage(Engine engine) {
|
||||
this.engine = new WeakReference<>(Objects.requireNonNull(engine,
|
||||
"Native structure ownership store requires an engine"));
|
||||
MantleStorage(Engine engine) {
|
||||
Engine required = Objects.requireNonNull(engine,
|
||||
"Native structure ownership store requires an engine");
|
||||
this.engine = new WeakReference<>(required);
|
||||
this.pendingBundles = new ConcurrentHashMap<>();
|
||||
if (required instanceof IrisEngine irisEngine) {
|
||||
this.retirementEngine = new WeakReference<>(irisEngine);
|
||||
this.retirementListener = this::flushRuntime;
|
||||
irisEngine.addGenerationRuntimeRetirementListener(retirementListener);
|
||||
} else {
|
||||
this.retirementEngine = null;
|
||||
this.retirementListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public NativeStructureOwnershipBundle read(int chunkX, int chunkZ) {
|
||||
MantleChunk<Matter> chunk = mantle().getChunk(chunkX, chunkZ).use();
|
||||
RuntimeMantleOwner owner = owner();
|
||||
MantleChunk<Matter> chunk = owner.mantle().getMantle().getChunk(chunkX, chunkZ).use();
|
||||
try {
|
||||
synchronized (chunk) {
|
||||
return chunk.get(0, 0, 0, NativeStructureOwnershipBundle.class);
|
||||
@@ -250,9 +274,10 @@ public final class NativeStructureOwnershipStore {
|
||||
|
||||
@Override
|
||||
public void write(long target, NativeStructureOwnershipRecord record) {
|
||||
RuntimeMantleOwner owner = owner();
|
||||
int targetChunkX = unpackX(target);
|
||||
int targetChunkZ = unpackZ(target);
|
||||
MantleChunk<Matter> chunk = mantle().getChunk(targetChunkX, targetChunkZ).use();
|
||||
MantleChunk<Matter> chunk = owner.mantle().getMantle().getChunk(targetChunkX, targetChunkZ).use();
|
||||
try {
|
||||
synchronized (chunk) {
|
||||
Matter section = chunk.getOrCreate(0);
|
||||
@@ -262,7 +287,7 @@ public final class NativeStructureOwnershipStore {
|
||||
NativeStructureOwnershipBundle updated = Objects.requireNonNullElseGet(
|
||||
bundle, NativeStructureOwnershipBundle::empty).with(record);
|
||||
slice.set(0, 0, 0, updated);
|
||||
pendingBundles.put(target, updated);
|
||||
pendingBundles.put(new PendingTarget(owner, target), updated);
|
||||
}
|
||||
} finally {
|
||||
chunk.release();
|
||||
@@ -272,9 +297,10 @@ public final class NativeStructureOwnershipStore {
|
||||
@Override
|
||||
public void remove(long target, String structureKey,
|
||||
int originChunkX, int originChunkZ) {
|
||||
RuntimeMantleOwner owner = owner();
|
||||
int targetChunkX = unpackX(target);
|
||||
int targetChunkZ = unpackZ(target);
|
||||
MantleChunk<Matter> chunk = mantle().getChunk(targetChunkX, targetChunkZ).use();
|
||||
MantleChunk<Matter> chunk = owner.mantle().getMantle().getChunk(targetChunkX, targetChunkZ).use();
|
||||
try {
|
||||
synchronized (chunk) {
|
||||
Matter section = chunk.getOrCreate(0);
|
||||
@@ -294,7 +320,7 @@ public final class NativeStructureOwnershipStore {
|
||||
}
|
||||
slice.set(0, 0, 0, updated.records().isEmpty() ? null : updated);
|
||||
section.trimSlices();
|
||||
pendingBundles.put(target, updated);
|
||||
pendingBundles.put(new PendingTarget(owner, target), updated);
|
||||
}
|
||||
} finally {
|
||||
chunk.release();
|
||||
@@ -303,27 +329,67 @@ public final class NativeStructureOwnershipStore {
|
||||
|
||||
@Override
|
||||
public boolean flush() {
|
||||
Map<Long, NativeStructureOwnershipBundle> pending = new TreeMap<>(pendingBundles);
|
||||
return flushMatching(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
IrisEngine activeRetirementEngine = retirementEngine == null ? null : retirementEngine.get();
|
||||
if (activeRetirementEngine != null) {
|
||||
activeRetirementEngine.removeGenerationRuntimeRetirementListener(retirementListener);
|
||||
}
|
||||
}
|
||||
|
||||
private void flushRuntime(int runtimeId) {
|
||||
if (!flushMatching(runtimeId)) {
|
||||
throw new IllegalStateException("Cannot retire generation runtime " + runtimeId
|
||||
+ " while native structure ownership regions are in use.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean flushMatching(Integer runtimeId) {
|
||||
List<Map.Entry<PendingTarget, NativeStructureOwnershipBundle>> pending = new ArrayList<>();
|
||||
for (Map.Entry<PendingTarget, NativeStructureOwnershipBundle> entry : pendingBundles.entrySet()) {
|
||||
if (runtimeId == null || entry.getKey().owner().runtimeId() == runtimeId) {
|
||||
pending.add(Map.entry(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
}
|
||||
if (pending.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
pending.sort(Comparator
|
||||
.comparingLong((Map.Entry<PendingTarget, NativeStructureOwnershipBundle> entry) ->
|
||||
entry.getKey().owner().activationId())
|
||||
.thenComparingInt(entry -> entry.getKey().owner().runtimeId())
|
||||
.thenComparingLong(entry -> entry.getKey().target()));
|
||||
|
||||
Mantle<Matter> mantle = mantle();
|
||||
Set<Long> regions = new TreeSet<>();
|
||||
for (Map.Entry<Long, NativeStructureOwnershipBundle> entry : pending.entrySet()) {
|
||||
long target = entry.getKey();
|
||||
restorePendingBundle(mantle, target, entry.getValue());
|
||||
regions.add(Mantle.key(unpackX(target) >> 5, unpackZ(target) >> 5));
|
||||
Map<RuntimeMantleOwner, Set<Long>> regionsByOwner = new LinkedHashMap<>();
|
||||
for (Map.Entry<PendingTarget, NativeStructureOwnershipBundle> entry : pending) {
|
||||
PendingTarget pendingTarget = entry.getKey();
|
||||
RuntimeMantleOwner owner = pendingTarget.owner();
|
||||
restorePendingBundle(owner.mantle().getMantle(), pendingTarget.target(), entry.getValue());
|
||||
regionsByOwner.computeIfAbsent(owner, ignored -> new TreeSet<>()).add(
|
||||
regionKey(pendingTarget.target()));
|
||||
}
|
||||
Set<Long> deferredRegions = mantle.saveIdleTectonicPlates(regions);
|
||||
for (Map.Entry<Long, NativeStructureOwnershipBundle> entry : pending.entrySet()) {
|
||||
long target = entry.getKey();
|
||||
long region = Mantle.key(unpackX(target) >> 5, unpackZ(target) >> 5);
|
||||
if (!deferredRegions.contains(region)) {
|
||||
pendingBundles.remove(target, entry.getValue());
|
||||
|
||||
boolean complete = true;
|
||||
for (Map.Entry<RuntimeMantleOwner, Set<Long>> ownerRegions : regionsByOwner.entrySet()) {
|
||||
RuntimeMantleOwner owner = ownerRegions.getKey();
|
||||
Set<Long> deferredRegions = owner.mantle().getMantle().saveIdleTectonicPlates(
|
||||
ownerRegions.getValue());
|
||||
if (!deferredRegions.isEmpty()) {
|
||||
complete = false;
|
||||
}
|
||||
for (Map.Entry<PendingTarget, NativeStructureOwnershipBundle> entry : pending) {
|
||||
PendingTarget pendingTarget = entry.getKey();
|
||||
if (!pendingTarget.owner().equals(owner)
|
||||
|| deferredRegions.contains(regionKey(pendingTarget.target()))) {
|
||||
continue;
|
||||
}
|
||||
pendingBundles.remove(pendingTarget, entry.getValue());
|
||||
}
|
||||
}
|
||||
return deferredRegions.isEmpty();
|
||||
return complete;
|
||||
}
|
||||
|
||||
private void restorePendingBundle(Mantle<Matter> mantle, long target,
|
||||
@@ -342,12 +408,55 @@ public final class NativeStructureOwnershipStore {
|
||||
}
|
||||
}
|
||||
|
||||
private Mantle<Matter> mantle() {
|
||||
private RuntimeMantleOwner owner() {
|
||||
Engine active = engine.get();
|
||||
if (active == null) {
|
||||
throw new IllegalStateException("Native structure ownership engine is unavailable");
|
||||
}
|
||||
return active.getMantle().getMantle();
|
||||
EngineMantle mantle = active.getMantle();
|
||||
int runtimeId = active.getCacheID();
|
||||
if (!(active instanceof IrisEngine irisEngine)) {
|
||||
return new RuntimeMantleOwner(0L, runtimeId, null, mantle);
|
||||
}
|
||||
Optional<GenerationHistoryRuntimeRouter> attached = irisEngine.getGenerationHistoryRuntimeRouter();
|
||||
if (attached.isEmpty()) {
|
||||
return new RuntimeMantleOwner(0L, runtimeId, null, mantle);
|
||||
}
|
||||
GenerationHistoryRuntimeRouter.RuntimeOwnership routed = attached.get()
|
||||
.currentRuntimeOwnership()
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Native structure ownership requires an active generation-history runtime route."));
|
||||
if (routed.binding().runtimeId() != runtimeId) {
|
||||
throw new IllegalStateException("Native structure ownership runtime route changed during capture.");
|
||||
}
|
||||
return new RuntimeMantleOwner(routed.activationId(), runtimeId, routed.binding(), mantle);
|
||||
}
|
||||
|
||||
private static long regionKey(long target) {
|
||||
return Mantle.key(unpackX(target) >> 5, unpackZ(target) >> 5);
|
||||
}
|
||||
}
|
||||
|
||||
private record RuntimeMantleOwner(
|
||||
long activationId,
|
||||
int runtimeId,
|
||||
IrisEngine.GenerationRuntimeBinding binding,
|
||||
EngineMantle mantle
|
||||
) {
|
||||
private RuntimeMantleOwner {
|
||||
if (activationId < 0L) {
|
||||
throw new IllegalArgumentException("Generation activation ID cannot be negative.");
|
||||
}
|
||||
Objects.requireNonNull(mantle, "native structure ownership mantle");
|
||||
if (activationId > 0L && binding == null) {
|
||||
throw new IllegalArgumentException("Generation-history ownership requires a runtime binding.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record PendingTarget(RuntimeMantleOwner owner, long target) {
|
||||
private PendingTarget {
|
||||
Objects.requireNonNull(owner, "native structure ownership runtime");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructure;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.engine.object.IrisStructureAnchorMode;
|
||||
@@ -20,9 +21,13 @@ public final class NativeStructurePlacementPlanner {
|
||||
}
|
||||
|
||||
public static KList<NativeStructureStartPlan> plansAt(Engine engine, int chunkX, int chunkZ) {
|
||||
Engine activeEngine = Objects.requireNonNull(engine, "Native structure planner requires an engine");
|
||||
if (!allowsStart(activeEngine, chunkX, chunkZ)) {
|
||||
return new KList<>();
|
||||
}
|
||||
Map<String, NativeStructureStartPlan> plansByStructure = new LinkedHashMap<>();
|
||||
for (IrisStructurePlacement placement : StructurePlacementScope.placementsAt(engine, chunkX, chunkZ)) {
|
||||
NativeStructureStartPlan plan = planAt(engine, placement, chunkX, chunkZ);
|
||||
for (IrisStructurePlacement placement : StructurePlacementScope.placementsAt(activeEngine, chunkX, chunkZ)) {
|
||||
NativeStructureStartPlan plan = planAt(activeEngine, placement, chunkX, chunkZ);
|
||||
if (plan != null) {
|
||||
String structureKey = normalize(plan.source().getStructure());
|
||||
NativeStructureStartPlan current = plansByStructure.get(structureKey);
|
||||
@@ -41,6 +46,9 @@ public final class NativeStructurePlacementPlanner {
|
||||
return null;
|
||||
}
|
||||
Engine activeEngine = Objects.requireNonNull(engine, "Native structure planner requires an engine");
|
||||
if (!allowsStart(activeEngine, chunkX, chunkZ)) {
|
||||
return null;
|
||||
}
|
||||
if (activeEngine.getSeedManager() == null) {
|
||||
throw new IllegalStateException("Native structure planner requires a bound seed manager");
|
||||
}
|
||||
@@ -163,6 +171,11 @@ public final class NativeStructurePlacementPlanner {
|
||||
return Engine.hostHeight(engine, blockX, blockZ, true) < localFluidHeight;
|
||||
}
|
||||
|
||||
private static boolean allowsStart(Engine engine, int chunkX, int chunkZ) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
return complex == null || complex.allowsNewGenerationChunk(chunkX, chunkZ);
|
||||
}
|
||||
|
||||
private static int comparePlacementPriority(IrisStructurePlacement left, IrisStructurePlacement right) {
|
||||
long leftIdentity = StructurePlacementGrid.placementIdentity(left);
|
||||
long rightIdentity = StructurePlacementGrid.placementIdentity(right);
|
||||
|
||||
@@ -21,6 +21,9 @@ package art.arcane.iris.engine.framework;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Chunk keyed memo in front of the platform volume hook. One chunk's object pass fires the same rect query dozens of
|
||||
* times, so the hook is asked once per chunk column and every later object reuses that answer. Long keyed so the
|
||||
@@ -29,8 +32,8 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
||||
public final class NativeStructureVolumeMemo {
|
||||
private static final int MAX_CACHED_CHUNKS = 2_048;
|
||||
|
||||
private final Long2ObjectLinkedOpenHashMap<KList<NativeStructureVolume>> chunks =
|
||||
new Long2ObjectLinkedOpenHashMap<>();
|
||||
private final Map<Integer, Long2ObjectLinkedOpenHashMap<KList<NativeStructureVolume>>> runtimes =
|
||||
new HashMap<>();
|
||||
|
||||
public KList<NativeStructureVolume> volumes(Engine engine, EnginePlatformHooks hooks,
|
||||
int minX, int minZ, int maxX, int maxZ) {
|
||||
@@ -62,15 +65,27 @@ public final class NativeStructureVolumeMemo {
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
synchronized (chunks) {
|
||||
chunks.clear();
|
||||
synchronized (runtimes) {
|
||||
runtimes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void evictRuntime(int runtimeId) {
|
||||
synchronized (runtimes) {
|
||||
runtimes.remove(runtimeId);
|
||||
}
|
||||
}
|
||||
|
||||
private KList<NativeStructureVolume> chunkVolumes(Engine engine, EnginePlatformHooks hooks,
|
||||
int chunkX, int chunkZ) {
|
||||
long key = ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL);
|
||||
synchronized (chunks) {
|
||||
int runtimeId = engine.getCacheID();
|
||||
synchronized (runtimes) {
|
||||
Long2ObjectLinkedOpenHashMap<KList<NativeStructureVolume>> chunks = runtimes.get(runtimeId);
|
||||
if (chunks == null) {
|
||||
chunks = new Long2ObjectLinkedOpenHashMap<>();
|
||||
runtimes.put(runtimeId, chunks);
|
||||
}
|
||||
KList<NativeStructureVolume> hit = chunks.getAndMoveToFirst(key);
|
||||
if (hit != null) {
|
||||
return hit;
|
||||
@@ -83,7 +98,10 @@ public final class NativeStructureVolumeMemo {
|
||||
if (resolved == null) {
|
||||
resolved = NativeStructureVolume.NONE;
|
||||
}
|
||||
synchronized (chunks) {
|
||||
synchronized (runtimes) {
|
||||
Long2ObjectLinkedOpenHashMap<KList<NativeStructureVolume>> chunks = runtimes.computeIfAbsent(
|
||||
runtimeId,
|
||||
ignored -> new Long2ObjectLinkedOpenHashMap<>());
|
||||
chunks.putAndMoveToFirst(key, resolved);
|
||||
while (chunks.size() > MAX_CACHED_CHUNKS) {
|
||||
chunks.removeLast();
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
|
||||
public final class ChunkGenerationOwnership {
|
||||
private static final int MAXIMUM_CACHED_REGIONS = 64;
|
||||
|
||||
private final Path directory;
|
||||
private final LinkedHashMap<Long, RegionGenerationOwnership> regions;
|
||||
private final Long2IntOpenHashMap regionAssignmentCounts;
|
||||
private final ReentrantReadWriteLock lock;
|
||||
private int explicitChunkCount;
|
||||
|
||||
private ChunkGenerationOwnership(Path directory) {
|
||||
this.directory = directory.toAbsolutePath().normalize();
|
||||
regions = new LinkedHashMap<>(MAXIMUM_CACHED_REGIONS, 0.75F, true);
|
||||
regionAssignmentCounts = new Long2IntOpenHashMap();
|
||||
regionAssignmentCounts.defaultReturnValue(-1);
|
||||
lock = new ReentrantReadWriteLock();
|
||||
}
|
||||
|
||||
public static ChunkGenerationOwnership load(Path directory) throws IOException {
|
||||
ChunkGenerationOwnership ownership = new ChunkGenerationOwnership(directory);
|
||||
ownership.loadRegionCatalog();
|
||||
return ownership;
|
||||
}
|
||||
|
||||
public long resolve(int chunkX, int chunkZ, long currentActivation) {
|
||||
requireActivation(currentActivation);
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
RegionGenerationOwnership region = loadExistingRegion(chunkX >> 5, chunkZ >> 5);
|
||||
return region == null ? currentActivation : region.resolve(chunkX, chunkZ, currentActivation);
|
||||
} catch (IOException error) {
|
||||
throw lookupFailure(chunkX, chunkZ, error);
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isExplicitlyAssigned(int chunkX, int chunkZ) {
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
RegionGenerationOwnership region = loadExistingRegion(chunkX >> 5, chunkZ >> 5);
|
||||
return region != null && region.hasAssignment(chunkX, chunkZ);
|
||||
} catch (IOException error) {
|
||||
throw lookupFailure(chunkX, chunkZ, error);
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean anyMatchingInSquare(int centerX, int centerZ, int radius, AssignmentPredicate predicate) {
|
||||
if (radius < 0) {
|
||||
throw new IllegalArgumentException("Ownership search radius cannot be negative.");
|
||||
}
|
||||
Objects.requireNonNull(predicate, "predicate");
|
||||
int minimumX = (int) Math.max(Integer.MIN_VALUE, (long) centerX - radius);
|
||||
int maximumX = (int) Math.min(Integer.MAX_VALUE, (long) centerX + radius);
|
||||
int minimumZ = (int) Math.max(Integer.MIN_VALUE, (long) centerZ - radius);
|
||||
int maximumZ = (int) Math.min(Integer.MAX_VALUE, (long) centerZ + radius);
|
||||
int minimumRegionX = minimumX >> 5;
|
||||
int maximumRegionX = maximumX >> 5;
|
||||
int minimumRegionZ = minimumZ >> 5;
|
||||
int maximumRegionZ = maximumZ >> 5;
|
||||
long regionCount = ((long) maximumRegionX - minimumRegionX + 1L)
|
||||
* ((long) maximumRegionZ - minimumRegionZ + 1L);
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
if (regionCount > regionAssignmentCounts.size()) {
|
||||
for (long key : regionAssignmentCounts.keySet()) {
|
||||
int x = regionX(key);
|
||||
int z = regionZ(key);
|
||||
if (x >= minimumRegionX && x <= maximumRegionX
|
||||
&& z >= minimumRegionZ && z <= maximumRegionZ
|
||||
&& matchesRegion(x, z, minimumX, minimumZ, maximumX, maximumZ, predicate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for (int x = minimumRegionX; x <= maximumRegionX; x++) {
|
||||
for (int z = minimumRegionZ; z <= maximumRegionZ; z++) {
|
||||
if (matchesRegion(x, z, minimumX, minimumZ, maximumX, maximumZ, predicate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException("Unable to search generation ownership near chunk "
|
||||
+ centerX + "," + centerZ + ".", error);
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public long explicitActivation(int chunkX, int chunkZ) {
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
RegionGenerationOwnership region = loadExistingRegion(chunkX >> 5, chunkZ >> 5);
|
||||
if (region == null) {
|
||||
throw new IllegalStateException(
|
||||
"Chunk " + chunkX + "," + chunkZ + " has no explicit generation activation"
|
||||
);
|
||||
}
|
||||
return region.explicitActivation(chunkX, chunkZ);
|
||||
} catch (IOException error) {
|
||||
throw lookupFailure(chunkX, chunkZ, error);
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean assign(int chunkX, int chunkZ, long activation) throws IOException {
|
||||
requireActivation(activation);
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
RegionGenerationOwnership region = regionForAssignment(chunkX, chunkZ);
|
||||
boolean assigned = region.assign(chunkX, chunkZ, activation);
|
||||
if (assigned) {
|
||||
recordAssignment(region);
|
||||
}
|
||||
return assigned;
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public int assignAll(WorldChunkInventory inventory, long activation) throws IOException {
|
||||
requireActivation(activation);
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
validateAssignments(inventory, activation);
|
||||
int[] assigned = new int[1];
|
||||
inventory.forEach((chunkX, chunkZ) -> {
|
||||
RegionGenerationOwnership region = regionForAssignment(chunkX, chunkZ);
|
||||
if (region.assign(chunkX, chunkZ, activation)) {
|
||||
recordAssignment(region);
|
||||
assigned[0]++;
|
||||
}
|
||||
});
|
||||
return assigned[0];
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public int assignUnassigned(WorldChunkInventory inventory, long activation) throws IOException {
|
||||
requireActivation(activation);
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
int[] assigned = new int[1];
|
||||
inventory.forEach((chunkX, chunkZ) -> {
|
||||
RegionGenerationOwnership existing = loadExistingRegion(chunkX >> 5, chunkZ >> 5);
|
||||
if (existing != null && existing.hasAssignment(chunkX, chunkZ)) {
|
||||
return;
|
||||
}
|
||||
RegionGenerationOwnership region = regionForAssignment(chunkX, chunkZ);
|
||||
if (region.assign(chunkX, chunkZ, activation)) {
|
||||
recordAssignment(region);
|
||||
assigned[0]++;
|
||||
}
|
||||
});
|
||||
return assigned[0];
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public int persist() throws IOException {
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
requireSafeParent();
|
||||
int persisted = 0;
|
||||
for (RegionGenerationOwnership region : regions.values()) {
|
||||
if (region.persist(directory)) {
|
||||
persisted++;
|
||||
}
|
||||
}
|
||||
return persisted;
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public int explicitChunkCount() {
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
return explicitChunkCount;
|
||||
} finally {
|
||||
lock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public void forEachAssignment(AssignmentConsumer consumer) throws IOException {
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
for (long regionKey : orderedRegionKeys()) {
|
||||
RegionGenerationOwnership region = loadExistingRegion(regionX(regionKey), regionZ(regionKey));
|
||||
if (region != null) {
|
||||
region.forEachAssignment(consumer::accept);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public long[] snapshotExplicitChunkKeys() {
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
long[] chunks = new long[explicitChunkCount];
|
||||
int[] cursor = new int[1];
|
||||
try {
|
||||
forEachAssignment((chunkX, chunkZ, activationId) -> chunks[cursor[0]++] = packChunk(chunkX, chunkZ));
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException("Unable to materialize generation ownership", error);
|
||||
}
|
||||
if (cursor[0] != chunks.length) {
|
||||
throw new IllegalStateException(
|
||||
"Generation ownership changed while creating a snapshot: expected "
|
||||
+ chunks.length + " chunks but found " + cursor[0]
|
||||
);
|
||||
}
|
||||
Arrays.sort(chunks);
|
||||
return chunks;
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
int cachedRegionCount() {
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
return regions.size();
|
||||
} finally {
|
||||
lock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
int regionCount() {
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
return regionAssignmentCounts.size();
|
||||
} finally {
|
||||
lock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public static long packChunk(int chunkX, int chunkZ) {
|
||||
return ((long) chunkX << Integer.SIZE) | (chunkZ & 0xFFFFFFFFL);
|
||||
}
|
||||
|
||||
public static int chunkX(long packedChunk) {
|
||||
return (int) (packedChunk >> Integer.SIZE);
|
||||
}
|
||||
|
||||
public static int chunkZ(long packedChunk) {
|
||||
return (int) packedChunk;
|
||||
}
|
||||
|
||||
private void loadRegionCatalog() throws IOException {
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isSymbolicLink(directory)) {
|
||||
Path parent = directory.getParent();
|
||||
if (parent != null
|
||||
&& !Files.exists(parent, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isSymbolicLink(parent)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
requireSafeParent();
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Generation ownership path is not a directory: " + directory);
|
||||
}
|
||||
|
||||
List<Path> files = new ArrayList<>();
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(
|
||||
directory,
|
||||
"*" + RegionGenerationOwnership.FILE_SUFFIX
|
||||
)) {
|
||||
for (Path file : stream) {
|
||||
files.add(file);
|
||||
}
|
||||
}
|
||||
files.sort(Comparator.comparing(path -> path.getFileName().toString()));
|
||||
for (Path file : files) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Generation ownership shard is not a regular file: " + file);
|
||||
}
|
||||
RegionGenerationOwnership.Metadata metadata = RegionGenerationOwnership.readMetadata(file);
|
||||
String expectedName = RegionGenerationOwnership.fileName(metadata.regionX(), metadata.regionZ());
|
||||
if (!file.getFileName().toString().equals(expectedName)) {
|
||||
throw new IOException("Generation ownership shard name does not match its coordinates: " + file);
|
||||
}
|
||||
long regionKey = packRegion(metadata.regionX(), metadata.regionZ());
|
||||
if (regionAssignmentCounts.putIfAbsent(regionKey, metadata.assignmentCount()) != -1) {
|
||||
throw new IOException("Duplicate generation ownership shard for region "
|
||||
+ metadata.regionX() + "," + metadata.regionZ());
|
||||
}
|
||||
try {
|
||||
explicitChunkCount = Math.addExact(explicitChunkCount, metadata.assignmentCount());
|
||||
} catch (ArithmeticException error) {
|
||||
throw new IOException("Generation ownership contains too many assigned chunks", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSafeParent() throws IOException {
|
||||
Path parent = directory.getParent();
|
||||
if (parent == null || !Files.exists(parent, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Generation ownership parent directory is missing: " + parent);
|
||||
}
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
parent,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
|
||||
throw new IOException("Generation ownership parent path is not a safe directory: " + parent);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAssignments(WorldChunkInventory inventory, long activation) throws IOException {
|
||||
inventory.forEach((chunkX, chunkZ) -> {
|
||||
RegionGenerationOwnership region = loadExistingRegion(chunkX >> 5, chunkZ >> 5);
|
||||
if (region == null || !region.hasAssignment(chunkX, chunkZ)) {
|
||||
return;
|
||||
}
|
||||
long existing = region.explicitActivation(chunkX, chunkZ);
|
||||
if (existing != activation) {
|
||||
throw new IllegalStateException(
|
||||
"Chunk " + chunkX + "," + chunkZ + " already belongs to activation " + existing
|
||||
+ " and cannot be reassigned to " + activation
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private RegionGenerationOwnership regionForAssignment(int chunkX, int chunkZ) throws IOException {
|
||||
int regionX = chunkX >> 5;
|
||||
int regionZ = chunkZ >> 5;
|
||||
long regionKey = packRegion(regionX, regionZ);
|
||||
RegionGenerationOwnership region = regions.get(regionKey);
|
||||
if (region != null) {
|
||||
return region;
|
||||
}
|
||||
region = regionAssignmentCounts.containsKey(regionKey)
|
||||
? readRegion(regionX, regionZ)
|
||||
: new RegionGenerationOwnership(regionX, regionZ);
|
||||
cacheRegion(regionKey, region);
|
||||
return region;
|
||||
}
|
||||
|
||||
private RegionGenerationOwnership loadExistingRegion(int regionX, int regionZ) throws IOException {
|
||||
long regionKey = packRegion(regionX, regionZ);
|
||||
RegionGenerationOwnership region = regions.get(regionKey);
|
||||
if (region != null) {
|
||||
return region;
|
||||
}
|
||||
if (!regionAssignmentCounts.containsKey(regionKey)) {
|
||||
return null;
|
||||
}
|
||||
region = readRegion(regionX, regionZ);
|
||||
cacheRegion(regionKey, region);
|
||||
return region;
|
||||
}
|
||||
|
||||
private RegionGenerationOwnership readRegion(int regionX, int regionZ) throws IOException {
|
||||
Path file = directory.resolve(RegionGenerationOwnership.fileName(regionX, regionZ));
|
||||
RegionGenerationOwnership region = RegionGenerationOwnership.read(file);
|
||||
if (region.regionX() != regionX || region.regionZ() != regionZ) {
|
||||
throw new IOException("Generation ownership shard name does not match its coordinates: " + file);
|
||||
}
|
||||
return region;
|
||||
}
|
||||
|
||||
private boolean matchesRegion(int regionX, int regionZ, int minimumX, int minimumZ,
|
||||
int maximumX, int maximumZ, AssignmentPredicate predicate) throws IOException {
|
||||
RegionGenerationOwnership region = loadExistingRegion(regionX, regionZ);
|
||||
return region != null && region.anyMatchingInBounds(minimumX, minimumZ, maximumX, maximumZ, predicate);
|
||||
}
|
||||
|
||||
private void cacheRegion(long regionKey, RegionGenerationOwnership region) throws IOException {
|
||||
regions.put(regionKey, region);
|
||||
while (regions.size() > MAXIMUM_CACHED_REGIONS) {
|
||||
Iterator<Map.Entry<Long, RegionGenerationOwnership>> entries = regions.entrySet().iterator();
|
||||
Map.Entry<Long, RegionGenerationOwnership> eldest = entries.next();
|
||||
RegionGenerationOwnership evicted = eldest.getValue();
|
||||
if (evicted.persist(directory)) {
|
||||
regionAssignmentCounts.put(eldest.getKey().longValue(), evicted.assignmentCount());
|
||||
}
|
||||
entries.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private void recordAssignment(RegionGenerationOwnership region) {
|
||||
explicitChunkCount++;
|
||||
regionAssignmentCounts.put(packRegion(region.regionX(), region.regionZ()), region.assignmentCount());
|
||||
}
|
||||
|
||||
private List<Long> orderedRegionKeys() {
|
||||
ArrayList<Long> ordered = new ArrayList<>(regionAssignmentCounts.keySet());
|
||||
ordered.sort(ChunkGenerationOwnership::compareRegionKeys);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static IllegalStateException lookupFailure(int chunkX, int chunkZ, IOException error) {
|
||||
return new IllegalStateException(
|
||||
"Unable to load generation ownership for chunk " + chunkX + "," + chunkZ,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
private static long packRegion(int regionX, int regionZ) {
|
||||
return ((long) regionX << Integer.SIZE) | (regionZ & 0xFFFFFFFFL);
|
||||
}
|
||||
|
||||
private static int regionX(long regionKey) {
|
||||
return (int) (regionKey >> Integer.SIZE);
|
||||
}
|
||||
|
||||
private static int regionZ(long regionKey) {
|
||||
return (int) regionKey;
|
||||
}
|
||||
|
||||
private static int compareRegionKeys(long first, long second) {
|
||||
int xComparison = Integer.compare(regionX(first), regionX(second));
|
||||
return xComparison != 0 ? xComparison : Integer.compare(regionZ(first), regionZ(second));
|
||||
}
|
||||
|
||||
private static void requireActivation(long activation) {
|
||||
if (activation <= 0L) {
|
||||
throw new IllegalArgumentException("Generation activation IDs must be positive: " + activation);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface AssignmentPredicate {
|
||||
boolean test(int chunkX, int chunkZ, long activationId);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface AssignmentConsumer {
|
||||
void accept(int chunkX, int chunkZ, long activationId) throws IOException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class ChunkGenerationSemantics {
|
||||
static final int MAX_KEY_BYTES = 1_024;
|
||||
static final int MAX_KEYS_PER_KIND = 2_048;
|
||||
static final int MAX_RIVER_FEATURES = 16_384;
|
||||
static final int MAX_STRUCTURES = 4_096;
|
||||
|
||||
private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_][a-z0-9._:/#@-]*");
|
||||
private static final Comparator<StructureOccurrence> STRUCTURE_COMPARATOR = Comparator
|
||||
.comparing(StructureOccurrence::key)
|
||||
.thenComparingInt(occurrence -> occurrence.position().x())
|
||||
.thenComparingInt(occurrence -> occurrence.position().z())
|
||||
.thenComparingInt(occurrence -> occurrence.position().y());
|
||||
private static final Comparator<RiverFeatureOccurrence> RIVER_FEATURE_COMPARATOR = Comparator
|
||||
.comparing(RiverFeatureOccurrence::profileKey)
|
||||
.thenComparing(RiverFeatureOccurrence::type)
|
||||
.thenComparingInt(occurrence -> occurrence.position().x())
|
||||
.thenComparingInt(occurrence -> occurrence.position().z())
|
||||
.thenComparingInt(occurrence -> occurrence.position().y())
|
||||
.thenComparingLong(RiverFeatureOccurrence::featureId);
|
||||
|
||||
private final int chunkX;
|
||||
private final int chunkZ;
|
||||
private final long activationId;
|
||||
private final boolean sealed;
|
||||
private final Set<String> surfaceBiomeKeys;
|
||||
private final Set<String> caveBiomeKeys;
|
||||
private final Set<String> regionKeys;
|
||||
private final Set<String> riverProfileKeys;
|
||||
private final Set<String> objectKeys;
|
||||
private final Set<RiverFeatureOccurrence> riverFeatures;
|
||||
private final Set<StructureOccurrence> structures;
|
||||
|
||||
private ChunkGenerationSemantics(Builder builder) {
|
||||
chunkX = builder.chunkX;
|
||||
chunkZ = builder.chunkZ;
|
||||
activationId = builder.activationId;
|
||||
sealed = builder.sealed;
|
||||
surfaceBiomeKeys = immutableKeys(builder.surfaceBiomeKeys, "surface biome");
|
||||
caveBiomeKeys = immutableKeys(builder.caveBiomeKeys, "cave biome");
|
||||
regionKeys = immutableKeys(builder.regionKeys, "region");
|
||||
riverProfileKeys = immutableKeys(builder.riverProfileKeys, "river profile");
|
||||
objectKeys = immutableKeys(builder.objectKeys, "object");
|
||||
riverFeatures = immutableRiverFeatures(builder.riverFeatures);
|
||||
structures = immutableStructures(builder.structures);
|
||||
}
|
||||
|
||||
public static Builder builder(int chunkX, int chunkZ, long activationId) {
|
||||
return new Builder(chunkX, chunkZ, activationId);
|
||||
}
|
||||
|
||||
public int chunkX() {
|
||||
return chunkX;
|
||||
}
|
||||
|
||||
public int chunkZ() {
|
||||
return chunkZ;
|
||||
}
|
||||
|
||||
public long activationId() {
|
||||
return activationId;
|
||||
}
|
||||
|
||||
public boolean sealed() {
|
||||
return sealed;
|
||||
}
|
||||
|
||||
public Set<String> surfaceBiomeKeys() {
|
||||
return surfaceBiomeKeys;
|
||||
}
|
||||
|
||||
public Set<String> caveBiomeKeys() {
|
||||
return caveBiomeKeys;
|
||||
}
|
||||
|
||||
public Set<String> regionKeys() {
|
||||
return regionKeys;
|
||||
}
|
||||
|
||||
public Set<String> riverProfileKeys() {
|
||||
return riverProfileKeys;
|
||||
}
|
||||
|
||||
public Set<String> objectKeys() {
|
||||
return objectKeys;
|
||||
}
|
||||
|
||||
public Set<RiverFeatureOccurrence> riverFeatures() {
|
||||
return riverFeatures;
|
||||
}
|
||||
|
||||
public Set<StructureOccurrence> structures() {
|
||||
return structures;
|
||||
}
|
||||
|
||||
public ChunkGenerationSemantics merge(ChunkGenerationSemantics update) {
|
||||
ChunkGenerationSemantics requiredUpdate = Objects.requireNonNull(update, "update");
|
||||
if (chunkX != requiredUpdate.chunkX || chunkZ != requiredUpdate.chunkZ) {
|
||||
throw new IllegalArgumentException(
|
||||
"Generation semantic updates must address the same chunk: "
|
||||
+ chunkX + "," + chunkZ + " and "
|
||||
+ requiredUpdate.chunkX + "," + requiredUpdate.chunkZ
|
||||
);
|
||||
}
|
||||
if (activationId != requiredUpdate.activationId) {
|
||||
throw new IllegalStateException(
|
||||
"Chunk " + chunkX + "," + chunkZ
|
||||
+ " belongs to generation activation " + activationId
|
||||
+ ", not " + requiredUpdate.activationId
|
||||
);
|
||||
}
|
||||
|
||||
boolean addsFacts = !surfaceBiomeKeys.containsAll(requiredUpdate.surfaceBiomeKeys)
|
||||
|| !caveBiomeKeys.containsAll(requiredUpdate.caveBiomeKeys)
|
||||
|| !regionKeys.containsAll(requiredUpdate.regionKeys)
|
||||
|| !riverProfileKeys.containsAll(requiredUpdate.riverProfileKeys)
|
||||
|| !objectKeys.containsAll(requiredUpdate.objectKeys)
|
||||
|| !riverFeatures.containsAll(requiredUpdate.riverFeatures)
|
||||
|| !structures.containsAll(requiredUpdate.structures);
|
||||
if (sealed && addsFacts) {
|
||||
throw new IllegalStateException(
|
||||
"Chunk " + chunkX + "," + chunkZ + " has sealed generation semantics"
|
||||
);
|
||||
}
|
||||
if (!addsFacts && (sealed || !requiredUpdate.sealed)) {
|
||||
return this;
|
||||
}
|
||||
|
||||
Builder merged = builder(chunkX, chunkZ, activationId)
|
||||
.addSurfaceBiomes(surfaceBiomeKeys)
|
||||
.addSurfaceBiomes(requiredUpdate.surfaceBiomeKeys)
|
||||
.addCaveBiomes(caveBiomeKeys)
|
||||
.addCaveBiomes(requiredUpdate.caveBiomeKeys)
|
||||
.addRegions(regionKeys)
|
||||
.addRegions(requiredUpdate.regionKeys)
|
||||
.addRiverProfiles(riverProfileKeys)
|
||||
.addRiverProfiles(requiredUpdate.riverProfileKeys)
|
||||
.addObjects(objectKeys)
|
||||
.addObjects(requiredUpdate.objectKeys)
|
||||
.addRiverFeatures(riverFeatures)
|
||||
.addRiverFeatures(requiredUpdate.riverFeatures)
|
||||
.addStructures(structures)
|
||||
.addStructures(requiredUpdate.structures);
|
||||
if (sealed || requiredUpdate.sealed) {
|
||||
merged.seal();
|
||||
}
|
||||
return merged.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object candidate) {
|
||||
if (this == candidate) {
|
||||
return true;
|
||||
}
|
||||
if (!(candidate instanceof ChunkGenerationSemantics semantics)) {
|
||||
return false;
|
||||
}
|
||||
return chunkX == semantics.chunkX
|
||||
&& chunkZ == semantics.chunkZ
|
||||
&& activationId == semantics.activationId
|
||||
&& sealed == semantics.sealed
|
||||
&& surfaceBiomeKeys.equals(semantics.surfaceBiomeKeys)
|
||||
&& caveBiomeKeys.equals(semantics.caveBiomeKeys)
|
||||
&& regionKeys.equals(semantics.regionKeys)
|
||||
&& riverProfileKeys.equals(semantics.riverProfileKeys)
|
||||
&& objectKeys.equals(semantics.objectKeys)
|
||||
&& riverFeatures.equals(semantics.riverFeatures)
|
||||
&& structures.equals(semantics.structures);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(
|
||||
chunkX,
|
||||
chunkZ,
|
||||
activationId,
|
||||
sealed,
|
||||
surfaceBiomeKeys,
|
||||
caveBiomeKeys,
|
||||
regionKeys,
|
||||
riverProfileKeys,
|
||||
objectKeys,
|
||||
riverFeatures,
|
||||
structures
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ChunkGenerationSemantics[chunkX=" + chunkX
|
||||
+ ", chunkZ=" + chunkZ
|
||||
+ ", activationId=" + activationId
|
||||
+ ", sealed=" + sealed
|
||||
+ ", surfaceBiomeKeys=" + surfaceBiomeKeys
|
||||
+ ", caveBiomeKeys=" + caveBiomeKeys
|
||||
+ ", regionKeys=" + regionKeys
|
||||
+ ", riverProfileKeys=" + riverProfileKeys
|
||||
+ ", objectKeys=" + objectKeys
|
||||
+ ", riverFeatures=" + riverFeatures
|
||||
+ ", structures=" + structures
|
||||
+ "]";
|
||||
}
|
||||
|
||||
static Comparator<StructureOccurrence> structureComparator() {
|
||||
return STRUCTURE_COMPARATOR;
|
||||
}
|
||||
|
||||
static Comparator<RiverFeatureOccurrence> riverFeatureComparator() {
|
||||
return RIVER_FEATURE_COMPARATOR;
|
||||
}
|
||||
|
||||
static String requireResourceKey(String key) {
|
||||
String requiredKey = Objects.requireNonNull(key, "key");
|
||||
if (requiredKey.isBlank()) {
|
||||
throw new IllegalArgumentException("Generation semantic resource keys must not be blank");
|
||||
}
|
||||
if (!requiredKey.equals(requiredKey.trim())) {
|
||||
throw new IllegalArgumentException("Generation semantic resource keys must not contain surrounding whitespace: " + requiredKey);
|
||||
}
|
||||
if (!requiredKey.equals(requiredKey.toLowerCase(Locale.ROOT))) {
|
||||
throw new IllegalArgumentException("Generation semantic resource keys must be lowercase: " + requiredKey);
|
||||
}
|
||||
if (!RESOURCE_KEY_PATTERN.matcher(requiredKey).matches()) {
|
||||
throw new IllegalArgumentException("Generation semantic resource key is not normalized: " + requiredKey);
|
||||
}
|
||||
if (requiredKey.getBytes(StandardCharsets.UTF_8).length > MAX_KEY_BYTES) {
|
||||
throw new IllegalArgumentException("Generation semantic resource key is too long");
|
||||
}
|
||||
return requiredKey;
|
||||
}
|
||||
|
||||
private static Set<String> immutableKeys(Collection<String> keys, String kind) {
|
||||
if (keys.size() > MAX_KEYS_PER_KIND) {
|
||||
throw new IllegalArgumentException(
|
||||
"A chunk cannot contain more than " + MAX_KEYS_PER_KIND + " " + kind + " keys"
|
||||
);
|
||||
}
|
||||
TreeSet<String> validated = new TreeSet<>();
|
||||
for (String key : keys) {
|
||||
validated.add(requireResourceKey(key));
|
||||
}
|
||||
return Collections.unmodifiableSet(validated);
|
||||
}
|
||||
|
||||
private static Set<StructureOccurrence> immutableStructures(Collection<StructureOccurrence> structures) {
|
||||
if (structures.size() > MAX_STRUCTURES) {
|
||||
throw new IllegalArgumentException(
|
||||
"A chunk cannot contain more than " + MAX_STRUCTURES + " structure occurrences"
|
||||
);
|
||||
}
|
||||
TreeSet<StructureOccurrence> validated = new TreeSet<>(STRUCTURE_COMPARATOR);
|
||||
for (StructureOccurrence occurrence : structures) {
|
||||
validated.add(Objects.requireNonNull(occurrence, "structure occurrence"));
|
||||
}
|
||||
return Collections.unmodifiableSet(validated);
|
||||
}
|
||||
|
||||
private static Set<RiverFeatureOccurrence> immutableRiverFeatures(
|
||||
Collection<RiverFeatureOccurrence> occurrences
|
||||
) {
|
||||
if (occurrences.size() > MAX_RIVER_FEATURES) {
|
||||
throw new IllegalArgumentException(
|
||||
"A chunk cannot contain more than " + MAX_RIVER_FEATURES + " river feature occurrences"
|
||||
);
|
||||
}
|
||||
TreeSet<RiverFeatureOccurrence> validated = new TreeSet<>(RIVER_FEATURE_COMPARATOR);
|
||||
for (RiverFeatureOccurrence occurrence : occurrences) {
|
||||
validated.add(Objects.requireNonNull(occurrence, "river feature occurrence"));
|
||||
}
|
||||
return Collections.unmodifiableSet(validated);
|
||||
}
|
||||
|
||||
public record BlockPosition(int x, int y, int z) {
|
||||
}
|
||||
|
||||
public record StructureOccurrence(String key, BlockPosition position) {
|
||||
public StructureOccurrence {
|
||||
key = requireResourceKey(key);
|
||||
position = Objects.requireNonNull(position, "position");
|
||||
}
|
||||
}
|
||||
|
||||
public record RiverFeatureOccurrence(
|
||||
String profileKey,
|
||||
HydrologyFeatureType type,
|
||||
long featureId,
|
||||
BlockPosition position
|
||||
) {
|
||||
public RiverFeatureOccurrence {
|
||||
profileKey = requireResourceKey(profileKey);
|
||||
type = Objects.requireNonNull(type, "type");
|
||||
position = Objects.requireNonNull(position, "position");
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private final int chunkX;
|
||||
private final int chunkZ;
|
||||
private final long activationId;
|
||||
private final Set<String> surfaceBiomeKeys;
|
||||
private final Set<String> caveBiomeKeys;
|
||||
private final Set<String> regionKeys;
|
||||
private final Set<String> riverProfileKeys;
|
||||
private final Set<String> objectKeys;
|
||||
private final Set<RiverFeatureOccurrence> riverFeatures;
|
||||
private final Set<StructureOccurrence> structures;
|
||||
private boolean sealed;
|
||||
|
||||
private Builder(int chunkX, int chunkZ, long activationId) {
|
||||
if (activationId <= 0L) {
|
||||
throw new IllegalArgumentException("Generation activation IDs must be positive: " + activationId);
|
||||
}
|
||||
this.chunkX = chunkX;
|
||||
this.chunkZ = chunkZ;
|
||||
this.activationId = activationId;
|
||||
surfaceBiomeKeys = new TreeSet<>();
|
||||
caveBiomeKeys = new TreeSet<>();
|
||||
regionKeys = new TreeSet<>();
|
||||
riverProfileKeys = new TreeSet<>();
|
||||
objectKeys = new TreeSet<>();
|
||||
riverFeatures = new TreeSet<>(RIVER_FEATURE_COMPARATOR);
|
||||
structures = new TreeSet<>(STRUCTURE_COMPARATOR);
|
||||
}
|
||||
|
||||
public Builder addSurfaceBiome(String key) {
|
||||
addKey(surfaceBiomeKeys, key, "surface biome");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addSurfaceBiomes(Collection<String> keys) {
|
||||
addKeys(surfaceBiomeKeys, keys, "surface biome");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addCaveBiome(String key) {
|
||||
addKey(caveBiomeKeys, key, "cave biome");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addCaveBiomes(Collection<String> keys) {
|
||||
addKeys(caveBiomeKeys, keys, "cave biome");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addRegion(String key) {
|
||||
addKey(regionKeys, key, "region");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addRegions(Collection<String> keys) {
|
||||
addKeys(regionKeys, keys, "region");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addRiverProfile(String key) {
|
||||
addKey(riverProfileKeys, key, "river profile");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addRiverProfiles(Collection<String> keys) {
|
||||
addKeys(riverProfileKeys, keys, "river profile");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addRiverFeature(
|
||||
String profileKey,
|
||||
HydrologyFeatureType type,
|
||||
long featureId,
|
||||
int blockX,
|
||||
int blockY,
|
||||
int blockZ
|
||||
) {
|
||||
return addRiverFeature(new RiverFeatureOccurrence(
|
||||
profileKey,
|
||||
type,
|
||||
featureId,
|
||||
new BlockPosition(blockX, blockY, blockZ)
|
||||
));
|
||||
}
|
||||
|
||||
public Builder addRiverFeature(RiverFeatureOccurrence occurrence) {
|
||||
RiverFeatureOccurrence required = Objects.requireNonNull(
|
||||
occurrence,
|
||||
"river feature occurrence"
|
||||
);
|
||||
if (riverFeatures.size() >= MAX_RIVER_FEATURES && !riverFeatures.contains(required)) {
|
||||
throw new IllegalArgumentException(
|
||||
"A chunk cannot contain more than " + MAX_RIVER_FEATURES
|
||||
+ " river feature occurrences"
|
||||
);
|
||||
}
|
||||
riverFeatures.add(required);
|
||||
addRiverProfile(required.profileKey());
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addRiverFeatures(Collection<RiverFeatureOccurrence> occurrences) {
|
||||
Collection<RiverFeatureOccurrence> requiredOccurrences = Objects.requireNonNull(
|
||||
occurrences,
|
||||
"river feature occurrences"
|
||||
);
|
||||
for (RiverFeatureOccurrence occurrence : requiredOccurrences) {
|
||||
addRiverFeature(occurrence);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addObject(String key) {
|
||||
addKey(objectKeys, key, "object");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addObjects(Collection<String> keys) {
|
||||
addKeys(objectKeys, keys, "object");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addStructure(String key, int blockX, int blockY, int blockZ) {
|
||||
return addStructure(new StructureOccurrence(key, new BlockPosition(blockX, blockY, blockZ)));
|
||||
}
|
||||
|
||||
public Builder addStructure(StructureOccurrence occurrence) {
|
||||
if (structures.size() >= MAX_STRUCTURES && !structures.contains(occurrence)) {
|
||||
throw new IllegalArgumentException(
|
||||
"A chunk cannot contain more than " + MAX_STRUCTURES + " structure occurrences"
|
||||
);
|
||||
}
|
||||
structures.add(Objects.requireNonNull(occurrence, "structure occurrence"));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addStructures(Collection<StructureOccurrence> occurrences) {
|
||||
Collection<StructureOccurrence> requiredOccurrences = Objects.requireNonNull(occurrences, "occurrences");
|
||||
for (StructureOccurrence occurrence : requiredOccurrences) {
|
||||
addStructure(occurrence);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder seal() {
|
||||
sealed = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ChunkGenerationSemantics build() {
|
||||
return new ChunkGenerationSemantics(this);
|
||||
}
|
||||
|
||||
private static void addKeys(Set<String> target, Collection<String> keys, String kind) {
|
||||
Collection<String> requiredKeys = Objects.requireNonNull(keys, "keys");
|
||||
for (String key : requiredKeys) {
|
||||
addKey(target, key, kind);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addKey(Set<String> target, String key, String kind) {
|
||||
String validated = requireResourceKey(key);
|
||||
if (target.size() >= MAX_KEYS_PER_KIND && !target.contains(validated)) {
|
||||
throw new IllegalArgumentException(
|
||||
"A chunk cannot contain more than " + MAX_KEYS_PER_KIND + " " + kind + " keys"
|
||||
);
|
||||
}
|
||||
target.add(validated);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonNull;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class GenerationActivation {
|
||||
private static final Pattern EPOCH_ID_PATTERN = Pattern.compile("[0-9a-f]{64}");
|
||||
|
||||
private final long activationId;
|
||||
private final String epochId;
|
||||
private final Long parentActivationId;
|
||||
private final long createdAtEpochMillis;
|
||||
private final GenerationTransition transition;
|
||||
|
||||
private GenerationActivation(State state) {
|
||||
State requiredState = Objects.requireNonNull(state, "state");
|
||||
this.activationId = requiredState.activationId();
|
||||
this.epochId = requiredState.epochId();
|
||||
this.parentActivationId = requiredState.parentActivationId();
|
||||
this.createdAtEpochMillis = requiredState.createdAtEpochMillis();
|
||||
this.transition = requiredState.transition();
|
||||
}
|
||||
|
||||
static GenerationActivation initial(String epochId, long createdAtEpochMillis) {
|
||||
return new GenerationActivation(new State(1L, epochId, null, createdAtEpochMillis, null));
|
||||
}
|
||||
|
||||
static GenerationActivation next(
|
||||
long activationId,
|
||||
String epochId,
|
||||
long parentActivationId,
|
||||
long createdAtEpochMillis,
|
||||
int transitionWidthBlocks
|
||||
) {
|
||||
return new GenerationActivation(new State(
|
||||
activationId,
|
||||
epochId,
|
||||
parentActivationId,
|
||||
createdAtEpochMillis,
|
||||
GenerationTransition.pending(transitionWidthBlocks)
|
||||
));
|
||||
}
|
||||
|
||||
static GenerationActivation restore(State state) {
|
||||
return new GenerationActivation(state);
|
||||
}
|
||||
|
||||
public long activationId() {
|
||||
return activationId;
|
||||
}
|
||||
|
||||
public String epochId() {
|
||||
return epochId;
|
||||
}
|
||||
|
||||
public Long parentActivationId() {
|
||||
return parentActivationId;
|
||||
}
|
||||
|
||||
public long createdAtEpochMillis() {
|
||||
return createdAtEpochMillis;
|
||||
}
|
||||
|
||||
public GenerationTransition transition() {
|
||||
return transition;
|
||||
}
|
||||
|
||||
public boolean isInitial() {
|
||||
return parentActivationId == null;
|
||||
}
|
||||
|
||||
GenerationActivation completeTransition(String boundaryIdentity, String terrainSignatureIdentity) {
|
||||
if (transition == null) {
|
||||
throw new IllegalStateException("The initial activation has no generation transition.");
|
||||
}
|
||||
return new GenerationActivation(new State(
|
||||
activationId,
|
||||
epochId,
|
||||
parentActivationId,
|
||||
createdAtEpochMillis,
|
||||
transition.complete(boundaryIdentity, terrainSignatureIdentity)
|
||||
));
|
||||
}
|
||||
|
||||
State state() {
|
||||
return new State(activationId, epochId, parentActivationId, createdAtEpochMillis, transition);
|
||||
}
|
||||
|
||||
JsonObject toJson() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("activationId", activationId);
|
||||
json.addProperty("epochId", epochId);
|
||||
if (parentActivationId == null) {
|
||||
json.add("parentActivationId", JsonNull.INSTANCE);
|
||||
} else {
|
||||
json.addProperty("parentActivationId", parentActivationId);
|
||||
}
|
||||
json.addProperty("createdAtEpochMillis", createdAtEpochMillis);
|
||||
if (transition == null) {
|
||||
json.add("transition", JsonNull.INSTANCE);
|
||||
} else {
|
||||
json.add("transition", transition.toJson());
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
static GenerationActivation fromJson(JsonObject json) {
|
||||
GenerationManifest.JsonSchema.requireFields(
|
||||
json,
|
||||
"activation",
|
||||
"activationId",
|
||||
"epochId",
|
||||
"parentActivationId",
|
||||
"createdAtEpochMillis",
|
||||
"transition"
|
||||
);
|
||||
long activationId = GenerationManifest.JsonSchema.requireLong(json, "activationId", "activation");
|
||||
String epochId = GenerationManifest.JsonSchema.requireString(json, "epochId", "activation");
|
||||
Long parentActivationId = GenerationManifest.JsonSchema.requireNullableLong(
|
||||
json,
|
||||
"parentActivationId",
|
||||
"activation"
|
||||
);
|
||||
long createdAtEpochMillis = GenerationManifest.JsonSchema.requireLong(
|
||||
json,
|
||||
"createdAtEpochMillis",
|
||||
"activation"
|
||||
);
|
||||
JsonObject transitionJson = GenerationManifest.JsonSchema.requireNullableObject(
|
||||
json,
|
||||
"transition",
|
||||
"activation"
|
||||
);
|
||||
GenerationTransition transition = transitionJson == null
|
||||
? null
|
||||
: GenerationTransition.fromJson(transitionJson);
|
||||
return restore(new State(
|
||||
activationId,
|
||||
epochId,
|
||||
parentActivationId,
|
||||
createdAtEpochMillis,
|
||||
transition
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof GenerationActivation activation)) {
|
||||
return false;
|
||||
}
|
||||
return activationId == activation.activationId
|
||||
&& createdAtEpochMillis == activation.createdAtEpochMillis
|
||||
&& epochId.equals(activation.epochId)
|
||||
&& Objects.equals(parentActivationId, activation.parentActivationId)
|
||||
&& Objects.equals(transition, activation.transition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(activationId, epochId, parentActivationId, createdAtEpochMillis, transition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "GenerationActivation[activationId=" + activationId
|
||||
+ ", epochId=" + epochId
|
||||
+ ", parentActivationId=" + parentActivationId
|
||||
+ ", createdAtEpochMillis=" + createdAtEpochMillis
|
||||
+ ", transition=" + transition + "]";
|
||||
}
|
||||
|
||||
record State(
|
||||
long activationId,
|
||||
String epochId,
|
||||
Long parentActivationId,
|
||||
long createdAtEpochMillis,
|
||||
GenerationTransition transition
|
||||
) {
|
||||
State {
|
||||
if (activationId < 1L) {
|
||||
throw new IllegalArgumentException("Activation ID must be positive.");
|
||||
}
|
||||
epochId = Objects.requireNonNull(epochId, "epochId");
|
||||
if (!EPOCH_ID_PATTERN.matcher(epochId).matches()) {
|
||||
throw new IllegalArgumentException("Activation epoch ID must be a lowercase SHA-256 value.");
|
||||
}
|
||||
if (parentActivationId != null
|
||||
&& (parentActivationId < 1L || parentActivationId >= activationId)) {
|
||||
throw new IllegalArgumentException("Parent activation ID must identify an earlier activation.");
|
||||
}
|
||||
if (createdAtEpochMillis < 0L) {
|
||||
throw new IllegalArgumentException("Activation creation time cannot be negative.");
|
||||
}
|
||||
if ((parentActivationId == null) != (transition == null)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Only the initial activation may omit a generation transition."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.locks.Condition;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
public final class GenerationAdmission {
|
||||
private static final ConcurrentHashMap<Path, State> STATES = new ConcurrentHashMap<>();
|
||||
|
||||
private final State state;
|
||||
|
||||
public GenerationAdmission(Path dimensionRoot) {
|
||||
Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot")
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
this.state = STATES.computeIfAbsent(root, ignored -> new State());
|
||||
}
|
||||
|
||||
public StageLease enterStage() {
|
||||
state.enterStage();
|
||||
return new StageLease(state);
|
||||
}
|
||||
|
||||
public CutoverLease beginCutover() {
|
||||
state.beginCutover(false);
|
||||
return new CutoverLease(state);
|
||||
}
|
||||
|
||||
CutoverLease beginStartupCutover() {
|
||||
state.beginCutover(true);
|
||||
return new CutoverLease(state);
|
||||
}
|
||||
|
||||
public static final class StageLease implements AutoCloseable {
|
||||
private final State state;
|
||||
private final AtomicBoolean closed;
|
||||
|
||||
private StageLease(State state) {
|
||||
this.state = state;
|
||||
this.closed = new AtomicBoolean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
state.leaveStage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class CutoverLease implements AutoCloseable {
|
||||
private final State state;
|
||||
private final AtomicBoolean closed;
|
||||
|
||||
private CutoverLease(State state) {
|
||||
this.state = state;
|
||||
this.closed = new AtomicBoolean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
state.endCutover();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class State {
|
||||
private final ReentrantLock lock;
|
||||
private final Condition changed;
|
||||
private int activeStages;
|
||||
private int waitingCutovers;
|
||||
private boolean cutoverActive;
|
||||
private boolean generationAdmissionOpened;
|
||||
|
||||
private State() {
|
||||
this.lock = new ReentrantLock(true);
|
||||
this.changed = lock.newCondition();
|
||||
}
|
||||
|
||||
private void enterStage() {
|
||||
lock.lock();
|
||||
try {
|
||||
generationAdmissionOpened = true;
|
||||
while (cutoverActive || waitingCutovers > 0) {
|
||||
changed.awaitUninterruptibly();
|
||||
}
|
||||
activeStages++;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void leaveStage() {
|
||||
lock.lock();
|
||||
try {
|
||||
if (activeStages < 1) {
|
||||
throw new IllegalStateException("No generation stage is active.");
|
||||
}
|
||||
activeStages--;
|
||||
if (activeStages == 0) {
|
||||
changed.signalAll();
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void beginCutover(boolean startupOnly) {
|
||||
lock.lock();
|
||||
try {
|
||||
if (startupOnly && generationAdmissionOpened) {
|
||||
throw new IllegalStateException(
|
||||
"Generation activation promotion is only allowed before generation admission opens."
|
||||
);
|
||||
}
|
||||
waitingCutovers++;
|
||||
try {
|
||||
while (cutoverActive || activeStages > 0) {
|
||||
changed.awaitUninterruptibly();
|
||||
}
|
||||
cutoverActive = true;
|
||||
} finally {
|
||||
waitingCutovers--;
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void endCutover() {
|
||||
lock.lock();
|
||||
try {
|
||||
if (!cutoverActive) {
|
||||
throw new IllegalStateException("No generation cutover is active.");
|
||||
}
|
||||
cutoverActive = false;
|
||||
changed.signalAll();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
public final class GenerationBlend {
|
||||
private GenerationBlend() {
|
||||
}
|
||||
|
||||
public static double newEpochWeight(double distanceToHistoricalBlocks, int widthBlocks) {
|
||||
if (!Double.isFinite(distanceToHistoricalBlocks) || distanceToHistoricalBlocks < 0D) {
|
||||
throw new IllegalArgumentException("Distance must be finite and non-negative");
|
||||
}
|
||||
if (widthBlocks <= 0) {
|
||||
throw new IllegalArgumentException("Blend width must be positive");
|
||||
}
|
||||
if (distanceToHistoricalBlocks <= 0D) {
|
||||
return 0D;
|
||||
}
|
||||
if (distanceToHistoricalBlocks >= widthBlocks) {
|
||||
return 1D;
|
||||
}
|
||||
return smootherstep(distanceToHistoricalBlocks / widthBlocks);
|
||||
}
|
||||
|
||||
public static double smootherstep(double progress) {
|
||||
if (!Double.isFinite(progress)) {
|
||||
throw new IllegalArgumentException("Progress must be finite");
|
||||
}
|
||||
if (progress <= 0D) {
|
||||
return 0D;
|
||||
}
|
||||
if (progress >= 1D) {
|
||||
return 1D;
|
||||
}
|
||||
return progress * progress * progress * (progress * (progress * 6D - 15D) + 10D);
|
||||
}
|
||||
|
||||
public static double interpolate(double historicalValue, double newValue, double newEpochWeight) {
|
||||
if (!Double.isFinite(historicalValue) || !Double.isFinite(newValue)) {
|
||||
throw new IllegalArgumentException("Blend values must be finite");
|
||||
}
|
||||
validateWeight(newEpochWeight);
|
||||
if (newEpochWeight == 0D) {
|
||||
return historicalValue;
|
||||
}
|
||||
if (newEpochWeight == 1D) {
|
||||
return newValue;
|
||||
}
|
||||
return historicalValue + (newValue - historicalValue) * newEpochWeight;
|
||||
}
|
||||
|
||||
public static int interpolateHeight(int historicalHeight, int newHeight, double newEpochWeight) {
|
||||
validateWeight(newEpochWeight);
|
||||
if (newEpochWeight == 0D) {
|
||||
return historicalHeight;
|
||||
}
|
||||
if (newEpochWeight == 1D) {
|
||||
return newHeight;
|
||||
}
|
||||
return (int) Math.round(historicalHeight + ((double) newHeight - historicalHeight) * newEpochWeight);
|
||||
}
|
||||
|
||||
private static void validateWeight(double weight) {
|
||||
if (!Double.isFinite(weight) || weight < 0D || weight > 1D) {
|
||||
throw new IllegalArgumentException("Blend weight must be finite and between zero and one");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class GenerationBoundary {
|
||||
public static final int CHUNK_SIZE = 16;
|
||||
|
||||
private final String identity;
|
||||
private final int historicalChunkCount;
|
||||
private final HistoricalChunkSource historicalChunks;
|
||||
|
||||
private GenerationBoundary(
|
||||
String identity,
|
||||
int historicalChunkCount,
|
||||
HistoricalChunkSource historicalChunks
|
||||
) {
|
||||
this.identity = requireIdentity(identity);
|
||||
if (historicalChunkCount < 0) {
|
||||
throw new IllegalArgumentException("Historical chunk count cannot be negative");
|
||||
}
|
||||
this.historicalChunkCount = historicalChunkCount;
|
||||
this.historicalChunks = Objects.requireNonNull(historicalChunks, "Historical chunks");
|
||||
}
|
||||
|
||||
public static GenerationBoundary freeze(String identity, Collection<ChunkCoordinate> historicalChunks) {
|
||||
Collection<ChunkCoordinate> requiredChunks = Objects.requireNonNull(
|
||||
historicalChunks,
|
||||
"Historical chunks"
|
||||
);
|
||||
long[] packedChunks = new long[requiredChunks.size()];
|
||||
int index = 0;
|
||||
for (ChunkCoordinate chunk : requiredChunks) {
|
||||
ChunkCoordinate coordinate = Objects.requireNonNull(chunk, "Historical chunk");
|
||||
packedChunks[index++] = packChunk(coordinate.chunkX(), coordinate.chunkZ());
|
||||
}
|
||||
return freezePacked(identity, packedChunks);
|
||||
}
|
||||
|
||||
public static GenerationBoundary freezePacked(String identity, long[] historicalChunks) {
|
||||
long[] normalized = normalize(Objects.requireNonNull(historicalChunks, "Historical chunks"));
|
||||
return new GenerationBoundary(identity, normalized.length, new ArraySource(normalized));
|
||||
}
|
||||
|
||||
static GenerationBoundary backed(
|
||||
String identity,
|
||||
int historicalChunkCount,
|
||||
HistoricalChunkSource historicalChunks
|
||||
) {
|
||||
return new GenerationBoundary(identity, historicalChunkCount, historicalChunks);
|
||||
}
|
||||
|
||||
public String identity() {
|
||||
return identity;
|
||||
}
|
||||
|
||||
public int historicalChunkCount() {
|
||||
return historicalChunkCount;
|
||||
}
|
||||
|
||||
public boolean isHistoricalChunk(int chunkX, int chunkZ) {
|
||||
try {
|
||||
return historicalChunks.contains(chunkX, chunkZ);
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException("Unable to read the frozen generation boundary", error);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isHistoricalBlock(int blockX, int blockZ) {
|
||||
return isHistoricalChunk(blockToChunk(blockX), blockToChunk(blockZ));
|
||||
}
|
||||
|
||||
public boolean intersectsHistoricalBlocks(
|
||||
int minimumX,
|
||||
int minimumZ,
|
||||
int maximumX,
|
||||
int maximumZ
|
||||
) {
|
||||
if (minimumX > maximumX || minimumZ > maximumZ) {
|
||||
throw new IllegalArgumentException("Generation footprint bounds are inverted");
|
||||
}
|
||||
if (historicalChunkCount == 0) {
|
||||
return false;
|
||||
}
|
||||
int minimumChunkX = blockToChunk(minimumX);
|
||||
int maximumChunkX = blockToChunk(maximumX);
|
||||
int minimumChunkZ = blockToChunk(minimumZ);
|
||||
int maximumChunkZ = blockToChunk(maximumZ);
|
||||
long candidateColumns = (long) maximumChunkX - minimumChunkX + 1L;
|
||||
long candidateRows = (long) maximumChunkZ - minimumChunkZ + 1L;
|
||||
long candidateCount = multiplySaturated(candidateColumns, candidateRows);
|
||||
if (candidateCount <= historicalChunkCount) {
|
||||
for (long chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
|
||||
for (long chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
|
||||
if (isHistoricalChunk((int) chunkX, (int) chunkZ)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
boolean[] found = new boolean[1];
|
||||
try {
|
||||
historicalChunks.forEach((chunkX, chunkZ) -> {
|
||||
if (!found[0]
|
||||
&& chunkX >= minimumChunkX && chunkX <= maximumChunkX
|
||||
&& chunkZ >= minimumChunkZ && chunkZ <= maximumChunkZ) {
|
||||
found[0] = true;
|
||||
}
|
||||
});
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException("Unable to scan the frozen generation boundary", error);
|
||||
}
|
||||
return found[0];
|
||||
}
|
||||
|
||||
public double distanceToHistoricalChunks(int blockX, int blockZ, int maximumDistanceBlocks) {
|
||||
if (maximumDistanceBlocks <= 0) {
|
||||
throw new IllegalArgumentException("Maximum distance must be positive");
|
||||
}
|
||||
if (historicalChunkCount == 0) {
|
||||
return maximumDistanceBlocks;
|
||||
}
|
||||
if (isHistoricalBlock(blockX, blockZ)) {
|
||||
return 0D;
|
||||
}
|
||||
|
||||
long minimumChunkX = blockToChunk((long) blockX - maximumDistanceBlocks);
|
||||
long maximumChunkX = blockToChunk((long) blockX + maximumDistanceBlocks);
|
||||
long minimumChunkZ = blockToChunk((long) blockZ - maximumDistanceBlocks);
|
||||
long maximumChunkZ = blockToChunk((long) blockZ + maximumDistanceBlocks);
|
||||
long candidateColumns = maximumChunkX - minimumChunkX + 1L;
|
||||
long candidateRows = maximumChunkZ - minimumChunkZ + 1L;
|
||||
long candidateCount = multiplySaturated(candidateColumns, candidateRows);
|
||||
|
||||
double distanceSquared = candidateCount <= historicalChunkCount
|
||||
? nearestByNeighborhood(
|
||||
blockX,
|
||||
blockZ,
|
||||
minimumChunkX,
|
||||
maximumChunkX,
|
||||
minimumChunkZ,
|
||||
maximumChunkZ,
|
||||
maximumDistanceBlocks
|
||||
)
|
||||
: nearestBySource(blockX, blockZ, maximumDistanceBlocks);
|
||||
return Math.sqrt(distanceSquared);
|
||||
}
|
||||
|
||||
public long[] packedHistoricalChunks() {
|
||||
long[] chunks = new long[historicalChunkCount];
|
||||
int[] cursor = new int[1];
|
||||
try {
|
||||
historicalChunks.forEach((chunkX, chunkZ) -> {
|
||||
if (cursor[0] >= chunks.length) {
|
||||
throw new IOException("Frozen boundary contains more chunks than its catalog declares");
|
||||
}
|
||||
chunks[cursor[0]++] = packChunk(chunkX, chunkZ);
|
||||
});
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException("Unable to materialize the frozen generation boundary", error);
|
||||
}
|
||||
if (cursor[0] != chunks.length) {
|
||||
throw new IllegalStateException("Frozen boundary contains fewer chunks than its catalog declares");
|
||||
}
|
||||
Arrays.sort(chunks);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
public void forEachHistoricalChunk(ChunkConsumer consumer) throws IOException {
|
||||
historicalChunks.forEach(Objects.requireNonNull(consumer, "Historical chunk consumer"));
|
||||
}
|
||||
|
||||
int cachedRegionCount() {
|
||||
return historicalChunks.cachedRegionCount();
|
||||
}
|
||||
|
||||
public void requireCompleteTerrainSignatures(
|
||||
Collection<TerrainBoundarySignature> signatures
|
||||
) {
|
||||
Collection<TerrainBoundarySignature> requiredSignatures = Objects.requireNonNull(
|
||||
signatures,
|
||||
"Terrain signatures"
|
||||
);
|
||||
LongOpenHashSet actual = new LongOpenHashSet(requiredSignatures.size());
|
||||
for (TerrainBoundarySignature signature : requiredSignatures) {
|
||||
TerrainBoundarySignature requiredSignature = Objects.requireNonNull(
|
||||
signature,
|
||||
"Terrain signature"
|
||||
);
|
||||
if (!isExposedBlockColumn(requiredSignature.blockX(), requiredSignature.blockZ())) {
|
||||
throw new IllegalArgumentException("Terrain signature is not on the frozen generation boundary: "
|
||||
+ requiredSignature.blockX() + "," + requiredSignature.blockZ());
|
||||
}
|
||||
long column = packBlock(requiredSignature.blockX(), requiredSignature.blockZ());
|
||||
if (!actual.add(column)) {
|
||||
throw new IllegalArgumentException("Duplicate terrain signature column: "
|
||||
+ requiredSignature.blockX() + "," + requiredSignature.blockZ());
|
||||
}
|
||||
}
|
||||
long expectedCount = exposedBlockColumnCount();
|
||||
if (actual.size() != expectedCount) {
|
||||
throw new IllegalArgumentException("Terrain signatures contain " + actual.size()
|
||||
+ " columns but the frozen boundary requires " + expectedCount);
|
||||
}
|
||||
}
|
||||
|
||||
public List<BlockColumn> exposedBlockColumns() {
|
||||
ArrayList<BlockColumn> columns = new ArrayList<>();
|
||||
try {
|
||||
forEachExposedBlockColumn((blockX, blockZ) -> columns.add(new BlockColumn(blockX, blockZ)));
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException("Unable to enumerate frozen boundary columns", error);
|
||||
}
|
||||
columns.sort(Comparator
|
||||
.comparingInt(BlockColumn::blockX)
|
||||
.thenComparingInt(BlockColumn::blockZ));
|
||||
return List.copyOf(columns);
|
||||
}
|
||||
|
||||
public long exposedBlockColumnCount() {
|
||||
long[] count = new long[1];
|
||||
try {
|
||||
forEachExposedBlockColumn((blockX, blockZ) -> count[0]++);
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException("Unable to count frozen boundary columns", error);
|
||||
}
|
||||
return count[0];
|
||||
}
|
||||
|
||||
public void forEachExposedBlockColumn(BlockColumnConsumer consumer) throws IOException {
|
||||
BlockColumnConsumer requiredConsumer = Objects.requireNonNull(consumer, "Boundary column consumer");
|
||||
historicalChunks.forEach((chunkX, chunkZ) -> emitExposedColumns(chunkX, chunkZ, requiredConsumer));
|
||||
}
|
||||
|
||||
public static long packChunk(int chunkX, int chunkZ) {
|
||||
return ((long) chunkX << Integer.SIZE) ^ (chunkZ & 0xffffffffL);
|
||||
}
|
||||
|
||||
public static ChunkCoordinate unpackChunk(long packedChunk) {
|
||||
return new ChunkCoordinate((int) (packedChunk >> Integer.SIZE), (int) packedChunk);
|
||||
}
|
||||
|
||||
private void emitExposedColumns(
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
BlockColumnConsumer consumer
|
||||
) throws IOException {
|
||||
int minimumX = Math.multiplyExact(chunkX, CHUNK_SIZE);
|
||||
int minimumZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
|
||||
boolean north = chunkZ == Integer.MIN_VALUE || !historicalChunks.contains(chunkX, chunkZ - 1);
|
||||
boolean south = chunkZ == Integer.MAX_VALUE || !historicalChunks.contains(chunkX, chunkZ + 1);
|
||||
boolean west = chunkX == Integer.MIN_VALUE || !historicalChunks.contains(chunkX - 1, chunkZ);
|
||||
boolean east = chunkX == Integer.MAX_VALUE || !historicalChunks.contains(chunkX + 1, chunkZ);
|
||||
for (int localX = 0; localX < CHUNK_SIZE; localX++) {
|
||||
for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) {
|
||||
if (localZ == 0 && north
|
||||
|| localZ == CHUNK_SIZE - 1 && south
|
||||
|| localX == 0 && west
|
||||
|| localX == CHUNK_SIZE - 1 && east) {
|
||||
consumer.accept(minimumX + localX, minimumZ + localZ);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isExposedBlockColumn(int blockX, int blockZ) {
|
||||
int chunkX = blockToChunk(blockX);
|
||||
int chunkZ = blockToChunk(blockZ);
|
||||
if (!isHistoricalChunk(chunkX, chunkZ)) {
|
||||
return false;
|
||||
}
|
||||
int localX = Math.floorMod(blockX, CHUNK_SIZE);
|
||||
int localZ = Math.floorMod(blockZ, CHUNK_SIZE);
|
||||
return localZ == 0 && (chunkZ == Integer.MIN_VALUE || !isHistoricalChunk(chunkX, chunkZ - 1))
|
||||
|| localZ == CHUNK_SIZE - 1
|
||||
&& (chunkZ == Integer.MAX_VALUE || !isHistoricalChunk(chunkX, chunkZ + 1))
|
||||
|| localX == 0 && (chunkX == Integer.MIN_VALUE || !isHistoricalChunk(chunkX - 1, chunkZ))
|
||||
|| localX == CHUNK_SIZE - 1
|
||||
&& (chunkX == Integer.MAX_VALUE || !isHistoricalChunk(chunkX + 1, chunkZ));
|
||||
}
|
||||
|
||||
private double nearestByNeighborhood(
|
||||
int blockX,
|
||||
int blockZ,
|
||||
long minimumChunkX,
|
||||
long maximumChunkX,
|
||||
long minimumChunkZ,
|
||||
long maximumChunkZ,
|
||||
int maximumDistanceBlocks
|
||||
) {
|
||||
double nearestSquared = square(maximumDistanceBlocks);
|
||||
for (long chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
|
||||
for (long chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
|
||||
if (!isHistoricalChunk((int) chunkX, (int) chunkZ)) {
|
||||
continue;
|
||||
}
|
||||
nearestSquared = Math.min(
|
||||
nearestSquared,
|
||||
distanceSquaredToChunk(blockX, blockZ, (int) chunkX, (int) chunkZ)
|
||||
);
|
||||
}
|
||||
}
|
||||
return nearestSquared;
|
||||
}
|
||||
|
||||
private double nearestBySource(int blockX, int blockZ, int maximumDistanceBlocks) {
|
||||
double[] nearestSquared = {square(maximumDistanceBlocks)};
|
||||
try {
|
||||
historicalChunks.forEach((chunkX, chunkZ) -> nearestSquared[0] = Math.min(
|
||||
nearestSquared[0],
|
||||
distanceSquaredToChunk(blockX, blockZ, chunkX, chunkZ)
|
||||
));
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException("Unable to scan the frozen generation boundary", error);
|
||||
}
|
||||
return nearestSquared[0];
|
||||
}
|
||||
|
||||
private static double distanceSquaredToChunk(int blockX, int blockZ, int chunkX, int chunkZ) {
|
||||
long minimumX = (long) chunkX * CHUNK_SIZE;
|
||||
long maximumX = minimumX + CHUNK_SIZE - 1L;
|
||||
long minimumZ = (long) chunkZ * CHUNK_SIZE;
|
||||
long maximumZ = minimumZ + CHUNK_SIZE - 1L;
|
||||
long distanceX = distanceToRange(blockX, minimumX, maximumX);
|
||||
long distanceZ = distanceToRange(blockZ, minimumZ, maximumZ);
|
||||
return square(distanceX) + square(distanceZ);
|
||||
}
|
||||
|
||||
private static long distanceToRange(long value, long minimum, long maximum) {
|
||||
if (value < minimum) {
|
||||
return minimum - value;
|
||||
}
|
||||
return value > maximum ? value - maximum : 0L;
|
||||
}
|
||||
|
||||
private static double square(long value) {
|
||||
return (double) value * value;
|
||||
}
|
||||
|
||||
private static long multiplySaturated(long left, long right) {
|
||||
if (left == 0L || right == 0L) {
|
||||
return 0L;
|
||||
}
|
||||
return left > Long.MAX_VALUE / right ? Long.MAX_VALUE : left * right;
|
||||
}
|
||||
|
||||
private static int blockToChunk(int blockCoordinate) {
|
||||
return Math.floorDiv(blockCoordinate, CHUNK_SIZE);
|
||||
}
|
||||
|
||||
private static long blockToChunk(long blockCoordinate) {
|
||||
return Math.floorDiv(blockCoordinate, CHUNK_SIZE);
|
||||
}
|
||||
|
||||
private static long packBlock(int blockX, int blockZ) {
|
||||
return ((long) blockX << Integer.SIZE) ^ (blockZ & 0xffffffffL);
|
||||
}
|
||||
|
||||
private static String requireIdentity(String identity) {
|
||||
String requiredIdentity = Objects.requireNonNull(identity, "Boundary identity");
|
||||
if (requiredIdentity.isBlank()) {
|
||||
throw new IllegalArgumentException("Boundary identity cannot be blank");
|
||||
}
|
||||
return requiredIdentity;
|
||||
}
|
||||
|
||||
private static long[] normalize(long[] historicalChunks) {
|
||||
long[] normalized = historicalChunks.clone();
|
||||
Arrays.sort(normalized);
|
||||
if (normalized.length < 2) {
|
||||
return normalized;
|
||||
}
|
||||
int uniqueCount = 1;
|
||||
for (int index = 1; index < normalized.length; index++) {
|
||||
if (normalized[index] != normalized[uniqueCount - 1]) {
|
||||
normalized[uniqueCount++] = normalized[index];
|
||||
}
|
||||
}
|
||||
return Arrays.copyOf(normalized, uniqueCount);
|
||||
}
|
||||
|
||||
public record ChunkCoordinate(int chunkX, int chunkZ) {
|
||||
}
|
||||
|
||||
public record BlockColumn(int blockX, int blockZ) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ChunkConsumer {
|
||||
void accept(int chunkX, int chunkZ) throws IOException;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface BlockColumnConsumer {
|
||||
void accept(int blockX, int blockZ) throws IOException;
|
||||
}
|
||||
|
||||
interface HistoricalChunkSource {
|
||||
boolean contains(int chunkX, int chunkZ) throws IOException;
|
||||
|
||||
void forEach(ChunkConsumer consumer) throws IOException;
|
||||
|
||||
default int cachedRegionCount() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ArraySource implements HistoricalChunkSource {
|
||||
private final long[] chunks;
|
||||
|
||||
private ArraySource(long[] chunks) {
|
||||
this.chunks = chunks;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(int chunkX, int chunkZ) {
|
||||
return Arrays.binarySearch(chunks, packChunk(chunkX, chunkZ)) >= 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void forEach(ChunkConsumer consumer) throws IOException {
|
||||
for (long chunk : chunks) {
|
||||
consumer.accept((int) (chunk >> Integer.SIZE), (int) chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface GenerationBoundarySignatureSampler {
|
||||
TerrainBoundarySignature sample(IrisEngine engine, int blockX, int blockZ) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.LongArrays;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.EOFException;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.util.Collection;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
public final class GenerationBoundaryStore {
|
||||
public static final String SNAPSHOT_FILE_SUFFIX = ".irgb";
|
||||
|
||||
private static final String MASK_FILE_SUFFIX = ".irbm";
|
||||
private static final int MAGIC = 0x49524742;
|
||||
private static final int SCHEMA_VERSION = 2;
|
||||
private static final int MASK_MAGIC = 0x4952424D;
|
||||
private static final int MASK_SCHEMA_VERSION = 1;
|
||||
private static final int IDENTITY_BYTES = 32;
|
||||
private static final int CATALOG_FIXED_BODY_BYTES = 56;
|
||||
private static final int CATALOG_ENTRY_BYTES = 40;
|
||||
private static final int CHECKSUM_BYTES = Integer.BYTES;
|
||||
private static final int REGION_SIDE = 32;
|
||||
private static final int CHUNKS_PER_REGION = REGION_SIDE * REGION_SIDE;
|
||||
private static final int MASK_BYTES = CHUNKS_PER_REGION / Byte.SIZE;
|
||||
private static final int MASK_BODY_BYTES = 8 + MASK_BYTES;
|
||||
private static final int MAXIMUM_CACHED_MASKS = 64;
|
||||
private static final int MAX_REGION_COUNT = 8_388_608;
|
||||
private static final long MAX_CATALOG_BYTES = CATALOG_FIXED_BODY_BYTES
|
||||
+ (long) MAX_REGION_COUNT * CATALOG_ENTRY_BYTES
|
||||
+ CHECKSUM_BYTES;
|
||||
private static final int TRANSFER_BYTES = 65_536;
|
||||
private static final byte[] IDENTITY_DOMAIN = {
|
||||
0x49, 0x72, 0x69, 0x73, 0x42, 0x6F, 0x75, 0x6E,
|
||||
0x64, 0x61, 0x72, 0x79, 0x02
|
||||
};
|
||||
|
||||
private final Path dimensionRoot;
|
||||
private final Path directory;
|
||||
|
||||
public GenerationBoundaryStore(Path dimensionRoot) {
|
||||
Path root = Objects.requireNonNull(dimensionRoot, "Dimension root").toAbsolutePath().normalize();
|
||||
this.dimensionRoot = root;
|
||||
this.directory = root.resolve("iris").resolve("generation").resolve("boundaries").normalize();
|
||||
if (!directory.startsWith(root)) {
|
||||
throw new IllegalArgumentException("Generation boundary path escapes the dimension root");
|
||||
}
|
||||
}
|
||||
|
||||
public Path directory() {
|
||||
return directory;
|
||||
}
|
||||
|
||||
public Path snapshotPath(long activationId) {
|
||||
requireActivation(activationId);
|
||||
return directory.resolve(fileName(activationId));
|
||||
}
|
||||
|
||||
public synchronized GenerationBoundary publish(
|
||||
long activationId,
|
||||
Collection<GenerationBoundary.ChunkCoordinate> historicalChunks
|
||||
) throws IOException {
|
||||
Collection<GenerationBoundary.ChunkCoordinate> requiredChunks = Objects.requireNonNull(
|
||||
historicalChunks,
|
||||
"Historical chunks"
|
||||
);
|
||||
MaskCatalogBuilder builder = new MaskCatalogBuilder();
|
||||
for (GenerationBoundary.ChunkCoordinate chunk : requiredChunks) {
|
||||
GenerationBoundary.ChunkCoordinate requiredChunk = Objects.requireNonNull(
|
||||
chunk,
|
||||
"Historical chunk"
|
||||
);
|
||||
builder.add(requiredChunk.chunkX(), requiredChunk.chunkZ());
|
||||
}
|
||||
return publishCatalog(activationId, builder);
|
||||
}
|
||||
|
||||
public synchronized GenerationBoundary publishPacked(long activationId, long[] historicalChunks)
|
||||
throws IOException {
|
||||
long[] requiredChunks = Objects.requireNonNull(historicalChunks, "Historical chunks");
|
||||
MaskCatalogBuilder builder = new MaskCatalogBuilder();
|
||||
for (long chunk : requiredChunks) {
|
||||
builder.add((int) (chunk >> Integer.SIZE), (int) chunk);
|
||||
}
|
||||
return publishCatalog(activationId, builder);
|
||||
}
|
||||
|
||||
synchronized GenerationBoundary publishOwnership(
|
||||
long activationId,
|
||||
ChunkGenerationOwnership ownership
|
||||
) throws IOException {
|
||||
ChunkGenerationOwnership requiredOwnership = Objects.requireNonNull(ownership, "Generation ownership");
|
||||
MaskCatalogBuilder builder = new MaskCatalogBuilder();
|
||||
requiredOwnership.forEachAssignment((chunkX, chunkZ, owner) -> builder.add(chunkX, chunkZ));
|
||||
return publishCatalog(activationId, builder);
|
||||
}
|
||||
|
||||
public GenerationBoundary load(long activationId) throws IOException {
|
||||
requireActivation(activationId);
|
||||
validateStorageAncestors();
|
||||
Path snapshot = snapshotPath(activationId);
|
||||
requireRegularFile(snapshot, "Generation boundary snapshot");
|
||||
long fileSize = Files.size(snapshot);
|
||||
if (fileSize < CATALOG_FIXED_BODY_BYTES + CHECKSUM_BYTES) {
|
||||
throw invalid(snapshot, "truncated data");
|
||||
}
|
||||
if (fileSize > MAX_CATALOG_BYTES) {
|
||||
throw invalid(snapshot, "catalog exceeds the maximum size");
|
||||
}
|
||||
verifyChecksum(snapshot, fileSize, "generation boundary snapshot");
|
||||
|
||||
long bodyBytes = fileSize - CHECKSUM_BYTES;
|
||||
try (InputStream fileInput = Files.newInputStream(snapshot);
|
||||
LimitedInputStream limitedInput = new LimitedInputStream(fileInput, bodyBytes);
|
||||
DataInputStream input = new DataInputStream(limitedInput)) {
|
||||
int magic = input.readInt();
|
||||
if (magic != MAGIC) {
|
||||
throw invalid(snapshot, "invalid magic");
|
||||
}
|
||||
int version = input.readUnsignedShort();
|
||||
if (version != SCHEMA_VERSION) {
|
||||
throw invalid(snapshot, "unsupported schema version " + version);
|
||||
}
|
||||
int flags = input.readUnsignedShort();
|
||||
if (flags != 0) {
|
||||
throw invalid(snapshot, "unsupported format flags " + flags);
|
||||
}
|
||||
long storedActivation = input.readLong();
|
||||
if (storedActivation != activationId) {
|
||||
throw invalid(snapshot, "activation ID does not match the snapshot path");
|
||||
}
|
||||
int chunkCount = input.readInt();
|
||||
if (chunkCount < 0) {
|
||||
throw invalid(snapshot, "invalid chunk count " + chunkCount);
|
||||
}
|
||||
byte[] storedIdentity = input.readNBytes(IDENTITY_BYTES);
|
||||
if (storedIdentity.length != IDENTITY_BYTES) {
|
||||
throw invalid(snapshot, "truncated snapshot identity");
|
||||
}
|
||||
int regionCount = input.readInt();
|
||||
if (regionCount < 0 || regionCount > MAX_REGION_COUNT) {
|
||||
throw invalid(snapshot, "invalid region count " + regionCount);
|
||||
}
|
||||
long expectedSize = CATALOG_FIXED_BODY_BYTES
|
||||
+ (long) regionCount * CATALOG_ENTRY_BYTES
|
||||
+ CHECKSUM_BYTES;
|
||||
if (fileSize != expectedSize) {
|
||||
throw invalid(snapshot, "catalog length does not match the region count");
|
||||
}
|
||||
|
||||
long[] regionKeys = new long[regionCount];
|
||||
Long2ObjectOpenHashMap<String> shardHashes = new Long2ObjectOpenHashMap<>(regionCount);
|
||||
int countedChunks = 0;
|
||||
long previousRegionKey = 0L;
|
||||
for (int index = 0; index < regionCount; index++) {
|
||||
int regionX = input.readInt();
|
||||
int regionZ = input.readInt();
|
||||
long regionKey = packRegion(regionX, regionZ);
|
||||
if (index > 0 && compareRegions(previousRegionKey, regionKey) >= 0) {
|
||||
throw invalid(snapshot, "region entries are not in canonical order");
|
||||
}
|
||||
byte[] hashBytes = input.readNBytes(IDENTITY_BYTES);
|
||||
if (hashBytes.length != IDENTITY_BYTES) {
|
||||
throw invalid(snapshot, "truncated mask hash");
|
||||
}
|
||||
String hash = HexFormat.of().formatHex(hashBytes);
|
||||
byte[] mask = readMask(hash);
|
||||
countedChunks = Math.addExact(countedChunks, countBits(mask));
|
||||
regionKeys[index] = regionKey;
|
||||
shardHashes.put(regionKey, hash);
|
||||
previousRegionKey = regionKey;
|
||||
}
|
||||
if (limitedInput.remaining() != 0L) {
|
||||
throw invalid(snapshot, "unexpected trailing catalog data");
|
||||
}
|
||||
if (countedChunks != chunkCount) {
|
||||
throw invalid(snapshot, "chunk count does not match the referenced masks");
|
||||
}
|
||||
byte[] computedIdentity = computeIdentity(activationId, chunkCount, regionKeys, shardHashes);
|
||||
if (!MessageDigest.isEqual(storedIdentity, computedIdentity)) {
|
||||
throw invalid(snapshot, "boundary identity mismatch");
|
||||
}
|
||||
MaskSource source = new MaskSource(directory, regionKeys, shardHashes);
|
||||
return GenerationBoundary.backed(toHex(computedIdentity), chunkCount, source);
|
||||
} catch (ArithmeticException error) {
|
||||
throw invalid(snapshot, "chunk count overflows the supported range", error);
|
||||
} catch (EOFException error) {
|
||||
throw invalid(snapshot, "truncated data", error);
|
||||
}
|
||||
}
|
||||
|
||||
private GenerationBoundary publishCatalog(long activationId, MaskCatalogBuilder builder) throws IOException {
|
||||
requireActivation(activationId);
|
||||
ensureDirectory();
|
||||
try (GenerationPublicationLock ignored = GenerationPublicationLock.acquire(
|
||||
directory,
|
||||
".activation-" + activationId + SNAPSHOT_FILE_SUFFIX + ".lock"
|
||||
)) {
|
||||
ensureDirectory();
|
||||
PublishedCatalog catalog = builder.publish(directory, activationId);
|
||||
Path target = snapshotPath(activationId);
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
GenerationBoundary existing = load(activationId);
|
||||
if (!existing.identity().equals(catalog.identity())) {
|
||||
throw new FileAlreadyExistsException(
|
||||
"Generation boundary activation " + activationId
|
||||
+ " already has a different snapshot: " + target
|
||||
);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
publishAtomic(target, catalog.encoded(), "generation boundary catalog");
|
||||
return load(activationId);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] readMask(String hash) throws IOException {
|
||||
Path file = directory.resolve(maskFileName(hash));
|
||||
requireRegularFile(file, "Generation boundary mask");
|
||||
return MaskShard.read(file, hash);
|
||||
}
|
||||
|
||||
private void ensureDirectory() throws IOException {
|
||||
requireSafeDirectory(dimensionRoot);
|
||||
Path irisDirectory = ensureChildDirectory(dimensionRoot, "iris");
|
||||
Path generationDirectory = ensureChildDirectory(irisDirectory, "generation");
|
||||
Path boundaryDirectory = ensureChildDirectory(generationDirectory, "boundaries");
|
||||
if (!boundaryDirectory.equals(directory)) {
|
||||
throw new IOException("Generation boundary resolved outside its storage path: " + boundaryDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateStorageAncestors() throws IOException {
|
||||
requireSafeDirectory(dimensionRoot);
|
||||
requireSafeDirectory(dimensionRoot.resolve("iris"));
|
||||
requireSafeDirectory(dimensionRoot.resolve("iris").resolve("generation"));
|
||||
requireSafeDirectory(directory);
|
||||
}
|
||||
|
||||
private static Path ensureChildDirectory(Path parent, String name) throws IOException {
|
||||
Path child = parent.resolve(name);
|
||||
if (Files.exists(child, LinkOption.NOFOLLOW_LINKS)) {
|
||||
requireSafeDirectory(child);
|
||||
return child;
|
||||
}
|
||||
try {
|
||||
Files.createDirectory(child);
|
||||
} catch (FileAlreadyExistsException race) {
|
||||
requireSafeDirectory(child);
|
||||
return child;
|
||||
}
|
||||
forceDirectory(parent);
|
||||
return child;
|
||||
}
|
||||
|
||||
private static void requireSafeDirectory(Path path) throws IOException {
|
||||
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new NoSuchFileException(path.toString());
|
||||
}
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
path,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
|
||||
throw new IOException("Generation boundary path is not a safe directory: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireRegularFile(Path path, String label) throws IOException {
|
||||
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new NoSuchFileException(path.toString());
|
||||
}
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
path,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (!attributes.isRegularFile() || attributes.isSymbolicLink()) {
|
||||
throw new IOException(label + " is not a safe regular file: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] computeIdentity(
|
||||
long activationId,
|
||||
int chunkCount,
|
||||
long[] regionKeys,
|
||||
Long2ObjectOpenHashMap<String> shardHashes
|
||||
) throws IOException {
|
||||
MessageDigest digest = sha256();
|
||||
digest.update(IDENTITY_DOMAIN);
|
||||
try (DigestOutputStream digestOutput = new DigestOutputStream(OutputStream.nullOutputStream(), digest);
|
||||
DataOutputStream output = new DataOutputStream(digestOutput)) {
|
||||
output.writeLong(activationId);
|
||||
output.writeInt(chunkCount);
|
||||
output.writeInt(regionKeys.length);
|
||||
for (long regionKey : regionKeys) {
|
||||
output.writeInt(regionX(regionKey));
|
||||
output.writeInt(regionZ(regionKey));
|
||||
output.write(HexFormat.of().parseHex(shardHashes.get(regionKey)));
|
||||
}
|
||||
}
|
||||
return digest.digest();
|
||||
}
|
||||
|
||||
private static byte[] encodeCatalog(
|
||||
long activationId,
|
||||
int chunkCount,
|
||||
long[] regionKeys,
|
||||
Long2ObjectOpenHashMap<String> shardHashes,
|
||||
byte[] identity
|
||||
) throws IOException {
|
||||
ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(
|
||||
CATALOG_FIXED_BODY_BYTES + regionKeys.length * CATALOG_ENTRY_BYTES
|
||||
);
|
||||
try (DataOutputStream output = new DataOutputStream(bodyBytes)) {
|
||||
output.writeInt(MAGIC);
|
||||
output.writeShort(SCHEMA_VERSION);
|
||||
output.writeShort(0);
|
||||
output.writeLong(activationId);
|
||||
output.writeInt(chunkCount);
|
||||
output.write(identity);
|
||||
output.writeInt(regionKeys.length);
|
||||
for (long regionKey : regionKeys) {
|
||||
output.writeInt(regionX(regionKey));
|
||||
output.writeInt(regionZ(regionKey));
|
||||
output.write(HexFormat.of().parseHex(shardHashes.get(regionKey)));
|
||||
}
|
||||
}
|
||||
return withChecksum(bodyBytes.toByteArray());
|
||||
}
|
||||
|
||||
private static void publishAtomic(Path target, byte[] encoded, String kind) throws IOException {
|
||||
Path directory = target.getParent();
|
||||
Path temporary = Files.createTempFile(directory, ".boundary-", ".tmp");
|
||||
try {
|
||||
try (FileChannel channel = FileChannel.open(
|
||||
temporary,
|
||||
StandardOpenOption.WRITE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING
|
||||
)) {
|
||||
writeFully(channel, ByteBuffer.wrap(encoded));
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException error) {
|
||||
throw new IOException("Generation boundary " + kind + " publication requires an atomic move", error);
|
||||
}
|
||||
forceDirectory(directory);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] withChecksum(byte[] body) throws IOException {
|
||||
CRC32 checksum = new CRC32();
|
||||
checksum.update(body);
|
||||
ByteArrayOutputStream encoded = new ByteArrayOutputStream(body.length + CHECKSUM_BYTES);
|
||||
encoded.write(body);
|
||||
try (DataOutputStream output = new DataOutputStream(encoded)) {
|
||||
output.writeInt((int) checksum.getValue());
|
||||
}
|
||||
return encoded.toByteArray();
|
||||
}
|
||||
|
||||
private static void verifyChecksum(Path file, long fileSize, String kind) throws IOException {
|
||||
long bodyBytes = fileSize - CHECKSUM_BYTES;
|
||||
CRC32 checksum = new CRC32();
|
||||
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(TRANSFER_BYTES);
|
||||
long remaining = bodyBytes;
|
||||
while (remaining > 0L) {
|
||||
buffer.clear();
|
||||
buffer.limit((int) Math.min(buffer.capacity(), remaining));
|
||||
readFully(channel, buffer);
|
||||
buffer.flip();
|
||||
checksum.update(buffer);
|
||||
remaining -= buffer.limit();
|
||||
}
|
||||
ByteBuffer stored = ByteBuffer.allocate(CHECKSUM_BYTES);
|
||||
readFully(channel, stored);
|
||||
stored.flip();
|
||||
if (stored.getInt() != (int) checksum.getValue()) {
|
||||
throw new IOException("Invalid " + kind + " " + file + ": checksum mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int countBits(byte[] mask) {
|
||||
int count = 0;
|
||||
for (byte value : mask) {
|
||||
count += Integer.bitCount(value & 0xff);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static long packRegion(int regionX, int regionZ) {
|
||||
return ((long) regionX << Integer.SIZE) | (regionZ & 0xffffffffL);
|
||||
}
|
||||
|
||||
private static int regionX(long regionKey) {
|
||||
return (int) (regionKey >> Integer.SIZE);
|
||||
}
|
||||
|
||||
private static int regionZ(long regionKey) {
|
||||
return (int) regionKey;
|
||||
}
|
||||
|
||||
private static int compareRegions(long first, long second) {
|
||||
int xComparison = Integer.compare(regionX(first), regionX(second));
|
||||
return xComparison != 0 ? xComparison : Integer.compare(regionZ(first), regionZ(second));
|
||||
}
|
||||
|
||||
private static String fileName(long activationId) {
|
||||
return "activation-" + activationId + SNAPSHOT_FILE_SUFFIX;
|
||||
}
|
||||
|
||||
private static String maskFileName(String hash) {
|
||||
return "chunk-mask-" + hash + MASK_FILE_SUFFIX;
|
||||
}
|
||||
|
||||
private static void requireActivation(long activationId) {
|
||||
if (activationId <= 0L) {
|
||||
throw new IllegalArgumentException("Generation activation IDs must be positive: " + activationId);
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException error) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha256(byte[] encoded) {
|
||||
return HexFormat.of().formatHex(sha256().digest(encoded));
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException {
|
||||
while (buffer.hasRemaining()) {
|
||||
channel.write(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private static void readFully(FileChannel channel, ByteBuffer buffer) throws IOException {
|
||||
while (buffer.hasRemaining()) {
|
||||
if (channel.read(buffer) < 0) {
|
||||
throw new EOFException("Unexpected end of generation boundary data");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path path) throws IOException {
|
||||
if (File.separatorChar == '\\') {
|
||||
return;
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
} catch (UnsupportedOperationException error) {
|
||||
throw new IOException("Generation boundary directory cannot be durability-synced: " + path, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IOException invalid(Path path, String reason) {
|
||||
return new IOException("Invalid generation boundary snapshot " + path + ": " + reason);
|
||||
}
|
||||
|
||||
private static IOException invalid(Path path, String reason, Throwable cause) {
|
||||
return new IOException("Invalid generation boundary snapshot " + path + ": " + reason, cause);
|
||||
}
|
||||
|
||||
private static final class MaskCatalogBuilder {
|
||||
private final Long2ObjectOpenHashMap<byte[]> masks = new Long2ObjectOpenHashMap<>();
|
||||
private int chunkCount;
|
||||
|
||||
private void add(int chunkX, int chunkZ) {
|
||||
int regionX = chunkX >> 5;
|
||||
int regionZ = chunkZ >> 5;
|
||||
long regionKey = packRegion(regionX, regionZ);
|
||||
byte[] mask = masks.computeIfAbsent(regionKey, ignored -> new byte[MASK_BYTES]);
|
||||
int localIndex = (Math.floorMod(chunkZ, REGION_SIDE) << 5)
|
||||
| Math.floorMod(chunkX, REGION_SIDE);
|
||||
int byteIndex = localIndex >>> 3;
|
||||
int bit = 1 << (localIndex & 7);
|
||||
if ((mask[byteIndex] & bit) == 0) {
|
||||
mask[byteIndex] = (byte) (mask[byteIndex] | bit);
|
||||
chunkCount = Math.addExact(chunkCount, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private PublishedCatalog publish(Path directory, long activationId) throws IOException {
|
||||
if (masks.size() > MAX_REGION_COUNT) {
|
||||
throw new IOException("Generation boundary exceeds the maximum region count");
|
||||
}
|
||||
long[] regionKeys = masks.keySet().toLongArray();
|
||||
sortRegionKeys(regionKeys);
|
||||
Long2ObjectOpenHashMap<String> shardHashes = new Long2ObjectOpenHashMap<>(regionKeys.length);
|
||||
for (long regionKey : regionKeys) {
|
||||
String hash = MaskShard.publish(directory, masks.get(regionKey));
|
||||
shardHashes.put(regionKey, hash);
|
||||
}
|
||||
byte[] identity = computeIdentity(activationId, chunkCount, regionKeys, shardHashes);
|
||||
byte[] encoded = encodeCatalog(activationId, chunkCount, regionKeys, shardHashes, identity);
|
||||
return new PublishedCatalog(toHex(identity), encoded);
|
||||
}
|
||||
|
||||
private static void sortRegionKeys(long[] regionKeys) {
|
||||
LongArrays.quickSort(regionKeys, GenerationBoundaryStore::compareRegions);
|
||||
}
|
||||
}
|
||||
|
||||
private record PublishedCatalog(String identity, byte[] encoded) {
|
||||
}
|
||||
|
||||
private static final class MaskShard {
|
||||
private static String publish(Path directory, byte[] mask) throws IOException {
|
||||
byte[] encoded = encode(mask);
|
||||
String hash = sha256(encoded);
|
||||
Path target = directory.resolve(maskFileName(hash));
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
requireRegularFile(target, "Generation boundary mask");
|
||||
read(target, hash);
|
||||
return hash;
|
||||
}
|
||||
try {
|
||||
publishAtomic(target, encoded, "mask shard");
|
||||
} catch (FileAlreadyExistsException race) {
|
||||
requireRegularFile(target, "Generation boundary mask");
|
||||
read(target, hash);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static byte[] encode(byte[] mask) throws IOException {
|
||||
if (mask.length != MASK_BYTES || countBits(mask) == 0) {
|
||||
throw new IOException("Generation boundary mask is empty or malformed");
|
||||
}
|
||||
ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(MASK_BODY_BYTES);
|
||||
try (DataOutputStream output = new DataOutputStream(bodyBytes)) {
|
||||
output.writeInt(MASK_MAGIC);
|
||||
output.writeShort(MASK_SCHEMA_VERSION);
|
||||
output.writeShort(0);
|
||||
output.write(mask);
|
||||
}
|
||||
return withChecksum(bodyBytes.toByteArray());
|
||||
}
|
||||
|
||||
private static byte[] read(Path file, String expectedHash) throws IOException {
|
||||
byte[] encoded = Files.readAllBytes(file);
|
||||
if (encoded.length != MASK_BODY_BYTES + CHECKSUM_BYTES) {
|
||||
throw new IOException("Invalid generation boundary mask " + file + ": invalid length");
|
||||
}
|
||||
if (!sha256(encoded).equals(expectedHash)) {
|
||||
throw new IOException("Invalid generation boundary mask " + file + ": content hash mismatch");
|
||||
}
|
||||
CRC32 checksum = new CRC32();
|
||||
checksum.update(encoded, 0, MASK_BODY_BYTES);
|
||||
if (ByteBuffer.wrap(encoded, MASK_BODY_BYTES, CHECKSUM_BYTES).getInt()
|
||||
!= (int) checksum.getValue()) {
|
||||
throw new IOException("Invalid generation boundary mask " + file + ": checksum mismatch");
|
||||
}
|
||||
try (DataInputStream input = new DataInputStream(
|
||||
new ByteArrayInputStream(encoded, 0, MASK_BODY_BYTES)
|
||||
)) {
|
||||
if (input.readInt() != MASK_MAGIC) {
|
||||
throw new IOException("Invalid generation boundary mask " + file + ": invalid magic");
|
||||
}
|
||||
int version = input.readUnsignedShort();
|
||||
if (version != MASK_SCHEMA_VERSION) {
|
||||
throw new IOException("Invalid generation boundary mask " + file
|
||||
+ ": unsupported schema version " + version);
|
||||
}
|
||||
if (input.readUnsignedShort() != 0) {
|
||||
throw new IOException("Invalid generation boundary mask " + file + ": unsupported flags");
|
||||
}
|
||||
byte[] mask = input.readNBytes(MASK_BYTES);
|
||||
if (mask.length != MASK_BYTES || countBits(mask) == 0 || input.available() != 0) {
|
||||
throw new IOException("Invalid generation boundary mask " + file + ": malformed mask");
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MaskSource implements GenerationBoundary.HistoricalChunkSource {
|
||||
private final Path directory;
|
||||
private final long[] regionKeys;
|
||||
private final Long2ObjectOpenHashMap<String> shardHashes;
|
||||
private final LinkedHashMap<Long, byte[]> masks;
|
||||
|
||||
private MaskSource(
|
||||
Path directory,
|
||||
long[] regionKeys,
|
||||
Long2ObjectOpenHashMap<String> shardHashes
|
||||
) {
|
||||
this.directory = directory;
|
||||
this.regionKeys = regionKeys;
|
||||
this.shardHashes = shardHashes;
|
||||
masks = new LinkedHashMap<>(MAXIMUM_CACHED_MASKS, 0.75F, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean contains(int chunkX, int chunkZ) throws IOException {
|
||||
long regionKey = packRegion(chunkX >> 5, chunkZ >> 5);
|
||||
String hash = shardHashes.get(regionKey);
|
||||
if (hash == null) {
|
||||
return false;
|
||||
}
|
||||
byte[] mask = load(regionKey, hash);
|
||||
int localIndex = (Math.floorMod(chunkZ, REGION_SIDE) << 5)
|
||||
| Math.floorMod(chunkX, REGION_SIDE);
|
||||
return (mask[localIndex >>> 3] & (1 << (localIndex & 7))) != 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void forEach(GenerationBoundary.ChunkConsumer consumer) throws IOException {
|
||||
for (long regionKey : regionKeys) {
|
||||
byte[] mask = load(regionKey, shardHashes.get(regionKey));
|
||||
int minimumChunkX = regionX(regionKey) << 5;
|
||||
int minimumChunkZ = regionZ(regionKey) << 5;
|
||||
for (int localIndex = 0; localIndex < CHUNKS_PER_REGION; localIndex++) {
|
||||
if ((mask[localIndex >>> 3] & (1 << (localIndex & 7))) == 0) {
|
||||
continue;
|
||||
}
|
||||
consumer.accept(
|
||||
minimumChunkX + (localIndex & 31),
|
||||
minimumChunkZ + (localIndex >>> 5)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized int cachedRegionCount() {
|
||||
return masks.size();
|
||||
}
|
||||
|
||||
private byte[] load(long regionKey, String hash) throws IOException {
|
||||
byte[] cached = masks.get(regionKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Path file = directory.resolve(maskFileName(hash));
|
||||
requireRegularFile(file, "Generation boundary mask");
|
||||
byte[] loaded = MaskShard.read(file, hash);
|
||||
masks.put(regionKey, loaded);
|
||||
while (masks.size() > MAXIMUM_CACHED_MASKS) {
|
||||
Iterator<Map.Entry<Long, byte[]>> entries = masks.entrySet().iterator();
|
||||
entries.next();
|
||||
entries.remove();
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LimitedInputStream extends InputStream {
|
||||
private final InputStream input;
|
||||
private long remaining;
|
||||
|
||||
private LimitedInputStream(InputStream input, long remaining) {
|
||||
this.input = input;
|
||||
this.remaining = remaining;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
if (remaining == 0L) {
|
||||
return -1;
|
||||
}
|
||||
int value = input.read();
|
||||
if (value < 0) {
|
||||
throw new EOFException("Unexpected end of bounded generation boundary input");
|
||||
}
|
||||
remaining--;
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer, int offset, int length) throws IOException {
|
||||
Objects.checkFromIndexSize(offset, length, buffer.length);
|
||||
if (remaining == 0L) {
|
||||
return -1;
|
||||
}
|
||||
int read = input.read(buffer, offset, (int) Math.min(length, remaining));
|
||||
if (read < 0) {
|
||||
throw new EOFException("Unexpected end of bounded generation boundary input");
|
||||
}
|
||||
remaining -= read;
|
||||
return read;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
input.close();
|
||||
}
|
||||
|
||||
private long remaining() {
|
||||
return remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class GenerationEpoch {
|
||||
public static final int SEED_DERIVATION_VERSION_ONE = 1;
|
||||
public static final int CURRENT_SEED_DERIVATION_VERSION = SEED_DERIVATION_VERSION_ONE;
|
||||
|
||||
private static final int IDENTITY_SCHEMA = 1;
|
||||
private static final Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}");
|
||||
private static final Pattern DIMENSION_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*");
|
||||
private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
|
||||
private static final Pattern ENVIRONMENT_PATTERN = Pattern.compile("[A-Za-z0-9_.:-]+");
|
||||
|
||||
private final String epochId;
|
||||
private final Spec spec;
|
||||
|
||||
private GenerationEpoch(String epochId, Spec spec) {
|
||||
this.spec = Objects.requireNonNull(spec, "spec");
|
||||
this.epochId = requireSha256(epochId, "Epoch ID");
|
||||
String expectedEpochId = identity(spec);
|
||||
if (!this.epochId.equals(expectedEpochId)) {
|
||||
throw new IllegalArgumentException("Epoch ID does not match its generation inputs.");
|
||||
}
|
||||
}
|
||||
|
||||
public static GenerationEpoch create(Spec spec) {
|
||||
Spec requiredSpec = Objects.requireNonNull(spec, "spec");
|
||||
return new GenerationEpoch(identity(requiredSpec), requiredSpec);
|
||||
}
|
||||
|
||||
static GenerationEpoch restore(String epochId, Spec spec) {
|
||||
return new GenerationEpoch(epochId, spec);
|
||||
}
|
||||
|
||||
public String epochId() {
|
||||
return epochId;
|
||||
}
|
||||
|
||||
public String packFingerprint() {
|
||||
return spec.packFingerprint();
|
||||
}
|
||||
|
||||
public int generatorAbi() {
|
||||
return spec.generatorAbi();
|
||||
}
|
||||
|
||||
public int packFingerprintVersion() {
|
||||
return spec.packFingerprintVersion();
|
||||
}
|
||||
|
||||
public int rngVersion() {
|
||||
return spec.rngVersion();
|
||||
}
|
||||
|
||||
public String kernelImplementationFingerprint() {
|
||||
return spec.kernelImplementationFingerprint();
|
||||
}
|
||||
|
||||
public long worldSeed() {
|
||||
return spec.worldSeed();
|
||||
}
|
||||
|
||||
public int seedDerivationVersion() {
|
||||
return spec.seedDerivationVersion();
|
||||
}
|
||||
|
||||
public GenerationKernelRegistry.Version kernelVersion() {
|
||||
return new GenerationKernelRegistry.Version(
|
||||
generatorAbi(),
|
||||
rngVersion(),
|
||||
seedDerivationVersion()
|
||||
);
|
||||
}
|
||||
|
||||
public DimensionContract dimensionContract() {
|
||||
return spec.dimensionContract();
|
||||
}
|
||||
|
||||
public GenerationRegistryContract registryContract() {
|
||||
return spec.registryContract();
|
||||
}
|
||||
|
||||
public Spec spec() {
|
||||
return spec;
|
||||
}
|
||||
|
||||
JsonObject toJson() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("epochId", epochId);
|
||||
json.addProperty("packFingerprint", packFingerprint());
|
||||
json.addProperty("packFingerprintVersion", packFingerprintVersion());
|
||||
json.addProperty("worldSeed", worldSeed());
|
||||
json.addProperty("seedDerivationVersion", seedDerivationVersion());
|
||||
json.addProperty("generatorAbi", generatorAbi());
|
||||
json.addProperty("rngVersion", rngVersion());
|
||||
json.addProperty("kernelImplementationFingerprint", kernelImplementationFingerprint());
|
||||
json.add("dimensionContract", dimensionContract().toJson());
|
||||
json.add("registryContract", registryContract().toJson());
|
||||
return json;
|
||||
}
|
||||
|
||||
static GenerationEpoch fromJson(JsonObject json) {
|
||||
GenerationManifest.JsonSchema.requireFields(
|
||||
json,
|
||||
"epoch",
|
||||
"epochId",
|
||||
"packFingerprint",
|
||||
"packFingerprintVersion",
|
||||
"worldSeed",
|
||||
"seedDerivationVersion",
|
||||
"generatorAbi",
|
||||
"rngVersion",
|
||||
"kernelImplementationFingerprint",
|
||||
"dimensionContract",
|
||||
"registryContract"
|
||||
);
|
||||
String epochId = GenerationManifest.JsonSchema.requireString(json, "epochId", "epoch");
|
||||
String packFingerprint = GenerationManifest.JsonSchema.requireString(json, "packFingerprint", "epoch");
|
||||
int packFingerprintVersion = GenerationManifest.JsonSchema.requireInt(
|
||||
json,
|
||||
"packFingerprintVersion",
|
||||
"epoch"
|
||||
);
|
||||
long worldSeed = GenerationManifest.JsonSchema.requireLong(json, "worldSeed", "epoch");
|
||||
int seedDerivationVersion = GenerationManifest.JsonSchema.requireInt(
|
||||
json,
|
||||
"seedDerivationVersion",
|
||||
"epoch"
|
||||
);
|
||||
int generatorAbi = GenerationManifest.JsonSchema.requireInt(json, "generatorAbi", "epoch");
|
||||
int rngVersion = GenerationManifest.JsonSchema.requireInt(json, "rngVersion", "epoch");
|
||||
String kernelImplementationFingerprint = GenerationManifest.JsonSchema.requireString(
|
||||
json,
|
||||
"kernelImplementationFingerprint",
|
||||
"epoch"
|
||||
);
|
||||
JsonObject contractJson = GenerationManifest.JsonSchema.requireObject(json, "dimensionContract", "epoch");
|
||||
DimensionContract contract = DimensionContract.fromJson(contractJson);
|
||||
JsonObject registryContractJson = GenerationManifest.JsonSchema.requireObject(
|
||||
json,
|
||||
"registryContract",
|
||||
"epoch"
|
||||
);
|
||||
GenerationRegistryContract registryContract = GenerationRegistryContract.fromJson(registryContractJson);
|
||||
return restore(epochId, new Spec(
|
||||
packFingerprint,
|
||||
packFingerprintVersion,
|
||||
worldSeed,
|
||||
seedDerivationVersion,
|
||||
generatorAbi,
|
||||
rngVersion,
|
||||
kernelImplementationFingerprint,
|
||||
contract,
|
||||
registryContract
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof GenerationEpoch epoch)) {
|
||||
return false;
|
||||
}
|
||||
return epochId.equals(epoch.epochId) && spec.equals(epoch.spec);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(epochId, spec);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "GenerationEpoch[epochId=" + epochId + ", spec=" + spec + "]";
|
||||
}
|
||||
|
||||
private static String identity(Spec spec) {
|
||||
MessageDigest digest = sha256();
|
||||
updateString(digest, "iris-generation-epoch");
|
||||
updateInt(digest, IDENTITY_SCHEMA);
|
||||
updateString(digest, spec.packFingerprint());
|
||||
updateInt(digest, spec.packFingerprintVersion());
|
||||
updateLong(digest, spec.worldSeed());
|
||||
updateInt(digest, spec.seedDerivationVersion());
|
||||
updateInt(digest, spec.generatorAbi());
|
||||
updateInt(digest, spec.rngVersion());
|
||||
updateString(digest, spec.kernelImplementationFingerprint());
|
||||
DimensionContract contract = spec.dimensionContract();
|
||||
updateString(digest, contract.dimensionKey());
|
||||
updateString(digest, contract.dimensionTypeKey());
|
||||
updateString(digest, contract.environment());
|
||||
updateString(digest, contract.generationMode());
|
||||
updateInt(digest, contract.internalFluidHeight());
|
||||
updateInt(digest, contract.minHeight());
|
||||
updateInt(digest, contract.height());
|
||||
updateInt(digest, contract.logicalHeight());
|
||||
updateLong(digest, Double.doubleToLongBits(contract.coordinateScale()));
|
||||
digest.update((byte) (contract.upperTerrainEnabled() ? 1 : 0));
|
||||
updateString(digest, contract.upperDimensionKey());
|
||||
updateInt(digest, contract.upperDimensionGap());
|
||||
updateString(digest, contract.upperTerrainPackFingerprint());
|
||||
updateInt(digest, contract.dimensionTypeFingerprintSchema());
|
||||
updateString(digest, contract.dimensionTypeFingerprint());
|
||||
GenerationRegistryContract registryContract = spec.registryContract();
|
||||
updateInt(digest, registryContract.fingerprintSchema());
|
||||
updateString(digest, registryContract.fingerprint());
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static String requireSha256(String value, String label) {
|
||||
String requiredValue = Objects.requireNonNull(value, label);
|
||||
if (!SHA_256_PATTERN.matcher(requiredValue).matches()) {
|
||||
throw new IllegalArgumentException(label + " must be a lowercase SHA-256 value.");
|
||||
}
|
||||
return requiredValue;
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateString(MessageDigest digest, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
updateInt(digest, bytes.length);
|
||||
digest.update(bytes);
|
||||
}
|
||||
|
||||
private static void updateInt(MessageDigest digest, int value) {
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array());
|
||||
}
|
||||
|
||||
private static void updateLong(MessageDigest digest, long value) {
|
||||
digest.update(ByteBuffer.allocate(Long.BYTES).putLong(value).array());
|
||||
}
|
||||
|
||||
public record Spec(
|
||||
String packFingerprint,
|
||||
int packFingerprintVersion,
|
||||
long worldSeed,
|
||||
int seedDerivationVersion,
|
||||
int generatorAbi,
|
||||
int rngVersion,
|
||||
String kernelImplementationFingerprint,
|
||||
DimensionContract dimensionContract,
|
||||
GenerationRegistryContract registryContract
|
||||
) {
|
||||
public Spec {
|
||||
packFingerprint = requireSha256(packFingerprint, "Pack fingerprint");
|
||||
if (packFingerprintVersion < 1) {
|
||||
throw new IllegalArgumentException("Pack fingerprint version must be positive.");
|
||||
}
|
||||
if (seedDerivationVersion < 1) {
|
||||
throw new IllegalArgumentException("Seed derivation version must be positive.");
|
||||
}
|
||||
if (generatorAbi < 1) {
|
||||
throw new IllegalArgumentException("Generator ABI must be positive.");
|
||||
}
|
||||
if (rngVersion < 1) {
|
||||
throw new IllegalArgumentException("RNG version must be positive.");
|
||||
}
|
||||
kernelImplementationFingerprint = requireSha256(
|
||||
kernelImplementationFingerprint,
|
||||
"Generation kernel implementation fingerprint"
|
||||
);
|
||||
dimensionContract = Objects.requireNonNull(dimensionContract, "dimensionContract");
|
||||
registryContract = Objects.requireNonNull(registryContract, "registryContract");
|
||||
}
|
||||
}
|
||||
|
||||
public record DimensionContract(
|
||||
String dimensionKey,
|
||||
String dimensionTypeKey,
|
||||
String environment,
|
||||
String generationMode,
|
||||
int internalFluidHeight,
|
||||
int minHeight,
|
||||
int height,
|
||||
int logicalHeight,
|
||||
double coordinateScale,
|
||||
boolean upperTerrainEnabled,
|
||||
String upperDimensionKey,
|
||||
int upperDimensionGap,
|
||||
String upperTerrainPackFingerprint,
|
||||
int dimensionTypeFingerprintSchema,
|
||||
String dimensionTypeFingerprint
|
||||
) {
|
||||
public DimensionContract {
|
||||
dimensionKey = requirePattern(dimensionKey, "Dimension key", DIMENSION_KEY_PATTERN);
|
||||
dimensionTypeKey = requirePattern(dimensionTypeKey, "Dimension type key", RESOURCE_KEY_PATTERN);
|
||||
environment = requirePattern(environment, "Dimension environment", ENVIRONMENT_PATTERN);
|
||||
generationMode = requirePattern(generationMode, "Generation mode", ENVIRONMENT_PATTERN);
|
||||
if (height < 1) {
|
||||
throw new IllegalArgumentException("Dimension height must be positive.");
|
||||
}
|
||||
Math.addExact(minHeight, height);
|
||||
if (logicalHeight < 0 || logicalHeight > height) {
|
||||
throw new IllegalArgumentException("Logical height must be inside the dimension height.");
|
||||
}
|
||||
if (!Double.isFinite(coordinateScale) || coordinateScale <= 0D || coordinateScale > 30000000D) {
|
||||
throw new IllegalArgumentException("Coordinate scale must be finite and inside (0, 30000000].");
|
||||
}
|
||||
upperDimensionKey = requirePattern(
|
||||
upperDimensionKey,
|
||||
"Upper dimension key",
|
||||
DIMENSION_KEY_PATTERN
|
||||
);
|
||||
if (upperDimensionGap < 0) {
|
||||
throw new IllegalArgumentException("Upper dimension gap must not be negative.");
|
||||
}
|
||||
upperTerrainPackFingerprint = requireSha256(
|
||||
upperTerrainPackFingerprint,
|
||||
"Upper terrain pack fingerprint"
|
||||
);
|
||||
if (upperTerrainEnabled == upperDimensionKey.equals("none")) {
|
||||
throw new IllegalArgumentException("Upper terrain enablement and dimension key disagree.");
|
||||
}
|
||||
if (upperTerrainEnabled == upperTerrainPackFingerprint.equals("0".repeat(64))) {
|
||||
throw new IllegalArgumentException("Upper terrain enablement and pack fingerprint disagree.");
|
||||
}
|
||||
if (!upperTerrainEnabled && upperDimensionGap != 0) {
|
||||
throw new IllegalArgumentException("Disabled upper terrain must have a zero gap.");
|
||||
}
|
||||
GenerationEpochContractFactory.requireSupportedDimensionTypeFingerprintSchema(
|
||||
dimensionTypeFingerprintSchema
|
||||
);
|
||||
dimensionTypeFingerprint = requireSha256(dimensionTypeFingerprint, "Dimension type fingerprint");
|
||||
}
|
||||
|
||||
public boolean hasSameLayout(DimensionContract other) {
|
||||
Objects.requireNonNull(other, "Dimension contract");
|
||||
return dimensionKey.equals(other.dimensionKey)
|
||||
&& dimensionTypeKey.equals(other.dimensionTypeKey)
|
||||
&& environment.equals(other.environment)
|
||||
&& generationMode.equals(other.generationMode)
|
||||
&& internalFluidHeight == other.internalFluidHeight
|
||||
&& minHeight == other.minHeight
|
||||
&& height == other.height
|
||||
&& logicalHeight == other.logicalHeight
|
||||
&& Double.compare(coordinateScale, other.coordinateScale) == 0
|
||||
&& upperTerrainEnabled == other.upperTerrainEnabled
|
||||
&& upperDimensionKey.equals(other.upperDimensionKey)
|
||||
&& upperDimensionGap == other.upperDimensionGap
|
||||
&& dimensionTypeFingerprintSchema == other.dimensionTypeFingerprintSchema
|
||||
&& dimensionTypeFingerprint.equals(other.dimensionTypeFingerprint);
|
||||
}
|
||||
|
||||
JsonObject toJson() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("dimensionKey", dimensionKey);
|
||||
json.addProperty("dimensionTypeKey", dimensionTypeKey);
|
||||
json.addProperty("environment", environment);
|
||||
json.addProperty("generationMode", generationMode);
|
||||
json.addProperty("internalFluidHeight", internalFluidHeight);
|
||||
json.addProperty("minHeight", minHeight);
|
||||
json.addProperty("height", height);
|
||||
json.addProperty("logicalHeight", logicalHeight);
|
||||
json.addProperty("coordinateScale", coordinateScale);
|
||||
json.addProperty("upperTerrainEnabled", upperTerrainEnabled);
|
||||
json.addProperty("upperDimensionKey", upperDimensionKey);
|
||||
json.addProperty("upperDimensionGap", upperDimensionGap);
|
||||
json.addProperty("upperTerrainPackFingerprint", upperTerrainPackFingerprint);
|
||||
json.addProperty("dimensionTypeFingerprintSchema", dimensionTypeFingerprintSchema);
|
||||
json.addProperty("dimensionTypeFingerprint", dimensionTypeFingerprint);
|
||||
return json;
|
||||
}
|
||||
|
||||
static DimensionContract fromJson(JsonObject json) {
|
||||
GenerationManifest.JsonSchema.requireFields(
|
||||
json,
|
||||
"dimension contract",
|
||||
"dimensionKey",
|
||||
"dimensionTypeKey",
|
||||
"environment",
|
||||
"generationMode",
|
||||
"internalFluidHeight",
|
||||
"minHeight",
|
||||
"height",
|
||||
"logicalHeight",
|
||||
"coordinateScale",
|
||||
"upperTerrainEnabled",
|
||||
"upperDimensionKey",
|
||||
"upperDimensionGap",
|
||||
"upperTerrainPackFingerprint",
|
||||
"dimensionTypeFingerprintSchema",
|
||||
"dimensionTypeFingerprint"
|
||||
);
|
||||
return new DimensionContract(
|
||||
GenerationManifest.JsonSchema.requireString(json, "dimensionKey", "dimension contract"),
|
||||
GenerationManifest.JsonSchema.requireString(json, "dimensionTypeKey", "dimension contract"),
|
||||
GenerationManifest.JsonSchema.requireString(json, "environment", "dimension contract"),
|
||||
GenerationManifest.JsonSchema.requireString(json, "generationMode", "dimension contract"),
|
||||
GenerationManifest.JsonSchema.requireInt(json, "internalFluidHeight", "dimension contract"),
|
||||
GenerationManifest.JsonSchema.requireInt(json, "minHeight", "dimension contract"),
|
||||
GenerationManifest.JsonSchema.requireInt(json, "height", "dimension contract"),
|
||||
GenerationManifest.JsonSchema.requireInt(json, "logicalHeight", "dimension contract"),
|
||||
GenerationManifest.JsonSchema.requireDouble(json, "coordinateScale", "dimension contract"),
|
||||
GenerationManifest.JsonSchema.requireBoolean(
|
||||
json,
|
||||
"upperTerrainEnabled",
|
||||
"dimension contract"
|
||||
),
|
||||
GenerationManifest.JsonSchema.requireString(json, "upperDimensionKey", "dimension contract"),
|
||||
GenerationManifest.JsonSchema.requireInt(json, "upperDimensionGap", "dimension contract"),
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
json,
|
||||
"upperTerrainPackFingerprint",
|
||||
"dimension contract"
|
||||
),
|
||||
GenerationManifest.JsonSchema.requireInt(
|
||||
json,
|
||||
"dimensionTypeFingerprintSchema",
|
||||
"dimension contract"
|
||||
),
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
json,
|
||||
"dimensionTypeFingerprint",
|
||||
"dimension contract"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public int maxHeight() {
|
||||
return Math.addExact(minHeight, height);
|
||||
}
|
||||
|
||||
private static String requirePattern(String value, String label, Pattern pattern) {
|
||||
String requiredValue = Objects.requireNonNull(value, label);
|
||||
if (!pattern.matcher(requiredValue).matches()) {
|
||||
throw new IllegalArgumentException(label + " is invalid: " + requiredValue);
|
||||
}
|
||||
return requiredValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionMode;
|
||||
import art.arcane.iris.engine.object.IrisDimensionModeType;
|
||||
import art.arcane.iris.engine.object.IrisDimensionType;
|
||||
import art.arcane.iris.engine.object.IrisDimensionTypeOptions;
|
||||
import art.arcane.iris.engine.object.IrisEnvironment;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class GenerationEpochContractFactory {
|
||||
public static final int DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE = 1;
|
||||
public static final int CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA =
|
||||
DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE;
|
||||
|
||||
private static final String FINGERPRINT_DOMAIN = "iris-dimension-type-authored-semantics";
|
||||
private static final String NO_UPPER_TERRAIN_FINGERPRINT = "0".repeat(64);
|
||||
private static final Gson SEMANTIC_JSON = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
private GenerationEpochContractFactory() {
|
||||
}
|
||||
|
||||
public static GenerationEpoch.DimensionContract create(
|
||||
IrisDimension dimension,
|
||||
String dimensionKey,
|
||||
String dimensionTypeKey
|
||||
) {
|
||||
return create(
|
||||
dimension,
|
||||
dimensionKey,
|
||||
dimensionTypeKey,
|
||||
CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA
|
||||
);
|
||||
}
|
||||
|
||||
public static GenerationEpoch.DimensionContract create(
|
||||
IrisDimension dimension,
|
||||
String dimensionKey,
|
||||
String dimensionTypeKey,
|
||||
int dimensionTypeFingerprintSchema
|
||||
) {
|
||||
requireSupportedDimensionTypeFingerprintSchema(dimensionTypeFingerprintSchema);
|
||||
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
||||
IrisEnvironment environment = Objects.requireNonNull(
|
||||
requiredDimension.getEnvironment(),
|
||||
"Dimension environment"
|
||||
);
|
||||
IrisDimensionType dimensionType = requiredDimension.getDimensionType();
|
||||
requireDimensionAgreement(requiredDimension, dimensionType);
|
||||
IrisDimensionMode configuredMode = requiredDimension.getMode();
|
||||
IrisDimensionModeType mode = configuredMode == null || configuredMode.getType() == null
|
||||
? IrisDimensionModeType.OVERWORLD
|
||||
: configuredMode.getType();
|
||||
boolean upperTerrainEnabled = requiredDimension.hasUpperDimension();
|
||||
String upperDimensionKey = upperTerrainEnabled
|
||||
? requireUpperDimensionKey(requiredDimension)
|
||||
: "none";
|
||||
|
||||
return new GenerationEpoch.DimensionContract(
|
||||
dimensionKey,
|
||||
dimensionTypeKey,
|
||||
environment.name(),
|
||||
mode.name(),
|
||||
requiredDimension.getFluidHeight(),
|
||||
dimensionType.minY(),
|
||||
dimensionType.height(),
|
||||
dimensionType.logicalHeight(),
|
||||
effectiveCoordinateScale(dimensionType),
|
||||
upperTerrainEnabled,
|
||||
upperDimensionKey,
|
||||
upperTerrainEnabled ? requiredDimension.getUpperDimensionGap() : 0,
|
||||
upperTerrainEnabled
|
||||
? requireUpperTerrainPackFingerprint(requiredDimension, upperDimensionKey)
|
||||
: NO_UPPER_TERRAIN_FINGERPRINT,
|
||||
dimensionTypeFingerprintSchema,
|
||||
fingerprintDimensionType(dimensionType, dimensionTypeFingerprintSchema)
|
||||
);
|
||||
}
|
||||
|
||||
public static String fingerprintDimensionType(IrisDimensionType dimensionType) {
|
||||
return fingerprintDimensionType(
|
||||
dimensionType,
|
||||
CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA
|
||||
);
|
||||
}
|
||||
|
||||
public static String dimensionTypeSemanticJson(IrisDimensionType dimensionType) {
|
||||
IrisDimensionType requiredType = Objects.requireNonNull(dimensionType, "dimensionType");
|
||||
JsonObject semantic = new JsonObject();
|
||||
semantic.addProperty("base", requiredType.base().name());
|
||||
semantic.addProperty("logicalHeight", requiredType.logicalHeight());
|
||||
semantic.addProperty("height", requiredType.height());
|
||||
semantic.addProperty("minY", requiredType.minY());
|
||||
semantic.add("options", SEMANTIC_JSON.toJsonTree(requiredType.options()));
|
||||
return semantic.toString();
|
||||
}
|
||||
|
||||
public static String fingerprintDimensionType(
|
||||
IrisDimensionType dimensionType,
|
||||
int fingerprintSchema
|
||||
) {
|
||||
requireSupportedDimensionTypeFingerprintSchema(fingerprintSchema);
|
||||
IrisDimensionType requiredType = Objects.requireNonNull(dimensionType, "dimensionType");
|
||||
MessageDigest digest = sha256();
|
||||
updateString(digest, FINGERPRINT_DOMAIN);
|
||||
updateInt(digest, fingerprintSchema);
|
||||
updateString(digest, dimensionTypeSemanticJson(requiredType));
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
public static void requireSupportedDimensionTypeFingerprintSchema(int fingerprintSchema) {
|
||||
if (fingerprintSchema != DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE) {
|
||||
throw new IllegalArgumentException(
|
||||
"Unsupported dimension-type fingerprint schema " + fingerprintSchema + "."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static double effectiveCoordinateScale(IrisDimensionType dimensionType) {
|
||||
IrisDimensionTypeOptions options = dimensionType.options();
|
||||
double configured = options.coordinateScale();
|
||||
if (configured != -1D) {
|
||||
return configured;
|
||||
}
|
||||
return dimensionType.base() == IDataFixer.Dimension.NETHER ? 8D : 1D;
|
||||
}
|
||||
|
||||
private static String requireUpperDimensionKey(IrisDimension dimension) {
|
||||
String key = Objects.requireNonNull(
|
||||
dimension.getUpperDimension(),
|
||||
"Upper dimension key"
|
||||
).trim().toLowerCase(Locale.ROOT);
|
||||
if (key.isEmpty() || key.equals("none")) {
|
||||
throw new IllegalStateException("Enabled upper terrain requires a dimension key.");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
private static String requireUpperTerrainPackFingerprint(
|
||||
IrisDimension dimension,
|
||||
String upperDimensionKey
|
||||
) {
|
||||
IrisData data = Objects.requireNonNull(
|
||||
dimension.getLoader(),
|
||||
"Upper terrain dimension loader"
|
||||
);
|
||||
if (!upperDimensionKey.equals(dimension.getLoadKey())) {
|
||||
IrisDimension upperDimension = data.getDimensionLoader().load(upperDimensionKey, false);
|
||||
if (upperDimension == null || upperDimension.getLoader() != data) {
|
||||
throw new IllegalStateException("Upper dimension '" + upperDimensionKey
|
||||
+ "' must exist in the same immutable Iris pack.");
|
||||
}
|
||||
}
|
||||
try {
|
||||
return GenerationPackFingerprint.compute(
|
||||
data.getDataFolder().toPath(),
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Unable to fingerprint the immutable upper terrain pack.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireDimensionAgreement(
|
||||
IrisDimension dimension,
|
||||
IrisDimensionType dimensionType
|
||||
) {
|
||||
int declaredMinHeight = dimension.getMinHeight();
|
||||
int declaredHeight = Math.subtractExact(dimension.getMaxHeight(), declaredMinHeight);
|
||||
int declaredLogicalHeight = dimension.getLogicalHeight();
|
||||
if (dimensionType.minY() == declaredMinHeight
|
||||
&& dimensionType.height() == declaredHeight
|
||||
&& dimensionType.logicalHeight() == declaredLogicalHeight) {
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Iris dimension-type semantics disagree with the dimension contract."
|
||||
);
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateString(MessageDigest digest, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
updateInt(digest, bytes.length);
|
||||
digest.update(bytes);
|
||||
}
|
||||
|
||||
private static void updateInt(MessageDigest digest, int value) {
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.IrisRegistrant;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisObjectPlacement;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
|
||||
public final class GenerationFindCatalog {
|
||||
private static final Map<GenerationHistory, RetainedCatalog> RETAINED = new WeakHashMap<>();
|
||||
private static final RetainedCatalog EMPTY = new RetainedCatalog(0L);
|
||||
|
||||
private GenerationFindCatalog() {
|
||||
}
|
||||
|
||||
public static KList<IrisBiome> biomes(Engine engine) {
|
||||
return merge(engine.getAllBiomes(), retained(engine).biomes);
|
||||
}
|
||||
|
||||
public static IrisBiome biome(Engine engine, String key) {
|
||||
return find(biomes(engine), key);
|
||||
}
|
||||
|
||||
public static KList<IrisRegion> regions(Engine engine) {
|
||||
return merge(engine.getDimension().getAllRegions(engine), retained(engine).regions);
|
||||
}
|
||||
|
||||
public static IrisRegion region(Engine engine, String key) {
|
||||
return find(regions(engine), key);
|
||||
}
|
||||
|
||||
public static KList<String> objectKeys(Engine engine) {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
for (String key : engine.getData().getObjectLoader().getPossibleKeys()) {
|
||||
keys.add(key);
|
||||
}
|
||||
keys.addAll(retained(engine).objects);
|
||||
return new KList<>(keys);
|
||||
}
|
||||
|
||||
public static boolean hasObjectPlacement(Engine engine, String key) {
|
||||
return engine.hasObjectPlacement(key) || retained(engine).objects.contains(normalizeObjectKey(key));
|
||||
}
|
||||
|
||||
public static KList<String> retainedStructureKeys(Engine engine) {
|
||||
return new KList<>(retained(engine).structures);
|
||||
}
|
||||
|
||||
public static boolean hasRetainedStructurePlacement(Engine engine, String key) {
|
||||
return retained(engine).structures.contains(normalizeKey(key));
|
||||
}
|
||||
|
||||
private static RetainedCatalog retained(Engine engine) {
|
||||
if (!(engine instanceof IrisEngine irisEngine)) {
|
||||
return EMPTY;
|
||||
}
|
||||
return irisEngine.getGenerationHistoryRuntimeRouter()
|
||||
.map(router -> retained(router.history()))
|
||||
.orElse(EMPTY);
|
||||
}
|
||||
|
||||
private static RetainedCatalog retained(GenerationHistory history) {
|
||||
GenerationManifest manifest = history.manifest();
|
||||
synchronized (RETAINED) {
|
||||
RetainedCatalog catalog = RETAINED.get(history);
|
||||
if (catalog != null && catalog.activeActivationId == manifest.activeActivation().activationId()) {
|
||||
return catalog;
|
||||
}
|
||||
catalog = loadRetained(history, manifest);
|
||||
RETAINED.put(history, catalog);
|
||||
return catalog;
|
||||
}
|
||||
}
|
||||
|
||||
private static RetainedCatalog loadRetained(GenerationHistory history, GenerationManifest manifest) {
|
||||
RetainedCatalog catalog = new RetainedCatalog(manifest.activeActivation().activationId());
|
||||
Set<String> loadedPacks = new HashSet<>();
|
||||
Long activationId = manifest.activeActivation().parentActivationId();
|
||||
while (activationId != null) {
|
||||
GenerationActivation activation = manifest.activation(activationId).orElseThrow();
|
||||
GenerationEpoch epoch = manifest.epoch(activation.epochId()).orElseThrow();
|
||||
String dimensionKey = epoch.dimensionContract().dimensionKey();
|
||||
if (loadedPacks.add(epoch.packFingerprint() + ":" + dimensionKey)) {
|
||||
try {
|
||||
loadPack(catalog, history.packRoot(activationId), dimensionKey);
|
||||
} catch (IOException error) {
|
||||
throw new UncheckedIOException("Unable to read retained Iris find catalog for activation "
|
||||
+ activationId, error);
|
||||
}
|
||||
}
|
||||
activationId = activation.parentActivationId();
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private static void loadPack(RetainedCatalog catalog, Path packRoot, String dimensionKey) {
|
||||
IrisData data = IrisData.openDatapackCompiler(packRoot.toFile());
|
||||
try {
|
||||
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false);
|
||||
if (dimension == null) {
|
||||
throw new IllegalStateException("Retained Iris dimension is absent: " + dimensionKey);
|
||||
}
|
||||
addStructures(catalog.structures, dimension.getStructures());
|
||||
for (IrisBiome biome : dimension.getReachableBiomes(() -> data)) {
|
||||
catalog.biomes.putIfAbsent(normalizeKey(biome.getLoadKey()), biome);
|
||||
addObjects(catalog.objects, biome.getObjects());
|
||||
addStructures(catalog.structures, biome.getStructures());
|
||||
}
|
||||
for (IrisRegion region : dimension.getAllRegions(() -> data)) {
|
||||
catalog.regions.putIfAbsent(normalizeKey(region.getLoadKey()), region);
|
||||
addObjects(catalog.objects, region.getObjects());
|
||||
addStructures(catalog.structures, region.getStructures());
|
||||
}
|
||||
if (dimension.hasUpperDimension() && !dimensionKey.equals(dimension.getUpperDimension())) {
|
||||
IrisDimension upper = data.getDimensionLoader().load(dimension.getUpperDimension(), false);
|
||||
if (upper == null) {
|
||||
throw new IllegalStateException("Retained Iris upper dimension is absent: "
|
||||
+ dimension.getUpperDimension());
|
||||
}
|
||||
for (IrisBiome biome : upper.getReachableBiomes(() -> data)) {
|
||||
addObjects(catalog.objects, biome.getSurfaceObjects());
|
||||
}
|
||||
for (IrisRegion region : upper.getAllRegions(() -> data)) {
|
||||
addObjects(catalog.objects, region.getSurfaceObjects());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static void addStructures(Set<String> structures, Iterable<IrisStructurePlacement> placements) {
|
||||
for (IrisStructurePlacement placement : placements) {
|
||||
if (placement == null) {
|
||||
continue;
|
||||
}
|
||||
for (String key : placement.getStructures()) {
|
||||
String normalized = normalizeKey(key);
|
||||
if (!normalized.isEmpty()) {
|
||||
structures.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addObjects(Set<String> objects, Iterable<IrisObjectPlacement> placements) {
|
||||
for (IrisObjectPlacement placement : placements) {
|
||||
if (placement == null || placement.getPlace() == null) {
|
||||
continue;
|
||||
}
|
||||
for (String key : placement.getPlace()) {
|
||||
String normalized = normalizeObjectKey(key);
|
||||
if (!normalized.isEmpty()) {
|
||||
objects.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static <T extends IrisRegistrant> KList<T> merge(Iterable<T> active, Map<String, T> historical) {
|
||||
Map<String, T> merged = new LinkedHashMap<>();
|
||||
for (T registrant : active) {
|
||||
merged.putIfAbsent(normalizeKey(registrant.getLoadKey()), registrant);
|
||||
}
|
||||
historical.forEach(merged::putIfAbsent);
|
||||
return new KList<>(merged.values());
|
||||
}
|
||||
|
||||
private static <T extends IrisRegistrant> T find(Iterable<T> entries, String key) {
|
||||
String normalized = normalizeKey(key);
|
||||
for (T entry : entries) {
|
||||
if (normalizeKey(entry.getLoadKey()).equals(normalized)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String normalizeKey(String key) {
|
||||
return key == null ? "" : key.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String normalizeObjectKey(String key) {
|
||||
String normalized = normalizeKey(key).replace('\\', '/');
|
||||
while (normalized.startsWith("/")) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
while (normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
return normalized.endsWith(".iob") ? normalized.substring(0, normalized.length() - 4) : normalized;
|
||||
}
|
||||
|
||||
private static final class RetainedCatalog {
|
||||
private final long activeActivationId;
|
||||
private final Map<String, IrisBiome> biomes = new LinkedHashMap<>();
|
||||
private final Map<String, IrisRegion> regions = new LinkedHashMap<>();
|
||||
private final Set<String> objects = new LinkedHashSet<>();
|
||||
private final Set<String> structures = new LinkedHashSet<>();
|
||||
|
||||
private RetainedCatalog(long activeActivationId) {
|
||||
this.activeActivationId = activeActivationId;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,111 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class GenerationHistoryPaths {
|
||||
public static final String IRIS_DIRECTORY_NAME = "iris";
|
||||
public static final String GENERATION_DIRECTORY_NAME = "generation";
|
||||
public static final String EPOCHS_DIRECTORY_NAME = "epochs";
|
||||
public static final String ACTIVATIONS_DIRECTORY_NAME = "activations";
|
||||
public static final String OWNERSHIP_DIRECTORY_NAME = "ownership";
|
||||
public static final String LEGACY_PACK_DIRECTORY_NAME = "pack";
|
||||
public static final String LEGACY_MANTLE_DIRECTORY_NAME = "mantle-hydrology";
|
||||
public static final String REGION_DIRECTORY_NAME = "region";
|
||||
|
||||
private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}");
|
||||
|
||||
private final Path dimensionRoot;
|
||||
private final Path irisRoot;
|
||||
private final Path generationRoot;
|
||||
private final Path epochsRoot;
|
||||
private final Path activationsRoot;
|
||||
private final Path ownershipRoot;
|
||||
private final Path manifest;
|
||||
private final Path legacyPackRoot;
|
||||
private final Path legacyMantleRoot;
|
||||
private final Path regionRoot;
|
||||
|
||||
private GenerationHistoryPaths(Path dimensionRoot) {
|
||||
this.dimensionRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot")
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
irisRoot = this.dimensionRoot.resolve(IRIS_DIRECTORY_NAME);
|
||||
generationRoot = irisRoot.resolve(GENERATION_DIRECTORY_NAME);
|
||||
epochsRoot = generationRoot.resolve(EPOCHS_DIRECTORY_NAME);
|
||||
activationsRoot = generationRoot.resolve(ACTIVATIONS_DIRECTORY_NAME);
|
||||
ownershipRoot = generationRoot.resolve(OWNERSHIP_DIRECTORY_NAME);
|
||||
manifest = generationRoot.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME);
|
||||
legacyPackRoot = irisRoot.resolve(LEGACY_PACK_DIRECTORY_NAME);
|
||||
legacyMantleRoot = this.dimensionRoot.resolve(LEGACY_MANTLE_DIRECTORY_NAME);
|
||||
regionRoot = this.dimensionRoot.resolve(REGION_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
public static GenerationHistoryPaths forDimension(Path dimensionRoot) {
|
||||
return new GenerationHistoryPaths(dimensionRoot);
|
||||
}
|
||||
|
||||
public Path dimensionRoot() {
|
||||
return dimensionRoot;
|
||||
}
|
||||
|
||||
public Path irisRoot() {
|
||||
return irisRoot;
|
||||
}
|
||||
|
||||
public Path generationRoot() {
|
||||
return generationRoot;
|
||||
}
|
||||
|
||||
public Path epochsRoot() {
|
||||
return epochsRoot;
|
||||
}
|
||||
|
||||
public Path epochRoot(String epochId) {
|
||||
String requiredEpochId = Objects.requireNonNull(epochId, "epochId");
|
||||
if (!SHA_256.matcher(requiredEpochId).matches()) {
|
||||
throw new IllegalArgumentException("epochId must be a lowercase SHA-256 digest");
|
||||
}
|
||||
return epochsRoot.resolve(requiredEpochId);
|
||||
}
|
||||
|
||||
public Path packRoot(String epochId) {
|
||||
return epochRoot(epochId).resolve(LEGACY_PACK_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
public Path activationsRoot() {
|
||||
return activationsRoot;
|
||||
}
|
||||
|
||||
public Path activationRoot(long activationId) {
|
||||
if (activationId <= 0L) {
|
||||
throw new IllegalArgumentException("activationId must be positive");
|
||||
}
|
||||
return activationsRoot.resolve(Long.toString(activationId));
|
||||
}
|
||||
|
||||
public Path activationMantleRoot(long activationId) {
|
||||
return activationRoot(activationId).resolve(LEGACY_MANTLE_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
public Path ownershipRoot() {
|
||||
return ownershipRoot;
|
||||
}
|
||||
|
||||
public Path manifest() {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public Path legacyPackRoot() {
|
||||
return legacyPackRoot;
|
||||
}
|
||||
|
||||
public Path legacyMantleRoot() {
|
||||
return legacyMantleRoot;
|
||||
}
|
||||
|
||||
public Path regionRoot() {
|
||||
return regionRoot;
|
||||
}
|
||||
}
|
||||
+1295
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,383 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.Strictness;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.StringReader;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class GenerationHistoryStore {
|
||||
public static final String MANIFEST_FILE_NAME = "manifest.json";
|
||||
private static final int MAX_MANIFEST_BYTES = 16 * 1_024 * 1_024;
|
||||
|
||||
private static final Gson GSON = new GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.serializeNulls()
|
||||
.setPrettyPrinting()
|
||||
.create();
|
||||
|
||||
private final Path generationDirectory;
|
||||
private final Path manifestPath;
|
||||
private final Clock clock;
|
||||
private final DirectorySync directorySync;
|
||||
private GenerationManifest manifest;
|
||||
private IOException failure;
|
||||
|
||||
private GenerationHistoryStore(
|
||||
Path generationDirectory,
|
||||
GenerationManifest manifest,
|
||||
Clock clock,
|
||||
DirectorySync directorySync
|
||||
) {
|
||||
this.generationDirectory = generationDirectory;
|
||||
this.manifestPath = generationDirectory.resolve(MANIFEST_FILE_NAME);
|
||||
this.manifest = Objects.requireNonNull(manifest, "manifest");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.directorySync = Objects.requireNonNull(directorySync, "directorySync");
|
||||
}
|
||||
|
||||
public static GenerationHistoryStore initialize(
|
||||
Path generationDirectory,
|
||||
GenerationEpoch initialEpoch
|
||||
) throws IOException {
|
||||
return initialize(generationDirectory, initialEpoch, Clock.systemUTC());
|
||||
}
|
||||
|
||||
static GenerationHistoryStore initialize(
|
||||
Path generationDirectory,
|
||||
GenerationEpoch initialEpoch,
|
||||
Clock clock
|
||||
) throws IOException {
|
||||
return initialize(generationDirectory, initialEpoch, clock, GenerationHistoryStore::forceDirectory);
|
||||
}
|
||||
|
||||
static GenerationHistoryStore initialize(
|
||||
Path generationDirectory,
|
||||
GenerationEpoch initialEpoch,
|
||||
Clock clock,
|
||||
DirectorySync directorySync
|
||||
) throws IOException {
|
||||
Path directory = normalizeDirectory(generationDirectory);
|
||||
requireSafeParent(directory);
|
||||
Path parent = Objects.requireNonNull(directory.getParent(), "Generation history parent");
|
||||
String lockName = "." + Objects.requireNonNull(directory.getFileName(), "Generation history directory name")
|
||||
+ ".manifest.lock";
|
||||
try (GenerationPublicationLock ignored = GenerationPublicationLock.acquire(parent, lockName)) {
|
||||
ensureDirectory(directory);
|
||||
Path manifestPath = directory.resolve(MANIFEST_FILE_NAME);
|
||||
if (Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new FileAlreadyExistsException(manifestPath.toString());
|
||||
}
|
||||
Clock requiredClock = Objects.requireNonNull(clock, "clock");
|
||||
GenerationManifest manifest = GenerationManifest.initial(
|
||||
Objects.requireNonNull(initialEpoch, "initialEpoch"),
|
||||
requiredClock.millis()
|
||||
);
|
||||
DirectorySync requiredDirectorySync = Objects.requireNonNull(directorySync, "directorySync");
|
||||
writeManifestAtomically(directory, manifestPath, manifest, false, requiredDirectorySync);
|
||||
return new GenerationHistoryStore(directory, manifest, requiredClock, requiredDirectorySync);
|
||||
}
|
||||
}
|
||||
|
||||
public static GenerationHistoryStore open(Path generationDirectory) throws IOException {
|
||||
return open(generationDirectory, Clock.systemUTC());
|
||||
}
|
||||
|
||||
static GenerationHistoryStore open(Path generationDirectory, Clock clock) throws IOException {
|
||||
Path directory = normalizeDirectory(generationDirectory);
|
||||
requireDirectory(directory);
|
||||
Path manifestPath = directory.resolve(MANIFEST_FILE_NAME);
|
||||
requireRegularFile(manifestPath);
|
||||
GenerationManifest manifest = readManifest(manifestPath);
|
||||
return new GenerationHistoryStore(
|
||||
directory,
|
||||
manifest,
|
||||
clock,
|
||||
GenerationHistoryStore::forceDirectory
|
||||
);
|
||||
}
|
||||
|
||||
public Path generationDirectory() {
|
||||
return generationDirectory;
|
||||
}
|
||||
|
||||
public synchronized GenerationManifest manifest() {
|
||||
requireUsable();
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public synchronized GenerationActivation activeActivation() {
|
||||
requireUsable();
|
||||
return manifest.activeActivation();
|
||||
}
|
||||
|
||||
public synchronized GenerationEpoch activeEpoch() {
|
||||
requireUsable();
|
||||
return manifest.activeEpoch();
|
||||
}
|
||||
|
||||
public synchronized Optional<GenerationActivation> pendingActivation() {
|
||||
requireUsable();
|
||||
return manifest.pendingActivation();
|
||||
}
|
||||
|
||||
public synchronized Optional<GenerationEpoch> pendingEpoch() {
|
||||
requireUsable();
|
||||
return manifest.pendingEpoch();
|
||||
}
|
||||
|
||||
public synchronized Optional<GenerationEpoch> epoch(String epochId) {
|
||||
requireUsable();
|
||||
return manifest.epoch(epochId);
|
||||
}
|
||||
|
||||
public synchronized Optional<GenerationActivation> activation(long activationId) {
|
||||
requireUsable();
|
||||
return manifest.activation(activationId);
|
||||
}
|
||||
|
||||
public synchronized GenerationActivation preparePendingActivation(
|
||||
GenerationEpoch epoch,
|
||||
int transitionWidthBlocks
|
||||
) throws IOException {
|
||||
requireUsable();
|
||||
GenerationManifest updated = manifest.preparePending(
|
||||
epoch,
|
||||
clock.millis(),
|
||||
transitionWidthBlocks
|
||||
);
|
||||
if (updated == manifest) {
|
||||
return manifest.pendingActivation().orElseThrow();
|
||||
}
|
||||
writeUpdatedManifest(updated);
|
||||
manifest = updated;
|
||||
return updated.pendingActivation().orElseThrow();
|
||||
}
|
||||
|
||||
public synchronized GenerationActivation completePendingTransition(
|
||||
long expectedActivationId,
|
||||
String boundaryIdentity,
|
||||
String terrainSignatureIdentity
|
||||
) throws IOException {
|
||||
requireUsable();
|
||||
GenerationManifest updated = manifest.completePendingTransition(
|
||||
expectedActivationId,
|
||||
boundaryIdentity,
|
||||
terrainSignatureIdentity
|
||||
);
|
||||
if (updated != manifest) {
|
||||
writeUpdatedManifest(updated);
|
||||
manifest = updated;
|
||||
}
|
||||
return manifest.pendingActivation().orElseThrow();
|
||||
}
|
||||
|
||||
public synchronized GenerationActivation activatePending(
|
||||
long expectedActivationId
|
||||
) throws IOException {
|
||||
requireUsable();
|
||||
GenerationManifest updated = manifest.activatePending(expectedActivationId);
|
||||
writeUpdatedManifest(updated);
|
||||
manifest = updated;
|
||||
return updated.activeActivation();
|
||||
}
|
||||
|
||||
private void writeUpdatedManifest(GenerationManifest updated) throws IOException {
|
||||
try {
|
||||
writeManifestAtomically(generationDirectory, manifestPath, updated, true, directorySync);
|
||||
} catch (IOException error) {
|
||||
reconcileAfterWriteFailure(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static GenerationManifest readManifest(Path manifestPath) throws IOException {
|
||||
long size = Files.size(manifestPath);
|
||||
if (size < 2L || size > MAX_MANIFEST_BYTES) {
|
||||
throw new IOException("Generation manifest size is invalid: " + manifestPath);
|
||||
}
|
||||
byte[] encoded = Files.readAllBytes(manifestPath);
|
||||
if (encoded.length < 2 || encoded.length > MAX_MANIFEST_BYTES) {
|
||||
throw new IOException("Generation manifest size changed while reading: " + manifestPath);
|
||||
}
|
||||
try (Reader input = new StringReader(new String(encoded, StandardCharsets.UTF_8));
|
||||
JsonReader reader = new JsonReader(input)) {
|
||||
reader.setStrictness(Strictness.STRICT);
|
||||
JsonElement parsed = JsonParser.parseReader(reader);
|
||||
if (parsed == null || !parsed.isJsonObject()) {
|
||||
throw new IllegalArgumentException("Generation manifest must be a JSON object.");
|
||||
}
|
||||
if (reader.peek() != JsonToken.END_DOCUMENT) {
|
||||
throw new IllegalArgumentException("Generation manifest contains trailing content.");
|
||||
}
|
||||
return GenerationManifest.fromJson(parsed.getAsJsonObject());
|
||||
} catch (JsonParseException | IllegalArgumentException | ArithmeticException exception) {
|
||||
throw new IOException("Invalid generation manifest: " + manifestPath, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeManifestAtomically(
|
||||
Path generationDirectory,
|
||||
Path manifestPath,
|
||||
GenerationManifest manifest,
|
||||
boolean replaceExisting,
|
||||
DirectorySync directorySync
|
||||
) throws IOException {
|
||||
requireSafeParent(generationDirectory);
|
||||
requireDirectory(generationDirectory);
|
||||
byte[] content = GSON.toJson(manifest.toJson()).getBytes(StandardCharsets.UTF_8);
|
||||
Path temporary = Files.createTempFile(generationDirectory, ".manifest-", ".tmp");
|
||||
try {
|
||||
try (FileChannel channel = FileChannel.open(
|
||||
temporary,
|
||||
StandardOpenOption.WRITE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING
|
||||
)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(content);
|
||||
while (buffer.hasRemaining()) {
|
||||
channel.write(buffer);
|
||||
}
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
if (replaceExisting) {
|
||||
Files.move(
|
||||
temporary,
|
||||
manifestPath,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
);
|
||||
} else {
|
||||
Files.move(temporary, manifestPath, StandardCopyOption.ATOMIC_MOVE);
|
||||
}
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
throw new IOException("Generation history requires atomic manifest publication.", exception);
|
||||
}
|
||||
directorySync.force(generationDirectory);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static Path normalizeDirectory(Path directory) {
|
||||
return Objects.requireNonNull(directory, "generationDirectory").toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private static void ensureDirectory(Path directory) throws IOException {
|
||||
requireSafeParent(directory);
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
requireDirectory(directory);
|
||||
forceDirectory(Objects.requireNonNull(directory.getParent(), "Generation history parent"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.createDirectory(directory);
|
||||
} catch (FileAlreadyExistsException race) {
|
||||
requireDirectory(directory);
|
||||
forceDirectory(Objects.requireNonNull(directory.getParent(), "Generation history parent"));
|
||||
return;
|
||||
}
|
||||
forceDirectory(Objects.requireNonNull(directory.getParent(), "Generation history parent"));
|
||||
requireDirectory(directory);
|
||||
}
|
||||
|
||||
private static void requireDirectory(Path directory) throws IOException {
|
||||
requireSafeParent(directory);
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new NoSuchFileException(directory.toString());
|
||||
}
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
directory,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
|
||||
throw new IOException("Generation history path is not a safe directory: " + directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSafeParent(Path directory) throws IOException {
|
||||
Path parent = directory.getParent();
|
||||
if (parent == null || !Files.exists(parent, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Generation history parent directory is missing: " + parent);
|
||||
}
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
parent,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
|
||||
throw new IOException("Generation history parent path is not a safe directory: " + parent);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireRegularFile(Path file) throws IOException {
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new NoSuchFileException(file.toString());
|
||||
}
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
file,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (!attributes.isRegularFile() || attributes.isSymbolicLink()) {
|
||||
throw new IOException("Generation manifest is not a safe regular file: " + file);
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
if (File.separatorChar == '\\') {
|
||||
return;
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
} catch (UnsupportedOperationException exception) {
|
||||
throw new IOException("Generation history directory cannot be durability-synced: " + directory, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void reconcileAfterWriteFailure(IOException writeFailure) {
|
||||
try {
|
||||
requireDirectory(generationDirectory);
|
||||
requireRegularFile(manifestPath);
|
||||
manifest = readManifest(manifestPath);
|
||||
} catch (IOException reconciliationFailure) {
|
||||
writeFailure.addSuppressed(reconciliationFailure);
|
||||
failure = writeFailure;
|
||||
}
|
||||
}
|
||||
|
||||
private void requireUsable() {
|
||||
if (failure != null) {
|
||||
throw new IllegalStateException("Generation history store is unusable after a failed manifest write", failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface DirectorySync {
|
||||
void force(Path directory) throws IOException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
final class GenerationKernelCatalog {
|
||||
private static final int MAXIMUM_BYTES = 16 * 1024;
|
||||
|
||||
private GenerationKernelCatalog() {
|
||||
}
|
||||
|
||||
static GenerationKernelRegistry load() {
|
||||
try (InputStream input = GenerationKernelCatalog.class.getResourceAsStream(
|
||||
"/META-INF/iris/generation-kernels/catalog.tsv")) {
|
||||
if (input == null) {
|
||||
throw new IOException("Missing Iris generation kernel catalog.");
|
||||
}
|
||||
Catalog catalog = parse(input.readNBytes(MAXIMUM_BYTES + 1));
|
||||
List<GenerationKernelRegistry.Kernel> kernels = new ArrayList<>();
|
||||
for (int abi : catalog.abis()) {
|
||||
GenerationKernelSourceSeal.Descriptor seal = GenerationKernelSourceSeal.load(abi);
|
||||
Class<? extends GenerationKernelRegistry.RuntimeFactory> factoryType = Class.forName(
|
||||
seal.factoryClass(), true, GenerationKernelCatalog.class.getClassLoader())
|
||||
.asSubclass(GenerationKernelRegistry.RuntimeFactory.class);
|
||||
Map<GenerationKernelRegistry.AlgorithmVersion, GenerationKernelRegistry.RuntimeFactory> factories = new LinkedHashMap<>();
|
||||
for (GenerationKernelRegistry.AlgorithmVersion algorithm : seal.algorithms()) {
|
||||
factories.put(algorithm, factoryType.getDeclaredConstructor().newInstance());
|
||||
}
|
||||
kernels.add(new GenerationKernelRegistry.Kernel(abi, seal.fingerprint(), factories));
|
||||
}
|
||||
return new GenerationKernelRegistry(catalog.current(), kernels);
|
||||
} catch (IOException | ReflectiveOperationException | IllegalArgumentException failure) {
|
||||
throw new IllegalStateException("Cannot load the sealed Iris generation kernel catalog.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
static Catalog parse(byte[] bytes) throws IOException {
|
||||
if (bytes.length == 0 || bytes.length > MAXIMUM_BYTES) {
|
||||
throw new IOException("Invalid generation kernel catalog size.");
|
||||
}
|
||||
String text = new String(bytes, StandardCharsets.UTF_8);
|
||||
String[] lines = text.split("\n", -1);
|
||||
if (!text.endsWith("\n") || text.indexOf('\r') >= 0 || lines.length < 4 || lines.length > 128
|
||||
|| !lines[0].equals("iris-generation-kernel-catalog-v1")) {
|
||||
throw new IOException("Invalid generation kernel catalog format.");
|
||||
}
|
||||
try {
|
||||
String[] current = lines[1].split("\t", -1);
|
||||
if (current.length != 4 || !current[0].equals("current")) {
|
||||
throw new IllegalArgumentException("Missing current generation version.");
|
||||
}
|
||||
GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version(
|
||||
positive(current[1]), positive(current[2]), positive(current[3]));
|
||||
List<Integer> abis = new ArrayList<>();
|
||||
int previous = 0;
|
||||
for (int index = 2; index < lines.length - 1; index++) {
|
||||
String[] kernel = lines[index].split("\t", -1);
|
||||
if (kernel.length != 2 || !kernel[0].equals("kernel")) {
|
||||
throw new IllegalArgumentException("Invalid generation kernel registration.");
|
||||
}
|
||||
int abi = positive(kernel[1]);
|
||||
if (abi <= previous) {
|
||||
throw new IllegalArgumentException("Generation kernel registrations must be unique and sorted.");
|
||||
}
|
||||
previous = abi;
|
||||
abis.add(abi);
|
||||
}
|
||||
if (!abis.contains(version.generatorAbi())) {
|
||||
throw new IllegalArgumentException("Current generation ABI is not registered.");
|
||||
}
|
||||
return new Catalog(version, List.copyOf(abis));
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("Invalid generation kernel catalog.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static int positive(String value) {
|
||||
if (!value.matches("[1-9][0-9]{0,8}")) {
|
||||
throw new IllegalArgumentException("Invalid generation version.");
|
||||
}
|
||||
return Integer.parseInt(value);
|
||||
}
|
||||
|
||||
record Catalog(GenerationKernelRegistry.Version current, List<Integer> abis) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.DimensionStackContext;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.UpperDimensionContext;
|
||||
import art.arcane.iris.engine.framework.EngineMode;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class GenerationKernelRegistry {
|
||||
private static final Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}");
|
||||
private static final GenerationKernelRegistry STANDARD = GenerationKernelCatalog.load();
|
||||
|
||||
private final Version current;
|
||||
private final Map<Integer, Kernel> kernels;
|
||||
|
||||
public GenerationKernelRegistry(Version current, Collection<Kernel> kernels) {
|
||||
this.current = Objects.requireNonNull(current, "current");
|
||||
Collection<Kernel> requiredKernels = Objects.requireNonNull(kernels, "kernels");
|
||||
TreeMap<Integer, Kernel> indexed = new TreeMap<>();
|
||||
for (Kernel kernel : requiredKernels) {
|
||||
Kernel requiredKernel = Objects.requireNonNull(kernel, "kernel");
|
||||
if (indexed.putIfAbsent(requiredKernel.generatorAbi(), requiredKernel) != null) {
|
||||
throw new IllegalArgumentException("Duplicate generation ABI "
|
||||
+ requiredKernel.generatorAbi() + ".");
|
||||
}
|
||||
}
|
||||
this.kernels = Map.copyOf(indexed);
|
||||
if (!supports(current.generatorAbi(), current.rngVersion(), current.seedDerivationVersion())) {
|
||||
throw new IllegalArgumentException("Current generation version is not registered.");
|
||||
}
|
||||
}
|
||||
|
||||
public static GenerationKernelRegistry standard() {
|
||||
return STANDARD;
|
||||
}
|
||||
|
||||
public Version current() {
|
||||
return current;
|
||||
}
|
||||
|
||||
public boolean supports(int generatorAbi, int rngVersion, int seedDerivationVersion) {
|
||||
Kernel kernel = kernels.get(generatorAbi);
|
||||
return kernel != null && kernel.runtimeFactories().containsKey(
|
||||
new AlgorithmVersion(rngVersion, seedDerivationVersion)
|
||||
);
|
||||
}
|
||||
|
||||
public Kernel requireSupported(
|
||||
int generatorAbi,
|
||||
int rngVersion,
|
||||
int seedDerivationVersion
|
||||
) throws IOException {
|
||||
Kernel kernel = kernels.get(generatorAbi);
|
||||
if (kernel == null) {
|
||||
throw new IOException("Unsupported Iris generation ABI " + generatorAbi + ".");
|
||||
}
|
||||
AlgorithmVersion version = new AlgorithmVersion(rngVersion, seedDerivationVersion);
|
||||
if (!kernel.runtimeFactories().containsKey(version)) {
|
||||
throw new IOException("Unsupported Iris RNG/seed derivation version "
|
||||
+ rngVersion + "/" + seedDerivationVersion
|
||||
+ " for generation ABI " + generatorAbi + ".");
|
||||
}
|
||||
return kernel;
|
||||
}
|
||||
|
||||
public Kernel requireSupported(Version version) throws IOException {
|
||||
Version requiredVersion = Objects.requireNonNull(version, "version");
|
||||
return requireSupported(
|
||||
requiredVersion.generatorAbi(),
|
||||
requiredVersion.rngVersion(),
|
||||
requiredVersion.seedDerivationVersion()
|
||||
);
|
||||
}
|
||||
|
||||
public Kernel requireSupported(Version version, String implementationFingerprint) throws IOException {
|
||||
Version requiredVersion = Objects.requireNonNull(version, "version");
|
||||
Kernel kernel = requireSupported(requiredVersion);
|
||||
String requiredFingerprint = requireFingerprint(
|
||||
implementationFingerprint,
|
||||
"Generation kernel implementation fingerprint"
|
||||
);
|
||||
if (!kernel.implementationFingerprint().equals(requiredFingerprint)) {
|
||||
throw new IOException("Iris generation kernel " + requiredVersion
|
||||
+ " has implementation fingerprint " + kernel.implementationFingerprint()
|
||||
+ " instead of the epoch's required fingerprint " + requiredFingerprint + ".");
|
||||
}
|
||||
return kernel;
|
||||
}
|
||||
|
||||
public RuntimeKernel select(Version version) throws IOException {
|
||||
Version requiredVersion = Objects.requireNonNull(version, "version");
|
||||
Kernel kernel = requireSupported(requiredVersion);
|
||||
AlgorithmVersion algorithmVersion = new AlgorithmVersion(
|
||||
requiredVersion.rngVersion(),
|
||||
requiredVersion.seedDerivationVersion()
|
||||
);
|
||||
RuntimeFactory factory = kernel.runtimeFactories().get(algorithmVersion);
|
||||
if (factory == null) {
|
||||
throw new IOException("No executable Iris generation kernel is registered for "
|
||||
+ requiredVersion + ".");
|
||||
}
|
||||
return new RuntimeKernel(requiredVersion, kernel.implementationFingerprint(), factory);
|
||||
}
|
||||
|
||||
public record Version(int generatorAbi, int rngVersion, int seedDerivationVersion) {
|
||||
public Version {
|
||||
if (generatorAbi < 1) {
|
||||
throw new IllegalArgumentException("Generator ABI must be positive.");
|
||||
}
|
||||
if (rngVersion < 1) {
|
||||
throw new IllegalArgumentException("RNG version must be positive.");
|
||||
}
|
||||
if (seedDerivationVersion < 1) {
|
||||
throw new IllegalArgumentException("Seed derivation version must be positive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireFingerprint(String value, String label) {
|
||||
String requiredValue = Objects.requireNonNull(value, label);
|
||||
if (!SHA_256_PATTERN.matcher(requiredValue).matches()) {
|
||||
throw new IllegalArgumentException(label + " must be a lowercase SHA-256 value.");
|
||||
}
|
||||
return requiredValue;
|
||||
}
|
||||
|
||||
public record Kernel(
|
||||
int generatorAbi,
|
||||
String implementationFingerprint,
|
||||
Map<AlgorithmVersion, RuntimeFactory> runtimeFactories
|
||||
) {
|
||||
public Kernel {
|
||||
if (generatorAbi < 1) {
|
||||
throw new IllegalArgumentException("Generator ABI must be positive.");
|
||||
}
|
||||
implementationFingerprint = requireFingerprint(
|
||||
implementationFingerprint,
|
||||
"Generation kernel implementation fingerprint"
|
||||
);
|
||||
Map<AlgorithmVersion, RuntimeFactory> requiredFactories = Map.copyOf(Objects.requireNonNull(
|
||||
runtimeFactories,
|
||||
"runtimeFactories"
|
||||
));
|
||||
if (requiredFactories.isEmpty()) {
|
||||
throw new IllegalArgumentException("A generation kernel must provide an executable runtime factory.");
|
||||
}
|
||||
for (Map.Entry<AlgorithmVersion, RuntimeFactory> entry : requiredFactories.entrySet()) {
|
||||
Objects.requireNonNull(entry.getKey(), "algorithmVersion");
|
||||
Objects.requireNonNull(entry.getValue(), "runtimeFactory");
|
||||
}
|
||||
runtimeFactories = requiredFactories;
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface RuntimeFactory {
|
||||
IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan);
|
||||
|
||||
default SeedManager createSeedManager(long worldSeed) {
|
||||
throw new UnsupportedOperationException("Generation kernel does not own seed derivation.");
|
||||
}
|
||||
|
||||
default EngineMantle createMantle(IrisEngine engine, Path storageDirectory) {
|
||||
throw new UnsupportedOperationException("Generation kernel does not own mantle construction.");
|
||||
}
|
||||
|
||||
default UpperDimensionContext createUpperContext(IrisEngine engine) {
|
||||
throw new UnsupportedOperationException("Generation kernel does not own upper-dimension construction.");
|
||||
}
|
||||
|
||||
default DimensionStackContext createDimensionStackContext(IrisEngine engine) {
|
||||
throw new UnsupportedOperationException("Generation kernel does not own dimension-stack construction.");
|
||||
}
|
||||
|
||||
default EngineMode createMode(IrisEngine engine) {
|
||||
throw new UnsupportedOperationException("Generation kernel does not own generation-mode construction.");
|
||||
}
|
||||
|
||||
default void registerStaticObjects(IrisEngine engine, EngineMode mode) {
|
||||
throw new UnsupportedOperationException("Generation kernel does not own static-object stages.");
|
||||
}
|
||||
|
||||
default void registerDimensionStack(IrisEngine engine, EngineMode mode, DimensionStackContext context) {
|
||||
throw new UnsupportedOperationException("Generation kernel does not own dimension-stack stages.");
|
||||
}
|
||||
}
|
||||
|
||||
public record RuntimeKernel(
|
||||
Version version,
|
||||
String implementationFingerprint,
|
||||
RuntimeFactory factory
|
||||
) {
|
||||
public RuntimeKernel {
|
||||
Objects.requireNonNull(version, "version");
|
||||
implementationFingerprint = requireFingerprint(
|
||||
implementationFingerprint,
|
||||
"Generation kernel implementation fingerprint"
|
||||
);
|
||||
Objects.requireNonNull(factory, "factory");
|
||||
}
|
||||
|
||||
public IrisComplex createComplex(IrisEngine engine, TransitionGenerationPlan transitionPlan) {
|
||||
return Objects.requireNonNull(
|
||||
factory.create(Objects.requireNonNull(engine, "engine"), transitionPlan),
|
||||
"generation kernel complex"
|
||||
);
|
||||
}
|
||||
|
||||
public SeedManager createSeedManager(long worldSeed) {
|
||||
return Objects.requireNonNull(factory.createSeedManager(worldSeed), "generation kernel seed manager");
|
||||
}
|
||||
|
||||
public EngineMantle createMantle(IrisEngine engine, Path storageDirectory) {
|
||||
return Objects.requireNonNull(
|
||||
factory.createMantle(
|
||||
Objects.requireNonNull(engine, "engine"),
|
||||
Objects.requireNonNull(storageDirectory, "storageDirectory")
|
||||
),
|
||||
"generation kernel mantle"
|
||||
);
|
||||
}
|
||||
|
||||
public UpperDimensionContext createUpperContext(IrisEngine engine) {
|
||||
return factory.createUpperContext(Objects.requireNonNull(engine, "engine"));
|
||||
}
|
||||
|
||||
public DimensionStackContext createDimensionStackContext(IrisEngine engine) {
|
||||
return factory.createDimensionStackContext(Objects.requireNonNull(engine, "engine"));
|
||||
}
|
||||
|
||||
public EngineMode createMode(IrisEngine engine) {
|
||||
return Objects.requireNonNull(
|
||||
factory.createMode(Objects.requireNonNull(engine, "engine")),
|
||||
"generation kernel mode"
|
||||
);
|
||||
}
|
||||
|
||||
public void registerStaticObjects(IrisEngine engine, EngineMode mode) {
|
||||
factory.registerStaticObjects(
|
||||
Objects.requireNonNull(engine, "engine"),
|
||||
Objects.requireNonNull(mode, "mode")
|
||||
);
|
||||
}
|
||||
|
||||
public void registerDimensionStack(
|
||||
IrisEngine engine,
|
||||
EngineMode mode,
|
||||
DimensionStackContext context
|
||||
) {
|
||||
factory.registerDimensionStack(
|
||||
Objects.requireNonNull(engine, "engine"),
|
||||
Objects.requireNonNull(mode, "mode"),
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public record AlgorithmVersion(int rngVersion, int seedDerivationVersion) {
|
||||
public AlgorithmVersion {
|
||||
if (rngVersion < 1) {
|
||||
throw new IllegalArgumentException("RNG version must be positive.");
|
||||
}
|
||||
if (seedDerivationVersion < 1) {
|
||||
throw new IllegalArgumentException("Seed derivation version must be positive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class GenerationKernelSourceSeal {
|
||||
private static final int MAXIMUM_BYTES = 8 * 1024 * 1024;
|
||||
private static final Pattern SHA256 = Pattern.compile("[0-9a-f]{64}");
|
||||
|
||||
private GenerationKernelSourceSeal() {
|
||||
}
|
||||
|
||||
static String requireFingerprint(int abi, Class<?> factory) {
|
||||
Descriptor descriptor = load(abi);
|
||||
if (!descriptor.factoryClass().equals(factory.getName())) {
|
||||
throw new IllegalStateException("Generation ABI " + abi + " is sealed for " + descriptor.factoryClass() + ".");
|
||||
}
|
||||
return descriptor.fingerprint();
|
||||
}
|
||||
|
||||
static Descriptor load(int abi) {
|
||||
String resource = "/META-INF/iris/generation-kernels/abi-" + abi + ".seal";
|
||||
try (InputStream input = GenerationKernelSourceSeal.class.getResourceAsStream(resource)) {
|
||||
if (input == null) {
|
||||
throw new IOException("Missing generation kernel source seal " + resource + ".");
|
||||
}
|
||||
byte[] bytes = input.readNBytes(MAXIMUM_BYTES + 1);
|
||||
String[] lines = new String(bytes, StandardCharsets.UTF_8).split("\n", -1);
|
||||
if (lines.length < 4 || !lines[2].startsWith("factory\t")) {
|
||||
throw new IOException("Generation kernel seal has no factory identity.");
|
||||
}
|
||||
String factoryClass = lines[2].substring("factory\t".length());
|
||||
if (!factoryClass.matches("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)+")) {
|
||||
throw new IOException("Invalid generation kernel factory identity.");
|
||||
}
|
||||
String fingerprint = fingerprint(abi, factoryClass, bytes);
|
||||
List<GenerationKernelRegistry.AlgorithmVersion> algorithms = new ArrayList<>();
|
||||
for (String line : lines) {
|
||||
if (line.startsWith("algorithm\t")) {
|
||||
String[] fields = line.split("\t", -1);
|
||||
algorithms.add(new GenerationKernelRegistry.AlgorithmVersion(
|
||||
Integer.parseInt(fields[1]), Integer.parseInt(fields[2])));
|
||||
}
|
||||
}
|
||||
return new Descriptor(abi, factoryClass, List.copyOf(algorithms), fingerprint);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Cannot load sealed Iris generation ABI " + abi + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
static String fingerprint(int abi, String factoryClass, byte[] bytes) throws IOException {
|
||||
Objects.requireNonNull(factoryClass, "factoryClass");
|
||||
Objects.requireNonNull(bytes, "bytes");
|
||||
if (abi < 1 || bytes.length == 0 || bytes.length > MAXIMUM_BYTES) {
|
||||
throw new IOException("Invalid generation kernel seal size or ABI.");
|
||||
}
|
||||
String source = new String(bytes, StandardCharsets.UTF_8);
|
||||
String prefix = "iris-generation-kernel-seal-v1\nabi\t" + abi + "\nfactory\t" + factoryClass + "\n";
|
||||
if (!source.startsWith(prefix) || !source.endsWith("\n") || source.indexOf('\r') >= 0
|
||||
|| !source.contains("\nsource\t") || !source.contains("\ndependency\t")) {
|
||||
throw new IOException("Generation kernel seal does not match ABI " + abi + " and factory " + factoryClass + ".");
|
||||
}
|
||||
requireCanonicalEntries(source.substring(prefix.length()));
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (NoSuchAlgorithmException failure) {
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireCanonicalEntries(String source) throws IOException {
|
||||
String[] entries = source.split("\n", -1);
|
||||
if (entries.length > 30_000) {
|
||||
throw new IOException("Generation kernel seal has too many entries.");
|
||||
}
|
||||
int previousType = -1;
|
||||
String previousKey = "";
|
||||
boolean hasScope = false;
|
||||
boolean hasAlgorithm = false;
|
||||
int previousRng = 0;
|
||||
int previousSeed = 0;
|
||||
for (int index = 0; index < entries.length - 1; index++) {
|
||||
String[] fields = entries[index].split("\t", -1);
|
||||
if (fields[0].equals("algorithm")) {
|
||||
if (previousType != -1 || fields.length != 3 || !fields[1].matches("[1-9][0-9]{0,8}")
|
||||
|| !fields[2].matches("[1-9][0-9]{0,8}")) {
|
||||
throw new IOException("Generation kernel seal contains an invalid algorithm version.");
|
||||
}
|
||||
int rng = Integer.parseInt(fields[1]);
|
||||
int seed = Integer.parseInt(fields[2]);
|
||||
if (rng < previousRng || (rng == previousRng && seed <= previousSeed)) {
|
||||
throw new IOException("Generation kernel algorithm versions are not canonical.");
|
||||
}
|
||||
previousRng = rng;
|
||||
previousSeed = seed;
|
||||
hasAlgorithm = true;
|
||||
continue;
|
||||
}
|
||||
int type = switch (fields[0]) {
|
||||
case "scope" -> 0;
|
||||
case "exclude" -> 1;
|
||||
case "source" -> 2;
|
||||
case "dependency" -> 3;
|
||||
default -> -1;
|
||||
};
|
||||
int expectedFields = type < 2 ? 2 : 3;
|
||||
if (type < previousType || type < 0 || fields.length != expectedFields || fields[1].isBlank()
|
||||
|| fields[1].chars().anyMatch(Character::isWhitespace)
|
||||
|| (type == previousType && fields[1].compareTo(previousKey) <= 0)
|
||||
|| (type >= 2 && !SHA256.matcher(fields[2]).matches())) {
|
||||
throw new IOException("Generation kernel seal contains an invalid or noncanonical entry.");
|
||||
}
|
||||
previousType = type;
|
||||
previousKey = fields[1];
|
||||
hasScope |= type == 0;
|
||||
}
|
||||
if (!hasScope || !hasAlgorithm) {
|
||||
throw new IOException("Generation kernel seal has no source scope or algorithm version.");
|
||||
}
|
||||
}
|
||||
|
||||
record Descriptor(int abi, String factoryClass, List<GenerationKernelRegistry.AlgorithmVersion> algorithms,
|
||||
String fingerprint) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.DimensionStackContext;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.IrisEngineMantle;
|
||||
import art.arcane.iris.engine.UpperDimensionContext;
|
||||
import art.arcane.iris.engine.framework.EngineMode;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.actuator.IrisDimensionStackActuator;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.modifier.IrisCustomModifier;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionMode;
|
||||
import art.arcane.iris.engine.object.IrisDimensionModeType;
|
||||
import art.arcane.iris.engine.object.IrisStaticObjectLayer;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
final class GenerationKernelV1 implements GenerationKernelRegistry.RuntimeFactory {
|
||||
static final String IMPLEMENTATION_FINGERPRINT =
|
||||
GenerationKernelSourceSeal.requireFingerprint(1, GenerationKernelV1.class);
|
||||
|
||||
@Override
|
||||
public SeedManager createSeedManager(long worldSeed) {
|
||||
return new SeedManager(worldSeed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EngineMantle createMantle(IrisEngine engine, Path storageDirectory) {
|
||||
return new IrisEngineMantle(engine, storageDirectory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan) {
|
||||
return new IrisComplex(engine, transitionPlan);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UpperDimensionContext createUpperContext(IrisEngine engine) {
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
if (!dimension.hasUpperDimension()) {
|
||||
return null;
|
||||
}
|
||||
String upperKey = dimension.getUpperDimension();
|
||||
IrisDimension upperDimension = upperKey.equals(dimension.getLoadKey())
|
||||
? dimension
|
||||
: engine.getData().getDimensionLoader().load(upperKey, false);
|
||||
if (upperDimension == null) {
|
||||
throw new IllegalStateException("Upper dimension '" + upperKey
|
||||
+ "' is absent from the immutable Iris epoch pack.");
|
||||
}
|
||||
UpperDimensionContext context = UpperDimensionContext.create(engine, upperDimension);
|
||||
IrisLogging.info("Upper dimension enabled: " + upperKey
|
||||
+ (context.isSelfReferencing() ? " (self-referencing)" : " (cross-referencing)"));
|
||||
return context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DimensionStackContext createDimensionStackContext(IrisEngine engine) {
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
if (!dimension.hasDimensionStack()) {
|
||||
return null;
|
||||
}
|
||||
if (dimension.hasUpperDimension()) {
|
||||
throw new IllegalStateException("Dimension '" + dimension.getLoadKey()
|
||||
+ "' cannot enable both dimensionStack and upperDimension.");
|
||||
}
|
||||
DimensionStackContext context = DimensionStackContext.create(engine, dimension.getDimensionStack());
|
||||
IrisLogging.info("Dimension stack enabled: "
|
||||
+ String.join(" -> ", dimension.getDimensionStack().getDimensions()));
|
||||
return context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EngineMode createMode(IrisEngine engine) {
|
||||
Throwable configuredFailure = null;
|
||||
try {
|
||||
IrisDimensionMode configuredMode = engine.getDimension().getMode();
|
||||
if (configuredMode == null) {
|
||||
configuredMode = new IrisDimensionMode();
|
||||
engine.getDimension().setMode(configuredMode);
|
||||
}
|
||||
EngineMode configured = configuredMode.create(engine);
|
||||
if (configured == null) {
|
||||
throw new IllegalStateException("Dimension mode factory returned null");
|
||||
}
|
||||
return configured;
|
||||
} catch (Throwable failure) {
|
||||
configuredFailure = failure;
|
||||
IrisLogging.reportError(failure);
|
||||
if (engine.getModeFallbackLogged().compareAndSet(false, true)) {
|
||||
IrisLogging.warn("Failed to initialize configured dimension mode for "
|
||||
+ engine.getDimension().getLoadKey() + ", falling back to OVERWORLD mode.");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
EngineMode fallback = IrisDimensionModeType.OVERWORLD.create(engine);
|
||||
if (fallback == null) {
|
||||
throw new IllegalStateException("OVERWORLD mode factory returned null");
|
||||
}
|
||||
return fallback;
|
||||
} catch (Throwable fallbackFailure) {
|
||||
fallbackFailure.addSuppressed(configuredFailure);
|
||||
throw new IllegalStateException("Both configured and fallback Iris engine modes failed.", fallbackFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStaticObjects(IrisEngine engine, EngineMode mode) {
|
||||
IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData());
|
||||
mode.registerStage((x, z, blocks, biomes, multicore, context) ->
|
||||
staticObjects.apply(engine, x, z, blocks));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerDimensionStack(
|
||||
IrisEngine engine,
|
||||
EngineMode mode,
|
||||
DimensionStackContext context
|
||||
) {
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
IrisDimensionStackActuator dimensionStack = new IrisDimensionStackActuator(engine);
|
||||
mode.registerStage((x, z, blocks, biomes, multicore, chunkContext) ->
|
||||
dimensionStack.actuate(x, z, blocks, multicore, chunkContext));
|
||||
IrisCustomModifier stackCustom = new IrisCustomModifier(engine);
|
||||
mode.registerStage((x, z, blocks, biomes, multicore, chunkContext) ->
|
||||
stackCustom.modify(x, z, blocks, multicore, chunkContext));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.NavigableMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
|
||||
public final class GenerationManifest {
|
||||
public static final int CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
private final int schemaVersion;
|
||||
private final NavigableMap<String, GenerationEpoch> epochs;
|
||||
private final NavigableMap<Long, GenerationActivation> activations;
|
||||
private final long activeActivationId;
|
||||
private final Long pendingActivationId;
|
||||
|
||||
private GenerationManifest(State state) {
|
||||
State requiredState = Objects.requireNonNull(state, "state");
|
||||
this.schemaVersion = requiredState.schemaVersion();
|
||||
this.epochs = immutableEpochs(requiredState.epochs());
|
||||
this.activations = immutableActivations(requiredState.activations());
|
||||
this.activeActivationId = requiredState.activeActivationId();
|
||||
this.pendingActivationId = requiredState.pendingActivationId();
|
||||
validate();
|
||||
}
|
||||
|
||||
public static GenerationManifest initial(GenerationEpoch epoch, long createdAtEpochMillis) {
|
||||
GenerationEpoch requiredEpoch = Objects.requireNonNull(epoch, "epoch");
|
||||
GenerationActivation activation = GenerationActivation.initial(
|
||||
requiredEpoch.epochId(),
|
||||
createdAtEpochMillis
|
||||
);
|
||||
return new GenerationManifest(new State(
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
Map.of(requiredEpoch.epochId(), requiredEpoch),
|
||||
Map.of(activation.activationId(), activation),
|
||||
activation.activationId(),
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
static GenerationManifest restore(State state) {
|
||||
return new GenerationManifest(state);
|
||||
}
|
||||
|
||||
public int schemaVersion() {
|
||||
return schemaVersion;
|
||||
}
|
||||
|
||||
public Collection<GenerationEpoch> epochs() {
|
||||
return epochs.values();
|
||||
}
|
||||
|
||||
public Collection<GenerationActivation> activations() {
|
||||
return activations.values();
|
||||
}
|
||||
|
||||
public Optional<GenerationEpoch> epoch(String epochId) {
|
||||
return Optional.ofNullable(epochs.get(epochId));
|
||||
}
|
||||
|
||||
public Optional<GenerationActivation> activation(long activationId) {
|
||||
return Optional.ofNullable(activations.get(activationId));
|
||||
}
|
||||
|
||||
public GenerationActivation activeActivation() {
|
||||
return activations.get(activeActivationId);
|
||||
}
|
||||
|
||||
public GenerationEpoch activeEpoch() {
|
||||
return epochs.get(activeActivation().epochId());
|
||||
}
|
||||
|
||||
public Optional<GenerationActivation> pendingActivation() {
|
||||
if (pendingActivationId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(activations.get(pendingActivationId));
|
||||
}
|
||||
|
||||
public Optional<GenerationEpoch> pendingEpoch() {
|
||||
return pendingActivation().map(activation -> epochs.get(activation.epochId()));
|
||||
}
|
||||
|
||||
GenerationManifest preparePending(
|
||||
GenerationEpoch epoch,
|
||||
long createdAtEpochMillis,
|
||||
int transitionWidthBlocks
|
||||
) {
|
||||
GenerationEpoch requiredEpoch = Objects.requireNonNull(epoch, "epoch");
|
||||
Optional<GenerationActivation> pending = pendingActivation();
|
||||
if (pending.isPresent()) {
|
||||
if (pending.get().epochId().equals(requiredEpoch.epochId())) {
|
||||
GenerationEpoch storedEpoch = epochs.get(requiredEpoch.epochId());
|
||||
if (!requiredEpoch.equals(storedEpoch)) {
|
||||
throw new IllegalStateException("Pending epoch identity is inconsistent.");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
throw new IllegalStateException("A different generation activation is already pending.");
|
||||
}
|
||||
requireCompatibleDimension(requiredEpoch);
|
||||
|
||||
TreeMap<String, GenerationEpoch> nextEpochs = new TreeMap<>(epochs);
|
||||
GenerationEpoch storedEpoch = nextEpochs.putIfAbsent(requiredEpoch.epochId(), requiredEpoch);
|
||||
if (storedEpoch != null && !storedEpoch.equals(requiredEpoch)) {
|
||||
throw new IllegalStateException("Epoch identity collision detected.");
|
||||
}
|
||||
|
||||
long activationId = Math.addExact(activations.lastKey(), 1L);
|
||||
GenerationActivation activation = GenerationActivation.next(
|
||||
activationId,
|
||||
requiredEpoch.epochId(),
|
||||
activeActivationId,
|
||||
createdAtEpochMillis,
|
||||
transitionWidthBlocks
|
||||
);
|
||||
TreeMap<Long, GenerationActivation> nextActivations = new TreeMap<>(activations);
|
||||
nextActivations.put(activationId, activation);
|
||||
return new GenerationManifest(new State(
|
||||
schemaVersion,
|
||||
nextEpochs,
|
||||
nextActivations,
|
||||
activeActivationId,
|
||||
activationId
|
||||
));
|
||||
}
|
||||
|
||||
GenerationManifest activatePending(long expectedActivationId) {
|
||||
GenerationActivation pending = pendingActivation()
|
||||
.orElseThrow(() -> new IllegalStateException("No generation activation is pending."));
|
||||
if (pending.activationId() != expectedActivationId) {
|
||||
throw new IllegalStateException("Pending activation changed before publication.");
|
||||
}
|
||||
return new GenerationManifest(new State(
|
||||
schemaVersion,
|
||||
epochs,
|
||||
activations,
|
||||
pending.activationId(),
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
GenerationManifest completePendingTransition(
|
||||
long expectedActivationId,
|
||||
String boundaryIdentity,
|
||||
String terrainSignatureIdentity
|
||||
) {
|
||||
GenerationActivation pending = pendingActivation()
|
||||
.orElseThrow(() -> new IllegalStateException("No generation activation is pending."));
|
||||
if (pending.activationId() != expectedActivationId) {
|
||||
throw new IllegalStateException("Pending activation changed before transition publication.");
|
||||
}
|
||||
GenerationActivation completed = pending.completeTransition(
|
||||
boundaryIdentity,
|
||||
terrainSignatureIdentity
|
||||
);
|
||||
if (completed == pending) {
|
||||
return this;
|
||||
}
|
||||
TreeMap<Long, GenerationActivation> nextActivations = new TreeMap<>(activations);
|
||||
nextActivations.put(completed.activationId(), completed);
|
||||
return new GenerationManifest(new State(
|
||||
schemaVersion,
|
||||
epochs,
|
||||
nextActivations,
|
||||
activeActivationId,
|
||||
pendingActivationId
|
||||
));
|
||||
}
|
||||
|
||||
JsonObject toJson() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("schemaVersion", schemaVersion);
|
||||
|
||||
JsonArray epochArray = new JsonArray(epochs.size());
|
||||
for (GenerationEpoch epoch : epochs.values()) {
|
||||
epochArray.add(epoch.toJson());
|
||||
}
|
||||
json.add("epochs", epochArray);
|
||||
|
||||
JsonArray activationArray = new JsonArray(activations.size());
|
||||
for (GenerationActivation activation : activations.values()) {
|
||||
activationArray.add(activation.toJson());
|
||||
}
|
||||
json.add("activations", activationArray);
|
||||
json.addProperty("activeActivationId", activeActivationId);
|
||||
if (pendingActivationId == null) {
|
||||
json.add("pendingActivationId", JsonNull.INSTANCE);
|
||||
} else {
|
||||
json.addProperty("pendingActivationId", pendingActivationId);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
static GenerationManifest fromJson(JsonObject json) {
|
||||
JsonSchema.requireFields(
|
||||
json,
|
||||
"generation manifest",
|
||||
"schemaVersion",
|
||||
"epochs",
|
||||
"activations",
|
||||
"activeActivationId",
|
||||
"pendingActivationId"
|
||||
);
|
||||
int schemaVersion = JsonSchema.requireInt(json, "schemaVersion", "generation manifest");
|
||||
JsonArray epochArray = JsonSchema.requireArray(json, "epochs", "generation manifest");
|
||||
JsonArray activationArray = JsonSchema.requireArray(json, "activations", "generation manifest");
|
||||
long activeActivationId = JsonSchema.requireLong(
|
||||
json,
|
||||
"activeActivationId",
|
||||
"generation manifest"
|
||||
);
|
||||
Long pendingActivationId = JsonSchema.requireNullableLong(
|
||||
json,
|
||||
"pendingActivationId",
|
||||
"generation manifest"
|
||||
);
|
||||
|
||||
TreeMap<String, GenerationEpoch> epochs = new TreeMap<>();
|
||||
for (int index = 0; index < epochArray.size(); index++) {
|
||||
JsonObject epochJson = JsonSchema.requireObject(epochArray.get(index), "epochs[" + index + "]");
|
||||
GenerationEpoch epoch = GenerationEpoch.fromJson(epochJson);
|
||||
if (epochs.putIfAbsent(epoch.epochId(), epoch) != null) {
|
||||
throw new IllegalArgumentException("Generation manifest contains duplicate epoch IDs.");
|
||||
}
|
||||
}
|
||||
|
||||
TreeMap<Long, GenerationActivation> activations = new TreeMap<>();
|
||||
for (int index = 0; index < activationArray.size(); index++) {
|
||||
JsonObject activationJson = JsonSchema.requireObject(
|
||||
activationArray.get(index),
|
||||
"activations[" + index + "]"
|
||||
);
|
||||
GenerationActivation activation = GenerationActivation.fromJson(activationJson);
|
||||
if (activations.putIfAbsent(activation.activationId(), activation) != null) {
|
||||
throw new IllegalArgumentException("Generation manifest contains duplicate activation IDs.");
|
||||
}
|
||||
}
|
||||
|
||||
return restore(new State(
|
||||
schemaVersion,
|
||||
epochs,
|
||||
activations,
|
||||
activeActivationId,
|
||||
pendingActivationId
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof GenerationManifest manifest)) {
|
||||
return false;
|
||||
}
|
||||
return schemaVersion == manifest.schemaVersion
|
||||
&& activeActivationId == manifest.activeActivationId
|
||||
&& epochs.equals(manifest.epochs)
|
||||
&& activations.equals(manifest.activations)
|
||||
&& Objects.equals(pendingActivationId, manifest.pendingActivationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(schemaVersion, epochs, activations, activeActivationId, pendingActivationId);
|
||||
}
|
||||
|
||||
private static NavigableMap<String, GenerationEpoch> immutableEpochs(
|
||||
Map<String, GenerationEpoch> source
|
||||
) {
|
||||
Objects.requireNonNull(source, "epochs");
|
||||
TreeMap<String, GenerationEpoch> copy = new TreeMap<>();
|
||||
for (Map.Entry<String, GenerationEpoch> entry : source.entrySet()) {
|
||||
String epochId = Objects.requireNonNull(entry.getKey(), "epoch ID");
|
||||
GenerationEpoch epoch = Objects.requireNonNull(entry.getValue(), "epoch");
|
||||
if (!epochId.equals(epoch.epochId())) {
|
||||
throw new IllegalArgumentException("Epoch map key does not match the epoch ID.");
|
||||
}
|
||||
copy.put(epochId, epoch);
|
||||
}
|
||||
return Collections.unmodifiableNavigableMap(copy);
|
||||
}
|
||||
|
||||
private static NavigableMap<Long, GenerationActivation> immutableActivations(
|
||||
Map<Long, GenerationActivation> source
|
||||
) {
|
||||
Objects.requireNonNull(source, "activations");
|
||||
TreeMap<Long, GenerationActivation> copy = new TreeMap<>();
|
||||
for (Map.Entry<Long, GenerationActivation> entry : source.entrySet()) {
|
||||
Long activationId = Objects.requireNonNull(entry.getKey(), "activation ID");
|
||||
GenerationActivation activation = Objects.requireNonNull(entry.getValue(), "activation");
|
||||
if (activationId.longValue() != activation.activationId()) {
|
||||
throw new IllegalArgumentException("Activation map key does not match the activation ID.");
|
||||
}
|
||||
copy.put(activationId, activation);
|
||||
}
|
||||
return Collections.unmodifiableNavigableMap(copy);
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
if (schemaVersion != CURRENT_SCHEMA_VERSION) {
|
||||
throw new IllegalArgumentException("Unsupported generation manifest schema: " + schemaVersion);
|
||||
}
|
||||
if (epochs.isEmpty()) {
|
||||
throw new IllegalArgumentException("Generation manifest must contain an epoch.");
|
||||
}
|
||||
if (activations.isEmpty()) {
|
||||
throw new IllegalArgumentException("Generation manifest must contain an activation.");
|
||||
}
|
||||
|
||||
GenerationEpoch.DimensionContract contract = epochs.firstEntry().getValue().dimensionContract();
|
||||
long worldSeed = epochs.firstEntry().getValue().worldSeed();
|
||||
for (GenerationEpoch epoch : epochs.values()) {
|
||||
if (!contract.hasSameLayout(epoch.dimensionContract())) {
|
||||
throw new IllegalArgumentException("Generation epochs use incompatible dimension contracts.");
|
||||
}
|
||||
if (worldSeed != epoch.worldSeed()) {
|
||||
throw new IllegalArgumentException("Generation epochs use different world seeds.");
|
||||
}
|
||||
}
|
||||
|
||||
long expectedActivationId = 1L;
|
||||
Set<String> referencedEpochs = new LinkedHashSet<>();
|
||||
for (GenerationActivation activation : activations.values()) {
|
||||
if (activation.activationId() != expectedActivationId) {
|
||||
throw new IllegalArgumentException("Generation activation IDs must be contiguous and monotonic.");
|
||||
}
|
||||
Long expectedParent = expectedActivationId == 1L ? null : expectedActivationId - 1L;
|
||||
if (!Objects.equals(activation.parentActivationId(), expectedParent)) {
|
||||
throw new IllegalArgumentException("Generation activation history is not a linear chain.");
|
||||
}
|
||||
if (!epochs.containsKey(activation.epochId())) {
|
||||
throw new IllegalArgumentException("Generation activation references a missing epoch.");
|
||||
}
|
||||
referencedEpochs.add(activation.epochId());
|
||||
if (activation.parentActivationId() == null) {
|
||||
if (activation.transition() != null) {
|
||||
throw new IllegalArgumentException("Initial activation must not define a transition.");
|
||||
}
|
||||
} else if (activation.transition() == null) {
|
||||
throw new IllegalArgumentException("Generation activation is missing its transition policy.");
|
||||
}
|
||||
expectedActivationId = Math.addExact(expectedActivationId, 1L);
|
||||
}
|
||||
if (!referencedEpochs.equals(epochs.keySet())) {
|
||||
throw new IllegalArgumentException("Generation manifest contains an unreferenced epoch.");
|
||||
}
|
||||
if (!activations.containsKey(activeActivationId)) {
|
||||
throw new IllegalArgumentException("Active generation activation is missing.");
|
||||
}
|
||||
|
||||
long latestActivationId = activations.lastKey();
|
||||
if (pendingActivationId == null) {
|
||||
if (activeActivationId != latestActivationId) {
|
||||
throw new IllegalArgumentException("Active generation activation is not the latest activation.");
|
||||
}
|
||||
requireCompletedTransitions();
|
||||
return;
|
||||
}
|
||||
if (!activations.containsKey(pendingActivationId)) {
|
||||
throw new IllegalArgumentException("Pending generation activation is missing.");
|
||||
}
|
||||
if (pendingActivationId != latestActivationId) {
|
||||
throw new IllegalArgumentException("Pending generation activation is not the latest activation.");
|
||||
}
|
||||
GenerationActivation pending = activations.get(pendingActivationId);
|
||||
if (!Objects.equals(pending.parentActivationId(), activeActivationId)) {
|
||||
throw new IllegalArgumentException("Pending generation activation does not follow the active activation.");
|
||||
}
|
||||
for (GenerationActivation activation : activations.values()) {
|
||||
if (activation.activationId() != pendingActivationId
|
||||
&& activation.transition() != null
|
||||
&& !activation.transition().isComplete()) {
|
||||
throw new IllegalArgumentException("Published generation transition is incomplete.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireCompletedTransitions() {
|
||||
for (GenerationActivation activation : activations.values()) {
|
||||
if (activation.transition() != null && !activation.transition().isComplete()) {
|
||||
throw new IllegalArgumentException("Published generation transition is incomplete.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireCompatibleDimension(GenerationEpoch epoch) {
|
||||
if (!activeEpoch().dimensionContract().hasSameLayout(epoch.dimensionContract())) {
|
||||
throw new IllegalArgumentException("Generation epoch changes the immutable dimension contract.");
|
||||
}
|
||||
if (activeEpoch().worldSeed() != epoch.worldSeed()) {
|
||||
throw new IllegalArgumentException("Generation epoch changes the immutable world seed.");
|
||||
}
|
||||
}
|
||||
|
||||
record State(
|
||||
int schemaVersion,
|
||||
Map<String, GenerationEpoch> epochs,
|
||||
Map<Long, GenerationActivation> activations,
|
||||
long activeActivationId,
|
||||
Long pendingActivationId
|
||||
) {
|
||||
}
|
||||
|
||||
static final class JsonSchema {
|
||||
private JsonSchema() {
|
||||
}
|
||||
|
||||
static void requireFields(JsonObject json, String context, String... expectedNames) {
|
||||
Objects.requireNonNull(json, context);
|
||||
Set<String> expected = Set.of(expectedNames);
|
||||
Set<String> actual = json.keySet();
|
||||
if (!actual.equals(expected)) {
|
||||
ArrayList<String> missing = new ArrayList<>(expected);
|
||||
missing.removeAll(actual);
|
||||
ArrayList<String> unknown = new ArrayList<>(actual);
|
||||
unknown.removeAll(expected);
|
||||
Collections.sort(missing);
|
||||
Collections.sort(unknown);
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid " + context + " fields. Missing=" + missing + ", unknown=" + unknown + "."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static JsonArray requireArray(JsonObject json, String name, String context) {
|
||||
JsonElement value = json.get(name);
|
||||
if (value == null || !value.isJsonArray()) {
|
||||
throw new IllegalArgumentException(context + "." + name + " must be an array.");
|
||||
}
|
||||
return value.getAsJsonArray();
|
||||
}
|
||||
|
||||
static JsonObject requireObject(JsonObject json, String name, String context) {
|
||||
JsonElement value = json.get(name);
|
||||
if (value == null || !value.isJsonObject()) {
|
||||
throw new IllegalArgumentException(context + "." + name + " must be an object.");
|
||||
}
|
||||
return value.getAsJsonObject();
|
||||
}
|
||||
|
||||
static JsonObject requireNullableObject(JsonObject json, String name, String context) {
|
||||
JsonElement value = json.get(name);
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException(context + "." + name + " is required.");
|
||||
}
|
||||
if (value.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
if (!value.isJsonObject()) {
|
||||
throw new IllegalArgumentException(context + "." + name + " must be an object or null.");
|
||||
}
|
||||
return value.getAsJsonObject();
|
||||
}
|
||||
|
||||
static JsonObject requireObject(JsonElement value, String context) {
|
||||
if (value == null || !value.isJsonObject()) {
|
||||
throw new IllegalArgumentException(context + " must be an object.");
|
||||
}
|
||||
return value.getAsJsonObject();
|
||||
}
|
||||
|
||||
static String requireString(JsonObject json, String name, String context) {
|
||||
JsonPrimitive value = requirePrimitive(json, name, context);
|
||||
if (!value.isString()) {
|
||||
throw new IllegalArgumentException(context + "." + name + " must be a string.");
|
||||
}
|
||||
return value.getAsString();
|
||||
}
|
||||
|
||||
static int requireInt(JsonObject json, String name, String context) {
|
||||
try {
|
||||
return requireNumber(json, name, context).intValueExact();
|
||||
} catch (ArithmeticException exception) {
|
||||
throw new IllegalArgumentException(context + "." + name + " must be an exact integer.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static long requireLong(JsonObject json, String name, String context) {
|
||||
try {
|
||||
return requireNumber(json, name, context).longValueExact();
|
||||
} catch (ArithmeticException exception) {
|
||||
throw new IllegalArgumentException(context + "." + name + " must be an exact long.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static Long requireNullableLong(JsonObject json, String name, String context) {
|
||||
JsonElement value = json.get(name);
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException(context + "." + name + " is required.");
|
||||
}
|
||||
if (value.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
return requireLong(json, name, context);
|
||||
}
|
||||
|
||||
static String requireNullableString(JsonObject json, String name, String context) {
|
||||
JsonElement value = json.get(name);
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException(context + "." + name + " is required.");
|
||||
}
|
||||
if (value.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
|
||||
throw new IllegalArgumentException(context + "." + name + " must be a string or null.");
|
||||
}
|
||||
return value.getAsString();
|
||||
}
|
||||
|
||||
static double requireDouble(JsonObject json, String name, String context) {
|
||||
return requireNumber(json, name, context).doubleValue();
|
||||
}
|
||||
|
||||
static boolean requireBoolean(JsonObject json, String name, String context) {
|
||||
JsonPrimitive value = requirePrimitive(json, name, context);
|
||||
if (!value.isBoolean()) {
|
||||
throw new IllegalArgumentException(context + "." + name + " must be a boolean.");
|
||||
}
|
||||
return value.getAsBoolean();
|
||||
}
|
||||
|
||||
private static BigDecimal requireNumber(JsonObject json, String name, String context) {
|
||||
JsonPrimitive value = requirePrimitive(json, name, context);
|
||||
if (!value.isNumber()) {
|
||||
throw new IllegalArgumentException(context + "." + name + " must be a number.");
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(value.getAsString());
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new IllegalArgumentException(context + "." + name + " is not a valid number.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonPrimitive requirePrimitive(JsonObject json, String name, String context) {
|
||||
JsonElement value = json.get(name);
|
||||
if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) {
|
||||
throw new IllegalArgumentException(context + "." + name + " must be a primitive value.");
|
||||
}
|
||||
return value.getAsJsonPrimitive();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class GenerationPackFingerprint {
|
||||
public static final int CURRENT_VERSION = 1;
|
||||
private static final int BUFFER_BYTES = 64 * 1_024;
|
||||
|
||||
private GenerationPackFingerprint() {
|
||||
}
|
||||
|
||||
public static String compute(Path packRoot, int version) throws IOException {
|
||||
requireSupported(version);
|
||||
return computeVersionOne(packRoot);
|
||||
}
|
||||
|
||||
public static void requireSupported(int version) throws IOException {
|
||||
if (version != 1) {
|
||||
throw new IOException("Unsupported Iris generation pack fingerprint version " + version + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private static String computeVersionOne(Path packRoot) throws IOException {
|
||||
Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize();
|
||||
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(root)) {
|
||||
throw new IOException("Generation pack fingerprint root is missing or unsafe: " + root);
|
||||
}
|
||||
Path realRoot = root.toRealPath(LinkOption.NOFOLLOW_LINKS);
|
||||
List<FingerprintEntry> entries = new ArrayList<>();
|
||||
collectTree(realRoot, entries);
|
||||
entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
|
||||
MessageDigest digest = sha256();
|
||||
byte[] buffer = new byte[BUFFER_BYTES];
|
||||
for (FingerprintEntry entry : entries) {
|
||||
updateEntry(digest, entry.relativePath(), entry.size());
|
||||
long readBytes = 0L;
|
||||
try (InputStream input = Files.newInputStream(
|
||||
entry.source(),
|
||||
StandardOpenOption.READ,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
)) {
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
if (read > 0) {
|
||||
digest.update(buffer, 0, read);
|
||||
readBytes += read;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (readBytes != entry.size()) {
|
||||
throw new IOException("Iris generation pack changed while fingerprinting: "
|
||||
+ entry.source());
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static void collectTree(Path root, List<FingerprintEntry> entries) throws IOException {
|
||||
Files.walkFileTree(root, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(
|
||||
Path directory,
|
||||
BasicFileAttributes attributes
|
||||
) throws IOException {
|
||||
if (attributes.isSymbolicLink() || Files.isSymbolicLink(directory)) {
|
||||
throw new IOException("Generation pack fingerprint rejected symbolic link: " + directory);
|
||||
}
|
||||
if (!directory.equals(root)
|
||||
&& root.relativize(directory).getNameCount() == 1
|
||||
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
|
||||
Path relativePath = root.relativize(file);
|
||||
String fileName = file.getFileName().toString();
|
||||
if ((relativePath.getNameCount() == 1 && PackDirectoryResolver.isHiddenName(fileName))
|
||||
|| fileName.endsWith(".code-workspace")) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
|
||||
throw new IOException("Generation pack fingerprint rejected symbolic link: " + file);
|
||||
}
|
||||
if (!attributes.isRegularFile()) {
|
||||
throw new IOException("Generation pack fingerprint rejected unsupported entry: " + file);
|
||||
}
|
||||
entries.add(new FingerprintEntry(
|
||||
file,
|
||||
relativePath.toString().replace(File.separatorChar, '/'),
|
||||
attributes.size()
|
||||
));
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
|
||||
throw new IOException("Unable to inspect Iris generation pack entry: " + file, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void updateEntry(MessageDigest digest, String relativePath, long size) {
|
||||
byte[] relativeBytes = relativePath.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(relativeBytes.length).array());
|
||||
digest.update(relativeBytes);
|
||||
digest.update(ByteBuffer.allocate(Long.BYTES).putLong(size).array());
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private record FingerprintEntry(Path source, String relativePath, long size) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class GenerationPackRepository {
|
||||
private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}");
|
||||
|
||||
private final Path dimensionRoot;
|
||||
private final Path generationRoot;
|
||||
private final Path epochsRoot;
|
||||
|
||||
public GenerationPackRepository(Path dimensionRoot) {
|
||||
Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot")
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
this.dimensionRoot = root;
|
||||
generationRoot = root.resolve("iris").resolve("generation");
|
||||
epochsRoot = generationRoot.resolve("epochs");
|
||||
}
|
||||
|
||||
public Path generationRoot() {
|
||||
return generationRoot;
|
||||
}
|
||||
|
||||
public Path epochRoot(String epochId) {
|
||||
return epochsRoot.resolve(requireDigest(epochId, "epochId"));
|
||||
}
|
||||
|
||||
public Path packRoot(String epochId) {
|
||||
return epochRoot(epochId).resolve("pack");
|
||||
}
|
||||
|
||||
public synchronized Path publish(
|
||||
String epochId,
|
||||
String packFingerprint,
|
||||
int packFingerprintVersion,
|
||||
Path source
|
||||
) throws IOException {
|
||||
String requiredEpochId = requireDigest(epochId, "epochId");
|
||||
String requiredFingerprint = requireDigest(packFingerprint, "packFingerprint");
|
||||
ensureEpochsRoot();
|
||||
try (GenerationPublicationLock ignored = GenerationPublicationLock.acquire(
|
||||
epochsRoot,
|
||||
".epoch-" + requiredEpochId + ".lock"
|
||||
)) {
|
||||
ensureEpochsRoot();
|
||||
return publishLocked(requiredEpochId, requiredFingerprint, packFingerprintVersion, source);
|
||||
}
|
||||
}
|
||||
|
||||
private Path publishLocked(
|
||||
String epochId,
|
||||
String packFingerprint,
|
||||
int packFingerprintVersion,
|
||||
Path source
|
||||
) throws IOException {
|
||||
Path target = epochsRoot.resolve(epochId);
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
requireSafeDirectory(target, "Generation epoch path is not a safe directory");
|
||||
return requireExactPack(epochId, packFingerprint, packFingerprintVersion);
|
||||
}
|
||||
|
||||
Path stage = Files.createTempDirectory(epochsRoot, ".epoch-" + epochId.substring(0, 12) + "-");
|
||||
boolean published = false;
|
||||
try {
|
||||
Path stagedPack = stage.resolve("pack");
|
||||
copyPackTree(source, stagedPack);
|
||||
requireFingerprint(stagedPack, packFingerprint, packFingerprintVersion);
|
||||
try (AtomicDirectoryPublisher.Publication publication =
|
||||
AtomicDirectoryPublisher.publishAbsent(stage, target)) {
|
||||
publication.commit();
|
||||
} catch (FileAlreadyExistsException race) {
|
||||
requireSafeDirectory(target, "Generation epoch path is not a safe directory");
|
||||
return requireExactPack(epochId, packFingerprint, packFingerprintVersion);
|
||||
}
|
||||
forceDirectory(epochsRoot);
|
||||
published = true;
|
||||
return requireExactPack(epochId, packFingerprint, packFingerprintVersion);
|
||||
} finally {
|
||||
if (!published && Files.exists(stage, LinkOption.NOFOLLOW_LINKS)) {
|
||||
AtomicDirectoryPublisher.deleteTree(stage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Path requireExactPack(
|
||||
String epochId,
|
||||
String packFingerprint,
|
||||
int packFingerprintVersion
|
||||
) throws IOException {
|
||||
String requiredEpochId = requireDigest(epochId, "epochId");
|
||||
validateExistingAncestors(requiredEpochId);
|
||||
Path epoch = epochRoot(requiredEpochId);
|
||||
requireSafeDirectory(epoch, "Generation epoch path is not a safe directory");
|
||||
Path pack = epoch.resolve("pack");
|
||||
requireSafeDirectory(pack, "Generation epoch pack is missing or unsafe");
|
||||
requireFingerprint(
|
||||
pack,
|
||||
requireDigest(packFingerprint, "packFingerprint"),
|
||||
packFingerprintVersion
|
||||
);
|
||||
return pack;
|
||||
}
|
||||
|
||||
private void ensureEpochsRoot() throws IOException {
|
||||
requireSafeDirectory(dimensionRoot, "Dimension root is not a safe directory");
|
||||
Path irisRoot = ensureChildDirectory(dimensionRoot, "iris");
|
||||
Path ensuredGenerationRoot = ensureChildDirectory(irisRoot, "generation");
|
||||
Path ensuredEpochsRoot = ensureChildDirectory(ensuredGenerationRoot, "epochs");
|
||||
if (!ensuredEpochsRoot.equals(epochsRoot)) {
|
||||
throw new IOException("Generation epoch repository resolved outside its storage path: " + ensuredEpochsRoot);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateExistingAncestors(String epochId) throws IOException {
|
||||
requireSafeDirectory(dimensionRoot, "Dimension root is not a safe directory");
|
||||
Path irisRoot = dimensionRoot.resolve("iris");
|
||||
requireSafeDirectory(irisRoot, "Generation epoch ancestor is not a safe directory");
|
||||
requireSafeDirectory(generationRoot, "Generation epoch ancestor is not a safe directory");
|
||||
requireSafeDirectory(epochsRoot, "Generation epoch ancestor is not a safe directory");
|
||||
Path epoch = epochsRoot.resolve(epochId);
|
||||
requireSafeDirectory(epoch, "Generation epoch path is not a safe directory");
|
||||
}
|
||||
|
||||
private static Path ensureChildDirectory(Path parent, String name) throws IOException {
|
||||
Path child = parent.resolve(name);
|
||||
if (Files.exists(child, LinkOption.NOFOLLOW_LINKS)) {
|
||||
requireSafeDirectory(child, "Generation epoch ancestor is not a safe directory");
|
||||
forceDirectory(parent);
|
||||
return child;
|
||||
}
|
||||
try {
|
||||
Files.createDirectory(child);
|
||||
} catch (FileAlreadyExistsException race) {
|
||||
requireSafeDirectory(child, "Generation epoch ancestor is not a safe directory");
|
||||
forceDirectory(parent);
|
||||
return child;
|
||||
}
|
||||
forceDirectory(parent);
|
||||
requireSafeDirectory(child, "Generation epoch ancestor is not a safe directory");
|
||||
return child;
|
||||
}
|
||||
|
||||
private static void requireSafeDirectory(Path directory, String message) throws IOException {
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException(message + ": " + directory);
|
||||
}
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
directory,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
|
||||
throw new IOException(message + ": " + directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
if (File.separatorChar == '\\') {
|
||||
return;
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
} catch (UnsupportedOperationException exception) {
|
||||
throw new IOException("Generation epoch directory cannot be durability-synced: " + directory, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyPackTree(Path source, Path target) throws IOException {
|
||||
Path sourcePath = Objects.requireNonNull(source, "source").toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(sourcePath)) {
|
||||
throw new IOException("Pack source is a symbolic link: " + sourcePath);
|
||||
}
|
||||
Path normalizedSource = sourcePath.toRealPath();
|
||||
if (!Files.isDirectory(normalizedSource, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Pack source is not a directory: " + normalizedSource);
|
||||
}
|
||||
Path normalizedTarget = Objects.requireNonNull(target, "target").toAbsolutePath().normalize();
|
||||
if (Files.exists(normalizedTarget, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(normalizedTarget)) {
|
||||
throw new IOException("Pack publication stage already exists: " + normalizedTarget);
|
||||
}
|
||||
if (normalizedTarget.startsWith(normalizedSource) || normalizedSource.startsWith(normalizedTarget)) {
|
||||
throw new IOException("Pack source and publication stage overlap: "
|
||||
+ normalizedSource + " and " + normalizedTarget);
|
||||
}
|
||||
|
||||
Files.walkFileTree(normalizedSource, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) throws IOException {
|
||||
if (attributes.isSymbolicLink() || Files.isSymbolicLink(directory)) {
|
||||
throw new IOException("Pack contains a symbolic link: " + directory);
|
||||
}
|
||||
if (!directory.equals(normalizedSource)
|
||||
&& normalizedSource.relativize(directory).getNameCount() == 1
|
||||
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
Files.createDirectories(copyDestination(normalizedSource, normalizedTarget, directory));
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
|
||||
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
|
||||
throw new IOException("Pack contains a symbolic link: " + file);
|
||||
}
|
||||
if (!attributes.isRegularFile()) {
|
||||
throw new IOException("Pack contains an unsupported entry: " + file);
|
||||
}
|
||||
Path relative = normalizedSource.relativize(file);
|
||||
String fileName = file.getFileName().toString();
|
||||
if ((relative.getNameCount() == 1 && PackDirectoryResolver.isHiddenName(fileName))
|
||||
|| fileName.endsWith(".code-workspace")) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
Path destination = copyDestination(normalizedSource, normalizedTarget, file);
|
||||
Files.createDirectories(Objects.requireNonNull(destination.getParent(), "Pack entry parent"));
|
||||
Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
try (FileChannel channel = FileChannel.open(destination, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
|
||||
throw new IOException("Unable to copy pack entry: " + file, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Path copyDestination(Path source, Path target, Path entry) throws IOException {
|
||||
Path destination = target.resolve(source.relativize(entry)).normalize();
|
||||
if (!destination.startsWith(target)) {
|
||||
throw new IOException("Pack entry escapes its publication stage: " + entry);
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
|
||||
private static void requireFingerprint(
|
||||
Path pack,
|
||||
String expected,
|
||||
int packFingerprintVersion
|
||||
) throws IOException {
|
||||
String actual = GenerationPackFingerprint.compute(pack, packFingerprintVersion);
|
||||
if (!expected.equals(actual)) {
|
||||
throw new IOException("Generation epoch pack fingerprint mismatch at " + pack
|
||||
+ ": expected " + expected + " but found " + actual);
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireDigest(String value, String field) {
|
||||
String requiredValue = Objects.requireNonNull(value, field);
|
||||
if (!SHA_256.matcher(requiredValue).matches()) {
|
||||
throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest");
|
||||
}
|
||||
return requiredValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
final class GenerationPublicationLock implements AutoCloseable {
|
||||
private static final ConcurrentHashMap<Path, ReentrantLock> PROCESS_LOCKS = new ConcurrentHashMap<>();
|
||||
|
||||
private final ReentrantLock processLock;
|
||||
private final FileChannel channel;
|
||||
private final FileLock fileLock;
|
||||
|
||||
private GenerationPublicationLock(ReentrantLock processLock, FileChannel channel, FileLock fileLock) {
|
||||
this.processLock = processLock;
|
||||
this.channel = channel;
|
||||
this.fileLock = fileLock;
|
||||
}
|
||||
|
||||
static GenerationPublicationLock acquire(Path directory, String fileName) throws IOException {
|
||||
Path requiredDirectory = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize();
|
||||
Path lockPath = requiredDirectory.resolve(Objects.requireNonNull(fileName, "fileName")).normalize();
|
||||
if (!Objects.equals(lockPath.getParent(), requiredDirectory)) {
|
||||
throw new IOException("Generation publication lock escapes its storage directory: " + lockPath);
|
||||
}
|
||||
requireSafeDirectory(requiredDirectory);
|
||||
ReentrantLock processLock = PROCESS_LOCKS.computeIfAbsent(lockPath, ignored -> new ReentrantLock());
|
||||
processLock.lock();
|
||||
FileChannel channel = null;
|
||||
FileLock fileLock = null;
|
||||
try {
|
||||
channel = FileChannel.open(
|
||||
lockPath,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
requireSafeRegularFile(lockPath);
|
||||
fileLock = channel.lock();
|
||||
return new GenerationPublicationLock(processLock, channel, fileLock);
|
||||
} catch (IOException | RuntimeException error) {
|
||||
if (fileLock != null) {
|
||||
fileLock.close();
|
||||
}
|
||||
if (channel != null) {
|
||||
channel.close();
|
||||
}
|
||||
processLock.unlock();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
IOException failure = null;
|
||||
try {
|
||||
fileLock.close();
|
||||
} catch (IOException error) {
|
||||
failure = error;
|
||||
}
|
||||
try {
|
||||
channel.close();
|
||||
} catch (IOException error) {
|
||||
if (failure == null) {
|
||||
failure = error;
|
||||
} else {
|
||||
failure.addSuppressed(error);
|
||||
}
|
||||
} finally {
|
||||
processLock.unlock();
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSafeDirectory(Path directory) throws IOException {
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
directory,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
|
||||
throw new IOException("Generation publication lock directory is unsafe: " + directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSafeRegularFile(Path file) throws IOException {
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
file,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (!attributes.isRegularFile() || attributes.isSymbolicLink()) {
|
||||
throw new IOException("Generation publication lock file is unsafe: " + file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Collections;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.NavigableMap;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class GenerationRegistryContract {
|
||||
public static final int FINGERPRINT_SCHEMA_VERSION_ONE = 1;
|
||||
public static final int CURRENT_FINGERPRINT_SCHEMA = FINGERPRINT_SCHEMA_VERSION_ONE;
|
||||
|
||||
private static final String FINGERPRINT_DOMAIN = "iris-generation-registry-contract";
|
||||
private static final int MAX_DEFINITIONS = 65_535;
|
||||
private static final int MAX_RENDERER_IDENTITY_BYTES = 512;
|
||||
private static final int MAX_GENERATED_SEMANTIC_BYTES = 1024 * 1024;
|
||||
private static final int MAX_GENERATED_SOURCE_BYTES = 1024 * 1024;
|
||||
private static final int MAX_TOTAL_GENERATED_SOURCE_BYTES = 8 * 1024 * 1024;
|
||||
private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
|
||||
private static final Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}");
|
||||
|
||||
private final int fingerprintSchema;
|
||||
private final String fingerprint;
|
||||
private final NavigableMap<PhysicalResourceKey, String> definitions;
|
||||
private final NavigableMap<PhysicalResourceKey, GeneratedSource> generatedSources;
|
||||
|
||||
private GenerationRegistryContract(
|
||||
int fingerprintSchema,
|
||||
String fingerprint,
|
||||
NavigableMap<PhysicalResourceKey, String> definitions,
|
||||
NavigableMap<PhysicalResourceKey, GeneratedSource> generatedSources
|
||||
) {
|
||||
requireSupportedFingerprintSchema(fingerprintSchema);
|
||||
this.fingerprintSchema = fingerprintSchema;
|
||||
this.definitions = immutableDefinitions(definitions);
|
||||
this.generatedSources = immutableGeneratedSources(generatedSources, this.definitions);
|
||||
this.fingerprint = requireSha256(fingerprint, "Registry contract fingerprint");
|
||||
String expectedFingerprint = fingerprint(fingerprintSchema, this.definitions, this.generatedSources);
|
||||
if (!this.fingerprint.equals(expectedFingerprint)) {
|
||||
throw new IllegalArgumentException("Registry contract fingerprint does not match its definitions.");
|
||||
}
|
||||
}
|
||||
|
||||
public static GenerationRegistryContract empty() {
|
||||
return fromDefinitions(CURRENT_FINGERPRINT_SCHEMA, Map.of());
|
||||
}
|
||||
|
||||
public static GenerationRegistryContract fromDefinitions(Map<PhysicalResourceKey, String> definitions) {
|
||||
return fromDefinitions(CURRENT_FINGERPRINT_SCHEMA, definitions);
|
||||
}
|
||||
|
||||
public static GenerationRegistryContract fromDefinitions(
|
||||
int fingerprintSchema,
|
||||
Map<PhysicalResourceKey, String> definitions
|
||||
) {
|
||||
return fromDefinitionsAndGeneratedSources(fingerprintSchema, definitions, Map.of());
|
||||
}
|
||||
|
||||
public static GenerationRegistryContract fromDefinitionsAndGeneratedSources(
|
||||
Map<PhysicalResourceKey, String> definitions,
|
||||
Map<PhysicalResourceKey, GeneratedSource> generatedSources
|
||||
) {
|
||||
return fromDefinitionsAndGeneratedSources(
|
||||
CURRENT_FINGERPRINT_SCHEMA,
|
||||
definitions,
|
||||
generatedSources
|
||||
);
|
||||
}
|
||||
|
||||
public static GenerationRegistryContract fromDefinitionsAndGeneratedSources(
|
||||
int fingerprintSchema,
|
||||
Map<PhysicalResourceKey, String> definitions,
|
||||
Map<PhysicalResourceKey, GeneratedSource> generatedSources
|
||||
) {
|
||||
requireSupportedFingerprintSchema(fingerprintSchema);
|
||||
NavigableMap<PhysicalResourceKey, String> canonicalDefinitions = canonicalizeDefinitions(definitions);
|
||||
NavigableMap<PhysicalResourceKey, GeneratedSource> canonicalSources = canonicalizeGeneratedSources(
|
||||
generatedSources,
|
||||
canonicalDefinitions
|
||||
);
|
||||
return new GenerationRegistryContract(
|
||||
fingerprintSchema,
|
||||
fingerprint(fingerprintSchema, canonicalDefinitions, canonicalSources),
|
||||
canonicalDefinitions,
|
||||
canonicalSources
|
||||
);
|
||||
}
|
||||
|
||||
public int fingerprintSchema() {
|
||||
return fingerprintSchema;
|
||||
}
|
||||
|
||||
public String fingerprint() {
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
public NavigableMap<PhysicalResourceKey, String> definitions() {
|
||||
return definitions;
|
||||
}
|
||||
|
||||
public NavigableMap<PhysicalResourceKey, GeneratedSource> generatedSources() {
|
||||
return generatedSources;
|
||||
}
|
||||
|
||||
public void requireDefinitionsAvailableIn(GenerationRegistryContract available) throws IOException {
|
||||
GenerationRegistryContract requiredAvailable = Objects.requireNonNull(available, "available");
|
||||
for (Map.Entry<PhysicalResourceKey, String> entry : definitions.entrySet()) {
|
||||
String availableFingerprint = requiredAvailable.definitions.get(entry.getKey());
|
||||
if (availableFingerprint == null) {
|
||||
throw new IOException("Missing historical registry definition "
|
||||
+ entry.getKey().registryKey() + " / " + entry.getKey().resourceKey() + ".");
|
||||
}
|
||||
if (!entry.getValue().equals(availableFingerprint)) {
|
||||
throw new IOException("Historical registry definition changed for "
|
||||
+ entry.getKey().registryKey() + " / " + entry.getKey().resourceKey() + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JsonObject toJson() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("fingerprintSchema", fingerprintSchema);
|
||||
json.addProperty("fingerprint", fingerprint);
|
||||
JsonArray definitionArray = new JsonArray();
|
||||
for (Map.Entry<PhysicalResourceKey, String> entry : definitions.entrySet()) {
|
||||
JsonObject definitionJson = new JsonObject();
|
||||
definitionJson.addProperty("registryKey", entry.getKey().registryKey());
|
||||
definitionJson.addProperty("resourceKey", entry.getKey().resourceKey());
|
||||
definitionJson.addProperty("definitionSha256", entry.getValue());
|
||||
definitionArray.add(definitionJson);
|
||||
}
|
||||
json.add("definitions", definitionArray);
|
||||
JsonArray generatedSourceArray = new JsonArray();
|
||||
for (Map.Entry<PhysicalResourceKey, GeneratedSource> entry : generatedSources.entrySet()) {
|
||||
JsonObject sourceJson = new JsonObject();
|
||||
sourceJson.addProperty("registryKey", entry.getKey().registryKey());
|
||||
sourceJson.addProperty("resourceKey", entry.getKey().resourceKey());
|
||||
sourceJson.addProperty("sourceSchema", entry.getValue().sourceSchema());
|
||||
sourceJson.addProperty("semanticSha256", entry.getValue().semanticSha256());
|
||||
sourceJson.addProperty("semanticJson", entry.getValue().semanticJson());
|
||||
sourceJson.addProperty("rendererIdentity", entry.getValue().rendererIdentity());
|
||||
sourceJson.addProperty(
|
||||
"renderedDefinitionSha256",
|
||||
entry.getValue().renderedDefinitionSha256()
|
||||
);
|
||||
sourceJson.addProperty("sourceJson", entry.getValue().sourceJson());
|
||||
generatedSourceArray.add(sourceJson);
|
||||
}
|
||||
json.add("generatedSources", generatedSourceArray);
|
||||
return json;
|
||||
}
|
||||
|
||||
static GenerationRegistryContract fromJson(JsonObject json) {
|
||||
GenerationManifest.JsonSchema.requireFields(
|
||||
json,
|
||||
"registry contract",
|
||||
"fingerprintSchema",
|
||||
"fingerprint",
|
||||
"definitions",
|
||||
"generatedSources"
|
||||
);
|
||||
int fingerprintSchema = GenerationManifest.JsonSchema.requireInt(
|
||||
json,
|
||||
"fingerprintSchema",
|
||||
"registry contract"
|
||||
);
|
||||
String fingerprint = GenerationManifest.JsonSchema.requireString(
|
||||
json,
|
||||
"fingerprint",
|
||||
"registry contract"
|
||||
);
|
||||
JsonArray definitionArray = GenerationManifest.JsonSchema.requireArray(
|
||||
json,
|
||||
"definitions",
|
||||
"registry contract"
|
||||
);
|
||||
NavigableMap<PhysicalResourceKey, String> definitions = parseCanonicalDefinitions(definitionArray);
|
||||
JsonArray generatedSourceArray = GenerationManifest.JsonSchema.requireArray(
|
||||
json,
|
||||
"generatedSources",
|
||||
"registry contract"
|
||||
);
|
||||
NavigableMap<PhysicalResourceKey, GeneratedSource> generatedSources = parseCanonicalGeneratedSources(
|
||||
generatedSourceArray,
|
||||
definitions
|
||||
);
|
||||
return new GenerationRegistryContract(
|
||||
fingerprintSchema,
|
||||
fingerprint,
|
||||
definitions,
|
||||
generatedSources
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof GenerationRegistryContract contract)) {
|
||||
return false;
|
||||
}
|
||||
return fingerprintSchema == contract.fingerprintSchema
|
||||
&& fingerprint.equals(contract.fingerprint)
|
||||
&& definitions.equals(contract.definitions)
|
||||
&& generatedSources.equals(contract.generatedSources);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(fingerprintSchema, fingerprint, definitions, generatedSources);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "GenerationRegistryContract[fingerprintSchema=" + fingerprintSchema
|
||||
+ ", fingerprint=" + fingerprint
|
||||
+ ", definitions=" + definitions
|
||||
+ ", generatedSourceKeys=" + generatedSources.keySet() + "]";
|
||||
}
|
||||
|
||||
public static void requireSupportedFingerprintSchema(int fingerprintSchema) {
|
||||
if (fingerprintSchema != FINGERPRINT_SCHEMA_VERSION_ONE) {
|
||||
throw new IllegalArgumentException(
|
||||
"Unsupported generation registry fingerprint schema " + fingerprintSchema + "."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static NavigableMap<PhysicalResourceKey, String> canonicalizeDefinitions(
|
||||
Map<PhysicalResourceKey, String> definitions
|
||||
) {
|
||||
Map<PhysicalResourceKey, String> requiredDefinitions = Objects.requireNonNull(definitions, "definitions");
|
||||
if (requiredDefinitions.size() > MAX_DEFINITIONS) {
|
||||
throw new IllegalArgumentException("Registry contract contains too many definitions.");
|
||||
}
|
||||
TreeMap<PhysicalResourceKey, String> canonicalDefinitions = new TreeMap<>();
|
||||
for (Map.Entry<PhysicalResourceKey, String> entry : requiredDefinitions.entrySet()) {
|
||||
PhysicalResourceKey key = Objects.requireNonNull(entry.getKey(), "registry definition key");
|
||||
String definitionSha256 = requireSha256(entry.getValue(), "Registry definition fingerprint");
|
||||
canonicalDefinitions.put(key, definitionSha256);
|
||||
}
|
||||
return canonicalDefinitions;
|
||||
}
|
||||
|
||||
private static NavigableMap<PhysicalResourceKey, String> parseCanonicalDefinitions(JsonArray definitionArray) {
|
||||
if (definitionArray.size() > MAX_DEFINITIONS) {
|
||||
throw new IllegalArgumentException("Registry contract contains too many definitions.");
|
||||
}
|
||||
TreeMap<PhysicalResourceKey, String> definitions = new TreeMap<>();
|
||||
PhysicalResourceKey previousKey = null;
|
||||
for (int index = 0; index < definitionArray.size(); index++) {
|
||||
JsonElement element = definitionArray.get(index);
|
||||
JsonObject definitionJson = GenerationManifest.JsonSchema.requireObject(
|
||||
element,
|
||||
"registry contract.definitions[" + index + "]"
|
||||
);
|
||||
GenerationManifest.JsonSchema.requireFields(
|
||||
definitionJson,
|
||||
"registry contract definition",
|
||||
"registryKey",
|
||||
"resourceKey",
|
||||
"definitionSha256"
|
||||
);
|
||||
PhysicalResourceKey key = new PhysicalResourceKey(
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
definitionJson,
|
||||
"registryKey",
|
||||
"registry contract definition"
|
||||
),
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
definitionJson,
|
||||
"resourceKey",
|
||||
"registry contract definition"
|
||||
)
|
||||
);
|
||||
if (previousKey != null && previousKey.compareTo(key) >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Registry contract definitions must be strictly sorted by physical resource key."
|
||||
);
|
||||
}
|
||||
String definitionSha256 = requireSha256(
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
definitionJson,
|
||||
"definitionSha256",
|
||||
"registry contract definition"
|
||||
),
|
||||
"Registry definition fingerprint"
|
||||
);
|
||||
definitions.put(key, definitionSha256);
|
||||
previousKey = key;
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private static NavigableMap<PhysicalResourceKey, String> immutableDefinitions(
|
||||
NavigableMap<PhysicalResourceKey, String> definitions
|
||||
) {
|
||||
NavigableMap<PhysicalResourceKey, String> canonicalDefinitions = canonicalizeDefinitions(definitions);
|
||||
return Collections.unmodifiableNavigableMap(canonicalDefinitions);
|
||||
}
|
||||
|
||||
private static NavigableMap<PhysicalResourceKey, GeneratedSource> canonicalizeGeneratedSources(
|
||||
Map<PhysicalResourceKey, GeneratedSource> generatedSources,
|
||||
NavigableMap<PhysicalResourceKey, String> definitions
|
||||
) {
|
||||
Map<PhysicalResourceKey, GeneratedSource> requiredSources = Objects.requireNonNull(
|
||||
generatedSources,
|
||||
"generatedSources"
|
||||
);
|
||||
if (requiredSources.size() > MAX_DEFINITIONS) {
|
||||
throw new IllegalArgumentException("Registry contract contains too many generated sources.");
|
||||
}
|
||||
TreeMap<PhysicalResourceKey, GeneratedSource> canonicalSources = new TreeMap<>();
|
||||
long totalBytes = 0L;
|
||||
for (Map.Entry<PhysicalResourceKey, GeneratedSource> entry : requiredSources.entrySet()) {
|
||||
PhysicalResourceKey key = Objects.requireNonNull(entry.getKey(), "generated source key");
|
||||
if (!definitions.containsKey(key)) {
|
||||
throw new IllegalArgumentException("Generated registry source has no matching definition: "
|
||||
+ key.registryKey() + " / " + key.resourceKey() + ".");
|
||||
}
|
||||
GeneratedSource source = Objects.requireNonNull(entry.getValue(), "generated source");
|
||||
int sourceBytes = source.sourceJson().getBytes(StandardCharsets.UTF_8).length;
|
||||
if (sourceBytes > MAX_GENERATED_SOURCE_BYTES) {
|
||||
throw new IllegalArgumentException("Generated registry source is too large: "
|
||||
+ key.registryKey() + " / " + key.resourceKey() + ".");
|
||||
}
|
||||
totalBytes += sourceBytes;
|
||||
totalBytes += source.semanticJson().getBytes(StandardCharsets.UTF_8).length;
|
||||
totalBytes += source.rendererIdentity().getBytes(StandardCharsets.UTF_8).length;
|
||||
totalBytes += source.sourceSchema().getBytes(StandardCharsets.UTF_8).length;
|
||||
if (totalBytes > MAX_TOTAL_GENERATED_SOURCE_BYTES) {
|
||||
throw new IllegalArgumentException("Registry contract generated sources are too large.");
|
||||
}
|
||||
canonicalSources.put(key, source);
|
||||
}
|
||||
return canonicalSources;
|
||||
}
|
||||
|
||||
private static NavigableMap<PhysicalResourceKey, GeneratedSource> parseCanonicalGeneratedSources(
|
||||
JsonArray generatedSourceArray,
|
||||
NavigableMap<PhysicalResourceKey, String> definitions
|
||||
) {
|
||||
if (generatedSourceArray.size() > MAX_DEFINITIONS) {
|
||||
throw new IllegalArgumentException("Registry contract contains too many generated sources.");
|
||||
}
|
||||
LinkedHashMap<PhysicalResourceKey, GeneratedSource> sources = new LinkedHashMap<>();
|
||||
PhysicalResourceKey previousKey = null;
|
||||
for (int index = 0; index < generatedSourceArray.size(); index++) {
|
||||
JsonObject sourceJson = GenerationManifest.JsonSchema.requireObject(
|
||||
generatedSourceArray.get(index),
|
||||
"registry contract.generatedSources[" + index + "]"
|
||||
);
|
||||
GenerationManifest.JsonSchema.requireFields(
|
||||
sourceJson,
|
||||
"registry contract generated source",
|
||||
"registryKey",
|
||||
"resourceKey",
|
||||
"sourceSchema",
|
||||
"semanticSha256",
|
||||
"semanticJson",
|
||||
"rendererIdentity",
|
||||
"renderedDefinitionSha256",
|
||||
"sourceJson"
|
||||
);
|
||||
PhysicalResourceKey key = new PhysicalResourceKey(
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
sourceJson,
|
||||
"registryKey",
|
||||
"registry contract generated source"
|
||||
),
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
sourceJson,
|
||||
"resourceKey",
|
||||
"registry contract generated source"
|
||||
)
|
||||
);
|
||||
if (previousKey != null && previousKey.compareTo(key) >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Registry contract generated sources must be strictly sorted by physical resource key."
|
||||
);
|
||||
}
|
||||
sources.put(
|
||||
key,
|
||||
new GeneratedSource(
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
sourceJson,
|
||||
"sourceSchema",
|
||||
"registry contract generated source"
|
||||
),
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
sourceJson,
|
||||
"semanticSha256",
|
||||
"registry contract generated source"
|
||||
),
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
sourceJson,
|
||||
"semanticJson",
|
||||
"registry contract generated source"
|
||||
),
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
sourceJson,
|
||||
"rendererIdentity",
|
||||
"registry contract generated source"
|
||||
),
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
sourceJson,
|
||||
"renderedDefinitionSha256",
|
||||
"registry contract generated source"
|
||||
),
|
||||
GenerationManifest.JsonSchema.requireString(
|
||||
sourceJson,
|
||||
"sourceJson",
|
||||
"registry contract generated source"
|
||||
)
|
||||
)
|
||||
);
|
||||
previousKey = key;
|
||||
}
|
||||
return canonicalizeGeneratedSources(sources, definitions);
|
||||
}
|
||||
|
||||
private static NavigableMap<PhysicalResourceKey, GeneratedSource> immutableGeneratedSources(
|
||||
NavigableMap<PhysicalResourceKey, GeneratedSource> generatedSources,
|
||||
NavigableMap<PhysicalResourceKey, String> definitions
|
||||
) {
|
||||
return Collections.unmodifiableNavigableMap(
|
||||
canonicalizeGeneratedSources(generatedSources, definitions)
|
||||
);
|
||||
}
|
||||
|
||||
private static String fingerprint(
|
||||
int fingerprintSchema,
|
||||
NavigableMap<PhysicalResourceKey, String> definitions,
|
||||
NavigableMap<PhysicalResourceKey, GeneratedSource> generatedSources
|
||||
) {
|
||||
return switch (fingerprintSchema) {
|
||||
case FINGERPRINT_SCHEMA_VERSION_ONE -> fingerprintVersionOne(definitions, generatedSources);
|
||||
default -> throw new IllegalArgumentException(
|
||||
"Unsupported generation registry fingerprint schema " + fingerprintSchema + "."
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private static String fingerprintVersionOne(
|
||||
NavigableMap<PhysicalResourceKey, String> definitions,
|
||||
NavigableMap<PhysicalResourceKey, GeneratedSource> generatedSources
|
||||
) {
|
||||
MessageDigest digest = sha256();
|
||||
updateString(digest, FINGERPRINT_DOMAIN);
|
||||
updateInt(digest, FINGERPRINT_SCHEMA_VERSION_ONE);
|
||||
updateInt(digest, definitions.size());
|
||||
for (Map.Entry<PhysicalResourceKey, String> entry : definitions.entrySet()) {
|
||||
updateString(digest, entry.getKey().registryKey());
|
||||
updateString(digest, entry.getKey().resourceKey());
|
||||
updateString(digest, entry.getValue());
|
||||
}
|
||||
updateInt(digest, generatedSources.size());
|
||||
for (Map.Entry<PhysicalResourceKey, GeneratedSource> entry : generatedSources.entrySet()) {
|
||||
updateString(digest, entry.getKey().registryKey());
|
||||
updateString(digest, entry.getKey().resourceKey());
|
||||
updateString(digest, entry.getValue().sourceSchema());
|
||||
updateString(digest, entry.getValue().semanticSha256());
|
||||
updateString(digest, entry.getValue().semanticJson());
|
||||
updateString(digest, entry.getValue().rendererIdentity());
|
||||
updateString(digest, entry.getValue().renderedDefinitionSha256());
|
||||
updateString(digest, entry.getValue().sourceJson());
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static String requireGeneratedJson(String value, String label, int maximumBytes) {
|
||||
String source = Objects.requireNonNull(value, label);
|
||||
if (source.isBlank()) {
|
||||
throw new IllegalArgumentException(label + " must not be blank.");
|
||||
}
|
||||
if (source.getBytes(StandardCharsets.UTF_8).length > maximumBytes) {
|
||||
throw new IllegalArgumentException(label + " is too large.");
|
||||
}
|
||||
JsonElement parsed;
|
||||
try {
|
||||
parsed = JsonParser.parseString(source);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException(label + " must be valid JSON.", exception);
|
||||
}
|
||||
if (!parsed.isJsonObject()) {
|
||||
throw new IllegalArgumentException(label + " must be a JSON object.");
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
private static String requireGeneratedSemanticJson(String value) {
|
||||
String semantic = requireGeneratedJson(
|
||||
value,
|
||||
"Generated registry semantic JSON",
|
||||
MAX_GENERATED_SEMANTIC_BYTES
|
||||
);
|
||||
return JsonParser.parseString(semantic).toString();
|
||||
}
|
||||
|
||||
private static String requireRendererIdentity(String value) {
|
||||
return requireBoundedIdentity(value, "Generated registry renderer identity");
|
||||
}
|
||||
|
||||
private static String requireBoundedIdentity(String value, String label) {
|
||||
String identity = Objects.requireNonNull(value, label).trim();
|
||||
if (identity.isEmpty()) {
|
||||
throw new IllegalArgumentException(label + " must not be blank.");
|
||||
}
|
||||
if (identity.getBytes(StandardCharsets.UTF_8).length > MAX_RENDERER_IDENTITY_BYTES) {
|
||||
throw new IllegalArgumentException(label + " is too large.");
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
private static String requireSha256(String value, String label) {
|
||||
String requiredValue = Objects.requireNonNull(value, label);
|
||||
if (!SHA_256_PATTERN.matcher(requiredValue).matches()) {
|
||||
throw new IllegalArgumentException(label + " must be a lowercase SHA-256 value.");
|
||||
}
|
||||
return requiredValue;
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateString(MessageDigest digest, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
updateInt(digest, bytes.length);
|
||||
digest.update(bytes);
|
||||
}
|
||||
|
||||
private static void updateInt(MessageDigest digest, int value) {
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array());
|
||||
}
|
||||
|
||||
public record PhysicalResourceKey(String registryKey, String resourceKey)
|
||||
implements Comparable<PhysicalResourceKey> {
|
||||
public PhysicalResourceKey {
|
||||
registryKey = requireResourceKey(registryKey, "Registry key");
|
||||
resourceKey = requireResourceKey(resourceKey, "Resource key");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(PhysicalResourceKey other) {
|
||||
PhysicalResourceKey requiredOther = Objects.requireNonNull(other, "other");
|
||||
int registryComparison = registryKey.compareTo(requiredOther.registryKey);
|
||||
if (registryComparison != 0) {
|
||||
return registryComparison;
|
||||
}
|
||||
return resourceKey.compareTo(requiredOther.resourceKey);
|
||||
}
|
||||
|
||||
private static String requireResourceKey(String value, String label) {
|
||||
String requiredValue = Objects.requireNonNull(value, label);
|
||||
if (!RESOURCE_KEY_PATTERN.matcher(requiredValue).matches()) {
|
||||
throw new IllegalArgumentException(label + " is invalid: " + requiredValue);
|
||||
}
|
||||
return requiredValue;
|
||||
}
|
||||
}
|
||||
|
||||
public record GeneratedSource(
|
||||
String sourceSchema,
|
||||
String semanticSha256,
|
||||
String semanticJson,
|
||||
String rendererIdentity,
|
||||
String renderedDefinitionSha256,
|
||||
String sourceJson
|
||||
) {
|
||||
public GeneratedSource {
|
||||
sourceSchema = requireBoundedIdentity(sourceSchema, "Generated registry source schema");
|
||||
semanticSha256 = requireSha256(semanticSha256, "Generated registry semantic fingerprint");
|
||||
semanticJson = requireGeneratedSemanticJson(semanticJson);
|
||||
rendererIdentity = requireRendererIdentity(rendererIdentity);
|
||||
renderedDefinitionSha256 = requireSha256(
|
||||
renderedDefinitionSha256,
|
||||
"Rendered registry definition fingerprint"
|
||||
);
|
||||
sourceJson = requireGeneratedJson(
|
||||
sourceJson,
|
||||
"Generated registry source JSON",
|
||||
MAX_GENERATED_SOURCE_BYTES
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1742
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,224 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.engine.framework.StructurePlacementMarker;
|
||||
import art.arcane.iris.engine.framework.StructurePlacementScope;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.volmlib.util.mantle.flag.ReservedFlag;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
public final class GenerationSemanticCapture {
|
||||
private static final int CHUNK_SIZE = 16;
|
||||
|
||||
private GenerationSemanticCapture() {
|
||||
}
|
||||
|
||||
public static ChunkGenerationSemantics capture(
|
||||
Engine engine,
|
||||
GenerationHistory.GenerationStage stage
|
||||
) {
|
||||
Engine requiredEngine = Objects.requireNonNull(engine, "engine");
|
||||
GenerationHistory.GenerationStage requiredStage = Objects.requireNonNull(stage, "stage");
|
||||
return capture(
|
||||
requiredEngine,
|
||||
requiredStage.chunkX(),
|
||||
requiredStage.chunkZ(),
|
||||
requiredStage.activation().activationId()
|
||||
);
|
||||
}
|
||||
|
||||
public static ChunkGenerationSemantics capture(
|
||||
Engine engine,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
long activationId
|
||||
) {
|
||||
Engine requiredEngine = Objects.requireNonNull(engine, "engine");
|
||||
ChunkGenerationSemantics.Builder semantics = ChunkGenerationSemantics.builder(
|
||||
chunkX,
|
||||
chunkZ,
|
||||
activationId
|
||||
);
|
||||
captureColumns(requiredEngine, chunkX, chunkZ, semantics);
|
||||
captureMantleFacts(requiredEngine, chunkX, chunkZ, semantics);
|
||||
captureStructures(requiredEngine, chunkX, chunkZ, semantics);
|
||||
return semantics.seal().build();
|
||||
}
|
||||
|
||||
private static void captureColumns(
|
||||
Engine engine,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
ChunkGenerationSemantics.Builder semantics
|
||||
) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
int minimumX = Math.multiplyExact(chunkX, CHUNK_SIZE);
|
||||
int minimumZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
|
||||
for (int localX = 0; localX < CHUNK_SIZE; localX++) {
|
||||
int blockX = minimumX + localX;
|
||||
for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) {
|
||||
int blockZ = minimumZ + localZ;
|
||||
if (complex.allowsNewDiscreteContentAt(blockX, blockZ)) {
|
||||
IrisBiome biome = complex.getTrueBiomeStream().get(blockX, blockZ);
|
||||
if (biome != null && biome.getLoadKey() != null) {
|
||||
semantics.addSurfaceBiome(biome.getLoadKey());
|
||||
}
|
||||
IrisRegion region = complex.getRegionStream().get(blockX, blockZ);
|
||||
if (region != null && region.getLoadKey() != null) {
|
||||
semantics.addRegion(region.getLoadKey());
|
||||
}
|
||||
}
|
||||
if (!hasFullHydrologyWeight(complex, blockX, blockZ)) {
|
||||
continue;
|
||||
}
|
||||
HydrologyColumnSample hydrology = complex.sampleHydrologyColumn(blockX, blockZ);
|
||||
if (hydrology == null) {
|
||||
continue;
|
||||
}
|
||||
for (HydrologyColumnLayer layer : hydrology.layers()) {
|
||||
semantics.addRiverProfile(layer.profileKey());
|
||||
semantics.addRiverFeature(
|
||||
layer.profileKey(),
|
||||
layer.feature().type(),
|
||||
layer.feature().id(),
|
||||
layer.feature().x(),
|
||||
Math.addExact(
|
||||
layer.feature().y(),
|
||||
engine.getDimension().getMinHeight()
|
||||
),
|
||||
layer.feature().z()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void captureMantleFacts(
|
||||
Engine engine,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
ChunkGenerationSemantics.Builder semantics
|
||||
) {
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
int minimumX = Math.multiplyExact(chunkX, CHUNK_SIZE);
|
||||
int minimumZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
|
||||
Set<CavePosition> resolvedCaves = new HashSet<>();
|
||||
mantle.iterateChunk(chunkX, chunkZ, MatterCavern.class, (localX, y, localZ, cavern) -> {
|
||||
captureCave(
|
||||
engine,
|
||||
semantics,
|
||||
resolvedCaves,
|
||||
minimumX + localX,
|
||||
y,
|
||||
minimumZ + localZ,
|
||||
cavern == null ? null : cavern.getCustomBiome()
|
||||
);
|
||||
});
|
||||
mantle.iterateChunk(chunkX, chunkZ, HydrologyCaveCell.class, (localX, y, localZ, cell) -> {
|
||||
int blockX = minimumX + localX;
|
||||
int blockZ = minimumZ + localZ;
|
||||
if (!hasFullHydrologyWeight(engine.getComplex(), blockX, blockZ)) {
|
||||
return;
|
||||
}
|
||||
captureCave(
|
||||
engine,
|
||||
semantics,
|
||||
resolvedCaves,
|
||||
blockX,
|
||||
y,
|
||||
blockZ,
|
||||
cell == null ? null : cell.floodedBiomeKey()
|
||||
);
|
||||
if (cell != null && cell.fluidProfileKey() != null && !cell.fluidProfileKey().isBlank()) {
|
||||
semantics.addRiverProfile(cell.fluidProfileKey());
|
||||
}
|
||||
});
|
||||
mantle.iterateChunk(chunkX, chunkZ, String.class, (localX, y, localZ, marker) -> {
|
||||
StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(marker);
|
||||
if (decoded != null) {
|
||||
semantics.addObject(decoded.objectKey());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean hasFullHydrologyWeight(IrisComplex complex, int blockX, int blockZ) {
|
||||
TransitionGenerationPlan transition = complex.getTransitionGenerationPlan();
|
||||
return transition == null || transition.hydrologyWeightAt(blockX, blockZ) == 1D;
|
||||
}
|
||||
|
||||
private static void captureCave(
|
||||
Engine engine,
|
||||
ChunkGenerationSemantics.Builder semantics,
|
||||
Set<CavePosition> resolvedCaves,
|
||||
int blockX,
|
||||
int y,
|
||||
int blockZ,
|
||||
String explicitBiomeKey
|
||||
) {
|
||||
CavePosition position = new CavePosition(blockX, y, blockZ);
|
||||
if (!resolvedCaves.add(position)) {
|
||||
return;
|
||||
}
|
||||
if (explicitBiomeKey != null && !explicitBiomeKey.isBlank()) {
|
||||
semantics.addCaveBiome(explicitBiomeKey);
|
||||
return;
|
||||
}
|
||||
IrisBiome caveBiome = engine.getCaveBiome(blockX, y, blockZ);
|
||||
if (caveBiome != null && caveBiome.getLoadKey() != null) {
|
||||
semantics.addCaveBiome(caveBiome.getLoadKey());
|
||||
}
|
||||
}
|
||||
|
||||
private static void captureStructures(
|
||||
Engine engine,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
ChunkGenerationSemantics.Builder semantics
|
||||
) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
if (!complex.allowsNewGenerationChunk(chunkX, chunkZ)
|
||||
|| !mantle.hasFlag(chunkX, chunkZ, ReservedFlag.JIGSAW)) {
|
||||
return;
|
||||
}
|
||||
for (IrisStructurePlacement placement : StructurePlacementScope.placementsAt(
|
||||
engine,
|
||||
chunkX,
|
||||
chunkZ)) {
|
||||
if (placement == null || !placement.hasIrisStructures()) {
|
||||
continue;
|
||||
}
|
||||
IrisStructureLocator.ResolvedPlacement resolved = IrisStructureLocator.resolvePlacement(
|
||||
engine,
|
||||
placement,
|
||||
chunkX,
|
||||
chunkZ
|
||||
);
|
||||
if (resolved == null || !IrisStructureLocator.allowsResolvedFootprint(engine, resolved)) {
|
||||
continue;
|
||||
}
|
||||
semantics.addStructure(
|
||||
resolved.structureKey(),
|
||||
resolved.originX(),
|
||||
resolved.baseY(),
|
||||
resolved.originZ()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private record CavePosition(int x, int y, int z) {
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,261 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
|
||||
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.function.IntConsumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class GenerationSemanticQueries {
|
||||
private GenerationSemanticQueries() {
|
||||
}
|
||||
|
||||
public static IrisStructureLocator.LocateResult nearestStructure(
|
||||
Engine engine,
|
||||
String key,
|
||||
int blockX,
|
||||
int blockZ,
|
||||
int maximumChunkRadius
|
||||
) {
|
||||
Engine requiredEngine = Objects.requireNonNull(engine, "engine");
|
||||
String requiredKey = ChunkGenerationSemantics.requireResourceKey(
|
||||
Objects.requireNonNull(key, "key").trim().toLowerCase(Locale.ROOT));
|
||||
int radius = Math.max(0, Math.min(maximumChunkRadius, 2048));
|
||||
GenerationHistory history = history(requiredEngine).orElse(null);
|
||||
if (history == null) {
|
||||
return IrisStructureLocator.locate(requiredEngine, requiredKey, blockX, blockZ, radius);
|
||||
}
|
||||
IrisStructureLocator.LocateResult active = IrisStructureLocator.locate(
|
||||
requiredEngine, requiredKey, blockX, blockZ, radius,
|
||||
(chunkX, chunkZ) -> allowsActivePrediction(requiredEngine, chunkX, chunkZ));
|
||||
if (active.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) {
|
||||
return active;
|
||||
}
|
||||
GenerationSemanticIndex.Match recorded = history.findRecorded(
|
||||
GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.STRUCTURE,
|
||||
requiredKey,
|
||||
new ChunkGenerationSemantics.BlockPosition(blockX, 0, blockZ),
|
||||
radius
|
||||
)
|
||||
).orElse(null);
|
||||
if (recorded == null) {
|
||||
return active;
|
||||
}
|
||||
ChunkGenerationSemantics.BlockPosition position = recorded.exactPosition().orElseThrow();
|
||||
if (active.found() && horizontalDistanceSquared(blockX, blockZ, active.originX(), active.originZ())
|
||||
.compareTo(horizontalDistanceSquared(blockX, blockZ, position.x(), position.z())) < 0) {
|
||||
return active;
|
||||
}
|
||||
return new IrisStructureLocator.LocateResult(
|
||||
IrisStructureLocator.LocateStatus.FOUND, position.x(), position.y(), position.z());
|
||||
}
|
||||
|
||||
public static Optional<RiverResult> nearestRiver(
|
||||
Engine engine,
|
||||
HydrologyFeatureQuery query,
|
||||
int blockX,
|
||||
int blockZ,
|
||||
int maximumDistance,
|
||||
IntConsumer progress
|
||||
) {
|
||||
Engine requiredEngine = Objects.requireNonNull(engine, "engine");
|
||||
HydrologyFeatureQuery requiredQuery = Objects.requireNonNull(query, "query");
|
||||
IntConsumer requiredProgress = Objects.requireNonNull(progress, "progress");
|
||||
if (maximumDistance < 0) {
|
||||
throw new IllegalArgumentException("Maximum river search distance cannot be negative.");
|
||||
}
|
||||
GenerationHistory history = history(requiredEngine).orElse(null);
|
||||
RiverResult recorded = recordedRiver(
|
||||
history,
|
||||
requiredQuery,
|
||||
blockX,
|
||||
blockZ,
|
||||
maximumDistance
|
||||
);
|
||||
RiverResult active = activeRiver(
|
||||
requiredEngine,
|
||||
history,
|
||||
requiredQuery,
|
||||
blockX,
|
||||
blockZ,
|
||||
maximumDistance,
|
||||
requiredProgress
|
||||
);
|
||||
if (recorded == null) {
|
||||
return Optional.ofNullable(active);
|
||||
}
|
||||
if (active == null) {
|
||||
return Optional.of(recorded);
|
||||
}
|
||||
int comparison = horizontalDistanceSquared(blockX, blockZ, recorded.x(), recorded.z())
|
||||
.compareTo(horizontalDistanceSquared(blockX, blockZ, active.x(), active.z()));
|
||||
return Optional.of(comparison <= 0 ? recorded : active);
|
||||
}
|
||||
|
||||
public static boolean allowsActivePrediction(Engine engine, int chunkX, int chunkZ) {
|
||||
Engine requiredEngine = Objects.requireNonNull(engine, "engine");
|
||||
if (!requiredEngine.getComplex().allowsNewGenerationChunk(chunkX, chunkZ)) {
|
||||
return false;
|
||||
}
|
||||
GenerationHistory history = history(requiredEngine).orElse(null);
|
||||
if (history == null) {
|
||||
return true;
|
||||
}
|
||||
return history.isActiveUnowned(chunkX, chunkZ)
|
||||
&& history.semantics(chunkX, chunkZ)
|
||||
.filter(ChunkGenerationSemantics::sealed)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
private static RiverResult recordedRiver(
|
||||
GenerationHistory history,
|
||||
HydrologyFeatureQuery query,
|
||||
int blockX,
|
||||
int blockZ,
|
||||
int maximumDistance
|
||||
) {
|
||||
if (history == null) {
|
||||
return null;
|
||||
}
|
||||
int maxChunkRadius = Math.addExact(Math.floorDiv(maximumDistance, 16), 1);
|
||||
GenerationSemanticIndex.RiverMatch match = history.findRecordedRiver(
|
||||
GenerationSemanticIndex.RiverQuery.acrossActivations(
|
||||
query.types(),
|
||||
query.profileKey(),
|
||||
new ChunkGenerationSemantics.BlockPosition(blockX, 0, blockZ),
|
||||
maxChunkRadius
|
||||
)
|
||||
).orElse(null);
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
ChunkGenerationSemantics.RiverFeatureOccurrence occurrence = match.occurrence();
|
||||
ChunkGenerationSemantics.BlockPosition position = occurrence.position();
|
||||
BigInteger maximumDistanceSquared = BigInteger.valueOf(maximumDistance)
|
||||
.multiply(BigInteger.valueOf(maximumDistance));
|
||||
if (horizontalDistanceSquared(blockX, blockZ, position.x(), position.z())
|
||||
.compareTo(maximumDistanceSquared) > 0) {
|
||||
return null;
|
||||
}
|
||||
return new RiverResult(
|
||||
RiverSource.RECORDED,
|
||||
occurrence.type(),
|
||||
Optional.of(occurrence.profileKey()),
|
||||
occurrence.featureId(),
|
||||
position.x(),
|
||||
position.y(),
|
||||
position.z(),
|
||||
OptionalLong.of(match.chunk().activationId())
|
||||
);
|
||||
}
|
||||
|
||||
private static RiverResult activeRiver(
|
||||
Engine engine,
|
||||
GenerationHistory history,
|
||||
HydrologyFeatureQuery query,
|
||||
int blockX,
|
||||
int blockZ,
|
||||
int maximumDistance,
|
||||
IntConsumer progress
|
||||
) {
|
||||
IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime();
|
||||
if (runtime == null) {
|
||||
return null;
|
||||
}
|
||||
Predicate<HydrologyFeatureRef> eligibility = feature -> allowsActiveHydrologyPrediction(
|
||||
engine,
|
||||
feature.x(),
|
||||
feature.z()
|
||||
);
|
||||
HydrologyFeatureRef feature = runtime.nearestFeature(
|
||||
query.types(),
|
||||
query.profileKey(),
|
||||
blockX,
|
||||
blockZ,
|
||||
maximumDistance,
|
||||
eligibility,
|
||||
progress
|
||||
).orElse(null);
|
||||
if (feature == null) {
|
||||
return null;
|
||||
}
|
||||
OptionalLong activationId = history == null
|
||||
? OptionalLong.empty()
|
||||
: OptionalLong.of(history.activeActivation().activationId());
|
||||
return new RiverResult(
|
||||
RiverSource.ACTIVE_PREDICTION,
|
||||
feature.type(),
|
||||
Optional.ofNullable(query.profileKey()),
|
||||
feature.id(),
|
||||
feature.x(),
|
||||
Math.addExact(feature.y(), engine.getDimension().getMinHeight()),
|
||||
feature.z(),
|
||||
activationId
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean allowsActiveHydrologyPrediction(Engine engine, int blockX, int blockZ) {
|
||||
TransitionGenerationPlan transition = engine.getComplex().getTransitionGenerationPlan();
|
||||
if (transition != null && transition.hydrologyWeightAt(blockX, blockZ) != 1D) {
|
||||
return false;
|
||||
}
|
||||
return allowsActivePrediction(
|
||||
engine,
|
||||
Math.floorDiv(blockX, 16),
|
||||
Math.floorDiv(blockZ, 16)
|
||||
);
|
||||
}
|
||||
|
||||
private static Optional<GenerationHistory> history(Engine engine) {
|
||||
if (!(engine instanceof IrisEngine irisEngine)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return irisEngine.getGenerationHistoryRuntimeRouter()
|
||||
.map(GenerationHistoryRuntimeRouter::history);
|
||||
}
|
||||
|
||||
private static BigInteger horizontalDistanceSquared(
|
||||
int firstX,
|
||||
int firstZ,
|
||||
int secondX,
|
||||
int secondZ
|
||||
) {
|
||||
BigInteger deltaX = BigInteger.valueOf((long) firstX - secondX);
|
||||
BigInteger deltaZ = BigInteger.valueOf((long) firstZ - secondZ);
|
||||
return deltaX.multiply(deltaX).add(deltaZ.multiply(deltaZ));
|
||||
}
|
||||
|
||||
public enum RiverSource {
|
||||
RECORDED,
|
||||
ACTIVE_PREDICTION
|
||||
}
|
||||
|
||||
public record RiverResult(
|
||||
RiverSource source,
|
||||
HydrologyFeatureType type,
|
||||
Optional<String> profileKey,
|
||||
long featureId,
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
OptionalLong activationId
|
||||
) {
|
||||
public RiverResult {
|
||||
source = Objects.requireNonNull(source, "source");
|
||||
type = Objects.requireNonNull(type, "type");
|
||||
profileKey = Objects.requireNonNull(profileKey, "profileKey");
|
||||
activationId = Objects.requireNonNull(activationId, "activationId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public record GenerationTransition(
|
||||
int algorithmVersion,
|
||||
int widthBlocks,
|
||||
String boundaryIdentity,
|
||||
String terrainSignatureIdentity
|
||||
) {
|
||||
public static final int CURRENT_ALGORITHM_VERSION = 1;
|
||||
public static final int MINIMUM_WIDTH_BLOCKS = 16;
|
||||
public static final int MAXIMUM_WIDTH_BLOCKS = 16_384;
|
||||
|
||||
private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}");
|
||||
|
||||
public GenerationTransition {
|
||||
if (algorithmVersion < 1) {
|
||||
throw new IllegalArgumentException("Transition algorithm version must be positive.");
|
||||
}
|
||||
if (widthBlocks < MINIMUM_WIDTH_BLOCKS || widthBlocks > MAXIMUM_WIDTH_BLOCKS) {
|
||||
throw new IllegalArgumentException("Transition width must be between "
|
||||
+ MINIMUM_WIDTH_BLOCKS + " and " + MAXIMUM_WIDTH_BLOCKS + " blocks.");
|
||||
}
|
||||
if ((boundaryIdentity == null) != (terrainSignatureIdentity == null)) {
|
||||
throw new IllegalArgumentException("Transition snapshot identities must be completed together.");
|
||||
}
|
||||
if (boundaryIdentity != null) {
|
||||
boundaryIdentity = requireIdentity(boundaryIdentity, "Boundary identity");
|
||||
terrainSignatureIdentity = requireIdentity(
|
||||
terrainSignatureIdentity,
|
||||
"Terrain signature identity"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static GenerationTransition pending(int widthBlocks) {
|
||||
return new GenerationTransition(CURRENT_ALGORITHM_VERSION, widthBlocks, null, null);
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return boundaryIdentity != null;
|
||||
}
|
||||
|
||||
public GenerationTransition complete(String boundaryIdentity, String terrainSignatureIdentity) {
|
||||
if (isComplete()) {
|
||||
if (this.boundaryIdentity.equals(boundaryIdentity)
|
||||
&& this.terrainSignatureIdentity.equals(terrainSignatureIdentity)) {
|
||||
return this;
|
||||
}
|
||||
throw new IllegalStateException("Generation transition snapshots are already immutable.");
|
||||
}
|
||||
return new GenerationTransition(
|
||||
algorithmVersion,
|
||||
widthBlocks,
|
||||
boundaryIdentity,
|
||||
terrainSignatureIdentity
|
||||
);
|
||||
}
|
||||
|
||||
JsonObject toJson() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("algorithmVersion", algorithmVersion);
|
||||
json.addProperty("widthBlocks", widthBlocks);
|
||||
if (boundaryIdentity == null) {
|
||||
json.add("boundaryIdentity", JsonNull.INSTANCE);
|
||||
json.add("terrainSignatureIdentity", JsonNull.INSTANCE);
|
||||
} else {
|
||||
json.addProperty("boundaryIdentity", boundaryIdentity);
|
||||
json.addProperty("terrainSignatureIdentity", terrainSignatureIdentity);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
static GenerationTransition fromJson(JsonObject json) {
|
||||
GenerationManifest.JsonSchema.requireFields(
|
||||
json,
|
||||
"generation transition",
|
||||
"algorithmVersion",
|
||||
"widthBlocks",
|
||||
"boundaryIdentity",
|
||||
"terrainSignatureIdentity"
|
||||
);
|
||||
int algorithmVersion = GenerationManifest.JsonSchema.requireInt(
|
||||
json,
|
||||
"algorithmVersion",
|
||||
"generation transition"
|
||||
);
|
||||
int widthBlocks = GenerationManifest.JsonSchema.requireInt(
|
||||
json,
|
||||
"widthBlocks",
|
||||
"generation transition"
|
||||
);
|
||||
String boundaryIdentity = GenerationManifest.JsonSchema.requireNullableString(
|
||||
json,
|
||||
"boundaryIdentity",
|
||||
"generation transition"
|
||||
);
|
||||
String terrainSignatureIdentity = GenerationManifest.JsonSchema.requireNullableString(
|
||||
json,
|
||||
"terrainSignatureIdentity",
|
||||
"generation transition"
|
||||
);
|
||||
return new GenerationTransition(
|
||||
algorithmVersion,
|
||||
widthBlocks,
|
||||
boundaryIdentity,
|
||||
terrainSignatureIdentity
|
||||
);
|
||||
}
|
||||
|
||||
private static String requireIdentity(String value, String label) {
|
||||
String requiredValue = Objects.requireNonNull(value, label);
|
||||
if (!SHA_256.matcher(requiredValue).matches()) {
|
||||
throw new IllegalArgumentException(label + " must be a lowercase SHA-256 value.");
|
||||
}
|
||||
return requiredValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.DimensionStackContext;
|
||||
import art.arcane.iris.engine.DimensionStackLayout;
|
||||
import art.arcane.iris.engine.UpperDimensionContext;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
public final class IrisBoundarySignatureSampler implements GenerationBoundarySignatureSampler {
|
||||
public static final IrisBoundarySignatureSampler INSTANCE = new IrisBoundarySignatureSampler();
|
||||
|
||||
static final int VERTICAL_SAMPLE_STEP = 4;
|
||||
private IrisBoundarySignatureSampler() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public TerrainBoundarySignature sample(IrisEngine engine, int blockX, int blockZ) throws IOException {
|
||||
IrisEngine requiredEngine = Objects.requireNonNull(engine, "Iris engine");
|
||||
try {
|
||||
IrisComplex complex = Objects.requireNonNull(requiredEngine.getComplex(), "Iris complex");
|
||||
int height = requiredEngine.getHeight();
|
||||
if (height <= 0) {
|
||||
throw new IOException("Iris boundary sampling requires a positive world height.");
|
||||
}
|
||||
|
||||
int minimumY = requiredEngine.getMinHeight();
|
||||
int terrainHeight = clampHeight(
|
||||
(int) Math.round(complex.getHeightStream().getDouble(blockX, blockZ)),
|
||||
height
|
||||
);
|
||||
int fluidHeight = clampHeight(
|
||||
(int) Math.round(complex.getRiverWaterSurfaceStream().getDouble(blockX, blockZ)),
|
||||
height
|
||||
);
|
||||
OptionalInt fluid = fluidHeight > terrainHeight
|
||||
? OptionalInt.of(fluidHeight)
|
||||
: OptionalInt.empty();
|
||||
int sampleCount = Math.ceilDiv(height, VERTICAL_SAMPLE_STEP);
|
||||
TerrainBoundarySignature.VerticalLayout layout = new TerrainBoundarySignature.VerticalLayout(
|
||||
minimumY,
|
||||
VERTICAL_SAMPLE_STEP,
|
||||
sampleCount
|
||||
);
|
||||
short[] biomeIndices = new short[sampleCount];
|
||||
LinkedHashMap<String, Short> paletteIndices = new LinkedHashMap<>();
|
||||
|
||||
for (int sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++) {
|
||||
int worldY = layout.sampleY(sampleIndex);
|
||||
int internalY = worldY - minimumY;
|
||||
String biomeKey = physicalBiomeKey(
|
||||
requiredEngine,
|
||||
complex,
|
||||
blockX,
|
||||
worldY,
|
||||
blockZ,
|
||||
internalY,
|
||||
terrainHeight
|
||||
);
|
||||
biomeIndices[sampleIndex] = paletteIndex(paletteIndices, biomeKey);
|
||||
}
|
||||
|
||||
int visibleSurfaceHeight = Math.max(terrainHeight, fluidHeight);
|
||||
UpperDimensionContext upperContext = requiredEngine.getUpperContext();
|
||||
int upperDepth = upperContext == null ? 0
|
||||
: height - 1 - upperContext.getEffectiveSurfaceY(blockX, blockZ);
|
||||
return new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(
|
||||
blockX,
|
||||
blockZ,
|
||||
visibleSurfaceHeight,
|
||||
terrainHeight,
|
||||
fluid,
|
||||
upperDepth > 0 ? OptionalInt.of(upperDepth) : OptionalInt.empty()
|
||||
),
|
||||
new TerrainBoundarySignature.Samples(
|
||||
layout,
|
||||
new TerrainBoundarySignature.BiomeEncoding(
|
||||
List.copyOf(paletteIndices.keySet()),
|
||||
biomeIndices
|
||||
)
|
||||
)
|
||||
);
|
||||
} catch (IOException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IOException("Unable to sample Iris terrain boundary at "
|
||||
+ blockX + "," + blockZ + ".", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static int clampHeight(int value, int height) {
|
||||
return Math.max(0, Math.min(height - 1, value));
|
||||
}
|
||||
|
||||
private static short paletteIndex(Map<String, Short> indices, String biomeKey) {
|
||||
Short existing = indices.get(biomeKey);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
int next = indices.size();
|
||||
if (next > Short.MAX_VALUE) {
|
||||
throw new IllegalStateException("Iris boundary biome palette exceeds compact index capacity.");
|
||||
}
|
||||
short index = (short) next;
|
||||
indices.put(biomeKey, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
private static String physicalBiomeKey(
|
||||
IrisEngine engine,
|
||||
IrisComplex complex,
|
||||
int blockX,
|
||||
int worldY,
|
||||
int blockZ,
|
||||
int internalY,
|
||||
int terrainHeight
|
||||
) {
|
||||
String historicalKey = complex.historicalPhysicalBiomeKeyAt(blockX, worldY, blockZ).orElse(null);
|
||||
if (historicalKey != null) {
|
||||
return historicalKey;
|
||||
}
|
||||
int caveSwitchY = Math.max(-8 - engine.getMinHeight(), 40);
|
||||
boolean underground = internalY <= caveSwitchY && internalY <= terrainHeight - 8;
|
||||
IrisDimension biomeDimension = engine.getDimension();
|
||||
IrisBiome biome;
|
||||
DimensionStackContext stackContext = engine.getDimensionStackContext();
|
||||
if (stackContext == null) {
|
||||
biome = underground
|
||||
? engine.getCaveBiome(blockX, internalY, blockZ)
|
||||
: complex.getTrueBiomeStream().get(blockX, blockZ);
|
||||
} else {
|
||||
DimensionStackLayout.Layer layer = stackContext.getLayout(blockX, blockZ).layerAt(internalY);
|
||||
biome = layer.biome();
|
||||
biomeDimension = layer.terrainContext().getDimension();
|
||||
underground = false;
|
||||
}
|
||||
if (biome == null && underground) {
|
||||
biome = complex.getTrueBiomeStream().get(blockX, blockZ);
|
||||
}
|
||||
if (biome == null) {
|
||||
return "minecraft:plains";
|
||||
}
|
||||
|
||||
RNG rng = new RNG(engine.getWorld().getRawWorldSeed()
|
||||
^ ((long) blockX * 341873128712L)
|
||||
^ ((long) worldY * 132897987541L)
|
||||
^ ((long) blockZ * 42317861L));
|
||||
if (biome.isCustom()) {
|
||||
IrisBiomeCustom custom = biome.getCustomBiome(rng, engine, blockX, worldY, blockZ);
|
||||
return custom == null
|
||||
? "minecraft:plains"
|
||||
: engine.getData().customBiomeResourceKey(biomeDimension, custom);
|
||||
}
|
||||
String key = underground
|
||||
? biome.getGroundBiomeKey(rng, engine, blockX, worldY, blockZ)
|
||||
: biome.getSkyBiomeKey(rng, engine, blockX, worldY, blockZ);
|
||||
return key == null || key.isBlank() ? "minecraft:plains" : key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.EOFException;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
final class RegionGenerationOwnership {
|
||||
static final int CHUNKS_PER_SIDE = 32;
|
||||
static final int CHUNK_COUNT = CHUNKS_PER_SIDE * CHUNKS_PER_SIDE;
|
||||
static final String FILE_SUFFIX = ".irow";
|
||||
|
||||
private static final int MAGIC = 0x49524F57;
|
||||
private static final int FORMAT_VERSION = 1;
|
||||
private static final int FIXED_BODY_BYTES = 32;
|
||||
private static final int CHECKSUM_BYTES = Integer.BYTES;
|
||||
private static final int MAX_FILE_BYTES = 16_384;
|
||||
private static final int MINIMUM_REGION_COORDINATE = Math.floorDiv(Integer.MIN_VALUE, CHUNKS_PER_SIDE);
|
||||
private static final int MAXIMUM_REGION_COORDINATE = Math.floorDiv(Integer.MAX_VALUE, CHUNKS_PER_SIDE);
|
||||
|
||||
private final int regionX;
|
||||
private final int regionZ;
|
||||
private final long[] activations;
|
||||
private int assignmentCount;
|
||||
private boolean dirty;
|
||||
|
||||
RegionGenerationOwnership(int regionX, int regionZ) {
|
||||
this.regionX = regionX;
|
||||
this.regionZ = regionZ;
|
||||
this.activations = new long[CHUNK_COUNT];
|
||||
}
|
||||
|
||||
static Metadata readMetadata(Path file) throws IOException {
|
||||
byte[] encoded = readEncoded(file);
|
||||
validateChecksum(file, encoded);
|
||||
int bodyLength = encoded.length - CHECKSUM_BYTES;
|
||||
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded, 0, bodyLength))) {
|
||||
int magic = input.readInt();
|
||||
if (magic != MAGIC) {
|
||||
throw invalid(file, "invalid magic");
|
||||
}
|
||||
int version = input.readUnsignedShort();
|
||||
if (version != FORMAT_VERSION) {
|
||||
throw invalid(file, "unsupported format version " + version);
|
||||
}
|
||||
int flags = input.readUnsignedShort();
|
||||
if (flags != 0) {
|
||||
throw invalid(file, "unsupported format flags " + flags);
|
||||
}
|
||||
int regionX = input.readInt();
|
||||
int regionZ = input.readInt();
|
||||
validateRegionCoordinate(file, regionX);
|
||||
validateRegionCoordinate(file, regionZ);
|
||||
int assignmentCount = input.readInt();
|
||||
int paletteSize = input.readInt();
|
||||
int bitsPerEntry = input.readInt();
|
||||
int wordCount = input.readInt();
|
||||
validateHeader(file, assignmentCount, paletteSize, bitsPerEntry, wordCount);
|
||||
long expectedLength = FIXED_BODY_BYTES
|
||||
+ (long) paletteSize * Long.BYTES
|
||||
+ (long) wordCount * Long.BYTES
|
||||
+ CHECKSUM_BYTES;
|
||||
if (encoded.length != expectedLength) {
|
||||
throw invalid(file, "length does not match the ownership header");
|
||||
}
|
||||
return new Metadata(regionX, regionZ, assignmentCount);
|
||||
} catch (EOFException error) {
|
||||
throw invalid(file, "truncated data", error);
|
||||
}
|
||||
}
|
||||
|
||||
static RegionGenerationOwnership read(Path file) throws IOException {
|
||||
byte[] encoded = readEncoded(file);
|
||||
validateChecksum(file, encoded);
|
||||
int bodyLength = encoded.length - CHECKSUM_BYTES;
|
||||
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded, 0, bodyLength))) {
|
||||
int magic = input.readInt();
|
||||
if (magic != MAGIC) {
|
||||
throw invalid(file, "invalid magic");
|
||||
}
|
||||
int version = input.readUnsignedShort();
|
||||
if (version != FORMAT_VERSION) {
|
||||
throw invalid(file, "unsupported format version " + version);
|
||||
}
|
||||
int flags = input.readUnsignedShort();
|
||||
if (flags != 0) {
|
||||
throw invalid(file, "unsupported format flags " + flags);
|
||||
}
|
||||
|
||||
int regionX = input.readInt();
|
||||
int regionZ = input.readInt();
|
||||
validateRegionCoordinate(file, regionX);
|
||||
validateRegionCoordinate(file, regionZ);
|
||||
int assignmentCount = input.readInt();
|
||||
int paletteSize = input.readInt();
|
||||
int bitsPerEntry = input.readInt();
|
||||
int wordCount = input.readInt();
|
||||
validateHeader(file, assignmentCount, paletteSize, bitsPerEntry, wordCount);
|
||||
|
||||
long[] palette = readPalette(file, input, paletteSize);
|
||||
long[] packed = readPacked(input, wordCount);
|
||||
if (input.available() != 0) {
|
||||
throw invalid(file, "unexpected trailing data");
|
||||
}
|
||||
validateUnusedBits(file, packed, bitsPerEntry);
|
||||
|
||||
RegionGenerationOwnership ownership = new RegionGenerationOwnership(regionX, regionZ);
|
||||
int decodedAssignments = ownership.decode(file, palette, packed, bitsPerEntry);
|
||||
if (decodedAssignments != assignmentCount) {
|
||||
throw invalid(file, "assignment count mismatch");
|
||||
}
|
||||
ownership.assignmentCount = decodedAssignments;
|
||||
return ownership;
|
||||
} catch (EOFException error) {
|
||||
throw invalid(file, "truncated data", error);
|
||||
}
|
||||
}
|
||||
|
||||
int regionX() {
|
||||
return regionX;
|
||||
}
|
||||
|
||||
int regionZ() {
|
||||
return regionZ;
|
||||
}
|
||||
|
||||
int assignmentCount() {
|
||||
synchronized (this) {
|
||||
return assignmentCount;
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasAssignment(int chunkX, int chunkZ) {
|
||||
return activations[chunkIndex(chunkX, chunkZ)] != 0L;
|
||||
}
|
||||
|
||||
boolean anyMatchingInBounds(int minimumX, int minimumZ, int maximumX, int maximumZ,
|
||||
ChunkGenerationOwnership.AssignmentPredicate predicate) {
|
||||
int baseX = regionX << 5;
|
||||
int baseZ = regionZ << 5;
|
||||
int minimumLocalX = Math.max(minimumX, baseX) & 31;
|
||||
int maximumLocalX = Math.min(maximumX, baseX + 31) & 31;
|
||||
int minimumLocalZ = Math.max(minimumZ, baseZ) & 31;
|
||||
int maximumLocalZ = Math.min(maximumZ, baseZ + 31) & 31;
|
||||
for (int z = minimumLocalZ; z <= maximumLocalZ; z++) {
|
||||
for (int x = minimumLocalX; x <= maximumLocalX; x++) {
|
||||
long activation = activations[chunkIndex(x, z)];
|
||||
if (activation != 0L && predicate.test(baseX + x, baseZ + z, activation)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
long explicitActivation(int chunkX, int chunkZ) {
|
||||
long activation = activations[chunkIndex(chunkX, chunkZ)];
|
||||
if (activation == 0L) {
|
||||
throw new IllegalStateException("Chunk " + chunkX + "," + chunkZ + " has no explicit generation activation");
|
||||
}
|
||||
return activation;
|
||||
}
|
||||
|
||||
long resolve(int chunkX, int chunkZ, long currentActivation) {
|
||||
requireActivation(currentActivation);
|
||||
long activation = activations[chunkIndex(chunkX, chunkZ)];
|
||||
return activation == 0L ? currentActivation : activation;
|
||||
}
|
||||
|
||||
synchronized boolean assign(int chunkX, int chunkZ, long activation) {
|
||||
requireActivation(activation);
|
||||
int index = chunkIndex(chunkX, chunkZ);
|
||||
long existing = activations[index];
|
||||
if (existing == activation) {
|
||||
return false;
|
||||
}
|
||||
if (existing != 0L) {
|
||||
throw new IllegalStateException(
|
||||
"Chunk " + chunkX + "," + chunkZ + " already belongs to activation " + existing
|
||||
+ " and cannot be reassigned to " + activation
|
||||
);
|
||||
}
|
||||
activations[index] = activation;
|
||||
assignmentCount++;
|
||||
dirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
synchronized boolean persist(Path directory) throws IOException {
|
||||
if (!dirty) {
|
||||
return false;
|
||||
}
|
||||
ensureDirectory(directory);
|
||||
Path target = directory.resolve(fileName(regionX, regionZ));
|
||||
Path temporary = Files.createTempFile(directory, "." + target.getFileName() + "-", ".tmp");
|
||||
try {
|
||||
byte[] encoded = encode();
|
||||
writeForced(temporary, encoded);
|
||||
try {
|
||||
Files.move(
|
||||
temporary,
|
||||
target,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
);
|
||||
} catch (AtomicMoveNotSupportedException error) {
|
||||
throw new IOException("Generation ownership requires atomic shard publication", error);
|
||||
}
|
||||
forceDirectory(directory);
|
||||
dirty = false;
|
||||
return true;
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
int fillPackedChunkKeys(long[] target, int offset) {
|
||||
int baseX = regionX << 5;
|
||||
int baseZ = regionZ << 5;
|
||||
int cursor = offset;
|
||||
for (int localZ = 0; localZ < CHUNKS_PER_SIDE; localZ++) {
|
||||
for (int localX = 0; localX < CHUNKS_PER_SIDE; localX++) {
|
||||
int index = chunkIndex(localX, localZ);
|
||||
if (activations[index] != 0L) {
|
||||
target[cursor++] = ChunkGenerationOwnership.packChunk(baseX + localX, baseZ + localZ);
|
||||
}
|
||||
}
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
void forEachAssignment(AssignmentConsumer consumer) throws IOException {
|
||||
int baseX = regionX << 5;
|
||||
int baseZ = regionZ << 5;
|
||||
for (int localZ = 0; localZ < CHUNKS_PER_SIDE; localZ++) {
|
||||
for (int localX = 0; localX < CHUNKS_PER_SIDE; localX++) {
|
||||
long activation = activations[chunkIndex(localX, localZ)];
|
||||
if (activation != 0L) {
|
||||
consumer.accept(baseX + localX, baseZ + localZ, activation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String fileName(int regionX, int regionZ) {
|
||||
return "r." + regionX + "." + regionZ + FILE_SUFFIX;
|
||||
}
|
||||
|
||||
private synchronized byte[] encode() throws IOException {
|
||||
long[] activationSnapshot = new long[CHUNK_COUNT];
|
||||
Long2IntOpenHashMap paletteIndexes = new Long2IntOpenHashMap();
|
||||
paletteIndexes.defaultReturnValue(-1);
|
||||
long[] palette = new long[assignmentCount];
|
||||
int paletteSize = 0;
|
||||
for (int index = 0; index < CHUNK_COUNT; index++) {
|
||||
long activation = activations[index];
|
||||
activationSnapshot[index] = activation;
|
||||
if (activation == 0L || paletteIndexes.get(activation) >= 0) {
|
||||
continue;
|
||||
}
|
||||
palette[paletteSize] = activation;
|
||||
paletteIndexes.put(activation, paletteSize + 1);
|
||||
paletteSize++;
|
||||
}
|
||||
|
||||
int bitsPerEntry = bitsRequired(paletteSize + 1);
|
||||
long[] packed = pack(activationSnapshot, paletteIndexes, bitsPerEntry);
|
||||
ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(MAX_FILE_BYTES);
|
||||
try (DataOutputStream output = new DataOutputStream(bodyBytes)) {
|
||||
output.writeInt(MAGIC);
|
||||
output.writeShort(FORMAT_VERSION);
|
||||
output.writeShort(0);
|
||||
output.writeInt(regionX);
|
||||
output.writeInt(regionZ);
|
||||
output.writeInt(assignmentCount);
|
||||
output.writeInt(paletteSize);
|
||||
output.writeInt(bitsPerEntry);
|
||||
output.writeInt(packed.length);
|
||||
for (int index = 0; index < paletteSize; index++) {
|
||||
output.writeLong(palette[index]);
|
||||
}
|
||||
for (long word : packed) {
|
||||
output.writeLong(word);
|
||||
}
|
||||
}
|
||||
|
||||
byte[] body = bodyBytes.toByteArray();
|
||||
CRC32 checksum = new CRC32();
|
||||
checksum.update(body);
|
||||
ByteArrayOutputStream encodedBytes = new ByteArrayOutputStream(body.length + CHECKSUM_BYTES);
|
||||
encodedBytes.write(body);
|
||||
try (DataOutputStream output = new DataOutputStream(encodedBytes)) {
|
||||
output.writeInt((int) checksum.getValue());
|
||||
}
|
||||
return encodedBytes.toByteArray();
|
||||
}
|
||||
|
||||
private int decode(Path file, long[] palette, long[] packed, int bitsPerEntry) throws IOException {
|
||||
int decodedAssignments = 0;
|
||||
boolean[] paletteUsed = new boolean[palette.length];
|
||||
for (int index = 0; index < CHUNK_COUNT; index++) {
|
||||
int paletteIndex = unpack(packed, index, bitsPerEntry);
|
||||
if (paletteIndex == 0) {
|
||||
continue;
|
||||
}
|
||||
if (paletteIndex > palette.length) {
|
||||
throw invalid(file, "palette index out of range");
|
||||
}
|
||||
activations[index] = palette[paletteIndex - 1];
|
||||
paletteUsed[paletteIndex - 1] = true;
|
||||
decodedAssignments++;
|
||||
}
|
||||
for (boolean used : paletteUsed) {
|
||||
if (!used) {
|
||||
throw invalid(file, "unused activation palette entry");
|
||||
}
|
||||
}
|
||||
return decodedAssignments;
|
||||
}
|
||||
|
||||
private static byte[] readEncoded(Path file) throws IOException {
|
||||
long size = Files.size(file);
|
||||
if (size < FIXED_BODY_BYTES + CHECKSUM_BYTES) {
|
||||
throw invalid(file, "truncated data");
|
||||
}
|
||||
if (size > MAX_FILE_BYTES) {
|
||||
throw invalid(file, "file is too large");
|
||||
}
|
||||
byte[] encoded = Files.readAllBytes(file);
|
||||
if (encoded.length > MAX_FILE_BYTES) {
|
||||
throw invalid(file, "file is too large");
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
private static void validateChecksum(Path file, byte[] encoded) throws IOException {
|
||||
int bodyLength = encoded.length - CHECKSUM_BYTES;
|
||||
int expected = ByteBuffer.wrap(encoded, bodyLength, CHECKSUM_BYTES).getInt();
|
||||
CRC32 checksum = new CRC32();
|
||||
checksum.update(encoded, 0, bodyLength);
|
||||
if ((int) checksum.getValue() != expected) {
|
||||
throw invalid(file, "checksum mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateHeader(
|
||||
Path file,
|
||||
int assignmentCount,
|
||||
int paletteSize,
|
||||
int bitsPerEntry,
|
||||
int wordCount
|
||||
) throws IOException {
|
||||
if (assignmentCount < 0 || assignmentCount > CHUNK_COUNT) {
|
||||
throw invalid(file, "invalid assignment count " + assignmentCount);
|
||||
}
|
||||
if (paletteSize < 0 || paletteSize > assignmentCount) {
|
||||
throw invalid(file, "invalid palette size " + paletteSize);
|
||||
}
|
||||
if ((assignmentCount == 0) != (paletteSize == 0)) {
|
||||
throw invalid(file, "assignment and palette counts disagree");
|
||||
}
|
||||
int expectedBits = bitsRequired(paletteSize + 1);
|
||||
if (bitsPerEntry != expectedBits) {
|
||||
throw invalid(file, "invalid bits per entry " + bitsPerEntry);
|
||||
}
|
||||
int expectedWords = packedWordCount(bitsPerEntry);
|
||||
if (wordCount != expectedWords) {
|
||||
throw invalid(file, "invalid packed word count " + wordCount);
|
||||
}
|
||||
}
|
||||
|
||||
private static long[] readPalette(Path file, DataInputStream input, int paletteSize) throws IOException {
|
||||
long[] palette = new long[paletteSize];
|
||||
LongOpenHashSet unique = new LongOpenHashSet(paletteSize);
|
||||
for (int index = 0; index < paletteSize; index++) {
|
||||
long activation = input.readLong();
|
||||
if (activation <= 0L) {
|
||||
throw invalid(file, "activation IDs must be positive");
|
||||
}
|
||||
if (!unique.add(activation)) {
|
||||
throw invalid(file, "duplicate activation in palette");
|
||||
}
|
||||
palette[index] = activation;
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
private static long[] readPacked(DataInputStream input, int wordCount) throws IOException {
|
||||
long[] packed = new long[wordCount];
|
||||
for (int index = 0; index < wordCount; index++) {
|
||||
packed[index] = input.readLong();
|
||||
}
|
||||
return packed;
|
||||
}
|
||||
|
||||
private static void validateUnusedBits(Path file, long[] packed, int bitsPerEntry) throws IOException {
|
||||
if (packed.length == 0) {
|
||||
return;
|
||||
}
|
||||
int usedBits = (CHUNK_COUNT * bitsPerEntry) & 63;
|
||||
if (usedBits == 0) {
|
||||
return;
|
||||
}
|
||||
long unusedMask = -1L << usedBits;
|
||||
if ((packed[packed.length - 1] & unusedMask) != 0L) {
|
||||
throw invalid(file, "nonzero unused packed bits");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateRegionCoordinate(Path file, int regionCoordinate) throws IOException {
|
||||
if (regionCoordinate < MINIMUM_REGION_COORDINATE || regionCoordinate > MAXIMUM_REGION_COORDINATE) {
|
||||
throw invalid(file, "region coordinate is outside the chunk coordinate range");
|
||||
}
|
||||
}
|
||||
|
||||
private static long[] pack(
|
||||
long[] activations,
|
||||
Long2IntOpenHashMap paletteIndexes,
|
||||
int bitsPerEntry
|
||||
) {
|
||||
long[] packed = new long[packedWordCount(bitsPerEntry)];
|
||||
if (bitsPerEntry == 0) {
|
||||
return packed;
|
||||
}
|
||||
for (int index = 0; index < CHUNK_COUNT; index++) {
|
||||
long activation = activations[index];
|
||||
int paletteIndex = activation == 0L ? 0 : paletteIndexes.get(activation);
|
||||
int bitIndex = index * bitsPerEntry;
|
||||
int wordIndex = bitIndex >>> 6;
|
||||
int bitOffset = bitIndex & 63;
|
||||
packed[wordIndex] |= (long) paletteIndex << bitOffset;
|
||||
if (bitOffset + bitsPerEntry > Long.SIZE) {
|
||||
packed[wordIndex + 1] |= (long) paletteIndex >>> (Long.SIZE - bitOffset);
|
||||
}
|
||||
}
|
||||
return packed;
|
||||
}
|
||||
|
||||
private static int unpack(long[] packed, int index, int bitsPerEntry) {
|
||||
if (bitsPerEntry == 0) {
|
||||
return 0;
|
||||
}
|
||||
int bitIndex = index * bitsPerEntry;
|
||||
int wordIndex = bitIndex >>> 6;
|
||||
int bitOffset = bitIndex & 63;
|
||||
long value = packed[wordIndex] >>> bitOffset;
|
||||
if (bitOffset + bitsPerEntry > Long.SIZE) {
|
||||
value |= packed[wordIndex + 1] << (Long.SIZE - bitOffset);
|
||||
}
|
||||
long mask = (1L << bitsPerEntry) - 1L;
|
||||
return (int) (value & mask);
|
||||
}
|
||||
|
||||
private static int bitsRequired(int values) {
|
||||
if (values <= 1) {
|
||||
return 0;
|
||||
}
|
||||
return Integer.SIZE - Integer.numberOfLeadingZeros(values - 1);
|
||||
}
|
||||
|
||||
private static int packedWordCount(int bitsPerEntry) {
|
||||
return (CHUNK_COUNT * bitsPerEntry + Long.SIZE - 1) / Long.SIZE;
|
||||
}
|
||||
|
||||
private static int chunkIndex(int chunkX, int chunkZ) {
|
||||
return (chunkX & 31) + ((chunkZ & 31) << 5);
|
||||
}
|
||||
|
||||
private static void requireActivation(long activation) {
|
||||
if (activation <= 0L) {
|
||||
throw new IllegalArgumentException("Generation activation IDs must be positive: " + activation);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeForced(Path file, byte[] encoded) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(
|
||||
file,
|
||||
StandardOpenOption.WRITE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING
|
||||
)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(encoded);
|
||||
while (buffer.hasRemaining()) {
|
||||
channel.write(buffer);
|
||||
}
|
||||
channel.force(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureDirectory(Path directory) throws IOException {
|
||||
Path parent = directory.getParent();
|
||||
if (parent == null || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Generation ownership parent directory is missing or unsafe: " + parent);
|
||||
}
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Generation ownership path is not a safe directory: " + directory);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.createDirectory(directory);
|
||||
} catch (FileAlreadyExistsException race) {
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Generation ownership path is not a safe directory: " + directory, race);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Generation ownership path is not a safe directory: " + directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
if (File.separatorChar == '\\') {
|
||||
return;
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
} catch (UnsupportedOperationException error) {
|
||||
throw new IOException("Generation ownership directory cannot be durability-synced", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static IOException invalid(Path file, String reason) {
|
||||
return new IOException("Invalid generation ownership file " + file + ": " + reason);
|
||||
}
|
||||
|
||||
private static IOException invalid(Path file, String reason, Throwable cause) {
|
||||
return new IOException("Invalid generation ownership file " + file + ": " + reason, cause);
|
||||
}
|
||||
|
||||
record Metadata(int regionX, int regionZ, int assignmentCount) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface AssignmentConsumer {
|
||||
void accept(int chunkX, int chunkZ, long activationId) throws IOException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
public final class TerrainBoundarySignature {
|
||||
private final Column column;
|
||||
private final Samples samples;
|
||||
|
||||
public TerrainBoundarySignature(Column column, Samples samples) {
|
||||
this.column = Objects.requireNonNull(column, "Boundary column");
|
||||
this.samples = Objects.requireNonNull(samples, "Boundary samples");
|
||||
}
|
||||
|
||||
public Column column() {
|
||||
return column;
|
||||
}
|
||||
|
||||
public Samples samples() {
|
||||
return samples;
|
||||
}
|
||||
|
||||
public int blockX() {
|
||||
return column.blockX();
|
||||
}
|
||||
|
||||
public int blockZ() {
|
||||
return column.blockZ();
|
||||
}
|
||||
|
||||
public int surfaceHeight() {
|
||||
return column.surfaceHeight();
|
||||
}
|
||||
|
||||
public int oceanFloorHeight() {
|
||||
return column.oceanFloorHeight();
|
||||
}
|
||||
|
||||
public OptionalInt fluidHeight() {
|
||||
return column.fluidHeight();
|
||||
}
|
||||
|
||||
public OptionalInt upperCeilingDepth() {
|
||||
return column.upperCeilingDepth();
|
||||
}
|
||||
|
||||
public int sampleCount() {
|
||||
return samples.layout().sampleCount();
|
||||
}
|
||||
|
||||
public int sampleY(int sampleIndex) {
|
||||
return samples.layout().sampleY(sampleIndex);
|
||||
}
|
||||
|
||||
public String biomeAtSample(int sampleIndex) {
|
||||
return samples.biomes().biomeAtSample(sampleIndex);
|
||||
}
|
||||
|
||||
public record Column(
|
||||
int blockX,
|
||||
int blockZ,
|
||||
int surfaceHeight,
|
||||
int oceanFloorHeight,
|
||||
OptionalInt fluidHeight,
|
||||
OptionalInt upperCeilingDepth
|
||||
) {
|
||||
public Column {
|
||||
Objects.requireNonNull(fluidHeight, "Fluid height");
|
||||
Objects.requireNonNull(upperCeilingDepth, "Upper ceiling depth");
|
||||
if (upperCeilingDepth.isPresent() && upperCeilingDepth.getAsInt() <= 0) {
|
||||
throw new IllegalArgumentException("Present upper ceiling depth must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record Samples(VerticalLayout layout, BiomeEncoding biomes) {
|
||||
public Samples {
|
||||
Objects.requireNonNull(layout, "Vertical layout");
|
||||
Objects.requireNonNull(biomes, "Biome samples");
|
||||
if (biomes.sampleCount() != layout.sampleCount()) {
|
||||
throw new IllegalArgumentException("Biome sample count must match the vertical layout");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record VerticalLayout(int minimumY, int sampleStep, int sampleCount) {
|
||||
public VerticalLayout {
|
||||
if (sampleStep <= 0) {
|
||||
throw new IllegalArgumentException("Sample step must be positive");
|
||||
}
|
||||
if (sampleCount < 0) {
|
||||
throw new IllegalArgumentException("Sample count cannot be negative");
|
||||
}
|
||||
if (sampleCount > 0) {
|
||||
Math.toIntExact((long) minimumY + (long) (sampleCount - 1) * sampleStep);
|
||||
}
|
||||
}
|
||||
|
||||
public int sampleY(int sampleIndex) {
|
||||
validateSampleIndex(sampleIndex, sampleCount);
|
||||
return Math.toIntExact((long) minimumY + (long) sampleIndex * sampleStep);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class BiomeEncoding {
|
||||
private final List<String> palette;
|
||||
private final short[] paletteIndices;
|
||||
|
||||
public BiomeEncoding(List<String> palette, short[] paletteIndices) {
|
||||
this.palette = List.copyOf(Objects.requireNonNull(palette, "Biome palette"));
|
||||
this.paletteIndices = Objects.requireNonNull(paletteIndices, "Biome palette indices").clone();
|
||||
validatePalette();
|
||||
}
|
||||
|
||||
public List<String> palette() {
|
||||
return palette;
|
||||
}
|
||||
|
||||
public short[] paletteIndices() {
|
||||
return paletteIndices.clone();
|
||||
}
|
||||
|
||||
public int sampleCount() {
|
||||
return paletteIndices.length;
|
||||
}
|
||||
|
||||
public String biomeAtSample(int sampleIndex) {
|
||||
validateSampleIndex(sampleIndex, paletteIndices.length);
|
||||
return palette.get(paletteIndices[sampleIndex]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object compared) {
|
||||
if (this == compared) {
|
||||
return true;
|
||||
}
|
||||
if (!(compared instanceof BiomeEncoding encoding)) {
|
||||
return false;
|
||||
}
|
||||
return palette.equals(encoding.palette) && Arrays.equals(paletteIndices, encoding.paletteIndices);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return 31 * palette.hashCode() + Arrays.hashCode(paletteIndices);
|
||||
}
|
||||
|
||||
private void validatePalette() {
|
||||
if (paletteIndices.length > 0 && palette.isEmpty()) {
|
||||
throw new IllegalArgumentException("Biome palette is required when samples exist");
|
||||
}
|
||||
if (palette.size() > Short.MAX_VALUE + 1) {
|
||||
throw new IllegalArgumentException("Biome palette exceeds compact index capacity");
|
||||
}
|
||||
for (short paletteIndex : paletteIndices) {
|
||||
if (paletteIndex < 0 || paletteIndex >= palette.size()) {
|
||||
throw new IllegalArgumentException("Biome palette index is outside the palette");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateSampleIndex(int sampleIndex, int sampleCount) {
|
||||
if (sampleIndex < 0 || sampleIndex >= sampleCount) {
|
||||
throw new IndexOutOfBoundsException("Sample index " + sampleIndex + " outside sample count " + sampleCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1397
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,367 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
final class TransitionBoundarySampler {
|
||||
private static final int NEIGHBOUR_COUNT = 4;
|
||||
private static final int MAXIMUM_CACHED_CHUNKS = 8;
|
||||
|
||||
private final int terrainWidth;
|
||||
private final int searchWidth;
|
||||
private final TerrainBoundarySignatureStore.Snapshot signatures;
|
||||
private final LinkedHashMap<Long, CandidateIndex> chunkCandidates;
|
||||
private final ThreadLocal<ChunkCache> cache;
|
||||
private long candidateBuildCount;
|
||||
|
||||
TransitionBoundarySampler(
|
||||
int terrainWidth,
|
||||
TerrainBoundarySignatureStore.Snapshot signatures
|
||||
) {
|
||||
if (terrainWidth <= 0) {
|
||||
throw new IllegalArgumentException("Terrain transition width must be positive");
|
||||
}
|
||||
this.terrainWidth = terrainWidth;
|
||||
this.searchWidth = Math.multiplyExact(terrainWidth, 2);
|
||||
this.signatures = Objects.requireNonNull(signatures, "Terrain boundary signatures");
|
||||
this.chunkCandidates = new LinkedHashMap<>(MAXIMUM_CACHED_CHUNKS, 0.75F, true);
|
||||
this.cache = ThreadLocal.withInitial(ChunkCache::new);
|
||||
}
|
||||
|
||||
TransitionGenerationPlan.TerrainSample sample(int blockX, int blockZ) {
|
||||
ChunkCache chunkCache = cache.get();
|
||||
int chunkX = Math.floorDiv(blockX, GenerationBoundary.CHUNK_SIZE);
|
||||
int chunkZ = Math.floorDiv(blockZ, GenerationBoundary.CHUNK_SIZE);
|
||||
if (chunkCache.chunkX != chunkX || chunkCache.chunkZ != chunkZ) {
|
||||
chunkCache.reset(chunkX, chunkZ, candidatesForChunk(chunkX, chunkZ));
|
||||
}
|
||||
int localX = Math.floorMod(blockX, GenerationBoundary.CHUNK_SIZE);
|
||||
int localZ = Math.floorMod(blockZ, GenerationBoundary.CHUNK_SIZE);
|
||||
int index = localX * GenerationBoundary.CHUNK_SIZE + localZ;
|
||||
TransitionGenerationPlan.TerrainSample existing = chunkCache.samples[index];
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
TransitionGenerationPlan.TerrainSample sampled = sampleUncached(
|
||||
blockX,
|
||||
blockZ,
|
||||
chunkCache.candidates
|
||||
);
|
||||
chunkCache.samples[index] = sampled;
|
||||
return sampled;
|
||||
}
|
||||
|
||||
boolean intersectsTerrainBand(int minimumX, int minimumZ, int maximumX, int maximumZ) {
|
||||
return signatures.intersectsTerrainBand(
|
||||
new TerrainBoundarySignatureStore.BlockBounds(minimumX, minimumZ, maximumX, maximumZ),
|
||||
terrainWidth
|
||||
);
|
||||
}
|
||||
|
||||
long catalogProbeCount() {
|
||||
return signatures.catalogProbeCount();
|
||||
}
|
||||
|
||||
long shardLoadCount() {
|
||||
return signatures.shardLoadCount();
|
||||
}
|
||||
|
||||
synchronized long candidateBuildCount() {
|
||||
return candidateBuildCount;
|
||||
}
|
||||
|
||||
private TransitionGenerationPlan.TerrainSample sampleUncached(
|
||||
int blockX,
|
||||
int blockZ,
|
||||
CandidateIndex candidates
|
||||
) {
|
||||
Nearest nearest = candidates.nearest(blockX, blockZ, square(searchWidth));
|
||||
if (nearest.count == 0) {
|
||||
return TransitionGenerationPlan.TerrainSample.newTerrain(searchWidth);
|
||||
}
|
||||
double distance = Math.sqrt(nearest.distancesSquared[0]);
|
||||
double newEpochWeight = GenerationBlend.newEpochWeight(
|
||||
Math.min(distance, terrainWidth),
|
||||
terrainWidth
|
||||
);
|
||||
double hydrologyDistance = Math.max(0D, distance - terrainWidth);
|
||||
double hydrologyWeight = GenerationBlend.newEpochWeight(
|
||||
Math.min(hydrologyDistance, terrainWidth),
|
||||
terrainWidth
|
||||
);
|
||||
return new TransitionGenerationPlan.TerrainSample(
|
||||
Math.min(distance, searchWidth),
|
||||
newEpochWeight,
|
||||
hydrologyWeight,
|
||||
nearest.signatures[0],
|
||||
weightedHeight(nearest, true),
|
||||
weightedHeight(nearest, false),
|
||||
weightedUpperCeilingDepth(nearest)
|
||||
);
|
||||
}
|
||||
|
||||
private CandidateIndex candidatesForChunk(int chunkX, int chunkZ) {
|
||||
long chunkKey = pack(chunkX, chunkZ);
|
||||
synchronized (this) {
|
||||
CandidateIndex existing = chunkCandidates.get(chunkKey);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
CandidateIndex built = CandidateIndex.build(signatures.nearestCandidatesForChunk(
|
||||
chunkX,
|
||||
chunkZ,
|
||||
searchWidth
|
||||
));
|
||||
synchronized (this) {
|
||||
CandidateIndex raced = chunkCandidates.get(chunkKey);
|
||||
if (raced != null) {
|
||||
return raced;
|
||||
}
|
||||
chunkCandidates.put(chunkKey, built);
|
||||
candidateBuildCount++;
|
||||
while (chunkCandidates.size() > MAXIMUM_CACHED_CHUNKS) {
|
||||
Iterator<Map.Entry<Long, CandidateIndex>> entries = chunkCandidates.entrySet().iterator();
|
||||
entries.next();
|
||||
entries.remove();
|
||||
}
|
||||
return built;
|
||||
}
|
||||
}
|
||||
|
||||
private static double weightedHeight(Nearest nearest, boolean surface) {
|
||||
if (nearest.distancesSquared[0] <= 1D) {
|
||||
return surface
|
||||
? nearest.signatures[0].surfaceHeight()
|
||||
: nearest.signatures[0].oceanFloorHeight();
|
||||
}
|
||||
double weighted = 0D;
|
||||
double totalWeight = 0D;
|
||||
for (int index = 0; index < nearest.count; index++) {
|
||||
double weight = 1D / nearest.distancesSquared[index];
|
||||
int height = surface
|
||||
? nearest.signatures[index].surfaceHeight()
|
||||
: nearest.signatures[index].oceanFloorHeight();
|
||||
weighted += height * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
return weighted / totalWeight;
|
||||
}
|
||||
|
||||
private static double weightedUpperCeilingDepth(Nearest nearest) {
|
||||
if (nearest.distancesSquared[0] <= 1D) {
|
||||
return nearest.signatures[0].upperCeilingDepth().orElse(0);
|
||||
}
|
||||
double weighted = 0D;
|
||||
double totalWeight = 0D;
|
||||
for (int index = 0; index < nearest.count; index++) {
|
||||
double weight = 1D / nearest.distancesSquared[index];
|
||||
weighted += nearest.signatures[index].upperCeilingDepth().orElse(0) * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
return weighted / totalWeight;
|
||||
}
|
||||
|
||||
private static long distanceToRange(long value, long minimum, long maximum) {
|
||||
if (value < minimum) {
|
||||
return minimum - value;
|
||||
}
|
||||
return value > maximum ? value - maximum : 0L;
|
||||
}
|
||||
|
||||
private static double square(long value) {
|
||||
return (double) value * value;
|
||||
}
|
||||
|
||||
private static long pack(int x, int z) {
|
||||
return ((long) x << Integer.SIZE) | (z & 0xffffffffL);
|
||||
}
|
||||
|
||||
private static final class CandidateIndex {
|
||||
private static final CandidateIndex EMPTY = new CandidateIndex(null);
|
||||
|
||||
private final Node root;
|
||||
|
||||
private CandidateIndex(Node root) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
private static CandidateIndex build(List<TerrainBoundarySignature> candidates) {
|
||||
if (candidates.isEmpty()) {
|
||||
return EMPTY;
|
||||
}
|
||||
TerrainBoundarySignature[] values = candidates.toArray(new TerrainBoundarySignature[0]);
|
||||
return new CandidateIndex(build(values, 0, values.length, 0));
|
||||
}
|
||||
|
||||
private Nearest nearest(int blockX, int blockZ, double maximumDistanceSquared) {
|
||||
Nearest nearest = new Nearest(maximumDistanceSquared);
|
||||
search(root, blockX, blockZ, nearest);
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private static Node build(
|
||||
TerrainBoundarySignature[] values,
|
||||
int from,
|
||||
int to,
|
||||
int depth
|
||||
) {
|
||||
if (from >= to) {
|
||||
return null;
|
||||
}
|
||||
Comparator<TerrainBoundarySignature> comparator = depth % 2 == 0
|
||||
? Comparator.comparingInt(TerrainBoundarySignature::blockX)
|
||||
.thenComparingInt(TerrainBoundarySignature::blockZ)
|
||||
: Comparator.comparingInt(TerrainBoundarySignature::blockZ)
|
||||
.thenComparingInt(TerrainBoundarySignature::blockX);
|
||||
Arrays.sort(values, from, to, comparator);
|
||||
int middle = from + (to - from) / 2;
|
||||
Node left = build(values, from, middle, depth + 1);
|
||||
Node right = build(values, middle + 1, to, depth + 1);
|
||||
return new Node(values[middle], left, right);
|
||||
}
|
||||
|
||||
private static void search(Node node, int blockX, int blockZ, Nearest nearest) {
|
||||
if (node == null || node.distanceSquaredToBounds(blockX, blockZ) > nearest.limitSquared()) {
|
||||
return;
|
||||
}
|
||||
long deltaX = (long) node.signature.blockX() - blockX;
|
||||
long deltaZ = (long) node.signature.blockZ() - blockZ;
|
||||
nearest.offer(node.signature, square(deltaX) + square(deltaZ));
|
||||
|
||||
Node first = node.left;
|
||||
Node second = node.right;
|
||||
if (first == null || second != null
|
||||
&& second.distanceSquaredToBounds(blockX, blockZ)
|
||||
< first.distanceSquaredToBounds(blockX, blockZ)) {
|
||||
first = node.right;
|
||||
second = node.left;
|
||||
}
|
||||
search(first, blockX, blockZ, nearest);
|
||||
search(second, blockX, blockZ, nearest);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Node {
|
||||
private final TerrainBoundarySignature signature;
|
||||
private final Node left;
|
||||
private final Node right;
|
||||
private final int minimumX;
|
||||
private final int maximumX;
|
||||
private final int minimumZ;
|
||||
private final int maximumZ;
|
||||
|
||||
private Node(TerrainBoundarySignature signature, Node left, Node right) {
|
||||
this.signature = signature;
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
minimumX = Math.min(signature.blockX(), Math.min(minimumX(left), minimumX(right)));
|
||||
maximumX = Math.max(signature.blockX(), Math.max(maximumX(left), maximumX(right)));
|
||||
minimumZ = Math.min(signature.blockZ(), Math.min(minimumZ(left), minimumZ(right)));
|
||||
maximumZ = Math.max(signature.blockZ(), Math.max(maximumZ(left), maximumZ(right)));
|
||||
}
|
||||
|
||||
private double distanceSquaredToBounds(int blockX, int blockZ) {
|
||||
long distanceX = distanceToRange(blockX, minimumX, maximumX);
|
||||
long distanceZ = distanceToRange(blockZ, minimumZ, maximumZ);
|
||||
return square(distanceX) + square(distanceZ);
|
||||
}
|
||||
|
||||
private static int minimumX(Node node) {
|
||||
return node == null ? Integer.MAX_VALUE : node.minimumX;
|
||||
}
|
||||
|
||||
private static int maximumX(Node node) {
|
||||
return node == null ? Integer.MIN_VALUE : node.maximumX;
|
||||
}
|
||||
|
||||
private static int minimumZ(Node node) {
|
||||
return node == null ? Integer.MAX_VALUE : node.minimumZ;
|
||||
}
|
||||
|
||||
private static int maximumZ(Node node) {
|
||||
return node == null ? Integer.MIN_VALUE : node.maximumZ;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Nearest {
|
||||
private final TerrainBoundarySignature[] signatures = new TerrainBoundarySignature[NEIGHBOUR_COUNT];
|
||||
private final double[] distancesSquared = new double[NEIGHBOUR_COUNT];
|
||||
private final double maximumDistanceSquared;
|
||||
private int count;
|
||||
|
||||
private Nearest(double maximumDistanceSquared) {
|
||||
this.maximumDistanceSquared = maximumDistanceSquared;
|
||||
}
|
||||
|
||||
private void offer(TerrainBoundarySignature signature, double distanceSquared) {
|
||||
if (distanceSquared > maximumDistanceSquared) {
|
||||
return;
|
||||
}
|
||||
int insertion = 0;
|
||||
while (insertion < count && compare(
|
||||
distancesSquared[insertion],
|
||||
signatures[insertion],
|
||||
distanceSquared,
|
||||
signature
|
||||
) <= 0) {
|
||||
insertion++;
|
||||
}
|
||||
if (insertion >= NEIGHBOUR_COUNT) {
|
||||
return;
|
||||
}
|
||||
int copyLength = Math.min(count, NEIGHBOUR_COUNT - 1) - insertion;
|
||||
if (copyLength > 0) {
|
||||
System.arraycopy(signatures, insertion, signatures, insertion + 1, copyLength);
|
||||
System.arraycopy(distancesSquared, insertion, distancesSquared, insertion + 1, copyLength);
|
||||
}
|
||||
signatures[insertion] = signature;
|
||||
distancesSquared[insertion] = distanceSquared;
|
||||
count = Math.min(NEIGHBOUR_COUNT, count + 1);
|
||||
}
|
||||
|
||||
private double limitSquared() {
|
||||
return count < NEIGHBOUR_COUNT
|
||||
? maximumDistanceSquared
|
||||
: Math.min(maximumDistanceSquared, distancesSquared[NEIGHBOUR_COUNT - 1]);
|
||||
}
|
||||
|
||||
private static int compare(
|
||||
double firstDistance,
|
||||
TerrainBoundarySignature first,
|
||||
double secondDistance,
|
||||
TerrainBoundarySignature second
|
||||
) {
|
||||
int distanceComparison = Double.compare(firstDistance, secondDistance);
|
||||
if (distanceComparison != 0) {
|
||||
return distanceComparison;
|
||||
}
|
||||
int xComparison = Integer.compare(first.blockX(), second.blockX());
|
||||
return xComparison != 0
|
||||
? xComparison
|
||||
: Integer.compare(first.blockZ(), second.blockZ());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ChunkCache {
|
||||
private final TransitionGenerationPlan.TerrainSample[] samples =
|
||||
new TransitionGenerationPlan.TerrainSample[
|
||||
GenerationBoundary.CHUNK_SIZE * GenerationBoundary.CHUNK_SIZE
|
||||
];
|
||||
private int chunkX = Integer.MIN_VALUE;
|
||||
private int chunkZ = Integer.MIN_VALUE;
|
||||
private CandidateIndex candidates = CandidateIndex.EMPTY;
|
||||
|
||||
private void reset(int nextChunkX, int nextChunkZ, CandidateIndex nextCandidates) {
|
||||
Arrays.fill(samples, null);
|
||||
chunkX = nextChunkX;
|
||||
chunkZ = nextChunkZ;
|
||||
candidates = nextCandidates;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class TransitionGenerationPlan {
|
||||
private final Specification specification;
|
||||
private final GenerationBoundary boundary;
|
||||
private final TerrainBoundarySignatureStore.Snapshot terrainSignatures;
|
||||
private final TransitionBoundarySampler terrainSampler;
|
||||
|
||||
public TransitionGenerationPlan(
|
||||
Specification specification,
|
||||
GenerationBoundary boundary,
|
||||
TerrainBoundarySignatureStore.Snapshot terrainSignatures
|
||||
) {
|
||||
this.specification = Objects.requireNonNull(specification, "Transition specification");
|
||||
this.boundary = Objects.requireNonNull(boundary, "Generation boundary");
|
||||
this.terrainSignatures = Objects.requireNonNull(terrainSignatures, "Terrain signatures");
|
||||
if (!specification.boundaryIdentity().equals(boundary.identity())) {
|
||||
throw new IllegalArgumentException("Transition boundary identity does not match the frozen boundary");
|
||||
}
|
||||
if (specification.activationId() != terrainSignatures.activationId()) {
|
||||
throw new IllegalArgumentException("Terrain signatures belong to a different activation");
|
||||
}
|
||||
if (!specification.terrainSignatureIdentity().equals(terrainSignatures.identity())) {
|
||||
throw new IllegalArgumentException("Transition terrain signature identity does not match the snapshot");
|
||||
}
|
||||
if (specification.algorithmVersion() != GenerationTransition.CURRENT_ALGORITHM_VERSION) {
|
||||
throw new IllegalArgumentException("Unsupported generation transition algorithm version "
|
||||
+ specification.algorithmVersion());
|
||||
}
|
||||
terrainSampler = new TransitionBoundarySampler(
|
||||
specification.widthBlocks(),
|
||||
terrainSignatures
|
||||
);
|
||||
}
|
||||
|
||||
public long activationId() {
|
||||
return specification.activationId();
|
||||
}
|
||||
|
||||
public String oldEpochId() {
|
||||
return specification.oldEpochId();
|
||||
}
|
||||
|
||||
public String newEpochId() {
|
||||
return specification.newEpochId();
|
||||
}
|
||||
|
||||
public int widthBlocks() {
|
||||
return specification.widthBlocks();
|
||||
}
|
||||
|
||||
public int algorithmVersion() {
|
||||
return specification.algorithmVersion();
|
||||
}
|
||||
|
||||
public String boundaryIdentity() {
|
||||
return specification.boundaryIdentity();
|
||||
}
|
||||
|
||||
public Specification specification() {
|
||||
return specification;
|
||||
}
|
||||
|
||||
public GenerationBoundary boundary() {
|
||||
return boundary;
|
||||
}
|
||||
|
||||
public TerrainBoundarySignatureStore.Snapshot terrainSignatures() {
|
||||
return terrainSignatures;
|
||||
}
|
||||
|
||||
public boolean isHistoricalBlock(int blockX, int blockZ) {
|
||||
return boundary.isHistoricalBlock(blockX, blockZ);
|
||||
}
|
||||
|
||||
public double distanceToHistoricalChunks(int blockX, int blockZ) {
|
||||
return Math.min(terrainSampleAt(blockX, blockZ).distanceToHistoricalTerrain(), widthBlocks());
|
||||
}
|
||||
|
||||
public double newEpochWeightAt(int blockX, int blockZ) {
|
||||
return terrainSampleAt(blockX, blockZ).newEpochWeight();
|
||||
}
|
||||
|
||||
public double hydrologyWeightAt(int blockX, int blockZ) {
|
||||
return terrainSampleAt(blockX, blockZ).hydrologyWeight();
|
||||
}
|
||||
|
||||
public boolean allowsNewDiscreteContentAt(int blockX, int blockZ) {
|
||||
return terrainSampleAt(blockX, blockZ).newEpochWeight() == 1D;
|
||||
}
|
||||
|
||||
public TerrainSample terrainSampleAt(int blockX, int blockZ) {
|
||||
if (boundary.isHistoricalBlock(blockX, blockZ)) {
|
||||
return TerrainSample.historicalTerrain();
|
||||
}
|
||||
return terrainSampler.sample(blockX, blockZ);
|
||||
}
|
||||
|
||||
public Optional<String> historicalPhysicalBiomeKeyAt(int blockX, int blockY, int blockZ) {
|
||||
TerrainSample sample = terrainSampleAt(blockX, blockZ);
|
||||
if (sample.newEpochWeight() == 1D) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return sample.historicalPhysicalBiomeKeyAt(blockY);
|
||||
}
|
||||
|
||||
public boolean allowsNewFootprint(int minimumX, int minimumZ, int maximumX, int maximumZ) {
|
||||
if (minimumX > maximumX || minimumZ > maximumZ) {
|
||||
throw new IllegalArgumentException("Generation footprint bounds are inverted");
|
||||
}
|
||||
return !boundary.intersectsHistoricalBlocks(minimumX, minimumZ, maximumX, maximumZ)
|
||||
&& !terrainSampler.intersectsTerrainBand(minimumX, minimumZ, maximumX, maximumZ);
|
||||
}
|
||||
|
||||
long terrainCatalogProbeCount() {
|
||||
return terrainSampler.catalogProbeCount();
|
||||
}
|
||||
|
||||
long terrainShardLoadCount() {
|
||||
return terrainSampler.shardLoadCount();
|
||||
}
|
||||
|
||||
long terrainCandidateBuildCount() {
|
||||
return terrainSampler.candidateBuildCount();
|
||||
}
|
||||
|
||||
public record Specification(
|
||||
long activationId,
|
||||
String oldEpochId,
|
||||
String newEpochId,
|
||||
int algorithmVersion,
|
||||
int widthBlocks,
|
||||
String boundaryIdentity,
|
||||
String terrainSignatureIdentity
|
||||
) {
|
||||
public Specification {
|
||||
if (activationId <= 0L) {
|
||||
throw new IllegalArgumentException("Activation ID must be positive");
|
||||
}
|
||||
oldEpochId = requireIdentifier(oldEpochId, "Old epoch ID");
|
||||
newEpochId = requireIdentifier(newEpochId, "New epoch ID");
|
||||
boundaryIdentity = requireIdentifier(boundaryIdentity, "Boundary identity");
|
||||
terrainSignatureIdentity = requireIdentifier(
|
||||
terrainSignatureIdentity,
|
||||
"Terrain signature identity"
|
||||
);
|
||||
if (oldEpochId.equals(newEpochId)) {
|
||||
throw new IllegalArgumentException("Transition epoch IDs must differ");
|
||||
}
|
||||
if (widthBlocks <= 0) {
|
||||
throw new IllegalArgumentException("Transition width must be positive");
|
||||
}
|
||||
if (algorithmVersion < 1) {
|
||||
throw new IllegalArgumentException("Transition algorithm version must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireIdentifier(String identifier, String label) {
|
||||
String requiredIdentifier = Objects.requireNonNull(identifier, label);
|
||||
if (requiredIdentifier.isBlank()) {
|
||||
throw new IllegalArgumentException(label + " cannot be blank");
|
||||
}
|
||||
return requiredIdentifier;
|
||||
}
|
||||
}
|
||||
|
||||
public record TerrainSample(
|
||||
double distanceToHistoricalTerrain,
|
||||
double newEpochWeight,
|
||||
double hydrologyWeight,
|
||||
TerrainBoundarySignature nearestSignature,
|
||||
double historicalSurfaceHeight,
|
||||
double historicalOceanFloorHeight,
|
||||
double historicalUpperCeilingDepth
|
||||
) {
|
||||
public TerrainSample {
|
||||
if (!Double.isFinite(distanceToHistoricalTerrain) || distanceToHistoricalTerrain < 0D) {
|
||||
throw new IllegalArgumentException("Historical terrain distance must be finite and non-negative");
|
||||
}
|
||||
if (!Double.isFinite(newEpochWeight) || newEpochWeight < 0D || newEpochWeight > 1D) {
|
||||
throw new IllegalArgumentException("New epoch weight must be between zero and one");
|
||||
}
|
||||
if (!Double.isFinite(hydrologyWeight) || hydrologyWeight < 0D || hydrologyWeight > 1D) {
|
||||
throw new IllegalArgumentException("Hydrology weight must be between zero and one");
|
||||
}
|
||||
if (nearestSignature != null
|
||||
&& (!Double.isFinite(historicalSurfaceHeight)
|
||||
|| !Double.isFinite(historicalOceanFloorHeight)
|
||||
|| !Double.isFinite(historicalUpperCeilingDepth)
|
||||
|| historicalUpperCeilingDepth < 0D)) {
|
||||
throw new IllegalArgumentException("Historical terrain heights must be finite");
|
||||
}
|
||||
}
|
||||
|
||||
static TerrainSample newTerrain(double distance) {
|
||||
return new TerrainSample(distance, 1D, 1D, null, 0D, 0D, 0D);
|
||||
}
|
||||
|
||||
static TerrainSample historicalTerrain() {
|
||||
return new TerrainSample(0D, 0D, 0D, null, 0D, 0D, 0D);
|
||||
}
|
||||
|
||||
public boolean hasHistoricalSignature() {
|
||||
return nearestSignature != null;
|
||||
}
|
||||
|
||||
public Optional<String> historicalPhysicalBiomeKeyAt(int blockY) {
|
||||
if (nearestSignature == null || nearestSignature.sampleCount() == 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
TerrainBoundarySignature.VerticalLayout layout = nearestSignature.samples().layout();
|
||||
long offset = (long) blockY - layout.minimumY();
|
||||
long roundedIndex = Math.round(offset / (double) layout.sampleStep());
|
||||
int sampleIndex = (int) Math.max(0L, Math.min(roundedIndex, layout.sampleCount() - 1L));
|
||||
return Optional.of(nearestSignature.biomeAtSample(sampleIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.util.nbt.common.mca.MCAFile;
|
||||
import art.arcane.iris.util.nbt.common.mca.MCAUtil;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.BitSet;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class WorldChunkInventory {
|
||||
private static final int REGION_SIDE = 32;
|
||||
private static final int SECTOR_BYTES = 4_096;
|
||||
private static final int HEADER_SECTORS = 2;
|
||||
private static final int HEADER_BYTES = SECTOR_BYTES * HEADER_SECTORS;
|
||||
private static final Pattern REGION_FILE = Pattern.compile("^r\\.(-?\\d+)\\.(-?\\d+)\\.mca$");
|
||||
|
||||
private final Long2ObjectOpenHashMap<BitSet> regionMasks;
|
||||
private final long[] regionKeys;
|
||||
private final int chunkCount;
|
||||
|
||||
private WorldChunkInventory(Long2ObjectOpenHashMap<BitSet> regionMasks) {
|
||||
this.regionMasks = regionMasks;
|
||||
this.regionKeys = regionMasks.keySet().toLongArray();
|
||||
Arrays.sort(regionKeys);
|
||||
int count = 0;
|
||||
for (BitSet mask : regionMasks.values()) {
|
||||
count = Math.addExact(count, mask.cardinality());
|
||||
}
|
||||
this.chunkCount = count;
|
||||
}
|
||||
|
||||
public static WorldChunkInventory empty() {
|
||||
return new WorldChunkInventory(new Long2ObjectOpenHashMap<>());
|
||||
}
|
||||
|
||||
public static WorldChunkInventory ofPackedChunks(long... packedChunks) {
|
||||
Long2ObjectOpenHashMap<BitSet> masks = new Long2ObjectOpenHashMap<>();
|
||||
for (long packed : packedChunks) {
|
||||
int chunkX = ChunkGenerationOwnership.chunkX(packed);
|
||||
int chunkZ = ChunkGenerationOwnership.chunkZ(packed);
|
||||
long regionKey = ChunkGenerationOwnership.packChunk(chunkX >> 5, chunkZ >> 5);
|
||||
masks.computeIfAbsent(regionKey, ignored -> new BitSet(REGION_SIDE * REGION_SIDE))
|
||||
.set(MCAFile.getChunkIndex(chunkX & 31, chunkZ & 31));
|
||||
}
|
||||
return new WorldChunkInventory(masks);
|
||||
}
|
||||
|
||||
public static WorldChunkInventory scan(Path worldDirectory) throws IOException {
|
||||
return scanRegionDirectory(worldDirectory.resolve("region"));
|
||||
}
|
||||
|
||||
public static boolean isDurablyAllocated(Path worldDirectory, int chunkX, int chunkZ) throws IOException {
|
||||
Path regionDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory")
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
.resolve("region");
|
||||
if (!Files.exists(regionDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
if (!Files.isDirectory(regionDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("World region path is not a directory: " + regionDirectory);
|
||||
}
|
||||
int regionX = Math.floorDiv(chunkX, REGION_SIDE);
|
||||
int regionZ = Math.floorDiv(chunkZ, REGION_SIDE);
|
||||
Path regionFile = regionDirectory.resolve(MCAUtil.createNameFromRegionLocation(regionX, regionZ));
|
||||
if (!Files.exists(regionFile, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
if (!Files.isRegularFile(regionFile, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("World region entry is not a regular file: " + regionFile);
|
||||
}
|
||||
BitSet mask = scanAllocationTable(regionFile);
|
||||
return mask.get(MCAFile.getChunkIndex(chunkX & 31, chunkZ & 31));
|
||||
}
|
||||
|
||||
public static WorldChunkInventory scanRegionDirectory(Path regionDirectory) throws IOException {
|
||||
Path normalized = regionDirectory.toAbsolutePath().normalize();
|
||||
if (!Files.exists(normalized, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return empty();
|
||||
}
|
||||
if (!Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("World region path is not a directory: " + normalized);
|
||||
}
|
||||
|
||||
List<Path> regionFiles = collectRegionFiles(normalized);
|
||||
Long2ObjectOpenHashMap<BitSet> masks = new Long2ObjectOpenHashMap<>(regionFiles.size());
|
||||
for (Path regionFile : regionFiles) {
|
||||
RegionPosition position = parseRegionPosition(regionFile);
|
||||
long regionKey = ChunkGenerationOwnership.packChunk(position.x(), position.z());
|
||||
if (masks.containsKey(regionKey)) {
|
||||
throw new IOException(
|
||||
"Duplicate world region file for " + position.x() + "," + position.z()
|
||||
);
|
||||
}
|
||||
masks.put(regionKey, scanAllocationTable(regionFile));
|
||||
}
|
||||
return new WorldChunkInventory(masks);
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return chunkCount;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return chunkCount == 0;
|
||||
}
|
||||
|
||||
public boolean contains(int chunkX, int chunkZ) {
|
||||
BitSet mask = regionMasks.get(ChunkGenerationOwnership.packChunk(chunkX >> 5, chunkZ >> 5));
|
||||
return mask != null && mask.get(MCAFile.getChunkIndex(chunkX & 31, chunkZ & 31));
|
||||
}
|
||||
|
||||
public void forEach(ChunkConsumer consumer) throws IOException {
|
||||
Objects.requireNonNull(consumer, "consumer");
|
||||
for (long regionKey : regionKeys) {
|
||||
int baseX = MCAUtil.regionToChunk(ChunkGenerationOwnership.chunkX(regionKey));
|
||||
int baseZ = MCAUtil.regionToChunk(ChunkGenerationOwnership.chunkZ(regionKey));
|
||||
BitSet mask = regionMasks.get(regionKey);
|
||||
for (int index = mask.nextSetBit(0); index >= 0; index = mask.nextSetBit(index + 1)) {
|
||||
consumer.accept(baseX + (index & 31), baseZ + (index >> 5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int retainedAllocationBytes() {
|
||||
return Math.multiplyExact(regionKeys.length, REGION_SIDE * REGION_SIDE / Byte.SIZE);
|
||||
}
|
||||
|
||||
private static List<Path> collectRegionFiles(Path directory) throws IOException {
|
||||
List<Path> files = new ArrayList<>();
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory, "*.mca")) {
|
||||
for (Path file : stream) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("World region entry is not a regular file: " + file);
|
||||
}
|
||||
files.add(file);
|
||||
}
|
||||
}
|
||||
files.sort(Comparator.comparing(path -> path.getFileName().toString()));
|
||||
return files;
|
||||
}
|
||||
|
||||
private static RegionPosition parseRegionPosition(Path file) throws IOException {
|
||||
String name = file.getFileName().toString();
|
||||
Matcher matcher = REGION_FILE.matcher(name);
|
||||
if (!matcher.matches()) {
|
||||
throw new IOException("Invalid world region filename: " + file);
|
||||
}
|
||||
try {
|
||||
int regionX = Integer.parseInt(matcher.group(1));
|
||||
int regionZ = Integer.parseInt(matcher.group(2));
|
||||
if (!MCAUtil.createNameFromRegionLocation(regionX, regionZ).equals(name)) {
|
||||
throw new IOException("Noncanonical world region filename: " + file);
|
||||
}
|
||||
validateRegionCoordinate(file, regionX);
|
||||
validateRegionCoordinate(file, regionZ);
|
||||
return new RegionPosition(regionX, regionZ);
|
||||
} catch (NumberFormatException error) {
|
||||
throw new IOException("World region coordinate is outside the supported range: " + file, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static BitSet scanAllocationTable(Path file) throws IOException {
|
||||
try (RandomAccessFile input = new RandomAccessFile(file.toFile(), "r")) {
|
||||
long length = input.length();
|
||||
if (length < HEADER_BYTES || length % SECTOR_BYTES != 0L) {
|
||||
throw new IOException("Truncated world region file: " + file);
|
||||
}
|
||||
long sectorCapacity = length / SECTOR_BYTES;
|
||||
BitSet claimedSectors = new BitSet(Math.toIntExact(sectorCapacity));
|
||||
claimedSectors.set(0, HEADER_SECTORS);
|
||||
BitSet mask = new BitSet(REGION_SIDE * REGION_SIDE);
|
||||
for (int localZ = 0; localZ < REGION_SIDE; localZ++) {
|
||||
for (int localX = 0; localX < REGION_SIDE; localX++) {
|
||||
int index = MCAFile.getChunkIndex(localX, localZ);
|
||||
input.seek((long) index * Integer.BYTES);
|
||||
int location = input.readInt();
|
||||
if (location == 0) {
|
||||
continue;
|
||||
}
|
||||
int sectorOffset = location >>> Byte.SIZE;
|
||||
int sectorCount = location & 0xFF;
|
||||
validateAllocation(file, sectorOffset, sectorCount, sectorCapacity, claimedSectors);
|
||||
mask.set(index);
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
} catch (ArithmeticException error) {
|
||||
throw new IOException("World region file is too large to scan safely: " + file, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateAllocation(
|
||||
Path file,
|
||||
int sectorOffset,
|
||||
int sectorCount,
|
||||
long sectorCapacity,
|
||||
BitSet claimedSectors
|
||||
) throws IOException {
|
||||
long allocationEnd = (long) sectorOffset + sectorCount;
|
||||
if (sectorOffset < HEADER_SECTORS || sectorCount == 0 || allocationEnd > sectorCapacity) {
|
||||
throw new IOException("Invalid chunk allocation in world region file: " + file);
|
||||
}
|
||||
int overlap = claimedSectors.nextSetBit(sectorOffset);
|
||||
if (overlap >= 0 && overlap < allocationEnd) {
|
||||
throw new IOException("Overlapping chunk allocations in world region file: " + file);
|
||||
}
|
||||
claimedSectors.set(sectorOffset, Math.toIntExact(allocationEnd));
|
||||
}
|
||||
|
||||
private static void validateRegionCoordinate(Path file, int regionCoordinate) throws IOException {
|
||||
int minimum = Math.floorDiv(Integer.MIN_VALUE, REGION_SIDE);
|
||||
int maximum = Math.floorDiv(Integer.MAX_VALUE, REGION_SIDE);
|
||||
if (regionCoordinate < minimum || regionCoordinate > maximum) {
|
||||
throw new IOException("World region coordinate is outside the chunk coordinate range: " + file);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ChunkConsumer {
|
||||
void accept(int chunkX, int chunkZ) throws IOException;
|
||||
}
|
||||
|
||||
private record RegionPosition(int x, int z) {
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class HydrologyTile {
|
||||
private final HydrologyTileKey key;
|
||||
@@ -172,8 +173,23 @@ public final class HydrologyTile {
|
||||
int x,
|
||||
int z,
|
||||
int maximumDistance
|
||||
) {
|
||||
return nearestFeature(types, profileKey, x, z, maximumDistance, feature -> true);
|
||||
}
|
||||
|
||||
public Optional<HydrologyFeatureRef> nearestFeature(
|
||||
Set<HydrologyFeatureType> types,
|
||||
String profileKey,
|
||||
int x,
|
||||
int z,
|
||||
int maximumDistance,
|
||||
Predicate<HydrologyFeatureRef> eligibility
|
||||
) {
|
||||
Objects.requireNonNull(types, "types");
|
||||
Predicate<HydrologyFeatureRef> requiredEligibility = Objects.requireNonNull(
|
||||
eligibility,
|
||||
"eligibility"
|
||||
);
|
||||
if (maximumDistance < 0) {
|
||||
throw new IllegalArgumentException("maximumDistance cannot be negative.");
|
||||
}
|
||||
@@ -196,7 +212,7 @@ public final class HydrologyTile {
|
||||
continue;
|
||||
}
|
||||
HydrologyFeatureRef feature = publishedCenterlineFeature(segment, point);
|
||||
if (feature == null) {
|
||||
if (feature == null || !requiredEligibility.test(feature)) {
|
||||
continue;
|
||||
}
|
||||
if (distance < nearestDistance || nearest == null || feature.id() < nearest.id()) {
|
||||
|
||||
+41
-2
@@ -72,6 +72,7 @@ import java.util.Set;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.IntConsumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
private static final int MAXIMUM_CACHE_TILES = 64;
|
||||
@@ -351,7 +352,15 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
int z,
|
||||
int maximumDistance
|
||||
) {
|
||||
return nearestFeature(types, profileKey, x, z, maximumDistance, (int visited) -> { });
|
||||
return nearestFeature(
|
||||
types,
|
||||
profileKey,
|
||||
x,
|
||||
z,
|
||||
maximumDistance,
|
||||
feature -> true,
|
||||
(int visited) -> { }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,8 +375,32 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
int z,
|
||||
int maximumDistance,
|
||||
IntConsumer progress
|
||||
) {
|
||||
return nearestFeature(
|
||||
types,
|
||||
profileKey,
|
||||
x,
|
||||
z,
|
||||
maximumDistance,
|
||||
feature -> true,
|
||||
progress
|
||||
);
|
||||
}
|
||||
|
||||
public Optional<HydrologyFeatureRef> nearestFeature(
|
||||
Set<HydrologyFeatureType> types,
|
||||
String profileKey,
|
||||
int x,
|
||||
int z,
|
||||
int maximumDistance,
|
||||
Predicate<HydrologyFeatureRef> eligibility,
|
||||
IntConsumer progress
|
||||
) {
|
||||
Objects.requireNonNull(types);
|
||||
Predicate<HydrologyFeatureRef> requiredEligibility = Objects.requireNonNull(
|
||||
eligibility,
|
||||
"eligibility"
|
||||
);
|
||||
Objects.requireNonNull(progress);
|
||||
if (types.isEmpty() || maximumDistance < 0) {
|
||||
return Optional.empty();
|
||||
@@ -393,7 +426,13 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
for (HydrologyTile tile : cache.tiles(HydrologyFeatureSearch.ring(originTileX, originTileZ, ring))) {
|
||||
visited++;
|
||||
HydrologyFeatureRef feature = tile.nearestFeature(
|
||||
types, profileKey, x, z, maximumDistance).orElse(null);
|
||||
types,
|
||||
profileKey,
|
||||
x,
|
||||
z,
|
||||
maximumDistance,
|
||||
requiredEligibility
|
||||
).orElse(null);
|
||||
if (feature == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -232,16 +232,13 @@ public interface EngineMantle extends MatterGenerator {
|
||||
} else if (protectUpper) {
|
||||
int chunkBlockX = x << 4;
|
||||
int chunkBlockZ = z << 4;
|
||||
int gap = getEngine().getDimension().getUpperDimensionGap();
|
||||
int[] upperYs = new int[256];
|
||||
for (int i = 0; i < 256; i++) {
|
||||
int lx = i >> 4;
|
||||
int lz = i & 15;
|
||||
int worldX = chunkBlockX + lx;
|
||||
int worldZ = chunkBlockZ + lz;
|
||||
int he = (int) Math.round(getEngine().getComplex().getHeightStream().get((double) worldX, (double) worldZ));
|
||||
int rawUpper = upperCtx.getUpperSurfaceY(worldX, worldZ);
|
||||
upperYs[i] = Math.max(rawUpper, he + gap);
|
||||
upperYs[i] = upperCtx.getEffectiveSurfaceY(worldX, worldZ);
|
||||
}
|
||||
chunk.iterate(PlatformBlockState.class, (lx, y, lz, value) -> {
|
||||
int colIdx = (lx << 4) | (lz & 15);
|
||||
|
||||
@@ -28,6 +28,8 @@ import art.arcane.iris.core.link.Identifier;
|
||||
import art.arcane.iris.core.tools.WorldMaintenance;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.object.IrisGeneratorStyle;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.engine.object.TileData;
|
||||
@@ -73,6 +75,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
private final int z;
|
||||
private final int windowSide;
|
||||
private final AtomicReferenceArray<MantleChunk<Matter>> window;
|
||||
private final TransitionGenerationPlan transitionGenerationPlan;
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
@EqualsAndHashCode.Exclude
|
||||
@@ -97,6 +100,8 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
this.radius = accessRadius;
|
||||
this.x = x;
|
||||
this.z = z;
|
||||
IrisComplex complex = engineMantle.getComplex();
|
||||
this.transitionGenerationPlan = complex == null ? null : complex.getTransitionGenerationPlan();
|
||||
// Every coordinate acquireChunk accepts lives in this window, so a flat array replaces the
|
||||
// boxed per-block map lookup on the placement and carve hot paths.
|
||||
this.windowSide = (this.radius * 2) + 1;
|
||||
@@ -230,7 +235,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
}
|
||||
|
||||
public <T> void setData(int x, int y, int z, T t) {
|
||||
if (t == null) {
|
||||
if (t == null || !allowsWrite(x, z)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -264,7 +269,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
}
|
||||
|
||||
public boolean setDataIfAbsent(int x, int y, int z, MatterCavern value) {
|
||||
if (value == null) {
|
||||
if (value == null || !allowsWrite(x, z)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -305,7 +310,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
}
|
||||
|
||||
public boolean carveDataIfAbsent(int x, int y, int z, MatterCavern value) {
|
||||
if (value == null || y < 0 || y >= mantle.getWorldHeight()) {
|
||||
if (value == null || !allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -341,7 +346,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
}
|
||||
|
||||
public void setForcedCarve(int x, int y, int z, MatterCavern value) {
|
||||
if (value == null || y < 0 || y >= mantle.getWorldHeight()) {
|
||||
if (value == null || !allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) {
|
||||
return;
|
||||
}
|
||||
MantleChunk<Matter> chunk = acquireChunk(x >> 4, z >> 4);
|
||||
@@ -366,7 +371,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
}
|
||||
|
||||
public void clearBlock(int x, int y, int z) {
|
||||
if (y < 0 || y >= mantle.getWorldHeight()) {
|
||||
if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) {
|
||||
return;
|
||||
}
|
||||
MantleChunk<Matter> chunk = acquireChunk(x >> 4, z >> 4);
|
||||
@@ -402,6 +407,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!allowsWrite(x, z)) {
|
||||
return getDataIfPresent(x, y, z, type);
|
||||
}
|
||||
|
||||
MantleChunk<Matter> chunk = acquireChunk(cx, cz);
|
||||
if (chunk == null) {
|
||||
return null;
|
||||
@@ -526,7 +535,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
|
||||
public boolean restorePrerequisiteData(int x, int y, int z, Class<?> type) {
|
||||
Objects.requireNonNull(type, "type");
|
||||
if (y < 0 || y >= mantle.getWorldHeight() || !isPreObjectType(type)) {
|
||||
if (!allowsWrite(x, z)
|
||||
|| y < 0
|
||||
|| y >= mantle.getWorldHeight()
|
||||
|| !isPreObjectType(type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -551,7 +563,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
}
|
||||
|
||||
public boolean restorePrerequisiteCell(int x, int y, int z) {
|
||||
if (y < 0 || y >= mantle.getWorldHeight()) {
|
||||
if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -584,7 +596,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
}
|
||||
|
||||
public void clearData(int x, int y, int z, Class<?> type) {
|
||||
if (y < 0 || y >= mantle.getWorldHeight()) {
|
||||
if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -624,7 +636,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
}
|
||||
|
||||
private void setCustomBlock(int x, int y, int z, PlatformBlockState baseState, Identifier identifier) {
|
||||
if (y < 0 || y >= mantle.getWorldHeight()) {
|
||||
if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) {
|
||||
return;
|
||||
}
|
||||
if (y == 0 && engineMantle.getEngine().getDimension().isBedrock()) {
|
||||
@@ -711,6 +723,11 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
|
||||
return matter.getSlice(type).get(x, y, z);
|
||||
}
|
||||
|
||||
private boolean allowsWrite(int blockX, int blockZ) {
|
||||
return transitionGenerationPlan == null
|
||||
|| !transitionGenerationPlan.isHistoricalBlock(blockX, blockZ);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void restoreRaw(Matter matter, int x, int y, int z, Class<?> type, Object value) {
|
||||
MatterSlice<Object> slice;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.engine.mantle;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EnginePlatformHooks;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
@@ -45,10 +46,23 @@ public interface MatterGenerator {
|
||||
if (!getEngine().getDimension().isUseMantle()) {
|
||||
return;
|
||||
}
|
||||
generateMatterWindow(x, z, multicore, context);
|
||||
IrisComplex complex = context == null ? null : context.getComplex();
|
||||
if (complex == null) {
|
||||
complex = getEngine().getComplex();
|
||||
}
|
||||
if (complex != null && !complex.allowsMantleChunkWrite(x, z)) {
|
||||
return;
|
||||
}
|
||||
generateMatterWindow(x, z, multicore, context, complex);
|
||||
}
|
||||
|
||||
private void generateMatterWindow(int x, int z, boolean multicore, ChunkContext context) {
|
||||
private void generateMatterWindow(
|
||||
int x,
|
||||
int z,
|
||||
boolean multicore,
|
||||
ChunkContext context,
|
||||
IrisComplex complex
|
||||
) {
|
||||
|
||||
MatterGenerationPlan generationPlan = resolveGenerationPlan(x, z, context);
|
||||
MatterPassPlan[] passPlans = generationPlan.passPlans();
|
||||
@@ -128,6 +142,11 @@ public interface MatterGenerator {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (complex != null && !complex.allowsMantleChunkWrite(passX, passZ)) {
|
||||
partialChunks.add(passKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (partial) {
|
||||
partialChunks.add(passKey);
|
||||
}
|
||||
@@ -207,6 +226,9 @@ public interface MatterGenerator {
|
||||
if (partialChunks.contains(realKey)) {
|
||||
continue;
|
||||
}
|
||||
if (complex != null && !complex.allowsMantleChunkWrite(realX, realZ)) {
|
||||
continue;
|
||||
}
|
||||
writer.acquireChunk(realX, realZ).flag(MantleFlag.PLANNED, true);
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -21,6 +21,7 @@ package art.arcane.iris.engine.mantle.components;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.object.IObjectPlacer;
|
||||
import art.arcane.iris.engine.object.TileData;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
|
||||
@@ -67,7 +68,13 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer {
|
||||
return CommitResult.EMPTY;
|
||||
}
|
||||
|
||||
IrisComplex complex = engine == null ? null : engine.getComplex();
|
||||
for (BufferedMutation mutation : mutations) {
|
||||
if (complex != null
|
||||
&& !complex.allowsNewDiscreteContentAt(mutation.x(), mutation.z())) {
|
||||
discard();
|
||||
return CommitResult.REJECTED_TRANSITION;
|
||||
}
|
||||
if (!isWithinBounds(mutation.x(), mutation.y(), mutation.z())) {
|
||||
discard();
|
||||
return CommitResult.REJECTED_BOUNDS;
|
||||
@@ -252,6 +259,7 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer {
|
||||
enum CommitResult {
|
||||
COMMITTED,
|
||||
EMPTY,
|
||||
REJECTED_TRANSITION,
|
||||
REJECTED_BOUNDS,
|
||||
REJECTED_HYDROLOGY
|
||||
}
|
||||
|
||||
+9
@@ -20,6 +20,7 @@ package art.arcane.iris.engine.mantle.components;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.object.IObjectPlacer;
|
||||
import art.arcane.iris.engine.object.TileData;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
@@ -47,7 +48,14 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer {
|
||||
discard();
|
||||
return CommitResult.EMPTY;
|
||||
}
|
||||
Engine engine = delegate.getEngine();
|
||||
IrisComplex complex = engine == null ? null : engine.getComplex();
|
||||
for (BufferedMutation mutation : mutations) {
|
||||
if (complex != null
|
||||
&& !complex.allowsNewDiscreteContentAt(mutation.x(), mutation.z())) {
|
||||
discard();
|
||||
return CommitResult.REJECTED_TRANSITION;
|
||||
}
|
||||
if (!delegate.canWriteObjectBlock(mutation.x(), mutation.y(), mutation.z())) {
|
||||
discard();
|
||||
return CommitResult.REJECTED_SUPPORT;
|
||||
@@ -170,6 +178,7 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer {
|
||||
enum CommitResult {
|
||||
COMMITTED,
|
||||
EMPTY,
|
||||
REJECTED_TRANSITION,
|
||||
REJECTED_SUPPORT
|
||||
}
|
||||
|
||||
|
||||
+31
-3
@@ -21,6 +21,7 @@ package art.arcane.iris.engine.mantle.components;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.engine.framework.PlacedStructurePiece;
|
||||
import art.arcane.iris.engine.framework.StructurePlacementScope;
|
||||
@@ -76,17 +77,27 @@ public class IrisStructureComponent extends IrisMantleComponent {
|
||||
@Override
|
||||
@ChunkCoordinates
|
||||
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
|
||||
IrisComplex complex = context.getComplex();
|
||||
if (!complex.allowsNewGenerationChunk(x, z)) {
|
||||
return;
|
||||
}
|
||||
for (IrisStructurePlacement placement : StructurePlacementScope.placementsAt(
|
||||
getEngineMantle().getEngine(), x, z)) {
|
||||
if (!placement.hasIrisStructures()) {
|
||||
continue;
|
||||
}
|
||||
placeFromPlacement(writer, placement, x, z);
|
||||
placeFromPlacement(writer, placement, x, z, complex);
|
||||
}
|
||||
}
|
||||
|
||||
@ChunkCoordinates
|
||||
private void placeFromPlacement(MantleWriter writer, IrisStructurePlacement placement, int cx, int cz) {
|
||||
private void placeFromPlacement(
|
||||
MantleWriter writer,
|
||||
IrisStructurePlacement placement,
|
||||
int cx,
|
||||
int cz,
|
||||
IrisComplex complex
|
||||
) {
|
||||
IrisStructureLocator.ResolvedPlacement resolved = IrisStructureLocator.resolvePlacement(
|
||||
getEngineMantle().getEngine(), placement, cx, cz);
|
||||
if (resolved == null) {
|
||||
@@ -105,6 +116,19 @@ public class IrisStructureComponent extends IrisMantleComponent {
|
||||
if (pieces == null) {
|
||||
return;
|
||||
}
|
||||
IrisStructureTerrain terrain = placement.resolvedTerrain();
|
||||
int[] bounds = computePieceBounds(pieces);
|
||||
int horizontalPadding = terrain.resolvedMode() == IrisStructureTerrainMode.FORCE_CARVE
|
||||
|| terrain.resolvedMode() == IrisStructureTerrainMode.BORE
|
||||
? Math.max(0, terrain.getHorizontalPadding())
|
||||
: 0;
|
||||
if (bounds == null || !complex.allowsNewGenerationFootprint(
|
||||
saturatedOffset(bounds[0], -horizontalPadding),
|
||||
saturatedOffset(bounds[2], -horizontalPadding),
|
||||
saturatedOffset(bounds[3], horizontalPadding),
|
||||
saturatedOffset(bounds[5], horizontalPadding))) {
|
||||
return;
|
||||
}
|
||||
RNG rng = resolved.rng();
|
||||
int baseY = resolved.baseY();
|
||||
if (trace) {
|
||||
@@ -115,7 +139,6 @@ public class IrisStructureComponent extends IrisMantleComponent {
|
||||
clearIntersectingObjectTrees(writer, resolved);
|
||||
}
|
||||
|
||||
IrisStructureTerrain terrain = placement.resolvedTerrain();
|
||||
IrisStructureTerrainMode terrainMode = terrain.resolvedMode();
|
||||
if (terrainMode == IrisStructureTerrainMode.FORCE_CARVE) {
|
||||
forceCarveStructure(writer, pieces, terrain);
|
||||
@@ -529,6 +552,11 @@ public class IrisStructureComponent extends IrisMantleComponent {
|
||||
return new int[]{minX, minY, minZ, maxX, maxY, maxZ};
|
||||
}
|
||||
|
||||
private static int saturatedOffset(int coordinate, int offset) {
|
||||
long result = (long) coordinate + offset;
|
||||
return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, result));
|
||||
}
|
||||
|
||||
private int placeObject(MantleWriter writer, IrisStructure structure, PlacedStructurePiece p,
|
||||
ObjectPlaceMode mode, int y, RNG rng, Long2IntOpenHashMap foundationColumns,
|
||||
boolean supportNonOccluding) {
|
||||
|
||||
+6
-6
@@ -98,6 +98,9 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
@Override
|
||||
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
|
||||
IrisComplex complex = context.getComplex();
|
||||
if (!complex.allowsNewGenerationChunk(x, z)) {
|
||||
return;
|
||||
}
|
||||
IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State();
|
||||
BlendScratch blendScratch = BLEND_SCRATCH.get();
|
||||
int[] chunkSurfaceHeights = prepareChunkSurfaceHeights(x, z, context, blendScratch.chunkSurfaceHeights);
|
||||
@@ -121,7 +124,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
|
||||
UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext();
|
||||
if (upperCtx != null && getDimension().isUpperDimensionCarving()) {
|
||||
carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, chunkSurfaceHeights, fluidSupportPlan);
|
||||
carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, fluidSupportPlan);
|
||||
}
|
||||
fluidSupportPlan.resolve(writer.acquireChunk(x, z));
|
||||
|
||||
@@ -154,11 +157,10 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
}
|
||||
|
||||
private void carveUpperTerrain(UpperDimensionContext upperCtx, List<WeightedProfile> normalProfiles,
|
||||
MantleWriter writer, int cx, int cz, int[] lowerSurfaceHeights,
|
||||
MantleWriter writer, int cx, int cz,
|
||||
CaveFluidSupportPlan fluidSupportPlan) {
|
||||
int chunkHeight = getEngineMantle().getEngine().getHeight();
|
||||
int worldMinHeight = getEngineMantle().getEngine().getWorld().minHeight();
|
||||
int gap = getDimension().getUpperDimensionGap();
|
||||
int baseX = PowerOfTwoCoordinates.chunkToBlock(cx);
|
||||
int baseZ = PowerOfTwoCoordinates.chunkToBlock(cz);
|
||||
|
||||
@@ -167,9 +169,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
int worldX = baseX + localX;
|
||||
for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) {
|
||||
int worldZ = baseZ + localZ;
|
||||
int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ);
|
||||
int rawUpper = upperCtx.getUpperSurfaceY(worldX, worldZ);
|
||||
int upperY = Math.max(rawUpper, lowerSurfaceHeights[columnIndex] + gap);
|
||||
int upperY = upperCtx.getEffectiveSurfaceY(worldX, worldZ);
|
||||
if (upperY < minUpperSurfaceY) {
|
||||
minUpperSurfaceY = upperY;
|
||||
}
|
||||
|
||||
+17
@@ -65,6 +65,9 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
|
||||
@Override
|
||||
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
|
||||
IrisComplex complex = context.getComplex();
|
||||
if (!complex.allowsNewGenerationChunk(x, z)) {
|
||||
return;
|
||||
}
|
||||
IrisData data = getData();
|
||||
int minX = x << 4;
|
||||
int minZ = z << 4;
|
||||
@@ -201,6 +204,14 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
|
||||
int xx = minX + (key & 15);
|
||||
int zz = minZ + (key >> 4);
|
||||
IrisObjectPlacement floatingPlacement = placement.toPlacement(obj.getLoadKey());
|
||||
if (!MantleObjectComponent.allowsObjectPlacement(
|
||||
complex,
|
||||
obj,
|
||||
floatingPlacement,
|
||||
xx,
|
||||
zz)) {
|
||||
continue;
|
||||
}
|
||||
int id = rng.i(0, Integer.MAX_VALUE);
|
||||
|
||||
try {
|
||||
@@ -279,6 +290,9 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
|
||||
anchored.setRotation(ROTATION_NONE);
|
||||
anchored.setForcePlace(true);
|
||||
anchored.setBottom(false);
|
||||
if (!MantleObjectComponent.allowsObjectPlacement(complex, obj, anchored, wx, wz)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int yv = pickTopY + 1 - fp.getLowestSolidKeyY();
|
||||
|
||||
@@ -385,6 +399,9 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
|
||||
inverted.setRotation(invertedRotation);
|
||||
inverted.setForcePlace(true);
|
||||
inverted.setBottom(false);
|
||||
if (!MantleObjectComponent.allowsObjectPlacement(complex, obj, inverted, wx, wz)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int yv = invertedBaseY(pickBottomY, fp, invertedRotation);
|
||||
|
||||
|
||||
+12
-9
@@ -11,6 +11,7 @@ import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
|
||||
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.mantle.ComponentFlag;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.mantle.IrisMantleComponent;
|
||||
@@ -71,8 +72,9 @@ public final class MantleHydrologyComponent extends IrisMantleComponent {
|
||||
|
||||
@Override
|
||||
public void generateLayer(MantleWriter writer, int chunkX, int chunkZ, ChunkContext context) {
|
||||
IrisHydrologyRuntime runtime = context.getComplex().getHydrologyRuntime();
|
||||
if (runtime == null) {
|
||||
IrisComplex complex = context.getComplex();
|
||||
IrisHydrologyRuntime runtime = complex.getHydrologyRuntime();
|
||||
if (runtime == null || !complex.allowsNewGenerationChunk(chunkX, chunkZ)) {
|
||||
return;
|
||||
}
|
||||
int minimumX = chunkX << 4;
|
||||
@@ -88,14 +90,15 @@ public final class MantleHydrologyComponent extends IrisMantleComponent {
|
||||
CaveVoxelView caveView = new MantleHydrologyCaveVoxelView(
|
||||
writer.getMantle(),
|
||||
writer.getMantle().getWorldHeight(),
|
||||
(x, z) -> runtime.sample(x, z)
|
||||
.map(HydrologyColumnSample::terrainHeight)
|
||||
.orElseGet(() -> (int) Math.round(
|
||||
context.getComplex().getNaturalHeightStream().getDouble(x, z)
|
||||
)),
|
||||
(x, z) -> {
|
||||
HydrologyColumnSample sample = complex.sampleHydrologyColumn(x, z);
|
||||
return sample == null
|
||||
? (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z))
|
||||
: sample.terrainHeight();
|
||||
},
|
||||
(inputChunkX, inputChunkZ) -> MantleHydrologyCaveVoxelView.generateCarvingInput(
|
||||
getEngineMantle(),
|
||||
context.getComplex(),
|
||||
complex,
|
||||
inputChunkX,
|
||||
inputChunkZ
|
||||
)
|
||||
@@ -104,7 +107,7 @@ public final class MantleHydrologyComponent extends IrisMantleComponent {
|
||||
chunkX,
|
||||
chunkZ,
|
||||
writer.getMantle().getWorldHeight(),
|
||||
runtime::sample,
|
||||
(x, z) -> Optional.ofNullable(complex.sampleHydrologyColumn(x, z)),
|
||||
cavePlans,
|
||||
caveView
|
||||
);
|
||||
|
||||
+48
-7
@@ -183,6 +183,9 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
|
||||
void generateOrigin(ObjectPassPlacer writer, int x, int z, ChunkContext context) {
|
||||
IrisComplex complex = context.getComplex();
|
||||
if (!complex.allowsNewGenerationChunk(x, z)) {
|
||||
return;
|
||||
}
|
||||
boolean traceRegen = isRegenTraceThread();
|
||||
RNG rng = applyNoise(x, z, Cache.key(x, z) + seed());
|
||||
int xxx = 8 + (x << 4);
|
||||
@@ -585,6 +588,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
IrisComplex complex = getComplex();
|
||||
int blockX = x << 4;
|
||||
int blockZ = z << 4;
|
||||
boolean golden = GoldenDebugObjectPlacer.isGoldenDebugChunk(x, z);
|
||||
@@ -693,6 +697,9 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
placeResult = contained.resultY();
|
||||
commitResult = contained.commitResult();
|
||||
} else {
|
||||
if (!allowsObjectPlacement(complex, variant, placement, xx, zz)) {
|
||||
continue;
|
||||
}
|
||||
String marker = placementMarker(variant, id, "procedural");
|
||||
placeResult = variant.place(xx, -1, zz, placer, placement, rng, (b, data) -> {
|
||||
if (marker != null) {
|
||||
@@ -813,6 +820,12 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
boolean treePlacement,
|
||||
RNG rng
|
||||
) {
|
||||
IrisComplex complex = placer.getEngine() == null ? null : placer.getEngine().getComplex();
|
||||
if (!allowsObjectPlacement(complex, object, placement, x, z)) {
|
||||
return new ContainedPlacementResult(
|
||||
-1,
|
||||
CaveObjectPlacementTransaction.CommitResult.REJECTED_TRANSITION);
|
||||
}
|
||||
CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(placer, anchorY, minDepthBelowSurface);
|
||||
int placeY = anchorY;
|
||||
if (placement.getMode() == ObjectPlaceMode.CEILING_HANG) {
|
||||
@@ -875,6 +888,10 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
int xx = rng.i(x, x + 16);
|
||||
int zz = rng.i(z, z + 16);
|
||||
IrisObjectPlacement effectivePlacement = resolveEffectivePlacement(objectPlacement, v);
|
||||
if (!allowsObjectPlacement(complex, v, effectivePlacement, xx, zz)) {
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
boolean treePlacement = isTreePlacement(v, effectivePlacement);
|
||||
int id = rng.i(0, Integer.MAX_VALUE);
|
||||
IObjectPlacer placePlacer = golden ? new GoldenDebugObjectPlacer(writer, scope + "/" + v.getLoadKey()) : writer;
|
||||
@@ -1186,7 +1203,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, dimension, complex, forcePlace, traceRegen, "upper-biome-surface");
|
||||
placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, complex, forcePlace, traceRegen, "upper-biome-surface");
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
IrisLogging.error("Failed to place upper-dimension objects in biome " + upperBiome.getName()
|
||||
@@ -1201,7 +1218,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, dimension, complex, forcePlace, traceRegen, "upper-region-surface");
|
||||
placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, complex, forcePlace, traceRegen, "upper-region-surface");
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
IrisLogging.error("Failed to place upper-dimension objects in region " + upperRegion.getName()
|
||||
@@ -1219,14 +1236,12 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
int chunkZ,
|
||||
IrisObjectPlacement objectPlacement,
|
||||
UpperDimensionContext upperCtx,
|
||||
IrisDimension dimension,
|
||||
IrisComplex complex,
|
||||
boolean forcePlace,
|
||||
boolean traceRegen,
|
||||
String scope
|
||||
) {
|
||||
int chunkHeight = getEngineMantle().getEngine().getHeight();
|
||||
int upperGap = dimension.getUpperDimensionGap();
|
||||
int minX = chunkX << 4;
|
||||
int minZ = chunkZ << 4;
|
||||
int density = objectPlacement.getDensity(rng, minX, minZ, getData());
|
||||
@@ -1239,9 +1254,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
|
||||
int xx = rng.i(minX, minX + 16);
|
||||
int zz = rng.i(minZ, minZ + 16);
|
||||
int columnLowerSurfaceY = getEngineMantle().getEngine().getHeight(xx, zz, true);
|
||||
int rawUpperSurface = upperCtx.getUpperSurfaceY(xx, zz);
|
||||
int upperSurfaceY = Math.max(rawUpperSurface, columnLowerSurfaceY + upperGap);
|
||||
int upperSurfaceY = upperCtx.getEffectiveSurfaceY(xx, zz);
|
||||
if (upperSurfaceY >= chunkHeight - 2) {
|
||||
continue;
|
||||
}
|
||||
@@ -1260,6 +1273,9 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
if (forcePlace) {
|
||||
placement.setForcePlace(true);
|
||||
}
|
||||
if (!allowsObjectPlacement(complex, v, placement, xx, zz)) {
|
||||
continue;
|
||||
}
|
||||
boolean treePlacement = isTreePlacement(v, objectPlacement);
|
||||
String marker = placementMarker(v, id, "upper");
|
||||
|
||||
@@ -1781,6 +1797,31 @@ public class MantleObjectComponent extends IrisMantleComponent {
|
||||
return reach > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) reach;
|
||||
}
|
||||
|
||||
static boolean allowsObjectPlacement(
|
||||
IrisComplex complex,
|
||||
IrisObject object,
|
||||
IrisObjectPlacement placement,
|
||||
int anchorX,
|
||||
int anchorZ
|
||||
) {
|
||||
if (complex == null || object == null) {
|
||||
return true;
|
||||
}
|
||||
int reach = calculatePlacementReach(
|
||||
new IrisBlockVector(object.getW(), object.getH(), object.getD()),
|
||||
placement);
|
||||
return complex.allowsNewGenerationFootprint(
|
||||
saturatedOffset(anchorX, -reach),
|
||||
saturatedOffset(anchorZ, -reach),
|
||||
saturatedOffset(anchorX, reach),
|
||||
saturatedOffset(anchorZ, reach));
|
||||
}
|
||||
|
||||
private static int saturatedOffset(int coordinate, int offset) {
|
||||
long result = (long) coordinate + offset;
|
||||
return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, result));
|
||||
}
|
||||
|
||||
private static int scaledDimension(int dimension, double scale) {
|
||||
int absoluteDimension = Math.abs(dimension);
|
||||
if (scale <= 1D) {
|
||||
|
||||
@@ -104,9 +104,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
if (protectUpper) {
|
||||
int worldX = localX + chunkBlockX;
|
||||
int worldZ = localZ + chunkBlockZ;
|
||||
int rawUpper = upperCtx.getUpperSurfaceY(worldX, worldZ);
|
||||
int gap = getEngine().getDimension().getUpperDimensionGap();
|
||||
upperSurfaceHeights[columnIndex] = Math.max(rawUpper, surfaceHeights[columnIndex] + gap);
|
||||
upperSurfaceHeights[columnIndex] = upperCtx.getEffectiveSurfaceY(worldX, worldZ);
|
||||
}
|
||||
if (stackLayouts != null) {
|
||||
stackLayouts[columnIndex] = context.getDimensionStackLayout(localX, localZ);
|
||||
|
||||
+19
-11
@@ -286,15 +286,17 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Platf
|
||||
}
|
||||
}
|
||||
|
||||
writeIslandSkyBiomes(
|
||||
parent,
|
||||
wx,
|
||||
wz,
|
||||
sample,
|
||||
chunkHeight,
|
||||
data,
|
||||
context.getDimensionStackLayout(xf, zf)
|
||||
);
|
||||
if (complex.allowsNewDiscreteContentAt(wx, wz)) {
|
||||
writeIslandSkyBiomes(
|
||||
parent,
|
||||
wx,
|
||||
wz,
|
||||
sample,
|
||||
chunkHeight,
|
||||
data,
|
||||
context.getDimensionStackLayout(xf, zf)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +314,9 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Platf
|
||||
for (int zf = 0; zf < 16; zf++) {
|
||||
int wx = x + xf;
|
||||
int wz = z + zf;
|
||||
if (!complex.allowsNewDiscreteContentAt(wx, wz)) {
|
||||
continue;
|
||||
}
|
||||
IrisBiome parent = boundarySampler.parent(wx, wz);
|
||||
if (parent == null || parent.getFloatingChildBiomes() == null || parent.getFloatingChildBiomes().isEmpty()) {
|
||||
continue;
|
||||
@@ -422,8 +427,11 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Platf
|
||||
private MatterBiomeInject createSkyBiomeMatter(IrisBiome target, int wx, int wz) {
|
||||
if (target.isCustom()) {
|
||||
IrisBiomeCustom custom = target.getCustomBiome(rng, getEngine(), wx, 0, wz);
|
||||
return BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(
|
||||
getDimension().getCustomBiomeKey(custom.getId())));
|
||||
String resourceKey = getEngine().getData().customBiomeResourceKey(
|
||||
getEngine().getDimension(),
|
||||
custom
|
||||
);
|
||||
return BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(resourceKey));
|
||||
}
|
||||
|
||||
return BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(target.getSkyBiomeKey(rng, getEngine(), wx, 0, wz)));
|
||||
|
||||
@@ -114,30 +114,34 @@ final class IrisBiomeDerivatives {
|
||||
|
||||
static String getSkyBiomeKey(IrisBiome biome, RNG rng, Engine engine, double x, double y, double z) {
|
||||
KList<String> biomeSkyScatter = biome.getBiomeSkyScatter();
|
||||
String logicalKey;
|
||||
|
||||
if (biomeSkyScatter.size() == 1) {
|
||||
return namespacedBiomeKey(biomeSkyScatter.get(0));
|
||||
}
|
||||
|
||||
if (biomeSkyScatter.isEmpty()) {
|
||||
logicalKey = namespacedBiomeKey(biomeSkyScatter.get(0));
|
||||
} else if (biomeSkyScatter.isEmpty()) {
|
||||
return getGroundBiomeKey(biome, rng, engine, x, y, z);
|
||||
} else {
|
||||
logicalKey = namespacedBiomeKey(biomeSkyScatter.get(
|
||||
biome.getBiomeGenerator(rng, engine).fit(0, biomeSkyScatter.size() - 1, x, y, z)
|
||||
));
|
||||
}
|
||||
|
||||
return namespacedBiomeKey(biomeSkyScatter.get(biome.getBiomeGenerator(rng, engine).fit(0, biomeSkyScatter.size() - 1, x, y, z)));
|
||||
return physicalBiomeKey(engine, logicalKey);
|
||||
}
|
||||
|
||||
static String getGroundBiomeKey(IrisBiome biome, RNG rng, Engine engine, double x, double y, double z) {
|
||||
KList<String> biomeScatter = biome.getBiomeScatter();
|
||||
String logicalKey;
|
||||
|
||||
if (biomeScatter.isEmpty()) {
|
||||
return biome.getDerivativeKey();
|
||||
logicalKey = biome.getDerivativeKey();
|
||||
} else if (biomeScatter.size() == 1) {
|
||||
logicalKey = namespacedBiomeKey(biomeScatter.get(0));
|
||||
} else {
|
||||
logicalKey = namespacedBiomeKey(biomeScatter.get(
|
||||
biome.getBiomeGenerator(rng, engine).fit(0, biomeScatter.size() - 1, x, y, z)
|
||||
));
|
||||
}
|
||||
|
||||
if (biomeScatter.size() == 1) {
|
||||
return namespacedBiomeKey(biomeScatter.get(0));
|
||||
}
|
||||
|
||||
return namespacedBiomeKey(biomeScatter.get(biome.getBiomeGenerator(rng, engine).fit(0, biomeScatter.size() - 1, x, y, z)));
|
||||
return physicalBiomeKey(engine, logicalKey);
|
||||
}
|
||||
|
||||
static IrisBiomeCustom getCustomBiome(IrisBiome biome, RNG rng, Engine engine, double x, double y, double z) {
|
||||
@@ -149,4 +153,11 @@ final class IrisBiomeDerivatives {
|
||||
|
||||
return customDerivitives.get(biome.getBiomeGenerator(rng, engine).fit(0, customDerivitives.size() - 1, x, y, z));
|
||||
}
|
||||
|
||||
private static String physicalBiomeKey(Engine engine, String logicalKey) {
|
||||
if (engine == null || logicalKey == null) {
|
||||
return logicalKey;
|
||||
}
|
||||
return engine.getData().physicalBiomeResourceKey(engine.getDimension(), logicalKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface IrisCustomBiomeAliasResolver {
|
||||
Collection<String> aliases(
|
||||
IrisDimension dimension,
|
||||
IrisBiome biome,
|
||||
IrisBiomeCustom customBiome,
|
||||
String physicalResourceKey
|
||||
);
|
||||
|
||||
default String physicalResourceKey(
|
||||
IrisDimension dimension,
|
||||
IrisBiome biome,
|
||||
IrisBiomeCustom customBiome,
|
||||
String currentPhysicalResourceKey
|
||||
) throws IOException {
|
||||
return currentPhysicalResourceKey;
|
||||
}
|
||||
|
||||
default String generatedSource(
|
||||
String registryKey,
|
||||
String resourceKey,
|
||||
String currentSource,
|
||||
IDataFixer fixer
|
||||
) throws IOException {
|
||||
return currentSource;
|
||||
}
|
||||
|
||||
static IrisCustomBiomeAliasResolver none() {
|
||||
return (dimension, biome, customBiome, physicalResourceKey) -> List.of();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer.Dimension;
|
||||
import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContractFactory;
|
||||
import art.arcane.iris.engine.object.annotations.ArrayType;
|
||||
import art.arcane.iris.engine.object.annotations.Desc;
|
||||
import art.arcane.iris.engine.object.annotations.MaxNumber;
|
||||
@@ -64,6 +65,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
@@ -71,6 +73,7 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -730,22 +733,21 @@ public class IrisDimension extends IrisRegistrant {
|
||||
}
|
||||
|
||||
public void installBiomes(IDataFixer fixer, DataProvider data, KList<File> datapackRoots, KSet<String> biomes) throws IOException {
|
||||
String keyPrefix = getCustomBiomeKeyPrefix();
|
||||
int separator = keyPrefix.indexOf(':');
|
||||
String namespace = keyPrefix.substring(0, separator);
|
||||
String pathPrefix = keyPrefix.substring(separator + 1);
|
||||
if (pathPrefix.endsWith("/")) {
|
||||
pathPrefix = pathPrefix.substring(0, pathPrefix.length() - 1);
|
||||
}
|
||||
installBiomes(fixer, data, datapackRoots, namespace, pathPrefix, biomes);
|
||||
installBiomes(fixer, data, datapackRoots, biomes, IrisCustomBiomeAliasResolver.none());
|
||||
}
|
||||
|
||||
public void installBiomes(IDataFixer fixer, DataProvider data, KList<File> datapackRoots,
|
||||
String namespace, String pathPrefix, KSet<String> biomes) throws IOException {
|
||||
public void installBiomes(
|
||||
IDataFixer fixer,
|
||||
DataProvider data,
|
||||
KList<File> datapackRoots,
|
||||
KSet<String> biomes,
|
||||
IrisCustomBiomeAliasResolver aliasResolver
|
||||
) throws IOException {
|
||||
// Tag membership is accumulated in memory and flushed once per tag at the end of the walk. Writing a
|
||||
// tag file per (biome, tag) pair made staging quadratic: every write re-read, re-parsed and re-emitted
|
||||
// a file that grows with every biome added to that tag.
|
||||
KMap<String, KSet<String>> tagMembership = new KMap<>();
|
||||
IrisData packData = Objects.requireNonNull(data.getData(), "Iris biome datapack data");
|
||||
|
||||
for (IrisBiome irisBiome : getAllBiomes(data)) {
|
||||
if (!irisBiome.isCustom()) {
|
||||
@@ -758,32 +760,58 @@ public class IrisDimension extends IrisRegistrant {
|
||||
// comes from the chunk generator's generation-settings getter, not from the datapack.
|
||||
String derivativeKey = irisBiome.getVanillaDerivativeKey();
|
||||
|
||||
ContentGate contentGate = getLoader() == null ? null : getLoader().getContentGate();
|
||||
ContentGate contentGate = packData.getContentGate();
|
||||
|
||||
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
|
||||
String customBiomeId = customBiome.getId();
|
||||
String customBiomePath = sanitizeRegistryPath(customBiomeId, "biome");
|
||||
String json = customBiome.generateJson(fixer, contentGate);
|
||||
String currentPhysicalKey = GenerationRegistryContractFactory.customBiomeResourceKey(
|
||||
getLoadKey(),
|
||||
customBiome,
|
||||
contentGate
|
||||
);
|
||||
String physicalKey = aliasResolver.physicalResourceKey(
|
||||
this,
|
||||
irisBiome,
|
||||
customBiome,
|
||||
currentPhysicalKey
|
||||
);
|
||||
String logicalKey = getLoadKey().toLowerCase(Locale.ROOT)
|
||||
+ ":" + customBiome.getId().toLowerCase(Locale.ROOT);
|
||||
|
||||
synchronized (biomes) {
|
||||
if (!biomes.add(customBiomePath)) {
|
||||
IrisLogging.debug("Duplicate Data Pack Biome: " + getLoadKey() + "/" + customBiomeId);
|
||||
continue;
|
||||
biomes.add(physicalKey);
|
||||
}
|
||||
|
||||
LinkedHashSet<String> resourceKeys = new LinkedHashSet<>();
|
||||
resourceKeys.add(physicalKey);
|
||||
Collection<String> aliases = aliasResolver.aliases(this, irisBiome, customBiome, physicalKey);
|
||||
if (aliases == null) {
|
||||
throw new IOException("Custom biome alias resolver returned null for '" + logicalKey + "'.");
|
||||
}
|
||||
if (!aliases.isEmpty()
|
||||
&& physicalKey.equals(packData.customBiomeResourceKey(this, customBiome.getId()))) {
|
||||
resourceKeys.addAll(aliases);
|
||||
}
|
||||
for (String resourceKey : resourceKeys) {
|
||||
String normalizedKey = normalizeResourceKey(resourceKey);
|
||||
if (normalizedKey == null) {
|
||||
throw new IOException("Invalid custom biome resource key '" + resourceKey + "'.");
|
||||
}
|
||||
String emittedJson = aliasResolver.generatedSource(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
normalizedKey,
|
||||
json,
|
||||
fixer
|
||||
);
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
writeCustomBiomeDefinition(datapackRoot.toPath(), normalizedKey, emittedJson);
|
||||
}
|
||||
collectBiomeTags(
|
||||
tagMembership,
|
||||
normalizedKey,
|
||||
customBiome.getEffectiveTags(derivativeKey)
|
||||
);
|
||||
}
|
||||
|
||||
String biomePath = pathPrefix.isBlank()
|
||||
? customBiomePath
|
||||
: pathPrefix + "/" + customBiomePath;
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
File output = new File(datapackRoot, "data/" + namespace + "/worldgen/biome/" + biomePath + ".json");
|
||||
|
||||
IrisLogging.debug(" Installing Data Pack Biome: " + output.getPath());
|
||||
output.getParentFile().mkdirs();
|
||||
IO.writeAll(output, json);
|
||||
}
|
||||
collectBiomeTags(tagMembership, namespace + ":" + biomePath,
|
||||
customBiome.getEffectiveTags(derivativeKey));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,6 +820,39 @@ public class IrisDimension extends IrisRegistrant {
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeCustomBiomeDefinition(
|
||||
Path datapackRoot,
|
||||
String resourceKey,
|
||||
String json
|
||||
) throws IOException {
|
||||
int separator = resourceKey.indexOf(':');
|
||||
String namespace = resourceKey.substring(0, separator);
|
||||
String path = resourceKey.substring(separator + 1);
|
||||
Path biomeRoot = datapackRoot.resolve("data")
|
||||
.resolve(namespace)
|
||||
.resolve("worldgen")
|
||||
.resolve("biome")
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
Path output = biomeRoot.resolve(path + ".json").normalize();
|
||||
if (!output.startsWith(biomeRoot)) {
|
||||
throw new IOException("Unsafe custom biome resource key '" + resourceKey + "'.");
|
||||
}
|
||||
if (Files.isRegularFile(output)) {
|
||||
String existing = Files.readString(output, StandardCharsets.UTF_8);
|
||||
String existingFingerprint = GenerationRegistryContractFactory
|
||||
.fingerprintCustomBiomeDefinition(existing);
|
||||
String candidateFingerprint = GenerationRegistryContractFactory
|
||||
.fingerprintCustomBiomeDefinition(json);
|
||||
if (!existingFingerprint.equals(candidateFingerprint)) {
|
||||
throw new IOException("Conflicting custom biome output for '" + resourceKey + "'.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
IrisLogging.debug(" Installing Data Pack Biome: " + output);
|
||||
writeAtomic(output, json);
|
||||
}
|
||||
|
||||
public static void clearGeneratedBiomeTags(KList<File> datapackRoots) {
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
File dataFolder = new File(datapackRoot, "data");
|
||||
@@ -993,11 +1054,25 @@ public class IrisDimension extends IrisRegistrant {
|
||||
}
|
||||
|
||||
public void installDimensionType(IDataFixer fixer, KList<File> datapackRoots) throws IOException {
|
||||
IrisDimensionType type = getDimensionType();
|
||||
String json = type.toJson(fixer);
|
||||
String dimensionTypeKey = getDimensionTypeKey();
|
||||
installDimensionType(fixer, datapackRoots, IrisCustomBiomeAliasResolver.none());
|
||||
}
|
||||
|
||||
IrisLogging.debug(" Installing Data Pack Dimension Type: \"iris:" + dimensionTypeKey + '"');
|
||||
public void installDimensionType(
|
||||
IDataFixer fixer,
|
||||
KList<File> datapackRoots,
|
||||
IrisCustomBiomeAliasResolver sourceResolver
|
||||
) throws IOException {
|
||||
IrisDimensionType type = getDimensionType();
|
||||
String dimensionTypeKey = getDimensionTypeKey();
|
||||
String resourceKey = "iris:" + dimensionTypeKey;
|
||||
String json = Objects.requireNonNull(sourceResolver, "sourceResolver").generatedSource(
|
||||
GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY,
|
||||
resourceKey,
|
||||
type.toJson(fixer),
|
||||
fixer
|
||||
);
|
||||
|
||||
IrisLogging.debug(" Installing Data Pack Dimension Type: \"" + resourceKey + '"');
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
File output = new File(datapackRoot, "data/iris/dimension_type/" + dimensionTypeKey + ".json");
|
||||
output.getParentFile().mkdirs();
|
||||
|
||||
@@ -45,6 +45,12 @@ import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.framework.WrongEngineBroException;
|
||||
import art.arcane.iris.engine.history.GenerationActivation;
|
||||
import art.arcane.iris.engine.history.GenerationEpoch;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.engine.history.IrisBoundarySignatureSampler;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.object.IrisDimensionContractException;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionRuntimeContract;
|
||||
@@ -84,7 +90,9 @@ import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
@@ -111,6 +119,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
private final IrisWorld world;
|
||||
private final File dataLocation;
|
||||
private final String dimensionKey;
|
||||
private final @Nullable GenerationHistory generationHistory;
|
||||
private final ReactiveFolder folder;
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final KList<BlockPopulator> populators;
|
||||
@@ -137,7 +146,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
@Setter
|
||||
private volatile StudioGenerator studioGenerator;
|
||||
|
||||
public BukkitChunkGenerator(IrisWorld world, boolean studio, File dataLocation, String dimensionKey) {
|
||||
public BukkitChunkGenerator(
|
||||
IrisWorld world,
|
||||
boolean studio,
|
||||
File dataLocation,
|
||||
String dimensionKey,
|
||||
@Nullable GenerationHistory generationHistory
|
||||
) {
|
||||
setup = new AtomicBoolean(false);
|
||||
studioGenerator = null;
|
||||
dummyBiomeProvider = new DummyBiomeProvider();
|
||||
@@ -149,6 +164,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
this.studioEntryBootstrapActive = new AtomicBoolean(studio);
|
||||
this.dataLocation = dataLocation;
|
||||
this.dimensionKey = dimensionKey;
|
||||
this.generationHistory = generationHistory;
|
||||
this.folder = new ReactiveFolder(
|
||||
dataLocation,
|
||||
(_a, _b, _c) -> hotloadFromWatcher(),
|
||||
@@ -385,9 +401,37 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
jigsawStudioActive = request != null
|
||||
&& session != null
|
||||
&& session.sessionId().equals(request.requestId());
|
||||
IrisEngine createdEngine = new IrisEngine(
|
||||
engineTarget,
|
||||
selectInitializationMode(studio, jigsawStudioActive));
|
||||
IrisEngine createdEngine = createEngine(engineTarget);
|
||||
if (generationHistory != null) {
|
||||
long initialActivationId = generationHistory.activeActivation().activationId();
|
||||
int transitionWidthBlocks = IrisSettings.get()
|
||||
.getGenerator()
|
||||
.getGenerationTransitionWidthBlocks();
|
||||
try {
|
||||
createdEngine.attachGenerationHistory(
|
||||
generationHistory,
|
||||
IrisBoundarySignatureSampler.INSTANCE,
|
||||
transitionWidthBlocks);
|
||||
if (generationHistory.activeActivation().activationId() != initialActivationId) {
|
||||
createdEngine.close();
|
||||
targetCache.reset();
|
||||
createdEngine = createEngine(loadActiveGenerationHistoryTarget());
|
||||
createdEngine.attachGenerationHistory(
|
||||
generationHistory,
|
||||
IrisBoundarySignatureSampler.INSTANCE,
|
||||
transitionWidthBlocks);
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
try {
|
||||
createdEngine.close();
|
||||
} catch (Throwable closeFailure) {
|
||||
if (failure != closeFailure) {
|
||||
failure.addSuppressed(closeFailure);
|
||||
}
|
||||
}
|
||||
throw propagateEngineSetupFailure(failure);
|
||||
}
|
||||
}
|
||||
createdEngine.setNativeStructureVolumeQueriesEnabled(shouldGenerateNativeStructures(
|
||||
jigsawStudioActive,
|
||||
studioEntryBootstrapActive.get(),
|
||||
@@ -397,6 +441,66 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
targetCache.reset();
|
||||
}
|
||||
|
||||
private EngineTarget loadActiveGenerationHistoryTarget() throws IOException {
|
||||
Path packRoot = generationHistory.activePackRoot();
|
||||
IrisData data = IrisData.openRuntime(packRoot.toFile());
|
||||
try {
|
||||
data.dump();
|
||||
data.clearLists();
|
||||
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey);
|
||||
if (dimension == null) {
|
||||
throw new IOException("Active Iris generation pack does not contain dimension '"
|
||||
+ dimensionKey + "': " + packRoot);
|
||||
}
|
||||
return new EngineTarget(world, dimension, data);
|
||||
} catch (Throwable failure) {
|
||||
if (!data.isClosed()) {
|
||||
data.close();
|
||||
}
|
||||
if (failure instanceof IOException ioFailure) {
|
||||
throw ioFailure;
|
||||
}
|
||||
if (failure instanceof RuntimeException runtimeFailure) {
|
||||
throw runtimeFailure;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IOException("Unable to load the active Iris generation pack.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private IrisEngine createEngine(EngineTarget engineTarget) {
|
||||
IrisEngine.InitializationMode mode = selectInitializationMode(studio, jigsawStudioActive);
|
||||
if (generationHistory == null) {
|
||||
return new IrisEngine(engineTarget, mode);
|
||||
}
|
||||
GenerationActivation active = generationHistory.activeActivation();
|
||||
GenerationEpoch epoch = generationHistory.activeEpoch();
|
||||
TransitionGenerationPlan plan;
|
||||
try {
|
||||
plan = active.isInitial() ? null : generationHistory.transitionPlan(active.activationId());
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Unable to load the active Iris generation transition.", failure);
|
||||
}
|
||||
return new IrisEngine(
|
||||
engineTarget,
|
||||
mode,
|
||||
generationHistory.paths().activationMantleRoot(active.activationId()),
|
||||
epoch.kernelVersion(),
|
||||
plan);
|
||||
}
|
||||
|
||||
private static RuntimeException propagateEngineSetupFailure(Throwable failure) {
|
||||
if (failure instanceof RuntimeException runtimeFailure) {
|
||||
return runtimeFailure;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
return new IllegalStateException("Unable to attach Iris generation history.", failure);
|
||||
}
|
||||
|
||||
static IrisEngine.InitializationMode selectInitializationMode(boolean studio, boolean jigsawStudioActive) {
|
||||
if (!studio) {
|
||||
return IrisEngine.InitializationMode.RUNTIME;
|
||||
@@ -882,10 +986,15 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
} else {
|
||||
ChunkDataHunkHolder blocks = new ChunkDataHunkHolder(d);
|
||||
Hunk<PlatformBiome> biomes = Hunk.viewBiomes(tc);
|
||||
try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope =
|
||||
openGenerationHistoryCoordinateScope(engine, x << 4, z << 4);
|
||||
GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
engine.generate(x << 4, z << 4, blocks, biomes, engine.shouldGenerateMulticore());
|
||||
blocks.apply();
|
||||
if (historyScope != null) {
|
||||
historyScope.claimGeneratedSemantics();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,6 +1018,23 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable GenerationHistoryRuntimeRouter.CoordinateScope openGenerationHistoryCoordinateScope(
|
||||
Engine currentEngine,
|
||||
int blockX,
|
||||
int blockZ
|
||||
) throws IOException {
|
||||
if (generationHistory == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(currentEngine instanceof IrisEngine irisEngine)) {
|
||||
throw new IllegalStateException("Persistent Iris generation requires an IrisEngine runtime.");
|
||||
}
|
||||
GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter()
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Persistent Iris generation has no generation-history runtime router."));
|
||||
return router.openCoordinateScope(blockX, blockZ);
|
||||
}
|
||||
|
||||
private boolean isExpectedTeardown(Engine currentEngine, Throwable throwable) {
|
||||
if (throwable instanceof GenerationSessionException generationSessionException && generationSessionException.isExpectedTeardown()) {
|
||||
return true;
|
||||
@@ -967,10 +1093,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
default -> false;
|
||||
};
|
||||
|
||||
try (GenerationSessionLease lease = currentEngine.acquireGenerationLease("bukkit_base_height");
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope =
|
||||
openGenerationHistoryCoordinateScope(currentEngine, x, z);
|
||||
GenerationSessionLease lease = currentEngine.acquireGenerationLease("bukkit_base_height");
|
||||
IrisContext.Scope ignored = IrisContext.open(currentEngine, lease.sessionId(), null)) {
|
||||
return currentEngine.getMinHeight() + currentEngine.getHeight(x, z, ignoreFluid) + 1;
|
||||
} catch (GenerationSessionException e) {
|
||||
} catch (GenerationSessionException | IOException e) {
|
||||
throw new IllegalStateException("Iris base height query was rejected for world '"
|
||||
+ worldInfo.getName() + "'.", e);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
package art.arcane.iris.engine.platform;
|
||||
|
||||
import art.arcane.iris.engine.history.GenerationFindCatalog;
|
||||
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.core.events.IrisLootEvent;
|
||||
import art.arcane.iris.core.link.Identifier;
|
||||
@@ -581,7 +583,7 @@ public final class EngineBukkitOps {
|
||||
}
|
||||
|
||||
public static void gotoRegion(Engine engine, IrisRegion r, Player player, boolean teleport) {
|
||||
if (!engine.getDimension().getRegions().contains(r.getLoadKey())) {
|
||||
if (GenerationFindCatalog.region(engine, r.getLoadKey()) == null) {
|
||||
ComponentMessenger.sendSection(player, IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_IS_NOT_DEFINED_DIMENSION, MessageArgument.untrusted("name", String.valueOf(r.getName()))));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import art.arcane.iris.spi.PlatformBiome;
|
||||
import art.arcane.iris.spi.PlatformBlockProperty;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformEntityType;
|
||||
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||
import art.arcane.iris.spi.PlatformItem;
|
||||
import art.arcane.iris.spi.PlatformNumericRange;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
@@ -54,6 +55,11 @@ import java.util.Map;
|
||||
* Bukkit adapter resolving namespaced keys against the live Bukkit registries.
|
||||
*/
|
||||
public final class BukkitRegistries implements PlatformRegistries {
|
||||
@Override
|
||||
public PlatformGenerationRegistry generationRegistry() {
|
||||
return INMS.get().generationRegistry();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlatformBlockState block(String key) {
|
||||
BlockData data = BukkitBlockResolution.get(key);
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package art.arcane.iris.util.common.director.specialhandlers;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.GenerationFindCatalog;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
public class LocatableObjectHandler extends ObjectHandler {
|
||||
@Override
|
||||
public KList<String> getPossibilities() {
|
||||
Engine activeEngine = engine();
|
||||
return activeEngine == null ? super.getPossibilities() : GenerationFindCatalog.objectKeys(activeEngine);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.util.common.director.specialhandlers;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.GenerationFindCatalog;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
@@ -14,7 +15,7 @@ public class ReachableBiomeHandler extends RegistrantHandler<IrisBiome> {
|
||||
Engine activeEngine = engine();
|
||||
return activeEngine == null
|
||||
? super.getPossibilities()
|
||||
: activeEngine.getAllBiomes();
|
||||
: GenerationFindCatalog.biomes(activeEngine);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package art.arcane.iris.util.common.director.specialhandlers;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.GenerationFindCatalog;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
public class ReachableRegionHandler extends RegistrantHandler<IrisRegion> {
|
||||
public ReachableRegionHandler() {
|
||||
super(IrisRegion.class, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public KList<IrisRegion> getPossibilities() {
|
||||
Engine activeEngine = engine();
|
||||
return activeEngine == null ? super.getPossibilities() : GenerationFindCatalog.regions(activeEngine);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRandomDefault() {
|
||||
return "region";
|
||||
}
|
||||
}
|
||||
+4
@@ -22,6 +22,7 @@ import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
|
||||
import art.arcane.iris.engine.framework.StructureReachability;
|
||||
import art.arcane.iris.engine.history.GenerationFindCatalog;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
@@ -67,6 +68,9 @@ public class StructureHandler implements DirectorParameterHandler<String> {
|
||||
}
|
||||
}
|
||||
|
||||
for (String key : GenerationFindCatalog.retainedStructureKeys(activeEngine)) {
|
||||
suggestions.putIfAbsent(normalizeKey(key), key);
|
||||
}
|
||||
return new KList<>(suggestions.values());
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user