
在MC中,只要是拥有正版的玩家在正版服务器中都能显示自己独特的皮肤。那么,这些皮肤是怎么加载与渲染的呢,这篇专栏将会对皮肤机制进行详解。
加载皮肤
GameProfile/游戏档案
Yggdrasil API
应用皮肤
ClientboundPlayerInfoPacket
渲染
如何自定义你的皮肤
正版更换皮肤原理
代理Yggdrasil服务
使用URL定位你的皮肤
1.GameProfile/游戏资料
在MC中,每个玩家都有GameProfile。这个游戏资料的内部存储了玩家的名称、UUID和皮肤信息,位于authlib库中。它的定义如下:
private final UUID id; // 玩家UUID数据,它代表了玩家的唯一标识
private final String name; // 玩家名称
private final PropertyMap properties = new PropertyMap(); // 属性信息,这内部存储了皮肤信息
private boolean legacy; // 这个我也无法确定是干什么的>_<,有可能为区分老账号与新账号的
public GameProfile(UUID id, String name) {
// 名称和UUID不能同时为空(在玩家刚进入服务器时可能找不到UUID)
if (id == null && StringUtils.isBlank(name))
throw new IllegalArgumentException("Name and ID cannot both be blank");
this.id = id;
this.name = name;
} 在PropertyMap中包含了我们的皮肤数据。皮肤数据是这个表中的一个Property,它是这样定义的:
private final String name; // 属性名称,对于皮肤属性此项通常为"textures"
private final String value; // 皮肤数据,为被Base64加密的JSON数据
private final String signature; // 签名,此签名是以Yggdrasil API的RSA私钥加密的结果,用于校验正确性 在value中保存的就是我们的皮肤数据,是一个JSON。这个JSON是这样的:
{
"timestamp":<这一项是Java的时间值,单位是ms>,
"profileId":"<玩家的UUID>",
"profileName":"<玩家的名称>",
"signatureRequired":true, // 当皮肤属性含有签名时才会含有这项,否则不含有此属性
"textures":{
"SKIN":{
"url":"<皮肤所处的URL>",
"metadata":{ // 如果为Steve皮肤则不含有此项
"model":"slim"
}
},
"CAPE":{ // 如果玩家没有披风,则没有此项
"url":"<披风所处的URL>"
}
}
} 可以看到,皮肤最后是引用到了一个URL上,这个URL通常是来源于http://textures.minecraft.net/textures/<哈希码值>
在了解这些后,我们看看GameProfile是如何被序列化的。
GameProfile也可以转变为JSON,它的JSON看起来像这样(也把PropertyMap的数据写入了)
{
"id":"<玩家UUID>",
"name":"<玩家名称>",
"properties":[
{
"name":"textures",
"value":"<Base64加密的皮肤数据>",
"signature":"<Yggdrasil API的RSA私钥加密的value数据>" // 这项可能不存在
}
]
} 玩家可以通过GameProfile得到皮肤的信息,那么MC是如何获得玩家的游戏数据的呢。接下来就是获得GameProfile的方法:Yggdrasil API
2.Yggdrsail API
MC采用了一套特殊的API用于玩家账户的验证和获取数据,这就是Yggdrasil API,位于authlib库中。这篇专栏主要讨论它加载皮肤的部分。
在玩家加入世界后,数据是不完全的,只含有玩家名称和UUID。这时系统就会调用fillGameProfile填充玩家的数据,补全成为上文那样。
// com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService#fillGameProfile
protected GameProfile fillGameProfile(GameProfile profile, boolean requireSecure) {
try {
URL url = HttpAuthenticationService
.constantURL("https://sessionserver.mojang.com/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(profile.getId()));
url = HttpAuthenticationService.concatenateURL(url, "unsigned=" + (!requireSecure ? 1 : 0));
MinecraftProfilePropertiesResponse response = getAuthenticationService().makeRequest(url, null, MinecraftProfilePropertiesResponse.class);
if (response == null) {
LOGGER.debug("Couldn't fetch profile properties for " + profile + " as the profile does not exist");
return profile;
}
GameProfile result = new GameProfile(response.getId(), response.getName());
result.getProperties().putAll((Multimap) response.getProperties());
profile.getProperties().putAll((Multimap) response.getProperties());
LOGGER.debug("Successfully fetched profile properties for " + profile);
return result;
} catch (AuthenticationException e) {
LOGGER.warn("Couldn't look up profile properties for " + profile, e);
return profile;
}
} Yggdrasil API在进行GameProfile数据填充时会向https://sessionserver.mojang.com/profile/<玩家UUID>发送一个GET请求,附加参数unsigned代表需不需要进行签名。在通常情况下,requireSecure都为true,也就是会请求签名。GET请求的结果是一个JSON文本,内容就是上文提到的GameProfile的序列化JSON。通过这个JSON,系统就能解析出玩家的资料。
获取皮肤使用了另一个方法:getTextures,它能通过玩家的资料提取出皮肤材质信息:
// com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService#getTextures
public Map<MinecraftProfileTexture.Type, MinecraftProfileTexture> getTextures(GameProfile profile, boolean requireSecure) {
MinecraftTexturesPayload result;
Property textureProperty = (Property) Iterables.getFirst(profile.getProperties().get("textures"), null);
if (textureProperty == null)
return new HashMap<>();
if (requireSecure) {
if (!textureProperty.hasSignature()) {
LOGGER.error("Signature is missing from textures payload");
throw new InsecureTextureException("Signature is missing from textures payload");
}
if (!textureProperty.isSignatureValid(this.publicKey)) {
LOGGER.error("Textures payload has been tampered with (signature invalid)");
throw new InsecureTextureException("Textures payload has been tampered with (signature invalid)");
}
}
try {
String json = new String(Base64.decodeBase64(textureProperty.getValue()), Charsets.UTF_8);
result = (MinecraftTexturesPayload) this.gson.fromJson(json, MinecraftTexturesPayload.class);
} catch (JsonParseException e) {
LOGGER.error("Could not decode textures payload", (Throwable) e);
return new HashMap<>();
}
if (result == null || result.getTextures() == null)
return new HashMap<>();
for (Map.Entry<MinecraftProfileTexture.Type, MinecraftProfileTexture> entry : result.getTextures().entrySet()) {
if (!isWhitelistedDomain(((MinecraftProfileTexture) entry.getValue()).getUrl())) {
LOGGER.error("Textures payload has been tampered with (non-whitelisted domain)");
return new HashMap<>();
}
}
return result.getTextures();
} 从这段代码中,我们可以看到皮肤的成功加载由下面几个条件:
1)这个皮肤数据必须是有签名的,否则不进行解析
2)找到文件签名后,就用Yggdrasil API的公钥进行解密。这个公钥在MC的文件中,位于/yggdrasil_session_pubkey.der。解密之后的文本应该与value相同,否则验证失败
3)进行解析,提取出URL。如果URL不属于白名单上的域名(这些在白名单的域名有".minecraft.net"和".mojang.com"),那么也不会通过验证
在三重验证之后,就得到了一个皮肤的映射,可以让客户端下载并使用。
但是,填充GameProfile是服务端的操作,而getTexture是客户端操作,客户端要怎么获取填充好的GameProfile呢?
1.ClientboundPlayerInfoPacket
紧接着上文,客户端需要服务端的已经填充好的GameProfile,那么服务端就要发送一个数据包向客户端通知客户端获得数据,这个数据包就是ClientboundPlayerInfoPacket。
这个数据包一共有5种模式:ADD_PLAYER(玩家加入),UPDATE_GAME_MODE(改变游戏模式),UPDATE_LATENCY,UPDATE_DISPLAY_NAME(显示名称改变),REMOVE_PLAYER(玩家移除)。有关于GameProfile的传输是在ADD_PLAYER内部的。
在玩家加入服务器后,服务器会把所有玩家(包括自身)的GameProfile通过ADD_PLAYER模式发送到客户端。
// net.minecraft.network.protocol.game.ClientboundPlayerInfoPacket.Action#ADD_PLAYER#read
// 读取部分
protected ClientboundPlayerInfoPacket.PlayerUpdate read(FriendlyByteBuf buf) {
GameProfile gameProfile = new GameProfile(buf.readUUID(), buf.readUtf(16));
PropertyMap propertyMap = gameProfile.getProperties();
buf.readWithCount(friendlyByteBuf -> {
String name = friendlyByteBuf.readUtf();
String value = friendlyByteBuf.readUtf();
if (friendlyByteBuf.readBoolean()) {
String signature = friendlyByteBuf.readUtf();
gameProfile.put(name, new Property(name, value, signature));
} else {
gameProfile.put(name, new Property(name, value));
}
});
GameType gameType = GameType.byId(buf.readVarInt());
int i = buf.readVarInt();
Component component = ClientboundPlayerInfoPacket.readDisplayName(buf);
return new ClientboundPlayerInfoPacket.PlayerUpdate(gameProfile, i, gameType, component);
}
在这个数据包传到客户端后,客户端的监听器将处理这个数据包:
// net.minecraft.client.multiplayer.ClientPacketListener#handlePlayerInfo
public void handlePlayerInfo(ClientboundPlayerInfoPacket packet) {
PacketUtils.ensureRunningOnSameThread(packet, this, minecraft);
for (ClientboundPlayerInfoPacket.PlayerUpdate playerUpdate : packet.getEntries()) {
// 删除了一部分无关代码
if (packet.getAction() == ClientboundPlayerInfoPacket.Action.ADD_PLAYER) {
playerInfo = new PlayerInfo(playerUpdate);
playerInfoMap.put(playerInfo.getProfile().getId(), playerInfo);
minecraft.getPlayerSocialManager().addPlayer(playerInfo);
}
// 删除了无关代码
}
} GameProfile的数据会在这时传入PlayerInfo中,之后渲染系统会访问这个对象获取皮肤信息。先看看渲染系统是怎么编写的。
2.渲染
玩家的渲染在PlayerRenderer中,它通过AbstractClientPlayer的方法获取皮肤数据,下面是一个例子:
// net.minecraft.client.renderer.entity.player.PlayerRenderer#renderHand
private void renderHand(PoseStack stack, MultiBufferSource buffer, int i, AbstractClientPlayer player, ModelPart modelPart1, ModelPart modelPart2) {
PlayerModel<AbstractClientPlayer> playerModel = getModel();
setModelProperties(player);
playerModel.attackTime = 0.0F;
playerModel.crouching = false;
playerModel.swimAmount = 0.0F;
playerModel.setupAnim(player, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F);
modelPart1.xRot = 0.0F;
modelPart1.render(stack, buffer.getBuffer(RenderType.entitySolid(player.getSkinTextureLocation())), i, OverlayTexture.NO_OVERLAY);
modelPart2.xRot = 0.0F;
modelPart2.render(stack, buffer.getBuffer(RenderType.entityTranslucent(player.getSkinTextureLocation())), i, OverlayTexture.NO_OVERLAY);
} 那么AbstractClientPlayer是哪里来的呢?答案还是在客户端数据包监听器内部。
在进入服务器时,服务器会把所有玩家GameProfile发送到客户端。而当其他玩家进入本玩家可视范围内时,服务端会再发送一个ClientboundAddPlayerPacket用于通知。通过这个数据包,客户端可以添加这个玩家对象。
// net.minecraft.client.multiplayer.ClientPacketListener#handleAddPlayer
public void handleAddPlayer(ClientboundAddPlayerPacket packet) {
PacketUtils.ensureRunningOnSameThread(packet, this, minecraft);
double x = packet.getX();
double y = packet.getY();
double z = packet.getZ();
float yRot = (packet.getyRot() * 360) / 256.0F;
float xRot = (packet.getxRot() * 360) / 256.0F;
int id = packet.getEntityId();
RemotePlayer remotePlayer = new RemotePlayer(minecraft.level, getPlayerInfo(packet.getPlayerId()).getProfile());
remotePlayer.setId(id);
remotePlayer.setPacketCoordinates(x, y, z);
remotePlayer.absMoveTo(x, y, z, yRot, xRot);
remotePlayer.setOldPosAndRot();
level.addPlayer(id, remotePlayer);
} PlayerInfo在这时就传入到了RemotePlayer(AbstractClientPlayer的子类)中。当渲染器渲染玩家时,调用getSkinTextureLocation,进而调用PlayerInfo的getSkinLocation。如果没有下载皮肤材质,调用皮肤下载器,使用GameProfile中的URL地址下载到皮肤。
// net.minecraft.client.multiplayer.PlayerInfo#registerTextures
protected void registerTextures() {
synchronized (this) {
if (!pendingTextures) {
pendingTextures = true;
Minecraft.getInstance().getSkinManager().registerSkins(profile, (type, location, texture) -> {
// 这里可能是并发线程加载
textureLocations.put(type, location);
if (type == MinecraftProfileTexture.Type.SKIN) {
skinModel = texture.getMetadata("model");
if (skinModel == null)
skinModel = "default";
}
}, true);
}
}
}
如果皮肤材质加载失败了,那么系统会自动使用Steve/Alex皮肤。使用这两个默认皮肤不是随机的,它有关于玩家UUID的哈希码值。
// net.minecraft.client.resource.DefaultPlayerSkin#isAlexDefault
private static boolean isAlexDefault(UUID uuid) {
return uuid.hashCode() & 0x1 == 1;
// 这段代码的意义是:如果哈希码值为奇数则使用Alex皮肤,偶数则使用Steve皮肤
}
至此,你的皮肤就展示在你的屏幕上了!
1.正版更换皮肤原理
官方启动器和很多第三方启动器都支持更换皮肤,它们是通过API进行换肤操作。

