Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fc7a0a7
feat(skin): 新增离线自定义皮肤核心库(PCL.Core)
sysfox Aug 8, 2026
2a183b5
feat(skin): 离线自定义皮肤加载、启动注入与 UI 集成
sysfox Aug 8, 2026
4009319
refactor(skin): 根据独立审阅意见精简代码
sysfox Aug 8, 2026
b062b1b
test(skin): 新增皮肤核心算法单测(14 项)
sysfox Aug 8, 2026
8d0b332
fix(skin): 修复离线皮肤不生效与头像加载失败
sysfox Aug 8, 2026
2d9d853
fix(skin): 离线皮肤服务器存活到游戏退出而非启动流程结束
sysfox Aug 8, 2026
c57b9d3
fix(skin): 重写皮肤设置弹窗为稳定两栏 Grid 布局
sysfox Aug 8, 2026
c7b63b5
fix(skin): 移除已删除预览元素的代码引用,修复应用无法启动
sysfox Aug 8, 2026
2717347
chore(skin): 移除无用注释与死代码
sysfox Aug 8, 2026
ee09187
refactor(skin): 精简皮肤核心库,消除死代码与手写轮子
sysfox Aug 8, 2026
01cd3e0
fix(skin): 修复多开时离线皮肤服务器被误关的问题
sysfox Aug 8, 2026
8213d0d
refactor(skin): 移除 CSL 贴图下载无效的缓存分支
sysfox Aug 8, 2026
6da5f66
test(skin): 修正测试中冗余的嵌套 unchecked
sysfox Aug 8, 2026
2150c4b
feat(skin): 添加离线皮肤设置页面并重构相关逻辑
sysfox Aug 9, 2026
c719d80
ui(skin): 本地文件选择按钮改为文件地址右侧的图标按钮
sysfox Aug 9, 2026
6d19d9b
ui(skin): 皮肤设置页确定与取消按钮居中排列
sysfox Aug 9, 2026
4ab5426
ui(skin): LittleSkin 面板"打开官网"文案改为"打开其官网"
sysfox Aug 9, 2026
015d661
ui(skin): 皮肤设置页文字按钮高度统一为 35,避免挤压文字
sysfox Aug 9, 2026
f08a2f3
ui(skin): 文件夹选择按钮包裹可见的圆角正方形背景框
sysfox Aug 9, 2026
67a8fb5
ui(skin): 修改 LittleSkin 面板"打开其官网"文案为"打开官网"
sysfox Aug 11, 2026
af1a9c4
ui(skin): 重构 PageLoginOfflineSkin 界面布局,优化皮肤类型和本地文件选项显示
sysfox Aug 11, 2026
fc4aec2
ui(skin): 将底部按钮从 Grid 改为 StackPanel,优化按钮布局和间距
sysfox Aug 11, 2026
36ef7a2
ui(skin): 调整边框圆角为2,优化界面元素的视觉效果
sysfox Aug 12, 2026
6ff5e73
fix: update PCL.Core/App/Localization/Languages/en-US.xaml
sysfox Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions PCL.Core.Test/Minecraft/OfflineSkinServerTest.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 回归测试:HttpServer 构造函数必须能为 IPv6 回环地址生成合法的 URI 前缀。
/// 此前直接拼接 "http://::1:port/" 会在 AddPrefix 时抛
/// "Only Uri prefixes with a valid hostname are supported",导致离线皮肤注入静默失败。
/// </summary>
[TestMethod]
public void Ctor_Ipv6Loopback_DoesNotThrow()
{
using var server = new OfflineSkinServer();
Assert.IsTrue(server.Port > 0);
}

/// <summary>
/// 启动后元数据路由可访问,且签名公钥非空。
/// </summary>
[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");
}

/// <summary>
/// 注册角色后,hasJoined 能返回带 textures 签名的完整档案。
/// </summary>
[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");
}

