Skip to content

Commit

Permalink
Add support for new profile field in player head blocks and items. (#…
Browse files Browse the repository at this point in the history
…1717)

* Add support for new profile field in player head blocks and items.

* Fix skull texture tests and add a test for the new profile property.
  • Loading branch information
leMaik authored Apr 11, 2024
1 parent 41a1bd5 commit dad7c4c
Show file tree
Hide file tree
Showing 8 changed files with 141 additions and 49 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import se.llbit.chunky.model.minecraft.FlowerPotModel.Kind;
import se.llbit.chunky.resources.ShulkerTexture;
import se.llbit.chunky.resources.Texture;
import se.llbit.chunky.world.BlockData;
import se.llbit.nbt.ListTag;
import se.llbit.nbt.Tag;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
package se.llbit.chunky.block.legacy.blocks;

import static se.llbit.chunky.block.minecraft.Head.getTextureUrl;

import se.llbit.chunky.block.MinecraftBlockTranslucent;
import se.llbit.chunky.entity.Entity;
import se.llbit.chunky.entity.HeadEntity;
import se.llbit.chunky.entity.SkullEntity;
import se.llbit.chunky.entity.SkullEntity.Kind;
import se.llbit.chunky.renderer.scene.Scene;
import se.llbit.chunky.resources.Texture;
import se.llbit.log.Log;
import se.llbit.math.Ray;
import se.llbit.math.Vector3;
import se.llbit.nbt.CompoundTag;

import java.io.IOException;

import static se.llbit.chunky.block.minecraft.Head.getTextureUrl;

