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 00000000..2153f6df --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/common/mixin/perf/resourcepacks/FilePackResourcesMixin.java @@ -0,0 +1,95 @@ +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.File; +import java.io.IOException; +import java.util.Set; +import java.util.zip.ZipFile; + +@Mixin(FilePackResources.class) +@RequiresFeatureLevel(FeatureLevel.BETA) +public class FilePackResourcesMixin { + @Final + @Shadow private File file; + + @Shadow @Nullable private ZipFile getOrCreateZipFile() { return null; } + + @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. + if (getOrCreateZipFile() == null) { + return null; + } + try { + mf$packIndex = index = new ZipPackIndex(file.toPath()); + } catch (IOException e) { + ModernFix.LOGGER.error("Failed to build zip index for {}", file, 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 = 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/resources/ZipPackIndex.java b/src/main/java/org/embeddedt/modernfix/resources/ZipPackIndex.java new file mode 100644 index 00000000..813d2d4f --- /dev/null +++ b/src/main/java/org/embeddedt/modernfix/resources/ZipPackIndex.java @@ -0,0 +1,311 @@ +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.MappedByteBuffer; +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. + * + *

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 int[] EMPTY_OFFSETS = new int[0]; + + // ------------------------------------------------------------------------- + // DirNode + // ------------------------------------------------------------------------- + + static final class DirNode { + Map 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 + // ------------------------------------------------------------------------- + + /** Memory-mapped central directory. May be null for empty/invalid zips. */ + private final MappedByteBuffer 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 mapped + */ + public ZipPackIndex(Path zipPath) throws IOException { + this.cdBuffer = mmapCentralDirectory(zipPath); + this.root = buildTree(); + } + + private static MappedByteBuffer mmapCentralDirectory(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 { + MappedByteBuffer buf = channel.map(FileChannel.MapMode.READ_ONLY, cdOffset, cdSize); + buf.order(ByteOrder.LITTLE_ENDIAN); + return buf; + } catch (RuntimeException e) { + throw new IOException("Failed to map central directory", e); + } + } + } + + private DirNode buildTree() throws IOException { + DirNode treeRoot = new DirNode(); + if (cdBuffer == null) { + treeRoot.freeze(); + return treeRoot; + } + + // Accumulate file offsets per DirNode before compacting to int[] + IdentityHashMap> 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("/"); + 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. + * + *

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; + } + + /** + * 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 + 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 child : node.childDirs.entrySet()) { + collectResources(child.getValue(), entryPrefix, + rlSubPath + child.getKey() + "/", zipFile, namespace, output); + } + } +}