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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ hs_err_pid*
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
.gradle/

# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
## 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 to extract the skin hashes 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.
In addition, `mcfunction` and `json` files in data packs are scanned for Base64 encoded player profiles.

The skin hash is then extracted from the Base64 encoded player profile.

### Compiling
1. Clone the repository
Expand All @@ -14,4 +16,4 @@ In addition, `mcfunction` and `json` files in data packs are scanned for Base64
### Running
`java -jar HeadExtractor-<VERSION>-all.jar <WORLD DIRECTORY>`

Player profiles are sent line by line to standard output.
Skin hashes are saved to `custom-skulls.yml`.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ dependencies {

implementation("com.fasterxml.jackson.core", "jackson-core", "2.14.1")
implementation("com.fasterxml.jackson.core", "jackson-databind", "2.14.1")
implementation("com.fasterxml.jackson.dataformat", "jackson-dataformat-yaml", "2.14.1")
}
51 changes: 41 additions & 10 deletions src/main/java/me/amberichu/headextractor/HeadExtractor.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@

package me.amberichu.headextractor;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.github.steveice10.opennbt.NBTIO;
import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
import com.github.steveice10.opennbt.tag.builtin.ListTag;
Expand All @@ -52,6 +55,7 @@

public class HeadExtractor {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory());

// 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}=))\\\\?[\"']");
Expand All @@ -61,8 +65,19 @@ public static void main(String[] args) throws IOException {
System.out.println("Please specify one world folder.");
System.exit(-1);
}

Set<String> heads = extractHeads(Path.of(args[0]));
heads.forEach(System.out::println);
YAML_MAPPER.writeValue(new File("custom-skulls.yml"), new SkinHashesConfig(heads));
}

static class SkinHashesConfig {
@JsonProperty("skin-hashes")
@JsonAlias("skin-hashes")
private Set<String> skinHashes = new HashSet<>();

SkinHashesConfig(Set<String> heads) {
this.skinHashes = heads;
}
}

private static Set<String> extractHeads(Path worldPath) throws IOException {
Expand All @@ -72,8 +87,9 @@ private static Set<String> extractHeads(Path worldPath) throws IOException {
List<CompletableFuture<?>> tasks = new ArrayList<>();

Consumer<String> headConsumer = head -> {
if (validateHead(head)) {
heads.add(head);
String url = validatedHeadHash(head);
if (url != null) {
heads.add(url);
}
};

Expand Down Expand Up @@ -178,7 +194,13 @@ private static void processMCA(Path mcaPath, Consumer<String> headConsumer) {
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());
buffer.order(ByteOrder.BIG_ENDIAN);
for (int i = 0; i < 1024; i++) {
int location = buffer.getInt(4 * i);
int location;
try {
location = buffer.getInt(4 * i);
} catch (IndexOutOfBoundsException e) {
// Chunk is empty
continue;
}
if (location == 0) {
// Chunk is not present
continue;
Expand Down Expand Up @@ -240,27 +262,36 @@ private static void processString(String string, Consumer<String> headConsumer)
}
}

private static boolean validateHead(String head) {
private static String validatedHeadHash(String head) {
try {
JsonNode node = MAPPER.readTree(Base64.getDecoder().decode(head));
if (!node.isObject()) {
return false;
return null;
}

JsonNode textures = node.get("textures");
if (textures == null || !textures.isObject()) {
return false;
return null;
}

JsonNode skin = textures.get("SKIN");
if (skin == null || !textures.isObject()) {
return false;
return null;
}

JsonNode url = skin.get("url");
return url != null && url.isTextual();
if (url != null && url.isTextual()) {
String hashPattern = "/([a-fA-F0-9]+)$";
Pattern pattern = Pattern.compile(hashPattern);
Matcher matcher = pattern.matcher(url.asText());
if (matcher.find()) {
return matcher.group(1);
}
}

return null;
} catch (Exception e) {
return false;
return null;
}
}
}