Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`.\
Expand All @@ -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-<VERSION>-all.jar`

### Running
`java -jar HeadExtractor-<VERSION>-all.jar <WORLD DIRECTORY>`
`java -jar HeadExtractor-<VERSION>-all.jar [OPTIONS] <WORLD DIRECTORIES>`

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.<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`
- Maven:
- Add the repository to the end of your repositories section:
```xml
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
```
- Add the dependency:
```xml
<dependency>
<groupId>com.github.User</groupId>
<artifactId>HeadExtractor</artifactId>
<version>Version</version>
</dependency>
```
- 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<Path> worldPaths, boolean includeEntities,
boolean includeRegion, boolean includePlayerData, boolean includeDataPacks)`!
21 changes: 20 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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")
}
}

publishing {
publications {
register("mavenJava", MavenPublication::class) {
from(components["java"])
}
}
}
2 changes: 2 additions & 0 deletions jitpack.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
jdk:
- openjdk17
144 changes: 117 additions & 27 deletions src/main/java/me/amberichu/headextractor/HeadExtractor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
* <p>
* 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-<VERSION>-all.jar [OPTIONS] <WORLD DIRECTORIES>

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<Path> 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<String> heads = extractHeads(Path.of(args[0]));

Set<String> heads = extractHeads(worldPaths, includeEntities, includeRegion, includePlayerData, includeDataPacks);
heads.forEach(System.out::println);
}

private static Set<String> 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<String> extractHeads(Set<Path> worldPaths, boolean includeEntities, boolean includeRegion,
boolean includePlayerData, boolean includeDataPacks) throws IOException {
Set<String> heads = ConcurrentHashMap.newKeySet();
if (!(includeEntities || includeRegion || includePlayerData || includeDataPacks)) return heads;

ExecutorService executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() - 1);
List<CompletableFuture<?>> tasks = new ArrayList<>();
Expand All @@ -77,13 +153,19 @@ private static Set<String> 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();
Expand All @@ -93,17 +175,18 @@ private static Set<String> extractHeads(Path worldPath) throws IOException {
return heads;
}

private static List<Path> gatherMCA(Path worldPath) throws IOException {
private static List<Path> gatherMCA(Path worldPath, boolean includeEntities, boolean includeRegion)
throws IOException {
Path entitiesPath = worldPath.resolve("entities");
Path regionPath = worldPath.resolve("region");

List<Path> mcaPaths = new ArrayList<>();
if (Files.isDirectory(entitiesPath)) {
if (includeEntities && Files.isDirectory(entitiesPath)) {
try (Stream<Path> stream = Files.list(entitiesPath)) {
stream.forEach(mcaPaths::add);
}
}
if (Files.isDirectory(regionPath)) {
if (includeRegion && Files.isDirectory(regionPath)) {
try (Stream<Path> stream = Files.list(regionPath)) {
stream.forEach(mcaPaths::add);
}
Expand Down Expand Up @@ -218,14 +301,21 @@ private static void processTag(Tag rootTag, Consumer<String> 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);
Expand Down Expand Up @@ -263,4 +353,4 @@ private static boolean validateHead(String head) {
return false;
}
}
}
}