From fc7a0a78c5cae0b5627703670b1f3fd5be38c187 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 16:32:19 +0800 Subject: [PATCH 01/24] =?UTF-8?q?feat(skin):=20=E6=96=B0=E5=A2=9E=E7=A6=BB?= =?UTF-8?q?=E7=BA=BF=E8=87=AA=E5=AE=9A=E4=B9=89=E7=9A=AE=E8=82=A4=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E5=BA=93(PCL.Core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SkinType/TextureModel/Skin 皮肤配置模型,含 JSON 序列化与容错反序列化 - SkinTexture:贴图内容哈希(SHA256)与进程内缓存,对齐 HMCL Texture - NormalizedSkin:尺寸校验、slim 自动判定、64x32 旧格式转 64x64 - LoadedSkin:皮肤加载结果 - RsaKeyUtils:RSA-SHA1withRSA 签名与公钥 PEM - OfflineSkinServer:内嵌 Yggdrasil 皮肤服务器(7 路由,含 hasJoined 签名) - HttpServer:新增带 {param} 的模板路由(精确>模板>通配,向后兼容) --- PCL.Core/IO/Net/Http/HttpServer.cs | 64 ++++- .../Minecraft/Skin/InvalidSkinException.cs | 11 + PCL.Core/Minecraft/Skin/LoadedSkin.cs | 9 + PCL.Core/Minecraft/Skin/NormalizedSkin.cs | 127 +++++++++ PCL.Core/Minecraft/Skin/OfflineSkinServer.cs | 258 ++++++++++++++++++ PCL.Core/Minecraft/Skin/PixelAccess.cs | 145 ++++++++++ PCL.Core/Minecraft/Skin/RsaKeyUtils.cs | 43 +++ PCL.Core/Minecraft/Skin/Skin.cs | 112 ++++++++ PCL.Core/Minecraft/Skin/SkinTexture.cs | 130 +++++++++ PCL.Core/Minecraft/Skin/SkinType.cs | 37 +++ PCL.Core/Minecraft/Skin/TextureModel.cs | 17 ++ 11 files changed, 951 insertions(+), 2 deletions(-) create mode 100644 PCL.Core/Minecraft/Skin/InvalidSkinException.cs create mode 100644 PCL.Core/Minecraft/Skin/LoadedSkin.cs create mode 100644 PCL.Core/Minecraft/Skin/NormalizedSkin.cs create mode 100644 PCL.Core/Minecraft/Skin/OfflineSkinServer.cs create mode 100644 PCL.Core/Minecraft/Skin/PixelAccess.cs create mode 100644 PCL.Core/Minecraft/Skin/RsaKeyUtils.cs create mode 100644 PCL.Core/Minecraft/Skin/Skin.cs create mode 100644 PCL.Core/Minecraft/Skin/SkinTexture.cs create mode 100644 PCL.Core/Minecraft/Skin/SkinType.cs create mode 100644 PCL.Core/Minecraft/Skin/TextureModel.cs diff --git a/PCL.Core/IO/Net/Http/HttpServer.cs b/PCL.Core/IO/Net/Http/HttpServer.cs index 53b5405586..e48221fa90 100644 --- a/PCL.Core/IO/Net/Http/HttpServer.cs +++ b/PCL.Core/IO/Net/Http/HttpServer.cs @@ -16,6 +16,7 @@ public abstract class HttpServer : IDisposable private Task? _handleLoop; private CancellationTokenSource? _cancellationTokenSource; private readonly Dictionary<(HttpMethod method, string path), Func>> _handlers = new(); + private readonly Dictionary<(HttpMethod method, string path), Func, Task>> _templateHandlers = new(); private bool _initialized = false; protected HttpServer(IPAddress[] listenAddr, ushort port = 0) @@ -60,12 +61,29 @@ protected void Register(HttpMethod method, string path, Func + /// 注册一个带路径参数的路由处理器。 + /// + /// HTTP 方法 + /// 路由路径模板,{xxx} 段为路径参数,匹配任意单个路径段并捕获其值 + /// 请求处理函数,第二个参数为捕获的路径参数集合 + protected void RegisterWithParams(HttpMethod method, string pathTemplate, Func, Task> handler) + { + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(pathTemplate); + ArgumentNullException.ThrowIfNull(handler); + + _templateHandlers[(method, pathTemplate)] = handler; + } + /// /// 启动 HTTP 服务器。 /// public void Start() { - // 如果没有注册路由,调用 Init 初始化 + // 如果没有注册精确路由,调用 Init 初始化。这里只检查 _handlers.Count:即使 Init 里只注册了模板路由 + //(_templateHandlers 非空但 _handlers.Count 仍为 0),_initialized 也会被置位,因此 Init 永远只执行一次; + // 若子类在 Start 之前已通过 Register 注册精确路由,同样不会重复初始化。 if (!_initialized && _handlers.Count == 0) { Init(); @@ -110,6 +128,15 @@ private async Task _ProcessRequestAsync(HttpListenerContext context) return; } + // 其次尝试模板路由匹配:{param} 段匹配任意单个路径段并捕获其值 + foreach (var ((templateMethod, templatePath), templateHandler) in _templateHandlers) + { + if (templateMethod != method) continue; + if (!_TryMatchTemplate(templatePath, path, out var parameters)) continue; + await _ExecuteHandlerAsync(templateHandler, parameters, request, response); + return; + } + // 如果没有精确匹配,尝试通配符匹配 if (_handlers.TryGetValue((method, "*"), out var wildcardHandler)) { @@ -134,10 +161,20 @@ private async Task _ProcessRequestAsync(HttpListenerContext context) } private static async Task _ExecuteHandlerAsync(Func> handler, HttpListenerRequest request, HttpListenerResponse response) + { + await _ExecuteCoreAsync(() => handler(request), response); + } + + private static async Task _ExecuteHandlerAsync(Func, Task> handler, IReadOnlyDictionary parameters, HttpListenerRequest request, HttpListenerResponse response) + { + await _ExecuteCoreAsync(() => handler(request, parameters), response); + } + + private static async Task _ExecuteCoreAsync(Func> invoke, HttpListenerResponse response) { try { - var routeResponse = await handler(request); + var routeResponse = await invoke(); routeResponse.Pour(response); } catch (Exception ex) @@ -151,6 +188,29 @@ private static async Task _ExecuteHandlerAsync(Func + /// 尝试将请求路径与路径模板匹配。{param} 段匹配任意单个路径段并捕获其值, + /// 非参数段必须与请求段完全一致(区分大小写),且两边的路径段数必须相等。 + /// 路径以 / 开头,模板与请求使用相同的 / 分段方式,保证首尾空段互相抵消。 + /// + private static bool _TryMatchTemplate(string template, string requestPath, out Dictionary parameters) + { + parameters = new Dictionary(); + var templateSegments = template.Split('/'); + var requestSegments = requestPath.Split('/'); + if (templateSegments.Length != requestSegments.Length) return false; + + for (var i = 0; i < templateSegments.Length; i++) + { + var templateSegment = templateSegments[i]; + if (templateSegment.Length > 2 && templateSegment[0] == '{' && templateSegment[^1] == '}') + parameters[templateSegment[1..^1]] = requestSegments[i]; + else if (!string.Equals(templateSegment, requestSegments[i], StringComparison.Ordinal)) + return false; + } + return true; + } + /// /// 停止 HTTP 服务器。 /// diff --git a/PCL.Core/Minecraft/Skin/InvalidSkinException.cs b/PCL.Core/Minecraft/Skin/InvalidSkinException.cs new file mode 100644 index 0000000000..f772ef7c5e --- /dev/null +++ b/PCL.Core/Minecraft/Skin/InvalidSkinException.cs @@ -0,0 +1,11 @@ +using System; + +namespace PCL.Core.Minecraft.Skin; + +/// +/// 皮肤纹理尺寸或格式非法时抛出的异常。 +/// +public sealed class InvalidSkinException : Exception +{ + public InvalidSkinException(string message) : base(message) { } +} diff --git a/PCL.Core/Minecraft/Skin/LoadedSkin.cs b/PCL.Core/Minecraft/Skin/LoadedSkin.cs new file mode 100644 index 0000000000..b59dcfe69a --- /dev/null +++ b/PCL.Core/Minecraft/Skin/LoadedSkin.cs @@ -0,0 +1,9 @@ +namespace PCL.Core.Minecraft.Skin; + +/// +/// 皮肤加载完成后得到的具体数据,对应 HMCL auth/offline 模块 Skin.load 的返回结果。 +/// +/// 纹理模型(宽/细)。 +/// 皮肤贴图;未设置时可为 null。 +/// 披风贴图;未设置时可为 null。 +public sealed record LoadedSkin(TextureModel Model, SkinTexture? Skin, SkinTexture? Cape); diff --git a/PCL.Core/Minecraft/Skin/NormalizedSkin.cs b/PCL.Core/Minecraft/Skin/NormalizedSkin.cs new file mode 100644 index 0000000000..e87b7efb20 --- /dev/null +++ b/PCL.Core/Minecraft/Skin/NormalizedSkin.cs @@ -0,0 +1,127 @@ +using System.Drawing; +using System.Drawing.Imaging; + +namespace PCL.Core.Minecraft.Skin; + +/// +/// 皮肤规范化器,对应 HMCL 的 NormalizedSkin。 +/// 将合法尺寸的皮肤(64x64、128x128 等)归一化为等宽正方形,并把旧格式(宽高比 2:1)重排为 64x64 布局。 +/// 位图生命周期由调用方管理,本类型不负责释放。 +/// +public sealed class NormalizedSkin +{ + /// + /// 原始纹理。 + /// + public Bitmap Texture { get; } + + /// + /// 规范化后的纹理(宽 = 原宽,高 = 原宽)。 + /// + public Bitmap NormalizedTexture { get; } + + /// + /// 缩放系数,等于宽 / 64。 + /// + public int Scale { get; } + + /// + /// 是否为旧格式(宽高比为 2:1,如 64x32)。 + /// + public bool IsOldFormat { get; } + + /// + /// 根据原始纹理构造规范化皮肤。 + /// + /// 原始皮肤纹理。 + /// 纹理宽度不是 64 的倍数,或宽高比既不是 1:1 也不是 2:1 时抛出。 + public NormalizedSkin(Bitmap texture) + { + var w = texture.Width; + var h = texture.Height; + if (w % 64 != 0) + throw new InvalidSkinException($"Invalid size {w}x{h}"); + if (w == h) + IsOldFormat = false; + else if (w == h * 2) + IsOldFormat = true; + else + throw new InvalidSkinException($"Invalid size {w}x{h}"); + + Scale = w / 64; + Texture = texture; + NormalizedTexture = new Bitmap(w, w, PixelFormat.Format32bppArgb); + + using (var source = PixelAccess.Lock(texture, ImageLockMode.ReadOnly)) + using (var dest = PixelAccess.Lock(NormalizedTexture, ImageLockMode.ReadWrite)) + { + // 先整体拷贝原图到 (0,0) + PixelAccess.CopyRegion(source, dest, 0, 0, w, h, 0, 0, flipHorizontal: false); + if (IsOldFormat) + ConvertOldSkin(source, dest); + } + } + + /// + /// 判断是否为纤细(Alex)模型,依据 HMCL 的特定区域透明/纯黑特征。 + /// + /// 纤细模型返回 true,经典模型返回 false + public bool IsSlim() + { + return HasTransparency(50, 16, 2, 4) + || HasTransparency(54, 20, 2, 12) + || HasTransparency(42, 48, 2, 4) + || HasTransparency(46, 52, 2, 12) + || (IsAreaBlack(50, 16, 2, 4) + && IsAreaBlack(54, 20, 2, 12) + && IsAreaBlack(42, 48, 2, 4) + && IsAreaBlack(46, 52, 2, 12)); + } + + private bool HasTransparency(int x0, int y0, int width, int height) + { + var s = Scale; + for (var y = y0 * s; y < (y0 + height) * s; y++) + { + for (var x = x0 * s; x < (x0 + width) * s; x++) + { + if (((PixelAccess.GetPixel(NormalizedTexture, x, y) >> 24) & 0xff) != 0xff) + return true; + } + } + return false; + } + + private bool IsAreaBlack(int x0, int y0, int width, int height) + { + var s = Scale; + for (var y = y0 * s; y < (y0 + height) * s; y++) + { + for (var x = x0 * s; x < (x0 + width) * s; x++) + { + if ((uint)PixelAccess.GetPixel(NormalizedTexture, x, y) != 0xff000000u) + return false; + } + } + return true; + } + + private void ConvertOldSkin(PixelAccessor source, PixelAccessor dest) + { + var s = Scale; + // 腿:top/bottom 为 4x4,其余面为 4x12 + PixelAccess.CopyRegion(source, dest, 4 * s, 16 * s, 4 * s, 4 * s, 20 * s, 48 * s, flipHorizontal: true); // top + PixelAccess.CopyRegion(source, dest, 8 * s, 16 * s, 4 * s, 4 * s, 24 * s, 48 * s, flipHorizontal: true); // bottom + PixelAccess.CopyRegion(source, dest, 0 * s, 20 * s, 4 * s, 12 * s, 24 * s, 52 * s, flipHorizontal: true); // outer + PixelAccess.CopyRegion(source, dest, 4 * s, 20 * s, 4 * s, 12 * s, 20 * s, 52 * s, flipHorizontal: true); // front + PixelAccess.CopyRegion(source, dest, 8 * s, 20 * s, 4 * s, 12 * s, 16 * s, 52 * s, flipHorizontal: true); // inner + PixelAccess.CopyRegion(source, dest, 12 * s, 20 * s, 4 * s, 12 * s, 28 * s, 52 * s, flipHorizontal: true); // back + // 臂:top/bottom 为 4x4,其余面为 4x12 + PixelAccess.CopyRegion(source, dest, 44 * s, 16 * s, 4 * s, 4 * s, 36 * s, 48 * s, flipHorizontal: true); // top + PixelAccess.CopyRegion(source, dest, 48 * s, 16 * s, 4 * s, 4 * s, 40 * s, 48 * s, flipHorizontal: true); // bottom + PixelAccess.CopyRegion(source, dest, 40 * s, 20 * s, 4 * s, 12 * s, 40 * s, 52 * s, flipHorizontal: true); // outer + PixelAccess.CopyRegion(source, dest, 44 * s, 20 * s, 4 * s, 12 * s, 36 * s, 52 * s, flipHorizontal: true); // front + PixelAccess.CopyRegion(source, dest, 48 * s, 20 * s, 4 * s, 12 * s, 32 * s, 52 * s, flipHorizontal: true); // inner + PixelAccess.CopyRegion(source, dest, 52 * s, 20 * s, 4 * s, 12 * s, 44 * s, 52 * s, flipHorizontal: true); // back + } +} diff --git a/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs b/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs new file mode 100644 index 0000000000..5da5035419 --- /dev/null +++ b/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Drawing.Imaging; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using PCL.Core.IO.Net.Http; + +namespace PCL.Core.Minecraft.Skin; + +/// +/// 内嵌的离线 Yggdrasil 皮肤服务器。注册到本地回环地址,供 authlib-injector 与游戏客户端 +/// 通过 http://localhost:{Port} 访问元数据、档案与皮肤贴图。对齐 HMCL 的 +/// YggdrasilServer。 +/// +public sealed class OfflineSkinServer : HttpServer +{ + /// + /// 用于对 textures 属性签名的 RSA 私钥,公钥通过 GET / 元数据暴露。 + /// + private readonly RSA _signKey = RsaKeyUtils.CreateKey(); + + /// + /// 注册到本服务器的玩家角色。 + /// + private sealed record Character(Guid Uuid, string Name, LoadedSkin? Skin); + + private readonly Dictionary _charactersByUuid = new(); + private readonly Dictionary _charactersByName = new(); + // 角色表在 HTTP 处理线程上读取、在 AddCharacter 写入,需要互斥保护。 + private readonly object _syncRoot = new(); + + public OfflineSkinServer() : base([IPAddress.Loopback, IPAddress.IPv6Loopback], port: 0) + { + } + + /// + /// 注册一个玩家角色。同名角色重复添加会覆盖旧的映射。 + /// + /// 玩家 UUID + /// 玩家名 + /// 皮肤(可为 null,表示不携带贴图) + public void AddCharacter(Guid uuid, string name, LoadedSkin? skin) + { + lock (_syncRoot) + { + var character = new Character(uuid, name, skin); + _charactersByUuid[uuid] = character; + _charactersByName[name] = character; + } + } + + protected override void Init() + { + Register(HttpMethod.Get, "/", _HandleMeta); + Register(HttpMethod.Get, "/status", _HandleStatus); + Register(HttpMethod.Post, "/api/profiles/minecraft", _HandleProfiles); + Register(HttpMethod.Get, "/sessionserver/session/minecraft/hasJoined", _HandleHasJoined); + Register(HttpMethod.Post, "/sessionserver/session/minecraft/join", _HandleJoin); + RegisterWithParams(HttpMethod.Get, "/sessionserver/session/minecraft/profile/{uuid}", _HandleProfile); + RegisterWithParams(HttpMethod.Get, "/textures/{hash}", _HandleTexture); + } + + /// + /// authlib-injector 启动时请求的元数据。 + /// + private Task _HandleMeta(HttpListenerRequest request) + { + var metadata = new JsonObject + { + ["signaturePublickey"] = RsaKeyUtils.GetPublicKeyPem(_signKey), + ["skinDomains"] = new JsonArray("127.0.0.1", "localhost"), + ["meta"] = new JsonObject + { + ["serverName"] = "PCL CE", + ["implementationName"] = "PCL CE", + ["implementationVersion"] = "2.15.0", + ["feature.non_email_login"] = true + } + }; + return HttpRouteResponse.Json(metadata).AsTask(); + } + + private Task _HandleStatus(HttpListenerRequest request) + { + int characterCount; + lock (_syncRoot) + characterCount = _charactersByUuid.Count; + + var status = new JsonObject + { + ["user.count"] = characterCount, + ["token.count"] = 0, + ["pendingAuthentication.count"] = 0 + }; + return HttpRouteResponse.Json(status).AsTask(); + } + + /// + /// 按玩家名批量查询档案(id 无横线)。无匹配时按 HMCL 行为返回 204 No Content。 + /// + private async Task _HandleProfiles(HttpListenerRequest request) + { + string body; + using (var reader = new StreamReader(request.InputStream, Encoding.UTF8)) + body = await reader.ReadToEndAsync(); + + JsonNode? parsed; + try + { + parsed = JsonNode.Parse(body); + } + catch (JsonException) + { + return HttpRouteResponse.BadRequest; + } + + if (parsed is not JsonArray names) + return HttpRouteResponse.BadRequest; + + var result = new JsonArray(); + foreach (var nameNode in names) + { + if (nameNode is not JsonValue value || !value.TryGetValue(out var name)) + continue; + if (!_TryGetByName(name, out var character)) + continue; + + result.Add(new JsonObject + { + ["id"] = character.Uuid.ToString("N"), + ["name"] = character.Name + }); + } + + return result.Count == 0 + ? HttpRouteResponse.Empty(HttpStatusCode.NoContent) + : HttpRouteResponse.Json(result); + } + + private Task _HandleHasJoined(HttpListenerRequest request) + { + var username = request.QueryString["username"]; + if (string.IsNullOrEmpty(username) || !_TryGetByName(username, out var character)) + return HttpRouteResponse.NotFound.AsTask(); + return HttpRouteResponse.Json(_CreateCompleteResponse(character)).AsTask(); + } + + private Task _HandleJoin(HttpListenerRequest request) => + HttpRouteResponse.NoContent.AsTask(); + + /// + /// 按 UUID(32 位无横线)返回完整档案。 + /// + private Task _HandleProfile(HttpListenerRequest request, IReadOnlyDictionary parameters) + { + var uuidText = parameters["uuid"]; + if (!Guid.TryParseExact(uuidText, "N", out var uuid) || !_TryGetByUuid(uuid, out var character)) + return HttpRouteResponse.NotFound.AsTask(); + return HttpRouteResponse.Json(_CreateCompleteResponse(character)).AsTask(); + } + + /// + /// 按 hash 从 缓存取出贴图并返回 PNG 字节。 + /// + private Task _HandleTexture(HttpListenerRequest request, IReadOnlyDictionary parameters) + { + var hash = parameters["hash"]; + var texture = SkinTexture.Has(hash) ? SkinTexture.Get(hash) : null; + if (texture?.Image is not { } image) + return HttpRouteResponse.NotFound.AsTask(); + + var stream = new MemoryStream(); + image.Save(stream, ImageFormat.Png); + stream.Position = 0; + + // 不显式释放 MemoryStream:HttpRouteResponse.Pour 只把 InputStream CopyTo 到响应输出流, + // 且不会释放该流(见 HttpRouteResponse.Pour 源码)。流交由 HttpRouteResponse 持有, + // 请求处理完毕后由 GC 回收,与 HttpRouteResponse.Json 内部对 MemoryStream 的处理方式一致。 + return HttpRouteResponse.Input(stream, "image/png").AsTask(); + } + + /// + /// 构造完整档案响应(对齐 HMCL YggdrasilServer.Character.toCompleteResponse)。 + /// + private JsonObject _CreateCompleteResponse(Character character) + { + var texturesPayload = _CreateTexturesPayload(character); + var value = Convert.ToBase64String(Encoding.UTF8.GetBytes(texturesPayload.ToJsonString())); + + return new JsonObject + { + ["id"] = character.Uuid.ToString("N"), + ["name"] = character.Name, + ["properties"] = new JsonArray + { + new JsonObject + { + ["name"] = "textures", + ["value"] = value, + ["signature"] = RsaKeyUtils.SignData(_signKey, Encoding.UTF8.GetBytes(value)) + } + } + }; + } + + /// + /// 构造 textures 属性值的 JSON 载荷。皮肤为 null 时 textures 对象保持为空。 + /// metadata.model 仅在 Slim 模型时写入。 + /// + private JsonObject _CreateTexturesPayload(Character character) + { + var textures = new JsonObject(); + var loadedSkin = character.Skin; + + if (loadedSkin?.Skin is { } skinTexture) + { + var skin = new JsonObject + { + ["url"] = $"http://localhost:{Port}/textures/{skinTexture.Hash}" + }; + if (loadedSkin.Model == TextureModel.Slim) + skin["metadata"] = new JsonObject { ["model"] = "slim" }; + textures["SKIN"] = skin; + } + + if (loadedSkin?.Cape is { } capeTexture) + textures["CAPE"] = new JsonObject + { + ["url"] = $"http://localhost:{Port}/textures/{capeTexture.Hash}" + }; + + return new JsonObject + { + ["timestamp"] = 0, + ["profileId"] = character.Uuid.ToString("N"), + ["profileName"] = character.Name, + ["textures"] = textures + }; + } + + private bool _TryGetByName(string name, out Character character) + { + lock (_syncRoot) + return _charactersByName.TryGetValue(name, out character); + } + + private bool _TryGetByUuid(Guid uuid, out Character character) + { + lock (_syncRoot) + return _charactersByUuid.TryGetValue(uuid, out character); + } +} diff --git a/PCL.Core/Minecraft/Skin/PixelAccess.cs b/PCL.Core/Minecraft/Skin/PixelAccess.cs new file mode 100644 index 0000000000..b7cabf114c --- /dev/null +++ b/PCL.Core/Minecraft/Skin/PixelAccess.cs @@ -0,0 +1,145 @@ +using System; +using System.Drawing; +using System.Drawing.Imaging; + +namespace PCL.Core.Minecraft.Skin; + +/// +/// 基于 的像素级读写辅助,统一以 32 位 ARGB 格式访问像素。 +/// +internal static class PixelAccess +{ + /// + /// 读取指定像素的 ARGB 值。单次调用会对整张位图加锁/解锁,适合少量采样。 + /// + /// 目标位图。 + /// 像素 X 坐标。 + /// 像素 Y 坐标。 + /// 像素的 ARGB 值(alpha 在高位)。 + public static int GetPixel(Bitmap bitmap, int x, int y) + { + using var accessor = Lock(bitmap, ImageLockMode.ReadOnly); + return accessor.GetPixel(x, y); + } + + /// + /// 写入指定像素的 ARGB 值。单次调用会对整张位图加锁/解锁,适合少量写入。 + /// + /// 目标位图。 + /// 像素 X 坐标。 + /// 像素 Y 坐标。 + /// 要写入的 ARGB 值(alpha 在高位)。 + public static void SetPixel(Bitmap bitmap, int x, int y, int argb) + { + using var accessor = Lock(bitmap, ImageLockMode.ReadWrite); + accessor.SetPixel(x, y, argb); + } + + /// + /// 锁定位图以获得批量访问句柄。使用完毕后应释放句柄以解锁位图。 + /// + /// 目标位图。 + /// 锁定模式。 + /// 位图访问句柄。 + public static PixelAccessor Lock(Bitmap bitmap, ImageLockMode mode) + { + return new PixelAccessor(bitmap, mode); + } + + /// + /// 将源区域像素拷贝到目标区域,可选水平翻转。 + /// + /// 源访问句柄。 + /// 目标访问句柄。 + /// 源区域左上角 X。 + /// 源区域左上角 Y。 + /// 区域宽度。 + /// 区域高度。 + /// 目标区域左上角 X。 + /// 目标区域左上角 Y。 + /// 是否水平翻转。 + public static void CopyRegion(PixelAccessor source, PixelAccessor dest, + int srcX, int srcY, int width, int height, int dstX, int dstY, bool flipHorizontal) + { + for (var row = 0; row < height; row++) + { + for (var col = 0; col < width; col++) + { + var sourceX = flipHorizontal ? srcX + (width - 1 - col) : srcX + col; + dest.SetPixel(dstX + col, dstY + row, source.GetPixel(sourceX, srcY + row)); + } + } + } +} + +/// +/// 位图锁定句柄:构造时锁定位图,释放时解锁。以 32 位 ARGB 访问像素。 +/// +internal sealed unsafe class PixelAccessor : IDisposable +{ + private readonly Bitmap _bitmap; + private readonly BitmapData _data; + private readonly byte* _base; + private readonly int _stride; + + /// + /// 位图宽度。 + /// + public int Width { get; } + + /// + /// 位图高度。 + /// + public int Height { get; } + + internal PixelAccessor(Bitmap bitmap, ImageLockMode mode) + { + _bitmap = bitmap; + Width = bitmap.Width; + Height = bitmap.Height; + _data = bitmap.LockBits(new Rectangle(0, 0, Width, Height), mode, PixelFormat.Format32bppArgb); + if (_data.Stride < 0) + { + // 自底向上的存储:指针定位到第一行,行步进取绝对值 + _base = (byte*)_data.Scan0 + _data.Stride * (Height - 1); + _stride = -_data.Stride; + } + else + { + _base = (byte*)_data.Scan0; + _stride = _data.Stride; + } + } + + /// + /// 读取指定像素的 ARGB 值。 + /// + /// 像素 X 坐标。 + /// 像素 Y 坐标。 + /// 像素的 ARGB 值(alpha 在高位)。 + public int GetPixel(int x, int y) + { + var p = _base + y * _stride + x * 4; + return (p[3] << 24) | (p[2] << 16) | (p[1] << 8) | p[0]; + } + + /// + /// 写入指定像素的 ARGB 值。 + /// + /// 像素 X 坐标。 + /// 像素 Y 坐标。 + /// 要写入的 ARGB 值(alpha 在高位)。 + public void SetPixel(int x, int y, int argb) + { + var p = _base + y * _stride + x * 4; + p[0] = (byte)argb; + p[1] = (byte)(argb >> 8); + p[2] = (byte)(argb >> 16); + p[3] = (byte)(argb >> 24); + } + + public void Dispose() + { + _bitmap.UnlockBits(_data); + } +} diff --git a/PCL.Core/Minecraft/Skin/RsaKeyUtils.cs b/PCL.Core/Minecraft/Skin/RsaKeyUtils.cs new file mode 100644 index 0000000000..056236f0e5 --- /dev/null +++ b/PCL.Core/Minecraft/Skin/RsaKeyUtils.cs @@ -0,0 +1,43 @@ +using System; +using System.Security.Cryptography; + +namespace PCL.Core.Minecraft.Skin; + +/// +/// RSA 签名工具,用于内嵌 Yggdrasil 皮肤服务器对 textures 属性进行签名, +/// 对齐 HMCL 的 util/KeyUtils.java。 +/// +public static class RsaKeyUtils +{ + /// + /// 创建 RSA 密钥对。 + /// + /// 密钥长度(位),默认 2048 位 + public static RSA CreateKey(int keySize = 2048) => RSA.Create(keySize); + + /// + /// 导出公钥的 PEM 字符串(含 -----BEGIN PUBLIC KEY----- 换行)。 + /// 用于 authlib-injector 元数据的 signaturePublickey 字段。 + /// + /// RSA 密钥对象 + public static string GetPublicKeyPem(RSA rsa) + { + ArgumentNullException.ThrowIfNull(rsa); + var keyInfo = rsa.ExportSubjectPublicKeyInfo(); + return PemEncoding.WriteString("PUBLIC KEY", keyInfo); + } + + /// + /// 对数据计算 SHA1withRSA 签名(PKCS#1 填充),返回 Base64 字符串。 + /// 用于对 textures 属性值(Base64 JSON)进行签名,供客户端校验来源。 + /// + /// RSA 密钥对象 + /// 待签名数据的 UTF-8 字节 + public static string SignData(RSA rsa, byte[] data) + { + ArgumentNullException.ThrowIfNull(rsa); + ArgumentNullException.ThrowIfNull(data); + var signature = rsa.SignData(data, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1); + return Convert.ToBase64String(signature); + } +} diff --git a/PCL.Core/Minecraft/Skin/Skin.cs b/PCL.Core/Minecraft/Skin/Skin.cs new file mode 100644 index 0000000000..b261ece3e4 --- /dev/null +++ b/PCL.Core/Minecraft/Skin/Skin.cs @@ -0,0 +1,112 @@ +using System; +using System.Text; +using System.Text.Json.Nodes; + +namespace PCL.Core.Minecraft.Skin; + +using TextureModelKind = TextureModel; + +/// +/// 离线账户的自定义皮肤配置模型。 +/// +/// 皮肤来源类型。 +/// Custom Skin Loader API 的皮肤接口地址。 +/// 皮肤纹理模型(宽/细)。 +/// 本地皮肤文件路径。 +/// 本地披风文件路径。 +public sealed record Skin( + SkinType Type, + string? CslApi, + TextureModel TextureModel, + string? LocalSkinPath, + string? LocalCapePath) +{ + /// + /// 是否为纤细(Alex)模型。 + /// + public bool IsSlim => TextureModel == TextureModelKind.Slim; + + /// + /// 从存储 JSON 中反序列化皮肤配置。 + /// + /// 已存在的存储对象,内部各键可直接读取。 + /// 解析成功返回皮肤配置;type 缺失或未知、任一字段解析失败均返回 null + public static Skin? FromStorage(JsonObject storage) + { + try + { + var typeNode = storage["type"]; + if (typeNode is not JsonValue typeValue || !typeValue.TryGetValue(out var typeText)) + return null; + if (!TryParseType(typeText, out var type)) + return null; + + // textureModel 只有严格等于 "slim" 才算纤细模型,其余一律视为经典模型 + var textureModel = TextureModelKind.Wide; + if (storage["textureModel"] is JsonValue modelValue + && modelValue.TryGetValue(out var modelText) + && string.Equals(modelText, "slim", StringComparison.Ordinal)) + textureModel = TextureModelKind.Slim; + + return new Skin( + type, + GetNullableString(storage, "cslApi"), + textureModel, + GetNullableString(storage, "localSkinPath"), + GetNullableString(storage, "localCapePath")); + } + catch + { + return null; + } + } + + /// + /// 将皮肤配置写入存储 JSON。所有键都会写入(null 值保留为 JSON null)。 + /// + /// 已存在的存储对象,键会写入其中。 + public void WriteStorage(JsonObject storage) + { + storage["type"] = TypeToSnakeCase(Type); + storage["cslApi"] = CslApi; + storage["textureModel"] = IsSlim ? "slim" : "wide"; + storage["localSkinPath"] = LocalSkinPath; + storage["localCapePath"] = LocalCapePath; + } + + private static string? GetNullableString(JsonObject storage, string key) + { + var node = storage[key]; + if (node is null) return null; + if (node is JsonValue value && value.TryGetValue(out var text)) return text; + throw new InvalidOperationException($"字段 {key} 的类型不是字符串。"); + } + + private static string TypeToSnakeCase(SkinType type) => ToSnakeCase(type.ToString()); + + private static string ToSnakeCase(string name) + { + var sb = new StringBuilder(name.Length + 4); + foreach (var c in name) + { + if (char.IsUpper(c) && sb.Length > 0) + sb.Append('_'); + sb.Append(char.ToLowerInvariant(c)); + } + return sb.ToString(); + } + + private static bool TryParseType(string text, out SkinType type) + { + foreach (var candidate in Enum.GetValues()) + { + if (string.Equals(TypeToSnakeCase(candidate), text, StringComparison.OrdinalIgnoreCase)) + { + type = candidate; + return true; + } + } + type = default; + return false; + } +} diff --git a/PCL.Core/Minecraft/Skin/SkinTexture.cs b/PCL.Core/Minecraft/Skin/SkinTexture.cs new file mode 100644 index 0000000000..1125b25288 --- /dev/null +++ b/PCL.Core/Minecraft/Skin/SkinTexture.cs @@ -0,0 +1,130 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Imaging; +using System.Security.Cryptography; + +namespace PCL.Core.Minecraft.Skin; + +/// +/// 皮肤纹理,对应 HMCL auth/offline 模块的 Texture。 +/// 纹理以内容哈希标识,进程内按哈希缓存,相同内容只保留一份位图。 +/// +public sealed class SkinTexture +{ + private static readonly object CacheLock = new(); + private static readonly Dictionary Cache = new(); + + /// + /// 纹理内容的 SHA-256 十六进制哈希(小写)。 + /// + public string Hash { get; } + + /// + /// 纹理位图。 + /// + public Bitmap Image { get; } + + private SkinTexture(string hash, Bitmap image) + { + Hash = hash; + Image = image; + } + + /// + /// 加载纹理:计算哈希并按哈希缓存。若已存在相同哈希的纹理,返回缓存项并忽略传入的位图; + /// 缓存未命中时直接持有传入的位图(不做拷贝)。 + /// + /// 纹理位图。 + /// 已缓存的纹理实例。 + public static SkinTexture Load(Bitmap image) + { + var hash = ComputeHash(image); + lock (CacheLock) + { + if (Cache.TryGetValue(hash, out var existing)) + return existing; + var created = new SkinTexture(hash, image); + Cache[hash] = created; + return created; + } + } + + /// + /// 是否已缓存指定哈希的纹理。 + /// + /// 纹理哈希。 + /// 已缓存返回 true + public static bool Has(string hash) + { + lock (CacheLock) + { + return Cache.ContainsKey(hash); + } + } + + /// + /// 获取指定哈希的纹理,未命中时返回 null。 + /// + /// 纹理哈希。 + /// 对应的纹理,未命中为 null + public static SkinTexture? Get(string hash) + { + lock (CacheLock) + { + return Cache.TryGetValue(hash, out var value) ? value : null; + } + } + + /// + /// 计算纹理哈希,算法与 HMCL Texture.computeTextureHash 一致: + /// SHA256(宽 4 字节大端 + 高 4 字节大端 + 每像素 ARGB 各 1 字节;alpha 为 0 时 RGB 清零)。 + /// + /// 纹理位图。 + /// 小写十六进制哈希字符串。 + public static string ComputeHash(Bitmap image) + { + using var sha256 = SHA256.Create(); + + var header = new byte[8]; + BinaryPrimitives.WriteInt32BigEndian(header, image.Width); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), image.Height); + sha256.TransformBlock(header, 0, header.Length, null, 0); + + using var accessor = PixelAccess.Lock(image, ImageLockMode.ReadOnly); + var buffer = new byte[4096]; + var bufferIndex = 0; + for (var y = 0; y < accessor.Height; y++) + { + for (var x = 0; x < accessor.Width; x++) + { + var pixel = accessor.GetPixel(x, y); + var alpha = (byte)(pixel >> 24); + var red = (byte)(pixel >> 16); + var green = (byte)(pixel >> 8); + var blue = (byte)pixel; + if (alpha == 0) + { + red = 0; + green = 0; + blue = 0; + } + buffer[bufferIndex++] = alpha; + buffer[bufferIndex++] = red; + buffer[bufferIndex++] = green; + buffer[bufferIndex++] = blue; + if (bufferIndex == buffer.Length) + { + sha256.TransformBlock(buffer, 0, buffer.Length, null, 0); + bufferIndex = 0; + } + } + } + if (bufferIndex > 0) + sha256.TransformBlock(buffer, 0, bufferIndex, null, 0); + + sha256.TransformFinalBlock(Array.Empty(), 0, 0); + return Convert.ToHexString(sha256.Hash!).ToLowerInvariant(); + } +} diff --git a/PCL.Core/Minecraft/Skin/SkinType.cs b/PCL.Core/Minecraft/Skin/SkinType.cs new file mode 100644 index 0000000000..2ade7da9ee --- /dev/null +++ b/PCL.Core/Minecraft/Skin/SkinType.cs @@ -0,0 +1,37 @@ +namespace PCL.Core.Minecraft.Skin; + +/// +/// 皮肤来源类型。 +/// +public enum SkinType +{ + /// + /// 使用启动器内置的默认皮肤。 + /// + Default, + + /// + /// 使用 Steve 皮肤。 + /// + Steve, + + /// + /// 使用 Alex 皮肤。 + /// + Alex, + + /// + /// 使用本地皮肤文件。 + /// + LocalFile, + + /// + /// 使用 LittleSkin 提供的皮肤。 + /// + LittleSkin, + + /// + /// 使用 Custom Skin Loader API 提供的皮肤。 + /// + CustomSkinLoaderApi, +} diff --git a/PCL.Core/Minecraft/Skin/TextureModel.cs b/PCL.Core/Minecraft/Skin/TextureModel.cs new file mode 100644 index 0000000000..46f6529149 --- /dev/null +++ b/PCL.Core/Minecraft/Skin/TextureModel.cs @@ -0,0 +1,17 @@ +namespace PCL.Core.Minecraft.Skin; + +/// +/// 皮肤纹理模型。 +/// +public enum TextureModel +{ + /// + /// 经典模型(Steve),手臂宽度为 4 像素。 + /// + Wide, + + /// + /// 纤细模型(Alex),手臂宽度为 3 像素。 + /// + Slim, +} From 2a183b57c17e803b89d36d24fc9efc629326984b Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 16:42:07 +0800 Subject: [PATCH 02/24] =?UTF-8?q?feat(skin):=20=E7=A6=BB=E7=BA=BF=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E7=9A=AE=E8=82=A4=E5=8A=A0=E8=BD=BD=E3=80=81?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E6=B3=A8=E5=85=A5=E4=B8=8E=20UI=20=E9=9B=86?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModSkin: LoadSkinAsync 皮肤加载器(内置/本地文件+披风/CSL 两步协议) - SkinJson: CSL API 响应解析(default/slim/cape, 容错) - ModLaunch: Legacy 分支注入 authlib-injector 指向内嵌皮肤服务器; 导出启动脚本时跳过注入; finally 清理服务器生命周期 - ModProfile: McProfile.Skin 皮肤配置字段 + 序列化/反序列化(兼容旧配置) - OfflineSkinDialog: 离线皮肤设置对话框(类型/模型/本地文件/披风/CSL URL) - PageLoginProfileSkin: 离线启用皮肤入口, 打开 OfflineSkinDialog - PageLaunchLeft: skinLegacy 消费 selectedProfile.Skin 显示自定义头像 - 语言: zh-CN/en-US 新增 Launch.OfflineSkin.* 21 个键 --- .../App/Localization/Languages/en-US.xaml | 24 ++ .../App/Localization/Languages/zh-CN.xaml | 24 ++ PCL.Core/Minecraft/Skin/OfflineSkinServer.cs | 24 +- .../Modules/Minecraft/ModLaunch.cs | 93 +++++++ .../Modules/Minecraft/ModProfile.cs | 22 +- .../Modules/Minecraft/ModSkin.cs | 167 ++++++++++++ .../Modules/Minecraft/SkinJson.cs | 133 ++++++++++ .../Pages/PageLaunch/OfflineSkinDialog.xaml | 118 +++++++++ .../PageLaunch/OfflineSkinDialog.xaml.cs | 242 ++++++++++++++++++ .../Pages/PageLaunch/PageLaunchLeft.xaml.cs | 40 ++- .../PageLaunch/PageLoginProfileSkin.xaml.cs | 21 +- 11 files changed, 898 insertions(+), 10 deletions(-) create mode 100644 Plain Craft Launcher 2/Modules/Minecraft/SkinJson.cs create mode 100644 Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml create mode 100644 Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml index c090e7a13e..7d147340b9 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -1668,6 +1668,30 @@ Select a location to save the skin file. Skin saved successfully! + + + Skin Settings + Skin Type + Default + Steve + Alex + Local file + LittleSkin + CSL API + Model + Steve (wide arms) + Alex (slim arms) + Skin file + Select skin file... + Cape file + Select cape file... + CSL API URL + LittleSkin provides skin hosting for Minecraft players. + Open LittleSkin website + OK + Cancel + Skin saved + Failed to change the cape. diff --git a/PCL.Core/App/Localization/Languages/zh-CN.xaml b/PCL.Core/App/Localization/Languages/zh-CN.xaml index 9bae29f93c..902a73c7e8 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -1668,6 +1668,30 @@ 选取保存皮肤的位置 皮肤保存成功! + + + 皮肤设置 + 皮肤类型 + 默认 + Steve + Alex + 本地文件 + LittleSkin + CSL API + 模型 + Steve(宽手臂) + Alex(细手臂) + 皮肤文件 + 选择皮肤文件… + 披风文件 + 选择披风文件… + CSL API 地址 + LittleSkin 为 Minecraft 玩家提供皮肤托管服务。 + 打开 LittleSkin 官网 + 确定 + 取消 + 皮肤已保存 + 更改披风失败 diff --git a/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs b/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs index 5da5035419..a3d28f764f 100644 --- a/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs +++ b/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs @@ -244,15 +244,31 @@ private JsonObject _CreateTexturesPayload(Character character) }; } - private bool _TryGetByName(string name, out Character character) + private bool _TryGetByName(string name, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Character character) { lock (_syncRoot) - return _charactersByName.TryGetValue(name, out character); + { + if (_charactersByName.TryGetValue(name, out var found)) + { + character = found; + return true; + } + character = null!; + return false; + } } - private bool _TryGetByUuid(Guid uuid, out Character character) + private bool _TryGetByUuid(Guid uuid, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Character character) { lock (_syncRoot) - return _charactersByUuid.TryGetValue(uuid, out character); + { + if (_charactersByUuid.TryGetValue(uuid, out var found)) + { + character = found; + return true; + } + character = null!; + return false; + } } } diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs index bac9cad01e..5ec27c5950 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs @@ -18,6 +18,7 @@ using PCL.Network; using PCL.Core.IO.Net.Http; using PCL.Core.Minecraft.IdentityModel.Yggdrasil; +using PCL.Core.Minecraft.Skin; using System.Globalization; namespace PCL; @@ -444,6 +445,15 @@ private static void McLaunchStart(ModLoader.LoaderTask : Lang.Text("Minecraft.Launch.Error.ExportScriptFailed")); throw; } + finally + { + // 关闭离线皮肤服务器(无论启动成功、失败还是取消,都不应残留监听端口) + if (mcLaunchOfflineSkinServer is not null) + { + mcLaunchOfflineSkinServer.Dispose(); + mcLaunchOfflineSkinServer = null; + } + } } #endregion @@ -2174,6 +2184,11 @@ public object HasArguments(string key) private static string mcLaunchArgument; + /// + /// 内嵌的离线皮肤服务器实例。首次注入时创建,启动流程结束(成功/失败/取消)后销毁。 + /// + private static OfflineSkinServer mcLaunchOfflineSkinServer; + /// /// 释放 Java Wrapper 并返回完整文件路径。 /// @@ -2498,6 +2513,12 @@ private static string McLaunchArgumentsJvmOld(McInstance instance) throw new Exception(Lang.Text("Minecraft.Launch.Error.CannotConnectAuthServer", server ?? null), ex); } } + // 离线(Legacy)账户的自定义皮肤:启动内嵌 Yggdrasil 皮肤服务器并注入 authlib-injector + else if (mcLoginLoader.output.Type == "Legacy" && + ModProfile.selectedProfile?.Skin is { } skinConfig && skinConfig.Type != SkinType.Default) + { + McLaunchOfflineSkinInject(dataList); + } if (Config.Instance.UseDebugLof4j2Config[instance.PathIndie]) { @@ -2629,6 +2650,12 @@ private static string McLaunchArgumentsJvmNew(McInstance instance) throw new Exception(Lang.Text("Minecraft.Launch.Error.CannotConnectAuthServer", server ?? null), ex); } } + // 离线(Legacy)账户的自定义皮肤:启动内嵌 Yggdrasil 皮肤服务器并注入 authlib-injector + else if (mcLoginLoader.output.Type == "Legacy" && + ModProfile.selectedProfile?.Skin is { } skinConfig && skinConfig.Type != SkinType.Default) + { + McLaunchOfflineSkinInject(dataList); + } // LWJGL Unsafe Agent if (McLaunchUsesLwjglUnsafeAgent(ModInstanceList.McMcInstanceSelected)) @@ -2724,6 +2751,65 @@ private static string McLaunchArgumentsJvmNew(McInstance instance) return result; } + /// + /// 离线(Legacy)账户的自定义皮肤:启动内嵌 Yggdrasil 皮肤服务器并注册玩家,再通过 + /// authlib-injector 将本地服务器注入 JVM 参数。任一步骤失败时不注入,仅记录日志, + /// 不中断启动流程。 + /// + private static void McLaunchOfflineSkinInject(List dataList) + { + // 导出启动脚本时本地服务器无法随脚本运行,跳过注入,避免生成指向失效端口的参数 + if (currentLaunchOptions?.SaveBatch is not null) + return; + + var profile = ModProfile.selectedProfile; + if (profile?.Skin is not { } skin) + return; + + var injectorPath = Path.Combine(ModBase.pathPure, "authlib-injector.jar"); + if (!File.Exists(injectorPath)) + { + ModBase.Log("[Launch] 未找到 authlib-injector.jar,跳过离线皮肤注入"); + return; + } + + // 档案 UUID 为 32 位无横线格式,按 "N" 格式解析;非法 UUID 不注入(保持兼容) + if (!Guid.TryParseExact(profile.Uuid, "N", out var uuid)) + { + ModBase.Log("[Launch] 离线皮肤注入失败:档案 UUID 无效,跳过注入"); + return; + } + + try + { + // 加载皮肤(同步等待,对齐 HMCL 的 skin.load().run());返回 null 表示无皮肤,仍注册角色 + var loadedSkin = ModSkin.LoadSkinAsync(skin, profile.Username).GetAwaiter().GetResult(); + + // 启动内嵌皮肤服务器并注册玩家 + mcLaunchOfflineSkinServer ??= new OfflineSkinServer(); + mcLaunchOfflineSkinServer.AddCharacter(uuid, profile.Username, loadedSkin); + mcLaunchOfflineSkinServer.Start(); + ModBase.Log($"[Launch] 已启动离线皮肤服务器:http://localhost:{mcLaunchOfflineSkinServer.Port}"); + + // 注入 authlib-injector javaagent(离线服务器已在线,无需 yggdrasil.prefetched) + dataList.Insert(0, + "-javaagent:\"" + injectorPath + "\"=" + + $"http://localhost:{mcLaunchOfflineSkinServer.Port}" + + " -Dauthlibinjector.side=client"); + } + catch (Exception ex) + { + // 清理可能未完全启动的服务器,避免端口残留 + if (mcLaunchOfflineSkinServer is not null) + { + mcLaunchOfflineSkinServer.Dispose(); + mcLaunchOfflineSkinServer = null; + } + + ModBase.Log(ex, "离线皮肤注入失败,将不注入离线皮肤服务器", ModBase.LogLevel.Developer); + } + } + // Game 部分(第二段) private static string McLaunchArgumentsGameOld(McInstance version) { @@ -3624,6 +3710,13 @@ private static void McLaunchWait(ModLoader.LoaderTask loader) private static void McLaunchEnd() { + // 关闭离线皮肤服务器(游戏已结束/启动已收尾,确保不残留监听端口) + if (mcLaunchOfflineSkinServer is not null) + { + mcLaunchOfflineSkinServer.Dispose(); + mcLaunchOfflineSkinServer = null; + } + McLaunchLog("开始启动结束处理"); // 暂停或开始音乐播放 diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModProfile.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModProfile.cs index e6d801470b..45c3cb5a8c 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModProfile.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModProfile.cs @@ -8,6 +8,7 @@ using PCL.Network; using PCL.Core.App.Localization; using PCL.Core.IO.Net; +using PCL.Core.Minecraft.Skin; namespace PCL; @@ -152,6 +153,11 @@ public class McProfile /// public string SkinHeadId; + /// + /// 离线账户的自定义皮肤配置 + /// + public Skin? Skin; + /// /// 档案类型 /// @@ -230,7 +236,9 @@ public static void GetProfile() Uuid = (string)Profile["uuid"], Username = (string)Profile["username"], Desc = (string)Profile["desc"], - SkinHeadId = (string)Profile["skinHeadId"] + SkinHeadId = (string)Profile["skinHeadId"], + // 兼容旧版 profiles.json:无 skin 键时自动为 null + Skin = Profile["skin"] is JsonObject skinStorage ? Skin.FromStorage(skinStorage) : null }; profileList.Add(newProfile); } @@ -298,11 +306,21 @@ public static void SaveProfile(JsonArray listJson = null) { "desc", Profile.Desc }, { "skinHeadId", Profile.SkinHeadId } }; else + { + // 离线账户的自定义皮肤配置,可能为 null + JsonObject? skinObj = null; + if (Profile.Skin is not null) + { + skinObj = new JsonObject(); + Profile.Skin.WriteStorage(skinObj); + } + profileJobj = new JsonObject { { "type", "offline" }, { "uuid", Profile.Uuid }, { "username", Profile.Username }, - { "desc", Profile.Desc }, { "skinHeadId", Profile.SkinHeadId } + { "desc", Profile.Desc }, { "skinHeadId", Profile.SkinHeadId }, { "skin", skinObj } }; + } list.Add(profileJobj); } diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs index 8cfcce2474..4de710ab76 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs @@ -1,10 +1,12 @@ using System; +using System.Drawing; using System.Globalization; using System.IO; using System.Text; using System.Text.Json.Nodes; using Microsoft.VisualBasic; using PCL.Core.App.Localization; +using PCL.Core.Minecraft.Skin; using PCL.Core.UI; using PCL.Core.Utils; using PCL.Network; @@ -189,4 +191,169 @@ public static string McSkinSex(string uuid) // Return hash // End Function } + + /// + /// 根据皮肤配置加载离线皮肤的贴图数据。无皮肤或加载失败时返回 null。 + /// + /// 离线账户的皮肤配置。 + /// 离线用户名,用于请求 Custom Skin Loader API。 + /// 加载到的皮肤数据;无皮肤或加载失败为 null + public static async Task LoadSkinAsync(Skin skin, string username) + { + switch (skin.Type) + { + case SkinType.Default: + return null; + case SkinType.Steve: + return LoadBuiltin("Steve", TextureModel.Wide); + case SkinType.Alex: + return LoadBuiltin("Alex", TextureModel.Slim); + case SkinType.LocalFile: + return await LoadLocalFileAsync(skin).ConfigureAwait(false); + case SkinType.LittleSkin: + case SkinType.CustomSkinLoaderApi: + return await LoadCslAsync(skin, username).ConfigureAwait(false); + default: + throw new NotSupportedException($"不支持的皮肤来源类型:{skin.Type}"); + } + } + + /// + /// 加载启动器内置的皮肤贴图(Steve / Alex),不含披风。 + /// + /// 皮肤名称(Steve 或 Alex),对应 下 Skins 文件夹中的文件名。 + /// 皮肤的纹理模型。 + /// 内置皮肤数据。 + private static LoadedSkin LoadBuiltin(string name, TextureModel model) + { + var bitmap = new MyBitmap(ModBase.pathImage + "Skins/" + name + ".png").pic; + return new LoadedSkin(model, SkinTexture.Load(bitmap), null); + } + + /// + /// 加载本地皮肤文件与本地披风文件。 + /// + /// 皮肤配置,包含本地文件路径。 + /// 加载到的皮肤数据;皮肤与披风均未设置或读取失败时为 null + private static async Task LoadLocalFileAsync(Skin skin) + { + var skinBitmap = await LoadBitmapAsync(skin.LocalSkinPath).ConfigureAwait(false); + var capeBitmap = await LoadBitmapAsync(skin.LocalCapePath).ConfigureAwait(false); + return new LoadedSkin(skin.TextureModel, + skinBitmap is null ? null : SkinTexture.Load(skinBitmap), + capeBitmap is null ? null : SkinTexture.Load(capeBitmap)); + } + + /// + /// 从本地文件加载位图。路径为空、文件不存在或图片损坏时返回 null 并记录开发者日志。 + /// + /// 图片文件路径。 + /// 加载到的位图;失败为 null + private static async Task LoadBitmapAsync(string? path) + { + if (string.IsNullOrEmpty(path)) + return null; + try + { + return await Task.Run(() => new MyBitmap(path).pic).ConfigureAwait(false); + } + catch (Exception ex) + { + ModBase.Log(ex, $"加载本地皮肤图片失败:{path}", ModBase.LogLevel.Developer); + return null; + } + } + + /// + /// 通过 Custom Skin Loader API 加载皮肤与披风。 + /// 分为两步:先请求 {api}/{username}.json 获取皮肤信息,再并行下载皮肤与披风贴图。 + /// + /// 皮肤配置。 + /// 离线用户名。 + /// 加载到的皮肤数据;请求失败或无皮肤时为 null + private static async Task LoadCslAsync(Skin skin, string username) + { + var api = skin.Type == SkinType.LittleSkin ? "https://littleskin.cn/csl" : NormalizeCslUrl(skin.CslApi); + if (string.IsNullOrEmpty(api) || string.IsNullOrEmpty(username)) + return null; + + // 第一步:获取皮肤信息 JSON + var jsonText = ModNet.NetGetCodeByRequestRetry($"{api}/{username}.json")?.ToString(); + if (string.IsNullOrEmpty(jsonText)) + return null; + var parsed = SkinJson.FromJson(jsonText); + if (parsed is null || !parsed.HasSkin) + return null; + + var model = parsed.GetModel(); + + // 第二步:并行下载皮肤与披风贴图 + var skinTask = parsed.SkinHash is null + ? Task.FromResult(null) + : DownloadCslTextureAsync(api, parsed.SkinHash); + var capeTask = parsed.CapeHash is null + ? Task.FromResult(null) + : DownloadCslTextureAsync(api, parsed.CapeHash); + var skinTex = await skinTask.ConfigureAwait(false); + var capeTex = await capeTask.ConfigureAwait(false); + if (skinTex is null) + return null; + return new LoadedSkin(model ?? TextureModel.Wide, skinTex, capeTex); + } + + /// + /// 下载并加载指定哈希的 CSL 皮肤贴图。 + /// 贴图先暂存到 下的 Skin 文件夹(已存在则直接复用,不重复下载), + /// 读取完成后删除临时文件。 + /// + /// Custom Skin Loader API 地址。 + /// 贴图哈希。 + /// 加载到的贴图;下载或读取失败为 null + private static async Task DownloadCslTextureAsync(string api, string hash) + { + var directory = Path.Combine(ModBase.pathTemp, "Skin"); + var tempPath = Path.Combine(directory, hash + ".png"); + var url = $"{api}/textures/{hash}"; + try + { + Directory.CreateDirectory(directory); + if (!File.Exists(tempPath)) + await FileDownloader.DownloadAsync(url, tempPath).ConfigureAwait(false); + + var bitmap = await Task.Run(() => new MyBitmap(tempPath).pic).ConfigureAwait(false); + return SkinTexture.Load(bitmap); + } + catch (Exception ex) + { + ModBase.Log(ex, $"下载或读取 CSL 皮肤贴图失败:{url}", ModBase.LogLevel.Developer); + return null; + } + finally + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch + { + // 临时文件删除失败不影响皮肤加载结果 + } + } + } + + /// + /// 规范化 Custom Skin Loader API 地址:空白返回空字符串,缺失协议时补全 https://,并去除末尾的斜杠。 + /// + /// 原始 API 地址。 + /// 规范化后的 API 地址。 + public static string NormalizeCslUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + return ""; + var result = url.Trim(); + if (!result.Contains("://")) + result = "https://" + result; + return result.TrimEnd('/'); + } } diff --git a/Plain Craft Launcher 2/Modules/Minecraft/SkinJson.cs b/Plain Craft Launcher 2/Modules/Minecraft/SkinJson.cs new file mode 100644 index 0000000000..ceef748be2 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/SkinJson.cs @@ -0,0 +1,133 @@ +using System; +using System.Text.Json.Nodes; +using PCL.Core.Minecraft.Skin; + +namespace PCL; + +/// +/// 解析 Custom Skin Loader API 返回的皮肤信息 JSON。 +/// 同时支持旧版字段(skin / cape / elytra)与新版 textures(或别名 skins)对象。 +/// +internal sealed class SkinJson +{ + /// + /// 皮肤所属的用户名。 + /// + public string? Username { get; } + + /// + /// 皮肤贴图哈希;未找到任何皮肤哈希时为 null。 + /// + public string? SkinHash { get; } + + /// + /// 披风贴图哈希;未设置时为 null。 + /// + public string? CapeHash { get; } + + /// + /// 是否包含皮肤信息(以用户名是否为非空字符串判断)。 + /// + public bool HasSkin => !string.IsNullOrEmpty(Username); + + private readonly JsonObject? _textures; + + private SkinJson(string? username, string? skinHash, string? capeHash, JsonObject? textures) + { + Username = username; + SkinHash = skinHash; + CapeHash = capeHash; + _textures = textures; + } + + /// + /// 从 JSON 文本解析皮肤信息;解析失败返回 null。 + /// + /// Custom Skin Loader API 返回的 JSON 文本。 + /// 解析结果;JSON 非法或结构不符为 null + public static SkinJson? FromJson(string jsonText) + { + try + { + var root = JsonNode.Parse(jsonText) as JsonObject; + if (root is null) + return null; + + var username = GetString(root, "username"); + var textures = GetObject(root, "textures") ?? GetObject(root, "skins"); + var model = GetModel(textures); + var skinHash = GetSkinHash(textures, model, root); + var capeHash = GetCapeHash(textures, root); + return new SkinJson(username, skinHash, capeHash, textures); + } + catch + { + return null; + } + } + + /// + /// 获取皮肤的纹理模型:优先纤细(slim),其次经典(default),均未设置时为 null。 + /// + /// 纹理模型;无法确定时为 null + public TextureModel? GetModel() + { + return GetModel(_textures); + } + + /// + /// 计算纹理模型:textures.slim 非空 → Slim;textures.default 非空 → Wide;否则为 null。 + /// + private static TextureModel? GetModel(JsonObject? textures) + { + if (textures is null) + return null; + if (!string.IsNullOrEmpty(GetString(textures, "slim"))) + return TextureModel.Slim; + if (!string.IsNullOrEmpty(GetString(textures, "default"))) + return TextureModel.Wide; + return null; + } + + /// + /// 确定皮肤贴图哈希:有模型信息时优先取对应模型(slim / default)的哈希, + /// 均不可用时回退到顶层旧字段 skin。 + /// + private static string? GetSkinHash(JsonObject? textures, TextureModel? model, JsonObject root) + { + var slimHash = textures is null ? null : GetString(textures, "slim"); + var defaultHash = textures is null ? null : GetString(textures, "default"); + if (model == TextureModel.Slim && !string.IsNullOrEmpty(slimHash)) + return slimHash; + if (model == TextureModel.Wide && !string.IsNullOrEmpty(defaultHash)) + return defaultHash; + return GetString(root, "skin"); + } + + /// + /// 确定披风贴图哈希:优先 textures.cape,其次顶层旧字段 cape。 + /// + private static string? GetCapeHash(JsonObject? textures, JsonObject root) + { + var capeHash = textures is null ? null : GetString(textures, "cape"); + if (!string.IsNullOrEmpty(capeHash)) + return capeHash; + return GetString(root, "cape"); + } + + private static string? GetString(JsonObject? obj, string key) + { + if (obj is null) + return null; + if (obj[key] is JsonValue value && value.TryGetValue(out var text)) + return text; + return null; + } + + private static JsonObject? GetObject(JsonObject? obj, string key) + { + if (obj is null) + return null; + return obj[key] as JsonObject; + } +} diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml new file mode 100644 index 0000000000..833dba83a4 --- /dev/null +++ b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs new file mode 100644 index 0000000000..bab4881cef --- /dev/null +++ b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs @@ -0,0 +1,242 @@ +using System; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using PCL.Core.App.Localization; +using PCL.Core.Minecraft.Skin; +using PCL.Core.UI; + +namespace PCL; + +/// +/// 离线账户的皮肤设置对话框,参照 HMCL 的 OfflineAccountSkinPane 实现。 +/// +public partial class OfflineSkinDialog +{ + // 皮肤类型选项顺序,与 ComboType 中的项一一对应 + private static readonly SkinType[] TypeOrder = + [ + SkinType.Default, SkinType.Steve, SkinType.Alex, SkinType.LocalFile, SkinType.LittleSkin, + SkinType.CustomSkinLoaderApi + ]; + + private string _skinPath; + private string _capePath; + private TextureModel _model = TextureModel.Wide; + + public OfflineSkinDialog() + { + InitializeComponent(); + Loaded += (_, _) => LoadCurrentSkin(); + } + + private SkinType CurrentType + { + get + { + var index = ComboType.SelectedIndex; + return index >= 0 && index < TypeOrder.Length ? TypeOrder[index] : SkinType.Default; + } + } + + /// + /// 打开时回显当前档案已保存的皮肤配置。 + /// + private void LoadCurrentSkin() + { + var skin = ModProfile.selectedProfile?.Skin; + _model = skin?.TextureModel ?? TextureModel.Wide; + _skinPath = skin?.LocalSkinPath ?? ""; + _capePath = skin?.LocalCapePath ?? ""; + + ComboType.SelectedIndex = skin is null ? 0 : Array.IndexOf(TypeOrder, skin.Type); + ComboModel.SelectedIndex = _model == TextureModel.Slim ? 1 : 0; + TextSkinPath.Text = _skinPath; + TextCapePath.Text = _capePath; + TextCslApi.Text = skin?.CslApi ?? ""; + + UpdatePanels(); + UpdatePreview(); + UpdateConfirmEnabled(); + } + + /// + /// 根据当前皮肤类型显示或隐藏对应的选项区域。 + /// + private void UpdatePanels() + { + PanLocalFile.Visibility = CurrentType == SkinType.LocalFile ? Visibility.Visible : Visibility.Collapsed; + PanCslApi.Visibility = CurrentType == SkinType.CustomSkinLoaderApi ? Visibility.Visible : Visibility.Collapsed; + PanLittleSkin.Visibility = CurrentType == SkinType.LittleSkin ? Visibility.Visible : Visibility.Collapsed; + } + + /// + /// CSL API 模式下需要填写有效的接口地址才能确定。 + /// + private void UpdateConfirmEnabled() + { + BtnConfirm.IsEnabled = CurrentType != SkinType.CustomSkinLoaderApi || + Uri.TryCreate(TextCslApi.Text.Trim(), UriKind.Absolute, out _); + } + + /// + /// 刷新头像预览。 + /// + private void UpdatePreview() + { + try + { + var path = GetPreviewPath(); + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + ImgPreviewFace.Source = null; + ImgPreviewHair.Source = null; + return; + } + + var image = new MyBitmap(path); + var scale = Math.Max(1, (int)Math.Round(image.pic.Width / 64d)); + // 脸层 + var face = image.Clip(scale * 8, scale * 8, scale * 8, scale * 8); + // 头发层(仅现代格式 64x64 及以上的皮肤才有) + MyBitmap? hair = null; + if (image.pic.Width >= 64 && image.pic.Height >= 64) + hair = image.Clip(scale * 40, scale * 8, scale * 8, scale * 8); + + ImgPreviewFace.Source = face; + ImgPreviewHair.Source = hair is null ? null : hair; + } + catch (Exception ex) + { + ImgPreviewFace.Source = null; + ImgPreviewHair.Source = null; + ModBase.Log(ex, "刷新皮肤预览失败", ModBase.LogLevel.Developer); + } + } + + private string GetPreviewPath() + { + switch (CurrentType) + { + case SkinType.Default: + case SkinType.Steve: + return ModBase.pathImage + "Skins/Steve.png"; + case SkinType.Alex: + return ModBase.pathImage + "Skins/Alex.png"; + case SkinType.LocalFile: + return _skinPath; + case SkinType.LittleSkin: + case SkinType.CustomSkinLoaderApi: + // 在线皮肤无法离线预览,按当前模型显示默认皮肤占位 + return _model == TextureModel.Slim + ? ModBase.pathImage + "Skins/Alex.png" + : ModBase.pathImage + "Skins/Steve.png"; + default: + return ModBase.pathImage + "Skins/Steve.png"; + } + } + + // 选择皮肤文件 + private void BtnSelectSkin_Click(object sender, MouseButtonEventArgs e) + { + var fileName = SystemDialogs.SelectFile(Lang.Text("Launch.Skin.FileDialog.Filter"), + Lang.Text("Launch.Skin.FileDialog.Title")); + if (string.IsNullOrEmpty(fileName)) + return; + try + { + var image = new MyBitmap(fileName); + // 允许高分辨率皮肤:宽度需为 64 的倍数,高度为宽度的一半或与宽度相同 + if (image.pic.Width % 64 != 0 || + !(image.pic.Height == image.pic.Width / 2 || image.pic.Height == image.pic.Width)) + { + HintService.Hint(Lang.Text("Launch.Skin.InvalidSize"), HintType.Error); + return; + } + } + catch (Exception ex) + { + ModBase.Log(ex, Lang.Text("Launch.Skin.File.Error"), ModBase.LogLevel.Hint, + userSummary: Lang.Text("Launch.Skin.File.Error")); + return; + } + + _skinPath = fileName; + TextSkinPath.Text = fileName; + UpdatePreview(); + } + + // 选择披风文件 + private void BtnSelectCape_Click(object sender, MouseButtonEventArgs e) + { + var fileName = SystemDialogs.SelectFile(Lang.Text("Launch.Skin.FileDialog.Filter"), + Lang.Text("Launch.Skin.FileDialog.Title")); + if (string.IsNullOrEmpty(fileName)) + return; + try + { + _ = new MyBitmap(fileName); // 校验文件可以正常读取 + } + catch (Exception ex) + { + ModBase.Log(ex, Lang.Text("Launch.Skin.File.Error"), ModBase.LogLevel.Hint, + userSummary: Lang.Text("Launch.Skin.File.Error")); + return; + } + + _capePath = fileName; + TextCapePath.Text = fileName; + } + + // 打开 LittleSkin 官网 + private void BtnOpenLittleSkin_Click(object sender, MouseButtonEventArgs e) + { + ModBase.OpenWebsite("https://littleskin.cn"); + } + + // 确定:回写皮肤配置并保存 + private void BtnConfirm_Click(object sender, MouseButtonEventArgs e) + { + if (CurrentType == SkinType.CustomSkinLoaderApi && + !Uri.TryCreate(TextCslApi.Text.Trim(), UriKind.Absolute, out _)) + return; + + var cslApi = CurrentType == SkinType.CustomSkinLoaderApi ? TextCslApi.Text.Trim() : null; + var skin = new Skin( + CurrentType, + cslApi, + _model, + string.IsNullOrEmpty(_skinPath) ? null : _skinPath, + string.IsNullOrEmpty(_capePath) ? null : _capePath); + + ModProfile.selectedProfile.Skin = skin; + ModProfile.SaveProfile(); + HintService.Hint(Lang.Text("Launch.OfflineSkin.Saved"), HintType.Success); + DialogResult = true; + } + + // 取消 + private void BtnCancel_Click(object sender, MouseButtonEventArgs e) + { + DialogResult = false; + } + + private void ComboType_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + UpdatePanels(); + UpdatePreview(); + UpdateConfirmEnabled(); + } + + private void ComboModel_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + _model = ComboModel.SelectedIndex == 1 ? TextureModel.Slim : TextureModel.Wide; + UpdatePreview(); + } + + private void TextCslApi_TextChanged(object sender, TextChangedEventArgs e) + { + UpdateConfirmEnabled(); + } +} diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs index 50a8ad08b3..bf42db7a64 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs @@ -6,6 +6,7 @@ using System.Windows.Threading; using PCL.Core.App; using PCL.Core.App.Localization; +using PCL.Core.Minecraft.Skin; using PCL.Core.Utils; using PCL.Network; @@ -935,7 +936,13 @@ private static void SkinLegacyLoad(ModLoader.LoaderTask ModMain.frmLoginProfileSkin.Skin.Load()); @@ -943,6 +950,37 @@ private static void SkinLegacyLoad(ModLoader.LoaderTask + /// 加载离线账户的自定义皮肤,并导出为临时 PNG 供头像显示。加载失败时回退到默认 Steve/Alex。 + /// + private static string LoadLegacyCustomSkin(Skin skinConfig, string username) + { + try + { + var loadedSkin = ModSkin.LoadSkinAsync(skinConfig, username).GetAwaiter().GetResult(); + if (loadedSkin?.Skin is { } skinTexture) + { + // 优先直接使用本地皮肤文件路径,避免不必要的临时文件导出 + if (skinConfig.Type == SkinType.LocalFile && + !string.IsNullOrEmpty(skinConfig.LocalSkinPath) && + File.Exists(skinConfig.LocalSkinPath)) + return skinConfig.LocalSkinPath!; + + var tempDir = Path.Combine(ModBase.pathTemp, "Skin", "Avatar"); + Directory.CreateDirectory(tempDir); + var tempPath = Path.Combine(tempDir, $"{ModProfile.selectedProfile.Uuid}.png"); + skinTexture.Image.Save(tempPath, System.Drawing.Imaging.ImageFormat.Png); + return tempPath; + } + } + catch (Exception ex) + { + ModBase.Log(ex, "加载离线自定义皮肤失败,回退到默认皮肤", ModBase.LogLevel.Developer); + } + + return ModBase.pathImage + "Skins/" + ModSkin.McSkinSex(ModProfile.selectedProfile.Uuid) + ".png"; + } + // Authlib-Injector 皮肤 public static ModLoader.LoaderTask, string> skinAuth = new("Loader Skin Auth", SkinAuthLoad, SkinAuthInput, ThreadPriority.AboveNormal); diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginProfileSkin.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginProfileSkin.xaml.cs index a3a40608af..47e1f4cb48 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginProfileSkin.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginProfileSkin.xaml.cs @@ -39,7 +39,7 @@ public void Reload() } else { - BtnEdit.Visibility = Visibility.Collapsed; + BtnEdit.Visibility = Visibility.Visible; ModBase.Log("[Profile] 使用离线皮肤加载器"); Skin.loader = PageLaunchLeft.skinLegacy; } @@ -128,7 +128,7 @@ private void Skin_Click(object sender, RoutedEventArgs e) ModBase.OpenWebsite(ModProfile.selectedProfile.Server.BeforeFirst("api/yggdrasil/authserver") + "user/closet"); else - HintService.Hint(Lang.Text("Launch.Account.ProfileSkin.SkinUnsupported")); + OpenOfflineSkinDialog(); } // 保存皮肤 @@ -152,8 +152,23 @@ private void BtnSkinCape_Click(object sender, RoutedEventArgs e) ModBase.OpenWebsite(ModProfile.selectedProfile.Server.BeforeFirst("api/yggdrasil/authserver") + "user/closet"); else - HintService.Hint(Lang.Text("Launch.Account.ProfileSkin.CapeUnsupported")); + OpenOfflineSkinDialog(); } #endregion + + // 打开离线皮肤设置对话框 + private void OpenOfflineSkinDialog() + { + ModBase.RunInUi(() => + { + var dialog = new OfflineSkinDialog { Owner = ModMain.frmMain }; + if (dialog.ShowDialog() == true) + { + // 刷新档案界面显示新皮肤 + ModMain.frmLoginProfileSkin?.Reload(); + ModMain.frmLaunchLeft.RefreshPage(true); + } + }); + } } \ No newline at end of file From 4009319f3cc56e4b1fcb65cfe518ee46d188ac19 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 16:50:07 +0800 Subject: [PATCH 03/24] =?UTF-8?q?refactor(skin):=20=E6=A0=B9=E6=8D=AE?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=AE=A1=E9=98=85=E6=84=8F=E8=A7=81=E7=B2=BE?= =?UTF-8?q?=E7=AE=80=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 接线 NormalizedSkin.IsSlim() 到选文件流程: 自动判定 Steve/Alex 模型(原为死代码, 现符合规格) - 删除 McLaunchEnd 中与 finally 重复的服务器销毁块 - OfflineSkinDialog 移除 TypeOrder 重复映射, 改读 Combo 项 Tag + Enum.TryParse - SkinTexture 移除 Has() 双重查询, 统一 Get() - Skin record 参数 TextureModel 改名 Model(与 LoadedSkin 一致) - NormalizeCslUrl 改 private - HttpServer.Start 守卫同时检查模板路由(防模板路由静默跳过) - OfflineSkinServer 版本号加同步注释 --- PCL.Core/IO/Net/Http/HttpServer.cs | 7 ++- PCL.Core/Minecraft/Skin/OfflineSkinServer.cs | 3 +- PCL.Core/Minecraft/Skin/Skin.cs | 14 +++--- PCL.Core/Minecraft/Skin/SkinTexture.cs | 13 ------ .../Modules/Minecraft/ModLaunch.cs | 7 --- .../Modules/Minecraft/ModSkin.cs | 4 +- .../PageLaunch/OfflineSkinDialog.xaml.cs | 43 ++++++++++++++----- 7 files changed, 45 insertions(+), 46 deletions(-) diff --git a/PCL.Core/IO/Net/Http/HttpServer.cs b/PCL.Core/IO/Net/Http/HttpServer.cs index e48221fa90..9ba366bed7 100644 --- a/PCL.Core/IO/Net/Http/HttpServer.cs +++ b/PCL.Core/IO/Net/Http/HttpServer.cs @@ -81,10 +81,9 @@ protected void RegisterWithParams(HttpMethod method, string pathTemplate, Func public void Start() { - // 如果没有注册精确路由,调用 Init 初始化。这里只检查 _handlers.Count:即使 Init 里只注册了模板路由 - //(_templateHandlers 非空但 _handlers.Count 仍为 0),_initialized 也会被置位,因此 Init 永远只执行一次; - // 若子类在 Start 之前已通过 Register 注册精确路由,同样不会重复初始化。 - if (!_initialized && _handlers.Count == 0) + // 若未注册任何路由(精确或模板),调用 Init 初始化。检查两者确保子类若在 Start 前 + // 通过 Register 注册了精确路由、而 Init 里只注册模板路由时,模板路由也不会被跳过。 + if (!_initialized && _handlers.Count == 0 && _templateHandlers.Count == 0) { Init(); _initialized = true; diff --git a/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs b/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs index a3d28f764f..e16abdbc8d 100644 --- a/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs +++ b/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs @@ -79,6 +79,7 @@ private Task _HandleMeta(HttpListenerRequest request) { ["serverName"] = "PCL CE", ["implementationName"] = "PCL CE", + // 版本号与 metadata.json 保持一致;升级启动器版本时需同步更新 ["implementationVersion"] = "2.15.0", ["feature.non_email_login"] = true } @@ -171,7 +172,7 @@ private Task _HandleProfile(HttpListenerRequest request, IRea private Task _HandleTexture(HttpListenerRequest request, IReadOnlyDictionary parameters) { var hash = parameters["hash"]; - var texture = SkinTexture.Has(hash) ? SkinTexture.Get(hash) : null; + var texture = SkinTexture.Get(hash); if (texture?.Image is not { } image) return HttpRouteResponse.NotFound.AsTask(); diff --git a/PCL.Core/Minecraft/Skin/Skin.cs b/PCL.Core/Minecraft/Skin/Skin.cs index b261ece3e4..a634e86718 100644 --- a/PCL.Core/Minecraft/Skin/Skin.cs +++ b/PCL.Core/Minecraft/Skin/Skin.cs @@ -4,27 +4,25 @@ namespace PCL.Core.Minecraft.Skin; -using TextureModelKind = TextureModel; - /// /// 离线账户的自定义皮肤配置模型。 /// /// 皮肤来源类型。 /// Custom Skin Loader API 的皮肤接口地址。 -/// 皮肤纹理模型(宽/细)。 +/// 皮肤纹理模型(宽/细)。 /// 本地皮肤文件路径。 /// 本地披风文件路径。 public sealed record Skin( SkinType Type, string? CslApi, - TextureModel TextureModel, + TextureModel Model, string? LocalSkinPath, string? LocalCapePath) { /// /// 是否为纤细(Alex)模型。 /// - public bool IsSlim => TextureModel == TextureModelKind.Slim; + public bool IsSlim => Model == TextureModel.Slim; /// /// 从存储 JSON 中反序列化皮肤配置。 @@ -42,16 +40,16 @@ public sealed record Skin( return null; // textureModel 只有严格等于 "slim" 才算纤细模型,其余一律视为经典模型 - var textureModel = TextureModelKind.Wide; + var model = TextureModel.Wide; if (storage["textureModel"] is JsonValue modelValue && modelValue.TryGetValue(out var modelText) && string.Equals(modelText, "slim", StringComparison.Ordinal)) - textureModel = TextureModelKind.Slim; + model = TextureModel.Slim; return new Skin( type, GetNullableString(storage, "cslApi"), - textureModel, + model, GetNullableString(storage, "localSkinPath"), GetNullableString(storage, "localCapePath")); } diff --git a/PCL.Core/Minecraft/Skin/SkinTexture.cs b/PCL.Core/Minecraft/Skin/SkinTexture.cs index 1125b25288..2305f9db8c 100644 --- a/PCL.Core/Minecraft/Skin/SkinTexture.cs +++ b/PCL.Core/Minecraft/Skin/SkinTexture.cs @@ -51,19 +51,6 @@ public static SkinTexture Load(Bitmap image) } } - /// - /// 是否已缓存指定哈希的纹理。 - /// - /// 纹理哈希。 - /// 已缓存返回 true - public static bool Has(string hash) - { - lock (CacheLock) - { - return Cache.ContainsKey(hash); - } - } - /// /// 获取指定哈希的纹理,未命中时返回 null。 /// diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs index 5ec27c5950..29d9102528 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs @@ -3710,13 +3710,6 @@ private static void McLaunchWait(ModLoader.LoaderTask loader) private static void McLaunchEnd() { - // 关闭离线皮肤服务器(游戏已结束/启动已收尾,确保不残留监听端口) - if (mcLaunchOfflineSkinServer is not null) - { - mcLaunchOfflineSkinServer.Dispose(); - mcLaunchOfflineSkinServer = null; - } - McLaunchLog("开始启动结束处理"); // 暂停或开始音乐播放 diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs index 4de710ab76..3385397d03 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs @@ -239,7 +239,7 @@ private static LoadedSkin LoadBuiltin(string name, TextureModel model) { var skinBitmap = await LoadBitmapAsync(skin.LocalSkinPath).ConfigureAwait(false); var capeBitmap = await LoadBitmapAsync(skin.LocalCapePath).ConfigureAwait(false); - return new LoadedSkin(skin.TextureModel, + return new LoadedSkin(skin.Model, skinBitmap is null ? null : SkinTexture.Load(skinBitmap), capeBitmap is null ? null : SkinTexture.Load(capeBitmap)); } @@ -347,7 +347,7 @@ private static LoadedSkin LoadBuiltin(string name, TextureModel model) /// /// 原始 API 地址。 /// 规范化后的 API 地址。 - public static string NormalizeCslUrl(string? url) + private static string NormalizeCslUrl(string? url) { if (string.IsNullOrWhiteSpace(url)) return ""; diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs index bab4881cef..be9881adfa 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs @@ -14,13 +14,6 @@ namespace PCL; /// public partial class OfflineSkinDialog { - // 皮肤类型选项顺序,与 ComboType 中的项一一对应 - private static readonly SkinType[] TypeOrder = - [ - SkinType.Default, SkinType.Steve, SkinType.Alex, SkinType.LocalFile, SkinType.LittleSkin, - SkinType.CustomSkinLoaderApi - ]; - private string _skinPath; private string _capePath; private TextureModel _model = TextureModel.Wide; @@ -31,26 +24,42 @@ public OfflineSkinDialog() Loaded += (_, _) => LoadCurrentSkin(); } + /// + /// 当前选中的皮肤类型,取自 ComboType 选中项的 Tag(XAML 中已与 SkinType 枚举名对应)。 + /// private SkinType CurrentType { get { - var index = ComboType.SelectedIndex; - return index >= 0 && index < TypeOrder.Length ? TypeOrder[index] : SkinType.Default; + if (ComboType.SelectedItem is MyComboBoxItem { Tag: string tag } && + Enum.TryParse(tag, out SkinType type)) + return type; + return SkinType.Default; } } + /// + /// 按 Tag 查找下拉框中的项序号,未找到返回 0。 + /// + private static int FindComboIndex(ComboBox combo, string tag) + { + for (var i = 0; i < combo.Items.Count; i++) + if (combo.Items[i] is MyComboBoxItem { Tag: string itemTag } && itemTag == tag) + return i; + return 0; + } + /// /// 打开时回显当前档案已保存的皮肤配置。 /// private void LoadCurrentSkin() { var skin = ModProfile.selectedProfile?.Skin; - _model = skin?.TextureModel ?? TextureModel.Wide; + _model = skin?.Model ?? TextureModel.Wide; _skinPath = skin?.LocalSkinPath ?? ""; _capePath = skin?.LocalCapePath ?? ""; - ComboType.SelectedIndex = skin is null ? 0 : Array.IndexOf(TypeOrder, skin.Type); + ComboType.SelectedIndex = skin is null ? 0 : FindComboIndex(ComboType, skin.Type.ToString()); ComboModel.SelectedIndex = _model == TextureModel.Slim ? 1 : 0; TextSkinPath.Text = _skinPath; TextCapePath.Text = _capePath; @@ -154,6 +163,18 @@ private void BtnSelectSkin_Click(object sender, MouseButtonEventArgs e) HintService.Hint(Lang.Text("Launch.Skin.InvalidSize"), HintType.Error); return; } + + // 依据手臂区域自动判定模型(Steve 宽臂 / Alex 细臂),用户之后仍可手动调整 + try + { + var isSlim = new NormalizedSkin(image.pic).IsSlim(); + _model = isSlim ? TextureModel.Slim : TextureModel.Wide; + ComboModel.SelectedIndex = isSlim ? 1 : 0; + } + catch (InvalidSkinException) + { + // 尺寸已通过上方校验,此处仅作兜底,忽略即可 + } } catch (Exception ex) { From b062b1bf12732b5702fa5a48c09b80c360747057 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 16:52:10 +0800 Subject: [PATCH 04/24] =?UTF-8?q?test(skin):=20=E6=96=B0=E5=A2=9E=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E6=A0=B8=E5=BF=83=E7=AE=97=E6=B3=95=E5=8D=95=E6=B5=8B?= =?UTF-8?q?(14=20=E9=A1=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 SkinTexture 哈希(参考实现一致/透明归一化/缓存)、 NormalizedSkin(尺寸校验/旧格式转换/slim 三分支)、 Skin JSON 序列化(往返/snake_case/容错) --- PCL.Core.Test/Minecraft/Skin/SkinTest.cs | 201 +++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 PCL.Core.Test/Minecraft/Skin/SkinTest.cs diff --git a/PCL.Core.Test/Minecraft/Skin/SkinTest.cs b/PCL.Core.Test/Minecraft/Skin/SkinTest.cs new file mode 100644 index 0000000000..90b0234517 --- /dev/null +++ b/PCL.Core.Test/Minecraft/Skin/SkinTest.cs @@ -0,0 +1,201 @@ +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.Text.Json.Nodes; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PCL.Core.Minecraft.Skin; +using SkinRecord = PCL.Core.Minecraft.Skin.Skin; + +namespace PCL.Core.Test.Minecraft; + +[TestClass] +public class SkinTest +{ + /// + /// 构造纯色位图。 + /// + private static Bitmap CreateBitmap(int width, int height, int argb) + { + var color = Color.FromArgb(argb); + var bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb); + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + bmp.SetPixel(x, y, color); + return bmp; + } + + // ---- SkinTexture.ComputeHash ---- + + [TestMethod] + public void ComputeHash_MatchesReference() + { + // 1x1 纯色位图:alpha=0xFF, r=0x12, g=0x34, b=0x56 + var bmp = new Bitmap(1, 1, PixelFormat.Format32bppArgb); + bmp.SetPixel(0, 0, Color.FromArgb(0xFF, 0x12, 0x34, 0x56)); + + // 参考实现:宽(4B BE) + 高(4B BE) + a,r,g,b 各 1B + var reference = new byte[12]; + reference[0] = 0; reference[1] = 0; reference[2] = 0; reference[3] = 1; // 宽=1 + reference[4] = 0; reference[5] = 0; reference[6] = 0; reference[7] = 1; // 高=1 + reference[8] = 0xFF; reference[9] = 0x12; reference[10] = 0x34; reference[11] = 0x56; + using var sha = System.Security.Cryptography.SHA256.Create(); + var expected = Convert.ToHexString(sha.ComputeHash(reference)).ToLowerInvariant(); + + Assert.AreEqual(expected, SkinTexture.ComputeHash(bmp)); + } + + [TestMethod] + public void ComputeHash_TransparentPixel_RgbNormalizedToZero() + { + // 两个透明像素(RGB 不同)的哈希必须一致:alpha==0 时 RGB 清零 + var bmpA = new Bitmap(1, 1, PixelFormat.Format32bppArgb); + bmpA.SetPixel(0, 0, Color.FromArgb(0, 0xFF, 0, 0)); // 透明红色 + var bmpB = new Bitmap(1, 1, PixelFormat.Format32bppArgb); + bmpB.SetPixel(0, 0, Color.FromArgb(0, 0, 0, 0xFF)); // 透明蓝色 + + Assert.AreEqual(SkinTexture.ComputeHash(bmpA), SkinTexture.ComputeHash(bmpB)); + } + + [TestMethod] + public void Load_CachesByHash() + { + var bmp = CreateBitmap(64, 64, unchecked((int)0xFF123456)); + var tex1 = SkinTexture.Load(bmp); + var tex2 = SkinTexture.Load(CreateBitmap(64, 64, unchecked((int)0xFF123456))); + + Assert.AreSame(tex1, tex2); // 相同内容命中缓存,返回同一实例 + Assert.AreEqual(tex1.Hash, tex2.Hash); + Assert.IsNotNull(SkinTexture.Get(tex1.Hash)); + Assert.IsNull(SkinTexture.Get("ffff")); // 未命中返回 null + } + + // ---- NormalizedSkin ---- + + [TestMethod] + public void NormalizedSkin_InvalidSize_Throws() + { + Assert.ThrowsExactly(() => new NormalizedSkin(CreateBitmap(32, 32, unchecked((int)0xFF000000)))); + Assert.ThrowsExactly(() => new NormalizedSkin(CreateBitmap(64, 48, unchecked((int)0xFF000000)))); + } + + [TestMethod] + public void NormalizedSkin_NewFormat_64x64_NotOld() + { + var skin = new NormalizedSkin(CreateBitmap(64, 64, unchecked((int)0xFF000000))); + Assert.IsFalse(skin.IsOldFormat); + Assert.AreEqual(1, skin.Scale); + Assert.AreEqual(64, skin.NormalizedTexture.Width); + Assert.AreEqual(64, skin.NormalizedTexture.Height); + } + + [TestMethod] + public void NormalizedSkin_OldFormat_64x32_ConvertedTo64x64() + { + var skin = new NormalizedSkin(CreateBitmap(64, 32, unchecked((int)0xFF000000))); + Assert.IsTrue(skin.IsOldFormat); + Assert.AreEqual(1, skin.Scale); + // 旧格式转换后目标应为 64x64 + Assert.AreEqual(64, skin.NormalizedTexture.Width); + Assert.AreEqual(64, skin.NormalizedTexture.Height); + } + + [TestMethod] + public void NormalizedSkin_IsSlim_TransparentRightArm_ReturnsTrue() + { + // 64x64 皮肤,右臂区域 (50,16)2x4 放一个透明像素 → slim + var bmp = CreateBitmap(64, 64, unchecked((int)unchecked((int)0xFF888888))); + for (var y = 16; y < 20; y++) + for (var x = 50; x < 52; x++) + bmp.SetPixel(x, y, Color.FromArgb(0, 0, 0, 0)); + + Assert.IsTrue(new NormalizedSkin(bmp).IsSlim()); + } + + [TestMethod] + public void NormalizedSkin_IsSlim_SolidSkin_ReturnsFalse() + { + // 全不透明且非黑的皮肤 → 非 slim + var bmp = CreateBitmap(64, 64, unchecked((int)unchecked((int)0xFF888888))); + Assert.IsFalse(new NormalizedSkin(bmp).IsSlim()); + } + + [TestMethod] + public void NormalizedSkin_IsSlim_AllBlackRightArm_ReturnsTrue() + { + // 右臂四区域全黑 → slim(HMCL 的 isAreaBlack 分支) + var bmp = CreateBitmap(64, 64, unchecked((int)unchecked((int)0xFF888888))); + for (var y = 16; y < 20; y++) + for (var x = 50; x < 52; x++) + bmp.SetPixel(x, y, Color.FromArgb(0xFF, 0, 0, 0)); + for (var y = 20; y < 32; y++) + for (var x = 54; x < 56; x++) + bmp.SetPixel(x, y, Color.FromArgb(0xFF, 0, 0, 0)); + for (var y = 48; y < 52; y++) + for (var x = 42; x < 44; x++) + bmp.SetPixel(x, y, Color.FromArgb(0xFF, 0, 0, 0)); + for (var y = 52; y < 64; y++) + for (var x = 46; x < 48; x++) + bmp.SetPixel(x, y, Color.FromArgb(0xFF, 0, 0, 0)); + + Assert.IsTrue(new NormalizedSkin(bmp).IsSlim()); + } + + // ---- Skin 序列化 ---- + + [TestMethod] + public void Skin_WriteRead_RoundTrip() + { + var skin = new SkinRecord(SkinType.LocalFile, null, TextureModel.Slim, @"C:\skin.png", @"C:\cape.png"); + var storage = new JsonObject(); + skin.WriteStorage(storage); + + var restored = SkinRecord.FromStorage(storage); + Assert.IsNotNull(restored); + Assert.AreEqual(SkinType.LocalFile, restored!.Type); + Assert.AreEqual(TextureModel.Slim, restored.Model); + Assert.AreEqual(@"C:\skin.png", restored.LocalSkinPath); + Assert.AreEqual(@"C:\cape.png", restored.LocalCapePath); + Assert.AreEqual("local_file", (string?)storage["type"]); + Assert.AreEqual("slim", (string?)storage["textureModel"]); + } + + [TestMethod] + public void Skin_FromStorage_UnknownType_ReturnsNull() + { + var storage = new JsonObject { ["type"] = "unknown_type" }; + Assert.IsNull(SkinRecord.FromStorage(storage)); + } + + [TestMethod] + public void Skin_FromStorage_MissingSkin_ReturnsNull() + { + Assert.IsNull(SkinRecord.FromStorage(new JsonObject())); + } + + [TestMethod] + public void Skin_FromStorage_NonSlimModelDefaultsToWide() + { + var storage = new JsonObject + { + ["type"] = "local_file", + ["textureModel"] = "wide", + ["localSkinPath"] = @"C:\skin.png" + }; + var skin = SkinRecord.FromStorage(storage); + Assert.IsNotNull(skin); + Assert.AreEqual(TextureModel.Wide, skin!.Model); + } + + [TestMethod] + public void Skin_FromStorage_SnakeCaseTypes() + { + // 验证所有枚举名的 snake_case 往返 + foreach (var type in Enum.GetValues()) + { + var skin = new SkinRecord(type, null, TextureModel.Wide, null, null); + var storage = new JsonObject(); + skin.WriteStorage(storage); + Assert.AreEqual(type, SkinRecord.FromStorage(storage)!.Type, $"往返失败:{type}"); + } + } +} From 8d0b332bf398d0df1cba2a2a2beb9f0ca419e42b Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 17:17:10 +0800 Subject: [PATCH 05/24] =?UTF-8?q?fix(skin):=20=E4=BF=AE=E5=A4=8D=E7=A6=BB?= =?UTF-8?q?=E7=BA=BF=E7=9A=AE=E8=82=A4=E4=B8=8D=E7=94=9F=E6=95=88=E4=B8=8E?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=E5=8A=A0=E8=BD=BD=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 皮肤不生效(根因):HttpServer 拼接 IPv6 前缀时未加方括号(::1), 导致 HttpListener.AddPrefix 抛异常、OfflineSkinServer 构造失败、注入被静默跳过。 - 修复:IPv6 地址用 [::1] 包裹 2. Host 头一致性:注入 javaagent 与 textures 载荷的皮肤 URL 统一改用 127.0.0.1(原 localhost 可能解析为 ::1 导致 Host 头与 HttpListener 前缀不匹配而 404) 3. 头像加载失败:LoadLegacyCustomSkin 对本地文件直接返回原路径, 但 MySkin.Load 依赖路径含 'Skin\\' 子串解析 skinHeadId,导致缓存目录拼错。 - 修复:一律导出到 Cache\\Skin\\{uuid}.png 再返回 4. OfflineSkinDialog 布局:根 Grid 显式左对齐固定宽度,消除偏右 5. 新增 OfflineSkinServerTest(4 项):IPv6 构造回归/元数据/hasJoined/404 --- .../Minecraft/OfflineSkinServerTest.cs | 82 +++++++++++++++++++ PCL.Core/IO/Net/Http/HttpServer.cs | 7 +- PCL.Core/Minecraft/Skin/OfflineSkinServer.cs | 6 +- .../Modules/Minecraft/ModLaunch.cs | 6 +- .../Pages/PageLaunch/OfflineSkinDialog.xaml | 3 +- .../Pages/PageLaunch/PageLaunchLeft.xaml.cs | 10 +-- 6 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 PCL.Core.Test/Minecraft/OfflineSkinServerTest.cs diff --git a/PCL.Core.Test/Minecraft/OfflineSkinServerTest.cs b/PCL.Core.Test/Minecraft/OfflineSkinServerTest.cs new file mode 100644 index 0000000000..d5561f0ef4 --- /dev/null +++ b/PCL.Core.Test/Minecraft/OfflineSkinServerTest.cs @@ -0,0 +1,82 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PCL.Core.Minecraft.Skin; + +namespace PCL.Core.Test.Minecraft; + +[TestClass] +public class OfflineSkinServerTest +{ + /// + /// 回归测试:HttpServer 构造函数必须能为 IPv6 回环地址生成合法的 URI 前缀。 + /// 此前直接拼接 "http://::1:port/" 会在 AddPrefix 时抛 + /// "Only Uri prefixes with a valid hostname are supported",导致离线皮肤注入静默失败。 + /// + [TestMethod] + public void Ctor_Ipv6Loopback_DoesNotThrow() + { + using var server = new OfflineSkinServer(); + Assert.IsTrue(server.Port > 0); + } + + /// + /// 启动后元数据路由可访问,且签名公钥非空。 + /// + [TestMethod] + public async Task Start_MetadataRoute_Reachable() + { + using var server = new OfflineSkinServer(); + server.AddCharacter(Guid.NewGuid(), "test", null); + server.Start(); + + using var client = new HttpClient(); + using var response = await client.GetAsync($"http://127.0.0.1:{server.Port}/"); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadAsStringAsync(); + StringAssert.Contains(body, "signaturePublickey"); + StringAssert.Contains(body, "127.0.0.1"); + StringAssert.Contains(body, "PCL CE"); + } + + /// + /// 注册角色后,hasJoined 能返回带 textures 签名的完整档案。 + /// + [TestMethod] + public async Task Start_HasJoined_ReturnsProfile() + { + using var server = new OfflineSkinServer(); + var uuid = Guid.NewGuid(); + server.AddCharacter(uuid, "Tester", null); + server.Start(); + + using var client = new HttpClient(); + using var response = await client.GetAsync( + $"http://127.0.0.1:{server.Port}/sessionserver/session/minecraft/hasJoined?username=Tester"); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadAsStringAsync(); + StringAssert.Contains(body, uuid.ToString("N")); + StringAssert.Contains(body, "textures"); + StringAssert.Contains(body, "signature"); + } + + /// + /// 未知玩家 hasJoined 返回 404。 + /// + [TestMethod] + public async Task Start_HasJoined_UnknownPlayer_Returns404() + { + using var server = new OfflineSkinServer(); + server.AddCharacter(Guid.NewGuid(), "Tester", null); + server.Start(); + + using var client = new HttpClient(); + using var response = await client.GetAsync( + $"http://127.0.0.1:{server.Port}/sessionserver/session/minecraft/hasJoined?username=Nobody"); + Assert.AreEqual(System.Net.HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/PCL.Core/IO/Net/Http/HttpServer.cs b/PCL.Core/IO/Net/Http/HttpServer.cs index 9ba366bed7..667f342a29 100644 --- a/PCL.Core/IO/Net/Http/HttpServer.cs +++ b/PCL.Core/IO/Net/Http/HttpServer.cs @@ -35,7 +35,12 @@ protected HttpServer(IPAddress[] listenAddr, ushort port = 0) var hosts = new List(); foreach (var address in listenAddr) { - _server.Prefixes.Add($"http://{address}:{port}/"); + // IPv6 地址在 URI host 中必须用方括号包裹(如 [::1]),否则 HttpListener.AddPrefix 抛 + // "Only Uri prefixes with a valid hostname are supported" + var host = address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 + ? $"[{address}]" + : address.ToString(); + _server.Prefixes.Add($"http://{host}:{port}/"); hosts.Add(address.ToString()); } Host = hosts.ToArray(); diff --git a/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs b/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs index e16abdbc8d..dfea2f7973 100644 --- a/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs +++ b/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs @@ -223,7 +223,9 @@ private JsonObject _CreateTexturesPayload(Character character) { var skin = new JsonObject { - ["url"] = $"http://localhost:{Port}/textures/{skinTexture.Hash}" + // 用 127.0.0.1 而非 localhost:HttpListener 对具体 IP 前缀要求 Host 头严格匹配, + // 与 ModLaunch 注入的 javaagent 地址保持一致,避免 Host 头不匹配导致 404 + ["url"] = $"http://127.0.0.1:{Port}/textures/{skinTexture.Hash}" }; if (loadedSkin.Model == TextureModel.Slim) skin["metadata"] = new JsonObject { ["model"] = "slim" }; @@ -233,7 +235,7 @@ private JsonObject _CreateTexturesPayload(Character character) if (loadedSkin?.Cape is { } capeTexture) textures["CAPE"] = new JsonObject { - ["url"] = $"http://localhost:{Port}/textures/{capeTexture.Hash}" + ["url"] = $"http://127.0.0.1:{Port}/textures/{capeTexture.Hash}" }; return new JsonObject diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs index 29d9102528..7381ed7a8e 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs @@ -2789,12 +2789,14 @@ private static void McLaunchOfflineSkinInject(List dataList) mcLaunchOfflineSkinServer ??= new OfflineSkinServer(); mcLaunchOfflineSkinServer.AddCharacter(uuid, profile.Username, loadedSkin); mcLaunchOfflineSkinServer.Start(); - ModBase.Log($"[Launch] 已启动离线皮肤服务器:http://localhost:{mcLaunchOfflineSkinServer.Port}"); + ModBase.Log($"[Launch] 已启动离线皮肤服务器:http://127.0.0.1:{mcLaunchOfflineSkinServer.Port}"); // 注入 authlib-injector javaagent(离线服务器已在线,无需 yggdrasil.prefetched) + // 用 127.0.0.1 而非 localhost:HttpListener 对具体 IP 前缀要求 Host 头严格匹配, + // localhost 可能解析为 ::1 导致 Host 头与监听前缀不一致而 404 dataList.Insert(0, "-javaagent:\"" + injectorPath + "\"=" + - $"http://localhost:{mcLaunchOfflineSkinServer.Port}" + + $"http://127.0.0.1:{mcLaunchOfflineSkinServer.Port}" + " -Dauthlibinjector.side=client"); } catch (Exception ex) diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml index 833dba83a4..69400490fa 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml +++ b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml @@ -8,7 +8,8 @@ ResizeMode="NoResize" ShowInTaskbar="False" Background="{DynamicResource ColorBrushBackground}"> - + diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs index bf42db7a64..6f77f543bb 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs @@ -960,13 +960,9 @@ private static string LoadLegacyCustomSkin(Skin skinConfig, string username) var loadedSkin = ModSkin.LoadSkinAsync(skinConfig, username).GetAwaiter().GetResult(); if (loadedSkin?.Skin is { } skinTexture) { - // 优先直接使用本地皮肤文件路径,避免不必要的临时文件导出 - if (skinConfig.Type == SkinType.LocalFile && - !string.IsNullOrEmpty(skinConfig.LocalSkinPath) && - File.Exists(skinConfig.LocalSkinPath)) - return skinConfig.LocalSkinPath!; - - var tempDir = Path.Combine(ModBase.pathTemp, "Skin", "Avatar"); + // 导出到 Cache\Skin\ 下的临时文件:MySkin.Load 依赖路径含 "Skin\" 子串来解析 skinHeadId, + // 直接返回原始文件路径会因缺少该约定导致头像缓存目录拼错而加载失败 + var tempDir = Path.Combine(ModBase.pathTemp, "Cache", "Skin"); Directory.CreateDirectory(tempDir); var tempPath = Path.Combine(tempDir, $"{ModProfile.selectedProfile.Uuid}.png"); skinTexture.Image.Save(tempPath, System.Drawing.Imaging.ImageFormat.Png); From 2d9d8534a679be56434003324a28f4fced74d7ed Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 18:52:12 +0800 Subject: [PATCH 06/24] =?UTF-8?q?fix(skin):=20=E7=A6=BB=E7=BA=BF=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=AD=98=E6=B4=BB=E5=88=B0?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E9=80=80=E5=87=BA=E8=80=8C=E9=9D=9E=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=B5=81=E7=A8=8B=E7=BB=93=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:服务器在 McLaunchStart 的 finally 于游戏窗口出现后即被销毁, 但皮肤/贴图 URL 在进入世界/第三人称时才被游戏拉取,端口已不通。 - 服务器清理改为挂在游戏进程 Exited 事件(进程退出时 Dispose) - McLaunchStart finally 改为兜底:仅当进程未创建或已退出时清理, 游戏运行中则交给 Exited 事件 - HttpServer.Dispose 幂等化(进程事件与 finally 兜底可能竞态调用) --- PCL.Core/IO/Net/Http/HttpServer.cs | 3 +++ .../Modules/Minecraft/ModLaunch.cs | 25 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/PCL.Core/IO/Net/Http/HttpServer.cs b/PCL.Core/IO/Net/Http/HttpServer.cs index 667f342a29..1655c0e2ed 100644 --- a/PCL.Core/IO/Net/Http/HttpServer.cs +++ b/PCL.Core/IO/Net/Http/HttpServer.cs @@ -18,6 +18,7 @@ public abstract class HttpServer : IDisposable private readonly Dictionary<(HttpMethod method, string path), Func>> _handlers = new(); private readonly Dictionary<(HttpMethod method, string path), Func, Task>> _templateHandlers = new(); private bool _initialized = false; + private bool _disposed = false; protected HttpServer(IPAddress[] listenAddr, ushort port = 0) { @@ -227,6 +228,8 @@ public void Stop() public void Dispose() { GC.SuppressFinalize(this); + if (_disposed) return; // 幂等:重复 Dispose 安全(进程退出事件与启动流程兜底可能竞态调用) + _disposed = true; Stop(); _server.Close(); _cancellationTokenSource?.Dispose(); diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs index 7381ed7a8e..cc488bcfb7 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs @@ -447,11 +447,19 @@ private static void McLaunchStart(ModLoader.LoaderTask } finally { - // 关闭离线皮肤服务器(无论启动成功、失败还是取消,都不应残留监听端口) + // 兜底清理:游戏进程未成功运行(未创建或已退出)时,服务器必须关闭。 + // 若游戏仍在运行,则交给进程 Exited 事件在游戏退出时关闭(皮肤需存活到游戏退出)。 if (mcLaunchOfflineSkinServer is not null) { - mcLaunchOfflineSkinServer.Dispose(); - mcLaunchOfflineSkinServer = null; + var processExited = mcLaunchProcess is null; + if (!processExited) + try { processExited = mcLaunchProcess.HasExited; } + catch { processExited = true; } // 进程对象无效时视为已退出,走清理 + if (processExited) + { + mcLaunchOfflineSkinServer.Dispose(); + mcLaunchOfflineSkinServer = null; + } } } } @@ -3601,6 +3609,17 @@ private static void McLaunchRun(ModLoader.LoaderTask loader) loader.output = gameProcess; mcLaunchProcess = gameProcess; + // 离线皮肤服务器需要存活到游戏退出(皮肤在进入世界/第三人称时才会拉取)。 + // 挂在进程退出事件上,而不是启动流程 finally,否则游戏窗口一出现服务器就被销毁。 + gameProcess.EnableRaisingEvents = true; + gameProcess.Exited += (_, _) => + { + if (mcLaunchOfflineSkinServer is not null) + { + mcLaunchOfflineSkinServer.Dispose(); + mcLaunchOfflineSkinServer = null; + } + }; // 进程优先级处理 try { From c57b9d37672ce155622310086d96ee21018e3ef0 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 19:05:06 +0800 Subject: [PATCH 07/24] =?UTF-8?q?fix(skin):=20=E9=87=8D=E5=86=99=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E8=AE=BE=E7=BD=AE=E5=BC=B9=E7=AA=97=E4=B8=BA=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E4=B8=A4=E6=A0=8F=20Grid=20=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原横向 StackPanel + 固定宽度导致左右错位、按钮被挤压、输入框溢出。 改为:左列头像固定 104px、中列固定间距、右列表单自适应; 输入框+按钮用 *+8+92 三列,按钮固定宽度不被压缩; 所有 label 左对齐,垂直间距统一。 --- .../Pages/PageLaunch/OfflineSkinDialog.xaml | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml index 69400490fa..cd4f48ed3e 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml +++ b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml @@ -3,18 +3,18 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:PCL" Title="{DynamicResource Launch.OfflineSkin.Title}" - Width="470" SizeToContent="Height" + Width="480" SizeToContent="Height" WindowStartupLocation="CenterOwner" ResizeMode="NoResize" ShowInTaskbar="False" Background="{DynamicResource ColorBrushBackground}"> - + - + - + @@ -22,10 +22,17 @@ - - - - + + + + + + + + + - - + + + + + + @@ -57,6 +68,7 @@ + @@ -66,27 +78,31 @@ Tag="Slim" /> + - - + + + - + - - + + + - @@ -106,7 +122,7 @@ Click="BtnOpenLittleSkin_Click" /> - + From c7b63b5d636b030cdfc5c7eb94329471b9a47ff4 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 19:18:11 +0800 Subject: [PATCH 08/24] =?UTF-8?q?fix(skin):=20=E7=A7=BB=E9=99=A4=E5=B7=B2?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E9=A2=84=E8=A7=88=E5=85=83=E7=B4=A0=E7=9A=84?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=BC=95=E7=94=A8,=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E6=97=A0=E6=B3=95=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户调整 OfflineSkinDialog.xaml(移除头像预览列)后,代码后置仍引用 ImgPreviewFace/ImgPreviewHair,导致编译失败、应用无法启动。 - 删除 UpdatePreview/GetPreviewPath 及所有调用 - 字段赋默认值消除可空警告 --- .../Pages/PageLaunch/OfflineSkinDialog.xaml | 157 +++++++----------- .../PageLaunch/OfflineSkinDialog.xaml.cs | 65 +------- 2 files changed, 61 insertions(+), 161 deletions(-) diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml index cd4f48ed3e..7a94f4125f 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml +++ b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml @@ -3,13 +3,12 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:PCL" Title="{DynamicResource Launch.OfflineSkin.Title}" - Width="480" SizeToContent="Height" + SizeToContent="WidthAndHeight" WindowStartupLocation="CenterOwner" ResizeMode="NoResize" ShowInTaskbar="False" Background="{DynamicResource ColorBrushBackground}"> - + @@ -22,107 +21,69 @@ - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + - - - - - + + + + + - - - - - + + + + - + @@ -132,4 +93,4 @@ Margin="12,0,0,0" ColorType="Normal" Click="BtnCancel_Click" /> - + \ No newline at end of file diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs index be9881adfa..f61458601b 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs @@ -14,8 +14,8 @@ namespace PCL; /// public partial class OfflineSkinDialog { - private string _skinPath; - private string _capePath; + private string _skinPath = ""; + private string _capePath = ""; private TextureModel _model = TextureModel.Wide; public OfflineSkinDialog() @@ -66,7 +66,6 @@ private void LoadCurrentSkin() TextCslApi.Text = skin?.CslApi ?? ""; UpdatePanels(); - UpdatePreview(); UpdateConfirmEnabled(); } @@ -89,63 +88,6 @@ private void UpdateConfirmEnabled() Uri.TryCreate(TextCslApi.Text.Trim(), UriKind.Absolute, out _); } - /// - /// 刷新头像预览。 - /// - private void UpdatePreview() - { - try - { - var path = GetPreviewPath(); - if (string.IsNullOrEmpty(path) || !File.Exists(path)) - { - ImgPreviewFace.Source = null; - ImgPreviewHair.Source = null; - return; - } - - var image = new MyBitmap(path); - var scale = Math.Max(1, (int)Math.Round(image.pic.Width / 64d)); - // 脸层 - var face = image.Clip(scale * 8, scale * 8, scale * 8, scale * 8); - // 头发层(仅现代格式 64x64 及以上的皮肤才有) - MyBitmap? hair = null; - if (image.pic.Width >= 64 && image.pic.Height >= 64) - hair = image.Clip(scale * 40, scale * 8, scale * 8, scale * 8); - - ImgPreviewFace.Source = face; - ImgPreviewHair.Source = hair is null ? null : hair; - } - catch (Exception ex) - { - ImgPreviewFace.Source = null; - ImgPreviewHair.Source = null; - ModBase.Log(ex, "刷新皮肤预览失败", ModBase.LogLevel.Developer); - } - } - - private string GetPreviewPath() - { - switch (CurrentType) - { - case SkinType.Default: - case SkinType.Steve: - return ModBase.pathImage + "Skins/Steve.png"; - case SkinType.Alex: - return ModBase.pathImage + "Skins/Alex.png"; - case SkinType.LocalFile: - return _skinPath; - case SkinType.LittleSkin: - case SkinType.CustomSkinLoaderApi: - // 在线皮肤无法离线预览,按当前模型显示默认皮肤占位 - return _model == TextureModel.Slim - ? ModBase.pathImage + "Skins/Alex.png" - : ModBase.pathImage + "Skins/Steve.png"; - default: - return ModBase.pathImage + "Skins/Steve.png"; - } - } - // 选择皮肤文件 private void BtnSelectSkin_Click(object sender, MouseButtonEventArgs e) { @@ -185,7 +127,6 @@ private void BtnSelectSkin_Click(object sender, MouseButtonEventArgs e) _skinPath = fileName; TextSkinPath.Text = fileName; - UpdatePreview(); } // 选择披风文件 @@ -246,14 +187,12 @@ private void BtnCancel_Click(object sender, MouseButtonEventArgs e) private void ComboType_SelectionChanged(object sender, SelectionChangedEventArgs e) { UpdatePanels(); - UpdatePreview(); UpdateConfirmEnabled(); } private void ComboModel_SelectionChanged(object sender, SelectionChangedEventArgs e) { _model = ComboModel.SelectedIndex == 1 ? TextureModel.Slim : TextureModel.Wide; - UpdatePreview(); } private void TextCslApi_TextChanged(object sender, TextChangedEventArgs e) From 271734739b0aa7c12eb0d2d9af9cff92f4d6ecae Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 19:23:52 +0800 Subject: [PATCH 09/24] =?UTF-8?q?chore(skin):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=97=A0=E7=94=A8=E6=B3=A8=E9=87=8A=E4=B8=8E=E6=AD=BB=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs | 13 ------------- .../Pages/PageLaunch/OfflineSkinDialog.xaml.cs | 5 ----- 2 files changed, 18 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs index 3385397d03..e75bd355fd 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs @@ -177,19 +177,6 @@ public static string McSkinSex(string uuid) var c = int.Parse(uuid[23].ToString(), NumberStyles.AllowHexSpecifier); var d = int.Parse(uuid[31].ToString(), NumberStyles.AllowHexSpecifier); return ((a ^ b ^ c ^ d) % 2) != 0 ? "Alex" : "Steve"; - // Math.floorMod(uuid.hashCode(), 18) - - // Public Function hashCode(ByVal str As String) As Integer - // Dim hash As Integer = 0 - // Dim n As Integer = str.Length - // If n = 0 Then - // Return hash - // End If - // For i As Integer = 0 To n - 1 - // hash = hash + Asc(str(i)) * (1 << (n - i - 1)) - // Next - // Return hash - // End Function } /// diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs index f61458601b..00d23c3b38 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs @@ -88,7 +88,6 @@ private void UpdateConfirmEnabled() Uri.TryCreate(TextCslApi.Text.Trim(), UriKind.Absolute, out _); } - // 选择皮肤文件 private void BtnSelectSkin_Click(object sender, MouseButtonEventArgs e) { var fileName = SystemDialogs.SelectFile(Lang.Text("Launch.Skin.FileDialog.Filter"), @@ -129,7 +128,6 @@ private void BtnSelectSkin_Click(object sender, MouseButtonEventArgs e) TextSkinPath.Text = fileName; } - // 选择披风文件 private void BtnSelectCape_Click(object sender, MouseButtonEventArgs e) { var fileName = SystemDialogs.SelectFile(Lang.Text("Launch.Skin.FileDialog.Filter"), @@ -151,13 +149,11 @@ private void BtnSelectCape_Click(object sender, MouseButtonEventArgs e) TextCapePath.Text = fileName; } - // 打开 LittleSkin 官网 private void BtnOpenLittleSkin_Click(object sender, MouseButtonEventArgs e) { ModBase.OpenWebsite("https://littleskin.cn"); } - // 确定:回写皮肤配置并保存 private void BtnConfirm_Click(object sender, MouseButtonEventArgs e) { if (CurrentType == SkinType.CustomSkinLoaderApi && @@ -178,7 +174,6 @@ private void BtnConfirm_Click(object sender, MouseButtonEventArgs e) DialogResult = true; } - // 取消 private void BtnCancel_Click(object sender, MouseButtonEventArgs e) { DialogResult = false; From ee0918740412cce3e9fd6a93cc1b2b64f5a6625f Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 19:52:30 +0800 Subject: [PATCH 10/24] =?UTF-8?q?refactor(skin):=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E7=9A=AE=E8=82=A4=E6=A0=B8=E5=BF=83=E5=BA=93,=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E6=AD=BB=E4=BB=A3=E7=A0=81=E4=B8=8E=E6=89=8B=E5=86=99?= =?UTF-8?q?=E8=BD=AE=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PCL.Core/Minecraft/Skin/NormalizedSkin.cs | 26 ++++++------ PCL.Core/Minecraft/Skin/OfflineSkinServer.cs | 5 ++- PCL.Core/Minecraft/Skin/PixelAccess.cs | 26 ------------ PCL.Core/Minecraft/Skin/Skin.cs | 43 ++++++++------------ PCL.Core/Minecraft/Skin/SkinTexture.cs | 40 ++++++++---------- 5 files changed, 50 insertions(+), 90 deletions(-) diff --git a/PCL.Core/Minecraft/Skin/NormalizedSkin.cs b/PCL.Core/Minecraft/Skin/NormalizedSkin.cs index e87b7efb20..37566e8cc2 100644 --- a/PCL.Core/Minecraft/Skin/NormalizedSkin.cs +++ b/PCL.Core/Minecraft/Skin/NormalizedSkin.cs @@ -68,38 +68,40 @@ public NormalizedSkin(Bitmap texture) /// 纤细模型返回 true,经典模型返回 false public bool IsSlim() { - return HasTransparency(50, 16, 2, 4) - || HasTransparency(54, 20, 2, 12) - || HasTransparency(42, 48, 2, 4) - || HasTransparency(46, 52, 2, 12) - || (IsAreaBlack(50, 16, 2, 4) - && IsAreaBlack(54, 20, 2, 12) - && IsAreaBlack(42, 48, 2, 4) - && IsAreaBlack(46, 52, 2, 12)); + // 统一加锁一次,循环内批量采样,避免逐像素 LockBits/UnlockBits + using var accessor = PixelAccess.Lock(NormalizedTexture, ImageLockMode.ReadOnly); + return HasTransparency(accessor, 50, 16, 2, 4) + || HasTransparency(accessor, 54, 20, 2, 12) + || HasTransparency(accessor, 42, 48, 2, 4) + || HasTransparency(accessor, 46, 52, 2, 12) + || (IsAreaBlack(accessor, 50, 16, 2, 4) + && IsAreaBlack(accessor, 54, 20, 2, 12) + && IsAreaBlack(accessor, 42, 48, 2, 4) + && IsAreaBlack(accessor, 46, 52, 2, 12)); } - private bool HasTransparency(int x0, int y0, int width, int height) + private bool HasTransparency(PixelAccessor accessor, int x0, int y0, int width, int height) { var s = Scale; for (var y = y0 * s; y < (y0 + height) * s; y++) { for (var x = x0 * s; x < (x0 + width) * s; x++) { - if (((PixelAccess.GetPixel(NormalizedTexture, x, y) >> 24) & 0xff) != 0xff) + if (((accessor.GetPixel(x, y) >> 24) & 0xff) != 0xff) return true; } } return false; } - private bool IsAreaBlack(int x0, int y0, int width, int height) + private bool IsAreaBlack(PixelAccessor accessor, int x0, int y0, int width, int height) { var s = Scale; for (var y = y0 * s; y < (y0 + height) * s; y++) { for (var x = x0 * s; x < (x0 + width) * s; x++) { - if ((uint)PixelAccess.GetPixel(NormalizedTexture, x, y) != 0xff000000u) + if ((uint)accessor.GetPixel(x, y) != 0xff000000u) return false; } } diff --git a/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs b/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs index dfea2f7973..f2f6bfd2b5 100644 --- a/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs +++ b/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Drawing.Imaging; using System.IO; using System.Net; @@ -247,7 +248,7 @@ private JsonObject _CreateTexturesPayload(Character character) }; } - private bool _TryGetByName(string name, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Character character) + private bool _TryGetByName(string name, [NotNullWhen(true)] out Character character) { lock (_syncRoot) { @@ -261,7 +262,7 @@ private bool _TryGetByName(string name, [System.Diagnostics.CodeAnalysis.NotNull } } - private bool _TryGetByUuid(Guid uuid, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Character character) + private bool _TryGetByUuid(Guid uuid, [NotNullWhen(true)] out Character character) { lock (_syncRoot) { diff --git a/PCL.Core/Minecraft/Skin/PixelAccess.cs b/PCL.Core/Minecraft/Skin/PixelAccess.cs index b7cabf114c..f80fa27574 100644 --- a/PCL.Core/Minecraft/Skin/PixelAccess.cs +++ b/PCL.Core/Minecraft/Skin/PixelAccess.cs @@ -9,32 +9,6 @@ namespace PCL.Core.Minecraft.Skin; /// internal static class PixelAccess { - /// - /// 读取指定像素的 ARGB 值。单次调用会对整张位图加锁/解锁,适合少量采样。 - /// - /// 目标位图。 - /// 像素 X 坐标。 - /// 像素 Y 坐标。 - /// 像素的 ARGB 值(alpha 在高位)。 - public static int GetPixel(Bitmap bitmap, int x, int y) - { - using var accessor = Lock(bitmap, ImageLockMode.ReadOnly); - return accessor.GetPixel(x, y); - } - - /// - /// 写入指定像素的 ARGB 值。单次调用会对整张位图加锁/解锁,适合少量写入。 - /// - /// 目标位图。 - /// 像素 X 坐标。 - /// 像素 Y 坐标。 - /// 要写入的 ARGB 值(alpha 在高位)。 - public static void SetPixel(Bitmap bitmap, int x, int y, int argb) - { - using var accessor = Lock(bitmap, ImageLockMode.ReadWrite); - accessor.SetPixel(x, y, argb); - } - /// /// 锁定位图以获得批量访问句柄。使用完毕后应释放句柄以解锁位图。 /// diff --git a/PCL.Core/Minecraft/Skin/Skin.cs b/PCL.Core/Minecraft/Skin/Skin.cs index a634e86718..cd188eee36 100644 --- a/PCL.Core/Minecraft/Skin/Skin.cs +++ b/PCL.Core/Minecraft/Skin/Skin.cs @@ -1,5 +1,6 @@ using System; -using System.Text; +using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Nodes; namespace PCL.Core.Minecraft.Skin; @@ -24,6 +25,19 @@ public sealed record Skin( /// public bool IsSlim => Model == TextureModel.Slim; + /// + /// 存储键(snake_case,忽略大小写)到皮肤类型的映射。 + /// + private static readonly Dictionary TypeByStorageKey = CreateTypeByStorageKey(); + + private static Dictionary CreateTypeByStorageKey() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var value in Enum.GetValues()) + map[JsonNamingPolicy.SnakeCaseLower.ConvertName(value.ToString())] = value; + return map; + } + /// /// 从存储 JSON 中反序列化皮肤配置。 /// @@ -65,7 +79,7 @@ public sealed record Skin( /// 已存在的存储对象,键会写入其中。 public void WriteStorage(JsonObject storage) { - storage["type"] = TypeToSnakeCase(Type); + storage["type"] = JsonNamingPolicy.SnakeCaseLower.ConvertName(Type.ToString()); storage["cslApi"] = CslApi; storage["textureModel"] = IsSlim ? "slim" : "wide"; storage["localSkinPath"] = LocalSkinPath; @@ -80,31 +94,8 @@ public void WriteStorage(JsonObject storage) throw new InvalidOperationException($"字段 {key} 的类型不是字符串。"); } - private static string TypeToSnakeCase(SkinType type) => ToSnakeCase(type.ToString()); - - private static string ToSnakeCase(string name) - { - var sb = new StringBuilder(name.Length + 4); - foreach (var c in name) - { - if (char.IsUpper(c) && sb.Length > 0) - sb.Append('_'); - sb.Append(char.ToLowerInvariant(c)); - } - return sb.ToString(); - } - private static bool TryParseType(string text, out SkinType type) { - foreach (var candidate in Enum.GetValues()) - { - if (string.Equals(TypeToSnakeCase(candidate), text, StringComparison.OrdinalIgnoreCase)) - { - type = candidate; - return true; - } - } - type = default; - return false; + return TypeByStorageKey.TryGetValue(text, out type); } } diff --git a/PCL.Core/Minecraft/Skin/SkinTexture.cs b/PCL.Core/Minecraft/Skin/SkinTexture.cs index 2305f9db8c..69a1080340 100644 --- a/PCL.Core/Minecraft/Skin/SkinTexture.cs +++ b/PCL.Core/Minecraft/Skin/SkinTexture.cs @@ -72,46 +72,38 @@ public static SkinTexture Load(Bitmap image) /// 小写十六进制哈希字符串。 public static string ComputeHash(Bitmap image) { - using var sha256 = SHA256.Create(); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - var header = new byte[8]; + Span header = stackalloc byte[8]; BinaryPrimitives.WriteInt32BigEndian(header, image.Width); - BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), image.Height); - sha256.TransformBlock(header, 0, header.Length, null, 0); + BinaryPrimitives.WriteInt32BigEndian(header.Slice(4), image.Height); + hash.AppendData(header); using var accessor = PixelAccess.Lock(image, ImageLockMode.ReadOnly); - var buffer = new byte[4096]; - var bufferIndex = 0; + Span pixel = stackalloc byte[4]; for (var y = 0; y < accessor.Height; y++) { for (var x = 0; x < accessor.Width; x++) { - var pixel = accessor.GetPixel(x, y); - var alpha = (byte)(pixel >> 24); - var red = (byte)(pixel >> 16); - var green = (byte)(pixel >> 8); - var blue = (byte)pixel; + var argb = accessor.GetPixel(x, y); + var alpha = (byte)(argb >> 24); + var red = (byte)(argb >> 16); + var green = (byte)(argb >> 8); + var blue = (byte)argb; if (alpha == 0) { red = 0; green = 0; blue = 0; } - buffer[bufferIndex++] = alpha; - buffer[bufferIndex++] = red; - buffer[bufferIndex++] = green; - buffer[bufferIndex++] = blue; - if (bufferIndex == buffer.Length) - { - sha256.TransformBlock(buffer, 0, buffer.Length, null, 0); - bufferIndex = 0; - } + pixel[0] = alpha; + pixel[1] = red; + pixel[2] = green; + pixel[3] = blue; + hash.AppendData(pixel); } } - if (bufferIndex > 0) - sha256.TransformBlock(buffer, 0, bufferIndex, null, 0); - sha256.TransformFinalBlock(Array.Empty(), 0, 0); - return Convert.ToHexString(sha256.Hash!).ToLowerInvariant(); + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); } } From 01cd3e0b6084398d6451829ea0f1d08e17dcdc5e Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 19:52:53 +0800 Subject: [PATCH 11/24] =?UTF-8?q?fix(skin):=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E5=BC=80=E6=97=B6=E7=A6=BB=E7=BA=BF=E7=9A=AE=E8=82=A4=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E8=A2=AB=E8=AF=AF=E5=85=B3=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PCL.Core/IO/Net/Http/HttpServer.cs | 7 ++++++- Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/PCL.Core/IO/Net/Http/HttpServer.cs b/PCL.Core/IO/Net/Http/HttpServer.cs index 1655c0e2ed..95b3566bc0 100644 --- a/PCL.Core/IO/Net/Http/HttpServer.cs +++ b/PCL.Core/IO/Net/Http/HttpServer.cs @@ -18,6 +18,7 @@ public abstract class HttpServer : IDisposable private readonly Dictionary<(HttpMethod method, string path), Func>> _handlers = new(); private readonly Dictionary<(HttpMethod method, string path), Func, Task>> _templateHandlers = new(); private bool _initialized = false; + private bool _started = false; private bool _disposed = false; protected HttpServer(IPAddress[] listenAddr, ushort port = 0) @@ -83,10 +84,13 @@ protected void RegisterWithParams(HttpMethod method, string pathTemplate, Func - /// 启动 HTTP 服务器。 + /// 启动 HTTP 服务器。重复调用安全,已启动时直接返回。 /// public void Start() { + // 重复调用安全:上一局游戏未退出时再次启动会复用同一服务器 + if (_started) return; + // 若未注册任何路由(精确或模板),调用 Init 初始化。检查两者确保子类若在 Start 前 // 通过 Register 注册了精确路由、而 Init 里只注册模板路由时,模板路由也不会被跳过。 if (!_initialized && _handlers.Count == 0 && _templateHandlers.Count == 0) @@ -97,6 +101,7 @@ public void Start() _cancellationTokenSource = new CancellationTokenSource(); _server.Start(); + _started = true; _handleLoop = _HandleRequestAsync(); } diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs index cc488bcfb7..561b6a9a75 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs @@ -3614,7 +3614,9 @@ private static void McLaunchRun(ModLoader.LoaderTask loader) gameProcess.EnableRaisingEvents = true; gameProcess.Exited += (_, _) => { - if (mcLaunchOfflineSkinServer is not null) + // 仅当退出的正是当前记录的游戏进程时才关闭服务器: + // 多开时先退出的游戏不能关掉仍在服务后一局游戏的服务器 + if (ReferenceEquals(mcLaunchProcess, gameProcess) && mcLaunchOfflineSkinServer is not null) { mcLaunchOfflineSkinServer.Dispose(); mcLaunchOfflineSkinServer = null; From 8213d0d129f7d082199410bda24780e57209e4d8 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 19:52:58 +0800 Subject: [PATCH 12/24] =?UTF-8?q?refactor(skin):=20=E7=A7=BB=E9=99=A4=20CS?= =?UTF-8?q?L=20=E8=B4=B4=E5=9B=BE=E4=B8=8B=E8=BD=BD=E6=97=A0=E6=95=88?= =?UTF-8?q?=E7=9A=84=E7=BC=93=E5=AD=98=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs index e75bd355fd..b621aabe38 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs @@ -290,7 +290,7 @@ private static LoadedSkin LoadBuiltin(string name, TextureModel model) /// /// 下载并加载指定哈希的 CSL 皮肤贴图。 - /// 贴图先暂存到 下的 Skin 文件夹(已存在则直接复用,不重复下载), + /// 贴图先暂存到 下的 Skin 文件夹, /// 读取完成后删除临时文件。 /// /// Custom Skin Loader API 地址。 @@ -304,8 +304,7 @@ private static LoadedSkin LoadBuiltin(string name, TextureModel model) try { Directory.CreateDirectory(directory); - if (!File.Exists(tempPath)) - await FileDownloader.DownloadAsync(url, tempPath).ConfigureAwait(false); + await FileDownloader.DownloadAsync(url, tempPath).ConfigureAwait(false); var bitmap = await Task.Run(() => new MyBitmap(tempPath).pic).ConfigureAwait(false); return SkinTexture.Load(bitmap); From 6da5f66f1ef9ea657136a23f3329d60209d03e9c Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sat, 8 Aug 2026 19:53:06 +0800 Subject: [PATCH 13/24] =?UTF-8?q?test(skin):=20=E4=BF=AE=E6=AD=A3=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E4=B8=AD=E5=86=97=E4=BD=99=E7=9A=84=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=20unchecked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PCL.Core.Test/Minecraft/Skin/SkinTest.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PCL.Core.Test/Minecraft/Skin/SkinTest.cs b/PCL.Core.Test/Minecraft/Skin/SkinTest.cs index 90b0234517..68ce09f9cb 100644 --- a/PCL.Core.Test/Minecraft/Skin/SkinTest.cs +++ b/PCL.Core.Test/Minecraft/Skin/SkinTest.cs @@ -103,7 +103,7 @@ public void NormalizedSkin_OldFormat_64x32_ConvertedTo64x64() public void NormalizedSkin_IsSlim_TransparentRightArm_ReturnsTrue() { // 64x64 皮肤,右臂区域 (50,16)2x4 放一个透明像素 → slim - var bmp = CreateBitmap(64, 64, unchecked((int)unchecked((int)0xFF888888))); + var bmp = CreateBitmap(64, 64, unchecked((int)0xFF888888)); for (var y = 16; y < 20; y++) for (var x = 50; x < 52; x++) bmp.SetPixel(x, y, Color.FromArgb(0, 0, 0, 0)); @@ -115,7 +115,7 @@ public void NormalizedSkin_IsSlim_TransparentRightArm_ReturnsTrue() public void NormalizedSkin_IsSlim_SolidSkin_ReturnsFalse() { // 全不透明且非黑的皮肤 → 非 slim - var bmp = CreateBitmap(64, 64, unchecked((int)unchecked((int)0xFF888888))); + var bmp = CreateBitmap(64, 64, unchecked((int)0xFF888888)); Assert.IsFalse(new NormalizedSkin(bmp).IsSlim()); } @@ -123,7 +123,7 @@ public void NormalizedSkin_IsSlim_SolidSkin_ReturnsFalse() public void NormalizedSkin_IsSlim_AllBlackRightArm_ReturnsTrue() { // 右臂四区域全黑 → slim(HMCL 的 isAreaBlack 分支) - var bmp = CreateBitmap(64, 64, unchecked((int)unchecked((int)0xFF888888))); + var bmp = CreateBitmap(64, 64, unchecked((int)0xFF888888)); for (var y = 16; y < 20; y++) for (var x = 50; x < 52; x++) bmp.SetPixel(x, y, Color.FromArgb(0xFF, 0, 0, 0)); From 2150c4b60ab11a7dfd428b7abe840abc28e899b8 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 9 Aug 2026 15:05:08 +0800 Subject: [PATCH 14/24] =?UTF-8?q?feat(skin):=20=E6=B7=BB=E5=8A=A0=E7=A6=BB?= =?UTF-8?q?=E7=BA=BF=E7=9A=AE=E8=82=A4=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=B9=B6=E9=87=8D=E6=9E=84=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plain Craft Launcher 2/Modules/ModMain.cs | 1 + .../Pages/PageLaunch/OfflineSkinDialog.xaml | 96 ------------------- .../Pages/PageLaunch/PageLaunchLeft.xaml.cs | 19 +++- .../PageLaunch/PageLoginOfflineSkin.xaml | 69 +++++++++++++ ...g.xaml.cs => PageLoginOfflineSkin.xaml.cs} | 19 ++-- .../PageLaunch/PageLoginProfileSkin.xaml.cs | 19 +--- 6 files changed, 102 insertions(+), 121 deletions(-) delete mode 100644 Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml create mode 100644 Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml rename Plain Craft Launcher 2/Pages/PageLaunch/{OfflineSkinDialog.xaml.cs => PageLoginOfflineSkin.xaml.cs} (93%) diff --git a/Plain Craft Launcher 2/Modules/ModMain.cs b/Plain Craft Launcher 2/Modules/ModMain.cs index c1ecd882a1..9ea9c44eb5 100644 --- a/Plain Craft Launcher 2/Modules/ModMain.cs +++ b/Plain Craft Launcher 2/Modules/ModMain.cs @@ -71,6 +71,7 @@ public static class ModMain public static PageLoginProfile? frmLoginProfile; public static PageLoginProfileSkin? frmLoginProfileSkin; public static PageLoginOffline? frmLoginOffline; + public static PageLoginOfflineSkin? frmLoginOfflineSkin; public static PageInstanceLeft? frmInstanceLeft; public static PageInstanceOverall? frmInstanceOverall; public static PageInstanceCompResource? frmInstanceMod; diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml deleted file mode 100644 index 7a94f4125f..0000000000 --- a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs index 6f77f543bb..6ac6a52568 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs @@ -669,7 +669,8 @@ private enum PageType Ms, Profile, ProfileSkin, - Offline + Offline, + OfflineSkin } /// @@ -711,6 +712,12 @@ private object PageGet(PageType type) ModMain.frmLoginOffline = new PageLoginOffline(); return ModMain.frmLoginOffline; } + case PageType.OfflineSkin: + { + if (ModMain.frmLoginOfflineSkin is null) + ModMain.frmLoginOfflineSkin = new PageLoginOfflineSkin(); + return ModMain.frmLoginOfflineSkin; + } default: { @@ -822,6 +829,16 @@ public void RefreshPage(bool anim, ModLaunch.McLoginType targetLoginType = defau PageChange(type, anim); } + /// + /// 切换到离线皮肤设置页面,并刷新页面内容。 + /// + public void PageChangeToOfflineSkin() + { + var page = (PageLoginOfflineSkin)PageGet(PageType.OfflineSkin); + PageChange(PageType.OfflineSkin, true); + page.Reload(); + } + #endregion #region 皮肤 diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml new file mode 100644 index 0000000000..16f5ed9efa --- /dev/null +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml.cs similarity index 93% rename from Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs rename to Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml.cs index 00d23c3b38..e5427c8808 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/OfflineSkinDialog.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml.cs @@ -1,5 +1,3 @@ -using System; -using System.IO; using System.Windows; using System.Windows.Controls; using System.Windows.Input; @@ -10,18 +8,18 @@ namespace PCL; /// -/// 离线账户的皮肤设置对话框,参照 HMCL 的 OfflineAccountSkinPane 实现。 +/// 离线账户的皮肤设置页面,参照 HMCL 的 OfflineAccountSkinPane 实现。 /// -public partial class OfflineSkinDialog +public partial class PageLoginOfflineSkin { private string _skinPath = ""; private string _capePath = ""; private TextureModel _model = TextureModel.Wide; - public OfflineSkinDialog() + public PageLoginOfflineSkin() { InitializeComponent(); - Loaded += (_, _) => LoadCurrentSkin(); + Loaded += (_, _) => Reload(); } /// @@ -50,9 +48,9 @@ private static int FindComboIndex(ComboBox combo, string tag) } /// - /// 打开时回显当前档案已保存的皮肤配置。 + /// 回显当前档案已保存的皮肤配置。 /// - private void LoadCurrentSkin() + public void Reload() { var skin = ModProfile.selectedProfile?.Skin; _model = skin?.Model ?? TextureModel.Wide; @@ -171,12 +169,13 @@ private void BtnConfirm_Click(object sender, MouseButtonEventArgs e) ModProfile.selectedProfile.Skin = skin; ModProfile.SaveProfile(); HintService.Hint(Lang.Text("Launch.OfflineSkin.Saved"), HintType.Success); - DialogResult = true; + ModMain.frmLoginProfileSkin?.Reload(); + ModMain.frmLaunchLeft.RefreshPage(true); } private void BtnCancel_Click(object sender, MouseButtonEventArgs e) { - DialogResult = false; + ModMain.frmLaunchLeft.RefreshPage(true); } private void ComboType_SelectionChanged(object sender, SelectionChangedEventArgs e) diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginProfileSkin.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginProfileSkin.xaml.cs index 47e1f4cb48..c60efe280e 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginProfileSkin.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginProfileSkin.xaml.cs @@ -128,7 +128,7 @@ private void Skin_Click(object sender, RoutedEventArgs e) ModBase.OpenWebsite(ModProfile.selectedProfile.Server.BeforeFirst("api/yggdrasil/authserver") + "user/closet"); else - OpenOfflineSkinDialog(); + OpenOfflineSkinPage(); } // 保存皮肤 @@ -152,23 +152,14 @@ private void BtnSkinCape_Click(object sender, RoutedEventArgs e) ModBase.OpenWebsite(ModProfile.selectedProfile.Server.BeforeFirst("api/yggdrasil/authserver") + "user/closet"); else - OpenOfflineSkinDialog(); + OpenOfflineSkinPage(); } #endregion - // 打开离线皮肤设置对话框 - private void OpenOfflineSkinDialog() + // 打开离线皮肤设置页面 + private void OpenOfflineSkinPage() { - ModBase.RunInUi(() => - { - var dialog = new OfflineSkinDialog { Owner = ModMain.frmMain }; - if (dialog.ShowDialog() == true) - { - // 刷新档案界面显示新皮肤 - ModMain.frmLoginProfileSkin?.Reload(); - ModMain.frmLaunchLeft.RefreshPage(true); - } - }); + ModBase.RunInUi(() => ModMain.frmLaunchLeft.PageChangeToOfflineSkin()); } } \ No newline at end of file From c719d8059caf24a53329e187b313859bacec41ae Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 9 Aug 2026 15:14:45 +0800 Subject: [PATCH 15/24] =?UTF-8?q?ui(skin):=20=E6=9C=AC=E5=9C=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=80=89=E6=8B=A9=E6=8C=89=E9=92=AE=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=9C=B0=E5=9D=80=E5=8F=B3=E4=BE=A7=E7=9A=84?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PageLaunch/PageLoginOfflineSkin.xaml | 26 ++++++++++++++----- .../PageLaunch/PageLoginOfflineSkin.xaml.cs | 4 +-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml index 16f5ed9efa..506fc997fa 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml @@ -29,15 +29,29 @@ - - + + + + + + + + - - + + + + + + + + diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml.cs index e5427c8808..cc3773ef05 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml.cs @@ -86,7 +86,7 @@ private void UpdateConfirmEnabled() Uri.TryCreate(TextCslApi.Text.Trim(), UriKind.Absolute, out _); } - private void BtnSelectSkin_Click(object sender, MouseButtonEventArgs e) + private void BtnSelectSkin_Click(object sender, EventArgs e) { var fileName = SystemDialogs.SelectFile(Lang.Text("Launch.Skin.FileDialog.Filter"), Lang.Text("Launch.Skin.FileDialog.Title")); @@ -126,7 +126,7 @@ private void BtnSelectSkin_Click(object sender, MouseButtonEventArgs e) TextSkinPath.Text = fileName; } - private void BtnSelectCape_Click(object sender, MouseButtonEventArgs e) + private void BtnSelectCape_Click(object sender, EventArgs e) { var fileName = SystemDialogs.SelectFile(Lang.Text("Launch.Skin.FileDialog.Filter"), Lang.Text("Launch.Skin.FileDialog.Title")); From 6d19d9b1e530f613867f9747a2c9547da5e5f3ae Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 9 Aug 2026 15:14:49 +0800 Subject: [PATCH 16/24] =?UTF-8?q?ui(skin):=20=E7=9A=AE=E8=82=A4=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E7=A1=AE=E5=AE=9A=E4=B8=8E=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E5=B1=85=E4=B8=AD=E6=8E=92=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Pages/PageLaunch/PageLoginOfflineSkin.xaml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml index 506fc997fa..4ac39f31e1 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml @@ -71,13 +71,11 @@ - - - - + + + + From 4ab5426025c380f984976ea3859d9076f345f12c Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 9 Aug 2026 15:14:53 +0800 Subject: [PATCH 17/24] =?UTF-8?q?ui(skin):=20LittleSkin=20=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF"=E6=89=93=E5=BC=80=E5=AE=98=E7=BD=91"=E6=96=87?= =?UTF-8?q?=E6=A1=88=E6=94=B9=E4=B8=BA"=E6=89=93=E5=BC=80=E5=85=B6?= =?UTF-8?q?=E5=AE=98=E7=BD=91"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PCL.Core/App/Localization/Languages/zh-CN.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PCL.Core/App/Localization/Languages/zh-CN.xaml b/PCL.Core/App/Localization/Languages/zh-CN.xaml index 902a73c7e8..8f0b15d25e 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -1687,7 +1687,7 @@ 选择披风文件… CSL API 地址 LittleSkin 为 Minecraft 玩家提供皮肤托管服务。 - 打开 LittleSkin 官网 + 打开其官网 确定 取消 皮肤已保存 From 015d661176210ba18d48445d850e6874d6996754 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 9 Aug 2026 15:16:58 +0800 Subject: [PATCH 18/24] =?UTF-8?q?ui(skin):=20=E7=9A=AE=E8=82=A4=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E6=96=87=E5=AD=97=E6=8C=89=E9=92=AE=E9=AB=98?= =?UTF-8?q?=E5=BA=A6=E7=BB=9F=E4=B8=80=E4=B8=BA=2035=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=8C=A4=E5=8E=8B=E6=96=87=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Pages/PageLaunch/PageLoginOfflineSkin.xaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml index 4ac39f31e1..6e21a4a4ce 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml @@ -65,16 +65,16 @@ - - - From f08a2f3ca2b0788a6906ea94bac68e867f9c6557 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Sun, 9 Aug 2026 15:28:49 +0800 Subject: [PATCH 19/24] =?UTF-8?q?ui(skin):=20=E6=96=87=E4=BB=B6=E5=A4=B9?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E6=8C=89=E9=92=AE=E5=8C=85=E8=A3=B9=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E7=9A=84=E5=9C=86=E8=A7=92=E6=AD=A3=E6=96=B9=E5=BD=A2?= =?UTF-8?q?=E8=83=8C=E6=99=AF=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PageLaunch/PageLoginOfflineSkin.xaml | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml index 6e21a4a4ce..877d88cdf4 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml @@ -35,9 +35,13 @@ - + + + - + + + From 67a8fb503ab60ebdf3907270eaaa60bd85139f59 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Tue, 11 Aug 2026 21:24:39 +0800 Subject: [PATCH 20/24] =?UTF-8?q?ui(skin):=20=E4=BF=AE=E6=94=B9=20LittleSk?= =?UTF-8?q?in=20=E9=9D=A2=E6=9D=BF"=E6=89=93=E5=BC=80=E5=85=B6=E5=AE=98?= =?UTF-8?q?=E7=BD=91"=E6=96=87=E6=A1=88=E4=B8=BA"=E6=89=93=E5=BC=80?= =?UTF-8?q?=E5=AE=98=E7=BD=91"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PCL.Core/App/Localization/Languages/zh-CN.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PCL.Core/App/Localization/Languages/zh-CN.xaml b/PCL.Core/App/Localization/Languages/zh-CN.xaml index 8f0b15d25e..354f0bf6a7 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -1687,7 +1687,7 @@ 选择披风文件… CSL API 地址 LittleSkin 为 Minecraft 玩家提供皮肤托管服务。 - 打开其官网 + 打开官网 确定 取消 皮肤已保存 From af1a9c4aa28b1a249f1b06ac5d9613da102570dd Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Tue, 11 Aug 2026 22:10:56 +0800 Subject: [PATCH 21/24] =?UTF-8?q?ui(skin):=20=E9=87=8D=E6=9E=84=20PageLogi?= =?UTF-8?q?nOfflineSkin=20=E7=95=8C=E9=9D=A2=E5=B8=83=E5=B1=80=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=9A=AE=E8=82=A4=E7=B1=BB=E5=9E=8B=E5=92=8C?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=96=87=E4=BB=B6=E9=80=89=E9=A1=B9=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PageLaunch/PageLoginOfflineSkin.xaml | 182 ++++++++++-------- 1 file changed, 102 insertions(+), 80 deletions(-) diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml index 877d88cdf4..dc5ae3621e 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml @@ -4,86 +4,108 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:PCL" - mc:Ignorable="d" d:DesignWidth="317.6" Margin="0,0,0,-2"> - - - - - - - - - - + mc:Ignorable="d" d:DesignWidth="317.6" Margin="0,0,0,-2" Grid.IsSharedSizeScope="True"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From fc4aec2c3d21b2f6b02ceb67babba5296bd48bb1 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Tue, 11 Aug 2026 22:13:34 +0800 Subject: [PATCH 22/24] =?UTF-8?q?ui(skin):=20=E5=B0=86=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E4=BB=8E=20Grid=20=E6=94=B9=E4=B8=BA=20Stack?= =?UTF-8?q?Panel=EF=BC=8C=E4=BC=98=E5=8C=96=E6=8C=89=E9=92=AE=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E5=92=8C=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Pages/PageLaunch/PageLoginOfflineSkin.xaml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml index dc5ae3621e..4f0d3433f3 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml @@ -100,12 +100,10 @@ Click="BtnOpenLittleSkin_Click" /> - - - - + + + + From 36ef7a282fca2b19dc32b8539730d3fa50fe1882 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Wed, 12 Aug 2026 09:39:14 +0800 Subject: [PATCH 23/24] =?UTF-8?q?ui(skin):=20=E8=B0=83=E6=95=B4=E8=BE=B9?= =?UTF-8?q?=E6=A1=86=E5=9C=86=E8=A7=92=E4=B8=BA2=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=95=8C=E9=9D=A2=E5=85=83=E7=B4=A0=E7=9A=84=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Pages/PageLaunch/PageLoginOfflineSkin.xaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml index 4f0d3433f3..c53a562103 100644 --- a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml +++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml @@ -56,7 +56,7 @@ - - Date: Thu, 13 Aug 2026 18:20:26 +0800 Subject: [PATCH 24/24] fix: update PCL.Core/App/Localization/Languages/en-US.xaml Co-authored-by: ChilovenBustiangle <116699482+Chiloven945@users.noreply.github.com> --- PCL.Core/App/Localization/Languages/en-US.xaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml index 7d147340b9..90d8bcfb55 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -1671,7 +1671,7 @@ Skin Settings - Skin Type + Type Default Steve Alex @@ -1681,9 +1681,9 @@ Model Steve (wide arms) Alex (slim arms) - Skin file + Skin Select skin file... - Cape file + Cape Select cape file... CSL API URL LittleSkin provides skin hosting for Minecraft players.