diff --git a/annotations/src/main/java/org/embeddedt/modernfix/annotation/FeatureLevel.java b/annotations/src/main/java/org/embeddedt/modernfix/annotation/FeatureLevel.java new file mode 100644 index 000000000..06aa3098f --- /dev/null +++ b/annotations/src/main/java/org/embeddedt/modernfix/annotation/FeatureLevel.java @@ -0,0 +1,9 @@ +package org.embeddedt.modernfix.annotation; + +public enum FeatureLevel { + GA, BETA; + + public boolean isAtLeast(FeatureLevel required) { + return this.ordinal() >= required.ordinal(); + } +} diff --git a/annotations/src/main/java/org/embeddedt/modernfix/annotation/RequiresFeatureLevel.java b/annotations/src/main/java/org/embeddedt/modernfix/annotation/RequiresFeatureLevel.java new file mode 100644 index 000000000..3cc9ebb22 --- /dev/null +++ b/annotations/src/main/java/org/embeddedt/modernfix/annotation/RequiresFeatureLevel.java @@ -0,0 +1,12 @@ +package org.embeddedt.modernfix.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.TYPE, ElementType.PACKAGE}) +public @interface RequiresFeatureLevel { + FeatureLevel value() default FeatureLevel.GA; +} diff --git a/annotations/src/main/java/org/embeddedt/modernfix/annotation/RequiresMod.java b/annotations/src/main/java/org/embeddedt/modernfix/annotation/RequiresMod.java index 7f718bc0b..b69ac89e1 100644 --- a/annotations/src/main/java/org/embeddedt/modernfix/annotation/RequiresMod.java +++ b/annotations/src/main/java/org/embeddedt/modernfix/annotation/RequiresMod.java @@ -6,7 +6,7 @@ import java.lang.annotation.Target; @Retention(RetentionPolicy.CLASS) -@Target(ElementType.TYPE) +@Target({ElementType.TYPE, ElementType.PACKAGE}) public @interface RequiresMod { String value() default ""; } diff --git a/build.gradle.kts b/build.gradle.kts index c1cab5912..45fdeb1ce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -118,15 +118,41 @@ dependencies { compileOnly("curse.maven:cofhcore-69162:5374122") compileOnly("curse.maven:resourcefullib-570073:5659871") compileOnly("curse.maven:kubejs-238086:5853326") + compileOnly("curse.maven:terrablender-neoforge-940057:8046313") } tasks.named("jar") { from(embed.map { if (it.isDirectory) it else zipTree(it) }) } +val checkDanglingMixinPackageInfo by tasks.registering { + val mixinDir = file("src/main/java/org/embeddedt/modernfix/common/mixin") + inputs.dir(mixinDir) + doLast { + val dangling = mutableListOf() + mixinDir.walkTopDown() + .filter { it.name == "package-info.java" } + .forEach { pkgInfo -> + val dir = pkgInfo.parentFile + val hasOtherJava = dir.listFiles { f -> f.name != "package-info.java" && f.extension == "java" }?.isNotEmpty() == true + val hasSubpackage = dir.listFiles { f -> f.isDirectory }?.isNotEmpty() == true + if (!hasOtherJava && !hasSubpackage) { + dangling += pkgInfo + } + } + if (dangling.isNotEmpty()) { + throw GradleException( + "Dangling package-info.java files found (no sibling classes or subpackages):\n" + + dangling.joinToString("\n") { " ${it.relativeTo(projectDir)}" } + ) + } + } +} + // For the AP tasks.withType().configureEach { if (!name.lowercase().contains("test")) { + dependsOn(checkDanglingMixinPackageInfo) options.compilerArgs.addAll( listOf( "-ArootProject.name=${rootProject.name}", diff --git a/scripts/feature-gates.sh b/scripts/feature-gates.sh new file mode 100755 index 000000000..48ca4fbd3 --- /dev/null +++ b/scripts/feature-gates.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Show FeatureLevel gates, excluding permanent infrastructure. +# If a level is given, show only gates at that level. +# +# Usage: +# ./scripts/feature-gates.sh # all FeatureLevel gates +# ./scripts/feature-gates.sh beta # only BETA gates +# ./scripts/feature-gates.sh ga # only GA gates + +set -euo pipefail + +# Permanent infrastructure — these are NOT gates to remove when promoting β → GA. +PERMANENT=( + "ModernFixMixinPlugin\.java.*!= FeatureLevel\.GA" + "ModernFixEarlyConfig\.java.*return FeatureLevel\.GA" + "ModernFixEarlyConfig\.java.*FeatureLevel requiredLevel = FeatureLevel\.GA" +) + +join() { local IFS='|'; echo "$*"; } + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$REPO_ROOT" + +if [ $# -ge 1 ]; then + LEVEL="${1^^}" + PATTERN="FeatureLevel\.${LEVEL}" +else + PATTERN="FeatureLevel\.[A-Z]+" +fi + +git grep -rE "$PATTERN" src/ | grep -v -E "$(join "${PERMANENT[@]}")" || true diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/bugfix/missing_block_entities/LevelChunkMixin.java b/src/main/java/org/embeddedt/modernfix/common/mixin/bugfix/missing_block_entities/LevelChunkMixin.java index d35e0bbee..d85e1e5d2 100644 --- a/src/main/java/org/embeddedt/modernfix/common/mixin/bugfix/missing_block_entities/LevelChunkMixin.java +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/bugfix/missing_block_entities/LevelChunkMixin.java @@ -85,13 +85,9 @@ private void makeBlockEntityIfNotExists(BlockState state, BlockPos.MutableBlockP } BlockEntity blockEntity = this.getBlockEntity(pos.immutable(), LevelChunk.EntityCreationType.IMMEDIATE); - String blockName = state.getBlock().toString(); - if (blockEntity != null) { - if (ModernFix.LOGGER.isDebugEnabled()) { - ModernFix.LOGGER.debug("Created missing block entity for {} at {}", blockName, pos.toShortString()); - } - } else { - ModernFix.LOGGER.error("Block entity is missing for {} at {}, but could not be created", blockName, pos.toShortString()); + if (blockEntity != null && ModernFix.LOGGER.isDebugEnabled()) { + String blockName = state.getBlock().toString(); + ModernFix.LOGGER.debug("Created missing block entity for {} at {}", blockName, pos.toShortString()); } } } diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/cache_strongholds/ChunkGeneratorMixin.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/cache_strongholds/ChunkGeneratorMixin.java index 0c1edbe53..dd6b9048a 100644 --- a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/cache_strongholds/ChunkGeneratorMixin.java +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/cache_strongholds/ChunkGeneratorMixin.java @@ -2,10 +2,13 @@ import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.sugar.Share; +import com.llamalad7.mixinextras.sugar.ref.LocalRef; +import net.minecraft.TracingExecutor; import net.minecraft.core.Holder; -import net.minecraft.core.RegistryAccess; import net.minecraft.nbt.*; import net.minecraft.resources.RegistryOps; +import net.minecraft.server.MinecraftServer; import net.minecraft.util.Util; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.biome.BiomeSource; @@ -17,6 +20,8 @@ import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; import java.lang.ref.SoftReference; import java.nio.charset.StandardCharsets; @@ -29,6 +34,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; @Mixin(ChunkGeneratorStructureState.class) public class ChunkGeneratorMixin implements IChunkGenerator { @@ -41,22 +48,24 @@ public class ChunkGeneratorMixin implements IChunkGenerator { private BiomeSource biomeSource; private Path mfix$dimensionPath; - private RegistryAccess.Frozen mfix$registryAccess; + private MinecraftServer mfix$server; + private SoftReference>> mfix$cachedPositions = new SoftReference<>(null); private static final String CACHE_FILENAME = "mfix_stronghold_cache_v2.nbt"; @Override - public void mfix$setStrongholdCachePath(Path cachePath, RegistryAccess.Frozen registryAccess) { + public void mfix$setStrongholdCachePath(Path cachePath, MinecraftServer server) { this.mfix$dimensionPath = cachePath; - this.mfix$registryAccess = registryAccess; + this.mfix$server = server; } @WrapMethod(method = "generateRingPositions") private CompletableFuture> modernfix$cacheRingPositions(Holder structureSet, - ConcentricRingsStructurePlacement placement, - Operation>> original) { - if (this.mfix$registryAccess == null || this.mfix$dimensionPath == null) { + ConcentricRingsStructurePlacement placement, + Operation>> original, + @Share("threadPool") LocalRef threadPoolRef) { + if (this.mfix$server == null || this.mfix$dimensionPath == null) { return original.call(structureSet, placement); } @@ -69,14 +78,35 @@ public class ChunkGeneratorMixin implements IChunkGenerator { return CompletableFuture.completedFuture(List.copyOf(cached)); } - return original.call(structureSet, placement).thenApplyAsync(positions -> { - mfix$writeToCache(cacheKey, positions); - return positions; - }, Util.ioPool()); + var server = this.mfix$server; + ExecutorService strongholdPool = Executors.newFixedThreadPool(Math.max(1, Runtime.getRuntime().availableProcessors() - 2)); + threadPoolRef.set(new TracingExecutor(strongholdPool)); + try { + return original.call(structureSet, placement).thenApplyAsync(positions -> { + // Skip write if server exited before we finished + if (server.isRunning()) { + mfix$writeToCache(cacheKey, positions); + } + return positions; + }, Util.ioPool()); + } finally { + strongholdPool.shutdown(); + } + } + + /** + * @author embeddedt + * @reason Ring position calculation is often not required for initial chunk generation, but the tasks still occupy + * CPU time on the main worker pool and prevent higher priority work from progressing. To fix this we use a + * dedicated pool. + */ + @Redirect(method = "generateRingPositions", at = @At(value = "INVOKE", target = "Lnet/minecraft/util/Util;backgroundExecutor()Lnet/minecraft/TracingExecutor;")) + private TracingExecutor useDedicatedService(@Share("threadPool") LocalRef threadPoolRef) { + return threadPoolRef.get(); } private String mfix$makeCacheKey(ConcentricRingsStructurePlacement placement) { - RegistryOps ops = RegistryOps.create(NbtOps.INSTANCE, this.mfix$registryAccess); + RegistryOps ops = RegistryOps.create(NbtOps.INSTANCE, this.mfix$server.registryAccess()); String placementKey = ConcentricRingsStructurePlacement.CODEC.codec().encodeStart(ops, placement) .result().map(Tag::toString).orElse(null); String biomeSourceKey = BiomeSource.CODEC.encodeStart(ops, this.biomeSource) diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/cache_strongholds/ConcentricRingsStructurePlacementMixin.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/cache_strongholds/ConcentricRingsStructurePlacementMixin.java new file mode 100644 index 000000000..79aa608f8 --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/cache_strongholds/ConcentricRingsStructurePlacementMixin.java @@ -0,0 +1,123 @@ +package org.embeddedt.modernfix.common.mixin.perf.cache_strongholds; + +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement; +import org.embeddedt.modernfix.annotation.FeatureLevel; +import org.embeddedt.modernfix.annotation.RequiresFeatureLevel; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(ConcentricRingsStructurePlacement.class) +@RequiresFeatureLevel(FeatureLevel.BETA) +public class ConcentricRingsStructurePlacementMixin { + + @Shadow @Final private int distance; + @Shadow @Final private int spread; + @Shadow @Final private int count; + + /** + * Maximum per-axis section displacement from the initial ring chunk after biome snapping. + * + * Vanilla calls findBiomeHorizontal with radius=112 blocks. In quart space this is ±28, + * and converting the selected quart back to section coordinates yields at most ±7 chunks + * per axis from the original (initialX, initialZ). + */ + @Unique private static final int MFIX_MAX_BIOME_SNAP_SECTIONS_PER_AXIS = 7; + /** + * Worst-case Euclidean error introduced by rounding: + * initialX/Z = round(cos(angle) * dist), round(sin(angle) * dist). + */ + @Unique private static final double MFIX_MAX_ROUNDING_ERROR = Math.sqrt(2.0) * 0.5; + /** + * Worst-case Euclidean biome-snap displacement when each axis can move by at most 7 chunks. + */ + @Unique private static final double MFIX_MAX_BIOME_SNAP_ERROR = MFIX_MAX_BIOME_SNAP_SECTIONS_PER_AXIS * Math.sqrt(2.0); + /** + * Total conservative positional slack (rounding + biome snap) applied to radial bounds. + */ + @Unique private static final double MFIX_MAX_POSITION_ERROR = MFIX_MAX_ROUNDING_ERROR + MFIX_MAX_BIOME_SNAP_ERROR; + + /** Squared chunk-distance below which no ring position can ever land. */ + @Unique private long mfix$innerRadiusSq; + /** Squared chunk-distance above which no ring position can ever land. */ + @Unique private long mfix$outerRadiusSq; + + /** + * Precomputes conservative radial bounds for vanilla's ring placement distance: + * {@code dist = 4*i + i*i1*6 + noise}, where {@code i=distance} and {@code i1=circle}. + * + * - Inner bound uses the minimum possible base term ({@code i1=0} => {@code 4*i}). + * - Outer bound uses the maximum reachable {@code i1} for this ({@code spread,count}) pair. + * + * Both bounds are expanded by {@link #MFIX_MAX_POSITION_ERROR} so we never reject a valid + * chunk produced by rounding and biome snapping. + */ + @Inject( + method = "(Lnet/minecraft/core/Vec3i;Lnet/minecraft/world/level/levelgen/structure/placement/StructurePlacement$FrequencyReductionMethod;FILjava/util/Optional;IIILnet/minecraft/core/HolderSet;)V", + at = @At("RETURN") + ) + private void mfix$computeRadiusBounds(CallbackInfo ci) { + double maxNoise = this.distance * 1.25; // (nextDouble() - 0.5) * (distance * 2.5) + + // min(dist): 4*i + i*0*6 - maxNoise + double minDist = 4.0 * this.distance - maxNoise; + double safeInnerRadius = minDist - MFIX_MAX_POSITION_ERROR; + this.mfix$innerRadiusSq = (long)Math.max(0.0, Math.floor(safeInnerRadius * safeInnerRadius)); + + if (this.spread == 0) { + // Vanilla behavior becomes non-finite here (angle += 2π / 0), so keep only inner rejection. + this.mfix$outerRadiusSq = Long.MAX_VALUE; + return; + } + + int maxCircle = this.mfix$computeMaxCircleIndex(); + // max(dist): 4*i + i*maxCircle*6 + maxNoise + double maxDist = 4.0 * this.distance + (double)this.distance * maxCircle * 6.0 + maxNoise; + double safeOuterRadius = maxDist + MFIX_MAX_POSITION_ERROR; + this.mfix$outerRadiusSq = (long)Math.ceil(safeOuterRadius * safeOuterRadius); + } + + /** + * Computes the highest ring index ({@code circle}) that vanilla can reach for this placement. + * + * This mirrors the spread/total update logic in + * {@link net.minecraft.world.level.chunk.ChunkGeneratorStructureState#generateRingPositions}, + * but only tracks deterministic loop state (no RNG). + */ + @Unique + private int mfix$computeMaxCircleIndex() { + int ringSpread = this.spread; + int total = 0; + int circle = 0; + + while (total + ringSpread < this.count) { + total += ringSpread; + circle++; + ringSpread += 2 * ringSpread / (circle + 1); + ringSpread = Math.min(ringSpread, this.count - total); + } + + return circle; + } + + /** + * @author embeddedt, GPT-5.3-Codex + * @reason Avoid calling getRingPositionsFor() when we know the current chunk lies outside the region where + * concentric placement can even happen. This is particularly helpful when creating new worlds, because we can + * avoid blocking on the slow noise computations within the spawn region around (0, 0). + */ + @Inject(method = "isPlacementChunk", at = @At("HEAD"), cancellable = true) + private void mfix$earlyRejectByRadius(ChunkGeneratorStructureState structureState, int x, int z, + CallbackInfoReturnable cir) { + long distSq = (long)x * x + (long)z * z; + if (distSq < this.mfix$innerRadiusSq || distSq > this.mfix$outerRadiusSq) { + cir.setReturnValue(false); + } + } +} diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/cache_strongholds/ServerLevelMixin.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/cache_strongholds/ServerLevelMixin.java index 8505303e8..dcd2b2f87 100644 --- a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/cache_strongholds/ServerLevelMixin.java +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/cache_strongholds/ServerLevelMixin.java @@ -24,7 +24,7 @@ private void setCachePath(ChunkGeneratorStructureState instance, Operation @Local(ordinal = 0, argsOnly = true) LevelStorageSource.LevelStorageAccess levelStorageAccess, @Local(ordinal = 0, argsOnly = true) ResourceKey dimension, @Local(ordinal = 0, argsOnly = true) MinecraftServer server) { - ((IChunkGenerator)instance).mfix$setStrongholdCachePath(levelStorageAccess.getDimensionPath(dimension), server.registryAccess()); + ((IChunkGenerator)instance).mfix$setStrongholdCachePath(levelStorageAccess.getDimensionPath(dimension), server); original.call(instance); } } diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/compact_entity_models/package-info.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/compact_entity_models/package-info.java new file mode 100644 index 000000000..8f34ae1e9 --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/compact_entity_models/package-info.java @@ -0,0 +1,5 @@ +@RequiresFeatureLevel(FeatureLevel.BETA) +package org.embeddedt.modernfix.common.mixin.perf.compact_entity_models; + +import org.embeddedt.modernfix.annotation.FeatureLevel; +import org.embeddedt.modernfix.annotation.RequiresFeatureLevel; \ No newline at end of file diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/dynamic_languages/package-info.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/dynamic_languages/package-info.java new file mode 100644 index 000000000..70745a0ef --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/dynamic_languages/package-info.java @@ -0,0 +1,5 @@ +@RequiresFeatureLevel(FeatureLevel.BETA) +package org.embeddedt.modernfix.common.mixin.perf.dynamic_languages; + +import org.embeddedt.modernfix.annotation.FeatureLevel; +import org.embeddedt.modernfix.annotation.RequiresFeatureLevel; \ No newline at end of file diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/optimize_surface_rules/NamespacedSurfaceRuleSourceMixin.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/optimize_surface_rules/NamespacedSurfaceRuleSourceMixin.java new file mode 100644 index 000000000..55127b1ba --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/optimize_surface_rules/NamespacedSurfaceRuleSourceMixin.java @@ -0,0 +1,97 @@ +package org.embeddedt.modernfix.common.mixin.perf.optimize_surface_rules; + +import com.google.common.collect.ImmutableList; +import com.llamalad7.mixinextras.sugar.Share; +import com.llamalad7.mixinextras.sugar.ref.LocalRef; +import it.unimi.dsi.fastutil.objects.ObjectArraySet; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.levelgen.SurfaceRules; +import org.embeddedt.modernfix.annotation.FeatureLevel; +import org.embeddedt.modernfix.annotation.RequiresFeatureLevel; +import org.embeddedt.modernfix.annotation.RequiresMod; +import org.embeddedt.modernfix.world.gen.ExtendedSurfaceContext; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.ModifyArg; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import terrablender.worldgen.surface.NamespacedSurfaceRuleSource; + +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +@Mixin(NamespacedSurfaceRuleSource.class) +@RequiresMod("terrablender") +@RequiresFeatureLevel(FeatureLevel.BETA) +public class NamespacedSurfaceRuleSourceMixin { + @Shadow + @Final + private Map sources; + + @Shadow + @Final + private SurfaceRules.RuleSource base; + + /** + * @author embeddedt + * @reason Avoid doing an expensive biome lookup per block in cases where we can prove all biomes will be from a + * single namespace. This achieves much of the benefit of TerraBlenderFix without the compatibility issues. + */ + @Inject(method = "apply(Lnet/minecraft/world/level/levelgen/SurfaceRules$Context;)Lnet/minecraft/world/level/levelgen/SurfaceRules$SurfaceRule;", at = @At("HEAD"), cancellable = true, remap = false) + private void modernfix$fastApply(SurfaceRules.Context context, CallbackInfoReturnable cir, + @Share("possibleNamespaces") LocalRef> possibleNamespacesRef) { + var possibleBiomes = ((ExtendedSurfaceContext)(Object)context).mfix$getPossibleBiomes(); + if (possibleBiomes == null) { + return; + } + Set namespaces = mfix$findNamespaces(possibleBiomes); + possibleNamespacesRef.set(namespaces); + if (namespaces.size() != 1) { + return; + } + String singleNamespace = namespaces.iterator().next(); + // In a single namespace scenario, we can bypass the biome lookup and directly construct a sequence rule + SurfaceRules.RuleSource namespacedSource = this.sources.get(singleNamespace); + if (namespacedSource == null) { + // Sequence rule wrapper not required + cir.setReturnValue(this.base.apply(context)); + } else { + cir.setReturnValue(new SurfaceRules.SequenceRule(ImmutableList.of(namespacedSource.apply(context), this.base.apply(context)))); + } + } + + /** + * @author embeddedt + * @reason Even if we have to fall back to the namespaced source, avoid compiling surface rules for namespaces that + * will never be hit in the given chunk. + */ + @ModifyArg(method = "apply(Lnet/minecraft/world/level/levelgen/SurfaceRules$Context;)Lnet/minecraft/world/level/levelgen/SurfaceRules$SurfaceRule;", at = @At(value = "INVOKE", target = "Ljava/util/Set;forEach(Ljava/util/function/Consumer;)V"), remap = false) + private Consumer> mfix$filterConsumer(Consumer> originalConsumer, + @Share("possibleNamespaces") LocalRef> possibleNamespacesRef) { + var possibleNamespaces = possibleNamespacesRef.get(); + if (possibleNamespaces == null) { + return originalConsumer; + } + return entry -> { + if(possibleNamespaces.contains(entry.getKey())) { + originalConsumer.accept(entry); + } + }; + } + + private static Set mfix$findNamespaces(Set> possibleBiomes) { + if (possibleBiomes.size() == 1) { + return Set.of(possibleBiomes.iterator().next().identifier().getNamespace()); + } else { + var namespaces = new ObjectArraySet(4); + for (var key : possibleBiomes) { + namespaces.add(key.identifier().getNamespace()); + } + return Set.copyOf(namespaces); + } + } +} diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/optimize_surface_rules/NoiseBasedChunkGeneratorMixin.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/optimize_surface_rules/NoiseBasedChunkGeneratorMixin.java index 8155a807a..2a23414e1 100644 --- a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/optimize_surface_rules/NoiseBasedChunkGeneratorMixin.java +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/optimize_surface_rules/NoiseBasedChunkGeneratorMixin.java @@ -26,14 +26,20 @@ public class NoiseBasedChunkGeneratorMixin { @SuppressWarnings("unchecked") private static void mfix$accumulate(Set> chunkBiomes, LevelChunkSection section) { var palette = ((ExtendedPalettedContainer>)section.getBiomes()).mfix$getPalette(); - for (int i = 0; i < palette.getSize(); i++) { - chunkBiomes.add(palette.valueFor(i).unwrapKey().orElseThrow()); + if (palette.getSize() == 1) { + // No need to iterate the storage itself, as there can only be one value + chunkBiomes.add(palette.valueFor(0).unwrapKey().orElseThrow()); + } else { + // Use getAll() rather than raw palette iteration. PalettedContainer.recreate() seeds the new + // palette with Biomes.PLAINS (the initial default), leaving a stale palette entry even after + // fillBiomesFromNoise replaces all cells with real biomes. getAll() only visits entries that + // are actually referenced in the backing storage, so stale entries are correctly excluded. + section.getBiomes().getAll(holder -> chunkBiomes.add(holder.unwrapKey().orElseThrow())); } } - private static Set> mfix$obtainBiomes(WorldGenRegion region, int chunkRadius) { + private static Set> mfix$obtainBiomes(WorldGenRegion region, ChunkPos center, int chunkRadius) { Set> chunkBiomes = new ReferenceOpenHashSet<>(); - ChunkPos center = region.getCenter(); for (int z = center.z() - chunkRadius; z <= center.z() + chunkRadius; z++) { for (int x = center.x() - chunkRadius; x <= center.x() + chunkRadius; x++) { var chunk = region.getChunk(x, z); @@ -53,9 +59,10 @@ public class NoiseBasedChunkGeneratorMixin { at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/levelgen/NoiseBasedChunkGenerator;buildSurface(Lnet/minecraft/world/level/chunk/ChunkAccess;Lnet/minecraft/world/level/levelgen/WorldGenerationContext;Lnet/minecraft/world/level/levelgen/RandomState;Lnet/minecraft/world/level/StructureManager;Lnet/minecraft/world/level/biome/BiomeManager;Lnet/minecraft/core/Registry;Lnet/minecraft/world/level/levelgen/blending/Blender;)V")) private void mfix$findNearbyBiomes(WorldGenRegion level, StructureManager structureManager, RandomState random, ChunkAccess chunk, CallbackInfo ci) { try { - ExtendedSurfaceContext.COMPUTED_POSSIBLE_BIOMES.set(mfix$obtainBiomes(level, 1)); - } catch (NoSuchElementException ignored) { - // Catch in case a biome somehow does not have a key. In that case we just don't use the computed set + ExtendedSurfaceContext.COMPUTED_POSSIBLE_BIOMES.set(mfix$obtainBiomes(level, chunk.getPos(), 1)); + } catch (Exception e) { + // Fall back to vanilla logic of not knowing the biomes in advance + ExtendedSurfaceContext.COMPUTED_POSSIBLE_BIOMES.remove(); } } } diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/optimize_surface_rules/SurfaceSystemMixin.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/optimize_surface_rules/SurfaceSystemMixin.java index 9c48fe9a3..9a3a8e64e 100644 --- a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/optimize_surface_rules/SurfaceSystemMixin.java +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/optimize_surface_rules/SurfaceSystemMixin.java @@ -37,11 +37,18 @@ private Function> useFasterLookup(Function lookupRef) { - var lookup = MFIX_LOOKUP_CACHE.get(); - BiomeManagerAccessor accessor = (BiomeManagerAccessor)manager; - lookup.prepare(accessor.mfix$getBiomeSource(), accessor.mfix$getZoomSeed(), chunk, manager); - lookupRef.set(lookup); - return lookup; + // If mods use their own BiomeManager subclass, we cannot trust them to use the same blurring as vanilla, + // so we cannot apply our optimized path + if (manager.getClass() == BiomeManager.class) { + var lookup = MFIX_LOOKUP_CACHE.get(); + BiomeManagerAccessor accessor = (BiomeManagerAccessor)manager; + lookup.prepare(accessor.mfix$getBiomeSource(), accessor.mfix$getZoomSeed(), chunk, manager); + lookupRef.set(lookup); + return lookup; + } else { + lookupRef.set(null); + return biomeGetter; + } } @Inject(method = "buildSurface", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/levelgen/SurfaceRules$RuleSource;apply(Ljava/lang/Object;)Ljava/lang/Object;", ordinal = 0)) @@ -60,7 +67,12 @@ private void finishAndDisposeLookups(RandomState randomState, BiomeManager biome @Redirect(method = "buildSurface", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/biome/BiomeManager;getBiome(Lnet/minecraft/core/BlockPos;)Lnet/minecraft/core/Holder;")) private Holder useFasterLookup(BiomeManager instance, BlockPos pos, @Share("chunkBiomeLookup") LocalRef lookupRef) { - return lookupRef.get().apply(pos); + var lookup = lookupRef.get(); + if (lookup != null) { + return lookup.apply(pos); + } else { + return instance.getBiome(pos); + } } @Inject(method = "buildSurface", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/levelgen/SurfaceRules$Context;(Lnet/minecraft/world/level/levelgen/SurfaceSystem;Lnet/minecraft/world/level/levelgen/RandomState;Lnet/minecraft/world/level/chunk/ChunkAccess;Lnet/minecraft/world/level/levelgen/NoiseChunk;Ljava/util/function/Function;Lnet/minecraft/core/Registry;Lnet/minecraft/world/level/levelgen/WorldGenerationContext;)V")) diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/release_protochunks/ChunkHolderMixin.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/release_protochunks/ChunkHolderMixin.java index 670e49148..9f03f74c6 100644 --- a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/release_protochunks/ChunkHolderMixin.java +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/release_protochunks/ChunkHolderMixin.java @@ -3,7 +3,6 @@ import net.minecraft.server.level.ChunkHolder; import net.minecraft.server.level.ChunkLevel; import net.minecraft.server.level.ChunkMap; -import net.minecraft.server.level.FullChunkStatus; import net.minecraft.server.level.GenerationChunkHolder; import net.minecraft.world.level.ChunkPos; import org.embeddedt.modernfix.duck.release_protochunks.IClearableChunkHolder; @@ -62,7 +61,7 @@ private void markForSuspensionOnDemotion(ChunkMap chunkMap, Executor executor, C } private void mfix$markAsNeedingProtoChunkDrop() { - if (!ChunkLevel.fullStatus(this.ticketLevel).isOrAfter(FullChunkStatus.FULL) + if (this.ticketLevel >= LOWEST_DROPPABLE_TICKET_LEVEL && ChunkLevel.isLoaded(this.ticketLevel)) { // register for suspension check when chain completes var map = ((ISuspendedHolderTrackingChunkMap)this.playerProvider); diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/release_protochunks/ChunkMapMixin.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/release_protochunks/ChunkMapMixin.java index 675f0ef32..deedccb6b 100644 --- a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/release_protochunks/ChunkMapMixin.java +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/release_protochunks/ChunkMapMixin.java @@ -5,7 +5,6 @@ import net.minecraft.server.level.ChunkHolder; import net.minecraft.server.level.ChunkLevel; import net.minecraft.server.level.ChunkMap; -import net.minecraft.server.level.FullChunkStatus; import net.minecraft.util.thread.BlockableEventLoop; import net.minecraft.world.level.ChunkPos; import org.embeddedt.modernfix.duck.release_protochunks.IClearableChunkHolder; @@ -62,7 +61,7 @@ private void dropProtoChunks(BooleanSupplier hasMoreTime, CallbackInfo ci) { long pos = entry.getLongKey(); ChunkHolder holder = this.updatingChunkMap.get(pos); if (holder == null // already removed - || ChunkLevel.fullStatus(holder.getTicketLevel()).isOrAfter(FullChunkStatus.FULL) // promoted to FULL + || holder.getTicketLevel() < IClearableChunkHolder.LOWEST_DROPPABLE_TICKET_LEVEL // promoted to FULL or adjacent to FULL chunk || !ChunkLevel.isLoaded(holder.getTicketLevel()) // is going to be dropped through normal code path ) { dropIterator.remove(); diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/release_protochunks/ImposterProtoChunkMixin.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/release_protochunks/ImposterProtoChunkMixin.java new file mode 100644 index 000000000..e97d27d37 --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/release_protochunks/ImposterProtoChunkMixin.java @@ -0,0 +1,46 @@ +package org.embeddedt.modernfix.common.mixin.perf.release_protochunks; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.sugar.Local; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.chunk.ImposterProtoChunk; +import org.embeddedt.modernfix.ModernFix; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; + +@Mixin(ImposterProtoChunk.class) +public class ImposterProtoChunkMixin { + @Shadow + @Final + private boolean allowWrites; + + /** + * @author embeddedt + * @reason This is a workaround for a very complicated and subtle vanilla issue. Vanilla uses ImposterProtoChunk as + * a way of exposing fully generated chunks to other chunks that are still working on earlier generation stages. + * The problem is that these fully generated chunks may be in two different states: promoted to FULL and already + * visible to the level (with real BlockEntity objects), or in a loaded but not yet promoted state, where the postload + * hook has not yet run to convert the NBT-serialized block entities from the disk into real BlockEntity objects. + * The former state is the problematic one. If such a chunk is exposed to worldgen, features/structures may try + * to interact with the block entity (e.g. by calling setChanged on it). This has the potential to deadlock. + *

