diff --git a/README.md b/README.md index 1eac0c7..47662c3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## Head Extractor -Head Extractor is a tool to extract the player profile from the player heads in a Minecraft world. +Head Extractor is a tool and library to extract the player profile from the player heads in a Minecraft world. This is accomplished somewhat inefficiently by searching chunk NBT, player data NBT, and entity NBT for lists of Compound tags that contain a String tag named `Value`.\ @@ -12,6 +12,49 @@ In addition, `mcfunction` and `json` files in data packs are scanned for Base64 3. Tool is located at `build/libs/HeadExtractor--all.jar` ### Running -`java -jar HeadExtractor--all.jar ` +`java -jar HeadExtractor--all.jar [OPTIONS] ` + +Options: +- `--exclude-entities`: Exclude heads carried by entities +- `--exclude-region`: Exclude heads placed in the world and in containers +- `--exclude-playerdata`: Exclude heads in players' inventories +- `--exclude-datapacks`: Exclude base64-encoded player profiles in .json or .mcfunction files in datapacks + +There is also a corresponding --include option for each of the above. The default behavior is to include all heads. Player profiles are sent line by line to standard output. + +### Library usage +You can include this library in your project from [jitpack.io](https://jitpack.io/)! + +- Gradle: + - Add `maven { url 'https://jitpack.io' }` to the end of your repositories section + - Add the dependency `implementation 'com.github.:HeadExtractor:'` + - Replace `` with the owner of the fork you would like to use + - Replace `` with the tag of the GitHub Release you would like to use + - You can also use `main-SNAPSHOT` to take the latest commit from `main` +- Maven: + - Add the repository to the end of your repositories section: + ```xml + + jitpack.io + https://jitpack.io + + ``` + - Add the dependency: + ```xml + + com.github.User + HeadExtractor + Version + + ``` + - Replace `User` with the owner of the fork you would like to use + - Replace `Version` with the tag of the GitHub Release you would like to use + - You can also use `main-SNAPSHOT` to take the latest commit from `main` + +You can also include the compiled jar as a library using your preferred method. + +Once you've included the library, all you need to do is call +`me.amberichu.headextractor.HeadExtractor#extractHeads(Set worldPaths, boolean includeEntities, +boolean includeRegion, boolean includePlayerData, boolean includeDataPacks)`! diff --git a/build.gradle.kts b/build.gradle.kts index 16d43d0..0b76f56 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,11 +2,22 @@ plugins { id("java") id("application") id("com.github.johnrengelman.shadow") version "7.1.2" + id("maven-publish") } group = "me.amberichu.headextractor" version = "1.0-SNAPSHOT" +java { + withSourcesJar() + withJavadocJar() + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + application { mainClass.set("me.amberichu.headextractor.HeadExtractor") } @@ -24,4 +35,12 @@ dependencies { implementation("com.fasterxml.jackson.core", "jackson-core", "2.14.1") implementation("com.fasterxml.jackson.core", "jackson-databind", "2.14.1") -} \ No newline at end of file +} + +publishing { + publications { + register("mavenJava", MavenPublication::class) { + from(components["java"]) + } + } +} diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 0000000..efde7bf --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,2 @@ +jdk: + - openjdk17 diff --git a/src/main/java/me/amberichu/headextractor/HeadExtractor.java b/src/main/java/me/amberichu/headextractor/HeadExtractor.java index 18342c7..db26cfb 100644 --- a/src/main/java/me/amberichu/headextractor/HeadExtractor.java +++ b/src/main/java/me/amberichu/headextractor/HeadExtractor.java @@ -36,11 +36,7 @@ import java.nio.ByteOrder; import java.nio.MappedByteBuffer; import java.nio.channels.FileChannel; -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; +import java.nio.file.*; import java.util.*; import java.util.concurrent.*; import java.util.function.Consumer; @@ -50,23 +46,103 @@ import java.util.zip.GZIPInputStream; import java.util.zip.InflaterInputStream; +/** + * Head Extractor is a tool and library to extract the player profile from the player heads in a Minecraft world. + *