/// <summary>
/// 未知玩家 hasJoined 返回 404。
/// </summary>
[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);
}
}
201 changes: 201 additions & 0 deletions PCL.Core.Test/Minecraft/Skin/SkinTest.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 构造纯色位图。
/// </summary>
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<InvalidSkinException>(() => new NormalizedSkin(CreateBitmap(32, 32, unchecked((int)0xFF000000))));
Assert.ThrowsExactly<InvalidSkinException>(() => 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<SkinType>())
{
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}");
}
}
}
24 changes: 24 additions & 0 deletions PCL.Core/App/Localization/Languages/en-US.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -1668,6 +1668,30 @@
<sys:String x:Key="Launch.Skin.SaveDialog.Title">Select a location to save the skin file.</sys:String>
<sys:String x:Key="Launch.Skin.SaveSuccess">Skin saved successfully!</sys:String>

<!-- Launch.OfflineSkin -->

<sys:String x:Key="Launch.OfflineSkin.Title">Skin Settings</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type">Type</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.Default">Default</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.Steve">Steve</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.Alex">Alex</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.LocalFile">Local file</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.LittleSkin">LittleSkin</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.CslApi">CSL API</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Model">Model</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Model.Wide">Steve (wide arms)</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Model.Slim">Alex (slim arms)</sys:String>
<sys:String x:Key="Launch.OfflineSkin.SkinFile">Skin</sys:String>
<sys:String x:Key="Launch.OfflineSkin.SelectSkin">Select skin file...</sys:String>
<sys:String x:Key="Launch.OfflineSkin.CapeFile">Cape</sys:String>
<sys:String x:Key="Launch.OfflineSkin.SelectCape">Select cape file...</sys:String>
<sys:String x:Key="Launch.OfflineSkin.CslApiUrl">CSL API URL</sys:String>
<sys:String x:Key="Launch.OfflineSkin.LittleSkinHint">LittleSkin provides skin hosting for Minecraft players.</sys:String>
<sys:String x:Key="Launch.OfflineSkin.OpenLittleSkin">Open LittleSkin website</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Confirm">OK</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Cancel">Cancel</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Saved">Skin saved</sys:String>

<!-- Launch.Skin.Cape -->

<sys:String x:Key="Launch.Skin.Cape.ChangeFailed">Failed to change the cape.</sys:String>
Expand Down
24 changes: 24 additions & 0 deletions PCL.Core/App/Localization/Languages/zh-CN.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -1668,6 +1668,30 @@
<sys:String x:Key="Launch.Skin.SaveDialog.Title">选取保存皮肤的位置</sys:String>
<sys:String x:Key="Launch.Skin.SaveSuccess">皮肤保存成功!</sys:String>

<!-- Launch.OfflineSkin -->

<sys:String x:Key="Launch.OfflineSkin.Title">皮肤设置</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type">皮肤类型</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.Default">默认</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.Steve">Steve</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.Alex">Alex</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.LocalFile">本地文件</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.LittleSkin">LittleSkin</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Type.CslApi">CSL API</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Model">模型</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Model.Wide">Steve(宽手臂)</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Model.Slim">Alex(细手臂)</sys:String>
<sys:String x:Key="Launch.OfflineSkin.SkinFile">皮肤文件</sys:String>
<sys:String x:Key="Launch.OfflineSkin.SelectSkin">选择皮肤文件…</sys:String>
<sys:String x:Key="Launch.OfflineSkin.CapeFile">披风文件</sys:String>
<sys:String x:Key="Launch.OfflineSkin.SelectCape">选择披风文件…</sys:String>
<sys:String x:Key="Launch.OfflineSkin.CslApiUrl">CSL API 地址</sys:String>
<sys:String x:Key="Launch.OfflineSkin.LittleSkinHint">LittleSkin 为 Minecraft 玩家提供皮肤托管服务。</sys:String>
<sys:String x:Key="Launch.OfflineSkin.OpenLittleSkin">打开官网</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Confirm">确定</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Cancel">取消</sys:String>
<sys:String x:Key="Launch.OfflineSkin.Saved">皮肤已保存</sys:String>

<!-- Launch.Skin.Cape -->

<sys:String x:Key="Launch.Skin.Cape.ChangeFailed">更改披风失败</sys:String>
Expand Down
Loading