+ * The solution we use here is to simply hide the existence of any "real" BE that has a level attached from worldgen. + * This is consistent with what other code would observe if the fully generated chunk were to be saved to disk + * and then reloaded (ending up in the latter state), so it should not break well-behaved mods. + *

+ * This problem occurs rather often with `mixin.perf.release_protochunks` enabled, because it significantly increases + * the chance of a promoted LevelChunk wrapped in ImposterProtoChunk being used for world generation. + */ + @ModifyExpressionValue(method = "getBlockEntity", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/chunk/LevelChunk;getBlockEntity(Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/block/entity/BlockEntity;")) + private BlockEntity avoidLeakingLiveBE(BlockEntity original, @Local(ordinal = 0, argsOnly = true) BlockPos pos) { + if (!this.allowWrites && original != null && original.getLevel() != null) { + ModernFix.LOGGER.debug("Blocked accessing the main level BlockEntity at {} from the ImposterProtoChunk wrapper, as this is unsafe during worldgen.", pos, new Exception("Stacktrace")); + return null; + } else { + return original; + } + } +} diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/resourcepacks/FilePackResourcesMixin.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/resourcepacks/FilePackResourcesMixin.java new file mode 100644 index 000000000..0c8591640 --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/resourcepacks/FilePackResourcesMixin.java @@ -0,0 +1,94 @@ +package org.embeddedt.modernfix.common.mixin.perf.resourcepacks; + +import net.minecraft.server.packs.FilePackResources; +import net.minecraft.server.packs.PackResources; +import net.minecraft.server.packs.PackType; +import org.embeddedt.modernfix.ModernFix; +import org.embeddedt.modernfix.annotation.FeatureLevel; +import org.embeddedt.modernfix.annotation.RequiresFeatureLevel; +import org.embeddedt.modernfix.resources.ZipPackIndex; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.io.IOException; +import java.util.Set; +import java.util.zip.ZipFile; + +@Mixin(FilePackResources.class) +@RequiresFeatureLevel(FeatureLevel.BETA) +public class FilePackResourcesMixin { + @Shadow + @Final + private FilePackResources.SharedZipFileAccess zipFileAccess; + + @Unique + @Nullable + private volatile ZipPackIndex mf$packIndex; + + @Unique + @Nullable + private ZipPackIndex mf$getOrCreateIndex() { + var index = mf$packIndex; + if (index == null) { + synchronized (this) { + index = mf$packIndex; + if (index == null) { + // Ensure the ZipFile is open first; if it fails, getOrCreateZipFile returns null. + var access = ((SharedZipFileAccessAccessor)this.zipFileAccess); + if (access.mfix$getOrCreateZipFile() == null) { + return null; + } + try { + mf$packIndex = index = new ZipPackIndex(access.mfix$getFile().toPath()); + } catch (IOException e) { + ModernFix.LOGGER.error("Failed to build zip index for {}", access.mfix$getFile(), e); + } + } + } + } + return index; + } + + /** + * @author embeddedt + * @reason use the index instead of scanning the whole zip + */ + @Inject(method = "getNamespaces", at = @At("HEAD"), cancellable = true) + private void mf$getNamespaces(PackType type, CallbackInfoReturnable> cir) { + ZipPackIndex index = mf$getOrCreateIndex(); + if (index != null) { + cir.setReturnValue(index.getNamespaces(type)); + } + } + + /** + * @author embeddedt + * @reason use the index instead of scanning the whole zip + */ + @Inject(method = "listResources", at = @At("HEAD"), cancellable = true) + private void mf$listResources(PackType packType, String namespace, String path, + PackResources.ResourceOutput resourceOutput, CallbackInfo ci) { + ZipFile zf = ((SharedZipFileAccessAccessor)this.zipFileAccess).mfix$getOrCreateZipFile(); + ZipPackIndex index = mf$getOrCreateIndex(); + if (index != null && zf != null) { + index.listResources(packType, namespace, path, zf, resourceOutput); + ci.cancel(); + } + } + + /** + * Drop the index when the pack is closed so it can be rebuilt cleanly if the + * pack is ever re-opened. + */ + @Inject(method = "close", at = @At("HEAD")) + private void mf$invalidateIndex(CallbackInfo ci) { + mf$packIndex = null; + } +} diff --git a/src/main/java/org/embeddedt/modernfix/common/mixin/perf/resourcepacks/SharedZipFileAccessAccessor.java b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/resourcepacks/SharedZipFileAccessAccessor.java new file mode 100644 index 000000000..35d1d74f2 --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/resourcepacks/SharedZipFileAccessAccessor.java @@ -0,0 +1,18 @@ +package org.embeddedt.modernfix.common.mixin.perf.resourcepacks; + +import net.minecraft.server.packs.FilePackResources; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +import java.io.File; +import java.util.zip.ZipFile; + +@Mixin(FilePackResources.SharedZipFileAccess.class) +public interface SharedZipFileAccessAccessor { + @Invoker("getOrCreateZipFile") + ZipFile mfix$getOrCreateZipFile(); + + @Accessor("file") + File mfix$getFile(); +} diff --git a/src/main/java/org/embeddedt/modernfix/core/ModernFixMixinPlugin.java b/src/main/java/org/embeddedt/modernfix/core/ModernFixMixinPlugin.java index 995a92a0c..8a09e7d20 100644 --- a/src/main/java/org/embeddedt/modernfix/core/ModernFixMixinPlugin.java +++ b/src/main/java/org/embeddedt/modernfix/core/ModernFixMixinPlugin.java @@ -3,8 +3,11 @@ import com.google.common.collect.ImmutableSet; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.embeddedt.modernfix.annotation.FeatureLevel; +import org.embeddedt.modernfix.core.config.BuiltInOptions; import org.embeddedt.modernfix.core.config.ModernFixEarlyConfig; import org.embeddedt.modernfix.core.config.Option; +import org.embeddedt.modernfix.core.config.OptionType; import org.embeddedt.modernfix.platform.ModernFixPlatformHooks; import org.embeddedt.modernfix.world.ThreadDumper; import org.objectweb.asm.Opcodes; @@ -39,6 +42,11 @@ public ModernFixMixinPlugin() { this.logger.info("Loaded configuration file for ModernFix {}: {} options available, {} override(s) found", ModernFixPlatformHooks.INSTANCE.getVersionString(), config.getOptionCount(), config.getOptionOverrideCount()); + if(activeFeatureLevel() != FeatureLevel.GA) { + this.logger.warn("ModernFix stability level is set to {}. Features at this level may be unstable or cause crashes.", + activeFeatureLevel()); + } + config.getOptionMap().values().forEach(option -> { if (option.isOverridden()) { String source = "[unknown]"; @@ -51,7 +59,7 @@ public ModernFixMixinPlugin() { source = "mods [" + String.join(", ", option.getDefiningMods()) + "]"; } this.logger.warn("Option '{}' overriden (by {}) to '{}'", option.getName(), - source, option.isEnabled()); + source, option.getValue()); } }); @@ -127,14 +135,21 @@ public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { } String mixin = mixinClassName.substring(MIXIN_PACKAGE_ROOT.length()); - if(!instance.isOptionEnabled(mixin)) + if(!instance.isOptionEnabled(mixin)) { + this.logger.debug("Skipping mixin {}: disabled by configuration", mixin); return false; + } String disabledBecauseMod = instance.config.getPermanentlyDisabledMixins().get(mixin); - return disabledBecauseMod == null; + if(disabledBecauseMod != null) { + this.logger.debug("Skipping mixin {}: disabled for mod compat ({})", mixin, disabledBecauseMod); + return false; + } + this.logger.debug("Applying mixin {}", mixin); + return true; } public boolean isOptionEnabled(String mixin) { - Option option = instance.config.getEffectiveOptionForMixin(mixin); + Option option = instance.config.getEffectiveOptionForMixin(mixin); if (option == null) { String msg = "No rules matched mixin '{}', treating as foreign and disabling!"; @@ -146,8 +161,17 @@ public boolean isOptionEnabled(String mixin) { return false; } - return option.isEnabled(); + return option.getType() == OptionType.BOOLEAN && option.asBoolean().getValue(); } + + public T getOptionValue(String optionName, Class type) { + return this.config.getOptionValue(optionName, type); + } + + public static FeatureLevel activeFeatureLevel() { + return instance.getOptionValue(BuiltInOptions.STABILITY_LEVEL, FeatureLevel.class); + } + @Override public void acceptTargets(Set myTargets, Set otherTargets) { diff --git a/src/main/java/org/embeddedt/modernfix/core/config/BuiltInOptions.java b/src/main/java/org/embeddedt/modernfix/core/config/BuiltInOptions.java new file mode 100644 index 000000000..5b4fb3bb0 --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/core/config/BuiltInOptions.java @@ -0,0 +1,5 @@ +package org.embeddedt.modernfix.core.config; + +public class BuiltInOptions { + public static final String STABILITY_LEVEL = "stability_level"; +} diff --git a/src/main/java/org/embeddedt/modernfix/core/config/ModernFixEarlyConfig.java b/src/main/java/org/embeddedt/modernfix/core/config/ModernFixEarlyConfig.java index 72e16ecaa..0627cc1b5 100644 --- a/src/main/java/org/embeddedt/modernfix/core/config/ModernFixEarlyConfig.java +++ b/src/main/java/org/embeddedt/modernfix/core/config/ModernFixEarlyConfig.java @@ -9,7 +9,9 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.embeddedt.modernfix.annotation.ClientOnlyMixin; +import org.embeddedt.modernfix.annotation.FeatureLevel; import org.embeddedt.modernfix.annotation.IgnoreOutsideDev; +import org.embeddedt.modernfix.annotation.RequiresFeatureLevel; import org.embeddedt.modernfix.annotation.RequiresMod; import org.embeddedt.modernfix.core.ModernFixMixinPlugin; import org.embeddedt.modernfix.platform.ModernFixPlatformHooks; @@ -35,8 +37,8 @@ public class ModernFixEarlyConfig { private static final Logger LOGGER = LogManager.getLogger("ModernFixConfig"); - private final Map options = new HashMap<>(); - private final Multimap optionsByCategory = HashMultimap.create(); + private final Map> options = new HashMap<>(); + private final Multimap> optionsByCategory = HashMultimap.create(); private static final boolean ALLOW_OVERRIDE_OVERRIDES = Boolean.getBoolean("modernfix.unsupported.allowOverriding"); @@ -65,6 +67,7 @@ private static boolean modPresent(String modId) { private static final String MIXIN_CLIENT_ONLY_DESC = Type.getDescriptor(ClientOnlyMixin.class); private static final String MIXIN_REQUIRES_MOD_DESC = Type.getDescriptor(RequiresMod.class); private static final String MIXIN_DEV_ONLY_DESC = Type.getDescriptor(IgnoreOutsideDev.class); + private static final String FEATURE_LEVEL_ANNOTATION_DESC = Type.getDescriptor(RequiresFeatureLevel.class); private static final Pattern PLATFORM_PREFIX = Pattern.compile("(neoforge|fabric|common)\\."); @@ -75,12 +78,60 @@ public static String sanitize(String mixinClassName) { private final Set mixinOptions = new ObjectOpenHashSet<>(); private final Map mixinsMissingMods = new Object2ObjectOpenHashMap<>(); + private final Map mixinsRequiringLowerStability = new Object2ObjectOpenHashMap<>(); + + private static class PackageMetadata { + String requiredModId; + FeatureLevel requiredLevel; + } + + private final Map packageMetadataCache = new HashMap<>(); + public static boolean isFabric = ModernFixEarlyConfig.class.getClassLoader().getResourceAsStream("modernfix-fabric.mixins.json") != null; public Map getPermanentlyDisabledMixins() { return mixinsMissingMods; } + @SuppressWarnings("unchecked") + private static T getAnnotationValue(AnnotationNode ann, String key) { + if (ann.values == null) return null; + for (int i = 0; i < ann.values.size(); i += 2) { + if (ann.values.get(i).equals(key)) return (T) ann.values.get(i + 1); + } + return null; + } + + private PackageMetadata loadPackageMetadata(String packageResourcePath) { + String classPath = packageResourcePath + "/package-info.class"; + try (InputStream stream = ModernFixEarlyConfig.class.getClassLoader().getResourceAsStream(classPath)) { + if (stream == null) return new PackageMetadata(); + ClassReader reader = new ClassReader(stream); + ClassNode node = new ClassNode(); + reader.accept(node, ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG); + PackageMetadata meta = new PackageMetadata(); + List annotations = new ArrayList<>(); + if (node.invisibleAnnotations != null) annotations.addAll(node.invisibleAnnotations); + if (node.visibleAnnotations != null) annotations.addAll(node.visibleAnnotations); + for (AnnotationNode annotation : annotations) { + if (Objects.equals(annotation.desc, MIXIN_REQUIRES_MOD_DESC)) { + meta.requiredModId = getAnnotationValue(annotation, "value"); + } else if (Objects.equals(annotation.desc, FEATURE_LEVEL_ANNOTATION_DESC)) { + String[] enumVal = getAnnotationValue(annotation, "value"); + meta.requiredLevel = FeatureLevel.valueOf(enumVal[1]); + } + } + return meta; + } catch (IOException e) { + LOGGER.error("Error scanning package-info " + classPath, e); + return new PackageMetadata(); + } + } + + private PackageMetadata getOrLoadPackageMetadata(String packageResourcePath) { + return packageMetadataCache.computeIfAbsent(packageResourcePath, this::loadPackageMetadata); + } + private void scanForAndBuildMixinOptions() { List configFiles = ImmutableList.of("modernfix-modernfix.mixins.json"); List mixinPaths = new ArrayList<>(); @@ -112,24 +163,48 @@ private void scanForAndBuildMixinOptions() { return; boolean isMixin = false, isClientOnly = false, requiredModPresent = true, isDevOnly = false; String requiredModId = ""; + FeatureLevel requiredLevel = FeatureLevel.GA; for(AnnotationNode annotation : node.invisibleAnnotations) { if(Objects.equals(annotation.desc, MIXIN_DESC)) { isMixin = true; } else if(Objects.equals(annotation.desc, MIXIN_CLIENT_ONLY_DESC)) { isClientOnly = true; } else if(Objects.equals(annotation.desc, MIXIN_REQUIRES_MOD_DESC)) { - for(int i = 0; i < annotation.values.size(); i += 2) { - if(annotation.values.get(i).equals("value")) { - String modId = (String)annotation.values.get(i + 1); - if(modId != null) { - requiredModPresent = modId.startsWith("!") ? !modPresent(modId.substring(1)) : modPresent(modId); - requiredModId = modId; - } - break; - } + String modId = getAnnotationValue(annotation, "value"); + if(modId != null) { + requiredModPresent = modId.startsWith("!") ? !modPresent(modId.substring(1)) : modPresent(modId); + requiredModId = modId; } } else if(Objects.equals(annotation.desc, MIXIN_DEV_ONLY_DESC)) { isDevOnly = true; + } else if(Objects.equals(annotation.desc, FEATURE_LEVEL_ANNOTATION_DESC)) { + // ASM stores enum annotation values as String[]{typeDescriptor, constantName} + String[] enumVal = getAnnotationValue(annotation, "value"); + requiredLevel = FeatureLevel.valueOf(enumVal[1]); + } + } + // Merge constraints from ancestor package-info files (up to the mixin root) + String classPackagePath = mixinPath.substring(0, mixinPath.lastIndexOf('/')); + int mixinRootEnd = classPackagePath.indexOf("/mixin"); + if (mixinRootEnd >= 0) { + String mixinRoot = classPackagePath.substring(0, mixinRootEnd + "/mixin".length()); + String walkPkg = mixinRoot; + while (walkPkg.length() < classPackagePath.length()) { + int nextSlash = classPackagePath.indexOf('/', walkPkg.length() + 1); + walkPkg = (nextSlash == -1) ? classPackagePath : classPackagePath.substring(0, nextSlash); + PackageMetadata pkgMeta = getOrLoadPackageMetadata(walkPkg); + if (requiredModPresent && pkgMeta.requiredModId != null) { + boolean present = pkgMeta.requiredModId.startsWith("!") + ? !modPresent(pkgMeta.requiredModId.substring(1)) + : modPresent(pkgMeta.requiredModId); + if (!present) { + requiredModPresent = false; + requiredModId = pkgMeta.requiredModId; + } + } + if (pkgMeta.requiredLevel != null && pkgMeta.requiredLevel.ordinal() > requiredLevel.ordinal()) { + requiredLevel = pkgMeta.requiredLevel; + } } } if(isMixin && (!isDevOnly || ModernFixPlatformHooks.INSTANCE.isDevEnv())) { @@ -138,6 +213,12 @@ private void scanForAndBuildMixinOptions() { mixinsMissingMods.put(mixinClassName, requiredModId); else if(isClientOnly && !ModernFixPlatformHooks.INSTANCE.isClient()) mixinsMissingMods.put(mixinClassName, "[not client]"); + + // Store the required stability level so it can be checked later + if (requiredLevel != FeatureLevel.GA) { + mixinsRequiringLowerStability.put(mixinClassName, requiredLevel); + } + String mixinCategoryName = "mixin." + mixinClassName.substring(0, mixinClassName.lastIndexOf('.')); mixinOptions.add(mixinCategoryName); } @@ -189,13 +270,6 @@ public DefaultSettingMapBuilder put(String key, Boolean value) { .put("mixin.devenv", isDevEnv) .putConditionally(() -> !isFabric, "mixin.bugfix.fix_config_crashes", true) .putConditionally(() -> !isFabric, "mixin.feature.registry_event_progress", true) - .putConditionally(() -> isFabric, "mixin.perf.clear_fabric_mapping_tables", false) - // Beta (promote on next release) - .put("mixin.perf.compact_entity_models", false) - .put("mixin.perf.dynamic_languages", false) - .put("mixin.perf.faster_capabilities.bytecode_analysis", false) - .put("mixin.perf.ingredient_item_deduplication", false) - // END .build(); private ModernFixEarlyConfig(File file) { @@ -205,16 +279,17 @@ private ModernFixEarlyConfig(File file) { mixinOptions.addAll(DEFAULT_SETTING_OVERRIDES.keySet()); for(String optionName : mixinOptions) { boolean defaultEnabled = DEFAULT_SETTING_OVERRIDES.getOrDefault(optionName, true); - Option option = new Option(optionName, defaultEnabled, false); + Option option = new Option<>(optionName, OptionType.BOOLEAN, defaultEnabled, false); this.options.putIfAbsent(optionName, option); this.optionsByCategory.put(OptionCategories.getCategoryForOption(optionName), option); } - for(Map.Entry entry : this.options.entrySet()) { + this.addBuiltInOptions(); + for(Map.Entry> entry : this.options.entrySet()) { int idx = entry.getKey().lastIndexOf('.'); if(idx <= 0) continue; String potentialParentKey = entry.getKey().substring(0, idx); - Option potentialParent = this.options.get(potentialParentKey); + Option potentialParent = this.options.get(potentialParentKey); if(potentialParent != null) { entry.getValue().setParent(potentialParent); } @@ -266,7 +341,7 @@ private void checkBlockstateCacheRebuilds() { ModernFixEarlyConfig.class.getClassLoader().getResource("/net/minecraft/world/level/Level.class"); if(deobfClass == null) { LOGGER.warn("We are in a non-Mojmap dev environment. Disabling blockstate cache patch"); - this.options.get("mixin.perf.reduce_blockstate_cache_rebuilds").addModOverride(false, "[not mojmap]"); + this.options.get("mixin.perf.reduce_blockstate_cache_rebuilds").asBoolean().addModOverride(false, "[not mojmap]"); } } catch(Throwable e) { e.printStackTrace(); @@ -275,10 +350,10 @@ private void checkBlockstateCacheRebuilds() { private void checkModelDataManager() { if(!isFabric && modPresent("rubidium") && !modPresent("embeddium")) { - Option option = this.options.get("mixin.bugfix.model_data_manager_cme"); + Option option = this.options.get("mixin.bugfix.model_data_manager_cme"); if(option != null) { LOGGER.warn("ModelDataManager bugfixes have been disabled to prevent broken rendering with Rubidium installed. Please migrate to Embeddium."); - option.addModOverride(false, "rubidium"); + option.asBoolean().addModOverride(false, "rubidium"); } } } @@ -286,13 +361,21 @@ private void checkModelDataManager() { private void disableIfModPresent(String configName, String... ids) { for(String id : ids) { if(!ModernFixPlatformHooks.INSTANCE.isEarlyLoadingNormally() || modPresent(id)) { - Option option = this.options.get(configName); + Option option = this.options.get(configName); if(option != null) - option.addModOverride(false, id); + option.asBoolean().addModOverride(false, id); } } } + private void addBuiltInOption(String name, OptionType type, T initialValue) { + this.options.putIfAbsent(name, new Option<>(name, type, initialValue, false)); + } + + private void addBuiltInOptions() { + this.addBuiltInOption(BuiltInOptions.STABILITY_LEVEL, OptionType.enumType(FeatureLevel.class), FeatureLevel.GA); + } + /** * Defines a Mixin rule which can be configured by users and other mods. * @throws IllegalStateException If a rule with that name already exists @@ -302,7 +385,7 @@ private void disableIfModPresent(String configName, String... ids) { private void addMixinRule(String mixin, boolean enabled) { String name = getMixinRuleName(mixin); - if (this.options.putIfAbsent(name, new Option(name, enabled, false)) != null) { + if (this.options.putIfAbsent(name, new Option<>(name, OptionType.BOOLEAN, enabled, false)) != null) { throw new IllegalStateException("Mixin rule already defined: " + mixin); } } @@ -312,9 +395,12 @@ private void readJVMProperties() { String value = System.getProperty("modernfix.config." + optionKey); if(value == null || value.length() == 0) continue; - boolean isEnabled = Boolean.valueOf(value); - ModernFixMixinPlugin.instance.logger.info("Configured {} to '{}' via JVM property.", optionKey, isEnabled); - this.options.get(optionKey).setEnabled(isEnabled, true); + try { + this.options.get(optionKey).setFromString(value, true); + ModernFixMixinPlugin.instance.logger.info("Configured {} to '{}' via JVM property.", optionKey, value); + } catch(RuntimeException e) { + ModernFixMixinPlugin.instance.logger.warn("Invalid value '{}' for JVM property '{}', ignoring", value, optionKey); + } } } @@ -352,27 +438,20 @@ private void readProperties(Properties props) { String key = (String) entry.getKey(); String value = (String) entry.getValue(); - Option option = this.options.get(key); + Option option = this.options.get(key); if (option == null) { LOGGER.warn("No configuration key exists with name '{}', ignoring", key); continue; } - boolean enabled; - - if (value.equalsIgnoreCase("true")) { - enabled = true; - } else if (value.equalsIgnoreCase("false")) { - enabled = false; - } else { - LOGGER.warn("Invalid value '{}' encountered for configuration key '{}', ignoring", value, key); - continue; - } - - if(ALLOW_OVERRIDE_OVERRIDES || !option.isModDefined()) - option.setEnabled(enabled, true); - else + if(ALLOW_OVERRIDE_OVERRIDES || !option.isModDefined()) { + try { + option.setFromString(value, true); + } catch(RuntimeException e) { + LOGGER.warn("Invalid value '{}' encountered for configuration key '{}', ignoring", value, key); + } + } else LOGGER.warn("Option '{}' already disabled by a mod. Ignoring user configuration", key); } } @@ -385,21 +464,21 @@ private void readProperties(Properties props) { * * @return Null if no options matched the given mixin name, otherwise the effective option for this Mixin */ - public Option getEffectiveOptionForMixin(String mixinClassName) { + public Option getEffectiveOptionForMixin(String mixinClassName) { int lastSplit = 0; int nextSplit; - Option rule = null; + Option rule = null; while ((nextSplit = mixinClassName.indexOf('.', lastSplit)) != -1) { String key = getMixinRuleName(mixinClassName.substring(0, nextSplit)); - Option candidate = this.options.get(key); + Option candidate = this.options.get(key); if (candidate != null) { rule = candidate; - if (!rule.isEnabled()) { + if (!rule.asBoolean().getValue()) { return rule; } } @@ -435,11 +514,25 @@ public static ModernFixEarlyConfig load(File file) { config.readGlobalProperties(); config.readJVMProperties(); + + config.finalizeLoad(); } return config; } + /** + * Called after all properties have been read. + */ + public void finalizeLoad() { + var stabilityLevel = this.getOptionValue(BuiltInOptions.STABILITY_LEVEL, FeatureLevel.class); + for (var entry : mixinsRequiringLowerStability.entrySet()) { + if (!stabilityLevel.isAtLeast(entry.getValue())) { + mixinsMissingMods.put(entry.getKey(), "[feature level: requires " + entry.getValue() + "]"); + } + } + } + public void save() throws IOException { File dir = configFile.getParentFile(); @@ -463,6 +556,9 @@ public void save() throws IOException { writer.write("# mixin.perf.dynamic_resources=true\n"); writer.write("# Do not include the #. You may reset to defaults by deleting this file.\n"); writer.write("#\n"); + writer.write("# To enable features that are still in testing, add a line at the bottom setting the stability level:\n"); + writer.write("# stability_level=BETA\n"); + writer.write("#\n"); writer.write("# Available options:\n"); List keys = this.options.keySet().stream() .filter(key -> !key.equals("mixin.core")) @@ -470,11 +566,11 @@ public void save() throws IOException { .collect(Collectors.toList()); for(String line : keys) { if(!line.equals("mixin.core")) { - Option option = this.options.get(line); + Option option = this.options.get(line); String extraContext = ""; if(option != null) { if(!option.isUserDefined()) - extraContext = "=" + option.isEnabled() + " # " + (option.isModDefined() ? "(overridden for mod compat)" : "(default)"); + extraContext = "=" + option.getSerializedValue() + " # " + (option.isModDefined() ? "(overridden for mod compat)" : "(default)"); else { boolean defaultEnabled = DEFAULT_SETTING_OVERRIDES.getOrDefault(line, true); extraContext = "=" + defaultEnabled + " # (default)"; @@ -488,9 +584,9 @@ public void save() throws IOException { writer.write("# User overrides go here.\n"); for (String key : keys) { - Option option = this.options.get(key); + Option option = this.options.get(key); if(option.isUserDefined()) - writer.write(key + "=" + option.isEnabled() + "\n"); + writer.write(key + "=" + option.getSerializedValue() + "\n"); } } } @@ -510,11 +606,21 @@ public int getOptionOverrideCount() { .count(); } - public Map getOptionMap() { + public Map> getOptionMap() { return Collections.unmodifiableMap(this.options); } - public Multimap getOptionCategoryMap() { + public Multimap> getOptionCategoryMap() { return Multimaps.unmodifiableMultimap(this.optionsByCategory); } + + public T getOptionValue(String optionName, Class type) { + var option = this.options.get(optionName); + + if (option == null) { + throw new IllegalStateException("Attempting to read option '" + optionName + "' that is not registered!"); + } + + return option.asType(type).getValue(); + } } diff --git a/src/main/java/org/embeddedt/modernfix/core/config/Option.java b/src/main/java/org/embeddedt/modernfix/core/config/Option.java index 6eac94345..15f2da2fe 100644 --- a/src/main/java/org/embeddedt/modernfix/core/config/Option.java +++ b/src/main/java/org/embeddedt/modernfix/core/config/Option.java @@ -3,33 +3,44 @@ import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; +import java.util.Objects; import java.util.Set; -public class Option { +public class Option { private final String name; + private final OptionType type; private Set modDefined = null; - private boolean enabled; + private T value; private boolean userDefined; - private Option parent = null; + private Option parent = null; - public Option(String name, boolean enabled, boolean userDefined) { + public Option(String name, OptionType type, T value, boolean userDefined) { this.name = name; - this.enabled = enabled; + this.type = type; + this.value = value; this.userDefined = userDefined; } - public void setEnabled(boolean enabled, boolean userDefined) { - if(this.enabled == enabled) + public OptionType getType() { + return this.type; + } + + public T getValue() { + return this.value; + } + + public void setValue(T value, boolean userDefined) { + if(Objects.equals(this.value, value)) return; - this.enabled = enabled; + this.value = value; this.userDefined = userDefined; } - public void addModOverride(boolean enabled, String modId) { - if(this.enabled == enabled) + public void addModOverride(T value, String modId) { + if(Objects.equals(this.value, value)) return; - this.enabled = enabled; + this.value = value; if (this.modDefined == null) { this.modDefined = new LinkedHashSet<>(); @@ -38,11 +49,31 @@ public void addModOverride(boolean enabled, String modId) { this.modDefined.add(modId); } - public void setParent(Option option) { + public String getSerializedValue() { + return this.type.serialize(this.value); + } + + public void setFromString(String value, boolean userDefined) { + setValue(this.type.parse(value), userDefined); + } + + @SuppressWarnings("unchecked") + public Option asType(Class type) { + if (this.type.type() != type) { + throw new IllegalStateException("Option '" + this.name + "' is not an option of type " + type.getName()); + } + return (Option) this; + } + + public Option asBoolean() { + return asType(Boolean.class); + } + + public void setParent(Option option) { this.parent = option; } - public Option getParent() { + public Option getParent() { return this.parent; } @@ -53,16 +84,12 @@ public int getDepth() { return this.parent.getDepth() + 1; } - public boolean isEnabled() { - return this.enabled; - } - /** * Checks if this option will effectively be disabled (regardless of its own status) * by the parent rule being disabled. */ public boolean isEffectivelyDisabledByParent() { - return this.parent != null && (!this.parent.enabled || this.parent.isEffectivelyDisabledByParent()); + return this.parent != null && (this.parent.type == OptionType.BOOLEAN && (!this.parent.asBoolean().getValue() || this.parent.isEffectivelyDisabledByParent())); } public boolean isOverridden() { @@ -88,15 +115,7 @@ public String getSelfName() { return this.name.substring(this.parent.getName().length() + 1); } - public void clearModsDefiningValue() { - this.modDefined = null; - } - - public void clearUserDefined() { - this.userDefined = false; - } - public Collection getDefiningMods() { return this.modDefined != null ? Collections.unmodifiableCollection(this.modDefined) : Collections.emptyList(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/embeddedt/modernfix/core/config/OptionType.java b/src/main/java/org/embeddedt/modernfix/core/config/OptionType.java new file mode 100644 index 000000000..46027f5a5 --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/core/config/OptionType.java @@ -0,0 +1,63 @@ +package org.embeddedt.modernfix.core.config; + +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; + +public abstract class OptionType { + public static final OptionType BOOLEAN = new OptionType() { + @Override + public Boolean parse(String s) { + if (s.equalsIgnoreCase("true")) { + return Boolean.TRUE; + } else if (s.equalsIgnoreCase("false")) { + return Boolean.FALSE; + } else { + throw new IllegalArgumentException(s); + } + } + + @Override + public String serialize(Boolean value) { + return value.toString(); + } + + @Override + public Class type() { + return Boolean.class; + } + }; + + private static final ConcurrentHashMap>, OptionType>> ENUM_TYPES = new ConcurrentHashMap<>(); + + private OptionType() { + + } + + public abstract Class type(); + public abstract T parse(String s) throws IllegalArgumentException; + public abstract String serialize(T value); + + private static > OptionType createEnumType(Class enumClass) { + return new OptionType<>() { + @Override + public T parse(String s) throws IllegalArgumentException { + return Enum.valueOf(enumClass, s.toUpperCase(Locale.ROOT)); + } + + @Override + public String serialize(T value) { + return value.name(); + } + + @Override + public Class type() { + return enumClass; + } + }; + } + + @SuppressWarnings("unchecked") + public static > OptionType enumType(Class enumClass) { + return (OptionType)ENUM_TYPES.computeIfAbsent(enumClass, k -> createEnumType((Class)k)); + } +} diff --git a/src/main/java/org/embeddedt/modernfix/duck/IChunkGenerator.java b/src/main/java/org/embeddedt/modernfix/duck/IChunkGenerator.java index 3cf83acc8..312c5b441 100644 --- a/src/main/java/org/embeddedt/modernfix/duck/IChunkGenerator.java +++ b/src/main/java/org/embeddedt/modernfix/duck/IChunkGenerator.java @@ -1,9 +1,9 @@ package org.embeddedt.modernfix.duck; -import net.minecraft.core.RegistryAccess; +import net.minecraft.server.MinecraftServer; import java.nio.file.Path; public interface IChunkGenerator { - void mfix$setStrongholdCachePath(Path cachePath, RegistryAccess.Frozen registryAccess); + void mfix$setStrongholdCachePath(Path cachePath, MinecraftServer server); } diff --git a/src/main/java/org/embeddedt/modernfix/duck/release_protochunks/IClearableChunkHolder.java b/src/main/java/org/embeddedt/modernfix/duck/release_protochunks/IClearableChunkHolder.java index cb7d22a6a..42bde319c 100644 --- a/src/main/java/org/embeddedt/modernfix/duck/release_protochunks/IClearableChunkHolder.java +++ b/src/main/java/org/embeddedt/modernfix/duck/release_protochunks/IClearableChunkHolder.java @@ -1,5 +1,13 @@ package org.embeddedt.modernfix.duck.release_protochunks; +import net.minecraft.server.level.ChunkLevel; +import net.minecraft.server.level.FullChunkStatus; + public interface IClearableChunkHolder { + /** + * We don't want to drop FULL chunks, or chunks immediately surrouding FULL. So + 2 is the minimum we can drop. + */ + int LOWEST_DROPPABLE_TICKET_LEVEL = ChunkLevel.byStatus(FullChunkStatus.FULL) + 2; + void mfix$resetProtoChunkFutures(); } diff --git a/src/main/java/org/embeddedt/modernfix/neoforge/recipe/IngredientItemStacksSoftReference.java b/src/main/java/org/embeddedt/modernfix/neoforge/recipe/IngredientItemStacksSoftReference.java index ab118a963..ff4977ed0 100644 --- a/src/main/java/org/embeddedt/modernfix/neoforge/recipe/IngredientItemStacksSoftReference.java +++ b/src/main/java/org/embeddedt/modernfix/neoforge/recipe/IngredientItemStacksSoftReference.java @@ -11,28 +11,15 @@ public class IngredientItemStacksSoftReference extends SoftReference QUEUE = new ReferenceQueue<>(); - private static final Thread DISCARD_THREAD = new Thread(IngredientItemStacksSoftReference::clearReferences, "Ingredient reference clearing thread"); - - static { - DISCARD_THREAD.setPriority(Thread.NORM_PRIORITY + 2); - DISCARD_THREAD.setDaemon(true); - DISCARD_THREAD.start(); - } public IngredientItemStacksSoftReference(Ingredient ingredient, ItemStack[] stacks) { super(stacks, QUEUE); this.ingredient = ingredient; } - private static void clearReferences() { - while (true) { - Reference ref; - try { - ref = QUEUE.remove(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } + public static void clearReferences() { + Reference ref; + while ((ref = QUEUE.poll()) != null) { if (ref instanceof IngredientItemStacksSoftReference ingRef && (Object)ingRef.ingredient instanceof ExtendedIngredient extIng) { // Null out the reference to the SoftReference object, to allow the SoftReference itself to be garbage collected. extIng.mfix$clearReference(); diff --git a/src/main/java/org/embeddedt/modernfix/resources/ZipPackIndex.java b/src/main/java/org/embeddedt/modernfix/resources/ZipPackIndex.java new file mode 100644 index 000000000..39490930f --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/resources/ZipPackIndex.java @@ -0,0 +1,393 @@ +package org.embeddedt.modernfix.resources; + +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.ints.IntList; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.PackResources; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.resources.IoSupplier; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * An index over a zip file's central directory that allows efficient namespace listing + * and resource enumeration without iterating all entries on every call. + * + *

The index is built once at construction time by memory-mapping the zip's central + * directory and parsing it into a {@link DirNode} tree. All subsequent queries run in + * O(depth + k) time where k is the number of matching results. + * + *

The caller is responsible for opening and closing the {@link ZipFile}; this class + * only holds a read-only view of the zip's metadata via a mmap'd buffer. + */ +public class ZipPackIndex { + + // ------------------------------------------------------------------------- + // Zip structural constants (identical to EfficientZipFileSystem in blacksmith) + // ------------------------------------------------------------------------- + + private static final int EOCD_SIGNATURE = 0x06054b50; + private static final int EOCD_SIZE = 22; + private static final int EOCD_OFF_CD_SIZE = 12; + private static final int EOCD_OFF_CD_OFFSET = 16; + private static final int EOCD_MAX_COMMENT_LENGTH = 65535; + + private static final int CD_ENTRY_SIGNATURE = 0x02014b50; + private static final int CD_ENTRY_HEADER_SIZE = 46; + private static final int CD_OFF_FILENAME_LENGTH = 28; + private static final int CD_OFF_EXTRA_LENGTH = 30; + private static final int CD_OFF_COMMENT_LENGTH = 32; + + private static final IntList EMPTY_OFFSETS = IntList.of(); + + // ------------------------------------------------------------------------- + // DirNode + // ------------------------------------------------------------------------- + + static final class DirNode { + Map childDirs; + IntList fileChildOffsets; // offsets into cdBuffer for each direct file child + + DirNode() { + childDirs = new Object2ObjectOpenHashMap<>(); + fileChildOffsets = EMPTY_OFFSETS; + } + + void freeze() { + if (fileChildOffsets instanceof IntArrayList arrayList) { + arrayList.trim(); + } + childDirs = childDirs.isEmpty() ? Map.of() : Map.copyOf(childDirs); + for (DirNode child : childDirs.values()) { + child.freeze(); + } + } + } + + // ------------------------------------------------------------------------- + // Fields + // ------------------------------------------------------------------------- + + /** Central directory buffer (memory-mapped or heap-allocated fallback). May be null for empty/invalid zips. */ + private final ByteBuffer cdBuffer; + /** Top-level directories tracked by the index. */ + private final Set trackedTopLevelDirs; + /** Root of the directory tree, always non-null (may be empty but frozen). */ + private final DirNode root; + + // ------------------------------------------------------------------------- + // Construction + // ------------------------------------------------------------------------- + + /** + * Build an index from the zip at the given path. Does not open a {@link ZipFile} + * and does not keep a reference to one; the caller owns all {@link ZipFile} lifecycle. + * + * @throws IOException if the file cannot be read or its central directory cannot be parsed + */ + public ZipPackIndex(Path zipPath) throws IOException { + this.cdBuffer = readCentralDirectory(zipPath); + // Computed here (not statically) so that any loader-injected PackType values + // registered after class-load are included. + Set packTypeDirs = new HashSet<>(); + for (PackType type : PackType.values()) packTypeDirs.add(type.getDirectory()); + this.trackedTopLevelDirs = Set.copyOf(packTypeDirs); + this.root = buildTree(); + } + + private static SeekableByteChannel obtainChannel(Path filePath) throws IOException { + try { + return FileChannel.open(filePath, StandardOpenOption.READ); + } catch (Exception e) { + return Files.newByteChannel(filePath); + } + } + + private static ByteBuffer readCentralDirectory(Path filePath) throws IOException { + try (SeekableByteChannel channel = obtainChannel(filePath)) { + long fileSize = channel.size(); + if (fileSize < EOCD_SIZE) return null; + + int tailSize = (int) Math.min(fileSize, (long) EOCD_SIZE + EOCD_MAX_COMMENT_LENGTH); + ByteBuffer tail = ByteBuffer.allocate(tailSize); + tail.order(ByteOrder.LITTLE_ENDIAN); + + long tailStart = fileSize - tailSize; + while (tail.hasRemaining()) { + channel.position(tailStart + tail.position()); + int n = channel.read(tail); + if (n < 0) { + break; + } + } + if (tail.hasRemaining()) { + throw new IOException("Failed to read ZIP tail"); + } + tail.flip(); + + // Scan backwards for the EOCD signature and validate comment length. + int eocdPos = -1; + for (int i = tailSize - EOCD_SIZE; i >= 0; i--) { + if (tail.getInt(i) == EOCD_SIGNATURE) { + int commentLen = Short.toUnsignedInt(tail.getShort(i + 20)); + if (i + EOCD_SIZE + commentLen == tailSize) { + eocdPos = i; + break; + } + } + } + if (eocdPos < 0) return null; + + long cdSize = Integer.toUnsignedLong(tail.getInt(eocdPos + EOCD_OFF_CD_SIZE)); + long cdOffset = Integer.toUnsignedLong(tail.getInt(eocdPos + EOCD_OFF_CD_OFFSET)); + if (cdSize == 0) return null; + if (cdSize == 0xFFFFFFFFL || cdOffset == 0xFFFFFFFFL) { + throw new IOException("ZIP64 not supported by ZipPackIndex"); + } + if (cdOffset > fileSize - cdSize) { + throw new IOException("Invalid central directory range"); + } + + // Try memory-mapping first; fall back to a heap copy if the OS refuses. + if (channel instanceof FileChannel fc) { + try { + ByteBuffer buf = fc.map(FileChannel.MapMode.READ_ONLY, cdOffset, cdSize); + buf.order(ByteOrder.LITTLE_ENDIAN); + return buf; + } catch (Exception ignored) { + // mmap unavailable (e.g. some Linux mount flags, container restrictions); + // read the central directory into a heap buffer instead. + } + } + + ByteBuffer buf = ByteBuffer.allocate((int) cdSize); + buf.order(ByteOrder.LITTLE_ENDIAN); + while (buf.hasRemaining()) { + channel.position(cdOffset + buf.position()); + int n = channel.read(buf); + if (n < 0) throw new IOException("Truncated central directory during heap read"); + } + buf.flip(); + return buf; + } + } + + private DirNode buildTree() throws IOException { + var cdBuffer = this.cdBuffer; + + DirNode treeRoot = new DirNode(); + if (cdBuffer == null) { + treeRoot.freeze(); + return treeRoot; + } + + int pos = 0; + int limit = cdBuffer.limit(); + while (pos + CD_ENTRY_HEADER_SIZE <= limit) { + if (cdBuffer.getInt(pos) != CD_ENTRY_SIGNATURE) break; + pos += indexCdEntry(pos, limit, treeRoot, cdBuffer); + } + + treeRoot.freeze(); + return treeRoot; + } + + /** + * Parses the CD entry at {@code pos}, inserts it into the tree, and returns the + * number of bytes to advance {@code pos} (i.e. the full record length). + */ + private int indexCdEntry(int pos, int limit, + DirNode treeRoot, + ByteBuffer cdBuffer) throws IOException { + int fileNameLen = Short.toUnsignedInt(cdBuffer.getShort(pos + CD_OFF_FILENAME_LENGTH)); + int extraLen = Short.toUnsignedInt(cdBuffer.getShort(pos + CD_OFF_EXTRA_LENGTH)); + int commentLen = Short.toUnsignedInt(cdBuffer.getShort(pos + CD_OFF_COMMENT_LENGTH)); + int recordLen = CD_ENTRY_HEADER_SIZE + fileNameLen + extraLen + commentLen; + if (pos + recordLen > limit) { + throw new IOException("Truncated central directory"); + } + + byte[] nameBytes = new byte[fileNameLen]; + cdBuffer.get(pos + CD_ENTRY_HEADER_SIZE, nameBytes); + + DirNode current = treeRoot; + boolean tracked = false; + boolean skipped = false; + int segStart = 0; + + for (int i = 0; i < fileNameLen; i++) { + if (nameBytes[i] == '/') { + int segLen = i - segStart; + if (segLen > 0) { + String segment = new String(nameBytes, segStart, segLen, StandardCharsets.UTF_8); + if (!tracked) { + if (!trackedTopLevelDirs.contains(segment)) { skipped = true; break; } + tracked = true; + } + DirNode next = current.childDirs.get(segment); + //noinspection Java8MapApi + if (next == null) { + current.childDirs.put(segment, next = new DirNode()); + } + current = next; + } + segStart = i + 1; + } + } + + // A remaining non-empty segment after the last '/' is a file basename. + if (!skipped && tracked && segStart < fileNameLen) { + if (current.fileChildOffsets == EMPTY_OFFSETS) { + current.fileChildOffsets = new IntArrayList(); + } + current.fileChildOffsets.add(pos); + } + + return recordLen; + } + + // ------------------------------------------------------------------------- + // CD buffer reads — absolute-position gets are thread-safe on Java 13+ + // ------------------------------------------------------------------------- + + /** + * Extract the basename (the portion after the last '/') of the entry whose + * central-directory record starts at {@code cdOffset}. + */ + String readBasename(int cdOffset) { + int nameLen = Short.toUnsignedInt(cdBuffer.getShort(cdOffset + CD_OFF_FILENAME_LENGTH)); + byte[] nameBytes = new byte[nameLen]; + cdBuffer.get(cdOffset + CD_ENTRY_HEADER_SIZE, nameBytes); + int lastSlash = -1; + for (int i = nameBytes.length - 1; i >= 0; i--) { + if (nameBytes[i] == '/') { lastSlash = i; break; } + } + return new String(nameBytes, lastSlash + 1, nameLen - lastSlash - 1, StandardCharsets.UTF_8); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + public Set getTrackedTopLevelDirs() { + return this.trackedTopLevelDirs; + } + + /** + * Returns all namespaces present under the given pack type directory. + * + *

Equivalent to {@code FilePackResources.getNamespaces(type)} but reads from + * the pre-built tree rather than scanning all zip entries. + */ + public Set getNamespaces(PackType type) { + DirNode typeNode = root.childDirs.get(type.getDirectory()); + if (typeNode == null) return Set.of(); + Set result = new HashSet<>(); + for (String ns : typeNode.childDirs.keySet()) { + if (ns.equals(ns.toLowerCase(Locale.ROOT))) { + result.add(ns); + } + } + return result; + } + + public boolean hasResource(String... paths) { + var node = this.root; + for (int i = 0; i < paths.length - 1; i++) { + var path = paths[i]; + if (path.isEmpty()) { + continue; + } + node = node.childDirs.get(path); + if (node == null) { + return false; + } + } + String basename = paths[paths.length - 1]; + var offsets = node.fileChildOffsets; + for (int i = 0; i < offsets.size(); i++) { + if (basename.equals(readBasename(offsets.getInt(i)))) { + return true; + } + } + return false; + } + + /** + * Enumerate all resources under {@code type/namespace/path/} and deliver them + * to {@code output}. + * + *

Equivalent to {@code FilePackResources.listResources(type, namespace, path, output)} + * but uses the pre-built tree for O(k) traversal instead of a full zip scan. + * + * @param zipFile the open zip file, used only to supply {@link InputStream}s on demand; + * the caller retains ownership of its lifecycle + */ + public void listResources(PackType type, String namespace, String path, + ZipFile zipFile, PackResources.ResourceOutput output) { + DirNode node = root.childDirs.get(type.getDirectory()); + if (node == null) return; + node = node.childDirs.get(namespace); + if (node == null) return; + + // Walk to the requested sub-path + String rlSubPath; + if (!path.isEmpty()) { + for (String segment : path.split("/")) { + if (segment.isEmpty()) continue; + node = node.childDirs.get(segment); + if (node == null) return; + } + rlSubPath = path + "/"; + } else { + rlSubPath = ""; + } + + // entryPrefix = the part of the zip entry name before the ResourceLocation path + String entryPrefix = type.getDirectory() + "/" + namespace + "/"; + collectResources(node, entryPrefix, rlSubPath, zipFile, namespace, output); + } + + /** + * Recursively walk {@code node}, reconstructing zip entry names as we go and + * emitting each file to {@code output}. + * + * @param entryPrefix the constant prefix before the RL path, e.g. {@code "assets/minecraft/"} + * @param rlSubPath the RL-relative path accumulated so far, e.g. {@code "textures/block/"} + */ + private void collectResources(DirNode node, String entryPrefix, String rlSubPath, + ZipFile zipFile, String namespace, + PackResources.ResourceOutput output) { + // Emit direct file children of this node + var offsets = node.fileChildOffsets; + for (int i = 0; i < offsets.size(); i++) { + String basename = readBasename(offsets.getInt(i)); + String rlPathFull = rlSubPath + basename; + Identifier rl = Identifier.tryBuild(namespace, rlPathFull); + if (rl != null) { + ZipEntry entry = zipFile.getEntry(entryPrefix + rlPathFull); + if (entry != null) { + output.accept(rl, IoSupplier.create(zipFile, entry)); + } + } + } + // Recurse into subdirectories + for (Map.Entry child : node.childDirs.entrySet()) { + collectResources(child.getValue(), entryPrefix, + rlSubPath + child.getKey() + "/", zipFile, namespace, output); + } + } +} diff --git a/src/main/java/org/embeddedt/modernfix/screen/OptionList.java b/src/main/java/org/embeddedt/modernfix/screen/OptionList.java index 507bdcca0..56b63ecda 100644 --- a/src/main/java/org/embeddedt/modernfix/screen/OptionList.java +++ b/src/main/java/org/embeddedt/modernfix/screen/OptionList.java @@ -21,6 +21,7 @@ import org.embeddedt.modernfix.core.ModernFixMixinPlugin; import org.embeddedt.modernfix.core.config.Option; import org.embeddedt.modernfix.core.config.OptionCategories; +import org.embeddedt.modernfix.core.config.OptionType; import org.embeddedt.modernfix.platform.ModernFixPlatformHooks; import java.io.IOException; @@ -38,7 +39,7 @@ public class OptionList extends ContainerObjectSelectionList { private ModernFixConfigScreen mainScreen; - private static MutableComponent getOptionComponent(Option option) { + private static MutableComponent getOptionComponent(Option option) { String friendlyKey = "modernfix.option.name." + option.getName(); MutableComponent baseComponent = Component.literal(option.getSelfName()); if(I18n.exists(friendlyKey)) @@ -55,9 +56,12 @@ public void updateOptionEntryStatuses() { } } - private final Set