/**
* A skull or player head from Minecraft 1.12 or earlier.
* <p>
Expand Down Expand Up @@ -40,9 +43,13 @@ public Entity toBlockEntity(Vector3 position, CompoundTag entityTag) {
Kind kind = getSkullKind(entityTag.get("SkullType").byteValue(0));
int rotation = entityTag.get("Rot").byteValue(0);
if (kind == Kind.PLAYER) {
String textureUrl = getTextureUrl(entityTag);
if (textureUrl != null) {
return new HeadEntity(position, textureUrl, rotation, placement);
try {
String textureUrl = getTextureUrl(entityTag);
if (textureUrl != null) {
return new HeadEntity(position, textureUrl, rotation, placement);
}
} catch (IOException e) {
Log.warn("Could not download skin", e);
}
}
return new SkullEntity(position, kind, rotation, placement);
Expand Down
47 changes: 36 additions & 11 deletions chunky/src/java/se/llbit/chunky/block/minecraft/Head.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,17 @@
import se.llbit.chunky.entity.SkullEntity.Kind;
import se.llbit.chunky.renderer.scene.Scene;
import se.llbit.chunky.resources.Texture;
import se.llbit.log.Log;
import se.llbit.math.Ray;
import se.llbit.math.Vector3;
import se.llbit.nbt.CompoundTag;
import se.llbit.nbt.Tag;
import se.llbit.util.UuidUtil;
import se.llbit.util.mojangapi.MinecraftSkin;
import se.llbit.util.mojangapi.MojangApi;

import java.io.IOException;
import java.util.Optional;

public class Head extends MinecraftBlockTranslucent {

Expand Down Expand Up @@ -73,24 +79,43 @@ public boolean isBlockEntity() {
@Override
public Entity toBlockEntity(Vector3 position, CompoundTag entityTag) {
if (type == Kind.PLAYER) {
String textureUrl = getTextureUrl(entityTag);
return textureUrl != null ? new HeadEntity(position, textureUrl, rotation, 1)
: new SkullEntity(position, type, rotation, 1);
} else {
return null;
try {
String textureUrl = getTextureUrl(entityTag);
return textureUrl != null ? new HeadEntity(position, textureUrl, rotation, 1)
: new SkullEntity(position, type, rotation, 1);
} catch (IOException e) {
Log.warn("Could not download skin", e);
}
}
return null;
}

public static String getTextureUrl(CompoundTag entityTag) {
public static String getTextureUrl(CompoundTag entityTag) throws IOException {
Tag ownerTag = entityTag.get("Owner"); // used by skulls
if (!ownerTag.isCompoundTag()) {
ownerTag = entityTag.get("SkullOwner"); // used by player heads
ownerTag = entityTag.get("SkullOwner"); // used by player heads (1.20 or earlier)
}
if (ownerTag.isCompoundTag()) {
String textureBase64 = ownerTag.get("Properties").get("textures").get(0)
.get("Value").stringValue();
if (!textureBase64.isEmpty()) {
return MinecraftSkin.getSkinFromEncodedTextures(textureBase64).map(MinecraftSkin::getSkinUrl).orElse(null);
}
}
String textureBase64 = ownerTag.get("Properties").get("textures").get(0)
.get("Value").stringValue();
if (!textureBase64.isEmpty()) {
return MinecraftSkin.getSkinFromEncodedTextures(textureBase64).map(MinecraftSkin::getSkinUrl).orElse(null);
Tag profileTag = entityTag.get("profile");
if (profileTag.isCompoundTag()) {
// 24w10a (i.e. 1.21) or later
return getSkinFromProfileTag(profileTag.asCompound()).map(MinecraftSkin::getSkinUrl).orElse(null);
}
return null;
}

public static Optional<MinecraftSkin> getSkinFromProfileTag(CompoundTag profileTag) throws IOException {
Tag properties = profileTag.get("properties");
if (properties.isCompoundTag()) {
return MinecraftSkin.getSkinFromEncodedTextures(properties.get("value").stringValue());
}
int[] uuidInts = profileTag.get("id").intArray();
return MojangApi.fetchProfile(UuidUtil.intsToUuid(uuidInts).toString()).getSkin();
}
}
14 changes: 10 additions & 4 deletions chunky/src/java/se/llbit/chunky/block/minecraft/WallHead.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@
import se.llbit.chunky.entity.SkullEntity.Kind;
import se.llbit.chunky.renderer.scene.Scene;
import se.llbit.chunky.resources.Texture;
import se.llbit.log.Log;
import se.llbit.math.Ray;
import se.llbit.math.Vector3;
import se.llbit.nbt.CompoundTag;

import java.io.IOException;

public class WallHead extends MinecraftBlockTranslucent {

private final String description;
Expand Down Expand Up @@ -86,11 +89,14 @@ public boolean isBlockEntity() {
@Override
public Entity toBlockEntity(Vector3 position, CompoundTag entityTag) {
if (type == Kind.PLAYER) {
String textureUrl = Head.getTextureUrl(entityTag);
return textureUrl != null ? new HeadEntity(position, textureUrl, 0, facing)
try {
String textureUrl = Head.getTextureUrl(entityTag);
return textureUrl != null ? new HeadEntity(position, textureUrl, 0, facing)
: new SkullEntity(position, type, 0, facing);
} else {
return null;
} catch (IOException e) {
Log.warn("Could not download skin", e);
}
}
return null;
}
}
43 changes: 29 additions & 14 deletions chunky/src/java/se/llbit/chunky/entity/PlayerEntity.java
Original file line number Diff line number Diff line change
Expand Up @@ -122,26 +122,41 @@ static JsonObject parseItem(CompoundTag tag) {
} else if (id.equals("minecraft:player_head")) {
Tag skinTag = tag.get("tag").get("SkullOwner").get("Properties").get("textures").get(0).get("Value");
if (!skinTag.isError()) {
String skinUrl = Head.getTextureUrl(tag.get("tag").asCompound());
if (skinUrl != null && !skinUrl.isEmpty()) {
item.add("skin", skinUrl);
try {
String skinUrl = Head.getTextureUrl(tag.get("tag").asCompound());
if (skinUrl != null && !skinUrl.isEmpty()) {
item.add("skin", skinUrl);
}
} catch (IOException e) {
Log.warn("Could not download skin", e);
}
} else {
Tag skinPlayername = tag.get("tag").get("SkullOwner");
if (!skinPlayername.isError()) {
Tag profileTag = tag.get("components").get("minecraft:profile");
if (profileTag.isCompoundTag()) {
// 24w10a (i.e. 1.21) or later
try {
String playername = skinPlayername.stringValue();
if (playername != null) {
String uuid = MojangApi.usernameToUUID(playername);
if (uuid != null) {
MinecraftProfile profile = MojangApi.fetchProfile(uuid);
Optional<MinecraftSkin> skin = profile.getSkin();
skin.ifPresent(minecraftSkin -> item.add("skin", minecraftSkin.getSkinUrl()));
}
}
Head.getSkinFromProfileTag(profileTag.asCompound()).ifPresent(minecraftSkin -> item.add("skin", minecraftSkin.getSkinUrl()));
} catch (IOException e) {
Log.warn("Could not download skin", e);
}
} else {
// 1.20 or earlier
Tag skinPlayername = tag.get("tag").get("SkullOwner");
if (!skinPlayername.isError()) {
try {
String playername = skinPlayername.stringValue();
if (playername != null) {
String uuid = MojangApi.usernameToUUID(playername);
if (uuid != null) {
MinecraftProfile profile = MojangApi.fetchProfile(uuid);
Optional<MinecraftSkin> skin = profile.getSkin();
skin.ifPresent(minecraftSkin -> item.add("skin", minecraftSkin.getSkinUrl()));
}
}
} catch (IOException e) {
Log.warn("Could not download skin", e);
}
}
}
}
}
Expand Down
18 changes: 18 additions & 0 deletions chunky/src/java/se/llbit/util/UuidUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package se.llbit.util;

import java.util.UUID;

public class UuidUtil {
/**
* Converts four integers (ordered from MSB to LSB) to a UUID.
*
* @param ints Four integers, ordered from MSB to LSB
* @return UUID
*/
public static UUID intsToUuid(int[] ints) {
return new UUID(
((long) ints[0]) << 32 | ((long) ints[1] & 0xFFFFFFFFL),
((long) ints[2]) << 32 | ((long) ints[3] & 0xFFFFFFFFL)
);
}
}
2 changes: 1 addition & 1 deletion chunky/src/java/se/llbit/util/mojangapi/MojangApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ static String fixBase64Padding(String base64String) {
* @throws IOException if downloading the profile failed
*/
public static MinecraftProfile fetchProfile(String uuid) throws IOException {
uuid = uuid.toLowerCase();
uuid = uuid.toLowerCase().replaceAll("-", "");
String key = uuid + ":profile";
File cacheFile =
new File(PersistentSettings.cacheDirectory(), Util.cacheEncode(key.hashCode()));
Expand Down
48 changes: 35 additions & 13 deletions chunky/src/test/se/llbit/chunky/block/SkullTextureTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Collections;
Expand All @@ -18,7 +19,7 @@
public class SkullTextureTest {

@Test
public void testValidJson() {
public void testValidJson() throws IOException {
CompoundTag validOwnerTag = createSkullTag("Owner", Base64.getEncoder().encodeToString(
"{ \"textures\": { \"SKIN\": { \"url\": \"http://textures.minecraft.net/texture/1acde954db327685201f785a6b248b73fdc8982c2aed430f697cdebec9b7e14\" } } }"
.getBytes(StandardCharsets.UTF_8)));
Expand All @@ -28,18 +29,29 @@ public void testValidJson() {
}

@Test
public void testSkullOwner() {
public void testSkullOwner() throws IOException {
// player heads use SkullOwner, which should work too
CompoundTag validOwnerTag = createSkullTag("SkullOwner", Base64.getEncoder().encodeToString(
"{ \"textures\": { \"SKIN\": { \"url\": \"http://textures.minecraft.net/texture/1acde954db327685201f785a6b248b73fdc8982c2aed430f697cdebec9b7e14\" } } }"
.getBytes(StandardCharsets.UTF_8)));
"{ \"textures\": { \"SKIN\": { \"url\": \"http://textures.minecraft.net/texture/1acde954db327685201f785a6b248b73fdc8982c2aed430f697cdebec9b7e14\" } } }"
.getBytes(StandardCharsets.UTF_8)));
assertEquals(
"http://textures.minecraft.net/texture/1acde954db327685201f785a6b248b73fdc8982c2aed430f697cdebec9b7e14",
Head.getTextureUrl(validOwnerTag));
"http://textures.minecraft.net/texture/1acde954db327685201f785a6b248b73fdc8982c2aed430f697cdebec9b7e14",
Head.getTextureUrl(validOwnerTag));
}

@Test
public void testInvalidJson() { // test for #680, #681
public void testProfileTag() throws IOException {
// player heads use profile (1.21+), which should work too
CompoundTag validTagWithProfile = createTagWithProfile(Base64.getEncoder().encodeToString(
"{ \"textures\": { \"SKIN\": { \"url\": \"http://textures.minecraft.net/texture/1acde954db327685201f785a6b248b73fdc8982c2aed430f697cdebec9b7e14\" } } }"
.getBytes(StandardCharsets.UTF_8)));
assertEquals(
"http://textures.minecraft.net/texture/1acde954db327685201f785a6b248b73fdc8982c2aed430f697cdebec9b7e14",
Head.getTextureUrl(validTagWithProfile));
}

@Test
public void testInvalidJson() throws IOException { // test for #680, #681
CompoundTag validOwnerTag = createSkullTag("Owner", Base64.getEncoder().encodeToString(
"{textures:{SKIN:{url:\"http://textures.minecraft.net/texture/1acde954db327685201f785a6b248b73fdc8982c2aed430f697cdebec9b7e14\"}}}"
.getBytes(StandardCharsets.UTF_8)));
Expand All @@ -49,7 +61,7 @@ public void testInvalidJson() { // test for #680, #681
}

@Test
public void testAlexSkull() { // test for #749
public void testAlexSkull() throws IOException { // test for #749
CompoundTag validJsonAlexTag = createSkullTag("Owner", Base64.getEncoder().encodeToString(
"{ \"textures\": { \"SKIN\": {\"metadata\": {\"model\": \"slim\"}, \"url\": \"http://textures.minecraft.net/texture/1acde954db327685201f785a6b248b73fdc8982c2aed430f697cdebec9b7e14\" } } }"
.getBytes(StandardCharsets.UTF_8)));
Expand All @@ -66,7 +78,7 @@ public void testAlexSkull() { // test for #749
}

@Test
public void testMetadataAfterUrl() { // test for #969
public void testMetadataAfterUrl() throws IOException { // test for #969
CompoundTag validJsonAlexTag = createSkullTag("Owner", Base64.getEncoder().encodeToString(
"{\"timestamp\":1425828978186,\"profileId\":\"00000000000000000000000000000000\",\"profileName\":\"chunky\",\"isPublic\":true,\"textures\":{\"SKIN\":{\"url\":\"http://textures.minecraft.net/texture/8e9fa6f8f2a1141524ff37c4df642dc2da29a6c05a35c38f43fe4919b84b34f\",\"metadata\":{\"model\":\"slim\"}}}}"
.getBytes(StandardCharsets.UTF_8)));
Expand All @@ -76,7 +88,7 @@ public void testMetadataAfterUrl() { // test for #969
}

@Test
public void testBadBase64Padding() { // test for #900
public void testBadBase64Padding() throws IOException { // test for #900
CompoundTag validOwnerTag = createSkullTag("Owner",
// the following base64 string has intentional wrong padding
"eyAidGV4dHVyZXMiOiB7ICJTS0lOIjogeyJtZXRhZGF0YSI6IHsibW9kZWwiOiAic2xpbSJ9LCAidXJsIjogImh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMWFjZGU5NTRkYjMyNzY4NTIwMWY3ODVhNmIyNDhiNzNmZGM4OTgyYzJhZWQ0MzBmNjk3Y2RlYmVjOWI3ZTE0IiB9IH0gfQ=");
Expand All @@ -86,7 +98,7 @@ public void testBadBase64Padding() { // test for #900
}

@Test
public void testLinebreaks() { // test for #764
public void testLinebreaks() throws IOException { // test for #764
CompoundTag validOwnerTag = createSkullTag("Owner", Base64.getEncoder().encodeToString(
"{ \"textures\":\n{ \"SKIN\": {\n \"metadata\": {\n\"model\": \"slim\"},\n \"url\":\n \"http://textures.minecraft.net/texture/1acde954db327685201f785a6b248b73fdc8982c2aed430f697cdebec9b7e14\" } } }"
.getBytes(StandardCharsets.UTF_8)));
Expand All @@ -96,7 +108,7 @@ public void testLinebreaks() { // test for #764
}

@Test
public void testCape() { // test for #1001
public void testCape() throws IOException { // test for #1001
CompoundTag capeTag = createSkullTag("Owner", Base64.getEncoder().encodeToString(
("{\n"
+ " \"timestamp\" : 1633816089260,\n"
Expand Down Expand Up @@ -139,10 +151,20 @@ private static CompoundTag createSkullTag(String rootKey, String value) {
CompoundTag valueTag = new CompoundTag();
valueTag.add("Value", new StringTag(value));
properties.add("textures", new ListTag(Tag.TAG_COMPOUND, Collections.singletonList(
valueTag
valueTag
)));
ownerTag.add("Properties", properties);
skull.add(rootKey, ownerTag);
return skull;
}

private static CompoundTag createTagWithProfile(String value) {
CompoundTag tag = new CompoundTag();
CompoundTag profile = new CompoundTag();
CompoundTag properties = new CompoundTag();
properties.add("value", new StringTag(value));
profile.add("properties", properties);
tag.add("profile", profile);
return tag;
}
}

0 comments on commit dad7c4c

Please sign in to comment.