Merge branch '1.20' into 1.21.1
This commit is contained in:
commit
12afd99b83
|
|
@ -2,11 +2,13 @@ package org.embeddedt.modernfix.common.mixin.perf.cache_strongholds;
|
|||
|
||||
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.Util;
|
||||
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.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
|
|
@ -17,6 +19,8 @@ import org.embeddedt.modernfix.duck.IChunkGenerator;
|
|||
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 +33,8 @@ import java.util.HashMap;
|
|||
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 +47,24 @@ public class ChunkGeneratorMixin implements IChunkGenerator {
|
|||
private BiomeSource biomeSource;
|
||||
|
||||
private Path mfix$dimensionPath;
|
||||
private RegistryAccess.Frozen mfix$registryAccess;
|
||||
private MinecraftServer mfix$server;
|
||||
|
||||
private SoftReference<Map<String, List<ChunkPos>>> 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<List<ChunkPos>> modernfix$cacheRingPositions(Holder<StructureSet> structureSet,
|
||||
ConcentricRingsStructurePlacement placement,
|
||||
Operation<CompletableFuture<List<ChunkPos>>> original) {
|
||||
if (this.mfix$registryAccess == null || this.mfix$dimensionPath == null) {
|
||||
ConcentricRingsStructurePlacement placement,
|
||||
Operation<CompletableFuture<List<ChunkPos>>> original,
|
||||
@Share("threadPool") LocalRef<ExecutorService> threadPoolRef) {
|
||||
if (this.mfix$server == null || this.mfix$dimensionPath == null) {
|
||||
return original.call(structureSet, placement);
|
||||
}
|
||||
|
||||
|
|
@ -69,14 +77,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(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;backgroundExecutor()Ljava/util/concurrent/ExecutorService;"))
|
||||
private ExecutorService useDedicatedService(@Share("threadPool") LocalRef<ExecutorService> threadPoolRef) {
|
||||
return threadPoolRef.get();
|
||||
}
|
||||
|
||||
private String mfix$makeCacheKey(ConcentricRingsStructurePlacement placement) {
|
||||
RegistryOps<Tag> ops = RegistryOps.create(NbtOps.INSTANCE, this.mfix$registryAccess);
|
||||
RegistryOps<Tag> 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)
|
||||
|
|
|
|||
|
|
@ -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 = "<init>(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<Boolean> cir) {
|
||||
long distSq = (long)x * x + (long)z * z;
|
||||
if (distSq < this.mfix$innerRadiusSq || distSq > this.mfix$outerRadiusSq) {
|
||||
cir.setReturnValue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ public class ServerLevelMixin {
|
|||
@Local(ordinal = 0, argsOnly = true) LevelStorageSource.LevelStorageAccess levelStorageAccess,
|
||||
@Local(ordinal = 0, argsOnly = true) ResourceKey<Level> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package org.embeddedt.modernfix.common.mixin.perf.release_protochunks;
|
|||
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 net.minecraft.world.level.chunk.ChunkAccess;
|
||||
|
|
@ -63,7 +62,7 @@ public abstract class ChunkHolderMixin extends GenerationChunkHolder implements
|
|||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
|||
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;
|
||||
|
|
@ -61,7 +60,7 @@ public abstract class ChunkMapMixin implements ISuspendedHolderTrackingChunkMap
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -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<Set<String>> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,330 @@
|
|||
package org.embeddedt.modernfix.resources;
|
||||
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
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.charset.StandardCharsets;
|
||||
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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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 int[] EMPTY_OFFSETS = new int[0];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DirNode
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static final class DirNode {
|
||||
Map<String, DirNode> childDirs;
|
||||
int[] fileChildOffsets; // offsets into cdBuffer for each direct file child
|
||||
|
||||
DirNode() {
|
||||
childDirs = new HashMap<>();
|
||||
fileChildOffsets = EMPTY_OFFSETS;
|
||||
}
|
||||
|
||||
void freeze() {
|
||||
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;
|
||||
/** 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);
|
||||
this.root = buildTree();
|
||||
}
|
||||
|
||||
private static ByteBuffer readCentralDirectory(Path filePath) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(filePath, StandardOpenOption.READ)) {
|
||||
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()) {
|
||||
int n = channel.read(tail, tailStart + tail.position());
|
||||
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.
|
||||
try {
|
||||
ByteBuffer buf = channel.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()) {
|
||||
int n = channel.read(buf, cdOffset + buf.position());
|
||||
if (n < 0) throw new IOException("Truncated central directory during heap read");
|
||||
}
|
||||
buf.flip();
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
private DirNode buildTree() throws IOException {
|
||||
DirNode treeRoot = new DirNode();
|
||||
if (cdBuffer == null) {
|
||||
treeRoot.freeze();
|
||||
return treeRoot;
|
||||
}
|
||||
|
||||
// Computed here (not statically) so that any loader-injected PackType values
|
||||
// registered after class-load are included.
|
||||
Set<String> packTypeDirs = new HashSet<>();
|
||||
for (PackType type : PackType.values()) packTypeDirs.add(type.getDirectory());
|
||||
|
||||
// Accumulate file offsets per DirNode before compacting to int[]
|
||||
IdentityHashMap<DirNode, List<Integer>> fileOffsets = new IdentityHashMap<>();
|
||||
|
||||
int pos = 0;
|
||||
int limit = cdBuffer.limit();
|
||||
while (pos + CD_ENTRY_HEADER_SIZE <= limit) {
|
||||
if (cdBuffer.getInt(pos) != CD_ENTRY_SIGNATURE) break;
|
||||
|
||||
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);
|
||||
String name = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
|
||||
boolean isDirectory = name.endsWith("/");
|
||||
if (isDirectory) name = name.substring(0, name.length() - 1);
|
||||
|
||||
if (!name.isEmpty()) {
|
||||
String[] parts = name.split("/");
|
||||
if (!packTypeDirs.contains(parts[0])) {
|
||||
pos += recordLen;
|
||||
continue;
|
||||
}
|
||||
DirNode current = treeRoot;
|
||||
int dirDepth = isDirectory ? parts.length : parts.length - 1;
|
||||
for (int i = 0; i < dirDepth; i++) {
|
||||
current = current.childDirs.computeIfAbsent(parts[i], k -> new DirNode());
|
||||
}
|
||||
if (!isDirectory) {
|
||||
fileOffsets.computeIfAbsent(current, k -> new ArrayList<>()).add(pos);
|
||||
}
|
||||
}
|
||||
|
||||
pos += recordLen;
|
||||
}
|
||||
|
||||
// Compact to int[] arrays
|
||||
fileOffsets.forEach((node, offsets) -> {
|
||||
int[] arr = new int[offsets.size()];
|
||||
for (int i = 0; i < arr.length; i++) arr[i] = offsets.get(i);
|
||||
node.fileChildOffsets = arr;
|
||||
});
|
||||
|
||||
treeRoot.freeze();
|
||||
return treeRoot;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns all namespaces present under the given pack type directory.
|
||||
*
|
||||
* <p>Equivalent to {@code FilePackResources.getNamespaces(type)} but reads from
|
||||
* the pre-built tree rather than scanning all zip entries.
|
||||
*/
|
||||
public Set<String> getNamespaces(PackType type) {
|
||||
DirNode typeNode = root.childDirs.get(type.getDirectory());
|
||||
if (typeNode == null) return Set.of();
|
||||
Set<String> result = new HashSet<>();
|
||||
for (String ns : typeNode.childDirs.keySet()) {
|
||||
if (ns.equals(ns.toLowerCase(Locale.ROOT))) {
|
||||
result.add(ns);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate all resources under {@code type/namespace/path/} and deliver them
|
||||
* to {@code output}.
|
||||
*
|
||||
* <p>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
|
||||
for (int cdOffset : node.fileChildOffsets) {
|
||||
String basename = readBasename(cdOffset);
|
||||
String rlPathFull = rlSubPath + basename;
|
||||
ResourceLocation rl = ResourceLocation.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<String, DirNode> child : node.childDirs.entrySet()) {
|
||||
collectResources(child.getValue(), entryPrefix,
|
||||
rlSubPath + child.getKey() + "/", zipFile, namespace, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user