Files
AIC-Project/docs/superpowers/plans/2026-06-19-publish-toolchain-step4.md
2026-07-10 10:24:29 +08:00

25 KiB
Raw Permalink Blame History

构建/发布工具链(§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.json2a 加载约定)与客户端 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 服务端 GameModuleLoadergames/{id}/{ver}/)、第 3 步客户端 MiniGameDownloaderminigame/{id}/{ver}/)。
  • 已完成 2a 的服务端 game.json schemagameId/version/serverAssembly/serverEntryType/minFrameworkVersion/playerCount/tickRateHz);第 3 步客户端 game.json schemagameId/version/clientEntryType/coreDll/clientDll/minFrameworkVersion/assets)。本工具两份都生成
  • 无 git:跳过 git 步骤。工作目录 D:/UD/AI/AIC#Projectdotnet SDK 10。

发布产物布局(本工具产出)

<outRoot>/server/games/{gameId}/{version}/
    {Game}.Server.dll  {Game}.Core.dll  game.json            # 2a 加载(无 .bytes,普通 DLL
<outRoot>/client/minigame/{gameId}/{version}/
    {Game}.Core.dll.bytes  {Game}.Client.dll.bytes           # HybridCLR 热更 DLL(字节流)
    {abName}_{md5}.unity3d ...                                # ABmd5 命名)
    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:
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<Nullable>disable</Nullable> + <ProjectReference Include="..\PublishTool\PublishTool.csproj" />PublishTool.csproj<Nullable>disable</Nullable>
  • Step 3: Run dotnet build Server/Server.sln -v minimal → 0 error。

Task 2: Md5UtilTDD

Files: Create Server/PublishTool/Md5Util.cs; Test Server/PublishTool.Tests/Md5UtilTests.cs.

  • Step 1: 失败测试
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: 实现
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<byte>());
                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: FilesManifestTDD

Files: Create Server/PublishTool/FilesManifest.cs; Test Server/PublishTool.Tests/FilesManifestTests.cs.

  • Step 1: 失败测试
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: 实现
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<string, Entry> Entries { get; } = new Dictionary<string, Entry>();

        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: ManifestSignerRSA 签名/验签,TDD

Files: Create Server/PublishTool/ManifestSigner.cs; Test Server/PublishTool.Tests/ManifestSignerTests.cs.

发布方用私钥签 files.txt 的 Digest(),产出 files.txt.sig;客户端/服务端加载前用内置公钥验签(设计 §7:防中间人篡改 DLL)。

  • Step 1: 失败测试
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: 实现
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.jsonTDD

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: 失败测试
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<string> { "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: 实现
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<string> Assets = new List<string>();
    }

    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: 失败测试
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 转 .bytesmd5 命名)+ 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: 实现
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(要点,非完整)
#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. HybridCLRCompileDllCommand.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.txtmd5/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)。