diff --git a/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/codec/v2168/BedrockCodecHelper_v2168.java b/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/codec/v2168/BedrockCodecHelper_v2168.java index ef4363d4..ccab3d1d 100644 --- a/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/codec/v2168/BedrockCodecHelper_v2168.java +++ b/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/codec/v2168/BedrockCodecHelper_v2168.java @@ -752,7 +752,7 @@ public void writeSkin(ByteBuf buffer, SerializedSkin skin) { this.writeString(buffer, skin.getFullSkinId()); buffer.writeByte("slim".equalsIgnoreCase(skin.getArmSize()) ? 0 : 1); - buffer.writeIntLE(parseSkinColor(skin.getSkinColor())); + buffer.writeIntLE(skinColorArgb(skin)); List pieces = skin.getPersonaPieces(); VarInts.writeUnsignedInt(buffer, pieces.size()); @@ -767,7 +767,8 @@ public void writeSkin(ByteBuf buffer, SerializedSkin skin) { List tints = skin.getTintColors(); VarInts.writeUnsignedInt(buffer, tints.size()); for (PersonaPieceTintData tint : tints) { - this.writeString(buffer, tint.type()); + // BDS writes the canonical name ("mouth"), while clients send the persona_ form in their login data. + this.writeString(buffer, PersonaPieceType.fromName(tint.type()).getSerializeName()); List colors = tint.colors(); if (colors.size() != 4) { throw new IllegalArgumentException("Expected 4 colors in PersonaPieceTintData"); @@ -1252,12 +1253,9 @@ public void writeSource(ByteBuf buffer, InventorySource inventorySource) { } } - protected static int parseSkinColor(String color) { - if (color == null || color.isBlank()) { - return 0; - } - String value = color.charAt(0) == '#' ? color.substring(1) : color; - return (int) Long.parseLong(value, 16); + protected static int skinColorArgb(SerializedSkin skin) { + var color = skin.getColor(); + return color == null ? 0 : color.getRGB(); } @Override diff --git a/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/codec/v2225/BedrockCodecHelper_v2225.java b/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/codec/v2225/BedrockCodecHelper_v2225.java index cd45b1d5..1f21160a 100644 --- a/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/codec/v2225/BedrockCodecHelper_v2225.java +++ b/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/codec/v2225/BedrockCodecHelper_v2225.java @@ -126,7 +126,7 @@ public void writeSkin(ByteBuf buffer, SerializedSkin skin) { this.writeString(buffer, skin.getFullSkinId()); buffer.writeByte("slim".equalsIgnoreCase(skin.getArmSize()) ? 0 : 1); - buffer.writeIntLE(parseSkinColor(skin.getSkinColor())); + buffer.writeIntLE(skinColorArgb(skin)); List pieces = skin.getPersonaPieces(); VarInts.writeUnsignedInt(buffer, pieces.size()); @@ -141,7 +141,7 @@ public void writeSkin(ByteBuf buffer, SerializedSkin skin) { List tints = skin.getTintColors(); VarInts.writeUnsignedInt(buffer, tints.size()); for (PersonaPieceTintData tint : tints) { - this.writeString(buffer, tint.type()); + this.writeString(buffer, PersonaPieceType.fromName(tint.type()).getSerializeName()); List colors = tint.colors(); if (colors.size() != 4) { throw new IllegalArgumentException("Expected 4 colors in PersonaPieceTintData"); diff --git a/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/data/skin/SerializedSkin.java b/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/data/skin/SerializedSkin.java index 6479ed26..d45bc086 100644 --- a/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/data/skin/SerializedSkin.java +++ b/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/data/skin/SerializedSkin.java @@ -237,8 +237,7 @@ public static SerializedSkin of(String skinId, String playFabId, String skinReso boolean trusted, String profileHash) { Color color = null; if (skinColor != null && !skinColor.isEmpty()) { - color = "#0".equals(skinColor) ? new Color(0, true) : - new Color((int) Long.parseLong(skinColor.startsWith("#") ? skinColor.substring(1) : skinColor, 16), true); + color = parseColor(skinColor); } return new SerializedSkin(skinId, null, skinData, capeData, geometryData, premium, skinResourcePatch, Collections.unmodifiableList(new ObjectArrayList<>(animations)), animationData, persona, capeOnClassic, @@ -259,6 +258,15 @@ public static SerializedSkin of(String skinId, String playFabId, String skinReso geometryDataEngineVersion, primaryUser, overridingPlayerAppearance, trusted, profileHash); } + /** + * Parses a skin colour as clients send it: {@code #RRGGBB} is opaque, {@code #AARRGGBB} keeps its alpha. + */ + private static Color parseColor(String skinColor) { + String value = skinColor.startsWith("#") ? skinColor.substring(1) : skinColor; + int argb = (int) Long.parseLong(value, 16); + return new Color(value.length() == 6 ? argb | 0xFF000000 : argb, true); + } + public static Builder builder() { return new Builder(); } @@ -345,11 +353,7 @@ public String getSkinColor() { */ public Color getColor() { if (color == null && skinColor != null && !skinColor.isEmpty()) { - if (skinColor.equals("#0")) { - return new Color(0, true); - } else { - return new Color((int) Long.parseLong(skinColor.startsWith("#") ? skinColor.substring(1) : skinColor, 16), true); - } + return parseColor(skinColor); } return color; } @@ -428,8 +432,7 @@ public SerializedSkin build() { Color resolvedColor = color; if (resolvedColor == null) { - resolvedColor = "#0".equals(skinColor) ? new Color(0, true) : - new Color((int) Long.parseLong(skinColor.startsWith("#") ? skinColor.substring(1) : skinColor, 16), true); + resolvedColor = parseColor(skinColor); } if (skinResourcePatch == null) { return new SerializedSkin(skinId, geometryName, skinData, capeData, geometryData, premium, null, diff --git a/bedrock-codec/src/test/java/org/cloudburstmc/protocol/bedrock/codec/v2168/BedrockCodecHelper_v2168SkinTest.java b/bedrock-codec/src/test/java/org/cloudburstmc/protocol/bedrock/codec/v2168/BedrockCodecHelper_v2168SkinTest.java new file mode 100644 index 00000000..ccfaf1de --- /dev/null +++ b/bedrock-codec/src/test/java/org/cloudburstmc/protocol/bedrock/codec/v2168/BedrockCodecHelper_v2168SkinTest.java @@ -0,0 +1,74 @@ +package org.cloudburstmc.protocol.bedrock.codec.v2168; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; +import org.cloudburstmc.protocol.bedrock.codec.v2193.Bedrock_v2193; +import org.cloudburstmc.protocol.bedrock.data.skin.ImageData; +import org.cloudburstmc.protocol.bedrock.data.skin.PersonaPieceData; +import org.cloudburstmc.protocol.bedrock.data.skin.PersonaPieceTintData; +import org.cloudburstmc.protocol.bedrock.data.skin.SerializedSkin; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BedrockCodecHelper_v2168SkinTest { + + @Test + @SuppressWarnings("deprecation") + void writesSkinsFromLoginDataTheWayBdsDoes() { + // Login data carries "persona_" tint types and an "#RRGGBB" skin colour, as a 1.26.52 client sends them. + SerializedSkin skin = SerializedSkin.builder() + .skinId("persona-id") + .playFabId("F17248EADD1FD0A8") + .skinResourcePatch("{\"geometry\":{\"default\":\"geometry.persona\"}}") + .skinData(ImageData.of(1, 1, new byte[4])) + .capeData(ImageData.EMPTY) + .geometryData("") + .geometryDataEngineVersion("1.14.0") + .animationData("") + .capeId("") + .fullSkinId("persona-id") + .armSize("wide") + .skinColor("#7e5337") + .personaPieces(List.of(new PersonaPieceData( + "8f96d1f8-e9bb-40d2-acc8-eb79746c5d7c", "persona_skeleton", + "2099de18-429a-465a-a49b-fc4710a17bb3", true, ""))) + .tintColors(List.of(new PersonaPieceTintData("persona_mouth", List.of("#0", "#0", "#ff45220e", "#0")))) + .persona(true) + .profileHash("hash") + .build(); + + var helper = Bedrock_v2193.CODEC.createHelper(); + ByteBuf buffer = Unpooled.buffer(); + try { + helper.writeSkin(buffer, skin); + String bytes = new String(ByteBufUtil.getBytes(buffer), StandardCharsets.ISO_8859_1); + + assertTrue(bytes.contains("\u0005mouth"), "tint type is written by its canonical name"); + assertFalse(bytes.contains("persona_mouth")); + + SerializedSkin decoded = helper.readSkin(buffer); + assertEquals("#ff7e5337", decoded.getSkinColor()); + assertEquals("mouth", decoded.getTintColors().getFirst().type()); + assertEquals("skeleton", decoded.getPersonaPieces().getFirst().type()); + assertEquals("F17248EADD1FD0A8", decoded.getPlayFabId()); + assertEquals("hash", decoded.getProfileHash()); + assertFalse(buffer.isReadable()); + } finally { + buffer.release(); + } + } + + @Test + void keepsAnExplicitAlphaInTheSkinColour() { + assertEquals(0x407e5337, SerializedSkin.builder().skinId("id").skinColor("#407e5337").build().getColor().getRGB()); + assertEquals(0xff7e5337, SerializedSkin.builder().skinId("id").skinColor("7e5337").build().getColor().getRGB()); + assertEquals(0, SerializedSkin.builder().skinId("id").build().getColor().getRGB()); + } +}