Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
using System;
namespace XWorld.PublishTool
{
// PublishTool.Cli 的参数:--mode client|server --src <dir> --out <dir> --spec <json> [--key <pem>]
public sealed class CliOptions
{
public string Mode; // "client" | "server"
public string Src; // 构建产物目录
public string Out; // 落位目录(版本目录本体)
public string SpecPath; // resolved-spec.json
public string KeyPath; // 私钥 pem,可空=不签名
public const string Usage =
"用法: PublishTool.Cli --mode client|server --src <dir> --out <dir> --spec <resolved-spec.json> [--key <private.pem>]";
public static CliOptions Parse(string[] args)
{
var o = new CliOptions();
for (int i = 0; i < args.Length; i += 2)
{
if (i + 1 >= args.Length) throw new ArgumentException($"缺少 {args[i]} 的值\n{Usage}");
string v = args[i + 1];
switch (args[i])
{
case "--mode": o.Mode = v; break;
case "--src": o.Src = v; break;
case "--out": o.Out = v; break;
case "--spec": o.SpecPath = v; break;
case "--key": o.KeyPath = v; break;
default: throw new ArgumentException($"未知参数 {args[i]}\n{Usage}");
}
}
if (o.Mode != "client" && o.Mode != "server")
throw new ArgumentException($"--mode 必须是 client 或 server\n{Usage}");
if (string.IsNullOrEmpty(o.Src) || string.IsNullOrEmpty(o.Out) || string.IsNullOrEmpty(o.SpecPath))
throw new ArgumentException($"--src/--out/--spec 均必填\n{Usage}");
return o;
}
}
}
+50
View File
@@ -0,0 +1,50 @@
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();
}
}
}
+34
View File
@@ -0,0 +1,34 @@
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 string CodeAb; // 代码 AB 文件名(统一 ABDLL .bytes 打进它)
public List<string> Assets = new List<string>(); // 资源 AB 完整文件名列表
}
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,
codeAb = s.CodeAb, assets = s.Assets
}, Opts);
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.IO;
using System.Text.Json;
namespace XWorld.PublishTool
{
// resolved-spec.jsonEditor 侧 JsonUtility 产出,字段名小写)→ GameSpec。
// GameSpec 用 public 字段,故必须 IncludeFields;键名大小写不敏感。
public static class GameSpecJson
{
private static readonly JsonSerializerOptions Opts = new JsonSerializerOptions
{
IncludeFields = true,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static GameSpec Parse(string json)
{
var s = JsonSerializer.Deserialize<GameSpec>(json, Opts);
if (s.Assets == null) s.Assets = new System.Collections.Generic.List<string>();
return s;
}
public static GameSpec ParseFile(string path) => Parse(File.ReadAllText(path));
}
}
+22
View File
@@ -0,0 +1,22 @@
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);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
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));
}
}
+73
View File
@@ -0,0 +1,73 @@
using System;
using System.IO;
namespace XWorld.PublishTool
{
// 把构建产物落成发布目录 + 清单 + (可选)签名。
// privateKeyPem 传 null = 不签名:files.txt.sig 落空文件(两端验签均为"未配置则跳过")。
public sealed class PublishLayout
{
// 服务器树:outDir 即 <gamesRoot>/<id>/<ver>/ 的内容(普通文件名,不做 md5 命名)
public void PublishServer(string srcDir, string outDir, GameSpec spec, string privateKeyPem)
{
Directory.CreateDirectory(outDir);
CopyTo(srcDir, spec.ServerAssembly, outDir);
CopyTo(srcDir, spec.CoreDll, outDir);
// game.json 先写,确保其 md5 纳入清单
File.WriteAllText(Path.Combine(outDir, "game.json"), GameJsonWriter.WriteServer(spec));
var fm = new FilesManifest();
foreach (var name in new[] { spec.ServerAssembly, spec.CoreDll, "game.json" })
{
byte[] b = File.ReadAllBytes(Path.Combine(outDir, name));
fm.Add(name, Md5Util.OfBytes(b), b.Length);
}
WriteManifestAndSig(outDir, fm, privateKeyPem);
}
// 客户端树:outDir 即 CDN/minigame/<id>/<ver>/<platform>/ 的内容。
// spec.CodeAb / spec.Assets 均为完整 AB 文件名(code.unity3d / res.unity3d),统一 AB:无散 .bytes。
public void PublishClient(string srcDir, string outDir, GameSpec spec, string privateKeyPem)
{
if (string.IsNullOrEmpty(spec.CodeAb))
throw new InvalidOperationException("spec.CodeAb 为空:客户端发布需要代码 AB(统一 AB 格式)");
Directory.CreateDirectory(outDir);
var fm = new FilesManifest();
CopyMd5Named(srcDir, spec.CodeAb, outDir, fm);
foreach (var ab in spec.Assets) CopyMd5Named(srcDir, ab, outDir, fm);
File.WriteAllText(Path.Combine(outDir, "game.json"), GameJsonWriter.WriteClient(spec));
WriteManifestAndSig(outDir, fm, privateKeyPem);
}
private static void WriteManifestAndSig(string outDir, FilesManifest fm, string privateKeyPem)
{
File.WriteAllText(Path.Combine(outDir, "files.txt"), fm.Render());
byte[] sig = privateKeyPem == null
? Array.Empty<byte>()
: ManifestSigner.Sign(fm.Digest(), privateKeyPem);
File.WriteAllBytes(Path.Combine(outDir, "files.txt.sig"), sig);
}
private static void CopyTo(string srcDir, string name, string dstDir)
=> File.Copy(Path.Combine(srcDir, name), Path.Combine(dstDir, name), true);
// 落盘 md5 命名(name_md5.ext,与 MiniGameDownloader.Md5Name 推导一致),files.txt 登记逻辑名
private static void CopyMd5Named(string srcDir, string name, string dstDir, FilesManifest fm)
{
byte[] data = File.ReadAllBytes(Path.Combine(srcDir, name));
string md5 = Md5Util.OfBytes(data);
File.WriteAllBytes(Path.Combine(dstDir, Md5Name(name, md5)), data);
fm.Add(name, 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}";
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
</PropertyGroup>
</Project>