官方启动器的换肤界面
这个API的基础网址是https://api.mojang.com/user/profile/<玩家UUID>/skin,支持三种操作:上传皮肤,改变皮肤,重置皮肤。注意:修改皮肤只能在已登录条件下执行,因为这些操作都需要提供accessToken(访问令牌)
1)上传皮肤
请求为PUT,Headers需要包含“Authorization”一项,值为“Bearer <访问令牌>”
负载数据有两项:
"model"-值为slim或者为空字符串,slim代表Alex模型,空字符串代表Steve模型
"file"-负载数据为图片
2)改变皮肤
请求为POST,Headers要求与第一项相同
在URL上负载数据“model”和“url”,分别为模型和材质地址。材质地址只能属于第一节所述的几个网站,否则不会成功换肤
返回值正常为空负载
3)重置皮肤
请求为DELETE,Headers要求与第一项相同,无负载
2.代理Yggdrasil服务
对于没有正版账号的玩家来说,无法更换皮肤,但是如果将Yggdrasil服务换成自己的,不就能用自己的皮肤了吗?这就是“皮肤站”和“外置登录”的来源。

HMCL上的外置登录
实现这个功能,就必须在启动MC时加入一个Java Agent进行代码修改,现在大家使用的注入器就是authlib-injector,它能修改Yggdrasil API的底层代码,使Yggdrasil服务定位到皮肤站的API上,这样就完成了代理。
(注:这种操作也让登录定位到了新的API上,所以MC系统会把你识别为“正版玩家”)
3.使用URL定位你的皮肤
根据Yggdrasil API的原理,可以看到我们可以只修改来源于服务端的GameProfile中的textures属性数据同时撤销客户端的Yggdrasil API的三重检查来达到重新定位皮肤的目的。
可是怎么修改呢?
首先是服务端,服务端在online-mode为false时会自动调用ServerLoginPacketListenerImpl#createFakeProfile生成一个“假的”游戏资料,,UUID的生成与名称有关:
// net.minecraft.world.entity.player.Player#createPlayerUUID(String)
public static UUID createPlayerUUID(String name) {
return UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
} 这个对象中不包含textures属性。我们可以在这里插入代码使它加上数据,这样传送到客户端的数据就包含了skin属性。(注意,此处不要生成signature,因为没有私钥生成不了)
// net.minecraft.server.network.ServerLoginPacketListenerImpl#createFakeProfile
protected GameProfile createFakeProfile(GameProfile profile) {
UUID uUID = Player.createPlayerUUID(profile.getName());
return new GameProfile(uUID, profile.getName());
// 插入点可以放在RETURN上(Mixin)
}
另一部分是客户端,我们只需要撤销掉三重检查就可以达到目的了。由于Mixin无法修改authlib库内部的类,所以我们可以采用继承+代理这个类更换掉Minecraft类中的对象。
// net.minecraft.client.Minecraft#<init>
public Minecraft(GameConfig config) {
// ...
proxy = config.user.proxy;
YggdrasilAuthenticationService yggdrasilAuthenticationService = new YggdrasilAuthenticationService(proxy);
// 在此处替换为代理的服务
minecraftSessionService = yggdrasilAuthenticationService.createMinecraftSessionService();
socialInteractionsService = createSocialInteractions(yggdrasilAuthenticationService, config);
user = config.user.user;
// ...
} 这样,就可以自定义你的皮肤了!(对披风一样管用,只是多加了一点代码而已)

代码来源:1.18_experiment-snapshot-1,Mojang Mapping,使用Yarn Mapping的类名及方法名可能不同,但是逻辑一样
代码反混淆器及反编译器:MCDynamicExchanger beta8,bug正在修复
Mojang API的用法:https://c4k3.github.io/wiki.vg/Mojang_API.html