# 构建/发布工具链(§9 第 4 步)实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans. Steps use `- [ ]`. > > 本计划分两半:**(.NET 可 TDD)** 打包/签名/manifest 生成工具(`Server/PublishTool/`,`dotnet test` 验证);**(Unity 人工核对)** Editor 菜单:Core 双编译为热更 DLL + 打 AssetBundle。Unity 半段标注「[Unity 人工核对]」。 **Goal:** 一键把一个小游戏(Core/Client/Server 源码 + 资源)产出**两端发布包**:服务端目录 `games/{gameId}/{version}/`(Server.dll + Core.dll + game.json,2a 加载约定)与客户端 CDN 目录 `minigame/{gameId}/{version}/`(Core/Client 热更 DLL `.bytes` + AB + game.json + files.txt + 签名,第 3 步消费约定);版本号绑定,一次构建产出两端,避免逻辑漂移(设计 §3.1)。 **Architecture:** Core 按 A1 编两次(客户端 HybridCLR 热更 DLL + 服务端普通 .NET DLL)——这步依赖 Unity/HybridCLR 工具链,做成 Editor 菜单(人工)。所有**与平台无关的打包工作**(计算 md5、写 files.txt、生成两份 game.json、对清单签名、按目录布局落盘)抽到 `Server/PublishTool` .NET 库,`dotnet test` 全程可验。客户端/服务端加载前用同一 `ManifestSigner` 验签(设计 §7)。 **Tech Stack:** .NET 10 控制台/库 `Server/PublishTool` · `System.Security.Cryptography`(MD5 文件指纹 + RSA 清单签名)· xUnit · Unity Editor 菜单(HybridCLR `CompileDllCommand` + `BuildAssetBundles`,人工)。 --- ## 背景与约定 - 上游:设计 §3.1(发布流程)、§7(签名校验)。消费方:2a 服务端 `GameModuleLoader`(`games/{id}/{ver}/`)、第 3 步客户端 `MiniGameDownloader`(`minigame/{id}/{ver}/`)。 - 已完成 2a 的服务端 `game.json` schema(gameId/version/serverAssembly/serverEntryType/minFrameworkVersion/playerCount/tickRateHz);第 3 步客户端 `game.json` schema(gameId/version/clientEntryType/coreDll/clientDll/minFrameworkVersion/assets)。本工具**两份都生成**。 - **无 git**:跳过 git 步骤。工作目录 `D:/UD/AI/AIC#Project`,`dotnet` SDK 10。 ## 发布产物布局(本工具产出) ``` /server/games/{gameId}/{version}/ {Game}.Server.dll {Game}.Core.dll game.json # 2a 加载(无 .bytes,普通 DLL) /client/minigame/{gameId}/{version}/ {Game}.Core.dll.bytes {Game}.Client.dll.bytes # HybridCLR 热更 DLL(字节流) {abName}_{md5}.unity3d ... # AB(md5 命名) game.json files.txt files.txt.sig # 客户端清单 + 签名 ``` ## 文件结构 | 文件 | 职责 | |---|---| | `Server/PublishTool/PublishTool.csproj` | net10.0 库 | | `Server/PublishTool/Md5Util.cs` | 文件/字节 md5 十六进制 | | `Server/PublishTool/ManifestSigner.cs` | RSA 对 files.txt 摘要签名 + 验签(客户端/服务端加载前用) | | `Server/PublishTool/FilesManifest.cs` | files.txt 模型 + 生成(`{'name','md5',size},` 行)+ 解析 | | `Server/PublishTool/GameJsonWriter.cs` | 生成服务端 + 客户端两份 game.json | | `Server/PublishTool/PublishLayout.cs` | 输入产物 → 落盘到上述目录布局 + 写 manifest + 签名 | | `Server/PublishTool.Tests/*` | xUnit | | `Client/Assets/Editor/MiniGamePublishMenu.cs` | [Unity] 菜单:Core 双编译 + 打 AB + 调 PublishLayout | --- ## Task 1: 工程脚手架 **Files:** Create `Server/PublishTool/PublishTool.csproj`, `Server/PublishTool.Tests/PublishTool.Tests.csproj`; modify `Server/Server.sln`. - [ ] **Step 1:** ```bash dotnet new classlib -n PublishTool -o Server/PublishTool -f net10.0 dotnet new xunit -n PublishTool.Tests -o Server/PublishTool.Tests rm -f Server/PublishTool/Class1.cs Server/PublishTool.Tests/UnitTest1.cs dotnet sln Server/Server.sln add Server/PublishTool/PublishTool.csproj dotnet sln Server/Server.sln add Server/PublishTool.Tests/PublishTool.Tests.csproj ``` - [ ] **Step 2:** 在 `PublishTool.Tests.csproj` 加 `disable` + ``;`PublishTool.csproj` 加 `disable`。 - [ ] **Step 3:** Run `dotnet build Server/Server.sln -v minimal` → 0 error。 --- ## Task 2: Md5Util(TDD) **Files:** Create `Server/PublishTool/Md5Util.cs`; Test `Server/PublishTool.Tests/Md5UtilTests.cs`. - [ ] **Step 1: 失败测试** ```csharp using Xunit; using XWorld.PublishTool; namespace XWorld.PublishTool.Tests { public class Md5UtilTests { [Fact] public void Hex_OfKnownBytes_IsStableLowercase32() { // MD5("abc") = 900150983cd24fb0d6963f7d28e17f72 var hex = Md5Util.OfBytes(System.Text.Encoding.ASCII.GetBytes("abc")); Assert.Equal("900150983cd24fb0d6963f7d28e17f72", hex); } [Fact] public void Empty_IsKnownMd5() { Assert.Equal("d41d8cd98f00b204e9800998ecf8427e", Md5Util.OfBytes(new byte[0])); } } } ``` - [ ] **Step 2:** Run `dotnet test Server/Server.sln --filter Md5UtilTests` → RED。 - [ ] **Step 3: 实现** ```csharp using System; using System.IO; using System.Security.Cryptography; using System.Text; namespace XWorld.PublishTool { public static class Md5Util { public static string OfBytes(byte[] data) { using (var md5 = MD5.Create()) { byte[] h = md5.ComputeHash(data ?? Array.Empty()); var sb = new StringBuilder(h.Length * 2); foreach (var b in h) sb.Append(b.ToString("x2")); return sb.ToString(); } } public static string OfFile(string path) => OfBytes(File.ReadAllBytes(path)); } } ``` - [ ] **Step 4:** Run filter → GREEN。 --- ## Task 3: FilesManifest(TDD) **Files:** Create `Server/PublishTool/FilesManifest.cs`; Test `Server/PublishTool.Tests/FilesManifestTests.cs`. - [ ] **Step 1: 失败测试** ```csharp using Xunit; using XWorld.PublishTool; namespace XWorld.PublishTool.Tests { public class FilesManifestTests { [Fact] public void Render_And_Parse_RoundTrips() { var m = new FilesManifest(); m.Add("RPS.Core.dll.bytes", "abc123", 100); m.Add("ui_rps_abc.unity3d", "def456", 2048); string text = m.Render(); var back = FilesManifest.Parse(text); Assert.Equal(2, back.Entries.Count); Assert.Equal("abc123", back.Entries["RPS.Core.dll.bytes"].Md5); Assert.Equal(2048, back.Entries["ui_rps_abc.unity3d"].Size); } [Fact] public void Render_LineFormat_MatchesDllfilesConvention() { var m = new FilesManifest(); m.Add("a.dll", "m5", 7); Assert.Equal("{'a.dll','m5',7},\n", m.Render()); } [Fact] public void Digest_IsStable_RegardlessOfInsertOrder() { var a = new FilesManifest(); a.Add("x", "1", 1); a.Add("y", "2", 2); var b = new FilesManifest(); b.Add("y", "2", 2); b.Add("x", "1", 1); Assert.Equal(a.Digest(), b.Digest()); // 按 name 排序后摘要,顺序无关 } } } ``` - [ ] **Step 2:** RED。 - [ ] **Step 3: 实现** ```csharp using System.Collections.Generic; using System.Linq; using System.Text; namespace XWorld.PublishTool { public sealed class FilesManifest { public sealed class Entry { public string Md5; public int Size; } public Dictionary Entries { get; } = new Dictionary(); public void Add(string name, string md5, int size) => Entries[name] = new Entry { Md5 = md5, Size = size }; // 行格式沿用 dllfiles:{'name','md5',size},\n(按 name 排序,保证确定性) public string Render() { var sb = new StringBuilder(); foreach (var kv in Entries.OrderBy(e => e.Key, System.StringComparer.Ordinal)) sb.Append("{'").Append(kv.Key).Append("','").Append(kv.Value.Md5).Append("',").Append(kv.Value.Size).Append("},\n"); return sb.ToString(); } public static FilesManifest Parse(string text) { var m = new FilesManifest(); if (string.IsNullOrEmpty(text)) return m; foreach (var raw in text.Split('\n')) { string line = raw.Trim(); int lb = line.IndexOf('{'); int rb = line.LastIndexOf('}'); if (lb < 0 || rb <= lb) continue; string inner = line.Substring(lb + 1, rb - lb - 1).Replace("'", ""); string[] p = inner.Split(','); if (p.Length < 3) continue; int.TryParse(p[2].Trim(), out int size); m.Add(p[0].Trim(), p[1].Trim(), size); } return m; } // 稳定摘要:排序后的 name+md5 串(用于签名) public string Digest() { var sb = new StringBuilder(); foreach (var kv in Entries.OrderBy(e => e.Key, System.StringComparer.Ordinal)) sb.Append(kv.Key).Append('=').Append(kv.Value.Md5).Append(';'); return sb.ToString(); } } } ``` - [ ] **Step 4:** GREEN。 --- ## Task 4: ManifestSigner(RSA 签名/验签,TDD) **Files:** Create `Server/PublishTool/ManifestSigner.cs`; Test `Server/PublishTool.Tests/ManifestSignerTests.cs`. > 发布方用私钥签 files.txt 的 `Digest()`,产出 `files.txt.sig`;客户端/服务端加载前用内置公钥验签(设计 §7:防中间人篡改 DLL)。 - [ ] **Step 1: 失败测试** ```csharp using System.Security.Cryptography; using Xunit; using XWorld.PublishTool; namespace XWorld.PublishTool.Tests { public class ManifestSignerTests { [Fact] public void Sign_Then_Verify_Succeeds() { using var rsa = RSA.Create(2048); string priv = rsa.ExportRSAPrivateKeyPem(); string pub = rsa.ExportRSAPublicKeyPem(); byte[] sig = ManifestSigner.Sign("digest-content", priv); Assert.True(ManifestSigner.Verify("digest-content", sig, pub)); } [Fact] public void Verify_Fails_OnTamperedContent() { using var rsa = RSA.Create(2048); byte[] sig = ManifestSigner.Sign("original", rsa.ExportRSAPrivateKeyPem()); Assert.False(ManifestSigner.Verify("tampered", sig, rsa.ExportRSAPublicKeyPem())); } [Fact] public void Verify_Fails_OnWrongKey() { using var signer = RSA.Create(2048); using var other = RSA.Create(2048); byte[] sig = ManifestSigner.Sign("c", signer.ExportRSAPrivateKeyPem()); Assert.False(ManifestSigner.Verify("c", sig, other.ExportRSAPublicKeyPem())); } } } ``` - [ ] **Step 2:** RED。 - [ ] **Step 3: 实现** ```csharp using System.Security.Cryptography; using System.Text; namespace XWorld.PublishTool { public static class ManifestSigner { public static byte[] Sign(string content, string privateKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); return rsa.SignData(Encoding.UTF8.GetBytes(content), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } public static bool Verify(string content, byte[] signature, string publicKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(publicKeyPem); return rsa.VerifyData(Encoding.UTF8.GetBytes(content), signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } } } ``` - [ ] **Step 4:** GREEN。 --- ## Task 5: GameJsonWriter(两份 game.json,TDD) **Files:** Create `Server/PublishTool/GameJsonWriter.cs`; Test `Server/PublishTool.Tests/GameJsonWriterTests.cs`. > 用 `System.Text.Json` 生成。服务端 schema 与 2a `GameManifest` 字段一致;客户端 schema 与第 3 步 `MiniGameManifest` 一致。用 2a 的 `GameManifest.Parse`/第 3 步字段名做往返断言(这里直接断言 JSON 含正确字段值)。 - [ ] **Step 1: 失败测试** ```csharp using System.Text.Json; using Xunit; using XWorld.PublishTool; namespace XWorld.PublishTool.Tests { public class GameJsonWriterTests { private static readonly GameSpec Spec = new GameSpec { GameId = "rps", Version = 2, MinFrameworkVersion = 1, PlayerCount = 2, TickRateHz = 10, ServerAssembly = "RPS.Server.dll", ServerEntryType = "RPS.Server.RpsServerRoom", CoreDll = "RPS.Core.dll", ClientDll = "RPS.Client.dll", ClientEntryType = "RPS.Client.RpsGameClient", Assets = new System.Collections.Generic.List { "ui_rps" } }; [Fact] public void Server_Json_HasRequiredFields() { using var doc = JsonDocument.Parse(GameJsonWriter.WriteServer(Spec)); var r = doc.RootElement; Assert.Equal("rps", r.GetProperty("gameId").GetString()); Assert.Equal(2, r.GetProperty("version").GetInt32()); Assert.Equal("RPS.Server.dll", r.GetProperty("serverAssembly").GetString()); Assert.Equal("RPS.Server.RpsServerRoom", r.GetProperty("serverEntryType").GetString()); Assert.Equal(2, r.GetProperty("playerCount").GetInt32()); Assert.Equal(10, r.GetProperty("tickRateHz").GetInt32()); } [Fact] public void Client_Json_HasRequiredFields() { using var doc = JsonDocument.Parse(GameJsonWriter.WriteClient(Spec)); var r = doc.RootElement; Assert.Equal("rps", r.GetProperty("gameId").GetString()); Assert.Equal("RPS.Client.RpsGameClient", r.GetProperty("clientEntryType").GetString()); Assert.Equal("RPS.Core.dll", r.GetProperty("coreDll").GetString()); Assert.Equal("RPS.Client.dll", r.GetProperty("clientDll").GetString()); Assert.Equal("ui_rps", r.GetProperty("assets")[0].GetString()); } } } ``` - [ ] **Step 2:** RED。 - [ ] **Step 3: 实现** ```csharp using System.Collections.Generic; using System.Text.Json; namespace XWorld.PublishTool { public sealed class GameSpec { public string GameId; public int Version; public int MinFrameworkVersion; public int PlayerCount; public int TickRateHz; public string ServerAssembly; public string ServerEntryType; public string CoreDll; public string ClientDll; public string ClientEntryType; public List Assets = new List(); } public static class GameJsonWriter { private static readonly JsonSerializerOptions Opts = new JsonSerializerOptions { WriteIndented = true }; public static string WriteServer(GameSpec s) => JsonSerializer.Serialize(new { gameId = s.GameId, version = s.Version, serverAssembly = s.ServerAssembly, serverEntryType = s.ServerEntryType, minFrameworkVersion = s.MinFrameworkVersion, playerCount = s.PlayerCount, tickRateHz = s.TickRateHz }, Opts); public static string WriteClient(GameSpec s) => JsonSerializer.Serialize(new { gameId = s.GameId, version = s.Version, clientEntryType = s.ClientEntryType, coreDll = s.CoreDll, clientDll = s.ClientDll, minFrameworkVersion = s.MinFrameworkVersion, assets = s.Assets }, Opts); } } ``` - [ ] **Step 4:** GREEN。 --- ## Task 6: PublishLayout(落盘布局 + 清单 + 签名,TDD) **Files:** Create `Server/PublishTool/PublishLayout.cs`; Test `Server/PublishTool.Tests/PublishLayoutTests.cs`. > 输入:一个已构建产物的临时目录(Server.dll/Core.dll/Client.dll/AB)+ GameSpec + 私钥。输出:server/ 与 client/ 两套目录 + game.json×2 + files.txt + files.txt.sig。用临时目录测全流程,再用公钥验签 + 重新 md5 校验。 - [ ] **Step 1: 失败测试** ```csharp using System.IO; using System.Security.Cryptography; using Xunit; using XWorld.PublishTool; namespace XWorld.PublishTool.Tests { public class PublishLayoutTests { [Fact] public void Publish_ProducesBothTrees_WithValidSignatureAndMd5() { // 准备假产物 string src = Path.Combine(Path.GetTempPath(), "pubsrc_" + System.Guid.NewGuid().ToString("N")); string outRoot = Path.Combine(Path.GetTempPath(), "pubout_" + System.Guid.NewGuid().ToString("N")); Directory.CreateDirectory(src); File.WriteAllBytes(Path.Combine(src, "RPS.Core.dll"), new byte[] { 1, 2, 3 }); File.WriteAllBytes(Path.Combine(src, "RPS.Server.dll"), new byte[] { 4, 5 }); File.WriteAllBytes(Path.Combine(src, "RPS.Client.dll"), new byte[] { 6, 7, 8, 9 }); File.WriteAllBytes(Path.Combine(src, "ui_rps.unity3d"), new byte[] { 10 }); using var rsa = RSA.Create(2048); var spec = new GameSpec { GameId = "rps", Version = 1, MinFrameworkVersion = 1, PlayerCount = 2, TickRateHz = 10, ServerAssembly = "RPS.Server.dll", ServerEntryType = "RPS.Server.RpsServerRoom", CoreDll = "RPS.Core.dll", ClientDll = "RPS.Client.dll", ClientEntryType = "RPS.Client.RpsGameClient", }; spec.Assets.Add("ui_rps"); new PublishLayout().Publish(src, outRoot, spec, rsa.ExportRSAPrivateKeyPem()); string serverDir = Path.Combine(outRoot, "server", "games", "rps", "1"); string clientDir = Path.Combine(outRoot, "client", "minigame", "rps", "1"); // 服务端:普通 DLL + game.json Assert.True(File.Exists(Path.Combine(serverDir, "RPS.Server.dll"))); Assert.True(File.Exists(Path.Combine(serverDir, "RPS.Core.dll"))); Assert.True(File.Exists(Path.Combine(serverDir, "game.json"))); // 客户端:热更 DLL 转 .bytes(md5 命名)+ AB(md5名) + game.json + files.txt + sig string coreMd5 = Md5Util.OfFile(Path.Combine(src, "RPS.Core.dll")); string abMd5 = Md5Util.OfFile(Path.Combine(src, "ui_rps.unity3d")); Assert.True(File.Exists(Path.Combine(clientDir, $"RPS.Core.dll_{coreMd5}.bytes"))); Assert.True(File.Exists(Path.Combine(clientDir, $"ui_rps_{abMd5}.unity3d"))); Assert.True(File.Exists(Path.Combine(clientDir, "game.json"))); Assert.True(File.Exists(Path.Combine(clientDir, "files.txt"))); Assert.True(File.Exists(Path.Combine(clientDir, "files.txt.sig"))); // 验签:用公钥验证 files.txt 的 digest var fm = FilesManifest.Parse(File.ReadAllText(Path.Combine(clientDir, "files.txt"))); byte[] sig = File.ReadAllBytes(Path.Combine(clientDir, "files.txt.sig")); Assert.True(ManifestSigner.Verify(fm.Digest(), sig, rsa.ExportRSAPublicKeyPem())); Directory.Delete(src, true); Directory.Delete(outRoot, true); } } } ``` - [ ] **Step 2:** RED。 - [ ] **Step 3: 实现** ```csharp using System.IO; namespace XWorld.PublishTool { // 把构建产物落成两端发布目录 + 清单 + 签名 public sealed class PublishLayout { public void Publish(string srcDir, string outRoot, GameSpec spec, string privateKeyPem) { string serverDir = Path.Combine(outRoot, "server", "games", spec.GameId, spec.Version.ToString()); string clientDir = Path.Combine(outRoot, "client", "minigame", spec.GameId, spec.Version.ToString()); Directory.CreateDirectory(serverDir); Directory.CreateDirectory(clientDir); // 服务端:普通 DLL 原样拷 + game.json CopyTo(srcDir, spec.ServerAssembly, serverDir, spec.ServerAssembly); CopyTo(srcDir, spec.CoreDll, serverDir, spec.CoreDll); File.WriteAllText(Path.Combine(serverDir, "game.json"), GameJsonWriter.WriteServer(spec)); // 客户端:Core/Client 转 .bytes(热更字节流)+ AB(md5名) + 清单 var fm = new FilesManifest(); string coreBytes = spec.CoreDll + ".bytes"; string clientBytes = spec.ClientDll + ".bytes"; CopyBytes(srcDir, spec.CoreDll, clientDir, coreBytes, fm); CopyBytes(srcDir, spec.ClientDll, clientDir, clientBytes, fm); foreach (var ab in spec.Assets) { string abFile = ab + ".unity3d"; byte[] data = File.ReadAllBytes(Path.Combine(srcDir, abFile)); string md5 = Md5Util.OfBytes(data); // md5 命名落盘:name_md5.unity3d string md5Name = ab + "_" + md5 + ".unity3d"; File.WriteAllBytes(Path.Combine(clientDir, md5Name), data); fm.Add(abFile, md5, data.Length); } File.WriteAllText(Path.Combine(clientDir, "game.json"), GameJsonWriter.WriteClient(spec)); string filesTxt = fm.Render(); File.WriteAllText(Path.Combine(clientDir, "files.txt"), filesTxt); byte[] sig = ManifestSigner.Sign(fm.Digest(), privateKeyPem); File.WriteAllBytes(Path.Combine(clientDir, "files.txt.sig"), sig); } private static void CopyTo(string srcDir, string srcName, string dstDir, string dstName) => File.Copy(Path.Combine(srcDir, srcName), Path.Combine(dstDir, dstName), true); // 落盘为 md5 命名(name_md5.ext,与 AB / LoadDll 约定及下载器 Md5Name 推导一致),files.txt 登记逻辑名 private static void CopyBytes(string srcDir, string srcDll, string dstDir, string logicalBytesName, FilesManifest fm) { byte[] data = File.ReadAllBytes(Path.Combine(srcDir, srcDll)); string md5 = Md5Util.OfBytes(data); File.WriteAllBytes(Path.Combine(dstDir, Md5Name(logicalBytesName, md5)), data); fm.Add(logicalBytesName, md5, data.Length); } // name.ext -> name_md5.ext(无扩展名则 name_md5) private static string Md5Name(string filename, string md5) { int pos = filename.LastIndexOf('.'); return pos >= 0 ? $"{filename.Substring(0, pos)}_{md5}{filename.Substring(pos)}" : $"{filename}_{md5}"; } } } ``` - [ ] **Step 4:** GREEN。 - [ ] **Step 5:** Run `dotnet test Server/Server.sln` 全量绿,0 警告。 --- ## Task 7: [Unity 人工核对] Editor 发布菜单 **Files:** Create `Client/Assets/Editor/MiniGamePublishMenu.cs`(仅 Editor 编译)。 > 这一段依赖 Unity/HybridCLR,**无法在 .NET 沙箱验证**,作为人工核对实施。 - [ ] **Step 1: 写 `MiniGamePublishMenu.cs`(要点,非完整)** ```csharp #if UNITY_EDITOR using UnityEditor; using UnityEngine; // using HybridCLR.Editor.Commands; // CompileDllCommand namespace XGame.Editor { public static class MiniGamePublishMenu { [MenuItem("XWorld/发布小游戏/RPS")] public static void PublishRps() { // 1) Core 双编译: // a. HybridCLR:CompileDllCommand.CompileDll(target) 产出热更 RPS.Core.dll / RPS.Client.dll(裁剪后字节) // b. 服务端 .NET:调用 `dotnet build` RPS.Core(netstandard2.1)/RPS.Server 产出普通 DLL(或复用 Server 解决方案构建产物) // 2) BuildPipeline.BuildAssetBundles(...) 打小游戏 AB(按目录命名),输出 ui_rps.unity3d // 3) 把以上产物汇集到临时 srcDir // 4) 调 .NET 工具:new XWorld.PublishTool.PublishLayout().Publish(srcDir, outRoot, spec, privateKeyPem) // (PublishTool 编成 DLL 供 Editor 引用,或用命令行调用一个 PublishTool 控制台封装) Debug.Log("发布完成(人工核对产物布局/签名)"); } } } #endif ``` - [ ] **Step 2: [Unity 人工核对]** 在 Editor 跑菜单,确认产出 `server/games/rps/1/` 与 `client/minigame/rps/1/`,文件齐全、签名可验(用 PublishTool 的 `ManifestSigner.Verify`)、md5 一致。Core 的两份 DLL(热更字节 vs 普通 .NET)来自**同一份 Core 源码**(A1)。 --- ## 完成判据 - [ ] `.NET` 段:`dotnet test Server/Server.sln` 全绿(Md5Util/FilesManifest/ManifestSigner/GameJsonWriter/PublishLayout 各测试),0 警告。 - [ ] `PublishLayout.Publish` 产出两端目录、两份 game.json(字段分别匹配 2a 服务端 schema 与第 3 步客户端 schema)、files.txt(md5/size)、files.txt.sig(公钥可验、篡改即失败)。 - [ ] [Unity 人工核对] Editor 菜单完成 Core 双编译 + 打 AB + 调 PublishLayout,产物可被 2a 服务端 `GameModuleLoader` 与第 3 步客户端 `MiniGameDownloader` 直接消费。 ## 不在本步骤范围 / 风险 - 真正的「发布服务」后台(灰度/正式通道、CDN 上传、版本库)——本步骤只产出**本地布局**;上传/通道是运维集成,后续。 - 客户端/服务端**加载前验签**的接线:把 `ManifestSigner.Verify` + 内置公钥接进第 3 步 `MiniGameDownloader`(客户端)与 2a `GameModuleLoader`(服务端)——建议作为各自的小增量补上(本工具已提供可复用的 `ManifestSigner`/`FilesManifest`)。 - 微信 WASM 热更合规(§6.6)。