Merge branch '1.20' into 1.21.1

This commit is contained in:
embeddedt 2026-05-24 20:58:09 -04:00
commit 12afd99b83
No known key found for this signature in database
GPG Key ID: A69433EC199B5613
10 changed files with 619 additions and 19 deletions

View File

@ -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)

View File

@ -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 += / 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);
}
}
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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();

View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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);
}
}
}