mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
F
This commit is contained in:
+40
-14
@@ -33,6 +33,7 @@ final class InitialSpawnQueue {
|
||||
private final long maxAgeNanos;
|
||||
private final LongSupplier nanoTime;
|
||||
private final ArrayDeque<Long> queue;
|
||||
private final ArrayDeque<Expiry> expiryOrder;
|
||||
private final Map<Long, Long> pending;
|
||||
private final Set<Long> queued;
|
||||
private boolean closed;
|
||||
@@ -52,6 +53,7 @@ final class InitialSpawnQueue {
|
||||
this.maxAgeNanos = maxAgeNanos;
|
||||
this.nanoTime = nanoTime;
|
||||
this.queue = new ArrayDeque<>(Math.min(capacity, 256));
|
||||
this.expiryOrder = new ArrayDeque<>(Math.min(capacity, 256));
|
||||
this.pending = new HashMap<>();
|
||||
this.queued = new HashSet<>();
|
||||
}
|
||||
@@ -66,9 +68,12 @@ final class InitialSpawnQueue {
|
||||
if (pending.size() >= capacity) {
|
||||
return false;
|
||||
}
|
||||
pending.put(key, nanoTime.getAsLong());
|
||||
queued.add(key);
|
||||
queue.addLast(key);
|
||||
long offeredAt = nanoTime.getAsLong();
|
||||
pending.put(key, offeredAt);
|
||||
expiryOrder.addLast(new Expiry(key, offeredAt));
|
||||
if (queued.add(key)) {
|
||||
queue.addLast(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -92,8 +97,10 @@ final class InitialSpawnQueue {
|
||||
pending.remove(key);
|
||||
continue;
|
||||
}
|
||||
expire(now);
|
||||
return key;
|
||||
}
|
||||
expire(now);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -115,6 +122,7 @@ final class InitialSpawnQueue {
|
||||
if (queued.remove(key)) {
|
||||
queue.removeFirstOccurrence(key);
|
||||
}
|
||||
expire(nanoTime.getAsLong());
|
||||
}
|
||||
|
||||
synchronized boolean isEmpty() {
|
||||
@@ -127,6 +135,7 @@ final class InitialSpawnQueue {
|
||||
|
||||
synchronized void clear() {
|
||||
queue.clear();
|
||||
expiryOrder.clear();
|
||||
pending.clear();
|
||||
queued.clear();
|
||||
}
|
||||
@@ -136,24 +145,41 @@ final class InitialSpawnQueue {
|
||||
clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops offers that aged out, plus entries for keys that already left {@code pending}.
|
||||
* {@code expiryOrder} is append-only and {@code nanoTime} is monotonic, so the deque is sorted by
|
||||
* offer time: the loop stops at the first live, unexpired entry. That makes the saturated-queue
|
||||
* call O(1) in the common case instead of the O(capacity) scan it used to be under the monitor,
|
||||
* and running it from poll/complete keeps the deque from growing past the live offers.
|
||||
*
|
||||
* <p>Expired keys are normally left in {@code queue}/{@code queued}: {@link #poll()} already drops keys
|
||||
* that are no longer pending, so the drain deque self-cleans without an O(n) sweep. That only holds while
|
||||
* something polls - a producer that keeps offering into a queue nobody drains would grow both past
|
||||
* {@code capacity} forever - so once the deque is over capacity it is swept against {@code pending} here.
|
||||
*/
|
||||
private void expire(long now) {
|
||||
Set<Long> expired = new HashSet<>();
|
||||
for (Map.Entry<Long, Long> entry : pending.entrySet()) {
|
||||
if (expired(entry.getValue(), now)) {
|
||||
expired.add(entry.getKey());
|
||||
Expiry head;
|
||||
while ((head = expiryOrder.peekFirst()) != null) {
|
||||
Long offeredAt = pending.get(head.key());
|
||||
boolean live = offeredAt != null && offeredAt.longValue() == head.offeredAt();
|
||||
if (live && !expired(head.offeredAt(), now)) {
|
||||
break;
|
||||
}
|
||||
expiryOrder.pollFirst();
|
||||
if (live) {
|
||||
pending.remove(head.key());
|
||||
}
|
||||
}
|
||||
if (expired.isEmpty()) {
|
||||
return;
|
||||
if (queue.size() > capacity) {
|
||||
queue.removeIf(key -> !pending.containsKey(key));
|
||||
queued.retainAll(pending.keySet());
|
||||
}
|
||||
for (Long key : expired) {
|
||||
pending.remove(key);
|
||||
queued.remove(key);
|
||||
}
|
||||
queue.removeIf(expired::contains);
|
||||
}
|
||||
|
||||
private boolean expired(long offeredAt, long now) {
|
||||
return now - offeredAt >= maxAgeNanos;
|
||||
}
|
||||
|
||||
private record Expiry(long key, long offeredAt) {
|
||||
}
|
||||
}
|
||||
|
||||
+260
-46
@@ -40,25 +40,33 @@ import net.minecraft.world.level.biome.Climate;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReferenceArray;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class IrisModdedBiomeSource extends BiomeSource {
|
||||
private static final int BIOME_CACHE_MAX = 262144;
|
||||
private static final int UNRESOLVED_WARN_KEYS_MAX = 256;
|
||||
|
||||
private final BiomeSource serializedSource;
|
||||
private final Set<String> warnedUnresolvedBiomeKeys = ConcurrentHashMap.newKeySet();
|
||||
private final ConcurrentHashMap<Long, Holder<Biome>> visibleBiomeCache = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<Long, Holder<Biome>> structureBiomeCache = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<Long, Holder<Biome>> surfaceStructureBiomeCache = new ConcurrentHashMap<>();
|
||||
private final Set<StructureStateBiomeSource> structureStateSources = ConcurrentHashMap.newKeySet();
|
||||
// Pack generation. Every cache on this source is keyed by it, which is what keeps memoized tables from
|
||||
// surviving a repoint with the previous pack's content.
|
||||
private final AtomicLong packGeneration = new AtomicLong();
|
||||
private volatile BiomeHolderTable visibleBiomeCache = new BiomeHolderTable();
|
||||
private volatile BiomeHolderTable structureBiomeCache = new BiomeHolderTable();
|
||||
private volatile BiomeHolderTable surfaceStructureBiomeCache = new BiomeHolderTable();
|
||||
private volatile IrisModdedChunkGenerator generator;
|
||||
private volatile Set<String> possibleStructureBiomeKeys;
|
||||
private volatile BiomeKeySets biomeKeySets;
|
||||
private volatile PossibleBiomes possibleBiomesCache;
|
||||
|
||||
IrisModdedBiomeSource(BiomeSource serializedSource) {
|
||||
this.serializedSource = serializedSource;
|
||||
@@ -69,16 +77,31 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
||||
}
|
||||
|
||||
void clearCaches() {
|
||||
visibleBiomeCache.clear();
|
||||
structureBiomeCache.clear();
|
||||
surfaceStructureBiomeCache.clear();
|
||||
// Republish empty tables instead of iterating: a repoint must not walk hundreds of thousands of slots
|
||||
// on the calling thread, and every reader is a pure function of the key so a lost entry is only a miss.
|
||||
// An empty table allocates no slot array, and a reader that captured the previous table writes its
|
||||
// in-flight value there, which is what keeps a pre-repoint holder from ever landing in the new table.
|
||||
packGeneration.incrementAndGet();
|
||||
visibleBiomeCache = new BiomeHolderTable();
|
||||
structureBiomeCache = new BiomeHolderTable();
|
||||
surfaceStructureBiomeCache = new BiomeHolderTable();
|
||||
warnedUnresolvedBiomeKeys.clear();
|
||||
possibleStructureBiomeKeys = null;
|
||||
biomeKeySets = null;
|
||||
possibleBiomesCache = null;
|
||||
for (StructureStateBiomeSource source : structureStateSources) {
|
||||
source.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack generation counter. Platform code that memoizes anything derived from this source (the imported
|
||||
* feature table) keys its memo on this value so {@code repoint} cannot leave stale content behind.
|
||||
*/
|
||||
long packGeneration() {
|
||||
return packGeneration.get();
|
||||
}
|
||||
|
||||
BiomeSource forStructureState(HolderLookup<StructureSet> structureSets) {
|
||||
LinkedHashSet<Holder<Biome>> possible = new LinkedHashSet<>();
|
||||
Registry<Biome> registry = biomeRegistry();
|
||||
@@ -113,12 +136,68 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
||||
|
||||
@Override
|
||||
protected Stream<Holder<Biome>> collectPossibleBiomes() {
|
||||
Set<String> generatedBiomeKeys = requireConfiguredStructureBiomeKeys(exactStructureBiomeKeys());
|
||||
return resolvePossibleBiomes().ordered().stream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Overridden because {@link BiomeSource#possibleBiomes()} memoizes its answer for the lifetime of the
|
||||
* instance, and this source outlives a {@code repoint} to a different pack. The returned set keeps
|
||||
* biome-registry iteration order: vanilla builds its feature-per-step table with
|
||||
* {@code List.copyOf(possibleBiomes())}, and FeatureSorter's cycle detection walks that list, so an
|
||||
* unordered set makes cycle detection depend on JVM hash order.
|
||||
*/
|
||||
@Override
|
||||
public Set<Holder<Biome>> possibleBiomes() {
|
||||
return resolvePossibleBiomes().set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry-ordered view of {@link #possibleBiomes()} for platform code that has to build a feature table.
|
||||
*/
|
||||
List<Holder<Biome>> orderedPossibleBiomes() {
|
||||
return resolvePossibleBiomes().ordered();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves any registered biome holder by key, whether or not this source can emit it. The imported feature
|
||||
* pass needs this: a biome's vanilla derivative is where its features come from, and a sea or shore biome's
|
||||
* structure derivative is rewritten away from that derivative, so the derivative itself is not always one of
|
||||
* the biomes this source claims. Null when the key is unknown or the registry is not up yet.
|
||||
*/
|
||||
Holder<Biome> registeredBiome(String key) {
|
||||
Registry<Biome> registry = biomeRegistry();
|
||||
return registry == null ? null : resolveHolder(registry, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately lock-free: resolving can bind an engine, which takes the generator monitor, and the
|
||||
* generator takes its monitor before invalidating this cache. A lock here would close that cycle. Two
|
||||
* threads racing only duplicate idempotent work.
|
||||
*/
|
||||
private PossibleBiomes resolvePossibleBiomes() {
|
||||
long generation = packGeneration.get();
|
||||
PossibleBiomes cached = possibleBiomesCache;
|
||||
if (cached != null && cached.generation() == generation) {
|
||||
return cached;
|
||||
}
|
||||
List<Holder<Biome>> ordered = collectPossibleBiomeHolders();
|
||||
PossibleBiomes resolved = new PossibleBiomes(generation, ordered,
|
||||
Collections.unmodifiableSet(new LinkedHashSet<>(ordered)));
|
||||
possibleBiomesCache = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private List<Holder<Biome>> collectPossibleBiomeHolders() {
|
||||
BiomeKeySets keys = biomeKeySets();
|
||||
Set<String> generatedBiomeKeys = requireConfiguredStructureBiomeKeys(keys.required());
|
||||
Set<String> visibleBiomeKeys = keys.visibleOnly();
|
||||
Registry<Biome> registry = biomeRegistry();
|
||||
LinkedHashSet<Holder<Biome>> possible = new LinkedHashSet<>();
|
||||
if (registry == null) {
|
||||
for (Holder<Biome> biome : serializedSource.possibleBiomes()) {
|
||||
if (isGeneratedBiomeKey(holderKey(biome), generatedBiomeKeys)) {
|
||||
String key = holderKey(biome);
|
||||
if (isGeneratedBiomeKey(key, generatedBiomeKeys)
|
||||
|| isGeneratedBiomeKey(key, visibleBiomeKeys)) {
|
||||
possible.add(biome);
|
||||
}
|
||||
}
|
||||
@@ -129,18 +208,44 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
||||
throw new IllegalStateException("Iris structure biomes are not registered: "
|
||||
+ missingBiomeKeys);
|
||||
}
|
||||
// Registry-ordered, never a hash-ordered walk: see possibleBiomes().
|
||||
registry.listElements().forEach((Holder.Reference<Biome> reference) -> {
|
||||
if (isGeneratedBiomeKey(holderKey(reference), generatedBiomeKeys)) {
|
||||
String key = holderKey(reference);
|
||||
if (isGeneratedBiomeKey(key, generatedBiomeKeys)
|
||||
|| isGeneratedBiomeKey(key, visibleBiomeKeys)) {
|
||||
possible.add(reference);
|
||||
}
|
||||
});
|
||||
warnUnregisteredVisibleBiomes(registry, visibleBiomeKeys);
|
||||
}
|
||||
if (possible.isEmpty()) {
|
||||
String phase = registry == null ? "serialized biome bootstrap" : "biome registry";
|
||||
throw new IllegalStateException("Iris configured structure biomes are absent from the "
|
||||
+ phase + ": " + generatedBiomeKeys);
|
||||
}
|
||||
return possible.stream();
|
||||
return List.copyOf(possible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scatter and derivative keys are advisory: a typo there must not stop a world from loading the way a
|
||||
* missing structure biome does, so they are reported once and skipped.
|
||||
*/
|
||||
private void warnUnregisteredVisibleBiomes(Registry<Biome> registry, Set<String> visibleBiomeKeys) {
|
||||
if (visibleBiomeKeys.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> missing = new LinkedHashSet<>(visibleBiomeKeys);
|
||||
missing.removeAll(registeredBiomeKeys(registry));
|
||||
for (String key : missing) {
|
||||
if (!warnedUnresolvedBiomeKeys.add(key)) {
|
||||
continue;
|
||||
}
|
||||
if (warnedUnresolvedBiomeKeys.size() > UNRESOLVED_WARN_KEYS_MAX) {
|
||||
warnedUnresolvedBiomeKeys.clear();
|
||||
}
|
||||
ModdedIrisLog.warn("Iris biome " + key + " is referenced by derivative or scatter but is not"
|
||||
+ " registered; it is dropped from this dimension's biome source");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -167,16 +272,14 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
||||
return getSurfaceStructureBiome(engine, quartX, quartZ, sampler);
|
||||
}
|
||||
long key = packNoiseKey(quartX, quartY, quartZ);
|
||||
Holder<Biome> cached = structureBiomeCache.get(key);
|
||||
BiomeHolderTable cache = structureBiomeCache;
|
||||
Holder<Biome> cached = cache.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Holder<Biome> resolved = resolveStructureBiome(engine, quartX, quartY, quartZ, sampler);
|
||||
Holder<Biome> existing = structureBiomeCache.putIfAbsent(key, resolved);
|
||||
if (structureBiomeCache.size() > BIOME_CACHE_MAX) {
|
||||
structureBiomeCache.clear();
|
||||
}
|
||||
return existing == null ? resolved : existing;
|
||||
cache.put(key, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
Holder<Biome> getVisibleNoiseBiome(int quartX, int quartY, int quartZ, Climate.Sampler sampler) {
|
||||
@@ -193,16 +296,14 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
||||
throw new IllegalStateException("Iris visible biome lookup has no active engine runtime");
|
||||
}
|
||||
long key = packNoiseKey(quartX, quartY, quartZ);
|
||||
Holder<Biome> cached = visibleBiomeCache.get(key);
|
||||
BiomeHolderTable cache = visibleBiomeCache;
|
||||
Holder<Biome> cached = cache.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Holder<Biome> resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler);
|
||||
Holder<Biome> existing = visibleBiomeCache.putIfAbsent(key, resolved);
|
||||
if (visibleBiomeCache.size() > BIOME_CACHE_MAX) {
|
||||
visibleBiomeCache.clear();
|
||||
}
|
||||
return existing == null ? resolved : existing;
|
||||
cache.put(key, resolved);
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,16 +369,14 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
||||
private Holder<Biome> getSurfaceStructureBiome(Engine engine, int quartX, int quartZ,
|
||||
Climate.Sampler sampler) {
|
||||
long key = packColumnKey(quartX, quartZ);
|
||||
Holder<Biome> cached = surfaceStructureBiomeCache.get(key);
|
||||
BiomeHolderTable cache = surfaceStructureBiomeCache;
|
||||
Holder<Biome> cached = cache.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Holder<Biome> resolved = resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler);
|
||||
Holder<Biome> existing = surfaceStructureBiomeCache.putIfAbsent(key, resolved);
|
||||
if (surfaceStructureBiomeCache.size() > BIOME_CACHE_MAX) {
|
||||
surfaceStructureBiomeCache.clear();
|
||||
}
|
||||
return existing == null ? resolved : existing;
|
||||
cache.put(key, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private Holder<Biome> resolveSurfaceStructureBiome(Engine engine, int quartX, int quartZ,
|
||||
@@ -500,13 +599,35 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
||||
}
|
||||
|
||||
private Set<String> exactStructureBiomeKeys() {
|
||||
return biomeKeySets().required();
|
||||
}
|
||||
|
||||
/**
|
||||
* Required and visible-only biome keys for the current pack generation. Required keys are the structure
|
||||
* derivatives and the generated custom biomes: a missing one is fatal, exactly as before. Visible-only
|
||||
* keys are the raw derivative plus every {@code biomeScatter} and {@code biomeSkyScatter} entry - biomes
|
||||
* Iris writes into chunk sections but which vanilla previously dropped, because
|
||||
* {@code applyBiomeDecoration} intersects the chunk's biomes with {@code possibleBiomes()}.
|
||||
*/
|
||||
private BiomeKeySets biomeKeySets() {
|
||||
long generation = packGeneration.get();
|
||||
BiomeKeySets cached = biomeKeySets;
|
||||
if (cached != null && cached.generation() == generation) {
|
||||
return cached;
|
||||
}
|
||||
BiomeKeySets resolved = collectBiomeKeySets(generation);
|
||||
biomeKeySets = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private BiomeKeySets collectBiomeKeySets(long generation) {
|
||||
IrisModdedChunkGenerator current = generator;
|
||||
if (current == null) {
|
||||
return Set.of();
|
||||
return new BiomeKeySets(generation, Set.of(), Set.of());
|
||||
}
|
||||
Engine engine = current.structureEngineOrNull();
|
||||
if (engine == null) {
|
||||
return current.configuredStructureBiomeKeys();
|
||||
return new BiomeKeySets(generation, current.configuredStructureBiomeKeys(), Set.of());
|
||||
}
|
||||
GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_biome_keys");
|
||||
if (lease == null) {
|
||||
@@ -516,20 +637,35 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
||||
if (!isReady(engine)) {
|
||||
throw new IllegalStateException("Iris structure biome key lookup has no active engine runtime");
|
||||
}
|
||||
LinkedHashSet<String> possible = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> required = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> visible = new LinkedHashSet<>();
|
||||
for (IrisBiome irisBiome : engine.getAllBiomes()) {
|
||||
String derivative = normalizeKey(irisBiome.getStructureDerivativeKey());
|
||||
if (derivative != null) {
|
||||
possible.add(derivative);
|
||||
required.add(derivative);
|
||||
}
|
||||
if (!irisBiome.isCustom()) {
|
||||
continue;
|
||||
if (irisBiome.isCustom()) {
|
||||
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
|
||||
required.add(ModdedWorldgenIds.biomeRef(engine, customBiome.getId()));
|
||||
}
|
||||
}
|
||||
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
|
||||
possible.add(ModdedWorldgenIds.biomeRef(engine, customBiome.getId()));
|
||||
addVisibleBiomeKey(visible, irisBiome.getDerivativeKey());
|
||||
for (String scatter : irisBiome.getBiomeScatter()) {
|
||||
addVisibleBiomeKey(visible, scatter);
|
||||
}
|
||||
for (String scatter : irisBiome.getBiomeSkyScatter()) {
|
||||
addVisibleBiomeKey(visible, scatter);
|
||||
}
|
||||
}
|
||||
return Set.copyOf(possible);
|
||||
visible.removeAll(required);
|
||||
return new BiomeKeySets(generation, Set.copyOf(required), Set.copyOf(visible));
|
||||
}
|
||||
}
|
||||
|
||||
private static void addVisibleBiomeKey(Set<String> target, String key) {
|
||||
String normalized = normalizeKey(key);
|
||||
if (normalized != null) {
|
||||
target.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,10 +744,90 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
||||
int blockZ, RNG rng) {
|
||||
}
|
||||
|
||||
private record BiomeKeySets(long generation, Set<String> required, Set<String> visibleOnly) {
|
||||
}
|
||||
|
||||
private record PossibleBiomes(long generation, List<Holder<Biome>> ordered, Set<Holder<Biome>> set) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-capacity open-addressed long-to-holder cache. Sized once and never resized, so there is no
|
||||
* stop-the-world clear when it fills: a colliding write past the probe limit simply displaces the entry at
|
||||
* the home slot, and the displaced key resolves again on its next lookup. Every cached value is a pure
|
||||
* function of its key, so displacement can only cost work, never correctness. Entries are published as one
|
||||
* immutable record, which is what keeps a concurrent writer from ever pairing one key with another's value.
|
||||
*
|
||||
* <p>The slot array is installed on the first put, never in the field initializer. One biome source is
|
||||
* constructed per emitted world preset at datapack load, and every create-world screen builds them all, so
|
||||
* eager arrays cost tens of megabytes of tables that nothing ever reads. An empty table answers every get
|
||||
* with a miss, which is the same answer a cold table gives.
|
||||
*/
|
||||
private static final class BiomeHolderTable {
|
||||
private static final int SLOTS = 32768;
|
||||
private static final int MASK = SLOTS - 1;
|
||||
private static final int PROBE_LIMIT = 8;
|
||||
|
||||
private volatile AtomicReferenceArray<Entry> table;
|
||||
|
||||
Holder<Biome> get(long key) {
|
||||
AtomicReferenceArray<Entry> slots = table;
|
||||
if (slots == null) {
|
||||
return null;
|
||||
}
|
||||
int home = home(key);
|
||||
for (int probe = 0; probe < PROBE_LIMIT; probe++) {
|
||||
Entry entry = slots.get((home + probe) & MASK);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
if (entry.key() == key) {
|
||||
return entry.value();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void put(long key, Holder<Biome> value) {
|
||||
AtomicReferenceArray<Entry> slots = table;
|
||||
if (slots == null) {
|
||||
slots = install();
|
||||
}
|
||||
int home = home(key);
|
||||
Entry entry = new Entry(key, value);
|
||||
for (int probe = 0; probe < PROBE_LIMIT; probe++) {
|
||||
int slot = (home + probe) & MASK;
|
||||
Entry existing = slots.get(slot);
|
||||
if (existing == null || existing.key() == key) {
|
||||
slots.set(slot, entry);
|
||||
return;
|
||||
}
|
||||
}
|
||||
slots.set(home, entry);
|
||||
}
|
||||
|
||||
private synchronized AtomicReferenceArray<Entry> install() {
|
||||
AtomicReferenceArray<Entry> existing = table;
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
AtomicReferenceArray<Entry> created = new AtomicReferenceArray<>(SLOTS);
|
||||
table = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
private static int home(long key) {
|
||||
long mixed = key * 0x9E3779B97F4A7C15L;
|
||||
return (int) ((mixed ^ (mixed >>> 32)) & MASK);
|
||||
}
|
||||
|
||||
private record Entry(long key, Holder<Biome> value) {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StructureStateBiomeSource extends BiomeSource {
|
||||
private final IrisModdedBiomeSource delegate;
|
||||
private final Set<Holder<Biome>> possibleBiomes;
|
||||
private final ConcurrentHashMap<Long, Holder<Biome>> resolvedBiomes = new ConcurrentHashMap<>();
|
||||
private volatile BiomeHolderTable resolvedBiomes = new BiomeHolderTable();
|
||||
|
||||
private StructureStateBiomeSource(IrisModdedBiomeSource delegate, Set<Holder<Biome>> possibleBiomes) {
|
||||
this.delegate = delegate;
|
||||
@@ -619,7 +835,7 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
||||
}
|
||||
|
||||
private void clearCache() {
|
||||
resolvedBiomes.clear();
|
||||
resolvedBiomes = new BiomeHolderTable();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -635,16 +851,14 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
||||
@Override
|
||||
public Holder<Biome> getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) {
|
||||
long key = packNoiseKey(x, y, z);
|
||||
Holder<Biome> cached = resolvedBiomes.get(key);
|
||||
BiomeHolderTable cache = resolvedBiomes;
|
||||
Holder<Biome> cached = cache.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Holder<Biome> resolved = delegate.resolveRequiredStructureBiome(x, y, z);
|
||||
Holder<Biome> existing = resolvedBiomes.putIfAbsent(key, resolved);
|
||||
if (resolvedBiomes.size() > BIOME_CACHE_MAX) {
|
||||
resolvedBiomes.clear();
|
||||
}
|
||||
return existing == null ? resolved : existing;
|
||||
cache.put(key, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+124
-8
@@ -21,6 +21,7 @@ package art.arcane.iris.modded;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.NativeFeatureGenerationPolicy;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
||||
@@ -96,6 +97,11 @@ import java.util.function.IntBinaryOperator;
|
||||
|
||||
public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
// Vanilla-shaped fallback for an unbound generator (matches IrisDimension defaults). getMinY,
|
||||
// getSeaLevel and getGenDepth are called from world creation and client screens, so they must
|
||||
// answer without disk I/O and without throwing before a level is bound.
|
||||
private static final ModdedDimensionMetadata.DimensionMetadata UNBOUND_HEIGHTS =
|
||||
new ModdedDimensionMetadata.DimensionMetadata(-64, 320, 63);
|
||||
public static final MapCodec<IrisModdedChunkGenerator> CODEC = RecordCodecBuilder.mapCodec((RecordCodecBuilder.Instance<IrisModdedChunkGenerator> instance) -> instance.group(
|
||||
BiomeSource.CODEC.fieldOf("biome_source").forGetter((IrisModdedChunkGenerator generator) -> generator.serializedBiomeSource),
|
||||
Codec.STRING.fieldOf("dimension").forGetter((IrisModdedChunkGenerator generator) -> generator.dimensionKey)
|
||||
@@ -117,6 +123,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
private final ModdedEngineBinding<Engine> engineBinding = new ModdedEngineBinding<>(60L, TimeUnit.SECONDS);
|
||||
private final ModdedNativeStructureStage nativeStructures = new ModdedNativeStructureStage(this);
|
||||
private final ModdedSpawnTableMerger spawnTables = new ModdedSpawnTableMerger(this);
|
||||
private final ModdedImportedFeatureStage importedFeatures;
|
||||
private final AtomicBoolean announced = new AtomicBoolean(false);
|
||||
private volatile boolean unloading;
|
||||
private volatile Engine engine;
|
||||
@@ -126,13 +133,28 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
private volatile long lastChunkGenAt = 0L;
|
||||
private volatile Set<String> configuredStructureBiomeKeys;
|
||||
private volatile ModdedDimensionMetadata.ConfiguredPack configuredPack;
|
||||
private volatile ModdedDimensionMetadata.DimensionMetadata heightMetadata;
|
||||
private volatile ServerLevel boundLevel;
|
||||
|
||||
public IrisModdedChunkGenerator(BiomeSource biomeSource, String dimensionKey) {
|
||||
this(biomeSource, dimensionKey, new IrisModdedBiomeSource(biomeSource));
|
||||
}
|
||||
|
||||
private IrisModdedChunkGenerator(BiomeSource serializedBiomeSource, String dimensionKey, IrisModdedBiomeSource structureBiomeSource) {
|
||||
super(structureBiomeSource);
|
||||
this(serializedBiomeSource, dimensionKey, structureBiomeSource,
|
||||
new ModdedImportedFeatureStage(structureBiomeSource));
|
||||
}
|
||||
|
||||
private IrisModdedChunkGenerator(BiomeSource serializedBiomeSource, String dimensionKey,
|
||||
IrisModdedBiomeSource structureBiomeSource,
|
||||
ModdedImportedFeatureStage importedFeatures) {
|
||||
// Two-argument ChunkGenerator constructor: the getter maps Iris custom-biome holders onto the
|
||||
// generation settings of their vanilla derivative, which is what feeds the per-step feature lists and
|
||||
// BiomeFilter's hasFeature gate. It is a pass-through to vanilla's default getter until a pack turns
|
||||
// importedFeatures on, so with the control off nothing about generation changes.
|
||||
super(structureBiomeSource, importedFeatures::generationSettings);
|
||||
this.importedFeatures = importedFeatures;
|
||||
importedFeatures.bind(this);
|
||||
this.dimensionKey = dimensionKey;
|
||||
this.serializedBiomeSource = serializedBiomeSource;
|
||||
this.structureBiomeSource = structureBiomeSource;
|
||||
@@ -177,18 +199,25 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
}
|
||||
throw new IllegalStateException("Iris generator '" + dimensionKey + "' failed to replace its engine", error);
|
||||
}
|
||||
this.boundLevel = level;
|
||||
this.activePack = pack;
|
||||
this.activeDimensionKey = packDimensionKey;
|
||||
this.seedOverride = seed;
|
||||
this.engine = replacement;
|
||||
this.configuredStructureBiomeKeys = null;
|
||||
this.configuredPack = null;
|
||||
this.heightMetadata = engineHeights(replacement);
|
||||
this.engineBinding.reset();
|
||||
this.engineBinding.complete(replacement);
|
||||
this.announced.set(false);
|
||||
this.structureBiomeSource.clearCaches();
|
||||
this.importedFeatures.invalidate();
|
||||
this.nativeStructures.clearWorldCheckStructureShifts();
|
||||
this.spawnTables.resetVanillaSpawnBiomes();
|
||||
// Bind time: a feature-order cycle in the new pack is reported here, once, and degrades to features-off.
|
||||
// Never waits on a build owned by another thread: this method owns the generator monitor and the build
|
||||
// path can need it.
|
||||
this.importedFeatures.prepareWithoutWaiting(replacement);
|
||||
}
|
||||
|
||||
public synchronized void unbindEngine() {
|
||||
@@ -207,11 +236,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
|
||||
private void clearEngineBinding() {
|
||||
this.engine = null;
|
||||
this.boundLevel = null;
|
||||
this.configuredStructureBiomeKeys = null;
|
||||
this.configuredPack = null;
|
||||
this.engineBinding.reset();
|
||||
this.announced.set(false);
|
||||
this.structureBiomeSource.clearCaches();
|
||||
this.importedFeatures.invalidate();
|
||||
this.nativeStructures.clearWorldCheckStructureShifts();
|
||||
this.spawnTables.resetVanillaSpawnBiomes();
|
||||
}
|
||||
@@ -221,13 +252,16 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
this.activeDimensionKey = packDimensionKey;
|
||||
this.seedOverride = seed;
|
||||
this.engine = null;
|
||||
this.boundLevel = null;
|
||||
this.configuredStructureBiomeKeys = null;
|
||||
this.configuredPack = null;
|
||||
this.engineBinding.reset();
|
||||
this.announced.set(false);
|
||||
this.structureBiomeSource.clearCaches();
|
||||
this.importedFeatures.invalidate();
|
||||
this.nativeStructures.clearWorldCheckStructureShifts();
|
||||
this.spawnTables.resetVanillaSpawnBiomes();
|
||||
primeHeightMetadata();
|
||||
}
|
||||
|
||||
public synchronized void resetToDefault() {
|
||||
@@ -276,12 +310,18 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
}
|
||||
|
||||
private ServerLevel boundLevel() {
|
||||
ServerLevel cached = boundLevel;
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
MinecraftServer server = ModdedEngineBootstrap.currentServer();
|
||||
if (server == null) {
|
||||
return null;
|
||||
}
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
// Snapshot, never server.getAllLevels(): this runs off the server thread from data queries.
|
||||
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||
if (level.getChunkSource().getGenerator() == this) {
|
||||
boundLevel = level;
|
||||
return level;
|
||||
}
|
||||
}
|
||||
@@ -308,7 +348,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
}
|
||||
requireCompletedShutdown(engine);
|
||||
unloading = false;
|
||||
bindEngine(level);
|
||||
Engine bound = bindEngine(level);
|
||||
// Bind time: a feature-order cycle is reported here, once, and degrades to features-off. Non-waiting for
|
||||
// the same reason as repointAndBind: this method owns the generator monitor.
|
||||
importedFeatures.prepareWithoutWaiting(bound);
|
||||
LOGGER.info("Iris bound {}: chunk system {}", level.dimension().identifier(), ModdedGenPool.describeChunkSystem());
|
||||
}
|
||||
|
||||
private Engine bindEngine(ServerLevel level) {
|
||||
@@ -320,6 +364,8 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
engineBinding.fail(error);
|
||||
throw error;
|
||||
}
|
||||
// Cache the owning level so hot paths never scan the level map to find themselves.
|
||||
boundLevel = level;
|
||||
Engine cached = engine;
|
||||
requireCompletedShutdown(cached);
|
||||
if (cached != null && !cached.isClosed() && cached.getComplex() != null) {
|
||||
@@ -342,6 +388,8 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
+ "' created an engine without a ready biome complex");
|
||||
}
|
||||
engine = created;
|
||||
boundLevel = level;
|
||||
heightMetadata = engineHeights(created);
|
||||
configuredStructureBiomeKeys = null;
|
||||
structureBiomeSource.clearCaches();
|
||||
engineBinding.complete(created);
|
||||
@@ -376,9 +424,22 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
if (enabled) {
|
||||
return;
|
||||
}
|
||||
String remedy = integratedEnvironment()
|
||||
? "enable 'Generate Structures' for this world; Iris requires it, and individual structures "
|
||||
+ "are denied through importedStructures.disabled"
|
||||
: "set generate-structures=true in server.properties, restart the server, "
|
||||
+ "and deny individual structures through importedStructures.disabled";
|
||||
throw new IllegalStateException("Iris generator '" + dimensionKey
|
||||
+ "' cannot bind while generate-structures=false; set generate-structures=true, restart the server, "
|
||||
+ "and deny individual structures through importedStructures.disabled");
|
||||
+ "' cannot bind while generate-structures=false; " + remedy);
|
||||
}
|
||||
|
||||
private static boolean integratedEnvironment() {
|
||||
try {
|
||||
return ModdedEngineBootstrap.loader().clientEnvironment();
|
||||
} catch (Throwable e) {
|
||||
// No loader bound (unit tests, very early boot): assume dedicated wording.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Engine engineOrNull() {
|
||||
@@ -490,6 +551,41 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
private static ModdedDimensionMetadata.DimensionMetadata engineHeights(Engine engine) {
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
int minY = engine.getMinHeight();
|
||||
return new ModdedDimensionMetadata.DimensionMetadata(minY, engine.getMaxHeight(),
|
||||
dimension == null ? minY : minY + dimension.getFluidHeight());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the pack height metadata on the calling thread so the vanilla height accessors stay pure
|
||||
* reads. Never fatal: a pack that cannot be read here falls back to {@link #UNBOUND_HEIGHTS} until a
|
||||
* bind succeeds.
|
||||
*/
|
||||
private void primeHeightMetadata() {
|
||||
try {
|
||||
heightMetadata = configuredPack().metadata();
|
||||
} catch (Throwable e) {
|
||||
LOGGER.warn("Iris generator '{}' could not pre-resolve pack heights for {}:{}: {}",
|
||||
dimensionKey, activePack, activeDimensionKey, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private ModdedDimensionMetadata.DimensionMetadata heightMetadata() {
|
||||
ModdedDimensionMetadata.DimensionMetadata cached = heightMetadata;
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
ModdedDimensionMetadata.ConfiguredPack pack = configuredPack;
|
||||
if (pack == null) {
|
||||
return UNBOUND_HEIGHTS;
|
||||
}
|
||||
ModdedDimensionMetadata.DimensionMetadata resolved = pack.metadata();
|
||||
heightMetadata = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private ModdedDimensionMetadata.ConfiguredPack configuredPack() {
|
||||
ModdedDimensionMetadata.ConfiguredPack cached = configuredPack;
|
||||
if (cached != null) {
|
||||
@@ -510,6 +606,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
ModdedDimensionMetadata.ConfiguredPack resolved = new ModdedDimensionMetadata.ConfiguredPack(
|
||||
data, dimension, ModdedDimensionMetadata.dimensionMetadata(dimension));
|
||||
configuredPack = resolved;
|
||||
heightMetadata = resolved.metadata();
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
@@ -518,6 +615,18 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
return dimensionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-facing importedFeatures state: null when the control is off, "on" when the feature table is
|
||||
* live, "degraded" when the control is enabled but the table failed to build (feature-order cycle).
|
||||
*/
|
||||
public String importedFeaturesStatus() {
|
||||
Engine current = engineIfBound();
|
||||
if (current == null || !NativeFeatureGenerationPolicy.isEnabled(current)) {
|
||||
return null;
|
||||
}
|
||||
return importedFeatures.active() ? "on" : "degraded";
|
||||
}
|
||||
|
||||
public Engine engineIfBound() {
|
||||
Engine current = engine;
|
||||
return unloading || current == null || current.isClosing() || current.isClosed() ? null : current;
|
||||
@@ -534,6 +643,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
public void onHotload() {
|
||||
configuredStructureBiomeKeys = null;
|
||||
structureBiomeSource.clearCaches();
|
||||
importedFeatures.invalidate();
|
||||
nativeStructures.clearWorldCheckStructureShifts();
|
||||
spawnTables.resetVanillaSpawnBiomes();
|
||||
}
|
||||
@@ -711,9 +821,15 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
@Override
|
||||
public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structureManager) {
|
||||
Engine current = engine();
|
||||
// Self-heal for an engine bound through a data-query path instead of bindLevel; a no-op once prepared.
|
||||
importedFeatures.prepare(current);
|
||||
try (GenerationSessionLease lease = requireGenerationLease(current, "modded_biome_decoration");
|
||||
IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) {
|
||||
nativeStructures.placeVanillaStructures(level, chunk, structureManager);
|
||||
// Vanilla's placed-feature pass, on THIS thread and never on ModdedGenPool: the FEATURES chunk
|
||||
// step writes into the eight neighbouring chunks and is not parallel-safe. Inert unless the
|
||||
// dimension set importedFeatures.enabled.
|
||||
importedFeatures.run(level, chunk, current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,7 +887,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
public int getGenDepth() {
|
||||
Engine current = engine;
|
||||
return current == null || current.isClosed()
|
||||
? configuredPack().metadata().depth()
|
||||
? heightMetadata().depth()
|
||||
: current.getMaxHeight() - current.getMinHeight();
|
||||
}
|
||||
|
||||
@@ -779,7 +895,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
public int getSeaLevel() {
|
||||
Engine current = engine;
|
||||
return current == null || current.isClosed()
|
||||
? configuredPack().metadata().seaLevel()
|
||||
? heightMetadata().seaLevel()
|
||||
: current.getMinHeight() + current.getDimension().getFluidHeight();
|
||||
}
|
||||
|
||||
@@ -787,7 +903,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
public int getMinY() {
|
||||
Engine current = engine;
|
||||
return current == null || current.isClosed()
|
||||
? configuredPack().metadata().minY()
|
||||
? heightMetadata().minY()
|
||||
: current.getMinHeight();
|
||||
}
|
||||
|
||||
|
||||
+156
-15
@@ -25,13 +25,21 @@ import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class MainWorldService {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final String MARKER_NAME = "mainworld.pending";
|
||||
private static final String PROPERTIES_NAME = "server.properties";
|
||||
/**
|
||||
* Distinct exit status for the staged main-world restart, so a wrapper can tell it apart from a clean
|
||||
* operator stop (0) and from a crash. 64 is the conventional first application-defined status.
|
||||
*/
|
||||
private static final int AUTO_RESTART_EXIT_STATUS = 64;
|
||||
private static final String[] VANILLA_DIMENSION_FOLDERS = {
|
||||
"region",
|
||||
"entities",
|
||||
@@ -62,23 +70,46 @@ public final class MainWorldService {
|
||||
if (pack == null || pack.isBlank()) {
|
||||
return;
|
||||
}
|
||||
Path properties = instanceRoot().resolve("server.properties");
|
||||
Path instanceRoot = verifiedInstanceRoot("reconcile the Iris main world");
|
||||
if (instanceRoot == null) {
|
||||
return;
|
||||
}
|
||||
Path properties = instanceRoot.resolve(PROPERTIES_NAME);
|
||||
String target = presetIdFor(pack);
|
||||
String currentType = readProperty(properties, "level-type");
|
||||
if (!target.equals(currentType)) {
|
||||
writeLevelProperties(properties, target, config.mainWorldSeed());
|
||||
markPending();
|
||||
LOGGER.warn("Iris main world '{}' staged: server.properties level-type set to {}. Restart again to generate it (this boot still uses the previous overworld; player data is kept).", pack, target);
|
||||
LOGGER.warn("Iris main world '{}' staged: {} level-type set to {}. Restart again to generate it (this boot still uses the previous overworld; player data is kept).",
|
||||
pack, properties, target);
|
||||
if (config.mainWorldAutoRestart()) {
|
||||
LOGGER.warn("Iris mainWorldAutoRestart is enabled; stopping the JVM now with exit status {} so a restart wrapper brings the server back on the new main world.",
|
||||
AUTO_RESTART_EXIT_STATUS);
|
||||
LOGGER.warn("Configure the start script to restart the server on exit status {} (status 0 means a clean stop, so it must not be reused for this).",
|
||||
AUTO_RESTART_EXIT_STATUS);
|
||||
System.exit(AUTO_RESTART_EXIT_STATUS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isPending()) {
|
||||
return;
|
||||
}
|
||||
String levelName = firstNonBlank(readProperty(properties, "level-name"), "world");
|
||||
Path worldRoot = resolveWorldRoot(levelName);
|
||||
Path worldRoot;
|
||||
try {
|
||||
worldRoot = resolveWorldRoot(instanceRoot, properties);
|
||||
} catch (MissingWorldRootException missing) {
|
||||
// First boot of a brand new instance, or a --universe/--world layout Iris cannot see from mod
|
||||
// bootstrap: there is no prior overworld to move aside, so this is nothing to quarantine, not a
|
||||
// reason to refuse startup.
|
||||
clearPending();
|
||||
LOGGER.warn("Iris main world '{}' had nothing to quarantine: {} does not exist. Continuing boot; the overworld generates as {}.",
|
||||
pack, missing.path(), target);
|
||||
return;
|
||||
}
|
||||
Path recovery = quarantineVanillaDimensions(worldRoot);
|
||||
clearPending();
|
||||
LOGGER.warn("Iris main world '{}' generated fresh: moved the prior overworld/nether/end data to {} so this boot regenerates them as {} (player data kept).", pack, recovery, target);
|
||||
LOGGER.warn("Iris main world '{}' generated fresh: moved the prior overworld/nether/end data from {} to {} so this boot regenerates them as {} (player data kept).",
|
||||
pack, worldRoot, recovery, target);
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("Iris main world reconciliation failed", e);
|
||||
throw new IllegalStateException(
|
||||
@@ -91,9 +122,12 @@ public final class MainWorldService {
|
||||
LOGGER.error("Iris main-world replacement is only available on dedicated servers; use the Create World generator selector in singleplayer");
|
||||
return false;
|
||||
}
|
||||
Path instanceRoot = verifiedInstanceRoot("stage the Iris main world");
|
||||
if (instanceRoot == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Path properties = instanceRoot().resolve("server.properties");
|
||||
writeLevelProperties(properties, presetIdFor(packRef), seed);
|
||||
writeLevelProperties(instanceRoot.resolve(PROPERTIES_NAME), presetIdFor(packRef), seed);
|
||||
markPending();
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
@@ -110,8 +144,21 @@ public final class MainWorldService {
|
||||
}
|
||||
}
|
||||
|
||||
private static Path instanceRoot() {
|
||||
return ModdedEngineBootstrap.loader().configDir().getParent();
|
||||
/**
|
||||
* net.minecraft.server.Main reads server.properties as Paths.get("server.properties"), so the authoritative
|
||||
* instance root is the JVM working directory - not configDir().getParent(), which points somewhere else
|
||||
* entirely whenever the loader config tree is relocated (-Dfabric.configDir, a shared config mount, a
|
||||
* launcher that starts the server from another directory). Refuse loudly rather than write or move files
|
||||
* against a guessed root: every caller treats null as "not a dedicated instance we may touch".
|
||||
*/
|
||||
private static Path verifiedInstanceRoot(String operation) {
|
||||
Path workingDirectory = Path.of("").toAbsolutePath().normalize();
|
||||
if (Files.isRegularFile(workingDirectory.resolve(PROPERTIES_NAME))) {
|
||||
return workingDirectory;
|
||||
}
|
||||
LOGGER.error("Iris refuses to {}: no {} in the server working directory {}", operation, PROPERTIES_NAME, workingDirectory);
|
||||
LOGGER.error("Iris only edits main-world properties in the directory the dedicated server reads {} from, and it moves no world data outside it. Start the server from its instance directory, or clear mainWorldPack in irisworldgen/modded.json.", PROPERTIES_NAME);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Path markerFile() {
|
||||
@@ -146,6 +193,11 @@ public final class MainWorldService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temp file plus ATOMIC_MOVE, the same publish shape ModdedForcedDatapack.writePublishedHash uses. A
|
||||
* truncated server.properties bricks the next boot, and this write happens during bootstrap where a crash
|
||||
* or a kill is entirely plausible.
|
||||
*/
|
||||
private static void writeLevelProperties(Path properties, String target, long seed) throws IOException {
|
||||
List<String> lines = Files.isRegularFile(properties)
|
||||
? new ArrayList<>(Files.readAllLines(properties, StandardCharsets.UTF_8))
|
||||
@@ -154,7 +206,14 @@ public final class MainWorldService {
|
||||
if (seed != 0L) {
|
||||
setProperty(lines, "level-seed", Long.toString(seed));
|
||||
}
|
||||
Files.write(properties, lines, StandardCharsets.UTF_8);
|
||||
|
||||
Path temp = properties.resolveSibling(PROPERTIES_NAME + ".iris-tmp-" + UUID.randomUUID());
|
||||
Files.write(temp, lines, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(temp, properties, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException atomicUnsupported) {
|
||||
Files.move(temp, properties, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
private static void setProperty(List<String> lines, String key, String value) {
|
||||
@@ -168,15 +227,97 @@ public final class MainWorldService {
|
||||
lines.add(prefix + value);
|
||||
}
|
||||
|
||||
private static Path resolveWorldRoot(String levelName) throws IOException {
|
||||
Path root = instanceRoot().toAbsolutePath().normalize();
|
||||
Path worldRoot = root.resolve(levelName).toAbsolutePath().normalize();
|
||||
if (worldRoot.equals(root) || !worldRoot.startsWith(root)) {
|
||||
throw new IOException("Unsafe level-name path outside the server instance: " + levelName);
|
||||
/**
|
||||
* Mirrors net.minecraft.server.Main world resolution: the universe root is --universe (default the working
|
||||
* directory) and the world folder name is --world, falling back to the level-name property.
|
||||
*/
|
||||
private static Path resolveWorldRoot(Path instanceRoot, Path properties) throws IOException {
|
||||
List<String> arguments = processArguments();
|
||||
Path universe = universeRoot(instanceRoot, commandLineOption(arguments, "universe"));
|
||||
String levelName = firstNonBlank(commandLineOption(arguments, "world"),
|
||||
firstNonBlank(readProperty(properties, "level-name"), "world"));
|
||||
return resolveWorldRoot(universe, levelName);
|
||||
}
|
||||
|
||||
static Path universeRoot(Path instanceRoot, String universeOption) {
|
||||
return (universeOption == null || universeOption.isBlank()
|
||||
? instanceRoot
|
||||
: instanceRoot.resolve(universeOption)).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
static Path resolveWorldRoot(Path universe, String levelName) throws IOException {
|
||||
if (!Files.isDirectory(universe)) {
|
||||
throw new MissingWorldRootException("Server universe directory does not exist: " + universe, universe);
|
||||
}
|
||||
Path worldRoot = universe.resolve(levelName).toAbsolutePath().normalize();
|
||||
if (worldRoot.equals(universe) || !worldRoot.startsWith(universe)) {
|
||||
throw new IOException("Unsafe world name outside the server universe " + universe + ": " + levelName);
|
||||
}
|
||||
if (!Files.isDirectory(worldRoot)) {
|
||||
throw new MissingWorldRootException("Server world directory does not exist: " + worldRoot, worldRoot);
|
||||
}
|
||||
return worldRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* A universe or world directory that is simply absent. Separated from every other IO failure so
|
||||
* reconciliation can treat it as nothing-to-quarantine instead of refusing startup; an unsafe world name
|
||||
* stays a plain IOException and still refuses.
|
||||
*/
|
||||
static final class MissingWorldRootException extends IOException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final Path path;
|
||||
|
||||
MissingWorldRootException(String message, Path path) {
|
||||
super(message);
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
Path path() {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best effort read of a dedicated-server launch option. The parsed OptionSet is not reachable from mod
|
||||
* bootstrap, so read the process arguments; when they are unavailable we fall back to the vanilla defaults,
|
||||
* which is what the unpatched code assumed unconditionally.
|
||||
*/
|
||||
static String commandLineOption(List<String> arguments, String name) {
|
||||
String flag = "--" + name;
|
||||
for (int index = 0; index < arguments.size(); index++) {
|
||||
String argument = arguments.get(index);
|
||||
if (argument.equals(flag)) {
|
||||
return index + 1 < arguments.size() ? arguments.get(index + 1) : null;
|
||||
}
|
||||
if (argument.startsWith(flag + "=")) {
|
||||
return argument.substring(flag.length() + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<String> processArguments() {
|
||||
try {
|
||||
Optional<String[]> arguments = ProcessHandle.current().info().arguments();
|
||||
if (arguments.isPresent() && arguments.get().length > 0) {
|
||||
return List.of(arguments.get());
|
||||
}
|
||||
} catch (RuntimeException unavailable) {
|
||||
LOGGER.debug("Iris could not read the process arguments", unavailable);
|
||||
}
|
||||
// Whitespace split only: sun.java.command is a flattened string with no quoting information, so a
|
||||
// --universe or --world value containing spaces cannot be recovered from it. Deliberately not parsed
|
||||
// further - a half-correct quote parser would hand world resolution a wrong directory, and the missing
|
||||
// world root path already degrades to the vanilla defaults instead of failing the boot.
|
||||
String command = System.getProperty("sun.java.command");
|
||||
if (command == null || command.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(command.trim().split("\\s+"));
|
||||
}
|
||||
|
||||
private static Path quarantineVanillaDimensions(Path worldRoot) throws IOException {
|
||||
Path recovery = markerFile().getParent().resolve("mainworld-recovery-" + UUID.randomUUID());
|
||||
List<Path> moved = new ArrayList<>();
|
||||
|
||||
@@ -34,13 +34,20 @@ import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class ModdedBiomeWriter implements PlatformBiomeWriter {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final String VANILLA_FALLBACK_KEY = "minecraft:plains";
|
||||
private static final int MAX_CACHED_IDS = 4096;
|
||||
/** NUL cannot occur in a pack or registry key, so the composite cache key stays unambiguous. */
|
||||
private static final char SCOPE_SEPARATOR = (char) 0;
|
||||
|
||||
private final Supplier<MinecraftServer> server;
|
||||
private final AtomicBoolean serverMissingReported = new AtomicBoolean();
|
||||
private volatile RegistryCache cache;
|
||||
|
||||
public ModdedBiomeWriter(Supplier<MinecraftServer> server) {
|
||||
this.server = server;
|
||||
@@ -50,9 +57,33 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter {
|
||||
public int biomeIdFor(String key) {
|
||||
Registry<Biome> registry = biomeRegistry();
|
||||
if (registry == null) {
|
||||
reportMissingServer("resolve the biome id for '" + key + "'", "using biome id 0");
|
||||
return 0;
|
||||
}
|
||||
int direct = idForKey(registry, scopedBiomeKey(key));
|
||||
if (key == null) {
|
||||
LOGGER.warn("Iris biome writer got a null biome key; falling back to {}", VANILLA_FALLBACK_KEY);
|
||||
return fallbackId(registry);
|
||||
}
|
||||
|
||||
RegistryCache cached = cacheFor(registry);
|
||||
String scoped = scopedBiomeKey(key);
|
||||
// The scoped key depends on the calling engine, so the same pack key can resolve differently per
|
||||
// dimension. Cache on both halves; the derivative path below reads the raw key.
|
||||
String cacheKey = scoped.equals(key) ? key : key + SCOPE_SEPARATOR + scoped;
|
||||
Integer hit = cached.ids.get(cacheKey);
|
||||
if (hit != null) {
|
||||
return hit;
|
||||
}
|
||||
|
||||
int resolved = resolve(registry, key, scoped);
|
||||
if (cached.ids.size() < MAX_CACHED_IDS) {
|
||||
cached.ids.put(cacheKey, resolved);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private int resolve(Registry<Biome> registry, String key, String scoped) {
|
||||
int direct = idForKey(registry, scoped);
|
||||
if (direct >= 0) {
|
||||
return direct;
|
||||
}
|
||||
@@ -79,17 +110,25 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter {
|
||||
@Override
|
||||
public List<PlatformBiome> allBiomes() {
|
||||
Registry<Biome> registry = biomeRegistry();
|
||||
List<PlatformBiome> biomes = new ArrayList<>();
|
||||
if (registry == null) {
|
||||
return biomes;
|
||||
reportMissingServer("enumerate the biome registry", "returning no biomes");
|
||||
return new ArrayList<>();
|
||||
}
|
||||
for (Identifier identifier : registry.keySet()) {
|
||||
Biome biome = registry.getValue(identifier);
|
||||
if (biome != null) {
|
||||
biomes.add(ModdedBiome.of(biome, identifier.toString()));
|
||||
|
||||
RegistryCache cached = cacheFor(registry);
|
||||
List<PlatformBiome> snapshot = cached.biomes;
|
||||
if (snapshot == null) {
|
||||
List<PlatformBiome> built = new ArrayList<>();
|
||||
for (Identifier identifier : registry.keySet()) {
|
||||
Biome biome = registry.getValue(identifier);
|
||||
if (biome != null) {
|
||||
built.add(ModdedBiome.of(biome, identifier.toString()));
|
||||
}
|
||||
}
|
||||
snapshot = List.copyOf(built);
|
||||
cached.biomes = snapshot;
|
||||
}
|
||||
return biomes;
|
||||
return new ArrayList<>(snapshot);
|
||||
}
|
||||
|
||||
private int idForKey(Registry<Biome> registry, String key) {
|
||||
@@ -151,6 +190,44 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter {
|
||||
if (instance == null) {
|
||||
return null;
|
||||
}
|
||||
// Read before write: this runs for every biome id on every generation thread, and an unconditional
|
||||
// store on a shared cache line is a contended write on the hot path for a flag that is almost always
|
||||
// already false.
|
||||
if (serverMissingReported.get()) {
|
||||
serverMissingReported.set(false);
|
||||
}
|
||||
return instance.registryAccess().lookupOrThrow(Registries.BIOME);
|
||||
}
|
||||
|
||||
/**
|
||||
* The SPI requires biome writers to cache their registry lookups: biomeIdFor runs from generation threads
|
||||
* for every biome a pack names, and the derivative path walks every active engine and every custom biome.
|
||||
* The cache is keyed on the biome Registry instance, which the server replaces whenever datapacks reload,
|
||||
* so a reload invalidates everything for free.
|
||||
*/
|
||||
private RegistryCache cacheFor(Registry<Biome> registry) {
|
||||
RegistryCache current = cache;
|
||||
if (current != null && current.registry == registry) {
|
||||
return current;
|
||||
}
|
||||
RegistryCache replacement = new RegistryCache(registry);
|
||||
cache = replacement;
|
||||
return replacement;
|
||||
}
|
||||
|
||||
private void reportMissingServer(String operation, String fallback) {
|
||||
if (serverMissingReported.compareAndSet(false, true)) {
|
||||
LOGGER.warn("Iris cannot {} before the Minecraft server is available; {}", operation, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RegistryCache {
|
||||
private final Registry<Biome> registry;
|
||||
private final ConcurrentHashMap<String, Integer> ids = new ConcurrentHashMap<>();
|
||||
private volatile List<PlatformBiome> biomes;
|
||||
|
||||
private RegistryCache(Registry<Biome> registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-4
@@ -68,6 +68,14 @@ public final class ModdedBlockBreakHandler {
|
||||
if (engineFor(level) == null) {
|
||||
return;
|
||||
}
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler == null) {
|
||||
// finishPending is the only thing that evicts an unconsumed entry. With no scheduler there is no
|
||||
// sweep, so an entry inserted here would leak for the rest of the server uptime.
|
||||
LOGGER.debug("Iris skipped block-break provenance at {},{},{}: scheduler unavailable",
|
||||
position.getX(), position.getY(), position.getZ());
|
||||
return;
|
||||
}
|
||||
BreakKey key = new BreakKey(level, position.asLong());
|
||||
ModdedTreeFellerService treeFeller = treeFellerService();
|
||||
ModdedTreeFellerService.PreparedOrigin preparedOrigin = treeFeller == null
|
||||
@@ -75,10 +83,7 @@ public final class ModdedBlockBreakHandler {
|
||||
: treeFeller.prepare(level, player, position, brokenState);
|
||||
PendingBreak pending = new PendingBreak(brokenState, preparedOrigin);
|
||||
PENDING.put(key, pending);
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler != null) {
|
||||
scheduler.laterGlobal(() -> finishPending(key, pending, position), 1);
|
||||
}
|
||||
scheduler.laterGlobal(() -> finishPending(key, pending, position), 1);
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
|
||||
+29
-9
@@ -210,12 +210,9 @@ public final class ModdedBlockResolution {
|
||||
}
|
||||
|
||||
static Parsed resolveGet(String bdxf) {
|
||||
Parsed parsed = resolveOrNull(bdxf, false);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
IrisLogging.error("Can't find block data for " + bdxf);
|
||||
return new Parsed(AIR, null, null);
|
||||
// Mirrors the Bukkit path: an unknown key warns (rate limited) and falls back to air, instead of
|
||||
// resolving to air with no output at all.
|
||||
return resolveNoCompat(bdxf);
|
||||
}
|
||||
|
||||
static Parsed resolveNoCompat(String bdxf) {
|
||||
@@ -226,6 +223,10 @@ public final class ModdedBlockResolution {
|
||||
return new Parsed(AIR, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a key, returning null when nothing claims it. Never substitutes air - {@link #resolveNoCompat(String)}
|
||||
* owns the air fallback.
|
||||
*/
|
||||
static Parsed resolveOrNull(String bdxf, boolean warn) {
|
||||
try {
|
||||
String bd = bdxf.trim();
|
||||
@@ -248,7 +249,7 @@ public final class ModdedBlockResolution {
|
||||
if (warn) {
|
||||
warnUnresolved(bd, "Unknown Block Data '" + bd + "'");
|
||||
}
|
||||
return new Parsed(AIR, null, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
return bdx;
|
||||
@@ -286,16 +287,35 @@ public final class ModdedBlockResolution {
|
||||
return parseStrict(s);
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (s.contains("[")) {
|
||||
return createBlockData(s.split("\\Q[\\E")[0], warn);
|
||||
String base = s.split("\\Q[\\E")[0];
|
||||
Parsed stripped = createBlockData(base, warn);
|
||||
if (stripped != null && warn) {
|
||||
// Dedup on the base block key, not the full state string. UnresolvedKeyLog interns every key it
|
||||
// is handed into a set it never trims, and a rejected property is usually rejected for every
|
||||
// value and every combination a pack uses - keying on the state string would intern one entry per
|
||||
// distinct state (16 levels x 6 facings x ...) for a single authoring mistake.
|
||||
warnUnresolved("props:" + base,
|
||||
"Block '" + base + "' rejected state '" + propertySection(s) + "'; using its default state");
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
|
||||
if (warn) {
|
||||
IrisLogging.warn("Can't find block data for " + s);
|
||||
warnUnresolved(s, "Can't find block data for " + s);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String propertySection(String key) {
|
||||
int open = key.indexOf('[');
|
||||
if (open < 0) {
|
||||
return "";
|
||||
}
|
||||
int close = key.indexOf(']', open);
|
||||
return close < 0 ? key.substring(open + 1) : key.substring(open + 1, close);
|
||||
}
|
||||
|
||||
private static Parsed materialBlockData(String ix) {
|
||||
if (ix.contains("[") || ix.contains(":")) {
|
||||
return null;
|
||||
|
||||
+8
-1
@@ -57,12 +57,15 @@ import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class ModdedDimensionManager {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final Object LOCK = new Object();
|
||||
private static final ConcurrentHashMap<String, Handle> HANDLES = new ConcurrentHashMap<>();
|
||||
private static final TicketType TELEPORT_WARM_TICKET = new TicketType(TicketType.NO_TIMEOUT, TicketType.FLAG_LOADING);
|
||||
private static final TicketType TELEPORT_WARM_TICKET = new TicketType(TicketType.NO_TIMEOUT,
|
||||
TicketType.FLAG_LOADING | TicketType.FLAG_KEEP_DIMENSION_ACTIVE);
|
||||
private static final long TELEPORT_WARM_TIMEOUT_SECONDS = 30L;
|
||||
private static volatile ModdedServerAccess access;
|
||||
|
||||
private ModdedDimensionManager() {
|
||||
@@ -96,6 +99,8 @@ public final class ModdedDimensionManager {
|
||||
return handle.level();
|
||||
}
|
||||
ResourceKey<Level> key = levelKey(dimensionId);
|
||||
// Server thread only (create/remove hold LOCK, teleport and the primary-world router tick, command
|
||||
// handlers). Off-thread callers must use ModdedServerLevels.level instead of the live map.
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
if (level.dimension().equals(key)) {
|
||||
return level;
|
||||
@@ -270,6 +275,8 @@ public final class ModdedDimensionManager {
|
||||
CompletableFuture
|
||||
.supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1), server)
|
||||
.thenCompose((CompletableFuture<?> inner) -> inner)
|
||||
// The ticket has no timeout of its own: bound the wait so the release below always runs.
|
||||
.orTimeout(TELEPORT_WARM_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.whenComplete((Object result, Throwable error) -> server.execute(() -> {
|
||||
level.getChunkSource().removeTicketWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1);
|
||||
if (error != null) {
|
||||
|
||||
+56
@@ -37,10 +37,13 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class ModdedDimensionRegistryStore {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final String FILE_NAME = "iris-dimensions.json";
|
||||
private static final Pattern ID_FIELD = Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
|
||||
private ModdedDimensionRegistryStore() {
|
||||
}
|
||||
@@ -53,6 +56,59 @@ public final class ModdedDimensionRegistryStore {
|
||||
return contents(file).dimensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-safe load: a corrupt registry must not abort server start. The broken file is renamed aside so the
|
||||
* next write starts clean, and every id we can still recognise in the raw text is reported as lost.
|
||||
*/
|
||||
public static List<PersistentDimension> loadForStartup(MinecraftServer server) {
|
||||
return loadForStartup(storeFile(server));
|
||||
}
|
||||
|
||||
static List<PersistentDimension> loadForStartup(Path file) {
|
||||
try {
|
||||
return load(file);
|
||||
} catch (RuntimeException corrupt) {
|
||||
LOGGER.error("Iris persistent dimension registry at {} is corrupt; quarantining it and continuing boot",
|
||||
file, corrupt);
|
||||
List<String> lostIds = salvageIds(file);
|
||||
if (lostIds.isEmpty()) {
|
||||
LOGGER.error("Iris could not recover any dimension ids from the corrupt registry; re-create the worlds with /iris world create");
|
||||
} else {
|
||||
LOGGER.error("Iris lost {} persistent dimension(s) from the corrupt registry: {}",
|
||||
lostIds.size(), String.join(", ", lostIds));
|
||||
}
|
||||
quarantine(file);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> salvageIds(Path file) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
try {
|
||||
Matcher matcher = ID_FIELD.matcher(Files.readString(file, StandardCharsets.UTF_8));
|
||||
while (matcher.find()) {
|
||||
String id = matcher.group(1);
|
||||
if (!ids.contains(id)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException unreadable) {
|
||||
LOGGER.warn("Iris could not scan the corrupt persistent dimension registry at {} for lost ids", file, unreadable);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static void quarantine(Path file) {
|
||||
Path broken = file.resolveSibling(FILE_NAME + ".broken-" + System.currentTimeMillis());
|
||||
try {
|
||||
Files.move(file, broken, StandardCopyOption.REPLACE_EXISTING);
|
||||
LOGGER.error("Iris moved the corrupt persistent dimension registry to {}", broken);
|
||||
} catch (IOException failure) {
|
||||
LOGGER.error("Iris could not quarantine the corrupt persistent dimension registry at {}; delete it by hand",
|
||||
file, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Contents contents(Path file) {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return new Contents(new ArrayList<>(), new ArrayList<>());
|
||||
|
||||
+9
-11
@@ -125,6 +125,9 @@ public final class ModdedEngineBootstrap {
|
||||
}
|
||||
|
||||
public static void serverStarted(MinecraftServer server) {
|
||||
// Prime the off-thread level snapshot before anything can read it; the per-tick refresh in
|
||||
// ModdedScheduler.tick has not run yet at this point.
|
||||
ModdedServerLevels.refreshIfStale(server);
|
||||
bindWorldGenerators(server);
|
||||
ModdedStartup.runOnce(server);
|
||||
reconcileSpawn(server);
|
||||
@@ -174,6 +177,10 @@ public final class ModdedEngineBootstrap {
|
||||
ModdedScheduler scheduler = schedulerOrNull();
|
||||
if (scheduler != null) {
|
||||
failure = runStopStage(failure, "scheduler", scheduler::shutdown);
|
||||
} else {
|
||||
// No bound runtime: the scheduler shutdown that normally drops the level snapshot never runs, and a
|
||||
// static snapshot of a stopped server keeps its whole level graph alive.
|
||||
failure = runStopStage(failure, "level snapshot", ModdedServerLevels::forget);
|
||||
}
|
||||
failure = runStopStage(failure, "generation pool", IrisModdedChunkGenerator::shutdownGenPool);
|
||||
failure = runStopStage(failure, "sentry", ModdedSentry::flush);
|
||||
@@ -184,8 +191,9 @@ public final class ModdedEngineBootstrap {
|
||||
initialSpawnWasDefault = false;
|
||||
});
|
||||
if (failure != null) {
|
||||
// The shutdown path must not propagate: propagating aborts the remaining loader stop handlers and
|
||||
// can leave the level unsaved. Every stage already logged its own failure.
|
||||
LOGGER.error("Iris modded shutdown completed with failures", failure);
|
||||
throw propagateStopFailure(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,16 +213,6 @@ public final class ModdedEngineBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeException propagateStopFailure(Throwable failure) {
|
||||
if (failure instanceof RuntimeException runtimeException) {
|
||||
return runtimeException;
|
||||
}
|
||||
if (failure instanceof Error fatalError) {
|
||||
throw fatalError;
|
||||
}
|
||||
return new IllegalStateException("Iris modded shutdown completed with failures", failure);
|
||||
}
|
||||
|
||||
private static void captureInitialSpawn(MinecraftServer server) {
|
||||
if (spawnCaptureServer == server) {
|
||||
return;
|
||||
|
||||
+255
-20
@@ -44,12 +44,18 @@ import org.slf4j.LoggerFactory;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -63,20 +69,79 @@ public final class ModdedForcedDatapack {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final String PACK_ID = "iris_worldgen";
|
||||
private static final String PACK_FOLDER = "iris";
|
||||
private static final String HASH_FILE_NAME = "packs.hash";
|
||||
// Bump this whenever the emitted datapack content changes for reasons the pack-directory hash cannot see.
|
||||
// v2: custom biomes now inherit their vanilla derivative's biome tags, so every already-published pack has
|
||||
// to regenerate once.
|
||||
private static final String HASH_SALT = "iris-forced-datapack-v2";
|
||||
private static final String GIT_DIRECTORY = ".git";
|
||||
private static final long PACKS_HASH_TTL_NANOS = 2_000_000_000L;
|
||||
private static final Object LOCK = new Object();
|
||||
private static final AtomicBoolean LOADED = new AtomicBoolean(false);
|
||||
private static final AtomicBoolean STALE_SERVE_LOGGED = new AtomicBoolean(false);
|
||||
private static volatile PublishedState published;
|
||||
private static volatile HashMemo packsHashMemo;
|
||||
|
||||
private ModdedForcedDatapack() {
|
||||
}
|
||||
|
||||
public static RepositorySource repositorySource() {
|
||||
return (Consumer<Pack> consumer) -> {
|
||||
Pack pack = buildPack();
|
||||
Pack pack = servePack();
|
||||
consumer.accept(pack);
|
||||
LOADED.set(true);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the published datapack without regenerating it whenever the installed packs still hash to what
|
||||
* was generated last. That fast path is lock-free; everything else takes LOCK and rechecks, so a boot-time
|
||||
* daemon regeneration and a loadPacks regeneration can never stage concurrently (publishDirectory moves the
|
||||
* live directory aside, which would break a concurrent read).
|
||||
*
|
||||
* <p>A hash mismatch is never served: the HASH_SALT bump alone mismatches every install on its first boot
|
||||
* after an upgrade, and serving that directory hands Create World a pack without the current biome tags.
|
||||
* A published pack whose hash cannot be computed at all is still served, with one warning.
|
||||
*/
|
||||
private static Pack servePack() {
|
||||
String currentHash = packsHashOrEmpty();
|
||||
PublishedState current = publishedState();
|
||||
if (current != null && !currentHash.isEmpty() && current.packsHash().equals(currentHash)) {
|
||||
try {
|
||||
return requireReadablePack(current.directory());
|
||||
} catch (RuntimeException unreadable) {
|
||||
published = null;
|
||||
LOGGER.error("Iris could not read the published forced datapack at {}; regenerating",
|
||||
current.directory(), unreadable);
|
||||
}
|
||||
}
|
||||
synchronized (LOCK) {
|
||||
String hash = packsHashOrEmpty();
|
||||
PublishedState state = publishedState();
|
||||
String reason;
|
||||
if (state == null) {
|
||||
reason = "no published pack";
|
||||
} else if (!hash.isEmpty() && !state.packsHash().equals(hash)) {
|
||||
reason = "stale cache (hash changed)";
|
||||
} else {
|
||||
if (hash.isEmpty() && STALE_SERVE_LOGGED.compareAndSet(false, true)) {
|
||||
LOGGER.warn("Iris cannot hash the installed packs; serving the last generated forced datapack from {} unverified",
|
||||
state.directory());
|
||||
}
|
||||
try {
|
||||
return requireReadablePack(state.directory());
|
||||
} catch (RuntimeException unreadable) {
|
||||
published = null;
|
||||
LOGGER.error("Iris could not read the published forced datapack at {}; regenerating",
|
||||
state.directory(), unreadable);
|
||||
}
|
||||
reason = "unreadable published pack";
|
||||
}
|
||||
LOGGER.info("Iris forced datapack cache is unusable ({}); generating it once now", reason);
|
||||
return buildPack();
|
||||
}
|
||||
}
|
||||
|
||||
public static void verifyInjected() {
|
||||
if (LOADED.get()) {
|
||||
return;
|
||||
@@ -105,11 +170,11 @@ public final class ModdedForcedDatapack {
|
||||
try {
|
||||
return requireReadablePack(regenerate());
|
||||
} catch (RuntimeException | Error generationFailure) {
|
||||
Path published = packDirectory();
|
||||
if (Files.isRegularFile(published.resolve("pack.mcmeta"))) {
|
||||
Path lastKnownGood = packDirectory();
|
||||
if (Files.isRegularFile(lastKnownGood.resolve("pack.mcmeta"))) {
|
||||
LOGGER.error("Iris kept the last known-good generated datapack after regeneration failed",
|
||||
generationFailure);
|
||||
return requireReadablePack(published);
|
||||
return requireReadablePack(lastKnownGood);
|
||||
}
|
||||
throw generationFailure;
|
||||
}
|
||||
@@ -148,8 +213,50 @@ public final class ModdedForcedDatapack {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates only when the installed packs no longer hash to the published datapack. Used by the boot
|
||||
* trigger so a steady-state restart does not pay the full staging cost.
|
||||
*/
|
||||
public static boolean regenerateIfStale(String reason) {
|
||||
synchronized (LOCK) {
|
||||
String currentHash = packsHashOrEmpty();
|
||||
PublishedState state = publishedState();
|
||||
if (state != null && !currentHash.isEmpty() && state.packsHash().equals(currentHash)) {
|
||||
LOGGER.debug("Iris forced datapack is current ({}); skipping regeneration", reason);
|
||||
return false;
|
||||
}
|
||||
LOGGER.info("Iris regenerating the forced datapack ({})", reason);
|
||||
regenerate();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Off-thread regeneration trigger. Call sites that run on the server thread must use this so a command
|
||||
* or lifecycle hook never blocks on pack staging.
|
||||
*/
|
||||
public static void scheduleRegeneration(String reason) {
|
||||
Runnable task = () -> {
|
||||
try {
|
||||
regenerateIfStale(reason);
|
||||
} catch (Throwable failure) {
|
||||
LOGGER.error("Iris forced datapack regeneration failed ({})", reason, failure);
|
||||
}
|
||||
};
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler != null) {
|
||||
scheduler.async(task);
|
||||
return;
|
||||
}
|
||||
Thread thread = new Thread(task, "iris-modded-datapack-regen");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private static Path write() throws IOException {
|
||||
ModdedStartup.ensureDefaultPack();
|
||||
// No ensureDefaultPack() here: write() is reachable from loadPacks on the first boot, and loadPacks
|
||||
// must never touch the network. ModdedStartup.prefetchDefaultPack covers the download at boot.
|
||||
String packsHash = packsHash();
|
||||
Path datapackRoot = datapackRoot();
|
||||
Files.createDirectories(datapackRoot);
|
||||
Path stagingDirectory = Files.createTempDirectory(datapackRoot, PACK_FOLDER + ".staging-");
|
||||
@@ -157,6 +264,12 @@ public final class ModdedForcedDatapack {
|
||||
writeStagedPack(stagingDirectory);
|
||||
requireReadablePack(stagingDirectory);
|
||||
publishDirectory(stagingDirectory, packDirectory());
|
||||
writePublishedHash(packsHash);
|
||||
published = new PublishedState(packDirectory(), packsHash);
|
||||
// Publish the hash this run was built from as the memo too: a memo captured before staging would
|
||||
// otherwise mismatch what was just published and send the next serve straight back into buildPack.
|
||||
packsHashMemo = new HashMemo(packsHash, System.nanoTime());
|
||||
STALE_SERVE_LOGGED.set(false);
|
||||
return packDirectory();
|
||||
} catch (IOException | RuntimeException | Error failure) {
|
||||
try {
|
||||
@@ -168,6 +281,115 @@ public final class ModdedForcedDatapack {
|
||||
}
|
||||
}
|
||||
|
||||
private static PublishedState publishedState() {
|
||||
PublishedState current = published;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
Path directory = packDirectory();
|
||||
if (!Files.isRegularFile(directory.resolve("pack.mcmeta"))) {
|
||||
return null;
|
||||
}
|
||||
PublishedState loaded = new PublishedState(directory, readPublishedHash());
|
||||
published = loaded;
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private static String readPublishedHash() {
|
||||
Path hashFile = datapackRoot().resolve(HASH_FILE_NAME);
|
||||
if (!Files.isRegularFile(hashFile)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return Files.readString(hashFile, StandardCharsets.UTF_8).trim();
|
||||
} catch (IOException unreadable) {
|
||||
LOGGER.warn("Iris could not read the forced datapack hash at {}", hashFile, unreadable);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static void writePublishedHash(String hash) throws IOException {
|
||||
Path hashFile = datapackRoot().resolve(HASH_FILE_NAME);
|
||||
Path temp = hashFile.resolveSibling(HASH_FILE_NAME + ".tmp-" + UUID.randomUUID());
|
||||
Files.writeString(temp, hash, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(temp, hashFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException atomicUnsupported) {
|
||||
Files.move(temp, hashFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-TTL memo: loadPacks runs on every PackRepository reload and the hash walks every installed pack
|
||||
* file (thousands on a studio install), so back-to-back reloads must not re-walk the tree. The window is
|
||||
* small enough that an operator dropping in a pack still gets picked up on the next reload.
|
||||
*/
|
||||
private static String packsHashOrEmpty() {
|
||||
long now = System.nanoTime();
|
||||
HashMemo memo = packsHashMemo;
|
||||
if (memo != null && now - memo.takenAtNanos() < PACKS_HASH_TTL_NANOS) {
|
||||
return memo.hash();
|
||||
}
|
||||
String hash;
|
||||
try {
|
||||
hash = packsHash();
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
LOGGER.warn("Iris could not hash the installed packs directory", failure);
|
||||
hash = "";
|
||||
}
|
||||
packsHashMemo = new HashMemo(hash, now);
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content hash over the installed packs: relative path, size and mtime of every regular file, plus the
|
||||
* pack format and loader the generated datapack is shaped for.
|
||||
*/
|
||||
private static String packsHash() throws IOException {
|
||||
MessageDigest digest;
|
||||
try {
|
||||
digest = MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException missing) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", missing);
|
||||
}
|
||||
digest.update((HASH_SALT + '|' + ModdedEngineBootstrap.loader().platformName()
|
||||
+ '|' + DataVersion.getLatest().getPackFormat() + '\n').getBytes(StandardCharsets.UTF_8));
|
||||
Path root = packsRoot();
|
||||
if (Files.isDirectory(root)) {
|
||||
List<String> entries = new ArrayList<>();
|
||||
Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
|
||||
// Studio packs can be git checkouts; .git churns constantly and never reaches the datapack.
|
||||
return GIT_DIRECTORY.equals(directory.getFileName().toString())
|
||||
? FileVisitResult.SKIP_SUBTREE
|
||||
: FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) {
|
||||
if (attributes.isRegularFile()) {
|
||||
entries.add(root.relativize(file).toString().replace('\\', '/')
|
||||
+ '|' + attributes.size() + '|' + attributes.lastModifiedTime().toMillis());
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException failure) {
|
||||
entries.add(root.relativize(file).toString().replace('\\', '/') + "|unreadable");
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
entries.sort(Comparator.naturalOrder());
|
||||
for (String entry : entries) {
|
||||
digest.update(entry.getBytes(StandardCharsets.UTF_8));
|
||||
digest.update((byte) '\n');
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static void writeStagedPack(Path stagingDirectory) throws IOException {
|
||||
Map<String, KSet<String>> seenBiomes = new LinkedHashMap<>();
|
||||
IDataFixer fixer = DataVersion.getLatest().get();
|
||||
@@ -189,6 +411,9 @@ public final class ModdedForcedDatapack {
|
||||
}
|
||||
|
||||
writePackMeta(stagingDirectory);
|
||||
// Forge only: FML has no block-drops event Iris can use, so drops are routed through a global loot
|
||||
// modifier. NeoForge does not need one - IrisNeoForgeBootstrap listens to BlockDropsEvent and both
|
||||
// replaces and appends drops there, so emitting a modifier would double-apply them.
|
||||
if ("forge".equalsIgnoreCase(ModdedEngineBootstrap.loader().platformName())) {
|
||||
writeForgeBlockLootModifier(stagingDirectory);
|
||||
}
|
||||
@@ -211,9 +436,7 @@ public final class ModdedForcedDatapack {
|
||||
} catch (Throwable validationFailure) {
|
||||
LOGGER.error("Iris excluded pack '{}' from Create World because validation failed",
|
||||
sourcePack.getName(), validationFailure);
|
||||
if (validationFailure instanceof Error fatalError) {
|
||||
throw fatalError;
|
||||
}
|
||||
rethrowIfUnrecoverable(validationFailure);
|
||||
return false;
|
||||
}
|
||||
if (!validation.isLoadable()) {
|
||||
@@ -235,9 +458,7 @@ public final class ModdedForcedDatapack {
|
||||
} catch (Throwable installationFailure) {
|
||||
LOGGER.error("Iris excluded pack '{}' from Create World because datapack serialization failed",
|
||||
sourcePack.getName(), installationFailure);
|
||||
if (installationFailure instanceof Error fatalError) {
|
||||
throw fatalError;
|
||||
}
|
||||
rethrowIfUnrecoverable(installationFailure);
|
||||
installed = false;
|
||||
}
|
||||
|
||||
@@ -259,6 +480,20 @@ public final class ModdedForcedDatapack {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-pack staging isolates every failure so one broken pack cannot brick Create World for all of them.
|
||||
* Only a VM-level failure (heap exhausted, native stack blown) is rethrown; LinkageError and
|
||||
* ExceptionInInitializerError are exactly the per-pack failures that must stay contained.
|
||||
*/
|
||||
private static void rethrowIfUnrecoverable(Throwable failure) {
|
||||
if (failure instanceof OutOfMemoryError outOfMemory) {
|
||||
throw outOfMemory;
|
||||
}
|
||||
if (failure instanceof StackOverflowError stackOverflow) {
|
||||
throw stackOverflow;
|
||||
}
|
||||
}
|
||||
|
||||
private static void mergeDirectory(Path sourceDirectory, Path destinationDirectory) throws IOException {
|
||||
List<Path> entries = new ArrayList<>();
|
||||
try (Stream<Path> walk = Files.walk(sourceDirectory)) {
|
||||
@@ -345,15 +580,6 @@ public final class ModdedForcedDatapack {
|
||||
.resolve("worldgen").resolve("world_preset").resolve(presetPath + ".json");
|
||||
Files.createDirectories(output.getParent());
|
||||
Files.writeString(output, json, StandardCharsets.UTF_8);
|
||||
String legacyPresetKey = dimensionKey.equals(packName)
|
||||
? packName
|
||||
: packName + "_" + dimensionKey;
|
||||
Path legacyOutput = datapackRoot.toPath().resolve("data").resolve("irisworldgen")
|
||||
.resolve("worldgen").resolve("world_preset").resolve(legacyPresetKey + ".json");
|
||||
if (!Files.exists(legacyOutput)) {
|
||||
Files.createDirectories(legacyOutput.getParent());
|
||||
Files.writeString(legacyOutput, json, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +652,9 @@ public final class ModdedForcedDatapack {
|
||||
.resolve("dimension_type").resolve(typePath + ".json");
|
||||
Files.createDirectories(output.getParent());
|
||||
Files.writeString(output, json, StandardCharsets.UTF_8);
|
||||
// Load-bearing, not dead: worlds created before the scoped pack path reference
|
||||
// irisworldgen:<dimensionTypeKey> in their level.dat, and ModdedWorldEngines accepts that legacy
|
||||
// key when validating the runtime dimension contract. Removing this emission unloads those worlds.
|
||||
Path legacyOutput = datapackRoot.toPath().resolve("data").resolve("irisworldgen")
|
||||
.resolve("dimension_type").resolve(dimension.getDimensionTypeKey() + ".json");
|
||||
if (!Files.exists(legacyOutput)) {
|
||||
@@ -517,4 +746,10 @@ public final class ModdedForcedDatapack {
|
||||
private static Path packsRoot() {
|
||||
return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs");
|
||||
}
|
||||
|
||||
private record PublishedState(Path directory, String packsHash) {
|
||||
}
|
||||
|
||||
private record HashMemo(String hash, long takenAtNanos) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,59 +18,329 @@
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Owns the Iris generation pool and the single decision of whether this loader already runs chunk
|
||||
* generation on its own worker threads.
|
||||
*
|
||||
* <p>The parallel-chunk-system probe is a two-stage check: {@link Class#forName} presence gates it
|
||||
* (never a version string), then the mod's own configuration is read reflectively to confirm the
|
||||
* feature is actually enabled. Any reflection failure resolves to "not parallel", which keeps
|
||||
* generation on the Iris pool - the safe side, since an extra hop only costs throughput while a
|
||||
* missing hop serializes generation on the loader's chunk threads.
|
||||
*/
|
||||
public final class ModdedGenPool {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L;
|
||||
private static final String[] C2ME_MARKERS = {
|
||||
"com.ishland.c2me.base.ModProperties",
|
||||
"com.ishland.c2me.base.common.config.C2MEConfig",
|
||||
"com.ishland.c2me.opts.chunkio.ModProperties"
|
||||
};
|
||||
private static final String C2ME_CONFIG = "com.ishland.c2me.base.common.config.C2MEConfig";
|
||||
private static final String[] C2ME_SECTIONS = {"asyncScheduling", "asyncSchedulingConfig", "threadedWorldGen"};
|
||||
private static final String[] C2ME_FLAGS = {"enabled", "isEnabled", "shouldEnable"};
|
||||
private static final String MOONRISE_MARKER = "ca.spottedleaf.moonrise.common.util.MoonriseCommon";
|
||||
private static final String[] MOONRISE_WORKER_COUNTS = {"getWorkerThreads", "workerThreads"};
|
||||
private static final String[] MOONRISE_POOLS = {"WORKER_POOL", "workerPool"};
|
||||
private static final String[] MOONRISE_POOL_COUNTS = {"getCoreThreads", "getThreadCount", "getThreads", "coreThreads", "threadCount"};
|
||||
|
||||
final class ModdedGenPool {
|
||||
private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger();
|
||||
private static final boolean PARALLEL_CHUNK_SYSTEM = detectParallelChunkSystem();
|
||||
private static volatile ExecutorService genPool = createGenPool();
|
||||
private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem();
|
||||
private static final AtomicReference<ExecutorService> GEN_POOL = new AtomicReference<>(createGenPool());
|
||||
|
||||
private ModdedGenPool() {
|
||||
}
|
||||
|
||||
static boolean parallelChunkSystem() {
|
||||
return PARALLEL_CHUNK_SYSTEM;
|
||||
/**
|
||||
* True when the loader's chunk system already generates off the server thread, so Iris must not
|
||||
* add its own pool hop.
|
||||
*/
|
||||
public static boolean parallelChunkSystem() {
|
||||
return CHUNK_SYSTEM.parallel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Terse description of the detected chunk system, for one-line diagnostics.
|
||||
*/
|
||||
public static String describeChunkSystem() {
|
||||
return CHUNK_SYSTEM.description();
|
||||
}
|
||||
|
||||
static ExecutorService pool() {
|
||||
return genPool;
|
||||
ExecutorService pool = GEN_POOL.get();
|
||||
if (pool != null && !pool.isShutdown()) {
|
||||
return pool;
|
||||
}
|
||||
start();
|
||||
ExecutorService restarted = GEN_POOL.get();
|
||||
if (restarted == null) {
|
||||
throw new RejectedExecutionException("Iris gen pool is shut down");
|
||||
}
|
||||
return restarted;
|
||||
}
|
||||
|
||||
static void start() {
|
||||
ExecutorService pool = genPool;
|
||||
if (pool == null || pool.isShutdown()) {
|
||||
genPool = createGenPool();
|
||||
while (true) {
|
||||
ExecutorService current = GEN_POOL.get();
|
||||
if (current != null && !current.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
ExecutorService created = createGenPool();
|
||||
if (GEN_POOL.compareAndSet(current, created)) {
|
||||
return;
|
||||
}
|
||||
created.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
static void shutdown() {
|
||||
ExecutorService pool = genPool;
|
||||
if (pool != null) {
|
||||
pool.shutdownNow();
|
||||
ExecutorService pool = GEN_POOL.getAndSet(null);
|
||||
if (pool == null) {
|
||||
return;
|
||||
}
|
||||
pool.shutdown();
|
||||
try {
|
||||
if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) {
|
||||
return;
|
||||
}
|
||||
LOGGER.debug("Iris gen pool did not drain in {}ms, forcing shutdown", SHUTDOWN_DRAIN_MILLIS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
pool.shutdownNow();
|
||||
}
|
||||
|
||||
private static ChunkSystem detectChunkSystem() {
|
||||
ChunkSystem detected = probeC2ME();
|
||||
if (detected == null) {
|
||||
detected = probeMoonrise();
|
||||
}
|
||||
if (detected == null) {
|
||||
detected = new ChunkSystem(false, "vanilla");
|
||||
}
|
||||
LOGGER.info("Iris chunk system: {} (parallel={}, generation on {})",
|
||||
detected.description(),
|
||||
detected.parallel() ? "yes" : "no",
|
||||
detected.parallel() ? "loader threads" : "Iris gen pool");
|
||||
return detected;
|
||||
}
|
||||
|
||||
private static ChunkSystem probeC2ME() {
|
||||
if (!anyPresent(C2ME_MARKERS)) {
|
||||
return null;
|
||||
}
|
||||
Class<?> config = loadOrNull(C2ME_CONFIG);
|
||||
if (config == null) {
|
||||
return new ChunkSystem(false, "c2me present, config class missing");
|
||||
}
|
||||
Boolean enabled = readSectionFlag(config, C2ME_SECTIONS, C2ME_FLAGS);
|
||||
if (enabled == null) {
|
||||
return new ChunkSystem(false, "c2me present, async scheduling unreadable");
|
||||
}
|
||||
return new ChunkSystem(enabled, enabled ? "c2me async scheduling on" : "c2me async scheduling off");
|
||||
}
|
||||
|
||||
private static ChunkSystem probeMoonrise() {
|
||||
Class<?> marker = loadOrNull(MOONRISE_MARKER);
|
||||
if (marker == null) {
|
||||
return null;
|
||||
}
|
||||
Integer workers = readMoonriseWorkers(marker);
|
||||
if (workers == null) {
|
||||
return new ChunkSystem(false, "moonrise present, worker pool unreadable");
|
||||
}
|
||||
if (workers <= 0) {
|
||||
return new ChunkSystem(false, "moonrise worker pool empty");
|
||||
}
|
||||
return new ChunkSystem(true, "moonrise workers=" + workers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads {@code <configClass>.<section>.<flag>} where the section may itself be the boolean.
|
||||
* Returns null when nothing along the path is readable.
|
||||
*/
|
||||
private static Boolean readSectionFlag(Class<?> configClass, String[] sections, String[] flags) {
|
||||
for (String section : sections) {
|
||||
Field field = staticFieldOrNull(configClass, section);
|
||||
if (field == null) {
|
||||
continue;
|
||||
}
|
||||
Object value;
|
||||
try {
|
||||
value = field.get(null);
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris chunk system probe could not read {}.{}: {}", configClass.getName(), section, e.toString());
|
||||
continue;
|
||||
}
|
||||
if (value instanceof Boolean flag) {
|
||||
return flag;
|
||||
}
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
Boolean nested = readBooleanMember(value, flags);
|
||||
if (nested != null) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Boolean readBooleanMember(Object owner, String[] names) {
|
||||
for (String name : names) {
|
||||
for (Class<?> type = owner.getClass(); type != null && type != Object.class; type = type.getSuperclass()) {
|
||||
Field field = declaredFieldOrNull(type, name);
|
||||
if (field != null && (field.getType() == boolean.class || field.getType() == Boolean.class)) {
|
||||
try {
|
||||
if (field.get(owner) instanceof Boolean flag) {
|
||||
return flag;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris chunk system probe could not read {}.{}: {}", type.getName(), name, e.toString());
|
||||
}
|
||||
}
|
||||
Method method = declaredMethodOrNull(type, name);
|
||||
if (method != null && (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class)) {
|
||||
try {
|
||||
if (method.invoke(owner) instanceof Boolean flag) {
|
||||
return flag;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris chunk system probe could not call {}.{}(): {}", type.getName(), name, e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Integer readMoonriseWorkers(Class<?> marker) {
|
||||
for (String name : MOONRISE_WORKER_COUNTS) {
|
||||
Integer direct = readIntMember(marker, null, name);
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
Field field = staticFieldOrNull(marker, name);
|
||||
if (field != null) {
|
||||
try {
|
||||
Object value = field.get(null);
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris chunk system probe could not read {}.{}: {}", marker.getName(), name, e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String poolName : MOONRISE_POOLS) {
|
||||
Field field = staticFieldOrNull(marker, poolName);
|
||||
if (field == null) {
|
||||
continue;
|
||||
}
|
||||
Object pool;
|
||||
try {
|
||||
pool = field.get(null);
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris chunk system probe could not read {}.{}: {}", marker.getName(), poolName, e.toString());
|
||||
continue;
|
||||
}
|
||||
if (pool == null) {
|
||||
continue;
|
||||
}
|
||||
for (String countName : MOONRISE_POOL_COUNTS) {
|
||||
Integer count = readIntMember(pool.getClass(), pool, countName);
|
||||
if (count != null) {
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Integer readIntMember(Class<?> type, Object owner, String name) {
|
||||
for (Class<?> current = type; current != null && current != Object.class; current = current.getSuperclass()) {
|
||||
Method method = declaredMethodOrNull(current, name);
|
||||
if (method == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (method.invoke(owner) instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris chunk system probe could not call {}.{}(): {}", current.getName(), name, e.toString());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Field staticFieldOrNull(Class<?> type, String name) {
|
||||
for (Class<?> current = type; current != null && current != Object.class; current = current.getSuperclass()) {
|
||||
Field field = declaredFieldOrNull(current, name);
|
||||
if (field != null) {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Field declaredFieldOrNull(Class<?> type, String name) {
|
||||
try {
|
||||
Field field = type.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field;
|
||||
} catch (NoSuchFieldException e) {
|
||||
return null;
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris chunk system probe could not access field {}.{}: {}", type.getName(), name, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean detectParallelChunkSystem() {
|
||||
String[] markers = {
|
||||
"com.ishland.c2me.base.ModProperties",
|
||||
"com.ishland.c2me.base.common.config.C2MEConfig",
|
||||
"com.ishland.c2me.opts.chunkio.ModProperties",
|
||||
"ca.spottedleaf.moonrise.common.util.MoonriseCommon"
|
||||
};
|
||||
private static Method declaredMethodOrNull(Class<?> type, String name) {
|
||||
try {
|
||||
Method method = type.getDeclaredMethod(name);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
} catch (NoSuchMethodException e) {
|
||||
return null;
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris chunk system probe could not access method {}.{}(): {}", type.getName(), name, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean anyPresent(String[] markers) {
|
||||
for (String marker : markers) {
|
||||
try {
|
||||
Class.forName(marker, false, IrisModdedChunkGenerator.class.getClassLoader());
|
||||
if (loadOrNull(marker) != null) {
|
||||
return true;
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Class<?> loadOrNull(String name) {
|
||||
try {
|
||||
return Class.forName(name, false, ModdedGenPool.class.getClassLoader());
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris chunk system probe: {} absent ({})", name, e.getClass().getSimpleName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static ExecutorService createGenPool() {
|
||||
int threads = Math.max(2, Runtime.getRuntime().availableProcessors());
|
||||
ThreadPoolExecutor pool = new ThreadPoolExecutor(
|
||||
@@ -84,4 +354,7 @@ final class ModdedGenPool {
|
||||
pool.allowCoreThreadTimeOut(true);
|
||||
return pool;
|
||||
}
|
||||
|
||||
private record ChunkSystem(boolean parallel, String description) {
|
||||
}
|
||||
}
|
||||
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 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.modded;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.framework.NativeFeatureGenerationPolicy;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.engine.object.IrisDecorationStep;
|
||||
import art.arcane.iris.engine.object.IrisImportedFeatureControl;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
import it.unimi.dsi.fastutil.ints.IntArraySet;
|
||||
import it.unimi.dsi.fastutil.ints.IntSet;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeGenerationSettings;
|
||||
import net.minecraft.world.level.biome.FeatureSorter;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.level.levelgen.RandomSupport;
|
||||
import net.minecraft.world.level.levelgen.WorldgenRandom;
|
||||
import net.minecraft.world.level.levelgen.XoroshiroRandomSource;
|
||||
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* Native placed-feature passthrough for one Iris dimension, gated on {@code importedFeatures.enabled}.
|
||||
*
|
||||
* <p>This runs the FEATURES half of vanilla's decoration pass and nothing else. Iris places native structures
|
||||
* itself, with its own vertical fitting and vegetation clearing, so calling {@code super.applyBiomeDecoration}
|
||||
* would place every structure a second time. The feature half is reproduced here off the same decoration and
|
||||
* feature seeds vanilla derives, so an imported feature lands where vanilla would have put it.
|
||||
*
|
||||
* <p>Threading: {@link #run} runs on the worldgen thread that is generating the chunk, never on
|
||||
* {@link ModdedGenPool}. The FEATURES chunk step is not parallel-safe - it writes into the eight neighbouring
|
||||
* chunks through {@code WorldGenLevel}, and vanilla and every threaded chunk system serialize it. Terrain is
|
||||
* the only Iris step that may fan out.
|
||||
*
|
||||
* <p>Everything here is inert while the control is disabled: no table is built, no registry is walked, and
|
||||
* {@link #generationSettings} answers exactly what vanilla's default getter answers.
|
||||
*/
|
||||
final class ModdedImportedFeatureStage {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final String CYCLE_MARKER = "Feature order cycle found";
|
||||
private static final long NO_GENERATION = Long.MIN_VALUE;
|
||||
|
||||
private final IrisModdedBiomeSource biomeSource;
|
||||
private final ReentrantLock buildLock = new ReentrantLock();
|
||||
private volatile IrisModdedChunkGenerator generator;
|
||||
private volatile FeatureTable featureTable;
|
||||
private volatile long inertGeneration = NO_GENERATION;
|
||||
|
||||
ModdedImportedFeatureStage(IrisModdedBiomeSource biomeSource) {
|
||||
this.biomeSource = biomeSource;
|
||||
}
|
||||
|
||||
void bind(IrisModdedChunkGenerator generator) {
|
||||
this.generator = generator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the feature table. Called from every repoint, hotload and unbind path so a table built for one
|
||||
* pack can never serve another.
|
||||
*/
|
||||
void invalidate() {
|
||||
featureTable = null;
|
||||
inertGeneration = NO_GENERATION;
|
||||
}
|
||||
|
||||
boolean active() {
|
||||
return featureTable != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The generation-settings getter handed to {@code ChunkGenerator}'s two-argument constructor. Maps an Iris
|
||||
* custom biome holder onto the generation settings of the vanilla biome its Iris biome derives from, so
|
||||
* the per-step feature lists and {@code BiomeFilter}'s hasFeature gate both see real features for a biome
|
||||
* whose datapack JSON declares none by design. Real registry biomes pass straight through.
|
||||
*
|
||||
* <p>With {@code importedFeatures} disabled there is no table and this is vanilla's default getter.
|
||||
*/
|
||||
BiomeGenerationSettings generationSettings(Holder<Biome> biome) {
|
||||
FeatureTable table = featureTable;
|
||||
if (table == null) {
|
||||
return biome.value().getGenerationSettings();
|
||||
}
|
||||
return settingsFor(biome, table.derivatives());
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk-path prepare. The volatile fast path is unlocked, so a prepared stage costs two reads per chunk;
|
||||
* the build itself is serialized because {@code applyBiomeDecoration} calls this from every worldgen
|
||||
* thread, and two threads that both found the stage unprepared would each run {@code FeatureSorter}, whose
|
||||
* cycle detection is the expensive part. Waiting here is safe: this caller holds no generator monitor.
|
||||
*/
|
||||
void prepare(Engine engine) {
|
||||
prepare(engine, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind and repoint prepare, which is what makes a feature-order cycle a single bind-time ERROR instead of
|
||||
* a chunk-generation crash. Those callers hold the generator monitor and the build path can need it (the
|
||||
* biome source may bind an engine while resolving), so this one never waits for another thread's build: it
|
||||
* builds now or leaves it to the next chunk's prepare.
|
||||
*/
|
||||
void prepareWithoutWaiting(Engine engine) {
|
||||
prepare(engine, false);
|
||||
}
|
||||
|
||||
private void prepare(Engine engine, boolean waitForBuild) {
|
||||
if (engine == null || engine.isClosed() || engine.isClosing()) {
|
||||
return;
|
||||
}
|
||||
long generation = biomeSource.packGeneration();
|
||||
if (settled(generation)) {
|
||||
return;
|
||||
}
|
||||
if (waitForBuild) {
|
||||
buildLock.lock();
|
||||
} else if (!buildLock.tryLock()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (settled(generation)) {
|
||||
return;
|
||||
}
|
||||
build(engine, generation);
|
||||
} finally {
|
||||
buildLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean settled(long generation) {
|
||||
FeatureTable current = featureTable;
|
||||
if (current != null && current.generation() == generation) {
|
||||
return true;
|
||||
}
|
||||
return inertGeneration == generation;
|
||||
}
|
||||
|
||||
private void build(Engine engine, long generation) {
|
||||
IrisImportedFeatureControl control;
|
||||
try {
|
||||
control = NativeFeatureGenerationPolicy.control(engine);
|
||||
} catch (RuntimeException error) {
|
||||
LOGGER.error("Iris could not read importedFeatures for this dimension; features off: {}",
|
||||
error.toString());
|
||||
markInert(generation);
|
||||
return;
|
||||
}
|
||||
if (!control.shouldGenerateFeatures()) {
|
||||
markInert(generation);
|
||||
return;
|
||||
}
|
||||
FeatureTable built;
|
||||
try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_imported_features");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
built = buildTable(engine, control, generation);
|
||||
} catch (Throwable error) {
|
||||
LOGGER.error("Iris importedFeatures is off for {}: feature table construction failed: {}",
|
||||
dimensionKey(engine), error.toString());
|
||||
markInert(generation);
|
||||
return;
|
||||
}
|
||||
if (built == null) {
|
||||
markInert(generation);
|
||||
return;
|
||||
}
|
||||
featureTable = built;
|
||||
inertGeneration = NO_GENERATION;
|
||||
// Arm the worldcheck log watch here, before any chunk decorates: arming from the first pass instead
|
||||
// missed every far-chunk write the first chunk made. No-op unless -Diris.worldcheck is set.
|
||||
WorldCheckFeaturePlacement.arm();
|
||||
LOGGER.info("Iris importedFeatures on for {}: {} biomes, {} steps, {} custom-biome derivative maps",
|
||||
dimensionKey(engine), built.biomes().size(), built.steps().size(),
|
||||
built.derivatives().size());
|
||||
}
|
||||
|
||||
private void markInert(long generation) {
|
||||
featureTable = null;
|
||||
inertGeneration = generation;
|
||||
}
|
||||
|
||||
private FeatureTable buildTable(Engine engine, IrisImportedFeatureControl control, long generation) {
|
||||
// Registry-ordered biome list. FeatureSorter's cycle detection walks it, so an unordered list makes
|
||||
// detection depend on JVM hash order and turns a real cycle into an intermittent one.
|
||||
List<Holder<Biome>> biomes = biomeSource.orderedPossibleBiomes();
|
||||
if (biomes.isEmpty()) {
|
||||
LOGGER.error("Iris importedFeatures is on but {} exposes no biomes; features off",
|
||||
dimensionKey(engine));
|
||||
return null;
|
||||
}
|
||||
Map<String, Holder<Biome>> byKey = new HashMap<>(biomes.size());
|
||||
for (Holder<Biome> biome : biomes) {
|
||||
String key = holderKey(biome);
|
||||
if (key != null) {
|
||||
byKey.put(key, biome);
|
||||
}
|
||||
}
|
||||
Map<String, Holder<Biome>> derivatives = customBiomeDerivatives(engine, byKey);
|
||||
List<FeatureSorter.StepFeatureData> steps;
|
||||
try {
|
||||
// Same inputs as vanilla's own memo, built here so it can be keyed on the Iris pack generation and
|
||||
// so the cycle failure lands at bind time.
|
||||
steps = FeatureSorter.buildFeaturesPerStep(biomes,
|
||||
(Holder<Biome> biome) -> settingsFor(biome, derivatives).features(), true);
|
||||
} catch (IllegalStateException error) {
|
||||
String message = error.getMessage();
|
||||
if (message == null || !message.contains(CYCLE_MARKER)) {
|
||||
throw error;
|
||||
}
|
||||
LOGGER.error("Iris importedFeatures is off for {}: the registered placed features cannot be ordered."
|
||||
+ " {}. Remove or reorder the conflicting content, or leave"
|
||||
+ " importedFeatures.enabled false.",
|
||||
dimensionKey(engine), message);
|
||||
return null;
|
||||
}
|
||||
boolean filtered = control.getDisabled() != null && !control.getDisabled().isEmpty();
|
||||
return new FeatureTable(generation, control, List.copyOf(biomes), Set.copyOf(biomes),
|
||||
Map.copyOf(byKey), steps, Map.copyOf(derivatives), filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps every generated Iris custom biome key onto the registry holder of its Iris biome's vanilla
|
||||
* derivative. The custom biome's own datapack JSON carries no features by design; this is the only place
|
||||
* the vanilla feature set enters.
|
||||
*/
|
||||
private Map<String, Holder<Biome>> customBiomeDerivatives(Engine engine, Map<String, Holder<Biome>> byKey) {
|
||||
Map<String, Holder<Biome>> derivatives = new HashMap<>();
|
||||
for (IrisBiome irisBiome : engine.getAllBiomes()) {
|
||||
if (!irisBiome.isCustom()) {
|
||||
continue;
|
||||
}
|
||||
String derivativeKey = normalizeKey(irisBiome.getVanillaDerivativeKey());
|
||||
// Resolve from the registry, not only from this source's own biome set: a sea or shore biome's
|
||||
// structure derivative is rewritten away from its vanilla derivative, so the derivative whose
|
||||
// features we want is not always a biome this source can emit.
|
||||
Holder<Biome> derivative = byKey.get(derivativeKey);
|
||||
if (derivative == null) {
|
||||
derivative = biomeSource.registeredBiome(derivativeKey);
|
||||
}
|
||||
if (derivative == null) {
|
||||
LOGGER.warn("Iris importedFeatures: vanilla derivative {} of biome {} is not registered;"
|
||||
+ " its custom biomes generate no imported features",
|
||||
derivativeKey, irisBiome.getLoadKey());
|
||||
continue;
|
||||
}
|
||||
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
|
||||
derivatives.put(ModdedWorldgenIds.biomeRef(engine, customBiome.getId()), derivative);
|
||||
}
|
||||
}
|
||||
return derivatives;
|
||||
}
|
||||
|
||||
private static BiomeGenerationSettings settingsFor(Holder<Biome> biome,
|
||||
Map<String, Holder<Biome>> derivatives) {
|
||||
Holder<Biome> mapped = derivatives.get(holderKey(biome));
|
||||
return mapped == null
|
||||
? biome.value().getGenerationSettings()
|
||||
: mapped.value().getGenerationSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the vanilla placed-feature pass for one chunk, on the calling worldgen thread. A no-op while the
|
||||
* control is disabled or the table degraded.
|
||||
*/
|
||||
void run(WorldGenLevel level, ChunkAccess chunk, Engine engine) {
|
||||
FeatureTable table = featureTable;
|
||||
if (table == null) {
|
||||
WorldCheckFeaturePlacement.recordFeaturesOff();
|
||||
return;
|
||||
}
|
||||
if (table.generation() != biomeSource.packGeneration()) {
|
||||
// A repoint landed between the prepare above and this chunk. Refuse stale content outright; the
|
||||
// next chunk's prepare rebuilds against the new pack.
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
IrisModdedChunkGenerator owner = generator;
|
||||
if (owner == null) {
|
||||
return;
|
||||
}
|
||||
ChunkPos centerPos = chunk.getPos();
|
||||
SectionPos sectionPos = SectionPos.of(centerPos, level.getMinSectionY());
|
||||
BlockPos origin = sectionPos.origin();
|
||||
Registry<PlacedFeature> featureRegistry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE);
|
||||
List<FeatureSorter.StepFeatureData> steps = table.steps();
|
||||
WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(RandomSupport.generateUniqueSeed()));
|
||||
long decorationSeed = random.setDecorationSeed(level.getSeed(), origin.getX(), origin.getZ());
|
||||
Set<Holder<Biome>> chunkBiomes = chunkBiomes(level, sectionPos, table);
|
||||
|
||||
try {
|
||||
for (int stepIndex = 0; stepIndex < steps.size(); stepIndex++) {
|
||||
if (!table.control().shouldGenerateStep(IrisDecorationStep.byOrdinal(stepIndex))) {
|
||||
continue;
|
||||
}
|
||||
placeStep(level, table, steps.get(stepIndex), featureRegistry, chunkBiomes, owner,
|
||||
random, decorationSeed, origin, stepIndex);
|
||||
}
|
||||
} catch (Throwable error) {
|
||||
WorldCheckFeaturePlacement.recordPlacementFailure(centerPos, error);
|
||||
throw new IllegalStateException("Iris imported feature placement failed for chunk "
|
||||
+ centerPos.x() + "," + centerPos.z(), error);
|
||||
} finally {
|
||||
level.setCurrentlyGenerating(null);
|
||||
}
|
||||
WorldCheckFeaturePlacement.recordPlacementPass();
|
||||
}
|
||||
|
||||
private void placeStep(WorldGenLevel level, FeatureTable table, FeatureSorter.StepFeatureData stepData,
|
||||
Registry<PlacedFeature> featureRegistry, Set<Holder<Biome>> chunkBiomes,
|
||||
IrisModdedChunkGenerator owner, WorldgenRandom random, long decorationSeed,
|
||||
BlockPos origin, int stepIndex) {
|
||||
IntSet stepFeatures = new IntArraySet();
|
||||
for (Holder<Biome> biome : chunkBiomes) {
|
||||
List<HolderSet<PlacedFeature>> biomeFeatures = settingsFor(biome, table.derivatives()).features();
|
||||
if (stepIndex >= biomeFeatures.size()) {
|
||||
continue;
|
||||
}
|
||||
for (Holder<PlacedFeature> feature : biomeFeatures.get(stepIndex)) {
|
||||
stepFeatures.add(stepData.indexMapping().applyAsInt(feature.value()));
|
||||
}
|
||||
}
|
||||
if (stepFeatures.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// Sorted global indices: identical ordering to vanilla, and each feature's seed comes from its own
|
||||
// global index, so denying one feature never shifts another.
|
||||
int[] featureIndices = stepFeatures.toIntArray();
|
||||
Arrays.sort(featureIndices);
|
||||
for (int globalIndex : featureIndices) {
|
||||
PlacedFeature feature = stepData.features().get(globalIndex);
|
||||
if (table.filtered()) {
|
||||
Identifier featureId = featureRegistry.getKey(feature);
|
||||
if (featureId != null && !table.control().shouldGenerate(featureId.toString())) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
random.setFeatureSeed(decorationSeed, globalIndex, stepIndex);
|
||||
level.setCurrentlyGenerating(() -> describeFeature(featureRegistry, feature));
|
||||
feature.placeWithBiomeCheck(level, owner, random, origin);
|
||||
}
|
||||
}
|
||||
|
||||
private static String describeFeature(Registry<PlacedFeature> registry, PlacedFeature feature) {
|
||||
Identifier id = registry.getKey(feature);
|
||||
return id == null ? feature.toString() : id.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Biomes actually present in the 3x3 chunk neighbourhood, intersected with the dimension's biome set. Same
|
||||
* shape as vanilla, which drops any section biome its biome source does not claim. Holders are canonicalised
|
||||
* back onto the table's own holders so the index mapping cannot be handed a holder it never saw.
|
||||
*/
|
||||
private Set<Holder<Biome>> chunkBiomes(WorldGenLevel level, SectionPos sectionPos, FeatureTable table) {
|
||||
List<Holder<Biome>> collected = new ArrayList<>();
|
||||
ChunkPos.rangeClosed(sectionPos.chunk(), 1).forEach((ChunkPos chunkPos) -> {
|
||||
ChunkAccess neighbour = level.getChunk(chunkPos.x(), chunkPos.z());
|
||||
for (LevelChunkSection section : neighbour.getSections()) {
|
||||
section.getBiomes().getAll(collected::add);
|
||||
}
|
||||
});
|
||||
Set<Holder<Biome>> present = new LinkedHashSet<>();
|
||||
for (Holder<Biome> biome : collected) {
|
||||
if (table.biomeSet().contains(biome)) {
|
||||
present.add(biome);
|
||||
continue;
|
||||
}
|
||||
String key = holderKey(biome);
|
||||
Holder<Biome> canonical = key == null ? null : table.byKey().get(key);
|
||||
if (canonical != null) {
|
||||
present.add(canonical);
|
||||
}
|
||||
}
|
||||
return present;
|
||||
}
|
||||
|
||||
private static String dimensionKey(Engine engine) {
|
||||
return engine.getDimension() == null ? "<unbound>" : engine.getDimension().getLoadKey();
|
||||
}
|
||||
|
||||
private static String holderKey(Holder<Biome> holder) {
|
||||
return holder.unwrapKey()
|
||||
.map(key -> key.identifier().toString().toLowerCase(Locale.ROOT))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static String normalizeKey(String key) {
|
||||
Identifier identifier = key == null ? null : Identifier.tryParse(key);
|
||||
return identifier == null ? null : identifier.toString().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private record FeatureTable(long generation, IrisImportedFeatureControl control,
|
||||
List<Holder<Biome>> biomes, Set<Holder<Biome>> biomeSet,
|
||||
Map<String, Holder<Biome>> byKey,
|
||||
List<FeatureSorter.StepFeatureData> steps,
|
||||
Map<String, Holder<Biome>> derivatives, boolean filtered) {
|
||||
}
|
||||
}
|
||||
+5
-24
@@ -59,7 +59,6 @@ import net.minecraft.world.item.component.TooltipDisplay;
|
||||
import net.minecraft.world.item.enchantment.Enchantment;
|
||||
import net.minecraft.world.item.enchantment.ItemEnchantments;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -69,8 +68,6 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class ModdedItemTranslator {
|
||||
private static final Set<String> WARNED = ConcurrentHashMap.newKeySet();
|
||||
private static final Field TYPE_FIELD = lootField("type");
|
||||
private static final Field DYE_COLOR_FIELD = lootField("dyeColor");
|
||||
private static final String COLOR_CODES = "0123456789AaBbCcDdEeFfKkLlMmNnOoRrXx";
|
||||
|
||||
private ModdedItemTranslator() {
|
||||
@@ -140,7 +137,7 @@ public final class ModdedItemTranslator {
|
||||
}
|
||||
|
||||
private static ItemStack baseStack(IrisLoot loot, RNG rng) {
|
||||
String raw = readString(TYPE_FIELD, loot);
|
||||
String raw = loot.getTypeKey();
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
@@ -187,7 +184,7 @@ public final class ModdedItemTranslator {
|
||||
}
|
||||
}
|
||||
|
||||
String dye = readString(DYE_COLOR_FIELD, loot);
|
||||
String dye = loot.getDyeColorKey();
|
||||
if (dye != null) {
|
||||
applyDyeColor(stack, dye);
|
||||
}
|
||||
@@ -214,7 +211,9 @@ public final class ModdedItemTranslator {
|
||||
if (name == null || name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String key = name.toLowerCase(Locale.ROOT);
|
||||
// Same normalization as IrisEnchantment.resolve() on Bukkit: trim, lowercase, spaces to underscores.
|
||||
// Without it 'Fire Aspect' resolves on Bukkit and warns as unknown here, for the same pack.
|
||||
String key = name.trim().toLowerCase(Locale.ROOT).replace(' ', '_');
|
||||
Identifier id = Identifier.tryParse(key.contains(":") ? key : "minecraft:" + key);
|
||||
Optional<Holder.Reference<Enchantment>> holder = id == null ? Optional.empty() : registry.get(id);
|
||||
if (holder.isEmpty()) {
|
||||
@@ -405,22 +404,4 @@ public final class ModdedItemTranslator {
|
||||
}
|
||||
}
|
||||
|
||||
private static Field lootField(String name) {
|
||||
try {
|
||||
Field field = IrisLoot.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field;
|
||||
} catch (NoSuchFieldException e) {
|
||||
throw new IllegalStateException("IrisLoot field missing: " + name, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readString(Field field, IrisLoot loot) {
|
||||
try {
|
||||
Object value = field.get(loot);
|
||||
return value == null ? null : value.toString();
|
||||
} catch (IllegalAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 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.modded;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/**
|
||||
* Boot audit for the Iris mixin configs. Mixin registration is per-loader (fabric.mod.json entry, NeoForge
|
||||
* mods.toml [[mixins]] block, Forge shadowJar MixinConfigs manifest attribute) and a config that never gets
|
||||
* registered fails silently: no crash, the hooks simply never exist, and entity persistence, custom mob loot
|
||||
* and the Iris world-type labels quietly stop working.
|
||||
*
|
||||
* <p>Application is checked structurally: Mixin transfers an {@code @Inject} handler into the target class,
|
||||
* so the handler's presence on the target proves the mixin applied. Mixin 0.8.7 renames the transferred
|
||||
* method to {@code handler$<ids>$<originalName>}, so the declared name is matched as an exact name or as a
|
||||
* {@code $}-prefixed suffix. Client targets are resolved by name so this class stays free of client
|
||||
* references and is safe on a dedicated server.
|
||||
*/
|
||||
public final class ModdedMixinAudit {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final AtomicBoolean AUDITED = new AtomicBoolean(false);
|
||||
|
||||
private static final List<ExpectedMixin> EXPECTED = List.of(
|
||||
new ExpectedMixin("EntityPersistenceMixin", "entity",
|
||||
"net.minecraft.world.entity.Entity", "iris$applyGeneratedPersistence",
|
||||
false, ModdedMixinFlags::entityPersistenceRan),
|
||||
new ExpectedMixin("LivingEntityLootMixin", "entity",
|
||||
"net.minecraft.world.entity.LivingEntity", "iris$replaceBaseLoot",
|
||||
false, ModdedMixinFlags::livingEntityLootRan),
|
||||
new ExpectedMixin("MobAwarenessMixin", "entity",
|
||||
"net.minecraft.world.entity.Mob", "iris$tickUnawareMob",
|
||||
false, ModdedMixinFlags::mobAwarenessRan),
|
||||
new ExpectedMixin("IrisWorldOpenFlowsMixin", "client",
|
||||
"net.minecraft.client.gui.screens.worldselection.WorldOpenFlows",
|
||||
"iris$openWorldCheckWorldStemCompatibility",
|
||||
true, ModdedMixinFlags::worldOpenFlowsRan),
|
||||
new ExpectedMixin("IrisWorldTypeEntryMixin", "client",
|
||||
"net.minecraft.client.gui.screens.worldselection.WorldCreationUiState$WorldTypeEntry",
|
||||
"iris$describePreset",
|
||||
true, ModdedMixinFlags::worldTypeEntryRan));
|
||||
|
||||
private ModdedMixinAudit() {
|
||||
}
|
||||
|
||||
public static void runOnce() {
|
||||
if (!AUDITED.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
audit(ModdedEngineBootstrap.loader().platformName(),
|
||||
ModdedEngineBootstrap.loader().clientEnvironment());
|
||||
}
|
||||
|
||||
static void reset() {
|
||||
AUDITED.set(false);
|
||||
}
|
||||
|
||||
static void audit(String platform, boolean clientEnvironment) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
List<String> applied = new ArrayList<>();
|
||||
for (ExpectedMixin expected : EXPECTED) {
|
||||
if (expected.clientOnly() && !clientEnvironment) {
|
||||
continue;
|
||||
}
|
||||
if (isApplied(expected)) {
|
||||
applied.add(expected.mixinName() + (expected.ran().getAsBoolean() ? "" : " (not yet exercised)"));
|
||||
} else {
|
||||
missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass());
|
||||
}
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
LOGGER.info("Iris mixin audit ok on {} ({} dist): {}", platform,
|
||||
clientEnvironment ? "client" : "server", String.join(", ", applied));
|
||||
return;
|
||||
}
|
||||
LOGGER.error("===============================================================");
|
||||
LOGGER.error("Iris mixin audit FAILED on {} ({} dist): {} of {} expected mixin(s) were not applied.",
|
||||
platform, clientEnvironment ? "client" : "server", missing.size(),
|
||||
missing.size() + applied.size());
|
||||
for (String entry : missing) {
|
||||
LOGGER.error(" missing: {}", entry);
|
||||
}
|
||||
LOGGER.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute).");
|
||||
LOGGER.error("Entity persistence, custom mob loot and Iris world-type labels are disabled until this is fixed.");
|
||||
LOGGER.error("===============================================================");
|
||||
}
|
||||
|
||||
private static boolean isApplied(ExpectedMixin expected) {
|
||||
try {
|
||||
Class<?> target = Class.forName(expected.targetClass(), false,
|
||||
ModdedMixinAudit.class.getClassLoader());
|
||||
for (Method method : target.getDeclaredMethods()) {
|
||||
// Mixin 0.8.7 renames applied @Inject handlers to handler$<ids>$<originalName>, so an exact
|
||||
// name match alone reports every applied mixin as missing.
|
||||
String name = method.getName();
|
||||
if (name.equals(expected.handlerMethod())
|
||||
|| name.endsWith('$' + expected.handlerMethod())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (ClassNotFoundException | LinkageError unavailable) {
|
||||
LOGGER.warn("Iris mixin audit could not inspect {}", expected.targetClass(), unavailable);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private record ExpectedMixin(String mixinName, String config, String targetClass, String handlerMethod,
|
||||
boolean clientOnly, BooleanSupplier ran) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 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.modded;
|
||||
|
||||
/**
|
||||
* Plain flag holder every Iris mixin marks from its injected body. It is deliberately free of any
|
||||
* net.minecraft, client or loader reference so the client-dist mixins in art.arcane.iris.client.mixin can
|
||||
* write to it without dragging client types into modded-common.
|
||||
*
|
||||
* <p>These flags say "the injected code ran at least once", which is weaker than "the mixin was applied":
|
||||
* a hook only fires when its target path executes. {@link ModdedMixinAudit} is what proves application at
|
||||
* boot; these are the runtime confirmation reported alongside it.
|
||||
*/
|
||||
public final class ModdedMixinFlags {
|
||||
private static volatile boolean entityPersistenceRan;
|
||||
private static volatile boolean livingEntityLootRan;
|
||||
private static volatile boolean mobAwarenessRan;
|
||||
private static volatile boolean worldOpenFlowsRan;
|
||||
private static volatile boolean worldTypeEntryRan;
|
||||
|
||||
private ModdedMixinFlags() {
|
||||
}
|
||||
|
||||
// Read before write on every marker: the entity hooks run per entity save and per mob tick, and a
|
||||
// volatile store is a cache-line invalidation on every core that reads the flag. The flags are one-way,
|
||||
// so the guarded write costs one plain-ish read once set and races only ever re-store the same value.
|
||||
public static void markEntityPersistence() {
|
||||
if (!entityPersistenceRan) {
|
||||
entityPersistenceRan = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static void markLivingEntityLoot() {
|
||||
if (!livingEntityLootRan) {
|
||||
livingEntityLootRan = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static void markMobAwareness() {
|
||||
if (!mobAwarenessRan) {
|
||||
mobAwarenessRan = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static void markWorldOpenFlows() {
|
||||
if (!worldOpenFlowsRan) {
|
||||
worldOpenFlowsRan = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static void markWorldTypeEntry() {
|
||||
if (!worldTypeEntryRan) {
|
||||
worldTypeEntryRan = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean entityPersistenceRan() {
|
||||
return entityPersistenceRan;
|
||||
}
|
||||
|
||||
public static boolean livingEntityLootRan() {
|
||||
return livingEntityLootRan;
|
||||
}
|
||||
|
||||
public static boolean mobAwarenessRan() {
|
||||
return mobAwarenessRan;
|
||||
}
|
||||
|
||||
public static boolean worldOpenFlowsRan() {
|
||||
return worldOpenFlowsRan;
|
||||
}
|
||||
|
||||
public static boolean worldTypeEntryRan() {
|
||||
return worldTypeEntryRan;
|
||||
}
|
||||
|
||||
static void reset() {
|
||||
entityPersistenceRan = false;
|
||||
livingEntityLootRan = false;
|
||||
mobAwarenessRan = false;
|
||||
worldOpenFlowsRan = false;
|
||||
worldTypeEntryRan = false;
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -210,8 +210,10 @@ final class ModdedNativeStructureStage {
|
||||
ChunkPos disabledChunk = chunk.getPos();
|
||||
throw new IllegalStateException("Iris cannot generate native structures in chunk "
|
||||
+ disabledChunk.x() + "," + disabledChunk.z()
|
||||
+ " because generate-structures=false disables them outside the pack; set generate-structures=true, "
|
||||
+ "restart the server, and deny individual structures through importedStructures.disabled");
|
||||
+ " because generate-structures=false disables them outside the pack. That flag is fixed when "
|
||||
+ "the world is created (server.properties generate-structures, or the Generate Structures "
|
||||
+ "toggle in singleplayer), so it cannot be changed for this world: create a new world with "
|
||||
+ "structures enabled, and deny individual structures through importedStructures.disabled");
|
||||
}
|
||||
ChunkPos chunkPos = chunk.getPos();
|
||||
SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY());
|
||||
|
||||
+19
-11
@@ -56,18 +56,26 @@ public final class ModdedPackInstaller {
|
||||
synchronized (installLock) {
|
||||
File packs = configDir.resolve("irisworldgen").resolve("packs").toFile();
|
||||
try {
|
||||
if (PackDownloader.isDefaultOverworld(pack)) {
|
||||
return PackDownloader.downloadDefaultOverworld(
|
||||
packs, forceOverwrite, feedback) != null;
|
||||
boolean installed = PackDownloader.isDefaultOverworld(pack)
|
||||
? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback) != null
|
||||
: PackDownloader.download(
|
||||
packs,
|
||||
"IrisDimensions/" + pack,
|
||||
branch,
|
||||
forceOverwrite,
|
||||
false,
|
||||
feedback) != null;
|
||||
if (installed) {
|
||||
// Pack-install completion is one of the four forced-datapack regeneration triggers; every
|
||||
// install call site already runs off the server thread, so regenerate inline here. A
|
||||
// regeneration failure must never turn a successful install into a failed one.
|
||||
try {
|
||||
ModdedForcedDatapack.regenerateIfStale("pack install " + pack);
|
||||
} catch (Throwable regenerationFailure) {
|
||||
LOGGER.error("Iris installed pack '{}' but could not regenerate the forced datapack", pack, regenerationFailure);
|
||||
}
|
||||
}
|
||||
return PackDownloader.download(
|
||||
packs,
|
||||
"IrisDimensions/" + pack,
|
||||
branch,
|
||||
forceOverwrite,
|
||||
false,
|
||||
feedback
|
||||
) != null;
|
||||
return installed;
|
||||
} catch (IOException error) {
|
||||
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error);
|
||||
feedback.accept(IrisLanguage.plain(
|
||||
|
||||
@@ -35,9 +35,17 @@ import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class ModdedPlatform implements IrisPlatform {
|
||||
private static final int ERROR_SIGNATURE_BURST = 5;
|
||||
private static final int ERROR_SIGNATURE_CAPACITY = 256;
|
||||
private static final long ERROR_SUMMARY_INTERVAL_MILLIS = 300_000L;
|
||||
private static final ConcurrentHashMap<String, ErrorThrottle> ERROR_THROTTLES = new ConcurrentHashMap<>();
|
||||
|
||||
private static volatile Consumer<Throwable> ERROR_SINK = null;
|
||||
private static volatile Consumer<Throwable> CAPTURE_SINK = null;
|
||||
|
||||
@@ -166,11 +174,19 @@ public final class ModdedPlatform implements IrisPlatform {
|
||||
ModdedIrisLog.info(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttled per exception signature. A single broken pack rule can fail on every generated chunk, and this
|
||||
* feeds Sentry, so each distinct signature reports its first few occurrences and then only a periodic
|
||||
* suppressed-count summary.
|
||||
*/
|
||||
@Override
|
||||
public void reportError(Throwable error) {
|
||||
if (error == null) {
|
||||
return;
|
||||
}
|
||||
if (!allowErrorReport(error)) {
|
||||
return;
|
||||
}
|
||||
Consumer<Throwable> sink = ERROR_SINK;
|
||||
if (sink != null) {
|
||||
sink.accept(error);
|
||||
@@ -187,6 +203,77 @@ public final class ModdedPlatform implements IrisPlatform {
|
||||
}
|
||||
}
|
||||
|
||||
static void resetErrorThrottles() {
|
||||
ERROR_THROTTLES.clear();
|
||||
}
|
||||
|
||||
private static boolean allowErrorReport(Throwable error) {
|
||||
String signature = errorSignature(error);
|
||||
ErrorThrottle throttle = ERROR_THROTTLES.get(signature);
|
||||
if (throttle == null) {
|
||||
if (ERROR_THROTTLES.size() >= ERROR_SIGNATURE_CAPACITY) {
|
||||
evictLeastRecentlySeen();
|
||||
}
|
||||
throttle = ERROR_THROTTLES.computeIfAbsent(signature, ignored -> new ErrorThrottle());
|
||||
}
|
||||
return throttle.allow(signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* At the cap a new signature used to report through unthrottled for the rest of the uptime, which is the
|
||||
* failure mode the cap exists to prevent: one broken pack rule firing on every chunk with a per-chunk line
|
||||
* number produces new signatures forever. Evict the entry nothing has hit for the longest instead. O(n) on
|
||||
* an error path with n=256, and an evicted signature simply earns a fresh burst if it comes back.
|
||||
*/
|
||||
private static void evictLeastRecentlySeen() {
|
||||
String oldest = null;
|
||||
long oldestSeenAt = Long.MAX_VALUE;
|
||||
for (Map.Entry<String, ErrorThrottle> entry : ERROR_THROTTLES.entrySet()) {
|
||||
long seenAt = entry.getValue().lastSeenAt();
|
||||
if (seenAt < oldestSeenAt) {
|
||||
oldestSeenAt = seenAt;
|
||||
oldest = entry.getKey();
|
||||
}
|
||||
}
|
||||
if (oldest != null) {
|
||||
ERROR_THROTTLES.remove(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
static String errorSignature(Throwable error) {
|
||||
StackTraceElement[] trace = error.getStackTrace();
|
||||
String frame = trace.length == 0
|
||||
? "<no-frame>"
|
||||
: trace[0].getClassName() + '.' + trace[0].getMethodName() + ':' + trace[0].getLineNumber();
|
||||
return error.getClass().getName() + '@' + frame;
|
||||
}
|
||||
|
||||
private static final class ErrorThrottle {
|
||||
private final AtomicLong reported = new AtomicLong();
|
||||
private final AtomicLong suppressed = new AtomicLong();
|
||||
private final AtomicLong nextSummaryAt = new AtomicLong();
|
||||
private final AtomicLong lastSeenAt = new AtomicLong(System.currentTimeMillis());
|
||||
|
||||
private long lastSeenAt() {
|
||||
return lastSeenAt.get();
|
||||
}
|
||||
|
||||
private boolean allow(String signature) {
|
||||
long now = System.currentTimeMillis();
|
||||
lastSeenAt.set(now);
|
||||
if (reported.get() < ERROR_SIGNATURE_BURST) {
|
||||
reported.incrementAndGet();
|
||||
return true;
|
||||
}
|
||||
long total = suppressed.incrementAndGet();
|
||||
long due = nextSummaryAt.get();
|
||||
if (now >= due && nextSummaryAt.compareAndSet(due, now + ERROR_SUMMARY_INTERVAL_MILLIS)) {
|
||||
ModdedIrisLog.warn("Iris suppressed " + total + " repeats of " + signature);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseVersion(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return -1;
|
||||
|
||||
+10
@@ -44,6 +44,16 @@ public final class ModdedPrimaryWorldRouter {
|
||||
routed.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a disconnected player's routing mark. Without this the set grows with every unique player the
|
||||
* server has ever seen, and a returning player is never routed again.
|
||||
*/
|
||||
public static void forget(UUID player) {
|
||||
if (player != null) {
|
||||
routed.remove(player);
|
||||
}
|
||||
}
|
||||
|
||||
public static void tick(MinecraftServer server) {
|
||||
if (server == null) {
|
||||
return;
|
||||
|
||||
+52
-13
@@ -34,6 +34,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class ModdedProtocolHandler {
|
||||
@@ -43,11 +44,13 @@ public final class ModdedProtocolHandler {
|
||||
|
||||
private static final ConcurrentHashMap<String, Engine> SESSION_ENGINES = new ConcurrentHashMap<>();
|
||||
private static final ConcurrentHashMap<String, String> SESSION_LEVELS = new ConcurrentHashMap<>();
|
||||
private static final ConcurrentHashMap<UUID, String> SESSION_IDS = new ConcurrentHashMap<>();
|
||||
|
||||
private static volatile ModdedProtocolChannel channel;
|
||||
private static volatile IrisSessionRegistry registry;
|
||||
private static volatile IrisProtocolServer protocolServer;
|
||||
private static volatile ModdedProtocolTransport transport;
|
||||
private static volatile IrisVisionRequestService visionRequests;
|
||||
private static int dimensionSyncTicks;
|
||||
|
||||
private ModdedProtocolHandler() {
|
||||
@@ -64,6 +67,7 @@ public final class ModdedProtocolHandler {
|
||||
}
|
||||
SESSION_ENGINES.clear();
|
||||
SESSION_LEVELS.clear();
|
||||
SESSION_IDS.clear();
|
||||
dimensionSyncTicks = 0;
|
||||
IrisSessionRegistry sessionRegistry = new IrisSessionRegistry();
|
||||
ModdedProtocolTransport serverTransport = new ModdedProtocolTransport(server, boundChannel);
|
||||
@@ -73,53 +77,85 @@ public final class ModdedProtocolHandler {
|
||||
return engine == null || engine.isClosed() ? null : engine;
|
||||
};
|
||||
protocol.setEngineResolver(engineResolver);
|
||||
protocol.setVisionTileHandler(IrisVisionRequestService.create(engineResolver, sessionRegistry));
|
||||
IrisVisionRequestService visionService = IrisVisionRequestService.create(engineResolver, sessionRegistry);
|
||||
protocol.setVisionTileHandler(visionService);
|
||||
registry = sessionRegistry;
|
||||
transport = serverTransport;
|
||||
protocolServer = protocol;
|
||||
visionRequests = visionService;
|
||||
IrisServices.register(IrisProtocolServer.class, protocol);
|
||||
if (server.getPlayerList() == null) {
|
||||
return;
|
||||
}
|
||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||
sessionRegistry.register(new IrisSession(player.getUUID().toString(), serverTransport));
|
||||
sessionRegistry.register(new IrisSession(sessionId(player), serverTransport));
|
||||
}
|
||||
}
|
||||
|
||||
public static void stop() {
|
||||
IrisServices.remove(IrisProtocolServer.class);
|
||||
IrisSessionRegistry current = registry;
|
||||
IrisVisionRequestService vision = visionRequests;
|
||||
if (current != null) {
|
||||
for (IrisSession session : current.all()) {
|
||||
current.unregister(session.id());
|
||||
if (vision != null) {
|
||||
vision.clearSession(session.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
SESSION_ENGINES.clear();
|
||||
SESSION_LEVELS.clear();
|
||||
SESSION_IDS.clear();
|
||||
dimensionSyncTicks = 0;
|
||||
registry = null;
|
||||
protocolServer = null;
|
||||
transport = null;
|
||||
visionRequests = null;
|
||||
}
|
||||
|
||||
public static void onPlayerJoin(ServerPlayer player) {
|
||||
IrisSessionRegistry current = registry;
|
||||
ModdedProtocolTransport currentTransport = transport;
|
||||
if (player == null || current == null || currentTransport == null) {
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
current.register(new IrisSession(player.getUUID().toString(), currentTransport));
|
||||
String sessionId = sessionId(player);
|
||||
ModdedStartup.warnPackFailuresTo(player);
|
||||
IrisSessionRegistry current = registry;
|
||||
ModdedProtocolTransport currentTransport = transport;
|
||||
if (current == null || currentTransport == null) {
|
||||
return;
|
||||
}
|
||||
current.register(new IrisSession(sessionId, currentTransport));
|
||||
}
|
||||
|
||||
public static void onPlayerDisconnect(ServerPlayer player) {
|
||||
IrisSessionRegistry current = registry;
|
||||
if (player == null || current == null) {
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
String sessionId = player.getUUID().toString();
|
||||
current.unregister(sessionId);
|
||||
UUID id = player.getUUID();
|
||||
String sessionId = SESSION_IDS.remove(id);
|
||||
if (sessionId == null) {
|
||||
sessionId = id.toString();
|
||||
}
|
||||
ModdedPrimaryWorldRouter.forget(id);
|
||||
SESSION_ENGINES.remove(sessionId);
|
||||
SESSION_LEVELS.remove(sessionId);
|
||||
IrisSessionRegistry current = registry;
|
||||
if (current != null) {
|
||||
current.unregister(sessionId);
|
||||
}
|
||||
IrisVisionRequestService vision = visionRequests;
|
||||
if (vision != null) {
|
||||
vision.clearSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID.toString allocates a 36-char string every call; the dimension sync tick runs over every player four
|
||||
* times a second, so the id is interned per player on first use and dropped on disconnect.
|
||||
*/
|
||||
private static String sessionId(ServerPlayer player) {
|
||||
return SESSION_IDS.computeIfAbsent(player.getUUID(), UUID::toString);
|
||||
}
|
||||
|
||||
public static void onInbound(ServerPlayer player, byte[] frame) {
|
||||
@@ -127,7 +163,7 @@ public final class ModdedProtocolHandler {
|
||||
if (player == null || frame == null || current == null) {
|
||||
return;
|
||||
}
|
||||
String sessionId = player.getUUID().toString();
|
||||
String sessionId = sessionId(player);
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler == null) {
|
||||
current.onClientFrame(sessionId, frame);
|
||||
@@ -148,7 +184,7 @@ public final class ModdedProtocolHandler {
|
||||
}
|
||||
dimensionSyncTicks = 0;
|
||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||
String sessionId = player.getUUID().toString();
|
||||
String sessionId = sessionId(player);
|
||||
IrisSession session = current.get(sessionId);
|
||||
if (session == null || !session.isReady()) {
|
||||
continue;
|
||||
@@ -167,6 +203,8 @@ public final class ModdedProtocolHandler {
|
||||
|
||||
private static boolean syncDimension(IrisProtocolServer protocol, String sessionId, ServerLevel level, String levelId) {
|
||||
ChunkGenerator generator = level.getChunkSource().getGenerator();
|
||||
// engineIfBound only, never commandEngine: the sync tick runs on the server thread and constructing an
|
||||
// engine there stalls the tick for the whole pack load. An unbound generator simply retries next tick.
|
||||
Engine engine = generator instanceof IrisModdedChunkGenerator irisGenerator ? resolveEngine(level, irisGenerator) : null;
|
||||
if (generator instanceof IrisModdedChunkGenerator && engine == null) {
|
||||
return false;
|
||||
@@ -185,7 +223,8 @@ public final class ModdedProtocolHandler {
|
||||
|
||||
private static Engine resolveEngine(ServerLevel level, IrisModdedChunkGenerator generator) {
|
||||
try {
|
||||
return generator.commandEngine();
|
||||
Engine engine = generator.engineIfBound();
|
||||
return engine == null || engine.isClosed() ? null : engine;
|
||||
} catch (Throwable failure) {
|
||||
LOGGER.error("Iris dimension status engine lookup failed for {}", level.dimension().identifier(), failure);
|
||||
return null;
|
||||
|
||||
@@ -18,12 +18,16 @@
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
|
||||
import art.arcane.iris.modded.api.ModdedDataType;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
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.PlatformItem;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.volmlib.util.data.UnresolvedKeyLog;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
@@ -45,6 +49,8 @@ import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class ModdedRegistries implements PlatformRegistries {
|
||||
private static final UnresolvedKeyLog NOT_READY = new UnresolvedKeyLog("Iris modded registry reads before server ready", 60_000L);
|
||||
|
||||
private final Supplier<MinecraftServer> server;
|
||||
|
||||
public ModdedRegistries(Supplier<MinecraftServer> server) {
|
||||
@@ -126,6 +132,7 @@ public final class ModdedRegistries implements PlatformRegistries {
|
||||
List<String> keys = new ArrayList<>();
|
||||
Registry<Biome> registry = biomeRegistry();
|
||||
if (registry == null) {
|
||||
warnNotReady("biome");
|
||||
return keys;
|
||||
}
|
||||
for (Identifier identifier : registry.keySet()) {
|
||||
@@ -139,6 +146,7 @@ public final class ModdedRegistries implements PlatformRegistries {
|
||||
List<String> keys = new ArrayList<>();
|
||||
MinecraftServer instance = server.get();
|
||||
if (instance == null) {
|
||||
warnNotReady("structure");
|
||||
return keys;
|
||||
}
|
||||
for (Identifier identifier : instance.registryAccess().lookupOrThrow(Registries.STRUCTURE).keySet()) {
|
||||
@@ -153,9 +161,15 @@ public final class ModdedRegistries implements PlatformRegistries {
|
||||
for (Identifier identifier : BuiltInRegistries.ITEM.keySet()) {
|
||||
keys.add(identifier.toString());
|
||||
}
|
||||
keys.addAll(ModdedCustomContentRegistry.providerKeys(ModdedDataType.ITEM));
|
||||
return keys;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> specialEntityKeys() {
|
||||
return ModdedCustomContentRegistry.providerKeys(ModdedDataType.ENTITY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> entityKeys() {
|
||||
List<String> keys = new ArrayList<>();
|
||||
@@ -171,6 +185,7 @@ public final class ModdedRegistries implements PlatformRegistries {
|
||||
for (Identifier identifier : BuiltInRegistries.BLOCK.keySet()) {
|
||||
keys.add(identifier.toString());
|
||||
}
|
||||
keys.addAll(customBlockKeys());
|
||||
return keys;
|
||||
}
|
||||
|
||||
@@ -179,6 +194,7 @@ public final class ModdedRegistries implements PlatformRegistries {
|
||||
List<String> keys = new ArrayList<>();
|
||||
Registry<Enchantment> registry = enchantmentRegistry();
|
||||
if (registry == null) {
|
||||
warnNotReady("enchantment");
|
||||
return keys;
|
||||
}
|
||||
for (Identifier identifier : registry.keySet()) {
|
||||
@@ -199,17 +215,50 @@ public final class ModdedRegistries implements PlatformRegistries {
|
||||
@Override
|
||||
public Map<String, List<PlatformBlockProperty>> blockStateProperties() {
|
||||
Map<String, List<PlatformBlockProperty>> properties = new LinkedHashMap<>();
|
||||
// One List instance per identical property group. SchemaBuilder groups consecutive entries by list
|
||||
// identity, so sharing collapses the emitted schema instead of writing a block-state object per block.
|
||||
Map<String, List<PlatformBlockProperty>> shared = new LinkedHashMap<>();
|
||||
for (Block block : BuiltInRegistries.BLOCK) {
|
||||
BlockState defaultState = block.defaultBlockState();
|
||||
List<PlatformBlockProperty> converted = new ArrayList<>();
|
||||
for (Property<?> property : block.getStateDefinition().getProperties()) {
|
||||
converted.add(convertProperty(property, defaultState));
|
||||
}
|
||||
properties.put(BuiltInRegistries.BLOCK.getKey(block).toString(), List.copyOf(converted));
|
||||
List<PlatformBlockProperty> group = shared.computeIfAbsent(groupSignature(converted), key -> List.copyOf(converted));
|
||||
properties.put(BuiltInRegistries.BLOCK.getKey(block).toString(), group);
|
||||
}
|
||||
List<PlatformBlockProperty> none = shared.computeIfAbsent(groupSignature(List.of()), key -> List.of());
|
||||
for (String key : customBlockKeys()) {
|
||||
properties.putIfAbsent(key, none);
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static String groupSignature(List<PlatformBlockProperty> group) {
|
||||
StringBuilder signature = new StringBuilder(group.size() * 24);
|
||||
for (PlatformBlockProperty property : group) {
|
||||
signature.append(property.name()).append(':').append(property.jsonType()).append('=')
|
||||
.append(property.defaultValue()).append(property.allowedValues()).append(';');
|
||||
}
|
||||
return signature.toString();
|
||||
}
|
||||
|
||||
private static List<String> customBlockKeys() {
|
||||
List<String> keys = new ArrayList<>(ModdedCustomContentRegistry.aliasBlockKeys());
|
||||
keys.addAll(ModdedCustomContentRegistry.providerKeys(ModdedDataType.BLOCK));
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static void warnNotReady(String registryName) {
|
||||
if (NOT_READY.firstOccurrence(registryName)) {
|
||||
IrisLogging.warn("Iris registry read for '" + registryName + "' before the server is ready; returning empty");
|
||||
}
|
||||
String summary = NOT_READY.pollSummary();
|
||||
if (summary != null) {
|
||||
IrisLogging.warn(summary);
|
||||
}
|
||||
}
|
||||
|
||||
private Registry<Biome> biomeRegistry() {
|
||||
MinecraftServer instance = server.get();
|
||||
if (instance == null) {
|
||||
|
||||
+22
-2
@@ -47,7 +47,9 @@ public final class ModdedRuntimeRegistry {
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException("Iris dimension type '" + typeRef
|
||||
+ "' is not synchronized. Restart after installing the pack before creating its world.");
|
||||
+ "' is not registered. Minecraft freezes the dimension-type registry before Iris can add it,"
|
||||
+ " so a pack installed after boot only takes effect on the next start."
|
||||
+ restartAdvice());
|
||||
}
|
||||
|
||||
static void ensureCustomBiomes(RegistryAccess registryAccess, IrisDimension dimension, String pack) {
|
||||
@@ -78,8 +80,26 @@ public final class ModdedRuntimeRegistry {
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
throw new IllegalStateException("Iris pack '" + pack + "' has " + missing.size()
|
||||
+ " unsynchronized custom biome(s). Restart before creating its world. First missing entry: "
|
||||
+ " custom biome(s) that are not registered. Pack '" + pack
|
||||
+ "' was installed after boot, and Minecraft freezes the biome registry before Iris can add them."
|
||||
+ restartAdvice() + " First missing entry: "
|
||||
+ missing.getFirst());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One complete instruction per environment. Singleplayer has no /iris world create step to follow the
|
||||
* restart, so the client branch must not be suffixed with one.
|
||||
*/
|
||||
private static String restartAdvice() {
|
||||
boolean client;
|
||||
try {
|
||||
client = ModdedEngineBootstrap.loader().clientEnvironment();
|
||||
} catch (RuntimeException unbound) {
|
||||
client = false;
|
||||
}
|
||||
return client
|
||||
? " Quit to the title screen and re-create the world from the Iris world type."
|
||||
: " Restart the server, then run /iris world create again.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,52 +24,78 @@ import net.minecraft.server.MinecraftServer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.PriorityBlockingQueue;
|
||||
import java.util.concurrent.RejectedExecutionHandler;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
public final class ModdedScheduler implements PlatformScheduler {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final int ASYNC_CORE_THREADS = 2;
|
||||
private static final int ASYNC_MAX_THREADS = Math.max(4, Runtime.getRuntime().availableProcessors());
|
||||
private static final int ASYNC_QUEUE_CAPACITY = 4096;
|
||||
private static final long ASYNC_KEEP_ALIVE_SECONDS = 30L;
|
||||
private static final int ASYNC_BACKLOG_WARN = 8192;
|
||||
private static final long ASYNC_BACKLOG_WARN_INTERVAL_MILLIS = 30000L;
|
||||
private static final long DRAIN_BUDGET_NANOS = TimeUnit.MILLISECONDS.toNanos(5L);
|
||||
private static final int DRAIN_TASK_CAP = 512;
|
||||
|
||||
private static volatile Thread mainThread;
|
||||
|
||||
private volatile ThreadPoolExecutor asyncExecutor;
|
||||
private final ConcurrentLinkedQueue<Runnable> mainQueue = new ConcurrentLinkedQueue<>();
|
||||
private final ConcurrentLinkedQueue<DelayedTask> delayedQueue = new ConcurrentLinkedQueue<>();
|
||||
private final PriorityBlockingQueue<DelayedTask> delayedQueue = new PriorityBlockingQueue<>();
|
||||
private final AtomicLong currentTick = new AtomicLong();
|
||||
private final AtomicLong delayedSequence = new AtomicLong();
|
||||
private final AtomicLong lastBacklogWarnAt = new AtomicLong();
|
||||
|
||||
public ModdedScheduler() {
|
||||
this.asyncExecutor = createAsyncExecutor();
|
||||
}
|
||||
|
||||
private static ThreadPoolExecutor createAsyncExecutor() {
|
||||
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(ASYNC_QUEUE_CAPACITY);
|
||||
// Unbounded queue: async work must never be executed inline on a tick thread (CallerRunsPolicy
|
||||
// stalled the server tick under load). Core == max with core timeout keeps the pool elastic,
|
||||
// which a LinkedBlockingQueue would otherwise pin to the core size.
|
||||
BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>();
|
||||
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||
ASYNC_CORE_THREADS,
|
||||
ASYNC_MAX_THREADS,
|
||||
ASYNC_MAX_THREADS,
|
||||
ASYNC_KEEP_ALIVE_SECONDS,
|
||||
TimeUnit.SECONDS,
|
||||
workQueue,
|
||||
new AsyncThreadFactory(),
|
||||
new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
dropRejectedTask());
|
||||
executor.allowCoreThreadTimeOut(true);
|
||||
return executor;
|
||||
}
|
||||
|
||||
private static RejectedExecutionHandler dropRejectedTask() {
|
||||
return (Runnable task, ThreadPoolExecutor executor) -> {
|
||||
if (executor.isShutdown()) {
|
||||
LOGGER.debug("Iris async task dropped: scheduler is shut down");
|
||||
return;
|
||||
}
|
||||
LOGGER.error("Iris async task rejected by the executor (queued={} active={})",
|
||||
executor.getQueue().size(), executor.getActiveCount());
|
||||
};
|
||||
}
|
||||
|
||||
public static void tick(MinecraftServer server) {
|
||||
if (server == null) {
|
||||
return;
|
||||
}
|
||||
mainThread = server.getRunningThread();
|
||||
Thread running = server.getRunningThread();
|
||||
if (mainThread != running) {
|
||||
mainThread = running;
|
||||
}
|
||||
// First thing in the Iris tick body: keep the off-thread level snapshot current for levels registered
|
||||
// outside ModdedServerAccess (vanilla boot, other mods) before the rest of the tick reads it.
|
||||
ModdedServerLevels.refreshIfStale(server);
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler == null) {
|
||||
return;
|
||||
@@ -99,7 +125,9 @@ public final class ModdedScheduler implements PlatformScheduler {
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
asyncExecutor.execute(() -> runGuarded(task));
|
||||
ThreadPoolExecutor executor = asyncExecutor;
|
||||
warnOnBacklog(executor);
|
||||
executor.execute(() -> runGuarded(task));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -111,7 +139,7 @@ public final class ModdedScheduler implements PlatformScheduler {
|
||||
global(task);
|
||||
return;
|
||||
}
|
||||
delayedQueue.add(new DelayedTask(task, ticks));
|
||||
delayedQueue.add(new DelayedTask(currentTick.get() + ticks, delayedSequence.getAndIncrement(), task));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -127,7 +155,10 @@ public final class ModdedScheduler implements PlatformScheduler {
|
||||
}
|
||||
mainQueue.clear();
|
||||
delayedQueue.clear();
|
||||
mainThread = null;
|
||||
// reset() only runs from ModdedEngineBootstrap.start at SERVER_STARTING, which every loader
|
||||
// fires on the server thread: capture it here instead of waiting for the first tick, so
|
||||
// global() cannot mistake a boot-time server-thread call for an off-thread one and defer it.
|
||||
mainThread = Thread.currentThread();
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
@@ -135,30 +166,54 @@ public final class ModdedScheduler implements PlatformScheduler {
|
||||
mainQueue.clear();
|
||||
delayedQueue.clear();
|
||||
mainThread = null;
|
||||
// Shutdown stage that always runs (ModdedEngineBootstrap.stop): release the level snapshot with it.
|
||||
ModdedServerLevels.forget();
|
||||
}
|
||||
|
||||
private void warnOnBacklog(ThreadPoolExecutor executor) {
|
||||
int queued = executor.getQueue().size();
|
||||
if (queued < ASYNC_BACKLOG_WARN) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
long last = lastBacklogWarnAt.get();
|
||||
if (now - last < ASYNC_BACKLOG_WARN_INTERVAL_MILLIS || !lastBacklogWarnAt.compareAndSet(last, now)) {
|
||||
return;
|
||||
}
|
||||
LOGGER.warn("Iris async backlog {} tasks (threads={}); async work is falling behind", queued, executor.getPoolSize());
|
||||
}
|
||||
|
||||
private void drain() {
|
||||
promoteDelayed();
|
||||
long tick = currentTick.incrementAndGet();
|
||||
promoteDelayed(tick);
|
||||
long deadline = System.nanoTime() + DRAIN_BUDGET_NANOS;
|
||||
int executed = 0;
|
||||
Runnable task;
|
||||
while ((task = mainQueue.poll()) != null) {
|
||||
runGuarded(task);
|
||||
executed++;
|
||||
if (executed >= DRAIN_TASK_CAP || System.nanoTime() >= deadline) {
|
||||
// Budget spent; the remainder stays queued in order and runs next tick.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void promoteDelayed() {
|
||||
if (delayedQueue.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<DelayedTask> retained = new ArrayList<>();
|
||||
DelayedTask delayed;
|
||||
while ((delayed = delayedQueue.poll()) != null) {
|
||||
if (delayed.tick()) {
|
||||
mainQueue.add(delayed.task());
|
||||
} else {
|
||||
retained.add(delayed);
|
||||
/**
|
||||
* Due-ordered promotion; only the tick thread polls, so the head is always the earliest due task. A task
|
||||
* added while this runs is due at least one tick after the tick being promoted, so it sorts behind
|
||||
* everything this pass drains and is picked up on a later tick instead of being skipped. No per-tick
|
||||
* rebuild of the pending set.
|
||||
*/
|
||||
private void promoteDelayed(long tick) {
|
||||
DelayedTask head;
|
||||
while ((head = delayedQueue.peek()) != null && head.dueTick() <= tick) {
|
||||
DelayedTask delayed = delayedQueue.poll();
|
||||
if (delayed == null) {
|
||||
return;
|
||||
}
|
||||
mainQueue.add(delayed.task());
|
||||
}
|
||||
delayedQueue.addAll(retained);
|
||||
}
|
||||
|
||||
private boolean onMainThread() {
|
||||
@@ -174,22 +229,11 @@ public final class ModdedScheduler implements PlatformScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DelayedTask {
|
||||
private final Runnable task;
|
||||
private int remaining;
|
||||
|
||||
private DelayedTask(Runnable task, int remaining) {
|
||||
this.task = task;
|
||||
this.remaining = remaining;
|
||||
}
|
||||
|
||||
private boolean tick() {
|
||||
remaining--;
|
||||
return remaining <= 0;
|
||||
}
|
||||
|
||||
private Runnable task() {
|
||||
return task;
|
||||
private record DelayedTask(long dueTick, long sequence, Runnable task) implements Comparable<DelayedTask> {
|
||||
@Override
|
||||
public int compareTo(DelayedTask other) {
|
||||
int byTick = Long.compare(dueTick, other.dueTick);
|
||||
return byTick != 0 ? byTick : Long.compare(sequence, other.sequence);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,18 +23,113 @@ import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.storage.LevelStorageSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ConcurrentModificationException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class ModdedServerLevels implements ModdedServerAccess {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final int CAPTURE_ATTEMPTS = 16;
|
||||
private static volatile Snapshot snapshot;
|
||||
|
||||
private final Consumer<MinecraftServer> levelCacheInvalidator;
|
||||
|
||||
public ModdedServerLevels(Consumer<MinecraftServer> levelCacheInvalidator) {
|
||||
this.levelCacheInvalidator = Objects.requireNonNull(levelCacheInvalidator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable view of the loaded levels. {@code server.levels} is a plain map mutated on the server
|
||||
* thread, so every off-server-thread reader must iterate this snapshot instead of
|
||||
* {@code server.getAllLevels()} to avoid ConcurrentModificationException.
|
||||
*/
|
||||
public static List<ServerLevel> levels(MinecraftServer server) {
|
||||
if (server == null) {
|
||||
return List.of();
|
||||
}
|
||||
Snapshot current = snapshot;
|
||||
if (current != null && current.server() == server) {
|
||||
return current.levels();
|
||||
}
|
||||
Snapshot captured = capture(server);
|
||||
return captured == null ? List.of() : captured.levels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed view of the loaded levels for off-server-thread lookups. Never used to gate injection:
|
||||
* {@link #hasLevel} stays on the live map so a stale snapshot cannot mask a registered level.
|
||||
*/
|
||||
public static ServerLevel level(MinecraftServer server, ResourceKey<Level> key) {
|
||||
if (server == null || key == null) {
|
||||
return null;
|
||||
}
|
||||
Snapshot current = snapshot;
|
||||
Snapshot resolved = current != null && current.server() == server ? current : capture(server);
|
||||
return resolved == null ? null : resolved.byKey().get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-captures the snapshot when the live map no longer matches it. Server thread only; called once
|
||||
* per tick so levels registered outside {@link ModdedServerAccess} (vanilla boot, other mods) are
|
||||
* picked up without any reader touching the live map.
|
||||
*/
|
||||
public static void refreshIfStale(MinecraftServer server) {
|
||||
if (server == null) {
|
||||
return;
|
||||
}
|
||||
Snapshot current = snapshot;
|
||||
if (current == null || current.server() != server) {
|
||||
capture(server);
|
||||
return;
|
||||
}
|
||||
List<ServerLevel> cached = current.levels();
|
||||
int index = 0;
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
if (index >= cached.size() || cached.get(index) != level) {
|
||||
capture(server);
|
||||
return;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
if (index != cached.size()) {
|
||||
capture(server);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the snapshot at shutdown so a stopped server (and its level graph) is not held alive until the
|
||||
* next server publishes one. Integrated servers restart inside the same JVM.
|
||||
*/
|
||||
static void forget() {
|
||||
snapshot = null;
|
||||
}
|
||||
|
||||
private static Snapshot capture(MinecraftServer server) {
|
||||
for (int attempt = 0; attempt < CAPTURE_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
LinkedHashMap<ResourceKey<Level>, ServerLevel> byKey = new LinkedHashMap<>();
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
byKey.put(level.dimension(), level);
|
||||
}
|
||||
Snapshot captured = new Snapshot(server, List.copyOf(byKey.values()), Map.copyOf(byKey));
|
||||
snapshot = captured;
|
||||
return captured;
|
||||
} catch (ConcurrentModificationException e) {
|
||||
Thread.onSpinWait();
|
||||
}
|
||||
}
|
||||
LOGGER.error("Iris could not snapshot the level map after {} attempts; readers will see the previous snapshot", CAPTURE_ATTEMPTS);
|
||||
Snapshot current = snapshot;
|
||||
return current != null && current.server() == server ? current : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Executor levelExecutor(MinecraftServer server) {
|
||||
return server.executor;
|
||||
@@ -49,6 +144,7 @@ public final class ModdedServerLevels implements ModdedServerAccess {
|
||||
public ServerLevel putLevel(MinecraftServer server, ResourceKey<Level> key, ServerLevel level) {
|
||||
ServerLevel previous = server.levels.put(key, level);
|
||||
if (previous != level) {
|
||||
capture(server);
|
||||
levelCacheInvalidator.accept(server);
|
||||
}
|
||||
return previous;
|
||||
@@ -58,6 +154,7 @@ public final class ModdedServerLevels implements ModdedServerAccess {
|
||||
public ServerLevel putLevelIfAbsent(MinecraftServer server, ResourceKey<Level> key, ServerLevel level) {
|
||||
ServerLevel previous = server.levels.putIfAbsent(key, level);
|
||||
if (previous == null) {
|
||||
capture(server);
|
||||
levelCacheInvalidator.accept(server);
|
||||
}
|
||||
return previous;
|
||||
@@ -67,6 +164,7 @@ public final class ModdedServerLevels implements ModdedServerAccess {
|
||||
public ServerLevel removeLevel(MinecraftServer server, ResourceKey<Level> key) {
|
||||
ServerLevel removed = server.levels.remove(key);
|
||||
if (removed != null) {
|
||||
capture(server);
|
||||
levelCacheInvalidator.accept(server);
|
||||
}
|
||||
return removed;
|
||||
@@ -76,4 +174,8 @@ public final class ModdedServerLevels implements ModdedServerAccess {
|
||||
public boolean hasLevel(MinecraftServer server, ResourceKey<Level> key) {
|
||||
return server.levels.containsKey(key);
|
||||
}
|
||||
|
||||
private record Snapshot(MinecraftServer server, List<ServerLevel> levels,
|
||||
Map<ResourceKey<Level>, ServerLevel> byKey) {
|
||||
}
|
||||
}
|
||||
|
||||
+9
-3
@@ -86,17 +86,23 @@ public final class ModdedServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown path: every service gets a disable attempt and nothing is rethrown. A throw here would abort the
|
||||
* loader's remaining stop handlers, so failures are logged and the manager still ends up disabled.
|
||||
*/
|
||||
public synchronized void disableAll() {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
Throwable failure = null;
|
||||
int failed = 0;
|
||||
ModdedService[] ordered = services.values().toArray(new ModdedService[0]);
|
||||
for (int i = ordered.length - 1; i >= 0; i--) {
|
||||
ModdedService service = ordered[i];
|
||||
try {
|
||||
service.onDisable();
|
||||
} catch (Throwable serviceFailure) {
|
||||
failed++;
|
||||
LOGGER.error("Iris service onDisable failed for {}", service.getClass().getName(), serviceFailure);
|
||||
if (failure == null) {
|
||||
failure = serviceFailure;
|
||||
@@ -105,10 +111,10 @@ public final class ModdedServiceManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new IllegalStateException("One or more Iris services failed to disable", failure);
|
||||
}
|
||||
enabled = false;
|
||||
if (failure != null) {
|
||||
LOGGER.error("Iris disabled all services with {} failure(s)", failed, failure);
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void rollback(Throwable failure) {
|
||||
|
||||
@@ -24,14 +24,22 @@ import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import art.arcane.iris.core.pack.PackValidator;
|
||||
import art.arcane.iris.modded.command.ModdedPackCommands;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class ModdedStartup {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
@@ -54,17 +62,26 @@ public final class ModdedStartup {
|
||||
validateAllPacks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler:
|
||||
* ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this
|
||||
* one-shot task, and the datapack must be regenerated before the level PackRepository reload if it can.
|
||||
*/
|
||||
public static void prefetchDefaultPack() {
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler != null) {
|
||||
scheduler.async(ModdedStartup::ensureDefaultPack);
|
||||
return;
|
||||
}
|
||||
Thread thread = new Thread(ModdedStartup::ensureDefaultPack, "iris-modded-pack-prefetch");
|
||||
Thread thread = new Thread(ModdedStartup::refreshPacksAndDatapack, "iris-modded-pack-prefetch");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private static void refreshPacksAndDatapack() {
|
||||
ensureDefaultPack();
|
||||
try {
|
||||
ModdedForcedDatapack.regenerateIfStale("boot");
|
||||
} catch (Throwable failure) {
|
||||
LOGGER.error("Iris could not refresh the forced datapack at boot", failure);
|
||||
}
|
||||
}
|
||||
|
||||
public static void runOnce(MinecraftServer server) {
|
||||
if (server == null || server.getPlayerList() == null) {
|
||||
return;
|
||||
@@ -74,14 +91,15 @@ public final class ModdedStartup {
|
||||
return;
|
||||
}
|
||||
ModdedForcedDatapack.verifyInjected();
|
||||
ModdedMixinAudit.runOnce();
|
||||
reinjectPersistentDimensions(server);
|
||||
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler == null) {
|
||||
ensureDefaultPack();
|
||||
refreshPacksAndDatapack();
|
||||
return;
|
||||
}
|
||||
scheduler.async(ModdedStartup::ensureDefaultPack);
|
||||
scheduler.async(ModdedStartup::refreshPacksAndDatapack);
|
||||
}
|
||||
|
||||
public static void validateAllPacks() {
|
||||
@@ -133,6 +151,12 @@ public final class ModdedStartup {
|
||||
throw new BrokenPackException(pack, List.of(
|
||||
"Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + "."));
|
||||
}
|
||||
PackValidationResult cached = PackValidationRegistry.get(pack);
|
||||
if (cached != null && cached.getValidatedAtMillis() >= newestModificationMillis(packDir.toPath())) {
|
||||
// prepareForStartup already validated this pack and nothing in it changed since; re-validating per
|
||||
// persistent dimension at boot costs a full pack parse each time.
|
||||
return PackValidationRegistry.requireLoadable(pack);
|
||||
}
|
||||
try {
|
||||
PackValidationResult result = PackValidator.validate(packDir);
|
||||
PackValidationRegistry.publish(result);
|
||||
@@ -156,23 +180,70 @@ public final class ModdedStartup {
|
||||
}
|
||||
|
||||
private static void reinjectPersistentDimensions(MinecraftServer server) {
|
||||
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions = ModdedDimensionRegistryStore.load(server);
|
||||
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
|
||||
ModdedDimensionRegistryStore.loadForStartup(server);
|
||||
if (dimensions.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int injected = 0;
|
||||
int index = 0;
|
||||
long startedAt = System.currentTimeMillis();
|
||||
for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) {
|
||||
index++;
|
||||
long dimensionStartedAt = System.currentTimeMillis();
|
||||
try {
|
||||
ModdedDimensionManager.create(server, dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed());
|
||||
injected++;
|
||||
LOGGER.info("Iris re-injected {}/{} '{}' (pack={} dim={}) in {}ms",
|
||||
index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(),
|
||||
System.currentTimeMillis() - dimensionStartedAt);
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), e);
|
||||
if (e instanceof Error fatalError) {
|
||||
throw fatalError;
|
||||
if (e instanceof OutOfMemoryError outOfMemory) {
|
||||
throw outOfMemory;
|
||||
}
|
||||
}
|
||||
}
|
||||
LOGGER.info("Iris re-injected {} persistent dimension(s) at startup", injected);
|
||||
LOGGER.info("Iris re-injected {}/{} persistent dimension(s) at startup in {}ms",
|
||||
injected, dimensions.size(), System.currentTimeMillis() - startedAt);
|
||||
}
|
||||
|
||||
private static long newestModificationMillis(Path root) {
|
||||
long newest = 0L;
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
for (Path path : (Iterable<Path>) walk::iterator) {
|
||||
BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class);
|
||||
long modified = attributes.lastModifiedTime().toMillis();
|
||||
if (modified > newest) {
|
||||
newest = modified;
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException unreadable) {
|
||||
LOGGER.debug("Iris could not stat {} for validation reuse; revalidating", root, unreadable);
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
/**
|
||||
* SP-6: a pack excluded by validation is otherwise only visible in the console. Tell the operators who
|
||||
* can actually act on it when they join.
|
||||
*/
|
||||
public static void warnPackFailuresTo(ServerPlayer player) {
|
||||
if (player == null || !Commands.LEVEL_GAMEMASTERS.check(player.permissions())) {
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<String, PackValidationResult> entry : PackValidationRegistry.snapshot().entrySet()) {
|
||||
PackValidationResult result = entry.getValue();
|
||||
if (result == null || result.isLoadable()) {
|
||||
continue;
|
||||
}
|
||||
String reason = result.getBlockingErrors().isEmpty()
|
||||
? "unknown validation failure"
|
||||
: result.getBlockingErrors().getFirst();
|
||||
player.sendSystemMessage(Component.literal("Iris pack '" + entry.getKey()
|
||||
+ "' failed validation and cannot be used: " + reason));
|
||||
}
|
||||
}
|
||||
|
||||
public static void ensureDefaultPack() {
|
||||
|
||||
@@ -59,6 +59,13 @@ import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class ModdedStructureHooks implements PlatformStructureHooks {
|
||||
/**
|
||||
* Hard ceiling on the chunk grid a single capture placement may touch. placeChunks loads every chunk in
|
||||
* the grid synchronously on the server thread, so a structure with a runaway bounding box (or maxSpan 0,
|
||||
* which disables the span check entirely) would otherwise stall the server for thousands of chunk loads.
|
||||
*/
|
||||
static final int MAX_PLACEMENT_CHUNKS = 1024;
|
||||
|
||||
private final Supplier<MinecraftServer> server;
|
||||
|
||||
public ModdedStructureHooks(Supplier<MinecraftServer> server) {
|
||||
@@ -182,6 +189,13 @@ public final class ModdedStructureHooks implements PlatformStructureHooks {
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swallow contract: a feature that refuses to place, or that throws while placing, is a normal outcome for
|
||||
* the importer that drives this - it probes many keys against arbitrary terrain and treats false as "not
|
||||
* here". So every Throwable is reported through IrisLogging and answered with false; placement never
|
||||
* escalates to the caller. placeStructure below deliberately does the opposite (it rethrows with context)
|
||||
* because a failed structure capture means the capture pass is broken, not that the site was unsuitable.
|
||||
*/
|
||||
@Override
|
||||
public boolean placeFeature(PlatformWorld world, int x, int y, int z, String featureKey, long seed) {
|
||||
try {
|
||||
@@ -212,6 +226,10 @@ public final class ModdedStructureHooks implements PlatformStructureHooks {
|
||||
if (level == null || identifier == null) {
|
||||
return null;
|
||||
}
|
||||
if (!level.getServer().isSameThread()) {
|
||||
throw new IllegalStateException("Structure placement loads chunks synchronously and must run on the server thread, not "
|
||||
+ Thread.currentThread().getName());
|
||||
}
|
||||
ChunkGenerator generator = level.getChunkSource().getGenerator();
|
||||
Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
|
||||
Structure structure = registry.getValue(identifier);
|
||||
@@ -244,6 +262,12 @@ public final class ModdedStructureHooks implements PlatformStructureHooks {
|
||||
if (!isWithinSpan(box, maxSpan)) {
|
||||
return null;
|
||||
}
|
||||
int placementChunks = chunkGridSize(box);
|
||||
if (placementChunks > MAX_PLACEMENT_CHUNKS) {
|
||||
IrisLogging.warn("Skipped structure capture for " + structureKey + " at " + chunkX + "," + chunkZ
|
||||
+ ": bounding box spans " + placementChunks + " chunks, cap is " + MAX_PLACEMENT_CHUNKS);
|
||||
return null;
|
||||
}
|
||||
placeChunks(level, structureManager, generator, start, box, seed);
|
||||
return bounds(box);
|
||||
} catch (RuntimeException error) {
|
||||
@@ -275,6 +299,20 @@ public final class ModdedStructureHooks implements PlatformStructureHooks {
|
||||
|| box.getXSpan() <= maxSpan && box.getYSpan() <= maxSpan && box.getZSpan() <= maxSpan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of chunks placeChunks would load for this bounding box. Computed in long arithmetic because a
|
||||
* corrupt box can span the whole coordinate range and the product overflows int.
|
||||
*/
|
||||
static int chunkGridSize(BoundingBox box) {
|
||||
long spanX = ((long) (box.maxX() >> 4)) - (box.minX() >> 4) + 1L;
|
||||
long spanZ = ((long) (box.maxZ() >> 4)) - (box.minZ() >> 4) + 1L;
|
||||
if (spanX <= 0L || spanZ <= 0L) {
|
||||
return 0;
|
||||
}
|
||||
long total = spanX * spanZ;
|
||||
return total > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) total;
|
||||
}
|
||||
|
||||
static int[] bounds(BoundingBox box) {
|
||||
return new int[]{box.minX(), box.minY(), box.minZ(), box.maxX(), box.maxY(), box.maxZ()};
|
||||
}
|
||||
|
||||
@@ -19,12 +19,15 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.engine.object.TileData;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import art.arcane.volmlib.util.data.UnresolvedKeyLog;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.Strictness;
|
||||
import net.minecraft.nbt.ByteTag;
|
||||
import net.minecraft.nbt.CollectionTag;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.DoubleTag;
|
||||
import net.minecraft.nbt.FloatTag;
|
||||
@@ -32,6 +35,7 @@ import net.minecraft.nbt.IntTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.nbt.LongTag;
|
||||
import net.minecraft.nbt.NbtUtils;
|
||||
import net.minecraft.nbt.NumericTag;
|
||||
import net.minecraft.nbt.ShortTag;
|
||||
import net.minecraft.nbt.StringTag;
|
||||
import net.minecraft.nbt.Tag;
|
||||
@@ -52,18 +56,24 @@ import net.minecraft.world.level.storage.TagValueInput;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class ModdedTileData extends TileData {
|
||||
public static final String NBT_PROPERTY = "nbt";
|
||||
static final String LEGACY_BANNER_COLOR_PROPERTY = "iris:legacy_banner_color";
|
||||
private static final int MAX_TAG_DEPTH = 64;
|
||||
private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setStrictness(Strictness.LENIENT).create();
|
||||
private static final UnresolvedKeyLog SNBT_FALLBACK = new UnresolvedKeyLog("Iris tile capture SNBT fallback", 30_000L);
|
||||
|
||||
private final byte[] raw;
|
||||
private final KMap<String, Object> tileProperties;
|
||||
private final String expectedBlockKey;
|
||||
private final int legacyType;
|
||||
private int hash;
|
||||
|
||||
ModdedTileData(byte[] raw, KMap<String, Object> tileProperties, String expectedBlockKey, int legacyType) {
|
||||
super();
|
||||
@@ -74,8 +84,7 @@ public final class ModdedTileData extends TileData {
|
||||
}
|
||||
|
||||
public static ModdedTileData capture(String blockKey, String snbt) throws IOException {
|
||||
KMap<String, Object> properties = new KMap<>();
|
||||
properties.put(NBT_PROPERTY, snbt);
|
||||
KMap<String, Object> properties = captureProperties(blockKey, snbt);
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (DataOutputStream out = new DataOutputStream(bytes)) {
|
||||
out.writeUTF(blockKey);
|
||||
@@ -84,6 +93,44 @@ public final class ModdedTileData extends TileData {
|
||||
return new ModdedTileData(bytes.toByteArray(), properties, normalizeBlockKey(blockKey), -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a captured tile to the generic map form the Bukkit side reads and writes, so an object captured on a
|
||||
* mod loader still pastes on Bukkit, and keeps the original SNBT under {@value #NBT_PROPERTY} alongside it.
|
||||
* <p>
|
||||
* Both forms are stored because the map form is lossy: {@link #fromTag(Tag, int, int)} collapses ByteArray,
|
||||
* IntArray and LongArray tags to a plain List, and pasting that back produces a ListTag, which Minecraft rejects
|
||||
* where it expects an array - a player head's {@code profile.id} (IntArray of 4) is the common case. Modded paste
|
||||
* reads the SNBT first ({@link #payload()}), so a modded capture pastes byte-identical on a mod loader; Bukkit
|
||||
* still reads the map form and accepts the array-shaped members degrading to lists, as it did before.
|
||||
* <p>
|
||||
* The SNBT is dropped when the captured tag itself has a root member named {@code nbt}, since that member owns the
|
||||
* map key; such a tile keeps the pre-existing lossy behaviour on both platforms. No vanilla block entity has one.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static KMap<String, Object> captureProperties(String blockKey, String snbt) {
|
||||
KMap<String, Object> properties = new KMap<>();
|
||||
if (snbt != null && !snbt.isBlank()) {
|
||||
try {
|
||||
Object converted = fromTag(NbtUtils.snbtToStructure(snbt), 0, MAX_TAG_DEPTH);
|
||||
if (converted instanceof KMap<?, ?> map) {
|
||||
properties.putAll((KMap<String, Object>) map);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
if (SNBT_FALLBACK.firstOccurrence(blockKey == null ? "<null>" : blockKey)) {
|
||||
IrisLogging.warn("Tile capture for '" + blockKey + "' kept SNBT form: " + e.getMessage());
|
||||
}
|
||||
String summary = SNBT_FALLBACK.pollSummary();
|
||||
if (summary != null) {
|
||||
IrisLogging.warn(summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (snbt != null && !properties.containsKey(NBT_PROPERTY)) {
|
||||
properties.put(NBT_PROPERTY, snbt);
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
public static ModdedTileData fromProperties(PlatformBlockState state, KMap<String, Object> properties) {
|
||||
String blockKey = state.placementBaseState().key();
|
||||
int bracket = blockKey.indexOf('[');
|
||||
@@ -121,6 +168,17 @@ public final class ModdedTileData extends TileData {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform-neutral block key. The superclass reads its own {@code material} field, which a modded record
|
||||
* never populates (it carries the key as {@link #expectedBlockKey} instead), so it must be answered here.
|
||||
* Null for a legacy record, which identifies its target by block-entity type rather than by key - see
|
||||
* {@link #isApplicable(BlockState, BlockEntity)}.
|
||||
*/
|
||||
@Override
|
||||
public String getMaterialKey() {
|
||||
return expectedBlockKey;
|
||||
}
|
||||
|
||||
public boolean isApplicable(BlockState state, BlockEntity blockEntity) {
|
||||
if (expectedBlockKey != null) {
|
||||
return expectedBlockKey.equals(normalizeBlockKey(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString()));
|
||||
@@ -218,10 +276,70 @@ public final class ModdedTileData extends TileData {
|
||||
return StringTag.valueOf(String.valueOf(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link #toTag(Object)}, matching the Bukkit NMS tile converter value-for-value so both platforms
|
||||
* produce the same generic map for the same block entity.
|
||||
*/
|
||||
private static Object fromTag(Tag tag, int depth, int maxDepth) {
|
||||
if (tag == null || depth > maxDepth) {
|
||||
return null;
|
||||
}
|
||||
if (tag instanceof CompoundTag compound) {
|
||||
KMap<String, Object> map = new KMap<>();
|
||||
for (String key : compound.keySet()) {
|
||||
Tag child = compound.get(key);
|
||||
if (child == null) {
|
||||
continue;
|
||||
}
|
||||
Object value = fromTag(child, depth + 1, maxDepth);
|
||||
if (value != null) {
|
||||
map.put(key, value);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
if (tag instanceof CollectionTag collection) {
|
||||
List<Object> values = new ArrayList<>();
|
||||
for (Object entry : collection) {
|
||||
if (entry instanceof Tag child) {
|
||||
Object value = fromTag(child, depth + 1, maxDepth);
|
||||
if (value != null) {
|
||||
values.add(value);
|
||||
}
|
||||
} else if (entry != null) {
|
||||
values.add(entry);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
if (tag instanceof NumericTag numeric) {
|
||||
return numeric.box();
|
||||
}
|
||||
return tag.asString().orElse(null);
|
||||
}
|
||||
|
||||
private static <T extends Comparable<T>> BlockState copyProperty(BlockState target, BlockState source, Property<T> property) {
|
||||
return target.setValue(property, source.getValue(property));
|
||||
}
|
||||
|
||||
private static Object deepCopy(Object value) {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
KMap<String, Object> copy = new KMap<>();
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
copy.put(String.valueOf(entry.getKey()), deepCopy(entry.getValue()));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
if (value instanceof List<?> values) {
|
||||
List<Object> copy = new ArrayList<>(values.size());
|
||||
for (Object entry : values) {
|
||||
copy.add(deepCopy(entry));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String normalizeBlockKey(String blockKey) {
|
||||
if (blockKey == null || blockKey.isBlank()) {
|
||||
return null;
|
||||
@@ -248,8 +366,53 @@ public final class ModdedTileData extends TileData {
|
||||
out.write(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity over this record's own state. The superclass generates equals/hashCode from its
|
||||
* {@code material} and {@code properties} fields, both of which stay null on a modded record, which would make
|
||||
* every modded tile equal with a constant hash. Mantle tile sections are palette backed
|
||||
* (16x16x16 = 4096 entries, so PaletteOrHunk picks a value-keyed DataContainer) and resolve palette ids through
|
||||
* equals, so a collapsed identity writes the first tile's NBT into every other tile in the section.
|
||||
* <p>
|
||||
* The serialized {@link #raw} form is the complete identity: it carries the block key and the property JSON for a
|
||||
* modern record and the consumed bytes for a legacy one. Two logically identical tiles still share one palette
|
||||
* entry, which is the intended dedup.
|
||||
*/
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof ModdedTileData other)) {
|
||||
return false;
|
||||
}
|
||||
return legacyType == other.legacyType
|
||||
&& Objects.equals(expectedBlockKey, other.expectedBlockKey)
|
||||
&& Arrays.equals(raw, other.raw);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int cached = hash;
|
||||
if (cached != 0) {
|
||||
return cached;
|
||||
}
|
||||
int computed = 31 * (31 * Arrays.hashCode(raw) + Objects.hashCode(expectedBlockKey)) + legacyType;
|
||||
if (computed == 0) {
|
||||
computed = 1;
|
||||
}
|
||||
hash = computed;
|
||||
return computed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return (expectedBlockKey == null ? "legacy:" + legacyType : expectedBlockKey) + GSON.toJson(tileProperties);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public TileData clone() {
|
||||
return this;
|
||||
return new ModdedTileData(raw == null ? null : raw.clone(),
|
||||
(KMap<String, Object>) deepCopy(tileProperties), expectedBlockKey, legacyType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ import art.arcane.iris.modded.WorldCheckStructureAudit.PendingVillagePoi;
|
||||
import art.arcane.iris.modded.WorldCheckStructureAudit.PoiAudit;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
@@ -39,6 +42,8 @@ import java.util.HexFormat;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
@@ -47,8 +52,11 @@ public final class ModdedWorldCheck {
|
||||
private static final int EXIT_FAILURE = 1;
|
||||
private static final long SERVER_WAIT_TIMEOUT_MILLIS = 600000L;
|
||||
private static final long SERVER_WAIT_INTERVAL_MILLIS = 250L;
|
||||
private static final long SERVER_TASK_TIMEOUT_MILLIS = 900000L;
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final ProcessExit PROCESS_EXIT = Runtime.getRuntime()::exit;
|
||||
// halt, not exit: awaitStopAndExit already waited for MinecraftServer.halt(true), and exit() would run the
|
||||
// shutdown hooks and block behind the server thread it just stopped, so a finished check could hang forever.
|
||||
private static final ProcessExit PROCESS_EXIT = Runtime.getRuntime()::halt;
|
||||
private static volatile MinecraftServer startedServer;
|
||||
|
||||
private ModdedWorldCheck() {
|
||||
@@ -70,7 +78,9 @@ public final class ModdedWorldCheck {
|
||||
|
||||
static Thread coordinatorThread(Runnable coordinator) {
|
||||
Thread thread = new Thread(coordinator, "Iris World Check");
|
||||
thread.setDaemon(false);
|
||||
// Daemon: every wait below is bounded and the coordinator exits the process itself, so this thread
|
||||
// must never be the reason a crashed dev server keeps the JVM alive.
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
|
||||
@@ -95,17 +105,20 @@ public final class ModdedWorldCheck {
|
||||
}
|
||||
|
||||
MinecraftServer serverRef = server;
|
||||
WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef)).join();
|
||||
WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef))
|
||||
.get(SERVER_TASK_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
|
||||
exitCode = serverRef.submit(() -> runAndRequestStop(
|
||||
() -> completeWorldCheck(preparation),
|
||||
() -> {
|
||||
stopRequested.set(true);
|
||||
serverRef.halt(false);
|
||||
}
|
||||
)).join();
|
||||
)).get(SERVER_TASK_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
LOGGER.error("[worldcheck] coordinator interrupted", e);
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (TimeoutException e) {
|
||||
LOGGER.error("[worldcheck] server task did not finish within {}ms", SERVER_TASK_TIMEOUT_MILLIS);
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("[worldcheck] check failed", e);
|
||||
} finally {
|
||||
@@ -275,16 +288,18 @@ public final class ModdedWorldCheck {
|
||||
private static ServerLevel targetLevel(MinecraftServer server) {
|
||||
String target = System.getProperty("iris.worldcheck.dimension");
|
||||
if (target != null && !target.isBlank()) {
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
if (level.dimension().identifier().toString().equals(target.trim())) {
|
||||
return level;
|
||||
}
|
||||
Identifier identifier = Identifier.tryParse(target.trim());
|
||||
ServerLevel requested = identifier == null
|
||||
? null
|
||||
: ModdedServerLevels.level(server, ResourceKey.create(Registries.DIMENSION, identifier));
|
||||
if (requested != null) {
|
||||
return requested;
|
||||
}
|
||||
LOGGER.error("[worldcheck] requested dimension '{}' is not loaded", target);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) {
|
||||
return level;
|
||||
}
|
||||
|
||||
+78
-24
@@ -45,14 +45,17 @@ import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterMarker;
|
||||
import art.arcane.volmlib.util.matter.slices.MarkerMatter;
|
||||
import it.unimi.dsi.fastutil.longs.LongArrayList;
|
||||
import it.unimi.dsi.fastutil.objects.Object2IntMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.level.NaturalSpawner;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
import java.util.HashSet;
|
||||
@@ -70,20 +73,23 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
private static final int MAX_INITIAL_DRAIN_PER_TICK = 8;
|
||||
private static final int MAX_INITIAL_RECOVERY_PER_PASS = 128;
|
||||
private static final int MANTLE_WARMUP_QUEUE_CAPACITY = 256;
|
||||
private static final int AMBIENT_CHUNK_SAMPLE = 64;
|
||||
private static final long INITIAL_RECOVERY_INTERVAL_MS = 1_000L;
|
||||
private static final long COUNT_INTERVAL_MS = 3_000L;
|
||||
|
||||
private final Engine engine;
|
||||
private final InitialSpawnQueue initialSpawnQueue;
|
||||
private final Set<Long> mantleWarmups;
|
||||
private final ThreadPoolExecutor mantleWarmupExecutor;
|
||||
private final long[] ambientChunkSample = new long[AMBIENT_CHUNK_SAMPLE];
|
||||
private int ambientChunkSampleSeen;
|
||||
private long lastAmbientAt;
|
||||
private long lastCountAt;
|
||||
private long lastInitialRecoveryAt;
|
||||
private boolean initialSpawnQueueClosed;
|
||||
private boolean mantleWarmupExecutorStopped;
|
||||
private boolean mantleWarmupsCleared;
|
||||
private boolean spawnStateMissingLogged;
|
||||
private volatile boolean closed;
|
||||
private volatile boolean entityCountAvailable;
|
||||
private volatile int cachedEntityCount;
|
||||
private volatile int cachedConsideredChunks;
|
||||
private volatile double cachedSaturation;
|
||||
@@ -114,9 +120,16 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
return;
|
||||
}
|
||||
EngineWorldManager worldManager = engine.getWorldManager();
|
||||
if (worldManager instanceof ModdedWorldManager moddedWorldManager) {
|
||||
moddedWorldManager.initialSpawnQueue.offer(pack(chunkX, chunkZ));
|
||||
if (!(worldManager instanceof ModdedWorldManager moddedWorldManager)) {
|
||||
return;
|
||||
}
|
||||
if (moddedWorldManager.closed || moddedWorldManager.isPregenActive()) {
|
||||
// runServerTick skips the drain while a pregen targets this world, so every offer from the
|
||||
// generation threads can only expire or overflow while contending for the queue monitor.
|
||||
// recoverLoadedInitialSpawns re-offers the chunks that matter once the job ends.
|
||||
return;
|
||||
}
|
||||
moddedWorldManager.initialSpawnQueue.offer(pack(chunkX, chunkZ));
|
||||
}
|
||||
|
||||
public void serverTick(ServerLevel level) {
|
||||
@@ -328,19 +341,23 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
return;
|
||||
}
|
||||
|
||||
long[] candidates = loadedChunkPositionsSnapshot(level);
|
||||
refreshEntityCount(level, now, candidates.length);
|
||||
int loadedChunks = sampleLoadedChunks(level);
|
||||
refreshEntityCount(level, loadedChunks);
|
||||
if (!entityCountAvailable) {
|
||||
return;
|
||||
}
|
||||
if (cachedSaturation > IrisSettings.get().getWorld().getTargetSpawnEntitiesPerChunk()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidates.length == 0) {
|
||||
int sampled = Math.min(loadedChunks, ambientChunkSample.length);
|
||||
if (sampled == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
int spawnBuffer = RNG.r.i(2, 12);
|
||||
while (spawnBuffer-- > 0) {
|
||||
long key = candidates[RNG.r.nextInt(candidates.length)];
|
||||
long key = ambientChunkSample[RNG.r.nextInt(sampled)];
|
||||
try {
|
||||
ambientSpawnChunk(level, unpackX(key), unpackZ(key));
|
||||
} catch (Throwable e) {
|
||||
@@ -673,29 +690,66 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
return (position.getX() >> 4) == chunkX && (position.getZ() >> 4) == chunkZ;
|
||||
}
|
||||
|
||||
private void refreshEntityCount(ServerLevel level, long now, int loadedChunks) {
|
||||
/**
|
||||
* ServerChunkCache.tickChunks rebuilds NaturalSpawner.SpawnState every tick from every entity in the
|
||||
* level, so read that instead of walking all entities again on an Iris timer. The count is the natural
|
||||
* spawn cap population (non persistent mobs in loaded chunks, MISC excluded), which is exactly the
|
||||
* population the ambient spawn gate throttles against. No state means the level has not ticked chunks
|
||||
* yet, so hold spawning rather than guess.
|
||||
*/
|
||||
private void refreshEntityCount(ServerLevel level, int loadedChunks) {
|
||||
cachedConsideredChunks = loadedChunks;
|
||||
cachedSaturation = cachedEntityCount / (loadedChunks + 1.0) * 1.28;
|
||||
if (now - lastCountAt < COUNT_INTERVAL_MS) {
|
||||
NaturalSpawner.SpawnState spawnState = level.getChunkSource().getLastSpawnState();
|
||||
if (spawnState == null) {
|
||||
entityCountAvailable = false;
|
||||
if (!spawnStateMissingLogged) {
|
||||
spawnStateMissingLogged = true;
|
||||
IrisLogging.warn("No spawn state for " + engine.getName() + " yet; ambient spawning held");
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastCountAt = now;
|
||||
|
||||
int livingEntities = 0;
|
||||
for (Entity entity : level.getAllEntities()) {
|
||||
if (entity instanceof LivingEntity && entity.isAlive()) {
|
||||
livingEntities++;
|
||||
}
|
||||
Object2IntMap<MobCategory> counts = spawnState.getMobCategoryCounts();
|
||||
int mobs = 0;
|
||||
for (MobCategory category : counts.keySet()) {
|
||||
mobs += counts.getInt(category);
|
||||
}
|
||||
|
||||
cachedEntityCount = livingEntities;
|
||||
cachedSaturation = livingEntities / (loadedChunks + 1.0) * 1.28;
|
||||
entityCountAvailable = true;
|
||||
cachedEntityCount = mobs;
|
||||
// Metric = natural-spawn-cap population over natural-spawn chunk count: numerator and denominator both
|
||||
// come from MC's own spawn state, so the ratio is not diluted by chunks the spawner never counts.
|
||||
cachedSaturation = mobs / (spawnState.getSpawnableChunkCount() + 1.0) * 1.28;
|
||||
}
|
||||
|
||||
private long[] loadedChunkPositionsSnapshot(ServerLevel level) {
|
||||
LongArrayList positions = new LongArrayList(level.getChunkSource().getLoadedChunksCount());
|
||||
level.getChunkSource().chunkMap.forEachReadyToSendChunk(chunk -> positions.add(chunk.getPos().pack()));
|
||||
return positions.toLongArray();
|
||||
/**
|
||||
* Reservoir sample (algorithm R) of the ready to send chunks into a fixed buffer. ambientTick only picks
|
||||
* up to 12 random chunks per pass, so materializing every loaded chunk position once per interval was
|
||||
* pure garbage. Returns how many chunks the walk saw, which is the same considered-chunk count the old
|
||||
* snapshot length reported. Server thread only, so the reservoir and its counter are plain fields.
|
||||
*/
|
||||
private int sampleLoadedChunks(ServerLevel level) {
|
||||
ambientChunkSampleSeen = 0;
|
||||
level.getChunkSource().chunkMap.forEachReadyToSendChunk((LevelChunk chunk) -> {
|
||||
int index = ambientChunkSampleSeen++;
|
||||
int capacity = ambientChunkSample.length;
|
||||
int slot = reservoirSlot(index, capacity, index < capacity ? 0 : RNG.r.nextInt(index + 1));
|
||||
if (slot >= 0) {
|
||||
ambientChunkSample[slot] = chunk.getPos().pack();
|
||||
}
|
||||
});
|
||||
return ambientChunkSampleSeen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Algorithm R slot for the item at {@code index}: fill the reservoir first, then keep the item only when
|
||||
* {@code roll} (uniform over 0..index) lands inside the reservoir. Negative means drop the item.
|
||||
*/
|
||||
static int reservoirSlot(int index, int capacity, int roll) {
|
||||
if (index < capacity) {
|
||||
return index;
|
||||
}
|
||||
return roll < capacity ? roll : -1;
|
||||
}
|
||||
|
||||
private boolean isPregenActive() {
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 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.modded;
|
||||
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
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.filter.AbstractFilter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* {@code feature_placement} worldcheck gate for the imported-feature pass.
|
||||
*
|
||||
* <p>Vanilla does not throw when a feature writes into a chunk it is not allowed to touch; it logs
|
||||
* {@code Detected setBlock in a far chunk} and drops the write. Reading back the world cannot distinguish that
|
||||
* from a feature that legitimately placed nothing, so the gate watches the log instead. The second marker,
|
||||
* {@code Requested chunk unavailable during world generation}, does throw, and is recorded from the feature
|
||||
* pass directly.
|
||||
*
|
||||
* <p>Only armed under {@code -Diris.worldcheck}. Outside a gated run every entry point here is a single
|
||||
* boolean read.
|
||||
*/
|
||||
final class WorldCheckFeaturePlacement {
|
||||
private static final boolean ENABLED = Boolean.getBoolean("iris.worldcheck");
|
||||
private static final String FAR_CHUNK_MARKER = "Detected setBlock in a far chunk";
|
||||
private static final String UNAVAILABLE_CHUNK_MARKER = "Requested chunk unavailable during world generation";
|
||||
private static final List<String> MARKERS = List.of(FAR_CHUNK_MARKER, UNAVAILABLE_CHUNK_MARKER);
|
||||
private static final int REPORTED_SAMPLE_MAX = 5;
|
||||
|
||||
private static final AtomicBoolean INSTALLED = new AtomicBoolean();
|
||||
private static final AtomicBoolean PASS_REPORTED = new AtomicBoolean();
|
||||
private static final AtomicBoolean SKIP_REPORTED = new AtomicBoolean();
|
||||
private static final AtomicInteger VIOLATIONS = new AtomicInteger();
|
||||
|
||||
private WorldCheckFeaturePlacement() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Arms the log watch. Called when the feature table is published, which is before any chunk decorates, so
|
||||
* the watch is installed for the very first pass rather than from the second chunk onwards. The pass and
|
||||
* failure recorders arm too, for a path that publishes a table without going through prepare. Idempotent
|
||||
* and cheap when the gate is off.
|
||||
*/
|
||||
static void arm() {
|
||||
if (!ENABLED || !INSTALLED.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
((Logger) LogManager.getRootLogger()).addFilter(new PlacementWatch());
|
||||
} catch (Throwable error) {
|
||||
WorldCheckPredicates.qaEvent("feature_placement", "all", false,
|
||||
"skipped=log-watch-unavailable," + error.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the passing gate event once, after the first clean chunk. A later violation emits its own failing
|
||||
* event, so a run that fails after passing still reports the failure.
|
||||
*/
|
||||
static void recordPlacementPass() {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
arm();
|
||||
if (VIOLATIONS.get() == 0 && PASS_REPORTED.compareAndSet(false, true)) {
|
||||
WorldCheckPredicates.qaEvent("feature_placement", "all", true, "violations=0");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the not-asserted event once, so a gated run can tell "importedFeatures was off, nothing to check"
|
||||
* apart from "checked and clean".
|
||||
*/
|
||||
static void recordFeaturesOff() {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (SKIP_REPORTED.compareAndSet(false, true)) {
|
||||
WorldCheckPredicates.qaEvent("feature_placement", "all", true, "skipped=importedFeatures-off");
|
||||
}
|
||||
}
|
||||
|
||||
static void recordPlacementFailure(ChunkPos chunkPos, Throwable error) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
arm();
|
||||
String message = messageChain(error);
|
||||
String detail = message.contains(UNAVAILABLE_CHUNK_MARKER)
|
||||
? "unavailableChunk"
|
||||
: "placementFailure";
|
||||
report(detail + ",chunk=" + chunkPos.x() + "," + chunkPos.z() + ",error="
|
||||
+ error.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
private static void report(String detail) {
|
||||
int count = VIOLATIONS.incrementAndGet();
|
||||
if (count <= REPORTED_SAMPLE_MAX) {
|
||||
WorldCheckPredicates.qaEvent("feature_placement", "all", false, detail + ",violations=" + count);
|
||||
}
|
||||
}
|
||||
|
||||
private static String messageChain(Throwable error) {
|
||||
StringBuilder chain = new StringBuilder();
|
||||
Throwable current = error;
|
||||
for (int depth = 0; current != null && depth < 16; depth++) {
|
||||
if (current.getMessage() != null) {
|
||||
chain.append(current.getMessage()).append('\n');
|
||||
}
|
||||
current = current.getCause() == current ? null : current.getCause();
|
||||
}
|
||||
return chain.toString();
|
||||
}
|
||||
|
||||
private static final class PlacementWatch extends AbstractFilter {
|
||||
private PlacementWatch() {
|
||||
super(Filter.Result.NEUTRAL, Filter.Result.NEUTRAL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Filter.Result filter(LogEvent event) {
|
||||
if (event == null || event.getMessage() == null) {
|
||||
return Filter.Result.NEUTRAL;
|
||||
}
|
||||
String message = event.getMessage().getFormattedMessage();
|
||||
if (message == null) {
|
||||
return Filter.Result.NEUTRAL;
|
||||
}
|
||||
for (String marker : MARKERS) {
|
||||
if (message.contains(marker)) {
|
||||
report("logMarker=" + marker);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Filter.Result.NEUTRAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -29,11 +29,14 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.ServiceLoader;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
@@ -216,6 +219,44 @@ public final class ModdedCustomContentRegistry {
|
||||
return !PROVIDERS.isEmpty() || !CUSTOM_BLOCKS.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every statically registered {@code namespace:key} block alias. Read-only snapshot for pack tooling
|
||||
* (schema completion, key validation); not on the resolution path.
|
||||
*/
|
||||
public static List<String> aliasBlockKeys() {
|
||||
return List.copyOf(CUSTOM_BLOCKS.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Every key claimed by a registered provider for {@code type}, in registration order, deduplicated. Read-only
|
||||
* snapshot for pack tooling; a provider that throws is logged against its mod id and skipped.
|
||||
*/
|
||||
public static List<String> providerKeys(ModdedDataType type) {
|
||||
if (type == null || PROVIDERS.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> keys = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (ModdedDataProvider provider : PROVIDERS) {
|
||||
Collection<Identifier> types;
|
||||
try {
|
||||
types = provider.getTypes(type);
|
||||
} catch (Throwable error) {
|
||||
LOGGER.error("Iris custom content provider '{}' failed listing {} types", provider.modId(), type, error);
|
||||
continue;
|
||||
}
|
||||
if (types == null) {
|
||||
continue;
|
||||
}
|
||||
for (Identifier identifier : types) {
|
||||
if (identifier != null && seen.add(identifier.toString())) {
|
||||
keys.add(identifier.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a pack block key against aliases first, then each ready provider that claims it, in registration
|
||||
* order. {@code key} may carry {@code [prop=value]} properties, which are parsed and passed along. Returns null
|
||||
|
||||
+14
-4
@@ -25,9 +25,11 @@ import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.iris.modded.ModdedDimensionManager;
|
||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import art.arcane.iris.modded.ModdedForcedDatapack;
|
||||
import art.arcane.iris.modded.ModdedLoader;
|
||||
import art.arcane.iris.modded.ModdedPackInstaller;
|
||||
import art.arcane.iris.modded.ModdedScheduler;
|
||||
import art.arcane.iris.modded.ModdedServerLevels;
|
||||
import art.arcane.iris.modded.ModdedWorldgenIds;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
@@ -121,6 +123,9 @@ public final class IrisModdedCommands {
|
||||
IrisSettings.invalidate();
|
||||
}
|
||||
IrisSettings.get();
|
||||
// Forced-datapack regeneration trigger. Async: staging revalidates every pack and must never run on
|
||||
// the server thread.
|
||||
ModdedForcedDatapack.scheduleRegeneration("/iris reload");
|
||||
boolean localeLoaded = IrisLanguage.reload();
|
||||
if (localeLoaded) {
|
||||
ok(source, IrisLanguage.plain(
|
||||
@@ -178,10 +183,13 @@ public final class IrisModdedCommands {
|
||||
|
||||
static int info(CommandSourceStack source, String filter) {
|
||||
MinecraftServer server = source.getServer();
|
||||
// The seed is the one field in this listing that is not free to hand a plain player, and /iris worlds
|
||||
// routes here too: emit it only for sources that pass the same gate /iris seed requires.
|
||||
boolean showSeed = ModdedCommandTree.isGamemaster(source);
|
||||
List<String> lines = new ArrayList<>();
|
||||
int total = 0;
|
||||
int iris = 0;
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||
total++;
|
||||
ChunkGenerator generator = level.getChunkSource().getGenerator();
|
||||
if (!(generator instanceof IrisModdedChunkGenerator irisGenerator)) {
|
||||
@@ -201,12 +209,14 @@ public final class IrisModdedCommands {
|
||||
+ " world=" + dimensionId + " (engine not started yet)");
|
||||
continue;
|
||||
}
|
||||
String featureStatus = irisGenerator.importedFeaturesStatus();
|
||||
lines.add(irisIdentity + ": pack=" + engine.getDimension().getLoadKey()
|
||||
+ " world=" + dimensionId
|
||||
+ " seed=" + level.getSeed()
|
||||
+ (showSeed ? " seed=" + level.getSeed() : "")
|
||||
+ " height=" + engine.getMinHeight() + ".." + engine.getMaxHeight()
|
||||
+ " generated=" + engine.getGenerated()
|
||||
+ " data=" + engine.getData().getDataFolder().getAbsolutePath());
|
||||
+ (featureStatus == null ? "" : " importedFeatures=" + featureStatus)
|
||||
+ " data=" + engine.getData().getDataFolder().getName());
|
||||
}
|
||||
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_LOADED_DIMENSIONS_IRIS, MessageArgument.untrusted("total", total), MessageArgument.untrusted("iris", iris)));
|
||||
if (lines.isEmpty()) {
|
||||
@@ -321,7 +331,7 @@ public final class IrisModdedCommands {
|
||||
|
||||
private static int engineCount(MinecraftServer server) {
|
||||
int count = 0;
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) {
|
||||
count++;
|
||||
}
|
||||
|
||||
+45
-2
@@ -231,13 +231,17 @@ final class ModdedCommandHelp {
|
||||
return 0;
|
||||
}
|
||||
|
||||
ModdedCommandFeedback.clear(source);
|
||||
|
||||
if (source.getPlayer() == null) {
|
||||
return sendConsole(source, request.section());
|
||||
}
|
||||
|
||||
int totalPages = Math.max(1, (int) Math.ceil(entries.size() / (double) PAGE_SIZE));
|
||||
int page = Math.max(0, Math.min(request.page(), totalPages - 1));
|
||||
int from = page * PAGE_SIZE;
|
||||
int to = Math.min(entries.size(), from + PAGE_SIZE);
|
||||
|
||||
ModdedCommandFeedback.clear(source);
|
||||
|
||||
sendHeader(source, request.section(), page, totalPages);
|
||||
if (!Commands.hasPermission(Commands.LEVEL_GAMEMASTERS).test(source)) {
|
||||
ModdedCommandFeedback.send(source, opNotice());
|
||||
@@ -252,6 +256,45 @@ final class ModdedCommandHelp {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int sendConsole(CommandSourceStack source, String section) {
|
||||
sendHeader(source, section, 0, 1);
|
||||
if (!Commands.hasPermission(Commands.LEVEL_GAMEMASTERS).test(source)) {
|
||||
ModdedCommandFeedback.send(source, opNotice());
|
||||
}
|
||||
for (String line : consoleLines(section)) {
|
||||
ModdedCommandFeedback.send(source, Component.literal(line));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static List<String> consoleLines(String section) {
|
||||
List<Entry> entries = SECTIONS.get(section);
|
||||
if (entries == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<String> lines = new ArrayList<>(entries.size());
|
||||
for (Entry entry : entries) {
|
||||
lines.add(consoleLine(section, entry));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static String consoleLine(String section, Entry entry) {
|
||||
StringBuilder line = new StringBuilder(section.isEmpty() ? "/iris " : "/iris " + section + " ");
|
||||
line.append(entry.name());
|
||||
if (!entry.usage().isBlank()) {
|
||||
line.append(' ').append(entry.usage());
|
||||
}
|
||||
if (entry.aliases().length > 0) {
|
||||
line.append(" (").append(String.join(", ", entry.aliases())).append(')');
|
||||
}
|
||||
if (entry.group()) {
|
||||
line.append(" - ").append(IrisLanguage.plain(DirectorHelpMessages.CATEGORY));
|
||||
}
|
||||
return line.append(" - ").append(IrisLanguage.plain(entry.description())).toString();
|
||||
}
|
||||
|
||||
private static void sendHeader(CommandSourceStack source, String path, int page, int totalPages) {
|
||||
String title = path.isEmpty() ? "/iris" : "/iris " + path;
|
||||
if (totalPages > 1) {
|
||||
|
||||
+2
-1
@@ -22,6 +22,7 @@ import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import art.arcane.iris.modded.ModdedServerLevels;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.suggestion.SuggestionProvider;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
@@ -181,7 +182,7 @@ final class ModdedCommandSuggestions {
|
||||
private static CompletableFuture<Suggestions> suggestDimensionNames(CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) {
|
||||
ModdedCommandFeedback.tab(context.getSource());
|
||||
List<String> names = new ArrayList<>();
|
||||
for (ServerLevel level : context.getSource().getServer().getAllLevels()) {
|
||||
for (ServerLevel level : ModdedServerLevels.levels(context.getSource().getServer())) {
|
||||
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) {
|
||||
names.add(level.dimension().identifier().toString());
|
||||
}
|
||||
|
||||
+24
-7
@@ -36,25 +36,42 @@ import java.util.function.Predicate;
|
||||
|
||||
final class ModdedCommandTree {
|
||||
private static final Predicate<CommandSourceStack> GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS);
|
||||
/**
|
||||
* SP-4: read-only inspection must work for an unopped player in a no-cheats singleplayer world, where the
|
||||
* what/height overlays are the only way to see what Iris generated. Everything that mutates a world,
|
||||
* downloads, opens studio or starts a pregen stays on GATE, and so does the world seed: it is the one
|
||||
* read-only value a plain player must not be handed, so /iris seed is gated and info/worlds omit the seed
|
||||
* field for anyone who fails {@link #isGamemaster(CommandSourceStack)}.
|
||||
*/
|
||||
private static final Predicate<CommandSourceStack> READ_ONLY = Commands.hasPermission(Commands.LEVEL_ALL);
|
||||
|
||||
private ModdedCommandTree() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Same gate the mutating subtrees use, for output that mixes gated and ungated fields in one command.
|
||||
*/
|
||||
static boolean isGamemaster(CommandSourceStack source) {
|
||||
return GATE.test(source);
|
||||
}
|
||||
|
||||
static LiteralArgumentBuilder<CommandSourceStack> rootTree() {
|
||||
LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal("iris");
|
||||
|
||||
root.executes((CommandContext<CommandSourceStack> context) -> ModdedCommandHelp.send(context.getSource(), ""));
|
||||
root.then(helpTree());
|
||||
|
||||
root.then(Commands.literal("version")
|
||||
root.then(Commands.literal("version").requires(READ_ONLY)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.version(context.getSource())));
|
||||
|
||||
root.then(Commands.literal("info").requires(GATE)
|
||||
root.then(Commands.literal("info").requires(READ_ONLY)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), null))
|
||||
.then(Commands.argument("dimension", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.DIMENSION_NAMES)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), StringArgumentType.getString(context, "dimension")))));
|
||||
|
||||
root.then(ModdedWhatCommands.tree());
|
||||
// ModdedWhatCommands.tree() gates itself at LEVEL_GAMEMASTERS; the /iris what overlays are read-only,
|
||||
// so the root builder relaxes the whole subtree here instead of forking that file.
|
||||
root.then(ModdedWhatCommands.tree().requires(READ_ONLY));
|
||||
|
||||
root.then(teleportTree("teleport"));
|
||||
root.then(teleportTree("tp"));
|
||||
@@ -69,9 +86,9 @@ final class ModdedCommandTree {
|
||||
|
||||
root.then(Commands.literal("reload").requires(GATE)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.reload(context.getSource())));
|
||||
root.then(Commands.literal("height").requires(GATE)
|
||||
root.then(Commands.literal("height").requires(READ_ONLY)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.height(context.getSource())));
|
||||
root.then(Commands.literal("worlds").requires(GATE)
|
||||
root.then(Commands.literal("worlds").requires(READ_ONLY)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), null)));
|
||||
root.then(Commands.literal("accesslist").requires(GATE)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), null)));
|
||||
@@ -162,7 +179,7 @@ final class ModdedCommandTree {
|
||||
}
|
||||
|
||||
private static LiteralArgumentBuilder<CommandSourceStack> helpTree() {
|
||||
return Commands.literal("help")
|
||||
return Commands.literal("help").requires(READ_ONLY)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> ModdedCommandHelp.send(context.getSource(), ""))
|
||||
.then(Commands.argument("section", StringArgumentType.greedyString())
|
||||
.executes((CommandContext<CommandSourceStack> context) -> ModdedCommandHelp.send(context.getSource(), StringArgumentType.getString(context, "section"))));
|
||||
@@ -202,7 +219,7 @@ final class ModdedCommandTree {
|
||||
}
|
||||
|
||||
private static LiteralArgumentBuilder<CommandSourceStack> metricsTree(String name) {
|
||||
return Commands.literal(name).requires(GATE)
|
||||
return Commands.literal(name).requires(READ_ONLY)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.metrics(context.getSource()));
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -23,6 +23,7 @@ import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.iris.modded.ModdedServerLevels;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
@@ -107,7 +108,7 @@ public final class ModdedDatapackCommands {
|
||||
MinecraftServer server = source.getServer();
|
||||
int irisLevels = 0;
|
||||
int mismatches = 0;
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) {
|
||||
continue;
|
||||
}
|
||||
@@ -150,7 +151,7 @@ public final class ModdedDatapackCommands {
|
||||
private static int install(CommandSourceStack source) {
|
||||
MinecraftServer server = source.getServer();
|
||||
List<String> written = new ArrayList<>();
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
+28
-3
@@ -26,6 +26,8 @@ import art.arcane.iris.modded.ModdedBlockResolution;
|
||||
import art.arcane.iris.modded.ModdedBlockState;
|
||||
import art.arcane.iris.modded.ModdedTileData;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
@@ -43,6 +45,7 @@ import java.util.Map;
|
||||
|
||||
final class ModdedObjectPlacer implements IObjectPlacer {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final int DEFAULT_FLUID_HEIGHT = 63;
|
||||
|
||||
private final ServerLevel level;
|
||||
private final Engine engine;
|
||||
@@ -116,9 +119,21 @@ final class ModdedObjectPlacer implements IObjectPlacer {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mantle Y is relative to the world minimum height while this placer works in absolute world Y, so shift
|
||||
* before the lookup. Only answers from an already loaded mantle chunk: a hand placed object can sit
|
||||
* anywhere, and loading a mantle chunk to answer a carve probe would generate terrain as a side effect.
|
||||
*/
|
||||
@Override
|
||||
public boolean isCarved(int x, int y, int z) {
|
||||
return false;
|
||||
if (engine == null) {
|
||||
return false;
|
||||
}
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
if (mantle.isClosed() || !mantle.isChunkLoaded(x >> 4, z >> 4)) {
|
||||
return false;
|
||||
}
|
||||
return engine.getMantle().isCarved(x, y - engine.getWorld().minHeight(), z);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -126,14 +141,24 @@ final class ModdedObjectPlacer implements IObjectPlacer {
|
||||
return ModdedBlockResolution.isSolid(level.getBlockState(new BlockPos(x, y, z)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine height stream against the dimension fluid height, both engine relative, so no shift here. Needs a
|
||||
* ready complex; the placer also runs from commands against levels that never bound an engine.
|
||||
*/
|
||||
@Override
|
||||
public boolean isUnderwater(int x, int z) {
|
||||
return false;
|
||||
return engine != null && engine.getComplex() != null && engine.getMantle().isUnderwater(x, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* IrisDimension fluid height is engine relative while this placer works in absolute world Y, so shift it
|
||||
* up by the engine minimum before handing it to object placement.
|
||||
*/
|
||||
@Override
|
||||
public int getFluidHeight() {
|
||||
return 63;
|
||||
return engine == null
|
||||
? DEFAULT_FLUID_HEIGHT
|
||||
: engine.getMinHeight() + engine.getDimension().getFluidHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+187
-49
@@ -23,8 +23,10 @@ import art.arcane.iris.core.pregenerator.PregenListener;
|
||||
import art.arcane.iris.core.pregenerator.PregenMantleBackpressure;
|
||||
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.modded.ModdedGenPool;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.dedicated.DedicatedServer;
|
||||
import net.minecraft.server.level.ChunkResult;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.TicketType;
|
||||
@@ -32,9 +34,9 @@ import net.minecraft.world.level.ChunkPos;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
@@ -52,7 +54,6 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
private static final long ADAPTIVE_RECOVERY_INTERVAL = 64L;
|
||||
private static final long FINAL_SAVE_TIMEOUT_MILLIS = 10_000L;
|
||||
private static final long FINAL_SAVE_POLL_MILLIS = 50L;
|
||||
private static final boolean PARALLEL_CHUNK_SYSTEM = detectParallelChunkSystem();
|
||||
|
||||
private final ServerLevel level;
|
||||
private final Engine engine;
|
||||
@@ -70,8 +71,10 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
private final AtomicBoolean finalSaveDeferred = new AtomicBoolean(false);
|
||||
private final AtomicBoolean finalSaveCompleted = new AtomicBoolean(false);
|
||||
private final AtomicReference<FinalSaveRequest> queuedFinalSave = new AtomicReference<>();
|
||||
private final AtomicBoolean stallHintLogged = new AtomicBoolean(false);
|
||||
private final int timeoutSeconds;
|
||||
private final PregenMantleBackpressure backpressure;
|
||||
private final PauseWhenEmptyGuard pauseGuard;
|
||||
|
||||
public ModdedPregenMethod(ServerLevel level, Engine engine) {
|
||||
this(level, engine, false);
|
||||
@@ -81,6 +84,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
this.level = level;
|
||||
this.engine = engine;
|
||||
this.sync = sync;
|
||||
this.pauseGuard = new PauseWhenEmptyGuard(level.getServer());
|
||||
IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen();
|
||||
this.maxInFlight = Math.max(8, pregen.getModdedPregenInFlight());
|
||||
this.minInFlight = Math.max(4, Math.min(16, maxInFlight / 4));
|
||||
@@ -99,33 +103,38 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
|
||||
@Override
|
||||
public void init() {
|
||||
LOGGER.info("Iris modded pregen init: dim={} mode={} inFlightCap={} timeout={}s workerPool={} parallelChunkSystem={}",
|
||||
pauseGuard.suspend();
|
||||
LOGGER.info("Iris modded pregen init: dim={} mode={} inFlightCap={} timeout={}s workerPool={} chunkSystem={}",
|
||||
level.dimension().identifier(),
|
||||
sync ? "sync" : "async",
|
||||
sync ? 1 : maxInFlight,
|
||||
timeoutSeconds,
|
||||
describeWorkerPool(),
|
||||
PARALLEL_CHUNK_SYSTEM ? "yes" : "no");
|
||||
if (!sync && !PARALLEL_CHUNK_SYSTEM) {
|
||||
ModdedGenPool.describeChunkSystem());
|
||||
if (!sync && !ModdedGenPool.parallelChunkSystem()) {
|
||||
LOGGER.info("Iris pregen note: this loader uses the vanilla main-thread chunk system, which caps pregen throughput. For Bukkit-level speed on Fabric install C2ME (Concurrent Chunk Management Engine); on servers use Paper.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!sync) {
|
||||
try {
|
||||
semaphore.tryAcquire(maxInFlight, 5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
try {
|
||||
if (!sync) {
|
||||
try {
|
||||
semaphore.tryAcquire(maxInFlight, 5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
LOGGER.info("Iris modded pregen done: dim={} completed={} peakInFlight={} finalLimit={}",
|
||||
level.dimension().identifier(), completed.get(), inFlightPeak.get(), adaptiveLimit.get());
|
||||
if (deferFinalSaveIfRequested()) {
|
||||
return;
|
||||
}
|
||||
saveLevel(true);
|
||||
} finally {
|
||||
pauseGuard.restore();
|
||||
}
|
||||
LOGGER.info("Iris modded pregen done: dim={} completed={} peakInFlight={} finalLimit={}",
|
||||
level.dimension().identifier(), completed.get(), inFlightPeak.get(), adaptiveLimit.get());
|
||||
if (deferFinalSaveIfRequested()) {
|
||||
return;
|
||||
}
|
||||
saveLevel(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -337,6 +346,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
if (e instanceof TimeoutException) {
|
||||
noteStallHint();
|
||||
}
|
||||
LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString());
|
||||
listener.onChunkFailed(x, z);
|
||||
} finally {
|
||||
@@ -413,11 +425,22 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
}
|
||||
|
||||
private void onTimeout() {
|
||||
noteStallHint();
|
||||
if (timeoutStreak.incrementAndGet() % ADAPTIVE_TIMEOUT_STEP == 0) {
|
||||
adjustAdaptiveLimit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First timeout of a job explains itself if the server is able to stop ticking under us. Without
|
||||
* this a paused server just produces a wall of identical chunk timeouts.
|
||||
*/
|
||||
private void noteStallHint() {
|
||||
if (stallHintLogged.compareAndSet(false, true)) {
|
||||
pauseGuard.logStallHint();
|
||||
}
|
||||
}
|
||||
|
||||
private void onSuccess() {
|
||||
int streak = timeoutStreak.get();
|
||||
if (streak > 0) {
|
||||
@@ -455,56 +478,171 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
private void cleanupMantleChunk(int x, int z) {
|
||||
try {
|
||||
engine.getMantle().forceCleanupChunk(x, z);
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris pregen mantle cleanup skipped for {},{}: {}", x, z, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private String describeWorkerPool() {
|
||||
try {
|
||||
Field field = MinecraftServer.class.getDeclaredField("executor");
|
||||
field.setAccessible(true);
|
||||
Object exec = field.get(level.getServer());
|
||||
if (exec == null) {
|
||||
return "unknown";
|
||||
}
|
||||
if (exec instanceof ThreadPoolExecutor tpe) {
|
||||
return "ThreadPoolExecutor(core=" + tpe.getCorePoolSize() + ",max=" + tpe.getMaximumPoolSize() + ")";
|
||||
}
|
||||
if (exec instanceof ForkJoinPool fjp) {
|
||||
return "ForkJoinPool(parallelism=" + fjp.getParallelism() + ")";
|
||||
}
|
||||
return exec.getClass().getSimpleName();
|
||||
} catch (Throwable e) {
|
||||
Executor exec = level.getServer().executor;
|
||||
if (exec == null) {
|
||||
return "unknown";
|
||||
}
|
||||
if (exec instanceof ThreadPoolExecutor tpe) {
|
||||
return "ThreadPoolExecutor(core=" + tpe.getCorePoolSize() + ",max=" + tpe.getMaximumPoolSize() + ")";
|
||||
}
|
||||
if (exec instanceof ForkJoinPool fjp) {
|
||||
return "ForkJoinPool(parallelism=" + fjp.getParallelism() + ")";
|
||||
}
|
||||
return exec.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
private static Throwable unwrap(Throwable error) {
|
||||
return error != null && error.getCause() != null ? error.getCause() : error;
|
||||
}
|
||||
|
||||
private static boolean detectParallelChunkSystem() {
|
||||
String[] markers = {
|
||||
"com.ishland.c2me.base.ModProperties",
|
||||
"com.ishland.c2me.base.common.config.C2MEConfig",
|
||||
"com.ishland.c2me.opts.chunkio.ModProperties",
|
||||
"ca.spottedleaf.moonrise.common.util.MoonriseCommon"
|
||||
};
|
||||
for (String marker : markers) {
|
||||
try {
|
||||
Class.forName(marker, false, ModdedPregenMethod.class.getClassLoader());
|
||||
return true;
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mantle getMantle() {
|
||||
return engine.getMantle().getMantle();
|
||||
}
|
||||
|
||||
/**
|
||||
* A dedicated server with {@code pause-when-empty-seconds > 0} returns from
|
||||
* {@code MinecraftServer#tickServer} before {@code tickChildren} once it has been empty for that
|
||||
* long (26.2 only keeps {@code tickConnection} plus the task/chunk-poll window alive). That
|
||||
* freezes every per-tick Iris service - world manager, scheduler, protocol sync, pregen HUD - and
|
||||
* the loader's own generation hooks for the whole job, and console pregen on a default
|
||||
* server.properties is always empty. The guard zeroes the setting for the duration of the job
|
||||
* through the vanilla public accessors
|
||||
* ({@code DedicatedServer#pauseWhenEmptySeconds}/{@code #setPauseWhenEmptySeconds}, both widened
|
||||
* to public by Mojang for the management API) and restores the previous value on completion or
|
||||
* abort. No reflection and no access widener, identical on all three loaders. An integrated
|
||||
* (singleplayer) server never pauses on empty - {@code MinecraftServer#pauseWhenEmptySeconds}
|
||||
* returns 0 there - so it is skipped silently.
|
||||
*
|
||||
* <p>A crash or a kill during the job would otherwise leave the setting at 0 for the rest of the install,
|
||||
* so suspending also arms a JVM shutdown hook that restores the previous value. The hook and
|
||||
* {@link #restore()} share the same atomic, so whichever runs first wins and the other is a no-op; a
|
||||
* normal restore also unregisters the hook. The setting is only ever restored in memory - nothing rewrites
|
||||
* server.properties, so operator edits made during the job survive.
|
||||
*/
|
||||
private static final class PauseWhenEmptyGuard {
|
||||
private static final int NOT_SUSPENDED = -1;
|
||||
|
||||
private final MinecraftServer server;
|
||||
private final AtomicInteger suspendedFrom = new AtomicInteger(NOT_SUSPENDED);
|
||||
private final AtomicReference<Thread> crashRestoreHook = new AtomicReference<>();
|
||||
|
||||
private PauseWhenEmptyGuard(MinecraftServer server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
private void suspend() {
|
||||
if (!(server instanceof DedicatedServer dedicated)) {
|
||||
return;
|
||||
}
|
||||
int current;
|
||||
try {
|
||||
current = dedicated.pauseWhenEmptySeconds();
|
||||
} catch (Throwable e) {
|
||||
LOGGER.warn("Iris pregen could not read pause-when-empty-seconds: {}", e.toString());
|
||||
return;
|
||||
}
|
||||
if (current <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dedicated.setPauseWhenEmptySeconds(0);
|
||||
} catch (Throwable e) {
|
||||
refuse(current, e.toString());
|
||||
return;
|
||||
}
|
||||
int applied;
|
||||
try {
|
||||
applied = dedicated.pauseWhenEmptySeconds();
|
||||
} catch (Throwable e) {
|
||||
refuse(current, e.toString());
|
||||
return;
|
||||
}
|
||||
if (applied != 0) {
|
||||
refuse(current, "still " + applied + "s after the write");
|
||||
return;
|
||||
}
|
||||
suspendedFrom.set(current);
|
||||
armCrashRestore();
|
||||
LOGGER.info("Iris pregen: suspending pause-when-empty (was {}s), restored when the job ends", current);
|
||||
}
|
||||
|
||||
private void restore() {
|
||||
disarmCrashRestore();
|
||||
restoreOnce("restored");
|
||||
}
|
||||
|
||||
private void restoreOnce(String what) {
|
||||
int previous = suspendedFrom.getAndSet(NOT_SUSPENDED);
|
||||
if (previous == NOT_SUSPENDED || !(server instanceof DedicatedServer dedicated)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dedicated.setPauseWhenEmptySeconds(previous);
|
||||
LOGGER.info("Iris pregen: {} pause-when-empty ({}s)", what, previous);
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("Iris pregen could not restore pause-when-empty-seconds={}: {}. Set pause-when-empty-seconds={} in server.properties.",
|
||||
previous, e.toString(), previous);
|
||||
}
|
||||
}
|
||||
|
||||
private void armCrashRestore() {
|
||||
Thread hook = new Thread(() -> restoreOnce("restored on shutdown"), "iris-pregen-pause-restore");
|
||||
if (!crashRestoreHook.compareAndSet(null, hook)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Runtime.getRuntime().addShutdownHook(hook);
|
||||
} catch (IllegalStateException shuttingDown) {
|
||||
crashRestoreHook.compareAndSet(hook, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void disarmCrashRestore() {
|
||||
Thread hook = crashRestoreHook.getAndSet(null);
|
||||
if (hook == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Runtime.getRuntime().removeShutdownHook(hook);
|
||||
} catch (IllegalStateException shuttingDown) {
|
||||
// Already inside shutdown; the hook itself restores the value.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the server can still stop ticking under a running job.
|
||||
*/
|
||||
private boolean pauseStillArmed() {
|
||||
if (suspendedFrom.get() != NOT_SUSPENDED || !(server instanceof DedicatedServer dedicated)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return dedicated.pauseWhenEmptySeconds() > 0 && server.getPlayerCount() == 0;
|
||||
} catch (Throwable e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void logStallHint() {
|
||||
if (!pauseStillArmed()) {
|
||||
return;
|
||||
}
|
||||
LOGGER.error("Iris pregen is timing out on an empty server while pause-when-empty-seconds is active: the paused server stops ticking. Set pause-when-empty-seconds=0 in server.properties, or keep a player online while pregenerating.");
|
||||
}
|
||||
|
||||
private void refuse(int current, String reason) {
|
||||
LOGGER.error("Iris pregen could not suspend pause-when-empty-seconds={} ({}). The server stops ticking once empty, which stalls pregen: set pause-when-empty-seconds=0 in server.properties, or keep a player online while pregenerating.",
|
||||
current, reason);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FinalSaveRequest {
|
||||
private final CompletableFuture<Void> completion = new CompletableFuture<>();
|
||||
private final AtomicBoolean active = new AtomicBoolean(true);
|
||||
|
||||
+32
-2
@@ -58,6 +58,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@@ -66,6 +67,8 @@ import art.arcane.iris.core.localization.ModdedCommandMessages;
|
||||
public final class ModdedRegen {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final int APPLY_AHEAD = 8;
|
||||
private static final long CHUNK_SLOT_TIMEOUT_MILLIS = 120000L;
|
||||
private static final long FINAL_APPLY_TIMEOUT_MILLIS = 300000L;
|
||||
private static final AtomicBoolean ACTIVE = new AtomicBoolean(false);
|
||||
|
||||
private final CommandSourceStack source;
|
||||
@@ -139,6 +142,7 @@ public final class ModdedRegen {
|
||||
private int regenerate(List<int[]> targets) throws InterruptedException {
|
||||
Semaphore inFlight = new Semaphore(APPLY_AHEAD);
|
||||
CountDownLatch allApplied = new CountDownLatch(targets.size());
|
||||
AtomicBoolean aborted = new AtomicBoolean(false);
|
||||
AtomicInteger completed = new AtomicInteger();
|
||||
AtomicInteger applied = new AtomicInteger();
|
||||
int total = targets.size();
|
||||
@@ -149,9 +153,21 @@ public final class ModdedRegen {
|
||||
for (int[] target : targets) {
|
||||
int chunkX = target[0];
|
||||
int chunkZ = target[1];
|
||||
inFlight.acquire();
|
||||
if (!inFlight.tryAcquire(CHUNK_SLOT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) {
|
||||
aborted.set(true);
|
||||
LOGGER.error("Iris regen aborted: chunk {},{} waited {}ms for an apply slot ({}/{} done)",
|
||||
chunkX, chunkZ, CHUNK_SLOT_TIMEOUT_MILLIS, completed.get(), total);
|
||||
fail("Regen aborted: apply pipeline stalled at " + completed.get() + "/" + total + " chunk(s)");
|
||||
break;
|
||||
}
|
||||
MultiBurst.burst.lazy(() -> {
|
||||
long chunkStart = M.ms();
|
||||
if (aborted.get()) {
|
||||
completed.incrementAndGet();
|
||||
inFlight.release();
|
||||
allApplied.countDown();
|
||||
return;
|
||||
}
|
||||
ModdedBlockBuffer blocks = new ModdedBlockBuffer(height, air);
|
||||
Hunk<PlatformBiome> biomes = Hunk.newArrayHunk(16, height, 16);
|
||||
try {
|
||||
@@ -167,6 +183,9 @@ public final class ModdedRegen {
|
||||
server.execute(() -> {
|
||||
boolean success = false;
|
||||
try {
|
||||
if (aborted.get()) {
|
||||
return;
|
||||
}
|
||||
apply(chunkX, chunkZ, blocks, biomes);
|
||||
success = true;
|
||||
applied.incrementAndGet();
|
||||
@@ -185,7 +204,18 @@ public final class ModdedRegen {
|
||||
});
|
||||
}
|
||||
|
||||
allApplied.await();
|
||||
if (aborted.get()) {
|
||||
// Targets were never submitted, so the latch can no longer reach zero; in-flight tasks
|
||||
// observe the abort flag and release themselves.
|
||||
return applied.get();
|
||||
}
|
||||
if (!allApplied.await(FINAL_APPLY_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) {
|
||||
aborted.set(true);
|
||||
long outstanding = allApplied.getCount();
|
||||
LOGGER.error("Iris regen aborted: {} of {} chunk(s) did not finish within {}ms",
|
||||
outstanding, total, FINAL_APPLY_TIMEOUT_MILLIS);
|
||||
fail("Regen aborted: " + outstanding + " of " + total + " chunk(s) never finished");
|
||||
}
|
||||
return applied.get();
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -29,6 +29,7 @@ import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import art.arcane.iris.modded.ModdedModConfig;
|
||||
import art.arcane.iris.modded.ModdedPackInstaller;
|
||||
import art.arcane.iris.modded.ModdedPrimaryWorldRouter;
|
||||
import art.arcane.iris.modded.ModdedServerLevels;
|
||||
import art.arcane.iris.modded.ModdedStartup;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
@@ -436,7 +437,7 @@ public final class ModdedWorldCommands {
|
||||
private static int status(CommandSourceStack source) {
|
||||
MinecraftServer server = source.getServer();
|
||||
int loaded = 0;
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) {
|
||||
loaded++;
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_LOADED_IRIS_LEVEL_PACK_DIMENSION, MessageArgument.untrusted("value", level.dimension().identifier()), MessageArgument.untrusted("value2", generator.activePack()), MessageArgument.untrusted("value3", generator.activeDimensionKey())));
|
||||
@@ -466,7 +467,7 @@ public final class ModdedWorldCommands {
|
||||
|
||||
private static List<String> loadedIrisDimensions(MinecraftServer server) {
|
||||
List<String> dimensions = new ArrayList<>();
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) {
|
||||
dimensions.add(level.dimension().identifier().toString());
|
||||
}
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.modded.mixin;
|
||||
|
||||
import art.arcane.iris.modded.ModdedEntityPersistence;
|
||||
import art.arcane.iris.modded.ModdedMixinFlags;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
@@ -11,6 +12,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
public abstract class EntityPersistenceMixin {
|
||||
@Inject(method = "shouldBeSaved", at = @At("RETURN"), cancellable = true)
|
||||
private void iris$applyGeneratedPersistence(CallbackInfoReturnable<Boolean> info) {
|
||||
ModdedMixinFlags.markEntityPersistence();
|
||||
Entity entity = (Entity) (Object) this;
|
||||
info.setReturnValue(ModdedEntityPersistence.shouldSave(entity, info.getReturnValue()));
|
||||
}
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.modded.mixin;
|
||||
|
||||
import art.arcane.iris.modded.ModdedDeathLoot;
|
||||
import art.arcane.iris.modded.ModdedMixinFlags;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.damagesource.DamageSource;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
@@ -13,6 +14,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
public abstract class LivingEntityLootMixin {
|
||||
@Inject(method = "dropFromLootTable(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/damagesource/DamageSource;Z)V", at = @At("HEAD"), cancellable = true)
|
||||
private void iris$replaceBaseLoot(ServerLevel level, DamageSource damageSource, boolean playerKilled, CallbackInfo info) {
|
||||
ModdedMixinFlags.markLivingEntityLoot();
|
||||
if (ModdedDeathLoot.replaceBaseLoot((LivingEntity) (Object) this)) {
|
||||
info.cancel();
|
||||
}
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.modded.mixin;
|
||||
|
||||
import art.arcane.iris.modded.ModdedEntityAwareness;
|
||||
import art.arcane.iris.modded.ModdedMixinFlags;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.minecraft.world.entity.ai.goal.FloatGoal;
|
||||
import net.minecraft.world.entity.ai.goal.WrappedGoal;
|
||||
@@ -21,6 +22,7 @@ public abstract class MobAwarenessMixin {
|
||||
shift = At.Shift.AFTER),
|
||||
cancellable = true)
|
||||
private void iris$tickUnawareMob(CallbackInfo info) {
|
||||
ModdedMixinFlags.markMobAwareness();
|
||||
Mob mob = (Mob) (Object) this;
|
||||
if (ModdedEntityAwareness.isAware(mob)) {
|
||||
return;
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ import art.arcane.iris.engine.object.TileData;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.iris.modded.ModdedBlockResolution;
|
||||
import art.arcane.iris.modded.ModdedLootApplier;
|
||||
import art.arcane.iris.modded.ModdedServerLevels;
|
||||
import art.arcane.iris.modded.ModdedTileData;
|
||||
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
@@ -100,7 +101,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
|
||||
return;
|
||||
}
|
||||
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
+2
-1
@@ -21,6 +21,7 @@ package art.arcane.iris.modded.service;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineWorldManager;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.iris.modded.ModdedServerLevels;
|
||||
import art.arcane.iris.modded.ModdedWorldManager;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
@@ -37,7 +38,7 @@ public final class ModdedEntitySpawnService implements ModdedTickableService {
|
||||
|
||||
@Override
|
||||
public void onServerTick(MinecraftServer server) {
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -2,7 +2,7 @@
|
||||
"required": true,
|
||||
"minVersion": "0.8",
|
||||
"package": "art.arcane.iris.client.mixin",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"compatibilityLevel": "JAVA_21",
|
||||
"client": [
|
||||
"IrisWorldOpenFlowsMixin",
|
||||
"IrisWorldTypeEntryMixin"
|
||||
|
||||
+51
@@ -53,5 +53,56 @@ public class IrisClientSessionTest {
|
||||
IrisProtocol.PROTOCOL_VERSION + 1, 0L, "Iris", true));
|
||||
|
||||
assertEquals(IrisClientSession.State.INCOMPATIBLE, session.state());
|
||||
assertEquals(IrisProtocol.PROTOCOL_VERSION + 1, session.serverProtocolVersion());
|
||||
assertEquals("Iris", session.serverBrand());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void helloWithNoSinkStillRetriesAndResolvesToUnsupported() {
|
||||
AtomicLong clock = new AtomicLong();
|
||||
IrisClientSession session = new IrisClientSession(clock::get);
|
||||
|
||||
// No bind: the loader has not wired the channel yet. Without arming the retry clock on this path,
|
||||
// nextHelloAt stayed at Long.MAX_VALUE, tick() returned immediately forever and the UI never left
|
||||
// "connecting".
|
||||
session.sendHello();
|
||||
assertEquals(IrisClientSession.State.AWAITING_HELLO, session.state());
|
||||
|
||||
for (int attempt = 0; attempt < 5; attempt++) {
|
||||
clock.addAndGet(2_000L);
|
||||
session.tick();
|
||||
}
|
||||
|
||||
assertEquals(IrisClientSession.State.UNSUPPORTED, session.state());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sinkBoundLateStillCompletesTheHandshake() {
|
||||
AtomicLong clock = new AtomicLong();
|
||||
AtomicInteger frames = new AtomicInteger();
|
||||
IrisClientSession session = new IrisClientSession(clock::get);
|
||||
|
||||
session.sendHello();
|
||||
assertEquals(0, frames.get());
|
||||
|
||||
session.bind(frame -> frames.incrementAndGet());
|
||||
clock.addAndGet(2_000L);
|
||||
session.tick();
|
||||
|
||||
assertEquals(1, frames.get());
|
||||
assertEquals(IrisClientSession.State.AWAITING_HELLO, session.state());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resetClearsTheServerVersion() {
|
||||
IrisClientSession session = new IrisClientSession();
|
||||
session.onServerHello(new IrisMessage.ServerHello(
|
||||
IrisProtocol.PROTOCOL_VERSION, IrisProtocol.CAPABILITY_VISION, "Iris", true));
|
||||
assertTrue(session.isReady());
|
||||
|
||||
session.reset();
|
||||
|
||||
assertEquals(IrisClientSession.State.IDLE, session.state());
|
||||
assertEquals(0, session.serverProtocolVersion());
|
||||
}
|
||||
}
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package art.arcane.iris.client;
|
||||
|
||||
import art.arcane.iris.core.protocol.IrisTileEncoder;
|
||||
import art.arcane.iris.spi.protocol.IrisMessage;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Every structure here is filled from the wire, so its size is a dial the server holds. Caps mean a hostile or
|
||||
* merely buggy server can waste bandwidth but not the client's heap.
|
||||
*/
|
||||
public class IrisClientWireBoundsTest {
|
||||
@Test
|
||||
public void markerTilesBeyondTheCapEvictTheOldest() {
|
||||
IrisClientMarkers markers = new IrisClientMarkers();
|
||||
int overflow = IrisClientMarkers.MAX_TILES + 8;
|
||||
for (int tileX = 0; tileX < overflow; tileX++) {
|
||||
markers.onMarkers(new IrisMessage.VisionMarkers(tileX, 0, 0,
|
||||
List.of(new IrisMessage.VisionMarkers.Marker(tileX, 0, 0, "m" + tileX))));
|
||||
}
|
||||
|
||||
assertEquals(IrisClientMarkers.MAX_TILES, markers.trackedTiles());
|
||||
assertNull("the oldest marker tile must be gone", markers.forTile(new IrisTileKey(0, 0, 0)));
|
||||
assertNotNull("the newest marker tile must be retained", markers.forTile(new IrisTileKey(overflow - 1, 0, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void markerTileIsReplacedNotAccumulated() {
|
||||
IrisClientMarkers markers = new IrisClientMarkers();
|
||||
IrisTileKey key = new IrisTileKey(4, 5, 1);
|
||||
markers.onMarkers(new IrisMessage.VisionMarkers(4, 5, 1,
|
||||
List.of(new IrisMessage.VisionMarkers.Marker(1, 1, 0, "first"),
|
||||
new IrisMessage.VisionMarkers.Marker(2, 2, 0, "second"))));
|
||||
markers.onMarkers(new IrisMessage.VisionMarkers(4, 5, 1,
|
||||
List.of(new IrisMessage.VisionMarkers.Marker(3, 3, 0, "third"))));
|
||||
|
||||
assertEquals(1, markers.forTile(key).size());
|
||||
assertEquals(1, markers.trackedTiles());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pregenJobsBeyondTheCapEvictTheOldest() {
|
||||
IrisClientPregenState pregen = new IrisClientPregenState();
|
||||
int overflow = IrisClientPregenState.MAX_JOBS + 5;
|
||||
for (int jobId = 0; jobId < overflow; jobId++) {
|
||||
pregen.onProgress(progress(jobId));
|
||||
}
|
||||
|
||||
assertEquals(IrisClientPregenState.MAX_JOBS, pregen.trackedJobs());
|
||||
assertEquals(Long.valueOf(overflow - 1L), pregen.activeJobId());
|
||||
assertNotNull(pregen.active());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pregenPanelGoesStaleThenExpiresWithoutUpdates() {
|
||||
AtomicLong clock = new AtomicLong(10_000L);
|
||||
IrisClientPregenState pregen = new IrisClientPregenState(clock::get);
|
||||
pregen.onProgress(progress(1L));
|
||||
|
||||
assertEquals(0L, pregen.activeAgeMillis());
|
||||
assertFalse(pregen.activeStale());
|
||||
assertFalse(pregen.activeExpired());
|
||||
|
||||
clock.addAndGet(IrisClientPregenState.STALE_AFTER_MILLIS);
|
||||
assertTrue(pregen.activeStale());
|
||||
assertFalse(pregen.activeExpired());
|
||||
|
||||
clock.addAndGet(IrisClientPregenState.EXPIRE_AFTER_MILLIS);
|
||||
assertTrue(pregen.activeExpired());
|
||||
|
||||
pregen.onProgress(progress(1L));
|
||||
assertFalse("a fresh frame must revive the panel", pregen.activeStale());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void noActiveJobIsNeitherStaleNorExpired() {
|
||||
AtomicLong clock = new AtomicLong(0L);
|
||||
IrisClientPregenState pregen = new IrisClientPregenState(clock::get);
|
||||
|
||||
assertEquals(-1L, pregen.activeAgeMillis());
|
||||
assertFalse(pregen.activeStale());
|
||||
assertFalse(pregen.activeExpired());
|
||||
|
||||
pregen.onProgress(progress(7L));
|
||||
pregen.onEnd(7L);
|
||||
|
||||
assertNull(pregen.active());
|
||||
assertEquals(-1L, pregen.activeAgeMillis());
|
||||
assertFalse(pregen.activeExpired());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tileCacheKeepsTheDefaultBudgetForASmallViewport() {
|
||||
assertEquals(IrisClientTileCache.DEFAULT_MAX_CACHED_TILES, retainedTiles(4));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tileCacheGrowsForALargeViewportButNotPastTheCeiling() {
|
||||
assertEquals(IrisClientTileCache.ABSOLUTE_MAX_CACHED_TILES,
|
||||
retainedTiles(IrisClientTileCache.ABSOLUTE_MAX_CACHED_TILES * 4));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedTileIsDroppedAndCountedWithoutPoisoningTheCache() {
|
||||
IrisClientTileCache cache = new IrisClientTileCache(frame -> {
|
||||
}, () -> 0L);
|
||||
byte[] garbage = new byte[32];
|
||||
Arrays.fill(garbage, (byte) 0x7F);
|
||||
|
||||
cache.onVisionTile(new IrisMessage.VisionTile(0, 0, 0, 1, 0, 1, garbage));
|
||||
|
||||
assertEquals(1L, cache.droppedMalformedCount());
|
||||
assertNull(cache.get(new IrisTileKey(0, 0, 0)));
|
||||
|
||||
cache.onVisionTile(flatTile(0));
|
||||
assertNotNull("a bad frame must not block later tiles", cache.get(new IrisTileKey(0, 0, 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Capacity is not exposed, so it is measured the only way that matters: how many tiles survive. Tiles go in
|
||||
* through the real wire path, so each one is a decoded image, not a stub.
|
||||
*/
|
||||
private static int retainedTiles(int viewportTiles) {
|
||||
IrisClientTileCache cache = new IrisClientTileCache(frame -> {
|
||||
}, () -> 0L);
|
||||
cache.ensureCapacity(viewportTiles);
|
||||
int expected = Math.max(IrisClientTileCache.DEFAULT_MAX_CACHED_TILES,
|
||||
Math.min(IrisClientTileCache.ABSOLUTE_MAX_CACHED_TILES, viewportTiles));
|
||||
int inserted = expected + 8;
|
||||
for (int tileX = 0; tileX < inserted; tileX++) {
|
||||
cache.onVisionTile(flatTile(tileX));
|
||||
}
|
||||
int retained = 0;
|
||||
for (int tileX = 0; tileX < inserted; tileX++) {
|
||||
if (cache.get(new IrisTileKey(tileX, 0, 0)) != null) {
|
||||
retained++;
|
||||
}
|
||||
}
|
||||
return retained;
|
||||
}
|
||||
|
||||
private static IrisMessage.VisionTile flatTile(int tileX) {
|
||||
int[] pixels = new int[4];
|
||||
Arrays.fill(pixels, 0xFF102030);
|
||||
return new IrisMessage.VisionTile(tileX, 0, 0, 1, 0, 1, IrisTileEncoder.encodePixels(pixels, 2, 2));
|
||||
}
|
||||
|
||||
private static IrisMessage.PregenProgress progress(long jobId) {
|
||||
return new IrisMessage.PregenProgress(jobId, 10L, 100L, 5.0D, 1000L, IrisMessage.PregenProgress.STATE_RUNNING);
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
package art.arcane.iris.client;
|
||||
|
||||
import art.arcane.iris.core.protocol.IrisTileEncoder;
|
||||
import art.arcane.iris.spi.protocol.IrisMessage;
|
||||
import art.arcane.iris.spi.protocol.ProtocolException;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.zip.Deflater;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
/**
|
||||
* The tile stream is attacker-controlled: any server a player joins picks the sequence, chunk index, chunk
|
||||
* count and payload. Structurally impossible headers must be dropped without allocating from them, a complete
|
||||
* set that decodes to garbage must raise {@link ProtocolException} rather than return a half-built image, and a
|
||||
* deflate stream that cannot make progress must not spin the render thread.
|
||||
*/
|
||||
public class IrisTileAssemblerAdversarialTest {
|
||||
private static final int SIZE = IrisTileEncoder.TILE_PIXELS;
|
||||
private static final byte[] PAYLOAD = {1, 2, 3, 4};
|
||||
|
||||
@Test
|
||||
public void impossibleChunkHeadersAreDropped() throws ProtocolException {
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
assertNull("zero chunk count", assembler.add(tile(0, 0, 1, 0, 0, PAYLOAD)));
|
||||
assertNull("negative chunk count", assembler.add(tile(0, 0, 1, 0, -1, PAYLOAD)));
|
||||
assertNull("chunk count beyond the largest legal tile",
|
||||
assembler.add(tile(0, 0, 1, 0, IrisTileAssembler.MAX_CHUNK_COUNT + 1, PAYLOAD)));
|
||||
assertNull("index at count", assembler.add(tile(0, 0, 1, 2, 2, PAYLOAD)));
|
||||
assertNull("index beyond count", assembler.add(tile(0, 0, 1, 9, 2, PAYLOAD)));
|
||||
assertNull("negative index", assembler.add(tile(0, 0, 1, -1, 2, PAYLOAD)));
|
||||
assertNull("missing payload", assembler.add(tile(0, 0, 1, 0, 1, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectedHeaderLeavesNoPartialBehind() throws ProtocolException {
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
assertNull(assembler.add(tile(0, 0, 1, 5, 2, PAYLOAD)));
|
||||
|
||||
List<IrisMessage.VisionTile> good = IrisTileEncoder.splitIntoChunks(validBlob(), 0, 0, 0, 1);
|
||||
assertNotNull("a rejected frame must not poison the tile slot",
|
||||
IrisVisionTileRoundTripTest.assemble(assembler, good));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chunkCountFlipMidSetRestartsTheSet() throws ProtocolException {
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
// Two chunks promised at sequence 4, then the same sequence claims a single-chunk set. The stale
|
||||
// half-set must be dropped rather than concatenated into the new one.
|
||||
assertNull(assembler.add(tile(0, 0, 4, 0, 2, PAYLOAD)));
|
||||
|
||||
List<IrisMessage.VisionTile> single = IrisTileEncoder.splitIntoChunks(validBlob(), 0, 0, 0, 4);
|
||||
assertEquals("a flat tile must deflate into a single chunk", 1, single.size());
|
||||
assertNotNull(assembler.add(single.get(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void staleSequenceChunksAreIgnoredAndTheNewerSetStillCompletes() throws ProtocolException {
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
byte[] blob = validBlob();
|
||||
byte[] head = Arrays.copyOfRange(blob, 0, blob.length / 2);
|
||||
byte[] tail = Arrays.copyOfRange(blob, blob.length / 2, blob.length);
|
||||
|
||||
assertNull(assembler.add(tile(0, 0, 9, 0, 2, head)));
|
||||
assertNull("a chunk from an older sequence must not join the current set",
|
||||
assembler.add(tile(0, 0, 8, 1, 2, tail)));
|
||||
assertNotNull(assembler.add(tile(0, 0, 9, 1, 2, tail)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partialsBeyondTheCapAreEvicted() throws ProtocolException {
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
byte[] blob = validBlob();
|
||||
byte[] head = Arrays.copyOfRange(blob, 0, blob.length / 2);
|
||||
byte[] tail = Arrays.copyOfRange(blob, blob.length / 2, blob.length);
|
||||
|
||||
int overflow = IrisTileAssembler.MAX_PENDING_TILES + 1;
|
||||
for (int tileX = 0; tileX < overflow; tileX++) {
|
||||
assertNull(assembler.add(tile(tileX, 0, 1, 0, 2, head)));
|
||||
}
|
||||
// Tile 0 was the oldest partial and is gone, so its closing chunk opens a fresh incomplete set
|
||||
// instead of finishing one. Retaining every partial forever would be the alternative.
|
||||
assertNull("the oldest partial must have been evicted", assembler.add(tile(0, 0, 1, 1, 2, tail)));
|
||||
assertNotNull("the newest partial must have survived", assembler.add(tile(overflow - 1, 0, 1, 1, 2, tail)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void garbagePayloadRaisesProtocolException() {
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
byte[] garbage = new byte[64];
|
||||
Arrays.fill(garbage, (byte) 0x7F);
|
||||
assertThrows(ProtocolException.class, () -> assembler.add(tile(0, 0, 1, 0, 1, garbage)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void truncatedDeflateStreamRaisesProtocolExceptionInsteadOfSpinning() {
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
byte[] noisy = new byte[60_000];
|
||||
new Random(4242L).nextBytes(noisy);
|
||||
byte[] full = deflate(noisy);
|
||||
byte[] truncated = Arrays.copyOfRange(full, 0, full.length / 4);
|
||||
// Zero-progress inflater: input exhausted, stream not finished. The old loop only broke out when
|
||||
// finished/needsInput/needsDictionary happened to be set, so this shape spun forever on the render
|
||||
// thread.
|
||||
assertThrows(ProtocolException.class, () -> assembler.add(tile(0, 0, 1, 0, 1, truncated)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decompressionBombRaisesProtocolException() {
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
byte[] bomb = deflate(new byte[IrisTileCodec.MAX_DECODED_BYTES + 1024]);
|
||||
assertThrows(ProtocolException.class, () -> assembler.add(tile(0, 0, 1, 0, 1, bomb)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void impossibleHeaderFieldsRaiseProtocolException() {
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
assertThrows("zero width", ProtocolException.class,
|
||||
() -> assembler.add(tile(0, 0, 1, 0, 1, deflate(header(0, SIZE, IrisTileCodec.MODE_RAW_RGB)))));
|
||||
assertThrows("oversized height", ProtocolException.class,
|
||||
() -> assembler.add(tile(1, 0, 1, 0, 1, deflate(header(SIZE, 4096, IrisTileCodec.MODE_RAW_RGB)))));
|
||||
assertThrows("unknown pixel mode", ProtocolException.class,
|
||||
() -> assembler.add(tile(2, 0, 1, 0, 1, deflate(header(SIZE, SIZE, 42)))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void paletteIndexBeyondPaletteRaisesProtocolException() {
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
ByteArrayOutputStream raw = new ByteArrayOutputStream();
|
||||
try (DataOutputStream out = new DataOutputStream(raw)) {
|
||||
out.writeInt(2);
|
||||
out.writeInt(2);
|
||||
out.writeByte(IrisTileCodec.MODE_PALETTE);
|
||||
out.writeInt(1);
|
||||
out.writeByte(0);
|
||||
out.writeByte(0);
|
||||
out.writeByte(0);
|
||||
for (int pixel = 0; pixel < 4; pixel++) {
|
||||
out.writeByte(pixel == 3 ? 200 : 0);
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new UncheckedIOException(failure);
|
||||
}
|
||||
byte[] blob = deflate(raw.toByteArray());
|
||||
assertThrows(ProtocolException.class, () -> assembler.add(tile(0, 0, 1, 0, 1, blob)));
|
||||
}
|
||||
|
||||
private static IrisMessage.VisionTile tile(int tileX, int tileZ, int sequence, int chunkIndex, int chunkCount, byte[] data) {
|
||||
return new IrisMessage.VisionTile(tileX, tileZ, 0, sequence, chunkIndex, chunkCount, data);
|
||||
}
|
||||
|
||||
/** A real encoder output for a flat tile: one chunk, palette mode, decodes cleanly. */
|
||||
private static byte[] validBlob() {
|
||||
int[] pixels = new int[SIZE * SIZE];
|
||||
Arrays.fill(pixels, 0xFF204060);
|
||||
return IrisTileEncoder.encodePixels(pixels, SIZE, SIZE);
|
||||
}
|
||||
|
||||
private static byte[] header(int width, int height, int mode) {
|
||||
ByteArrayOutputStream raw = new ByteArrayOutputStream();
|
||||
try (DataOutputStream out = new DataOutputStream(raw)) {
|
||||
out.writeInt(width);
|
||||
out.writeInt(height);
|
||||
out.writeByte(mode);
|
||||
} catch (IOException failure) {
|
||||
throw new UncheckedIOException(failure);
|
||||
}
|
||||
return raw.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] deflate(byte[] input) {
|
||||
Deflater deflater = new Deflater(Deflater.BEST_SPEED);
|
||||
deflater.setInput(input);
|
||||
deflater.finish();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(64, input.length / 2));
|
||||
byte[] buffer = new byte[8192];
|
||||
while (!deflater.finished()) {
|
||||
out.write(buffer, 0, deflater.deflate(buffer));
|
||||
}
|
||||
deflater.end();
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package art.arcane.iris.client;
|
||||
|
||||
import art.arcane.iris.core.protocol.IrisTileEncoder;
|
||||
import art.arcane.iris.spi.protocol.IrisMessage;
|
||||
import art.arcane.iris.spi.protocol.IrisMessageCodec;
|
||||
import art.arcane.iris.spi.protocol.IrisProtocol;
|
||||
import art.arcane.iris.spi.protocol.ProtocolException;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* End-to-end vision tile path: {@link IrisTileEncoder} on the server, the wire codec both ways, then
|
||||
* {@link IrisTileAssembler} and {@link IrisTileCodec} on the client. Encoder and decoder live in different
|
||||
* modules and share no code, so pixel equality is the only thing that proves they still agree - a palette
|
||||
* write order change or a header field added on one side is otherwise silent.
|
||||
*/
|
||||
public class IrisVisionTileRoundTripTest {
|
||||
private static final int SIZE = IrisTileEncoder.TILE_PIXELS;
|
||||
private static final int OPAQUE = 0xFF000000;
|
||||
|
||||
@Test
|
||||
public void paletteTileRoundTripsToIdenticalPixels() throws ProtocolException {
|
||||
int[] pixels = bandedPixels(16);
|
||||
IrisTileImage decoded = roundTrip(pixels, 3, -4, 2, 7);
|
||||
assertNotNull(decoded);
|
||||
assertEquals(SIZE, decoded.width());
|
||||
assertEquals(SIZE, decoded.height());
|
||||
assertOpaqueRgbEquals(pixels, decoded.argb());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rawTileSplitsIntoChunksAndRoundTrips() throws ProtocolException {
|
||||
int[] pixels = highEntropyPixels();
|
||||
byte[] blob = IrisTileEncoder.encodePixels(pixels, SIZE, SIZE);
|
||||
List<IrisMessage.VisionTile> chunks = IrisTileEncoder.splitIntoChunks(blob, 0, 0, 0, 1);
|
||||
assertTrue("a high-entropy tile must exceed one chunk to exercise reassembly", chunks.size() > 1);
|
||||
for (IrisMessage.VisionTile chunk : chunks) {
|
||||
assertTrue("chunk frame must fit the wire cap",
|
||||
IrisMessageCodec.encode(chunk).length <= IrisProtocol.MAX_FRAME_BYTES);
|
||||
}
|
||||
|
||||
IrisTileImage decoded = assemble(new IrisTileAssembler(), overTheWire(chunks));
|
||||
assertNotNull(decoded);
|
||||
assertOpaqueRgbEquals(pixels, decoded.argb());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chunksReassembleWhenDeliveredOutOfOrder() throws ProtocolException {
|
||||
int[] pixels = highEntropyPixels();
|
||||
byte[] blob = IrisTileEncoder.encodePixels(pixels, SIZE, SIZE);
|
||||
List<IrisMessage.VisionTile> chunks = new ArrayList<>(overTheWire(IrisTileEncoder.splitIntoChunks(blob, 5, 6, 1, 42)));
|
||||
assertTrue(chunks.size() > 1);
|
||||
Collections.reverse(chunks);
|
||||
|
||||
IrisTileImage decoded = assemble(new IrisTileAssembler(), chunks);
|
||||
assertNotNull(decoded);
|
||||
assertOpaqueRgbEquals(pixels, decoded.argb());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void incompleteChunkSetProducesNothing() throws ProtocolException {
|
||||
int[] pixels = highEntropyPixels();
|
||||
byte[] blob = IrisTileEncoder.encodePixels(pixels, SIZE, SIZE);
|
||||
List<IrisMessage.VisionTile> chunks = overTheWire(IrisTileEncoder.splitIntoChunks(blob, 1, 1, 0, 1));
|
||||
assertTrue(chunks.size() > 1);
|
||||
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
assertNull(assemble(assembler, chunks.subList(0, chunks.size() - 1)));
|
||||
assertNotNull(assembler.add(chunks.get(chunks.size() - 1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedChunkDoesNotCompleteTheSetEarly() throws ProtocolException {
|
||||
int[] pixels = highEntropyPixels();
|
||||
byte[] blob = IrisTileEncoder.encodePixels(pixels, SIZE, SIZE);
|
||||
List<IrisMessage.VisionTile> chunks = overTheWire(IrisTileEncoder.splitIntoChunks(blob, 2, 2, 0, 1));
|
||||
assertTrue(chunks.size() > 1);
|
||||
|
||||
IrisTileAssembler assembler = new IrisTileAssembler();
|
||||
for (int repeat = 0; repeat < chunks.size() + 2; repeat++) {
|
||||
assertNull("a duplicated chunk must not count twice", assembler.add(chunks.get(0)));
|
||||
}
|
||||
}
|
||||
|
||||
static IrisTileImage assemble(IrisTileAssembler assembler, List<IrisMessage.VisionTile> chunks) throws ProtocolException {
|
||||
IrisTileImage assembled = null;
|
||||
for (IrisMessage.VisionTile chunk : chunks) {
|
||||
IrisTileImage produced = assembler.add(chunk);
|
||||
if (produced != null) {
|
||||
assembled = produced;
|
||||
}
|
||||
}
|
||||
return assembled;
|
||||
}
|
||||
|
||||
/** Pushes each chunk through encode/decode so the test covers the wire form, not just the record. */
|
||||
private static List<IrisMessage.VisionTile> overTheWire(List<IrisMessage.VisionTile> chunks) {
|
||||
List<IrisMessage.VisionTile> transported = new ArrayList<>(chunks.size());
|
||||
for (IrisMessage.VisionTile chunk : chunks) {
|
||||
try {
|
||||
transported.add((IrisMessage.VisionTile) IrisMessageCodec.decode(IrisMessageCodec.encode(chunk)));
|
||||
} catch (ProtocolException rejected) {
|
||||
throw new AssertionError("a chunk the encoder produced must survive the codec", rejected);
|
||||
}
|
||||
}
|
||||
return transported;
|
||||
}
|
||||
|
||||
private static IrisTileImage roundTrip(int[] pixels, int tileX, int tileZ, int zoom, int sequence) throws ProtocolException {
|
||||
byte[] blob = IrisTileEncoder.encodePixels(pixels, SIZE, SIZE);
|
||||
return assemble(new IrisTileAssembler(), overTheWire(IrisTileEncoder.splitIntoChunks(blob, tileX, tileZ, zoom, sequence)));
|
||||
}
|
||||
|
||||
private static void assertOpaqueRgbEquals(int[] source, int[] decoded) {
|
||||
assertEquals(source.length, decoded.length);
|
||||
for (int index = 0; index < source.length; index++) {
|
||||
assertEquals("pixel " + index, OPAQUE | source[index] & 0xFFFFFF, decoded[index]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Few enough distinct colours that the encoder picks palette mode. */
|
||||
private static int[] bandedPixels(int colors) {
|
||||
int[] pixels = new int[SIZE * SIZE];
|
||||
for (int index = 0; index < pixels.length; index++) {
|
||||
int band = index % colors;
|
||||
pixels[index] = OPAQUE | band * 16 << 16 | band * 8 << 8 | band * 4;
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
/** Enough distinct colours to force raw mode, and enough entropy that deflate cannot fold it into one chunk. */
|
||||
private static int[] highEntropyPixels() {
|
||||
Random random = new Random(1337L);
|
||||
int[] pixels = new int[SIZE * SIZE];
|
||||
for (int index = 0; index < pixels.length; index++) {
|
||||
pixels[index] = OPAQUE | random.nextInt() & 0xFFFFFF;
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
}
|
||||
+17
@@ -105,6 +105,23 @@ public class InitialSpawnQueueTest {
|
||||
assertEquals(Long.valueOf(3L), queue.poll());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expireKeepsYoungerOffersWhenReleasingCapacity() {
|
||||
AtomicLong now = new AtomicLong();
|
||||
InitialSpawnQueue queue = new InitialSpawnQueue(2, 100L, now::get);
|
||||
queue.offer(1L);
|
||||
now.set(60L);
|
||||
queue.offer(2L);
|
||||
|
||||
now.set(100L);
|
||||
|
||||
assertTrue(queue.offer(3L));
|
||||
assertEquals(2, queue.size());
|
||||
assertEquals(Long.valueOf(2L), queue.poll());
|
||||
assertEquals(Long.valueOf(3L), queue.poll());
|
||||
assertNull(queue.poll());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expiredInFlightEntryCannotRetry() {
|
||||
AtomicLong now = new AtomicLong();
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class MainWorldServiceInstanceRootTest {
|
||||
@Test
|
||||
public void universeOptionIsReadInBothArgumentForms() {
|
||||
assertEquals("worlds", MainWorldService.commandLineOption(
|
||||
List.of("nogui", "--universe", "worlds"), "universe"));
|
||||
assertEquals("worlds", MainWorldService.commandLineOption(
|
||||
List.of("--universe=worlds", "nogui"), "universe"));
|
||||
assertEquals("survival", MainWorldService.commandLineOption(
|
||||
List.of("--universe", "worlds", "--world", "survival"), "world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingOrValuelessOptionsResolveToNull() {
|
||||
assertNull(MainWorldService.commandLineOption(List.of("nogui"), "universe"));
|
||||
assertNull(MainWorldService.commandLineOption(List.of(), "world"));
|
||||
assertNull(MainWorldService.commandLineOption(List.of("nogui", "--world"), "world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void universeRootDefaultsToTheInstanceRootAndHonorsTheOption() throws IOException {
|
||||
Path instanceRoot = Files.createTempDirectory("iris-instance");
|
||||
Path elsewhere = Files.createTempDirectory("iris-elsewhere");
|
||||
|
||||
assertEquals(instanceRoot, MainWorldService.universeRoot(instanceRoot, null));
|
||||
assertEquals(instanceRoot, MainWorldService.universeRoot(instanceRoot, ""));
|
||||
assertEquals(instanceRoot.resolve("worlds"), MainWorldService.universeRoot(instanceRoot, "worlds"));
|
||||
assertEquals(elsewhere, MainWorldService.universeRoot(instanceRoot, elsewhere.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void worldRootResolvesInsideTheUniverse() throws IOException {
|
||||
Path universe = Files.createTempDirectory("iris-universe");
|
||||
Path world = Files.createDirectory(universe.resolve("survival"));
|
||||
|
||||
assertEquals(world.toAbsolutePath().normalize(),
|
||||
MainWorldService.resolveWorldRoot(universe, "survival"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void worldRootRefusesToEscapeTheUniverse() throws IOException {
|
||||
Path universe = Files.createTempDirectory("iris-universe");
|
||||
Files.createDirectory(universe.resolve("survival"));
|
||||
|
||||
IOException escaped = assertThrows(IOException.class,
|
||||
() -> MainWorldService.resolveWorldRoot(universe, "../survival"));
|
||||
IOException empty = assertThrows(IOException.class,
|
||||
() -> MainWorldService.resolveWorldRoot(universe, "."));
|
||||
|
||||
assertTrue(escaped.getMessage().contains("Unsafe world name"));
|
||||
assertTrue(empty.getMessage().contains("Unsafe world name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void worldRootReportsMissingDirectoriesAsTheRecoverableCase() throws IOException {
|
||||
Path universe = Files.createTempDirectory("iris-universe");
|
||||
|
||||
MainWorldService.MissingWorldRootException missingWorld = assertThrows(
|
||||
MainWorldService.MissingWorldRootException.class,
|
||||
() -> MainWorldService.resolveWorldRoot(universe, "survival"));
|
||||
MainWorldService.MissingWorldRootException missingUniverse = assertThrows(
|
||||
MainWorldService.MissingWorldRootException.class,
|
||||
() -> MainWorldService.resolveWorldRoot(universe.resolve("absent"), "survival"));
|
||||
|
||||
assertTrue(missingWorld.getMessage().contains("world directory does not exist"));
|
||||
assertEquals(universe.resolve("survival").toAbsolutePath().normalize(), missingWorld.path());
|
||||
assertTrue(missingUniverse.getMessage().contains("universe directory does not exist"));
|
||||
assertEquals(universe.resolve("absent"), missingUniverse.path());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unsafeWorldNameIsNotTreatedAsAMissingWorldRoot() throws IOException {
|
||||
Path universe = Files.createTempDirectory("iris-universe");
|
||||
|
||||
IOException escaped = assertThrows(IOException.class,
|
||||
() -> MainWorldService.resolveWorldRoot(universe, "../survival"));
|
||||
|
||||
assertFalse(escaped instanceof MainWorldService.MissingWorldRootException);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.spi.PlatformBiome;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedBiomeWriterCacheTest {
|
||||
@Test
|
||||
public void unavailableServerFallsBackToBiomeIdZero() {
|
||||
ModdedBiomeWriter writer = new ModdedBiomeWriter(() -> null);
|
||||
|
||||
assertEquals(0, writer.biomeIdFor("minecraft:plains"));
|
||||
assertEquals(0, writer.biomeIdFor("minecraft:plains"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unavailableServerYieldsAnEmptyMutableBiomeList() {
|
||||
ModdedBiomeWriter writer = new ModdedBiomeWriter(() -> null);
|
||||
|
||||
List<PlatformBiome> first = writer.allBiomes();
|
||||
List<PlatformBiome> second = writer.allBiomes();
|
||||
|
||||
assertTrue(first.isEmpty());
|
||||
assertNotSame("callers must never share the writer cache instance", first, second);
|
||||
first.add(null);
|
||||
assertTrue(second.isEmpty());
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
/**
|
||||
* The SPI splits block resolution into a null-returning lookup and an air-falling-back lookup. Modded used to
|
||||
* collapse both onto air, so an unknown key produced no output at all.
|
||||
*/
|
||||
public class ModdedBlockResolutionContractTest {
|
||||
private static final String UNKNOWN = "minecraft:definitely_not_a_real_block";
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getOrNullReturnsNullForUnknownKey() {
|
||||
assertNull(ModdedBlockResolution.getOrNull(UNKNOWN));
|
||||
assertNull(ModdedBlockResolution.getOrNull(UNKNOWN, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getFallsBackToAirForUnknownKey() {
|
||||
ModdedBlockState state = ModdedBlockResolution.get(UNKNOWN);
|
||||
assertNotNull(state);
|
||||
assertEquals(Blocks.AIR, state.handle().getBlock());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getOrNullResolvesKnownKeyWithProperties() {
|
||||
ModdedBlockState state = ModdedBlockResolution.getOrNull("minecraft:oak_log[axis=x]", true);
|
||||
assertNotNull(state);
|
||||
assertEquals(Blocks.OAK_LOG, state.handle().getBlock());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknownPropertyFallsBackToDefaultState() {
|
||||
ModdedBlockState state = ModdedBlockResolution.getOrNull("minecraft:oak_log[not_a_property=x]", true);
|
||||
assertNotNull(state);
|
||||
assertEquals(Blocks.OAK_LOG, state.handle().getBlock());
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Dist gate. The loader source sets fold modded-common, minecraft-common and client-common into one output, so
|
||||
* nothing at compile time stops a server-side class from touching a client-only type. On a dedicated server
|
||||
* that is a NoClassDefFoundError at the first call, usually deep inside worldgen.
|
||||
*
|
||||
* <p>The check is a constant-pool byte scan for the internal (slash) form of the forbidden packages. Slash form
|
||||
* is the point: a real type reference - supertype, field or local descriptor, method owner, class literal - is
|
||||
* always stored slash-separated, while a reflective lookup keeps the class name as a dotted string constant.
|
||||
* {@link ModdedMixinAudit} depends on that distinction: it names client mixin targets as dotted strings on
|
||||
* purpose so it can audit them from a dedicated server, and this gate must not flag it.
|
||||
*
|
||||
* <p>Same style as the core purity gate: read the bytes, do not load the class. Loading is what the gate is
|
||||
* trying to prove is safe.
|
||||
*/
|
||||
public class ModdedClientPackageIsolationTest {
|
||||
private static final String CLIENT_MINECRAFT = "net/minecraft/client/";
|
||||
private static final String CLIENT_IRIS = "art/arcane/iris/client/";
|
||||
private static final List<String> GUARDED_PACKAGES = List.of(
|
||||
"art/arcane/iris/modded",
|
||||
"art/arcane/iris/nativegen");
|
||||
private static final int MINIMUM_SCANNED = 50;
|
||||
|
||||
@Test
|
||||
public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException {
|
||||
Path root = classesRoot();
|
||||
List<String> violations = new ArrayList<>();
|
||||
int scanned = 0;
|
||||
for (String guarded : GUARDED_PACKAGES) {
|
||||
Path directory = root.resolve(guarded);
|
||||
if (!Files.isDirectory(directory)) {
|
||||
continue;
|
||||
}
|
||||
try (Stream<Path> walk = Files.walk(directory)) {
|
||||
for (Path classFile : walk.filter(ModdedClientPackageIsolationTest::isClassFile).toList()) {
|
||||
scanned++;
|
||||
String bytecode = readAsLatin1(classFile);
|
||||
String name = root.relativize(classFile).toString();
|
||||
if (bytecode.contains(CLIENT_MINECRAFT)) {
|
||||
violations.add(name + " -> " + CLIENT_MINECRAFT);
|
||||
}
|
||||
if (bytecode.contains(CLIENT_IRIS)) {
|
||||
violations.add(name + " -> " + CLIENT_IRIS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue("guarded packages produced only " + scanned + " classes; the scan root is wrong",
|
||||
scanned >= MINIMUM_SCANNED);
|
||||
assertEquals("server-side classes referencing client-only types: " + violations,
|
||||
List.of(), violations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Positive control. If the needle ever stops matching - a package rename, a scan that reads the wrong
|
||||
* bytes - the gate above would pass silently forever. A known client-tainted class must still trip it.
|
||||
*/
|
||||
@Test
|
||||
public void scanDetectsClientReferencesInAKnownClientClass() throws IOException, URISyntaxException {
|
||||
Path visionScreen = classesRoot().resolve("art/arcane/iris/client/IrisVisionScreen.class");
|
||||
assertTrue("IrisVisionScreen.class missing from " + visionScreen, Files.isRegularFile(visionScreen));
|
||||
assertTrue("scan needle no longer matches a known client class",
|
||||
readAsLatin1(visionScreen).contains(CLIENT_MINECRAFT));
|
||||
}
|
||||
|
||||
private static boolean isClassFile(Path path) {
|
||||
return Files.isRegularFile(path) && path.getFileName().toString().endsWith(".class");
|
||||
}
|
||||
|
||||
private static String readAsLatin1(Path path) throws IOException {
|
||||
return new String(Files.readAllBytes(path), StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
|
||||
private static Path classesRoot() throws URISyntaxException {
|
||||
String anchor = "/art/arcane/iris/modded/ModdedMixinFlags.class";
|
||||
URL located = ModdedClientPackageIsolationTest.class.getResource(anchor);
|
||||
assertNotNull("compiled main classes are not on the test classpath as files", located);
|
||||
assertEquals("expected a directory classpath entry, got " + located, "file", located.getProtocol());
|
||||
Path root = Path.of(located.toURI());
|
||||
for (int depth = 0; depth < 5; depth++) {
|
||||
root = root.getParent();
|
||||
assertNotNull("walked past the classpath root resolving " + anchor, root);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
}
|
||||
+53
@@ -6,10 +6,14 @@ import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedDimensionRegistryStoreTest {
|
||||
@Test
|
||||
@@ -84,4 +88,53 @@ public class ModdedDimensionRegistryStoreTest {
|
||||
Files.deleteIfExists(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupLoadQuarantinesACorruptRegistryInsteadOfFailingBoot() throws IOException {
|
||||
Path root = Files.createTempDirectory("iris-dimension-registry-corrupt-boot");
|
||||
Path file = root.resolve("iris-dimensions.json");
|
||||
try {
|
||||
Files.writeString(file, "{\"dimensions\":[{\"id\":\"iris:lost\",", StandardCharsets.UTF_8);
|
||||
|
||||
assertEquals(List.of(), ModdedDimensionRegistryStore.loadForStartup(file));
|
||||
assertFalse(Files.exists(file));
|
||||
|
||||
try (Stream<Path> entries = Files.list(root)) {
|
||||
assertTrue(entries.anyMatch((Path entry) ->
|
||||
entry.getFileName().toString().startsWith("iris-dimensions.json.broken-")));
|
||||
}
|
||||
} finally {
|
||||
deleteTree(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupLoadReturnsHealthyEntriesUntouched() throws IOException {
|
||||
Path root = Files.createTempDirectory("iris-dimension-registry-healthy-boot");
|
||||
Path file = root.resolve("iris-dimensions.json");
|
||||
try {
|
||||
List<ModdedDimensionRegistryStore.PersistentDimension> expected = List.of(
|
||||
new ModdedDimensionRegistryStore.PersistentDimension(
|
||||
"iris:first", "overworld", "overworld", 42L));
|
||||
ModdedDimensionRegistryStore.write(file, expected);
|
||||
|
||||
assertEquals(expected, ModdedDimensionRegistryStore.loadForStartup(file));
|
||||
assertTrue(Files.exists(file));
|
||||
} finally {
|
||||
deleteTree(root);
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteTree(Path root) throws IOException {
|
||||
if (!Files.exists(root)) {
|
||||
return;
|
||||
}
|
||||
List<Path> entries;
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
entries = walk.sorted(Comparator.reverseOrder()).toList();
|
||||
}
|
||||
for (Path entry : entries) {
|
||||
Files.deleteIfExists(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -151,7 +151,8 @@ public class ModdedLifecycleFailureContractTest {
|
||||
assertBefore(stop, "\"services\"", "\"world engines\"");
|
||||
assertBefore(stop, "\"world engines\"", "\"dimension manager\"");
|
||||
assertBefore(stop, "\"server state\"", "if (failure != null)");
|
||||
assertTrue(stop.contains("throw propagateStopFailure(failure);"));
|
||||
assertFalse(stop.contains("throw"));
|
||||
assertTrue(stop.contains("LOGGER.error(\"Iris modded shutdown completed with failures\", failure);"));
|
||||
|
||||
String runStage = method(source, "private static Throwable runStopStage(");
|
||||
assertTrue(runStage.contains("catch (Throwable stageFailure)"));
|
||||
|
||||
+6
-3
@@ -49,7 +49,7 @@ public class ModdedServiceManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures() {
|
||||
public void disableAttemptsEveryServiceInReverseOrderAndNeverRethrows() {
|
||||
ModdedServiceManager manager = new ModdedServiceManager();
|
||||
RuntimeException firstFailure = new RuntimeException("first disable failed");
|
||||
RuntimeException secondFailure = new RuntimeException("second disable failed");
|
||||
@@ -59,13 +59,16 @@ public class ModdedServiceManagerTest {
|
||||
SecondService.class, new SecondService(null, secondFailure));
|
||||
|
||||
manager.enableAll();
|
||||
IllegalStateException thrown = assertThrows(IllegalStateException.class, manager::disableAll);
|
||||
manager.disableAll();
|
||||
|
||||
assertEquals(1, first.disableCount);
|
||||
assertEquals(1, second.disableCount);
|
||||
assertSame(secondFailure, thrown.getCause());
|
||||
assertEquals(1, secondFailure.getSuppressed().length);
|
||||
assertSame(firstFailure, secondFailure.getSuppressed()[0]);
|
||||
|
||||
manager.disableAll();
|
||||
assertEquals(1, first.disableCount);
|
||||
assertEquals(1, second.disableCount);
|
||||
}
|
||||
|
||||
private static final class FirstService implements ModdedService {
|
||||
|
||||
+17
@@ -40,6 +40,23 @@ public class ModdedStructureHooksTest {
|
||||
assertTrue(ModdedStructureHooks.isWithinSpan(box, -1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chunkGridSizeCountsEveryChunkPlacementWouldLoad() {
|
||||
assertEquals(1, ModdedStructureHooks.chunkGridSize(new BoundingBox(0, 0, 0, 15, 15, 15)));
|
||||
assertEquals(4, ModdedStructureHooks.chunkGridSize(new BoundingBox(0, 0, 0, 16, 15, 16)));
|
||||
assertEquals(9, ModdedStructureHooks.chunkGridSize(new BoundingBox(-16, 0, -16, 16, 15, 16)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chunkGridSizeSaturatesInsteadOfOverflowing() {
|
||||
BoundingBox box = new BoundingBox(
|
||||
Integer.MIN_VALUE / 2, 0, Integer.MIN_VALUE / 2,
|
||||
Integer.MAX_VALUE / 2, 15, Integer.MAX_VALUE / 2);
|
||||
|
||||
assertEquals(Integer.MAX_VALUE, ModdedStructureHooks.chunkGridSize(box));
|
||||
assertTrue(ModdedStructureHooks.chunkGridSize(box) > ModdedStructureHooks.MAX_PLACEMENT_CHUNKS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structurePlacementReturnsPaperOrderedBounds() {
|
||||
BoundingBox box = new BoundingBox(-16, -64, 32, 15, 63, 47);
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.engine.object.TileData;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.IntArrayTag;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Mantle tile sections are palette backed and resolve palette ids through equals/hashCode, so two different tiles
|
||||
* in one section must not share an identity - otherwise the first tile's NBT is written into every other tile.
|
||||
* The superclass generates equals/hashCode from fields a modded record never populates, which is why this class
|
||||
* answers identity itself.
|
||||
*/
|
||||
public class ModdedTileDataIdentityTest {
|
||||
private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create();
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
/**
|
||||
* The generic map form the Bukkit side reads cannot express an NBT array: ByteArray, IntArray and LongArray all
|
||||
* collapse to a plain List, and pasting that back produces a ListTag where Minecraft demands an array. A player
|
||||
* head's {@code profile.id} is an IntArray of four, so a captured head pasted from the map form loses its skin.
|
||||
* Capture therefore keeps the original SNBT alongside the map, and the modded paste path reads it first.
|
||||
*/
|
||||
@Test
|
||||
public void captureKeepsSnbtSoArrayTypingSurvivesThePasteRoundTrip() throws Exception {
|
||||
String snbt = "{profile:{id:[I;1,2,3,4],name:\"iris\"}}";
|
||||
|
||||
ModdedTileData captured = ModdedTileData.capture("minecraft:player_head", snbt);
|
||||
|
||||
assertEquals(snbt, captured.snbt());
|
||||
assertEquals(snbt, captured.getProperties().get(ModdedTileData.NBT_PROPERTY));
|
||||
|
||||
// The Bukkit-readable map form is still written, and is still array-lossy - which is why the SNBT has to stay.
|
||||
assertTrue(captured.getProperties().get("profile") instanceof KMap);
|
||||
KMap<?, ?> profile = (KMap<?, ?>) captured.getProperties().get("profile");
|
||||
assertTrue(profile.get("id") instanceof List);
|
||||
|
||||
CompoundTag payload = captured.payload();
|
||||
assertNotNull(payload);
|
||||
Tag profileTag = payload.get("profile");
|
||||
assertTrue(profileTag instanceof CompoundTag);
|
||||
Tag idTag = ((CompoundTag) profileTag).get("id");
|
||||
assertTrue("profile.id must paste back as an IntArrayTag, got "
|
||||
+ (idTag == null ? "null" : idTag.getClass().getSimpleName()),
|
||||
idTag instanceof IntArrayTag);
|
||||
assertArrayEquals(new int[]{1, 2, 3, 4}, ((IntArrayTag) idTag).getAsIntArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void differentTilesAreNotEqualAndDoNotShareHashCode() {
|
||||
ModdedTileData chest = tile("minecraft:chest", "Items", "diamond");
|
||||
ModdedTileData otherChest = tile("minecraft:chest", "Items", "emerald");
|
||||
ModdedTileData sign = tile("minecraft:oak_sign", "front_text", "hello");
|
||||
|
||||
assertNotEquals(chest, otherChest);
|
||||
assertNotEquals(chest, sign);
|
||||
// The inherited identity was a constant hash for every modded record; unequal objects are allowed to
|
||||
// collide, so assert only that the hash actually varies with the record.
|
||||
assertTrue(Set.of(chest.hashCode(), otherChest.hashCode(), sign.hashCode()).size() > 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void identicalTilesShareOnePaletteIdentity() {
|
||||
ModdedTileData first = tile("minecraft:chest", "Items", "diamond");
|
||||
ModdedTileData second = tile("minecraft:chest", "Items", "diamond");
|
||||
|
||||
assertEquals(first, second);
|
||||
assertEquals(first.hashCode(), second.hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void materialKeyReportsTheBlockKeyInsteadOfNull() {
|
||||
assertEquals("minecraft:chest", tile("minecraft:chest", "Items", "diamond").getMaterialKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyRecordHasNoBlockKey() {
|
||||
ModdedTileData legacy = new ModdedTileData(new byte[]{0, 1}, new KMap<>(), null, 0);
|
||||
assertNull(legacy.getMaterialKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloneIsEqualButIndependentOfTheSource() {
|
||||
ModdedTileData source = tile("minecraft:chest", "Items", "diamond");
|
||||
KMap<String, Object> nested = new KMap<>();
|
||||
nested.put("id", "minecraft:stone");
|
||||
source.getProperties().put("nested", nested);
|
||||
source.getProperties().put("list", List.of("a", "b"));
|
||||
|
||||
TileData copy = source.clone();
|
||||
|
||||
assertNotSame(source, copy);
|
||||
assertNotSame(source.getProperties(), copy.getProperties());
|
||||
assertEquals(source.getProperties(), copy.getProperties());
|
||||
assertNotSame(source.getProperties().get("nested"), copy.getProperties().get("nested"));
|
||||
assertTrue(copy.getProperties().get("list") instanceof List);
|
||||
}
|
||||
|
||||
private static ModdedTileData tile(String blockKey, String propertyKey, String propertyValue) {
|
||||
KMap<String, Object> properties = new KMap<>();
|
||||
properties.put(propertyKey, propertyValue);
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (DataOutputStream out = new DataOutputStream(bytes)) {
|
||||
out.writeUTF(blockKey);
|
||||
out.writeUTF(GSON.toJson(properties));
|
||||
}
|
||||
return new ModdedTileData(bytes.toByteArray(), properties, blockKey, -1);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -19,11 +19,11 @@ import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedWorldCheckTest {
|
||||
@Test
|
||||
public void coordinatorThreadIsNonDaemon() {
|
||||
public void coordinatorThreadIsDaemonSoItCannotWedgeTheJvm() {
|
||||
Thread thread = ModdedWorldCheck.coordinatorThread(() -> {
|
||||
});
|
||||
|
||||
assertFalse(thread.isDaemon());
|
||||
assertTrue(thread.isDaemon());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -33,7 +33,7 @@ public class ModdedWorldCheckTest {
|
||||
String auditSource = Files.readString(
|
||||
sourceRoot.resolve("art/arcane/iris/modded/WorldCheckStructureAudit.java"));
|
||||
int preparationSubmit = source.indexOf(
|
||||
"WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef)).join();");
|
||||
"WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef))");
|
||||
int completionSubmit = source.indexOf(
|
||||
"exitCode = serverRef.submit(() -> runAndRequestStop(", preparationSubmit);
|
||||
int completionMethod = source.indexOf("private static boolean completeWorldCheck");
|
||||
|
||||
+15
@@ -2,10 +2,25 @@ package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedWorldManagerParityTest {
|
||||
@Test
|
||||
public void ambientChunkReservoirFillsBeforeSampling() {
|
||||
assertEquals(0, ModdedWorldManager.reservoirSlot(0, 4, 0));
|
||||
assertEquals(3, ModdedWorldManager.reservoirSlot(3, 4, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ambientChunkReservoirReplacesOnlyOnInRangeRolls() {
|
||||
assertEquals(2, ModdedWorldManager.reservoirSlot(4, 4, 2));
|
||||
assertEquals(0, ModdedWorldManager.reservoirSlot(9, 4, 0));
|
||||
assertEquals(-1, ModdedWorldManager.reservoirSlot(4, 4, 4));
|
||||
assertEquals(-1, ModdedWorldManager.reservoirSlot(9, 4, 7));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalWorldAlwaysAllowsEntitySpawning() {
|
||||
assertTrue(ModdedWorldManager.entitySpawningEnabled(false, false));
|
||||
|
||||
+29
@@ -7,6 +7,8 @@ import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
@@ -75,6 +77,33 @@ public class IrisModdedCommandParityTest {
|
||||
assertTrue(ModdedCommandHelp.documents("world", "mainworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void categoryLiteralsFallBackToHelpWithoutArguments() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
CommandDispatcher<CommandSourceStack> dispatcher = new CommandDispatcher<>();
|
||||
|
||||
IrisModdedCommands.register(dispatcher);
|
||||
|
||||
CommandNode<CommandSourceStack> iris = child(dispatcher.getRoot(), "iris");
|
||||
assertNotNull("iris", iris.getCommand());
|
||||
for (String category : new String[]{"help", "find", "goto", "pregen", "pregenerate", "object", "o",
|
||||
"edit", "studio", "std", "s", "pack", "pk", "world", "w", "datapack", "datapacks", "dp",
|
||||
"structure", "struct", "str", "developer", "dev"}) {
|
||||
assertNotNull(category, child(iris, category).getCommand());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void consoleHelpListsEveryEntryWithInlineUsageAndDescription() {
|
||||
List<String> root = ModdedCommandHelp.consoleLines("");
|
||||
|
||||
assertTrue(root.stream().anyMatch((String line) -> line.startsWith("/iris developer (dev) - ")));
|
||||
assertTrue(root.stream().anyMatch((String line) -> line.startsWith("/iris teleport <dimension> [player] (tp) - ")));
|
||||
assertTrue(ModdedCommandHelp.consoleLines("pregen").stream().anyMatch((String line) ->
|
||||
line.startsWith("/iris pregen start <radius> [dimension] [at] [x] [z] [gui] [sync] [nocache] - ")));
|
||||
}
|
||||
|
||||
private static CommandNode<CommandSourceStack> child(
|
||||
CommandNode<CommandSourceStack> parent, String name) {
|
||||
CommandNode<CommandSourceStack> child = parent.getChild(name);
|
||||
|
||||
Reference in New Issue
Block a user