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.Test/Minecraft/Skin/SkinTest.cs b/PCL.Core.Test/Minecraft/Skin/SkinTest.cs
new file mode 100644
index 0000000000..68ce09f9cb
--- /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)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)0xFF888888));
+ Assert.IsFalse(new NormalizedSkin(bmp).IsSlim());
+ }
+
+ [TestMethod]
+ public void NormalizedSkin_IsSlim_AllBlackRightArm_ReturnsTrue()
+ {
+ // 右臂四区域全黑 → slim(HMCL 的 isAreaBlack 分支)
+ 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));
+ 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}");
+ }
+ }
+}
diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml
index c090e7a13e..90d8bcfb55 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
+ Type
+ Default
+ Steve
+ Alex
+ Local file
+ LittleSkin
+ CSL API
+ Model
+ Steve (wide arms)
+ Alex (slim arms)
+ Skin
+ Select skin file...
+ Cape
+ 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..354f0bf6a7 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 玩家提供皮肤托管服务。
+ 打开官网
+ 确定
+ 取消
+ 皮肤已保存
+
更改披风失败
diff --git a/PCL.Core/IO/Net/Http/HttpServer.cs b/PCL.Core/IO/Net/Http/HttpServer.cs
index 53b5405586..95b3566bc0 100644
--- a/PCL.Core/IO/Net/Http/HttpServer.cs
+++ b/PCL.Core/IO/Net/Http/HttpServer.cs
@@ -16,7 +16,10 @@ 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;
+ private bool _started = false;
+ private bool _disposed = false;
protected HttpServer(IPAddress[] listenAddr, ushort port = 0)
{
@@ -34,7 +37,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();
@@ -61,12 +69,31 @@ protected void Register(HttpMethod method, string path, Func
- /// 启动 HTTP 服务器。
+ /// 注册一个带路径参数的路由处理器。
+ ///
+ /// 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 初始化
- if (!_initialized && _handlers.Count == 0)
+ // 重复调用安全:上一局游戏未退出时再次启动会复用同一服务器
+ if (_started) return;
+
+ // 若未注册任何路由(精确或模板),调用 Init 初始化。检查两者确保子类若在 Start 前
+ // 通过 Register 注册了精确路由、而 Init 里只注册模板路由时,模板路由也不会被跳过。
+ if (!_initialized && _handlers.Count == 0 && _templateHandlers.Count == 0)
{
Init();
_initialized = true;
@@ -74,6 +101,7 @@ public void Start()
_cancellationTokenSource = new CancellationTokenSource();
_server.Start();
+ _started = true;
_handleLoop = _HandleRequestAsync();
}
@@ -110,6 +138,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 +171,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 +198,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 服务器。
///
@@ -163,6 +233,8 @@ public void Stop()
public void Dispose()
{
GC.SuppressFinalize(this);
+ if (_disposed) return; // 幂等:重复 Dispose 安全(进程退出事件与启动流程兜底可能竞态调用)
+ _disposed = true;
Stop();
_server.Close();
_cancellationTokenSource?.Dispose();
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..37566e8cc2
--- /dev/null
+++ b/PCL.Core/Minecraft/Skin/NormalizedSkin.cs
@@ -0,0 +1,129 @@
+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()
+ {
+ // 统一加锁一次,循环内批量采样,避免逐像素 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(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 (((accessor.GetPixel(x, y) >> 24) & 0xff) != 0xff)
+ return true;
+ }
+ }
+ return false;
+ }
+
+ 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)accessor.GetPixel(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..f2f6bfd2b5
--- /dev/null
+++ b/PCL.Core/Minecraft/Skin/OfflineSkinServer.cs
@@ -0,0 +1,278 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+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",
+ // 版本号与 metadata.json 保持一致;升级启动器版本时需同步更新
+ ["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.Get(hash);
+ 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
+ {
+ // 用 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" };
+ textures["SKIN"] = skin;
+ }
+
+ if (loadedSkin?.Cape is { } capeTexture)
+ textures["CAPE"] = new JsonObject
+ {
+ ["url"] = $"http://127.0.0.1:{Port}/textures/{capeTexture.Hash}"
+ };
+
+ return new JsonObject
+ {
+ ["timestamp"] = 0,
+ ["profileId"] = character.Uuid.ToString("N"),
+ ["profileName"] = character.Name,
+ ["textures"] = textures
+ };
+ }
+
+ private bool _TryGetByName(string name, [NotNullWhen(true)] out Character character)
+ {
+ lock (_syncRoot)
+ {
+ if (_charactersByName.TryGetValue(name, out var found))
+ {
+ character = found;
+ return true;
+ }
+ character = null!;
+ return false;
+ }
+ }
+
+ private bool _TryGetByUuid(Guid uuid, [NotNullWhen(true)] out Character character)
+ {
+ lock (_syncRoot)
+ {
+ if (_charactersByUuid.TryGetValue(uuid, out var found))
+ {
+ character = found;
+ return true;
+ }
+ character = null!;
+ return false;
+ }
+ }
+}
diff --git a/PCL.Core/Minecraft/Skin/PixelAccess.cs b/PCL.Core/Minecraft/Skin/PixelAccess.cs
new file mode 100644
index 0000000000..f80fa27574
--- /dev/null
+++ b/PCL.Core/Minecraft/Skin/PixelAccess.cs
@@ -0,0 +1,119 @@
+using System;
+using System.Drawing;
+using System.Drawing.Imaging;
+
+namespace PCL.Core.Minecraft.Skin;
+
+///
+/// 基于 的像素级读写辅助,统一以 32 位 ARGB 格式访问像素。
+///
+internal static class PixelAccess
+{
+ ///
+ /// 锁定位图以获得批量访问句柄。使用完毕后应释放句柄以解锁位图。
+ ///
+ /// 目标位图。
+ /// 锁定模式。
+ /// 位图访问句柄。
+ 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..cd188eee36
--- /dev/null
+++ b/PCL.Core/Minecraft/Skin/Skin.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace PCL.Core.Minecraft.Skin;
+
+///
+/// 离线账户的自定义皮肤配置模型。
+///
+/// 皮肤来源类型。
+/// Custom Skin Loader API 的皮肤接口地址。
+/// 皮肤纹理模型(宽/细)。
+/// 本地皮肤文件路径。
+/// 本地披风文件路径。
+public sealed record Skin(
+ SkinType Type,
+ string? CslApi,
+ TextureModel Model,
+ string? LocalSkinPath,
+ string? LocalCapePath)
+{
+ ///
+ /// 是否为纤细(Alex)模型。
+ ///
+ 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 中反序列化皮肤配置。
+ ///
+ /// 已存在的存储对象,内部各键可直接读取。
+ /// 解析成功返回皮肤配置;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 model = TextureModel.Wide;
+ if (storage["textureModel"] is JsonValue modelValue
+ && modelValue.TryGetValue(out var modelText)
+ && string.Equals(modelText, "slim", StringComparison.Ordinal))
+ model = TextureModel.Slim;
+
+ return new Skin(
+ type,
+ GetNullableString(storage, "cslApi"),
+ model,
+ GetNullableString(storage, "localSkinPath"),
+ GetNullableString(storage, "localCapePath"));
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// 将皮肤配置写入存储 JSON。所有键都会写入(null 值保留为 JSON null)。
+ ///
+ /// 已存在的存储对象,键会写入其中。
+ public void WriteStorage(JsonObject storage)
+ {
+ storage["type"] = JsonNamingPolicy.SnakeCaseLower.ConvertName(Type.ToString());
+ 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 bool TryParseType(string text, out SkinType type)
+ {
+ return TypeByStorageKey.TryGetValue(text, out type);
+ }
+}
diff --git a/PCL.Core/Minecraft/Skin/SkinTexture.cs b/PCL.Core/Minecraft/Skin/SkinTexture.cs
new file mode 100644
index 0000000000..69a1080340
--- /dev/null
+++ b/PCL.Core/Minecraft/Skin/SkinTexture.cs
@@ -0,0 +1,109 @@
+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;
+ }
+ }
+
+ ///
+ /// 获取指定哈希的纹理,未命中时返回 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 hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+
+ Span header = stackalloc byte[8];
+ BinaryPrimitives.WriteInt32BigEndian(header, image.Width);
+ BinaryPrimitives.WriteInt32BigEndian(header.Slice(4), image.Height);
+ hash.AppendData(header);
+
+ using var accessor = PixelAccess.Lock(image, ImageLockMode.ReadOnly);
+ Span pixel = stackalloc byte[4];
+ for (var y = 0; y < accessor.Height; y++)
+ {
+ for (var x = 0; x < accessor.Width; x++)
+ {
+ 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;
+ }
+ pixel[0] = alpha;
+ pixel[1] = red;
+ pixel[2] = green;
+ pixel[3] = blue;
+ hash.AppendData(pixel);
+ }
+ }
+
+ return Convert.ToHexString(hash.GetHashAndReset()).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,
+}
diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs
index bac9cad01e..561b6a9a75 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,23 @@ private static void McLaunchStart(ModLoader.LoaderTask
: Lang.Text("Minecraft.Launch.Error.ExportScriptFailed"));
throw;
}
+ finally
+ {
+ // 兜底清理:游戏进程未成功运行(未创建或已退出)时,服务器必须关闭。
+ // 若游戏仍在运行,则交给进程 Exited 事件在游戏退出时关闭(皮肤需存活到游戏退出)。
+ if (mcLaunchOfflineSkinServer is not null)
+ {
+ var processExited = mcLaunchProcess is null;
+ if (!processExited)
+ try { processExited = mcLaunchProcess.HasExited; }
+ catch { processExited = true; } // 进程对象无效时视为已退出,走清理
+ if (processExited)
+ {
+ mcLaunchOfflineSkinServer.Dispose();
+ mcLaunchOfflineSkinServer = null;
+ }
+ }
+ }
}
#endregion
@@ -2174,6 +2192,11 @@ public object HasArguments(string key)
private static string mcLaunchArgument;
+ ///
+ /// 内嵌的离线皮肤服务器实例。首次注入时创建,启动流程结束(成功/失败/取消)后销毁。
+ ///
+ private static OfflineSkinServer mcLaunchOfflineSkinServer;
+
///
/// 释放 Java Wrapper 并返回完整文件路径。
///
@@ -2498,6 +2521,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 +2658,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 +2759,67 @@ 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://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://127.0.0.1:{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)
{
@@ -3513,6 +3609,19 @@ private static void McLaunchRun(ModLoader.LoaderTask loader)
loader.output = gameProcess;
mcLaunchProcess = gameProcess;
+ // 离线皮肤服务器需要存活到游戏退出(皮肤在进入世界/第三人称时才会拉取)。
+ // 挂在进程退出事件上,而不是启动流程 finally,否则游戏窗口一出现服务器就被销毁。
+ gameProcess.EnableRaisingEvents = true;
+ gameProcess.Exited += (_, _) =>
+ {
+ // 仅当退出的正是当前记录的游戏进程时才关闭服务器:
+ // 多开时先退出的游戏不能关掉仍在服务后一局游戏的服务器
+ if (ReferenceEquals(mcLaunchProcess, gameProcess) && mcLaunchOfflineSkinServer is not null)
+ {
+ mcLaunchOfflineSkinServer.Dispose();
+ mcLaunchOfflineSkinServer = null;
+ }
+ };
// 进程优先级处理
try
{
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..b621aabe38 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;
@@ -175,18 +177,169 @@ 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
+ }
+
+ ///
+ /// 根据皮肤配置加载离线皮肤的贴图数据。无皮肤或加载失败时返回 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.Model,
+ 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);
+ 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 地址。
+ private 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/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/PageLaunchLeft.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/PageLaunchLeft.xaml.cs
index 50a8ad08b3..6ac6a52568 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;
@@ -668,7 +669,8 @@ private enum PageType
Ms,
Profile,
ProfileSkin,
- Offline
+ Offline,
+ OfflineSkin
}
///
@@ -710,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:
{
@@ -821,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 皮肤
@@ -935,7 +953,13 @@ private static void SkinLegacyLoad(ModLoader.LoaderTask ModMain.frmLoginProfileSkin.Skin.Load());
@@ -943,6 +967,33 @@ 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)
+ {
+ // 导出到 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);
+ 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/PageLoginOfflineSkin.xaml b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml
new file mode 100644
index 0000000000..c53a562103
--- /dev/null
+++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml.cs
new file mode 100644
index 0000000000..cc3773ef05
--- /dev/null
+++ b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginOfflineSkin.xaml.cs
@@ -0,0 +1,196 @@
+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 PageLoginOfflineSkin
+{
+ private string _skinPath = "";
+ private string _capePath = "";
+ private TextureModel _model = TextureModel.Wide;
+
+ public PageLoginOfflineSkin()
+ {
+ InitializeComponent();
+ Loaded += (_, _) => Reload();
+ }
+
+ ///
+ /// 当前选中的皮肤类型,取自 ComboType 选中项的 Tag(XAML 中已与 SkinType 枚举名对应)。
+ ///
+ private SkinType CurrentType
+ {
+ get
+ {
+ 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;
+ }
+
+ ///
+ /// 回显当前档案已保存的皮肤配置。
+ ///
+ public void Reload()
+ {
+ var skin = ModProfile.selectedProfile?.Skin;
+ _model = skin?.Model ?? TextureModel.Wide;
+ _skinPath = skin?.LocalSkinPath ?? "";
+ _capePath = skin?.LocalCapePath ?? "";
+
+ ComboType.SelectedIndex = skin is null ? 0 : FindComboIndex(ComboType, skin.Type.ToString());
+ ComboModel.SelectedIndex = _model == TextureModel.Slim ? 1 : 0;
+ TextSkinPath.Text = _skinPath;
+ TextCapePath.Text = _capePath;
+ TextCslApi.Text = skin?.CslApi ?? "";
+
+ UpdatePanels();
+ 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 BtnSelectSkin_Click(object sender, EventArgs 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;
+ }
+
+ // 依据手臂区域自动判定模型(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)
+ {
+ 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;
+ }
+
+ private void BtnSelectCape_Click(object sender, EventArgs 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;
+ }
+
+ 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);
+ ModMain.frmLoginProfileSkin?.Reload();
+ ModMain.frmLaunchLeft.RefreshPage(true);
+ }
+
+ private void BtnCancel_Click(object sender, MouseButtonEventArgs e)
+ {
+ ModMain.frmLaunchLeft.RefreshPage(true);
+ }
+
+ private void ComboType_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ UpdatePanels();
+ UpdateConfirmEnabled();
+ }
+
+ private void ComboModel_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ _model = ComboModel.SelectedIndex == 1 ? TextureModel.Slim : TextureModel.Wide;
+ }
+
+ private void TextCslApi_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ UpdateConfirmEnabled();
+ }
+}
diff --git a/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginProfileSkin.xaml.cs b/Plain Craft Launcher 2/Pages/PageLaunch/PageLoginProfileSkin.xaml.cs
index a3a40608af..c60efe280e 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"));
+ OpenOfflineSkinPage();
}
// 保存皮肤
@@ -152,8 +152,14 @@ 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"));
+ OpenOfflineSkinPage();
}
#endregion
+
+ // 打开离线皮肤设置页面
+ private void OpenOfflineSkinPage()
+ {
+ ModBase.RunInUi(() => ModMain.frmLaunchLeft.PageChangeToOfflineSkin());
+ }
}
\ No newline at end of file