Optimize ZIP resource packs significantly
This commit is contained in:
parent
538c52bc2a
commit
62dbbea083
|
|
@ -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<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 = 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,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.
|
||||
*
|
||||
* <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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 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<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("/");
|
||||
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