+ * This is accomplished somewhat inefficiently by searching chunk NBT, player data NBT, and entity NBT for lists of + * Compound tags that contain a String tag named Value. + * In addition, mcfunction and json files in data packs are scanned for Base64 encoded player profiles. + */ public class HeadExtractor { + + private static final String USAGE = """ + HeadExtractor by Amberichu + https://github.com/davchoo/HeadExtractor + + Head Extractor is a tool to extract the player profile from the player heads in a Minecraft world. + + Usage: java -jar HeadExtractor--all.jar [OPTIONS] + + Options: + --exclude-entities: Exclude heads carried by entities + --exclude-region: Exclude heads placed in the world and in containers + --exclude-playerdata: Exclude heads in players' inventories + --exclude-datapacks: Exclude base64-encoded player profiles in .json or .mcfunction files in datapacks + There is also a corresponding --include option for each of the above. + The default behavior is to include all heads."""; + private static final ObjectMapper MAPPER = new ObjectMapper(); // Adapted from https://stackoverflow.com/a/475217 private static final Pattern BASE64_PATTERN = Pattern.compile("\\\\?[\"']((?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=))\\\\?[\"']"); public static void main(String[] args) throws IOException { - if (args.length != 1) { - System.out.println("Please specify one world folder."); - System.exit(-1); + Set worldPaths = new HashSet<>(); + boolean includeEntities = true; + boolean includeRegion = true; + boolean includePlayerData = true; + boolean includeDataPacks = true; + + for (String arg : args) { + if (arg.startsWith("--")) { + switch (arg) { + case "--include-entities" -> includeEntities = true; + case "--include-region" -> includeRegion = true; + case "--include-playerdata" -> includePlayerData = true; + case "--include-datapacks" -> includeDataPacks = true; + case "--exclude-entities" -> includeEntities = false; + case "--exclude-region" -> includeRegion = false; + case "--exclude-playerdata" -> includePlayerData = false; + case "--exclude-datapacks" -> includeDataPacks = false; + case "--help" -> { + System.out.println(USAGE); + return; + } + default -> { + System.err.println("Unknown option " + arg + ", use --help for help."); + System.exit(1); + } + } + } else { + try { + Path worldPath = Path.of(arg); + + if (!Files.isDirectory(worldPath)) { + System.err.println("World path " + arg + " does not exist, use --help for help."); + System.exit(1); + } + + worldPaths.add(worldPath); + } catch (InvalidPathException e) { + System.err.println("Invalid world path " + arg + ", use --help for help."); + System.exit(1); + } + } + } + + if (worldPaths.isEmpty()) { + System.out.println(USAGE); + return; } - Set heads = extractHeads(Path.of(args[0])); + + Set heads = extractHeads(worldPaths, includeEntities, includeRegion, includePlayerData, includeDataPacks); heads.forEach(System.out::println); } - private static Set extractHeads(Path worldPath) throws IOException { + /** + * Extract player head textures from worlds + * @param worldPaths Paths to the worlds to scan + * @param includeEntities Whether to scan heads carried by non-player entities + * @param includeRegion Whether to scan heads placed in the world or in containers + * @param includePlayerData Whether to scan heads carried by players + * @param includeDataPacks Whether to scan .json and .mcfunction files in datapacks + * @return A set of the base64-encoded player profiles in the given worlds + * @throws IOException If an I/O error occurs + */ + public static Set extractHeads(Set worldPaths, boolean includeEntities, boolean includeRegion, + boolean includePlayerData, boolean includeDataPacks) throws IOException { Set heads = ConcurrentHashMap.newKeySet(); + if (!(includeEntities || includeRegion || includePlayerData || includeDataPacks)) return heads; ExecutorService executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() - 1); List> tasks = new ArrayList<>(); @@ -77,13 +153,19 @@ private static Set extractHeads(Path worldPath) throws IOException { } }; - for (Path path : gatherMCA(worldPath)) { - tasks.add(CompletableFuture.runAsync(() -> processMCA(path, headConsumer), executor)); - } - for (Path path : gatherPlayerData(worldPath)) { - tasks.add(CompletableFuture.runAsync(() -> processDAT(path, headConsumer), executor)); + for (Path worldPath : worldPaths) { + if (includeEntities || includeRegion) { + for (Path path : gatherMCA(worldPath, includeEntities, includeRegion)) { + tasks.add(CompletableFuture.runAsync(() -> processMCA(path, headConsumer), executor)); + } + } + if (includePlayerData) { + for (Path path : gatherPlayerData(worldPath)) { + tasks.add(CompletableFuture.runAsync(() -> processDAT(path, headConsumer), executor)); + } + } + if (includeDataPacks) gatherFromDataPacks(worldPath, headConsumer); } - gatherFromDataPacks(worldPath, headConsumer); // Wait for all tasks to be complete CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0])).join(); @@ -93,17 +175,18 @@ private static Set extractHeads(Path worldPath) throws IOException { return heads; } - private static List gatherMCA(Path worldPath) throws IOException { + private static List gatherMCA(Path worldPath, boolean includeEntities, boolean includeRegion) + throws IOException { Path entitiesPath = worldPath.resolve("entities"); Path regionPath = worldPath.resolve("region"); List mcaPaths = new ArrayList<>(); - if (Files.isDirectory(entitiesPath)) { + if (includeEntities && Files.isDirectory(entitiesPath)) { try (Stream stream = Files.list(entitiesPath)) { stream.forEach(mcaPaths::add); } } - if (Files.isDirectory(regionPath)) { + if (includeRegion && Files.isDirectory(regionPath)) { try (Stream stream = Files.list(regionPath)) { stream.forEach(mcaPaths::add); } @@ -218,14 +301,21 @@ private static void processTag(Tag rootTag, Consumer headConsumer) { // The ListTag can't store player profiles continue; } - if (!listTag.getName().equals("textures")) { - listTag.forEach(tags::addLast); - continue; - } - if (listTag.size() != 0 && listTag.get(0) instanceof CompoundTag texture) { - if (texture.get("Value") instanceof StringTag valueTag) { - headConsumer.accept(valueTag.getValue()); + if (listTag.getName().equals("textures")) { // Pre-1.20.5 item component rework + if (listTag.size() != 0 && listTag.get(0) instanceof CompoundTag texture) { + if (texture.get("Value") instanceof StringTag valueTag) { + headConsumer.accept(valueTag.getValue()); + } } + } else if (listTag.getName().equals("properties")) { // Item component storage system + if (listTag.size() != 0 && listTag.get(0) instanceof CompoundTag texture) { + if (texture.get("name") instanceof StringTag nameTag && + texture.get("value") instanceof StringTag valueTag) { + if (nameTag.getValue().equals("textures")) headConsumer.accept(valueTag.getValue()); + } + } + } else { // Scan children of this ListTag + listTag.forEach(tags::addLast); } } else if (tag instanceof StringTag stringTag) { processString(stringTag.getValue(), headConsumer); @@ -263,4 +353,4 @@ private static boolean validateHead(String head) { return false; } } -} \ No newline at end of file +}