Experimental Decompiler cache (#1043)

This commit is contained in:
modmuss
2024-03-17 22:28:47 +00:00
committed by GitHub
parent dbebbdb944
commit 7bb1224642
25 changed files with 2078 additions and 136 deletions

View File

@@ -58,7 +58,7 @@ public class SingleJarDecompileConfiguration extends DecompileConfiguration<Mapp
// Decompiler will be passed to the constructor of GenerateSourcesTask
project.getTasks().register(taskName, GenerateSourcesTask.class, options).configure(task -> {
task.getInputJarName().set(minecraftJar.getName());
task.getOutputJar().fileValue(GenerateSourcesTask.getMappedJarFileWithSuffix("-sources.jar", minecraftJar.getPath()));
task.getOutputJar().fileValue(GenerateSourcesTask.getJarFileWithSuffix("-sources.jar", minecraftJar.getPath()));
task.dependsOn(project.getTasks().named("validateAccessWidener"));
task.setDescription("Decompile minecraft using %s.".formatted(decompilerName));

View File

@@ -55,7 +55,7 @@ public final class SplitDecompileConfiguration extends DecompileConfiguration<Ma
final TaskProvider<Task> commonDecompileTask = createDecompileTasks("Common", task -> {
task.getInputJarName().set(commonJar.getName());
task.getOutputJar().fileValue(GenerateSourcesTask.getMappedJarFileWithSuffix("-sources.jar", commonJar.getPath()));
task.getOutputJar().fileValue(GenerateSourcesTask.getJarFileWithSuffix("-sources.jar", commonJar.getPath()));
if (mappingConfiguration.hasUnpickDefinitions()) {
File unpickJar = new File(extension.getMappingConfiguration().mappingsWorkingDir().toFile(), "minecraft-common-unpicked.jar");
@@ -65,7 +65,7 @@ public final class SplitDecompileConfiguration extends DecompileConfiguration<Ma
final TaskProvider<Task> clientOnlyDecompileTask = createDecompileTasks("ClientOnly", task -> {
task.getInputJarName().set(clientOnlyJar.getName());
task.getOutputJar().fileValue(GenerateSourcesTask.getMappedJarFileWithSuffix("-sources.jar", clientOnlyJar.getPath()));
task.getOutputJar().fileValue(GenerateSourcesTask.getJarFileWithSuffix("-sources.jar", clientOnlyJar.getPath()));
if (mappingConfiguration.hasUnpickDefinitions()) {
File unpickJar = new File(extension.getMappingConfiguration().mappingsWorkingDir().toFile(), "minecraft-clientonly-unpicked.jar");

View File

@@ -0,0 +1,158 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2019-2021 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.decompilers;
import static java.text.MessageFormat.format;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import org.jetbrains.annotations.Nullable;
public record ClassLineNumbers(Map<String, ClassLineNumbers.Entry> lineMap) {
public ClassLineNumbers {
Objects.requireNonNull(lineMap, "lineMap");
if (lineMap.isEmpty()) {
throw new IllegalArgumentException("lineMap is empty");
}
}
public static ClassLineNumbers readMappings(Path lineMappingsPath) {
try (BufferedReader reader = Files.newBufferedReader(lineMappingsPath)) {
return readMappings(reader);
} catch (IOException e) {
throw new UncheckedIOException("Exception reading LineMappings file.", e);
}
}
public static ClassLineNumbers readMappings(BufferedReader reader) {
var lineMap = new HashMap<String, ClassLineNumbers.Entry>();
String line = null;
int lineNumber = 0;
record CurrentClass(String className, int maxLine, int maxLineDest) {
void putEntry(Map<String, ClassLineNumbers.Entry> entries, Map<Integer, Integer> mappings) {
var entry = new ClassLineNumbers.Entry(className(), maxLine(), maxLineDest(), Collections.unmodifiableMap(mappings));
final ClassLineNumbers.Entry previous = entries.put(className(), entry);
if (previous != null) {
throw new IllegalStateException("Duplicate class line mappings for " + className());
}
}
}
CurrentClass currentClass = null;
Map<Integer, Integer> currentMappings = new HashMap<>();
try {
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
continue;
}
final String[] segments = line.trim().split("\t");
if (line.charAt(0) != '\t') {
if (currentClass != null) {
currentClass.putEntry(lineMap, currentMappings);
currentMappings = new HashMap<>();
}
currentClass = new CurrentClass(segments[0], Integer.parseInt(segments[1]), Integer.parseInt(segments[2]));
} else {
Objects.requireNonNull(currentClass, "No class line mappings found for line " + lineNumber);
currentMappings.put(Integer.parseInt(segments[0]), Integer.parseInt(segments[1]));
}
lineNumber++;
}
} catch (Exception e) {
throw new RuntimeException(format("Exception reading mapping line @{0}: {1}", lineNumber, line), e);
}
assert currentClass != null;
currentClass.putEntry(lineMap, currentMappings);
return new ClassLineNumbers(Collections.unmodifiableMap(lineMap));
}
public void write(Writer writer) throws IOException {
for (Map.Entry<String, ClassLineNumbers.Entry> entry : lineMap.entrySet()) {
entry.getValue().write(writer);
}
}
/**
* Merge two ClassLineNumbers together, throwing an exception if there are any duplicate class line mappings.
*/
@Nullable
public static ClassLineNumbers merge(@Nullable ClassLineNumbers a, @Nullable ClassLineNumbers b) {
if (a == null) {
return b;
} else if (b == null) {
return a;
}
var lineMap = new HashMap<>(a.lineMap());
for (Map.Entry<String, Entry> entry : b.lineMap().entrySet()) {
lineMap.merge(entry.getKey(), entry.getValue(), (v1, v2) -> {
throw new IllegalStateException("Duplicate class line mappings for " + entry.getKey());
});
}
return new ClassLineNumbers(Collections.unmodifiableMap(lineMap));
}
public record Entry(String className, int maxLine, int maxLineDest, Map<Integer, Integer> lineMap) {
public void write(Writer writer) throws IOException {
writer.write(className);
writer.write('\t');
writer.write(Integer.toString(maxLine));
writer.write('\t');
writer.write(Integer.toString(maxLineDest));
writer.write('\n');
for (Map.Entry<Integer, Integer> lineEntry : lineMap.entrySet()) {
writer.write('\t');
writer.write(Integer.toString(lineEntry.getKey()));
writer.write('\t');
writer.write(Integer.toString(lineEntry.getValue()));
writer.write('\n');
}
}
}
}

View File

@@ -24,11 +24,6 @@
package net.fabricmc.loom.decompilers;
import static java.text.MessageFormat.format;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileVisitResult;
@@ -37,57 +32,21 @@ import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashMap;
import java.util.Map;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.fabricmc.loom.util.Constants;
import net.fabricmc.loom.util.IOStringConsumer;
/**
* Created by covers1624 on 18/02/19.
*/
public class LineNumberRemapper {
private final Map<String, RClass> lineMap = new HashMap<>();
public record LineNumberRemapper(ClassLineNumbers lineNumbers) {
private static final Logger LOGGER = LoggerFactory.getLogger(LineNumberRemapper.class);
public void readMappings(File lineMappings) {
try (BufferedReader reader = new BufferedReader(new FileReader(lineMappings))) {
RClass clazz = null;
String line = null;
int i = 0;
try {
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
continue;
}
String[] segs = line.trim().split("\t");
if (line.charAt(0) != '\t') {
clazz = lineMap.computeIfAbsent(segs[0], RClass::new);
clazz.maxLine = Integer.parseInt(segs[1]);
clazz.maxLineDest = Integer.parseInt(segs[2]);
} else {
clazz.lineMap.put(Integer.parseInt(segs[0]), Integer.parseInt(segs[1]));
}
i++;
}
} catch (Exception e) {
throw new RuntimeException(format("Exception reading mapping line @{0}: {1}", i, line), e);
}
} catch (IOException e) {
throw new RuntimeException("Exception reading LineMappings file.", e);
}
}
public void process(IOStringConsumer logger, Path input, Path output) throws IOException {
public void process(Path input, Path output) throws IOException {
Files.walkFileTree(input, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
@@ -108,9 +67,7 @@ public class LineNumberRemapper {
String idx = rel.substring(0, rel.length() - 6);
if (logger != null) {
logger.accept("Remapping " + idx);
}
LOGGER.debug("Remapping line numbers for class: " + idx);
int dollarPos = idx.indexOf('$'); //This makes the assumption that only Java classes are to be remapped.
@@ -118,12 +75,12 @@ public class LineNumberRemapper {
idx = idx.substring(0, dollarPos);
}
if (lineMap.containsKey(idx)) {
if (lineNumbers.lineMap().containsKey(idx)) {
try (InputStream is = Files.newInputStream(file)) {
ClassReader reader = new ClassReader(is);
ClassWriter writer = new ClassWriter(0);
reader.accept(new LineNumberVisitor(Constants.ASM_VERSION, writer, lineMap.get(idx)), 0);
reader.accept(new LineNumberVisitor(Constants.ASM_VERSION, writer, lineNumbers.lineMap().get(idx)), 0);
Files.write(dst, writer.toByteArray());
return FileVisitResult.CONTINUE;
}
@@ -137,11 +94,11 @@ public class LineNumberRemapper {
}
private static class LineNumberVisitor extends ClassVisitor {
private final RClass rClass;
private final ClassLineNumbers.Entry lineNumbers;
LineNumberVisitor(int api, ClassVisitor classVisitor, RClass rClass) {
LineNumberVisitor(int api, ClassVisitor classVisitor, ClassLineNumbers.Entry lineNumbers) {
super(api, classVisitor);
this.rClass = rClass;
this.lineNumbers = lineNumbers;
}
@Override
@@ -153,30 +110,19 @@ public class LineNumberRemapper {
if (tLine <= 0) {
super.visitLineNumber(line, start);
} else if (tLine >= rClass.maxLine) {
super.visitLineNumber(rClass.maxLineDest, start);
} else if (tLine >= lineNumbers.maxLine()) {
super.visitLineNumber(lineNumbers.maxLineDest(), start);
} else {
Integer matchedLine = null;
while (tLine <= rClass.maxLine && ((matchedLine = rClass.lineMap.get(tLine)) == null)) {
while (tLine <= lineNumbers.maxLine() && ((matchedLine = lineNumbers.lineMap().get(tLine)) == null)) {
tLine++;
}
super.visitLineNumber(matchedLine != null ? matchedLine : rClass.maxLineDest, start);
super.visitLineNumber(matchedLine != null ? matchedLine : lineNumbers.maxLineDest(), start);
}
}
};
}
}
private static class RClass {
private final String name;
private int maxLine;
private int maxLineDest;
private final Map<Integer, Integer> lineMap = new HashMap<>();
private RClass(String name) {
this.name = name;
}
}
}

View File

@@ -0,0 +1,209 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.decompilers.cache;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringWriter;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Objects;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.fabricmc.loom.decompilers.ClassLineNumbers;
// Serialised data for a class entry in the cache
// Uses the RIFF format, allows for appending the line numbers to the end of the file
// Stores the source code and line numbers for the class
public record CachedData(String className, String sources, @Nullable ClassLineNumbers.Entry lineNumbers) {
public static final CachedFileStore.EntrySerializer<CachedData> SERIALIZER = new EntrySerializer();
private static final String HEADER_ID = "LOOM";
private static final String NAME_ID = "NAME";
private static final String SOURCES_ID = "SRC ";
private static final String LINE_NUMBERS_ID = "LNUM";
private static final Logger LOGGER = LoggerFactory.getLogger(CachedData.class);
public CachedData {
Objects.requireNonNull(className, "className");
Objects.requireNonNull(sources, "sources");
if (lineNumbers != null) {
if (!className.equals(lineNumbers.className())) {
throw new IllegalArgumentException("Class name does not match line numbers class name");
}
}
}
public void write(FileChannel fileChannel) {
try (var c = new RiffChunk(HEADER_ID, fileChannel)) {
writeClassname(fileChannel);
writeSource(fileChannel);
if (lineNumbers != null) {
writeLineNumbers(fileChannel);
}
} catch (IOException e) {
throw new RuntimeException("Failed to write cached data", e);
}
}
private void writeClassname(FileChannel fileChannel) throws IOException {
try (var c = new RiffChunk(NAME_ID, fileChannel)) {
fileChannel.write(ByteBuffer.wrap(className.getBytes(StandardCharsets.UTF_8)));
}
}
private void writeSource(FileChannel fileChannel) throws IOException {
try (var c = new RiffChunk(SOURCES_ID, fileChannel)) {
fileChannel.write(ByteBuffer.wrap(sources.getBytes(StandardCharsets.UTF_8)));
}
}
private void writeLineNumbers(FileChannel fileChannel) throws IOException {
Objects.requireNonNull(lineNumbers);
try (var c = new RiffChunk(LINE_NUMBERS_ID, fileChannel);
StringWriter stringWriter = new StringWriter()) {
lineNumbers.write(stringWriter);
fileChannel.write(ByteBuffer.wrap(stringWriter.toString().getBytes(StandardCharsets.UTF_8)));
}
}
public static CachedData read(InputStream inputStream) throws IOException {
// Read and validate the RIFF header
final String header = readHeader(inputStream);
if (!header.equals(HEADER_ID)) {
throw new IOException("Invalid RIFF header: " + header + ", expected " + HEADER_ID);
}
// Read the data length
int length = readInt(inputStream);
String className = null;
String sources = null;
ClassLineNumbers.Entry lineNumbers = null;
while (inputStream.available() > 0) {
String chunkHeader = readHeader(inputStream);
int chunkLength = readInt(inputStream);
byte[] chunkData = readBytes(inputStream, chunkLength);
switch (chunkHeader) {
case NAME_ID -> {
if (className != null) {
throw new IOException("Duplicate name chunk");
}
className = new String(chunkData, StandardCharsets.UTF_8);
}
case SOURCES_ID -> {
if (sources != null) {
throw new IOException("Duplicate sources chunk");
}
sources = new String(chunkData, StandardCharsets.UTF_8);
}
case LINE_NUMBERS_ID -> {
if (lineNumbers != null) {
throw new IOException("Duplicate line numbers chunk");
}
try (var br = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(chunkData), StandardCharsets.UTF_8))) {
ClassLineNumbers classLineNumbers = ClassLineNumbers.readMappings(br);
if (classLineNumbers.lineMap().size() != 1) {
throw new IOException("Expected exactly one class line numbers entry got " + classLineNumbers.lineMap().size() + " entries");
}
lineNumbers = classLineNumbers.lineMap().values().iterator().next();
}
}
default -> {
// Skip unknown chunk
LOGGER.warn("Skipping unknown chunk: {} of size {}", chunkHeader, chunkLength);
inputStream.skip(chunkLength);
}
}
}
if (sources == null) {
throw new IOException("Missing sources");
}
return new CachedData(className, sources, lineNumbers);
}
private static String readHeader(InputStream inputStream) throws IOException {
byte[] header = readBytes(inputStream, 4);
return new String(header, StandardCharsets.US_ASCII);
}
private static int readInt(InputStream inputStream) throws IOException {
byte[] bytes = readBytes(inputStream, 4);
return ByteBuffer.wrap(bytes).getInt();
}
private static byte[] readBytes(InputStream inputStream, int length) throws IOException {
byte[] bytes = new byte[length];
int read = inputStream.read(bytes);
if (read != length) {
throw new IOException("Failed to read bytes expected " + length + " bytes but got " + read + " bytes");
}
return bytes;
}
static class EntrySerializer implements CachedFileStore.EntrySerializer<CachedData> {
@Override
public CachedData read(Path path) throws IOException {
try (var inputStream = new BufferedInputStream(Files.newInputStream(path))) {
return CachedData.read(inputStream);
}
}
@Override
public void write(CachedData entry, Path path) throws IOException {
try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
entry.write(fileChannel);
}
}
}
}

View File

@@ -0,0 +1,42 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.decompilers.cache;
import java.io.IOException;
import java.nio.file.Path;
import org.jetbrains.annotations.Nullable;
public interface CachedFileStore<T> {
@Nullable T getEntry(String key) throws IOException;
void putEntry(String key, T entry) throws IOException;
interface EntrySerializer<T> {
T read(Path path) throws IOException;
void write(T entry, Path path) throws IOException;
}
}

View File

@@ -0,0 +1,141 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.decompilers.cache;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
import org.jetbrains.annotations.Nullable;
public record CachedFileStoreImpl<T>(Path root, EntrySerializer<T> entrySerializer, CacheRules cacheRules) implements CachedFileStore<T> {
public CachedFileStoreImpl {
Objects.requireNonNull(root, "root");
}
@Override
public @Nullable T getEntry(String key) throws IOException {
Path path = resolve(key);
if (Files.notExists(path)) {
return null;
}
// Update last modified, so recently used files stay in the cache
Files.setLastModifiedTime(path, FileTime.from(Instant.now()));
return entrySerializer.read(path);
}
@Override
public void putEntry(String key, T data) throws IOException {
Path path = resolve(key);
Files.createDirectories(path.getParent());
entrySerializer.write(data, path);
}
private Path resolve(String key) {
return root.resolve(key);
}
public void prune() throws IOException {
// Sorted oldest -> newest
List<PathEntry> entries = new ArrayList<>();
// Iterate over all the files in the cache, and store them into the sorted list.
try (Stream<Path> walk = Files.walk(root)) {
Iterator<Path> iterator = walk.iterator();
while (iterator.hasNext()) {
final Path entry = iterator.next();
if (!Files.isRegularFile(entry)) {
continue;
}
insertSorted(entries, new PathEntry(entry));
}
}
// Delete the oldest files to get under the max file limit
if (entries.size() > cacheRules.maxFiles) {
for (int i = 0; i < cacheRules.maxFiles; i++) {
PathEntry toRemove = entries.remove(0);
Files.delete(toRemove.path);
}
}
final Instant maxAge = Instant.now().minus(cacheRules().maxAge());
Iterator<PathEntry> iterator = entries.iterator();
while (iterator.hasNext()) {
final PathEntry entry = iterator.next();
if (entry.lastModified().toInstant().isAfter(maxAge)) {
// File is not longer than the max age
// As this is a sorted list we don't need to keep checking
break;
}
// Remove all files over the max age
iterator.remove();
Files.delete(entry.path);
}
}
private void insertSorted(List<PathEntry> list, PathEntry entry) {
int index = Collections.binarySearch(list, entry, Comparator.comparing(PathEntry::lastModified));
if (index < 0) {
index = -index - 1;
}
list.add(index, entry);
}
/**
* The rules for the cache.
*
* @param maxFiles The maximum number of files in the cache
* @param maxAge The maximum age of a file in the cache
*/
public record CacheRules(long maxFiles, Duration maxAge) {
}
record PathEntry(Path path, FileTime lastModified) {
PathEntry(Path path) throws IOException {
this(path, Files.getLastModifiedTime(path));
}
}
}

View File

@@ -0,0 +1,268 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.decompilers.cache;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.fabricmc.loom.decompilers.ClassLineNumbers;
import net.fabricmc.loom.util.FileSystemUtil;
public record CachedJarProcessor(CachedFileStore<CachedData> fileStore, String baseHash) {
private static final Logger LOGGER = LoggerFactory.getLogger(CachedJarProcessor.class);
public WorkRequest prepareJob(Path inputJar) throws IOException {
boolean isIncomplete = false;
boolean hasSomeExisting = false;
Path incompleteJar = Files.createTempFile("loom-cache-incomplete", ".jar");
Path existingJar = Files.createTempFile("loom-cache-existing", ".jar");
// We must delete the empty files, so they can be created as a zip
Files.delete(incompleteJar);
Files.delete(existingJar);
// Sources name -> hash
Map<String, String> outputNameMap = new HashMap<>();
Map<String, ClassLineNumbers.Entry> lineNumbersMap = new HashMap<>();
int hits = 0;
int misses = 0;
try (FileSystemUtil.Delegate inputFs = FileSystemUtil.getJarFileSystem(inputJar, false);
FileSystemUtil.Delegate incompleteFs = FileSystemUtil.getJarFileSystem(incompleteJar, true);
FileSystemUtil.Delegate existingFs = FileSystemUtil.getJarFileSystem(existingJar, true)) {
final List<ClassEntry> inputClasses = JarWalker.findClasses(inputFs);
for (ClassEntry entry : inputClasses) {
String outputFileName = entry.sourcesFileName();
String fullHash = baseHash + "/" + entry.hash(inputFs.getRoot());
final CachedData entryData = fileStore.getEntry(fullHash);
if (entryData == null) {
// Cached entry was not found, so copy the input to the incomplete jar to be processed
entry.copyTo(inputFs.getRoot(), incompleteFs.getRoot());
isIncomplete = true;
outputNameMap.put(outputFileName, fullHash);
LOGGER.debug("Cached entry ({}) not found, going to process {}", fullHash, outputFileName);
misses++;
} else {
final Path outputPath = existingFs.getPath(outputFileName);
Files.createDirectories(outputPath.getParent());
Files.writeString(outputPath, entryData.sources());
lineNumbersMap.put(entryData.className(), entryData.lineNumbers());
hasSomeExisting = true;
LOGGER.debug("Cached entry ({}) found: {}", fullHash, outputFileName);
hits++;
}
}
}
// A jar file that will be created by the work action, containing the newly processed items.
Path outputJar = Files.createTempFile("loom-cache-output", ".jar");
Files.delete(outputJar);
final ClassLineNumbers lineNumbers = lineNumbersMap.isEmpty() ? null : new ClassLineNumbers(Collections.unmodifiableMap(lineNumbersMap));
final var stats = new CacheStats(hits, misses);
if (isIncomplete && !hasSomeExisting) {
// The cache contained nothing of use, fully process the input jar
Files.delete(incompleteJar);
Files.delete(existingJar);
LOGGER.info("No cached entries found, going to process the whole jar");
return new FullWorkJob(inputJar, outputJar, outputNameMap)
.asRequest(stats, lineNumbers);
} else if (isIncomplete) {
// The cache did not contain everything so we have some work to do
LOGGER.info("Some cached entries found, using partial work job");
return new PartialWorkJob(incompleteJar, existingJar, outputJar, outputNameMap)
.asRequest(stats, lineNumbers);
} else {
// The cached contained everything we need, so the existing jar is the output
LOGGER.info("All cached entries found, using completed work job");
Files.delete(incompleteJar);
return new CompletedWorkJob(existingJar)
.asRequest(stats, lineNumbers);
}
}
public void completeJob(Path output, WorkJob workJob, ClassLineNumbers lineNumbers) throws IOException {
if (workJob instanceof CompletedWorkJob completedWorkJob) {
// Fully complete, nothing new to cache
Files.move(completedWorkJob.completed(), output);
return;
}
// Work has been done, we need to cache the newly processed items
if (workJob instanceof WorkToDoJob workToDoJob) {
// Sources name -> hash
Map<String, String> outputNameMap = workToDoJob.outputNameMap();
try (FileSystemUtil.Delegate outputFs = FileSystemUtil.getJarFileSystem(workToDoJob.output(), false);
Stream<Path> walk = Files.walk(outputFs.getRoot())) {
Iterator<Path> iterator = walk.iterator();
while (iterator.hasNext()) {
final Path fsPath = iterator.next();
if (fsPath.startsWith("/META-INF/")) {
continue;
}
if (!Files.isRegularFile(fsPath)) {
continue;
}
final String hash = outputNameMap.get(fsPath.toString().substring(outputFs.getRoot().toString().length()));
if (hash == null) {
throw new IllegalStateException("Unexpected output: " + fsPath);
}
// Trim the leading / and the .java extension
final String className = fsPath.toString().substring(1, fsPath.toString().length() - ".java".length());
final String sources = Files.readString(fsPath);
ClassLineNumbers.Entry lineMapEntry = null;
if (lineNumbers != null) {
lineMapEntry = lineNumbers.lineMap().get(className);
}
final var cachedData = new CachedData(className, sources, lineMapEntry);
fileStore.putEntry(hash, cachedData);
LOGGER.debug("Saving processed entry ({}) to cache: {}", hash, fsPath);
}
}
} else {
throw new IllegalStateException();
}
if (workJob instanceof PartialWorkJob partialWorkJob) {
// Copy all the existing items to the output jar
try (FileSystemUtil.Delegate outputFs = FileSystemUtil.getJarFileSystem(partialWorkJob.output(), false);
FileSystemUtil.Delegate existingFs = FileSystemUtil.getJarFileSystem(partialWorkJob.existing(), false);
Stream<Path> walk = Files.walk(existingFs.getRoot())) {
Iterator<Path> iterator = walk.iterator();
while (iterator.hasNext()) {
Path existingPath = iterator.next();
if (!Files.isRegularFile(existingPath)) {
continue;
}
final Path outputPath = outputFs.getRoot().resolve(existingPath.toString());
LOGGER.debug("Copying existing entry to output: {}", existingPath);
Files.createDirectories(outputPath.getParent());
Files.copy(existingPath, outputPath);
}
}
Files.delete(partialWorkJob.existing());
Files.move(partialWorkJob.output(), output);
} else if (workJob instanceof FullWorkJob fullWorkJob) {
// Nothing to merge, just use the output jar
Files.move(fullWorkJob.output, output);
} else {
throw new IllegalStateException();
}
}
public record WorkRequest(WorkJob job, CacheStats stats, @Nullable ClassLineNumbers lineNumbers) {
}
public record CacheStats(int hits, int misses) {
}
public sealed interface WorkJob permits CompletedWorkJob, WorkToDoJob {
default WorkRequest asRequest(CacheStats stats, @Nullable ClassLineNumbers lineNumbers) {
return new WorkRequest(this, stats, lineNumbers);
}
}
public sealed interface WorkToDoJob extends WorkJob permits PartialWorkJob, FullWorkJob {
/**
* A path to jar file containing all the classes to be processed.
*/
Path incomplete();
/**
* @return A jar file to be written to during processing
*/
Path output();
/**
* @return A map of sources name to hash
*/
Map<String, String> outputNameMap();
}
/**
* No work to be done, all restored from cache.
*
* @param completed
*/
public record CompletedWorkJob(Path completed) implements WorkJob {
}
/**
* Some work needs to be done.
*
* @param incomplete A path to jar file containing all the classes to be processed
* @param existing A path pointing to a jar containing existing classes that have previously been processed
* @param output A path to a temporary jar where work output should be written to
* @param outputNameMap A map of sources name to hash
*/
public record PartialWorkJob(Path incomplete, Path existing, Path output, Map<String, String> outputNameMap) implements WorkToDoJob {
}
/**
* The full jar must be processed.
*
* @param incomplete A path to jar file containing all the classes to be processed
* @param output A path to a temporary jar where work output should be written to
* @param outputNameMap A map of sources name to hash
*/
public record FullWorkJob(Path incomplete, Path output, Map<String, String> outputNameMap) implements WorkToDoJob {
}
}

View File

@@ -0,0 +1,75 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.decompilers.cache;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.StringJoiner;
import net.fabricmc.loom.util.Checksum;
public record ClassEntry(String parentClass, List<String> innerClasses) {
/**
* Copy the class and its inner classes to the target root.
* @param sourceRoot The root of the source jar
* @param targetRoot The root of the target jar
*
* @throws IOException If an error occurs while copying the files
*/
public void copyTo(Path sourceRoot, Path targetRoot) throws IOException {
Path targetPath = targetRoot.resolve(parentClass);
Files.createDirectories(targetPath.getParent());
Files.copy(sourceRoot.resolve(parentClass), targetPath);
for (String innerClass : innerClasses) {
Files.copy(sourceRoot.resolve(innerClass), targetRoot.resolve(innerClass));
}
}
/**
* Hash the class and its inner classes using sha256.
* @param root The root of the jar
* @return The hash of the class and its inner classes
*
* @throws IOException If an error occurs while hashing the files
*/
public String hash(Path root) throws IOException {
StringJoiner joiner = new StringJoiner(",");
joiner.add(Checksum.sha256Hex(Files.readAllBytes(root.resolve(parentClass))));
for (String innerClass : innerClasses) {
joiner.add(Checksum.sha256Hex(Files.readAllBytes(root.resolve(innerClass))));
}
return Checksum.sha256Hex(joiner.toString().getBytes());
}
public String sourcesFileName() {
return parentClass.replace(".class", ".java");
}
}

View File

@@ -0,0 +1,108 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.decompilers.cache;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.fabricmc.loom.util.FileSystemUtil;
public final class JarWalker {
private static final Logger LOGGER = LoggerFactory.getLogger(JarWalker.class);
private JarWalker() {
}
public static List<ClassEntry> findClasses(Path jar) throws IOException {
try (FileSystemUtil.Delegate fs = FileSystemUtil.getJarFileSystem(jar)) {
return findClasses(fs);
}
}
public static List<ClassEntry> findClasses(FileSystemUtil.Delegate fs) throws IOException {
List<String> outerClasses = new ArrayList<>();
Map<String, List<String>> innerClasses = new HashMap<>();
// Iterate over all the classes in the jar, and store them into the sorted list.
try (Stream<Path> walk = Files.walk(fs.getRoot())) {
Iterator<Path> iterator = walk.iterator();
while (iterator.hasNext()) {
final Path entry = iterator.next();
if (!Files.isRegularFile(entry)) {
continue;
}
final String fileName = entry.toString().substring(fs.getRoot().toString().length());
if (!fileName.endsWith(".class")) {
continue;
}
boolean isInnerClass = fileName.contains("$");
if (isInnerClass) {
String outerClassName = fileName.substring(0, fileName.indexOf('$')) + ".class";
innerClasses.computeIfAbsent(outerClassName, k -> new ArrayList<>()).add(fileName);
} else {
outerClasses.add(fileName);
}
}
}
LOGGER.info("Found {} outer classes and {} inner classes", outerClasses.size(), innerClasses.size());
Collections.sort(outerClasses);
List<ClassEntry> classEntries = new ArrayList<>();
for (String outerClass : outerClasses) {
List<String> innerClasList = innerClasses.get(outerClass);
if (innerClasList == null) {
innerClasList = Collections.emptyList();
} else {
Collections.sort(innerClasList);
}
ClassEntry classEntry = new ClassEntry(outerClass, Collections.unmodifiableList(innerClasList));
classEntries.add(classEntry);
}
return Collections.unmodifiableList(classEntries);
}
}

View File

@@ -0,0 +1,69 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.decompilers.cache;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
/**
* Write a RIFF chunk to a file channel
*
* <p>Works by writing the chunk header and then reserving space for the chunk size.
* The chunk size is then written after the chunk data has been written.
*/
public class RiffChunk implements AutoCloseable {
private final long position;
private final FileChannel fileChannel;
public RiffChunk(String id, FileChannel fileChannel) throws IOException {
if (id.length() != 4) {
throw new IllegalArgumentException("ID must be 4 characters long");
}
// Write the chunk header and reserve space for the chunk size
fileChannel.write(ByteBuffer.wrap(id.getBytes(StandardCharsets.US_ASCII)));
this.position = fileChannel.position();
fileChannel.write(ByteBuffer.allocate(4));
// Store the position and file channel for later use
this.fileChannel = fileChannel;
}
@Override
public void close() throws IOException {
long endPosition = fileChannel.position();
long chunkSize = endPosition - position - 4;
if (chunkSize > Integer.MAX_VALUE) {
throw new IOException("Chunk size is too large");
}
fileChannel.position(position);
fileChannel.write(ByteBuffer.allocate(Integer.BYTES).putInt((int) (chunkSize)).flip());
fileChannel.position(endPosition);
}
}

View File

@@ -50,4 +50,5 @@ public interface LoomFiles {
File getRemapClasspathFile();
File getGlobalMinecraftRepo();
File getLocalMinecraftRepo();
File getDecompileCache(String version);
}

View File

@@ -107,4 +107,9 @@ public abstract class LoomFilesBaseImpl implements LoomFiles {
public File getLocalMinecraftRepo() {
return new File(getRootProjectPersistentCache(), "minecraftMaven");
}
@Override
public File getDecompileCache(String version) {
return new File(getUserCache(), "decompile/" + version + ".zip");
}
}

View File

@@ -24,6 +24,7 @@
package net.fabricmc.loom.task;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
@@ -36,26 +37,33 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.StringJoiner;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.inject.Inject;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.FileCollection;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.services.ServiceReference;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.Optional;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.options.Option;
import org.gradle.process.ExecOperations;
import org.gradle.process.ExecResult;
import org.gradle.work.DisableCachingByDefault;
@@ -64,8 +72,12 @@ import org.gradle.workers.WorkParameters;
import org.gradle.workers.WorkQueue;
import org.gradle.workers.WorkerExecutor;
import org.gradle.workers.internal.WorkerDaemonClientsManager;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.fabricmc.loom.LoomGradleExtension;
import net.fabricmc.loom.api.decompilers.DecompilationMetadata;
import net.fabricmc.loom.api.decompilers.DecompilerOptions;
import net.fabricmc.loom.api.decompilers.LoomDecompiler;
@@ -75,7 +87,12 @@ import net.fabricmc.loom.configuration.processors.MappingProcessorContextImpl;
import net.fabricmc.loom.configuration.processors.MinecraftJarProcessorManager;
import net.fabricmc.loom.configuration.providers.minecraft.MinecraftJar;
import net.fabricmc.loom.configuration.providers.minecraft.mapped.AbstractMappedMinecraftProvider;
import net.fabricmc.loom.decompilers.ClassLineNumbers;
import net.fabricmc.loom.decompilers.LineNumberRemapper;
import net.fabricmc.loom.decompilers.cache.CachedData;
import net.fabricmc.loom.decompilers.cache.CachedFileStoreImpl;
import net.fabricmc.loom.decompilers.cache.CachedJarProcessor;
import net.fabricmc.loom.util.Checksum;
import net.fabricmc.loom.util.Constants;
import net.fabricmc.loom.util.ExceptionUtil;
import net.fabricmc.loom.util.FileSystemUtil;
@@ -95,6 +112,8 @@ import net.fabricmc.mappingio.tree.MemoryMappingTree;
@DisableCachingByDefault
public abstract class GenerateSourcesTask extends AbstractLoomTask {
private static final Logger LOGGER = LoggerFactory.getLogger(GenerateSourcesTask.class);
private static final String CACHE_VERSION = "v1";
private final DecompilerOptions decompilerOptions;
/**
@@ -122,10 +141,25 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
@Optional
public abstract ConfigurableFileCollection getUnpickClasspath();
@InputFiles
@Optional
@ApiStatus.Internal
public abstract ConfigurableFileCollection getUnpickRuntimeClasspath();
@OutputFile
@Optional
public abstract RegularFileProperty getUnpickOutputJar();
@Input
@Option(option = "use-cache", description = "Use the decompile cache")
@ApiStatus.Experimental
public abstract Property<Boolean> getUseCache();
// Internal outputs
@ApiStatus.Internal
@Internal
protected abstract RegularFileProperty getDecompileCacheFile();
// Injects
@Inject
public abstract WorkerExecutor getWorkerExecutor();
@@ -147,6 +181,12 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
getOutputs().upToDateWhen((o) -> false);
getClasspath().from(decompilerOptions.getClasspath()).finalizeValueOnRead();
dependsOn(decompilerOptions.getClasspath().getBuiltBy());
LoomGradleExtension extension = LoomGradleExtension.get(getProject());
getDecompileCacheFile().set(extension.getFiles().getDecompileCache(CACHE_VERSION));
getUnpickRuntimeClasspath().from(getProject().getConfigurations().getByName(Constants.Configurations.UNPICK_CLASSPATH));
getUseCache().convention(false);
}
@TaskAction
@@ -157,21 +197,195 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
throw new UnsupportedOperationException("GenSources task requires a 64bit JVM to run due to the memory requirements.");
}
if (!getUseCache().get()) {
try (var timer = new Timer("Decompiled sources")) {
runWithoutCache();
}
return;
}
LOGGER.warn("Using decompile cache is experimental and may not work as expected.");
try (var timer = new Timer("Decompiled sources with cache")) {
final Path cacheFile = getDecompileCacheFile().getAsFile().get().toPath();
// TODO ensure we have a lock on this file to prevent multiple tasks from running at the same time
// TODO handle being unable to read the cache file
Files.createDirectories(cacheFile.getParent());
try (FileSystemUtil.Delegate fs = FileSystemUtil.getJarFileSystem(cacheFile, true)) {
runWithCache(fs.getRoot());
}
}
}
private void runWithCache(Path cacheRoot) throws IOException {
final MinecraftJar minecraftJar = rebuildInputJar();
// Input jar is the jar to decompile, this may be unpicked.
final var cacheRules = new CachedFileStoreImpl.CacheRules(50_000, Duration.ofDays(90));
final var decompileCache = new CachedFileStoreImpl<>(cacheRoot, CachedData.SERIALIZER, cacheRules);
final String cacheKey = getCacheKey();
final CachedJarProcessor cachedJarProcessor = new CachedJarProcessor(decompileCache, cacheKey);
final CachedJarProcessor.WorkRequest workRequest;
LOGGER.info("Decompile cache key: {}", cacheKey);
try (var timer = new Timer("Prepare job")) {
workRequest = cachedJarProcessor.prepareJob(minecraftJar.getPath());
}
final CachedJarProcessor.WorkJob job = workRequest.job();
final CachedJarProcessor.CacheStats cacheStats = workRequest.stats();
getProject().getLogger().lifecycle("Decompiling: Cache stats: {} hits, {} misses", cacheStats.hits(), cacheStats.misses());
ClassLineNumbers outputLineNumbers = null;
if (job instanceof CachedJarProcessor.WorkToDoJob workToDoJob) {
Path inputJar = workToDoJob.incomplete();
@Nullable Path existing = (job instanceof CachedJarProcessor.PartialWorkJob partialWorkJob) ? partialWorkJob.existing() : null;
if (getUnpickDefinitions().isPresent()) {
try (var timer = new Timer("Unpick")) {
inputJar = unpickJar(inputJar, existing);
}
}
try (var timer = new Timer("Decompile")) {
outputLineNumbers = runDecompileJob(inputJar, workToDoJob.output(), existing);
}
if (Files.notExists(workToDoJob.output())) {
throw new RuntimeException("Failed to decompile sources");
}
} else if (job instanceof CachedJarProcessor.CompletedWorkJob completedWorkJob) {
// Nothing to do :)
}
// The final output sources jar
final Path sourcesJar = getOutputJar().get().getAsFile().toPath();
Files.deleteIfExists(sourcesJar);
try (var timer = new Timer("Complete job")) {
cachedJarProcessor.completeJob(sourcesJar, job, outputLineNumbers);
}
// This is the minecraft jar used at runtime.
final Path classesJar = minecraftJar.getPath();
// Remap the line numbers with the new and existing numbers
final ClassLineNumbers existingLinenumbers = workRequest.lineNumbers();
final ClassLineNumbers lineNumbers = ClassLineNumbers.merge(existingLinenumbers, outputLineNumbers);
if (lineNumbers == null) {
LOGGER.info("No line numbers to remap, skipping remapping");
return;
}
Path tempJar = Files.createTempFile("loom", "linenumber-remap.jar");
Files.delete(tempJar);
try (var timer = new Timer("Remap line numbers")) {
remapLineNumbers(lineNumbers, classesJar, tempJar);
}
Files.move(tempJar, classesJar, StandardCopyOption.REPLACE_EXISTING);
try (var timer = new Timer("Prune cache")) {
decompileCache.prune();
}
}
private void runWithoutCache() throws IOException {
final MinecraftJar minecraftJar = rebuildInputJar();
Path inputJar = minecraftJar.getPath();
// Runtime jar is the jar used to run the game
final Path runtimeJar = inputJar;
// The final output sources jar
final Path sourcesJar = getOutputJar().get().getAsFile().toPath();
if (getUnpickDefinitions().isPresent()) {
inputJar = unpickJar(inputJar);
try (var timer = new Timer("Unpick")) {
inputJar = unpickJar(inputJar, null);
}
}
ClassLineNumbers lineNumbers;
try (var timer = new Timer("Decompile")) {
lineNumbers = runDecompileJob(inputJar, sourcesJar, null);
}
if (Files.notExists(sourcesJar)) {
throw new RuntimeException("Failed to decompile sources");
}
if (lineNumbers == null) {
LOGGER.info("No line numbers to remap, skipping remapping");
return;
}
// This is the minecraft jar used at runtime.
final Path classesJar = minecraftJar.getPath();
final Path tempJar = Files.createTempFile("loom", "linenumber-remap.jar");
Files.delete(tempJar);
try (var timer = new Timer("Remap line numbers")) {
remapLineNumbers(lineNumbers, classesJar, tempJar);
}
Files.move(tempJar, classesJar, StandardCopyOption.REPLACE_EXISTING);
}
private String getCacheKey() {
var sj = new StringJoiner(",");
sj.add(getDecompilerCheckKey());
sj.add(getUnpickCacheKey());
LOGGER.info("Decompile cache data: {}", sj);
try {
return Checksum.sha256Hex(sj.toString().getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private String getDecompilerCheckKey() {
var sj = new StringJoiner(",");
sj.add(decompilerOptions.getDecompilerClassName().get());
sj.add(fileCollectionHash(decompilerOptions.getClasspath()));
for (Map.Entry<String, String> entry : decompilerOptions.getOptions().get().entrySet()) {
sj.add(entry.getKey() + "=" + entry.getValue());
}
return sj.toString();
}
private String getUnpickCacheKey() {
if (!getUnpickDefinitions().isPresent()) {
return "";
}
var sj = new StringJoiner(",");
sj.add(fileHash(getUnpickDefinitions().getAsFile().get()));
sj.add(fileCollectionHash(getUnpickConstantJar()));
sj.add(fileCollectionHash(getUnpickRuntimeClasspath()));
return sj.toString();
}
@Nullable
private ClassLineNumbers runDecompileJob(Path inputJar, Path outputJar, @Nullable Path existingJar) throws IOException {
final Platform platform = Platform.CURRENT;
final Path lineMapFile = File.createTempFile("loom", "linemap").toPath();
Files.delete(lineMapFile);
if (!platform.supportsUnixDomainSockets()) {
getProject().getLogger().warn("Decompile worker logging disabled as Unix Domain Sockets is not supported on your operating system.");
doWork(null, inputJar, runtimeJar);
return;
doWork(null, inputJar, outputJar, lineMapFile, existingJar);
return readLineNumbers(lineMapFile);
}
// Set up the IPC path to get the log output back from the forked JVM
@@ -180,12 +394,14 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
try (ThreadedProgressLoggerConsumer loggerConsumer = new ThreadedProgressLoggerConsumer(getProject(), decompilerOptions.getName(), "Decompiling minecraft sources");
IPCServer logReceiver = new IPCServer(ipcPath, loggerConsumer)) {
doWork(logReceiver, inputJar, runtimeJar);
doWork(logReceiver, inputJar, outputJar, lineMapFile, existingJar);
} catch (InterruptedException e) {
throw new RuntimeException("Failed to shutdown log receiver", e);
} finally {
Files.deleteIfExists(ipcPath);
}
return readLineNumbers(lineMapFile);
}
// Re-run the named minecraft provider to give us a fresh jar to decompile.
@@ -214,13 +430,13 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
);
}
private Path unpickJar(Path inputJar) {
private Path unpickJar(Path inputJar, @Nullable Path existingJar) {
final Path outputJar = getUnpickOutputJar().get().getAsFile().toPath();
final List<String> args = getUnpickArgs(inputJar, outputJar);
final List<String> args = getUnpickArgs(inputJar, outputJar, existingJar);
ExecResult result = getExecOperations().javaexec(spec -> {
spec.getMainClass().set("daomephsta.unpick.cli.Main");
spec.classpath(getProject().getConfigurations().getByName(Constants.Configurations.UNPICK_CLASSPATH));
spec.classpath(getUnpickRuntimeClasspath());
spec.args(args);
spec.systemProperty("java.util.logging.config.file", writeUnpickLogConfig().getAbsolutePath());
});
@@ -230,7 +446,7 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
return outputJar;
}
private List<String> getUnpickArgs(Path inputJar, Path outputJar) {
private List<String> getUnpickArgs(Path inputJar, Path outputJar, @Nullable Path existingJar) {
var fileArgs = new ArrayList<File>();
fileArgs.add(inputJar.toFile());
@@ -247,6 +463,10 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
fileArgs.add(file);
}
if (existingJar != null) {
fileArgs.add(existingJar.toFile());
}
return fileArgs.stream()
.map(File::getAbsolutePath)
.toList();
@@ -265,25 +485,40 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
return unpickLoggingConfigFile;
}
private void doWork(@Nullable IPCServer ipcServer, Path inputJar, Path runtimeJar) {
private void remapLineNumbers(ClassLineNumbers lineNumbers, Path inputJar, Path outputJar) throws IOException {
Objects.requireNonNull(lineNumbers, "lineNumbers");
final var remapper = new LineNumberRemapper(lineNumbers);
try (FileSystemUtil.Delegate inFs = FileSystemUtil.getJarFileSystem(inputJar, false);
FileSystemUtil.Delegate outFs = FileSystemUtil.getJarFileSystem(outputJar, true)) {
remapper.process(inFs.get().getPath("/"), outFs.get().getPath("/"));
}
}
private void doWork(@Nullable IPCServer ipcServer, Path inputJar, Path outputJar, Path linemapFile, @Nullable Path existingJar) {
final String jvmMarkerValue = UUID.randomUUID().toString();
final WorkQueue workQueue = createWorkQueue(jvmMarkerValue);
ConfigurableFileCollection classpath = getProject().files();
classpath.from(getProject().getConfigurations().getByName(Constants.Configurations.MINECRAFT_COMPILE_LIBRARIES));
if (existingJar != null) {
classpath.from(existingJar);
}
workQueue.submit(DecompileAction.class, params -> {
params.getDecompilerOptions().set(decompilerOptions.toDto());
params.getInputJar().set(inputJar.toFile());
params.getRuntimeJar().set(runtimeJar.toFile());
params.getSourcesDestinationJar().set(getOutputJar());
params.getLinemap().set(getMappedJarFileWithSuffix("-sources.lmap", runtimeJar));
params.getLinemapJar().set(getMappedJarFileWithSuffix("-linemapped.jar", runtimeJar));
params.getOutputJar().set(outputJar.toFile());
params.getLinemapFile().set(linemapFile.toFile());
params.getMappings().set(getMappings().toFile());
if (ipcServer != null) {
params.getIPCPath().set(ipcServer.getPath().toFile());
}
params.getClassPath().setFrom(getProject().getConfigurations().getByName(Constants.Configurations.MINECRAFT_COMPILE_LIBRARIES));
params.getClassPath().setFrom(classpath);
});
try {
@@ -325,10 +560,8 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
Property<DecompilerOptions.Dto> getDecompilerOptions();
RegularFileProperty getInputJar();
RegularFileProperty getRuntimeJar();
RegularFileProperty getSourcesDestinationJar();
RegularFileProperty getLinemap();
RegularFileProperty getLinemapJar();
RegularFileProperty getOutputJar();
RegularFileProperty getLinemapFile();
RegularFileProperty getMappings();
RegularFileProperty getIPCPath();
@@ -356,10 +589,8 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
private void doDecompile(IOStringConsumer logger) {
final Path inputJar = getParameters().getInputJar().get().getAsFile().toPath();
final Path sourcesDestinationJar = getParameters().getSourcesDestinationJar().get().getAsFile().toPath();
final Path linemap = getParameters().getLinemap().get().getAsFile().toPath();
final Path linemapJar = getParameters().getLinemapJar().get().getAsFile().toPath();
final Path runtimeJar = getParameters().getRuntimeJar().get().getAsFile().toPath();
final Path linemap = getParameters().getLinemapFile().get().getAsFile().toPath();
final Path outputJar = getParameters().getOutputJar().get().getAsFile().toPath();
final DecompilerOptions.Dto decompilerOptions = getParameters().getDecompilerOptions().get();
@@ -375,7 +606,7 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
throw new RuntimeException("Failed to create decompiler", e);
}
DecompilationMetadata metadata = new DecompilationMetadata(
final var metadata = new DecompilationMetadata(
decompilerOptions.maxThreads(),
getParameters().getMappings().get().getAsFile().toPath(),
getLibraries(),
@@ -385,7 +616,7 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
decompiler.decompile(
inputJar,
sourcesDestinationJar,
outputJar,
linemap,
metadata
);
@@ -396,28 +627,6 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
} catch (IOException e) {
throw new UncheckedIOException("Failed to close loggers", e);
}
if (Files.exists(linemap)) {
try {
// Line map the actually jar used to run the game, not the one used to decompile
remapLineNumbers(metadata.logger(), runtimeJar, linemap, linemapJar);
Files.copy(linemapJar, runtimeJar, StandardCopyOption.REPLACE_EXISTING);
Files.delete(linemapJar);
} catch (IOException e) {
throw new UncheckedIOException("Failed to remap line numbers", e);
}
}
}
private void remapLineNumbers(IOStringConsumer logger, Path oldCompiledJar, Path linemap, Path linemappedJarDestination) throws IOException {
LineNumberRemapper remapper = new LineNumberRemapper();
remapper.readMappings(linemap.toFile());
try (FileSystemUtil.Delegate inFs = FileSystemUtil.getJarFileSystem(oldCompiledJar.toFile(), true);
FileSystemUtil.Delegate outFs = FileSystemUtil.getJarFileSystem(linemappedJarDestination.toFile(), true)) {
remapper.process(logger, inFs.get().getPath("/"), outFs.get().getPath("/"));
}
}
private Collection<Path> getLibraries() {
@@ -425,16 +634,6 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
}
}
public static File getMappedJarFileWithSuffix(String suffix, Path runtimeJar) {
final String path = runtimeJar.toFile().getAbsolutePath();
if (!path.toLowerCase(Locale.ROOT).endsWith(".jar")) {
throw new RuntimeException("Invalid mapped JAR path: " + path);
}
return new File(path.substring(0, path.length() - 4) + suffix);
}
private Path getMappings() {
Path inputMappings = getExtension().getMappingConfiguration().tinyMappings;
@@ -493,8 +692,25 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
return outputMappings;
}
public interface MappingsProcessor {
boolean transform(MemoryMappingTree mappings);
public static File getJarFileWithSuffix(String suffix, Path runtimeJar) {
final String path = runtimeJar.toFile().getAbsolutePath();
if (!path.toLowerCase(Locale.ROOT).endsWith(".jar")) {
throw new RuntimeException("Invalid mapped JAR path: " + path);
}
return new File(path.substring(0, path.length() - 4) + suffix);
}
@Nullable
private static ClassLineNumbers readLineNumbers(Path linemapFile) throws IOException {
if (Files.notExists(linemapFile)) {
return null;
}
try (BufferedReader reader = Files.newBufferedReader(linemapFile, StandardCharsets.UTF_8)) {
return ClassLineNumbers.readMappings(reader);
}
}
private static Constructor<LoomDecompiler> getDecompilerConstructor(String clazz) {
@@ -507,4 +723,43 @@ public abstract class GenerateSourcesTask extends AbstractLoomTask {
throw new RuntimeException(e);
}
}
private static String fileHash(File file) {
try {
return Checksum.sha256Hex(Files.readAllBytes(file.toPath()));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static String fileCollectionHash(FileCollection files) {
var sj = new StringJoiner(",");
files.getFiles()
.stream()
.sorted(Comparator.comparing(File::getAbsolutePath))
.map(GenerateSourcesTask::fileHash)
.forEach(sj::add);
return sj.toString();
}
public interface MappingsProcessor {
boolean transform(MemoryMappingTree mappings);
}
private final class Timer implements AutoCloseable {
private final String name;
private final long start;
Timer(String name) {
this.name = name;
this.start = System.currentTimeMillis();
}
@Override
public void close() {
getProject().getLogger().info("{} took {}ms", name, System.currentTimeMillis() - start);
}
}
}

View File

@@ -67,6 +67,11 @@ public class Checksum {
}
}
public static String sha256Hex(byte[] input) throws IOException {
HashCode hash = ByteSource.wrap(input).hash(Hashing.sha256());
return Checksum.toHex(hash.asBytes());
}
public static String sha1Hex(Path path) throws IOException {
HashCode hash = Files.asByteSource(path.toFile()).hash(Hashing.sha1());
return toHex(hash.asBytes());

View File

@@ -43,6 +43,10 @@ public final class FileSystemUtil {
return get().getPath(path, more);
}
public Path getRoot() {
return get().getPath("/");
}
public byte[] readAllBytes(String path) throws IOException {
Path fsPath = getPath(path);

View File

@@ -79,13 +79,13 @@ public class ZipUtils {
public static void unpackAll(Path zip, Path output) throws IOException {
try (FileSystemUtil.Delegate fs = FileSystemUtil.getJarFileSystem(zip, false);
Stream<Path> walk = Files.walk(fs.get().getPath("/"))) {
Stream<Path> walk = Files.walk(fs.getRoot())) {
Iterator<Path> iterator = walk.iterator();
while (iterator.hasNext()) {
Path fsPath = iterator.next();
if (!Files.isRegularFile(fsPath)) continue;
Path dstPath = output.resolve(fs.get().getPath("/").relativize(fsPath).toString());
Path dstPath = output.resolve(fs.getRoot().relativize(fsPath).toString());
Path dstPathParent = dstPath.getParent();
if (dstPathParent != null) Files.createDirectories(dstPathParent);
Files.copy(fsPath, dstPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);

View File

@@ -84,7 +84,7 @@ class DebugLineNumbersTest extends Specification implements GradleProjectTestTra
'''
when:
// First generate sources
def genSources = gradle.run(task: "genSources")
def genSources = gradle.run(task: "genSources", args: ["--info"])
genSources.task(":genSources").outcome == SUCCESS
// Print out the source of the file

View File

@@ -74,4 +74,36 @@ class DecompileTest extends Specification implements GradleProjectTestTrait {
where:
version << STANDARD_TEST_VERSIONS
}
def "decompile cache"() {
setup:
def gradle = gradleProject(project: "minimalBase", version: PRE_RELEASE_GRADLE)
gradle.buildSrc("decompile")
gradle.buildGradle << '''
dependencies {
minecraft "com.mojang:minecraft:1.20.4"
mappings "net.fabricmc:yarn:1.20.4+build.3:v2"
}
'''
when:
def result = gradle.run(tasks: ["genSourcesWithVineflower"], args: ["--use-cache", "--info"])
// Add fabric API to the project, this introduces some transitive access wideners
gradle.buildGradle << '''
dependencies {
modImplementation "net.fabricmc.fabric-api:fabric-api:0.96.4+1.20.4"
}
'''
def result2 = gradle.run(tasks: ["genSourcesWithVineflower"], args: ["--use-cache", "--info"])
// And run again, with no changes
def result3 = gradle.run(tasks: ["genSourcesWithVineflower"], args: ["--use-cache", "--info"])
then:
result.task(":genSourcesWithVineflower").outcome == SUCCESS
result2.task(":genSourcesWithVineflower").outcome == SUCCESS
result3.task(":genSourcesWithVineflower").outcome == SUCCESS
}
}

View File

@@ -26,6 +26,8 @@ package net.fabricmc.loom.test.integration.buildSrc.decompile
import java.nio.file.Path
import com.google.common.io.Files
import net.fabricmc.loom.api.decompilers.DecompilationMetadata
import net.fabricmc.loom.api.decompilers.LoomDecompiler
@@ -33,5 +35,6 @@ class CustomDecompiler implements LoomDecompiler {
@Override
void decompile(Path compiledJar, Path sourcesDestination, Path linemapDestination, DecompilationMetadata metaData) {
println("Running custom decompiler")
Files.touch(sourcesDestination.toFile())
}
}

View File

@@ -0,0 +1,99 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2023 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.test.unit
import spock.lang.Specification
import net.fabricmc.loom.decompilers.ClassLineNumbers
class ClassLineNumbersTest extends Specification {
def "read linemap"() {
when:
def reader = new BufferedReader(new StringReader(LINE_MAP))
def lineNumbers = ClassLineNumbers.readMappings(reader)
def lineMap = lineNumbers.lineMap()
then:
lineMap.size() == 2
lineMap["net/minecraft/server/dedicated/ServerPropertiesHandler"].lineMap().size() == 39
lineMap["net/minecraft/server/dedicated/ServerPropertiesHandler"].maxLine() == 203
lineMap["net/minecraft/server/dedicated/ServerPropertiesHandler"].maxLineDest() == 187
lineMap["net/minecraft/server/dedicated/ServerPropertiesLoader"].lineMap().size() == 6
lineMap["net/minecraft/server/dedicated/ServerPropertiesLoader"].maxLine() == 25
lineMap["net/minecraft/server/dedicated/ServerPropertiesLoader"].maxLineDest() == 30
}
private static final String LINE_MAP = """
net/minecraft/server/dedicated/ServerPropertiesHandler\t203\t187
\t48\t187
\t91\t92
\t96\t97
\t110\t108
\t112\t109
\t113\t110
\t115\t111
\t116\t112
\t118\t113
\t119\t113
\t120\t113
\t122\t114
\t130\t115
\t147\t129
\t149\t131
\t151\t133
\t154\t136
\t158\t141
\t159\t142
\t163\t144
\t164\t145
\t165\t146
\t166\t147
\t168\t149
\t169\t150
\t170\t151
\t172\t153
\t175\t155
\t176\t156
\t177\t157
\t178\t158
\t181\t160
\t186\t165
\t187\t166
\t192\t171
\t194\t173
\t195\t174
\t197\t176
\t203\t182
net/minecraft/server/dedicated/ServerPropertiesLoader\t25\t30
\t11\t15
\t12\t16
\t16\t20
\t20\t24
\t24\t28
\t25\t30
"""
}

View File

@@ -0,0 +1,87 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.test.unit
import spock.lang.Specification
import net.fabricmc.loom.decompilers.cache.JarWalker
import net.fabricmc.loom.test.util.ZipTestUtils
import net.fabricmc.loom.util.FileSystemUtil
class JarWalkerTest extends Specification {
def "find classes in jar"() {
given:
def jar = ZipTestUtils.createZip([
"net/fabricmc/Test.class": "",
"net/fabricmc/other/Test.class": "",
"net/fabricmc/other/Test\$Inner.class": "",
"net/fabricmc/other/Test\$1.class": "",
])
when:
def entries = JarWalker.findClasses(jar)
then:
entries.size() == 2
entries[0].parentClass() == "net/fabricmc/Test.class"
entries[0].sourcesFileName() == "net/fabricmc/Test.java"
entries[0].innerClasses().size() == 0
entries[1].parentClass() == "net/fabricmc/other/Test.class"
entries[1].sourcesFileName() == "net/fabricmc/other/Test.java"
entries[1].innerClasses().size() == 2
entries[1].innerClasses()[0] == "net/fabricmc/other/Test\$1.class"
entries[1].innerClasses()[1] == "net/fabricmc/other/Test\$Inner.class"
}
def "Hash Classes"() {
given:
def jar = ZipTestUtils.createZip(zipEntries)
when:
def entries = JarWalker.findClasses(jar)
def hash = FileSystemUtil.getJarFileSystem(jar).withCloseable { fs ->
return entries[0].hash(fs.root)
}
then:
entries.size() == 1
hash == expectedHash
where:
expectedHash | zipEntries
"2339de144d8a4a1198adf8142b6d3421ec0baacea13c9ade42a93071b6d62e43" | [
"net/fabricmc/Test.class": "abc123",
]
"1053cfadf4e371ec89ff5b58d9b3bdb80373f3179e804b2e241171223709f4d1" | [
"net/fabricmc/other/Test.class": "Hello",
"net/fabricmc/other/Test\$Inner.class": "World",
"net/fabricmc/other/Test\$Inner\$2.class": "123",
"net/fabricmc/other/Test\$1.class": "test",
]
"f30b705f3a921b60103a4ee9951aff59b6db87cc289ba24563743d753acff433" | [
"net/fabricmc/other/Test.class": "Hello",
"net/fabricmc/other/Test\$Inner.class": "World",
"net/fabricmc/other/Test\$Inner\$2.class": "abc123",
"net/fabricmc/other/Test\$1.class": "test",
]
}
}

View File

@@ -0,0 +1,62 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.test.unit.cache
import java.nio.channels.FileChannel
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import spock.lang.Specification
import spock.lang.TempDir
import net.fabricmc.loom.decompilers.ClassLineNumbers
import net.fabricmc.loom.decompilers.cache.CachedData
class CachedDataTest extends Specification {
@TempDir
Path testPath
// Simple test to check if the CachedData class can be written and read from a file
def "Read + Write CachedData"() {
given:
def lineNumberEntry = new ClassLineNumbers.Entry("net/test/TestClass", 1, 2, [1: 2, 4: 7])
def cachedData = new CachedData("net/test/TestClass", "Example sources", lineNumberEntry)
def path = testPath.resolve("cachedData.bin")
when:
// Write the cachedData to a file
FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE).withCloseable {
cachedData.write(it)
}
// And read it back
def readCachedData = Files.newInputStream(path).withCloseable {
return CachedData.read(it)
}
then:
cachedData == readCachedData
}
}

View File

@@ -0,0 +1,132 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.test.unit.cache
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.FileTime
import java.time.Duration
import java.time.Instant
import spock.lang.Specification
import spock.lang.TempDir
import net.fabricmc.loom.decompilers.cache.CachedFileStore
import net.fabricmc.loom.decompilers.cache.CachedFileStoreImpl
import net.fabricmc.loom.util.FileSystemUtil
class CachedFileStoreTest extends Specification {
@TempDir
Path testPath
FileSystemUtil.Delegate zipDelegate
Path root
void setup() {
zipDelegate = FileSystemUtil.getJarFileSystem(testPath.resolve("cache.zip"), true)
root = zipDelegate.get().getPath("/")
}
void cleanup() {
zipDelegate.close()
}
def "putEntry"() {
given:
def cacheRules = new CachedFileStoreImpl.CacheRules(100, Duration.ofDays(7))
def store = new CachedFileStoreImpl(root, BYTE_ARRAY_SERIALIZER, cacheRules)
when:
store.putEntry("abc", "Hello world".bytes)
then:
Files.exists(root.resolve("abc"))
}
def "getEntry"() {
given:
def cacheRules = new CachedFileStoreImpl.CacheRules(100, Duration.ofDays(7))
def store = new CachedFileStoreImpl(root, BYTE_ARRAY_SERIALIZER, cacheRules)
when:
store.putEntry("abc", "Hello world".bytes)
def entry = store.getEntry("abc")
def unknownEntry = store.getEntry("123")
then:
entry == "Hello world".bytes
unknownEntry == null
}
def "pruneManyFiles"() {
given:
def cacheRules = new CachedFileStoreImpl.CacheRules(250, Duration.ofDays(7))
def store = new CachedFileStoreImpl(root, BYTE_ARRAY_SERIALIZER, cacheRules)
when:
for (i in 0..<500) {
def key = "test_" + i
store.putEntry(key, "Hello world".bytes)
// Higher files are older and should be removed.
Files.setLastModifiedTime(root.resolve(key), FileTime.from(Instant.now().minusSeconds(i)))
}
store.prune()
then:
Files.exists(root.resolve("test_0"))
Files.exists(root.resolve("test_100"))
Files.notExists(root.resolve("test_300"))
}
def "pruneOldFiles"() {
given:
def cacheRules = new CachedFileStoreImpl.CacheRules(1000, Duration.ofSeconds(250))
def store = new CachedFileStoreImpl(root, BYTE_ARRAY_SERIALIZER, cacheRules)
when:
for (i in 0..<500) {
def key = "test_" + i
store.putEntry(key, "Hello world".bytes)
// Higher files are older and should be removed.
Files.setLastModifiedTime(root.resolve(key), FileTime.from(Instant.now().minusSeconds(i)))
}
store.prune()
then:
Files.exists(root.resolve("test_0"))
Files.exists(root.resolve("test_100"))
Files.notExists(root.resolve("test_300"))
}
private static CachedFileStore.EntrySerializer<byte[]> BYTE_ARRAY_SERIALIZER = new CachedFileStore.EntrySerializer<byte[]>() {
@Override
byte[] read(Path path) throws IOException {
return Files.readAllBytes(path)
}
@Override
void write(byte[] entry, Path path) throws IOException {
Files.write(path, entry)
}
}
}

View File

@@ -0,0 +1,241 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2024 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.test.unit.cache
import java.nio.file.Files
import spock.lang.Specification
import net.fabricmc.loom.decompilers.ClassLineNumbers
import net.fabricmc.loom.decompilers.cache.CachedData
import net.fabricmc.loom.decompilers.cache.CachedFileStore
import net.fabricmc.loom.decompilers.cache.CachedJarProcessor
import net.fabricmc.loom.test.util.ZipTestUtils
import net.fabricmc.loom.util.ZipUtils
class CachedJarProcessorTest extends Specification {
static Map<String, String> jarEntries = [
"net/fabricmc/Example.class": "",
"net/fabricmc/other/Test.class": "",
"net/fabricmc/other/Test\$Inner.class": "",
"net/fabricmc/other/Test\$1.class": "",
]
static String ExampleHash = "abc123/cd372fb85148700fa88095e3492d3f9f5beb43e555e5ff26d95f5a6adc36f8e6"
static String TestHash = "abc123/ecd40b16ec50b636a390cb8da716a22606965f14e526e3051144dd567f336bc5"
static CachedData ExampleCachedData = new CachedData("net/fabricmc/Example", "Example sources", lineNumber("net/fabricmc/Example"))
static CachedData TestCachedData = new CachedData("net/fabricmc/other/Test", "Test sources", lineNumber("net/fabricmc/other/Test"))
def "prepare full work job"() {
given:
def jar = ZipTestUtils.createZip(jarEntries)
def cache = Mock(CachedFileStore)
def processor = new CachedJarProcessor(cache, "abc123")
when:
def workRequest = processor.prepareJob(jar)
def workJob = workRequest.job() as CachedJarProcessor.FullWorkJob
then:
workRequest.lineNumbers() == null
workJob.outputNameMap().size() == 2
// Expect two calls looking for the existing entry in the cache
2 * cache.getEntry(_) >> null
0 * _ // Strict mock
}
def "prepare partial work job"() {
given:
def jar = ZipTestUtils.createZip(jarEntries)
def cache = Mock(CachedFileStore)
def processor = new CachedJarProcessor(cache, "abc123")
when:
def workRequest = processor.prepareJob(jar)
def workJob = workRequest.job() as CachedJarProcessor.PartialWorkJob
def lineMap = workRequest.lineNumbers().lineMap()
then:
lineMap.size() == 1
lineMap.get("net/fabricmc/Example") == ExampleCachedData.lineNumbers()
workJob.outputNameMap().size() == 1
ZipUtils.unpackNullable(workJob.existing(), "net/fabricmc/Example.java") == "Example sources".bytes
// Provide one cached entry
// And then one call not finding the entry in the cache
1 * cache.getEntry(ExampleHash) >> ExampleCachedData
1 * cache.getEntry(_) >> null
0 * _ // Strict mock
}
def "prepare completed work job"() {
given:
def jar = ZipTestUtils.createZip(jarEntries)
def cache = Mock(CachedFileStore)
def processor = new CachedJarProcessor(cache, "abc123")
when:
def workRequest = processor.prepareJob(jar)
def workJob = workRequest.job() as CachedJarProcessor.CompletedWorkJob
def lineMap = workRequest.lineNumbers().lineMap()
then:
lineMap.size() == 2
lineMap.get("net/fabricmc/Example") == ExampleCachedData.lineNumbers()
lineMap.get("net/fabricmc/other/Test") == TestCachedData.lineNumbers()
workJob.completed() != null
ZipUtils.unpackNullable(workJob.completed(), "net/fabricmc/Example.java") == "Example sources".bytes
ZipUtils.unpackNullable(workJob.completed(), "net/fabricmc/other/Test.java") == "Test sources".bytes
// Provide one cached entry
// And then two calls not finding the entry in the cache
1 * cache.getEntry(ExampleHash) >> ExampleCachedData
1 * cache.getEntry(TestHash) >> TestCachedData
0 * _ // Strict mock
}
def "complete full work job"() {
given:
def jar = ZipTestUtils.createZip(jarEntries)
def cache = Mock(CachedFileStore)
def processor = new CachedJarProcessor(cache, "abc123")
when:
def workRequest = processor.prepareJob(jar)
def workJob = workRequest.job() as CachedJarProcessor.FullWorkJob
// Do the work, such as decompiling.
ZipUtils.add(workJob.output(), "net/fabricmc/Example.java", "Example sources")
ZipUtils.add(workJob.output(), "net/fabricmc/other/Test.java", "Test sources")
def outputJar = Files.createTempFile("loom-test-output", ".jar")
Files.delete(outputJar)
ClassLineNumbers lineNumbers = lineNumbers([
"net/fabricmc/Example",
"net/fabricmc/other/Test"
])
processor.completeJob(outputJar, workJob, lineNumbers)
then:
workJob.outputNameMap().size() == 2
ZipUtils.unpackNullable(outputJar, "net/fabricmc/Example.java") == "Example sources".bytes
ZipUtils.unpackNullable(outputJar, "net/fabricmc/other/Test.java") == "Test sources".bytes
// Expect two calls looking for the existing entry in the cache
1 * cache.getEntry(ExampleHash) >> null
1 * cache.getEntry(TestHash) >> null
// Expect the new work to be put into the cache
1 * cache.putEntry(ExampleHash, ExampleCachedData)
1 * cache.putEntry(TestHash, TestCachedData)
0 * _ // Strict mock
}
def "complete partial work job"() {
given:
def jar = ZipTestUtils.createZip(jarEntries)
def cache = Mock(CachedFileStore)
def processor = new CachedJarProcessor(cache, "abc123")
when:
def workRequest = processor.prepareJob(jar)
def workJob = workRequest.job() as CachedJarProcessor.PartialWorkJob
// Do the work
ZipUtils.add(workJob.output(), "net/fabricmc/other/Test.java", "Test sources")
def outputJar = Files.createTempFile("loom-test-output", ".jar")
Files.delete(outputJar)
ClassLineNumbers lineNumbers = lineNumbers([
"net/fabricmc/Example",
"net/fabricmc/other/Test"
])
processor.completeJob(outputJar, workJob, lineNumbers)
then:
workJob.outputNameMap().size() == 1
ZipUtils.unpackNullable(outputJar, "net/fabricmc/Example.java") == "Example sources".bytes
ZipUtils.unpackNullable(outputJar, "net/fabricmc/other/Test.java") == "Test sources".bytes
// The cache already contains sources for example, but not for test
1 * cache.getEntry(ExampleHash) >> ExampleCachedData
1 * cache.getEntry(TestHash) >> null
// Expect the new work to be put into the cache
1 * cache.putEntry(TestHash, TestCachedData)
0 * _ // Strict mock
}
def "complete completed work job"() {
given:
def jar = ZipTestUtils.createZip(jarEntries)
def cache = Mock(CachedFileStore)
def processor = new CachedJarProcessor(cache, "abc123")
when:
def workRequest = processor.prepareJob(jar)
def workJob = workRequest.job() as CachedJarProcessor.CompletedWorkJob
def outputJar = Files.createTempFile("loom-test-output", ".jar")
Files.delete(outputJar)
ClassLineNumbers lineNumbers = lineNumbers([
"net/fabricmc/Example",
"net/fabricmc/other/Test"
])
processor.completeJob(outputJar, workJob, lineNumbers)
then:
ZipUtils.unpackNullable(outputJar, "net/fabricmc/Example.java") == "Example sources".bytes
ZipUtils.unpackNullable(outputJar, "net/fabricmc/other/Test.java") == "Test sources".bytes
// The cache already contains sources for example, but not for test
1 * cache.getEntry(ExampleHash) >> ExampleCachedData
1 * cache.getEntry(TestHash) >> TestCachedData
0 * _ // Strict mock
}
private static ClassLineNumbers lineNumbers(List<String> names) {
return new ClassLineNumbers(names.collectEntries { [it, lineNumber(it)] })
}
private static ClassLineNumbers.Entry lineNumber(String name) {
return new ClassLineNumbers.Entry(name, 0, 0, [:])
}
}