皮肤库与换肤
「皮肤」在这套系统里有三个互不相干的东西,混起来是 bug 的来源。
库内的皮肤是启动器自己的:用户导入的 PNG,存在 %APPDATA%/starcloud-launcher/skins/ 下。微软账号上的皮肤是微软托管的,别人通过你的正版 UUID 看到的是它。皮肤站上的皮肤(Blessing Skin / LittleSkin 一系)是第三方认证服务器自己的衣柜。
账户里只存一个 skin_id 指向库内条目,文件本体由皮肤库统一管。删掉库里的皮肤时,账户上的引用会被 AccountStore.ClearSkinReference 清掉,不会留下指向空洞的引用。
皮肤库的目录结构
skins/ 下一张皮肤一个 PNG,外加一个 library.json 索引(id / name / file / model / source / width / height / added_at)。
导入时先校验再落盘:PNG 能被解码、尺寸符合 Minecraft 皮肤规则(64×32、64×64 或它们的等比放大)、体积不超过 4MB。校验用的是渲染层同一个 PngDecoder,保证预览、上传、注入三处口径一致——避免把一张风景照塞进库,到启动游戏时才发现画不出来。
索引写入用临时文件加原子替换(先写 library.json.tmp,再 File.Move(..., overwrite: true)),避免写到一半崩溃留下半截索引。
型号判定
手臂宽度有 4px(classic)和 3px(slim)两种。判据是双层皮肤上右手臂区域的透明像素——classic 手臂占满 4px,slim 只占 3px,留下一列透明:
public static bool DetectSlim(SkinBitmap bitmap)
{
if (bitmap.Width != bitmap.Height) return false; // 64×32 老皮肤只有经典版型
var x = 54 * bitmap.Width / 64;
var y = 20 * bitmap.Height / 64;
if (x >= bitmap.Width || y >= bitmap.Height) return false;
return bitmap.Rgba[(y * bitmap.Width + x) * 4 + 3] == 0;
}坐标是 (54, 20),按 64 为基准等比缩放,所以 HD 皮肤也判得对。老的 64×32 单层皮肤直接返回 false——它根本没有 slim 版型。
读失败兜底,写失败必须拒绝
TryLoad 读索引失败时返回 null 并带上原因,而读路径和写路径对它的处理不一样:
/// 读路径用的兜底版本:读不出来就当空库,界面照常可用
private List<SkinEntry> Load() => TryLoad(out _) ?? [];写路径则拒绝动手:
var list = TryLoad(out var readError);
if (list is null)
return (null, $"皮肤库索引读不出来({readError}),这次没有写入,避免覆盖已有的皮肤");读失败当空库 + 紧接着写入 = 删光用户的皮肤
library.json 读失败可能是文件被占用、写到一半、临时崩溃留下的半截文件——这些都是瞬时的。如果读路径抛错,界面会报错,但只要有一次"读失败 → 当空库 → 导入一张皮肤",用户攒的皮肤就只剩这一张了。
所以规矩是:读路径兜底成空库(界面照常显示,不至于整个页面崩掉),写路径一律拒绝(宁可这次导入失败,也不能拿一个空索引盖掉真库)。
上传到账号
微软走官方 API,PUT https://api.minecraftservices.com/minecraft/profile/skins,multipart:表单里 variant 字段填 classic 或 slim,file 字段是 image/png 的二进制,文件名 skin.png,Authorization: Bearer <token>。
第三方走标准 Yggdrasil 换肤,PUT {base}/api/v1/user/profile/skin,JSON body 是 {"model": "slim" 或 "", "file": "<base64>"}。
base64 默认转义会把 + 写成 \u002B
System.Text.Json 的默认编码器会把 base64 里的 + 转义成 \u002B。后果是白白多传一圈转义字节(base64 里 + 不算罕见),而且简易服务端如果按字符串长度截断或自己再解一次码,就会读歪。
所以上传请求体用宽松编码器 JavaScriptEncoder.UnsafeRelaxedJsonEscaping——请求体本来就是给 JSON API 吃的,不会嵌进 HTML,放宽转义没有 XSS 风险。
皮肤站衣柜
第三方账户还可以直接用皮肤站上的皮肤。列表来自 GET {base}/api/v1/user/skins?page=1&page_size=100,条目 id 取 tid 或 id,版型取 model(皮肤站用 steve / alex 表示)。
设为当前皮肤有两条路:优先 PUT /api/v1/user/profile/skin 带 {"tid": <皮肤 id>}——皮肤站若支持「直接设为当前」一步到位,皮肤不下载不重传;不支持就回退,把那张 PNG 从站上下下来再走标准换肤接口上传回账号。
HTTP 200 不等于成功
Blessing Skin 一系用 HTTP 200 + {"code": 1, "message": "..."} 表示业务失败。只看状态码的话,界面上会显示「已设为当前皮肤」,而服务器其实什么都没做。所以每个皮肤站响应都要先过一遍业务码检查:code != 0 即失败,错误文案从 message / msg / error 里挑第一个非空的。
回退的判定标准也分两类:401/403 直接抛,因为凭证有问题回退也一样过不去;400/404/405/501 视为「写法不被支持」,才走回退路径。
离线皮肤怎么生效
离线账户没有会话服务器,客户端拿不到 textures 属性。启动器自己起一个只监听 127.0.0.1 的极简 HTTP 服务托管 PNG,把 URL 通过 --userProperties 注入。1.19.3 起该参数整体要 Base64,更早版本用原样 JSON,所以按版本号分两种编码。
注入只影响本机客户端
别人看到什么由他们那侧的验证决定。未签名的纹理属性在 1.19.2 及更早版本会被客户端直接采用,1.19.3 起不保证生效——所以界面上每次更换皮肤都会先提示这一点。
相关链接:离线账户与 UUID 算法 · 第三方认证 · 令牌存储策略