fix(all): show client startup progress and stabilize SDK cache

This commit is contained in:
2026-10-03 23:35:18 +08:00
parent 992fa47fd5
commit c4f77881ed
7 changed files with 21 additions and 143 deletions
+4 -3
View File
@@ -6,10 +6,11 @@
## 开发构建
外部 PowerShell 终端从 `go` 目录执行 `. .\env.ps1`,将当前终端的 Go 缓存设为 `go/.cache/go-build`,不修改用户级 Go 配置。VS Code 可在工作区 `terminal.integrated.env.windows` 和 `go.toolsEnvVars` 中配置同一路径,让本项目的新终端自动生效。
```powershell
# Go
Push-Location .\go
$env:GOCACHE = (Join-Path $PWD '.cache\go-build')
go test ./...
go build .\cmd\bd2server
go build .\cmd\bd2client
@@ -19,17 +20,17 @@ Pop-Location
开发时在两个终端中分别从 `go` 目录运行:
```powershell
$env:GOCACHE = (Join-Path $PWD '.cache\go-build')
go run .\cmd\bd2server --dev run
```
```powershell
$env:GOCACHE = (Join-Path $PWD '.cache\go-build')
go run .\cmd\bd2client --dev run
```
服务端开发入口会自动使用仓库根目录的版本、认证、资源、数据和存档配置;客户端开发入口会直接使用仓库版本清单和插件产物,并从本机开发配置读取游戏目录,随后用 SDK 内嵌的当前版本名字表增量构建两个客户端插件,无需提供官方映射文件。首次开发前创建该配置:
客户端窗口在插件准备完成后打开。首次生成完整游戏源码或 SDK/游戏更新后可能等待数分钟,终端会实时显示构建和反编译进度;后续启动复用共享缓存。
```powershell
Copy-Item .\go\config.example.json .\go\config.json
```
+10 -2
View File
@@ -3,6 +3,7 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -177,6 +178,7 @@ func buildDevelopmentPlugins(root, gameDir string) error {
filepath.Join(root, "plugins", "LoginUI", "LoginUI.csproj"),
}
for _, project := range projects {
fmt.Fprintf(os.Stderr, "bd2client: preparing development plugin %s; first-time game source generation may take several minutes\n", filepath.Base(project))
command := exec.Command(
"dotnet", "build", project, "-c", "Release",
"-p:GameDir="+filepath.Clean(gameDir),
@@ -185,9 +187,15 @@ func buildDevelopmentPlugins(root, gameDir string) error {
"--nologo",
)
command.Dir = root
output, err := command.CombinedOutput()
// SDK preparation can decompile the entire game before the window opens.
// Stream progress immediately, while retaining diagnostics for failures.
var output bytes.Buffer
writer := io.MultiWriter(os.Stderr, &output)
command.Stdout = writer
command.Stderr = writer
err := command.Run()
if err != nil {
message := strings.TrimSpace(string(output))
message := strings.TrimSpace(output.String())
if message == "" {
message = err.Error()
}
+3
View File
@@ -0,0 +1,3 @@
# Dot-source in an external PowerShell terminal: . .\env.ps1
# Applies only to this terminal and child processes; no user-level Go settings.
$env:GOCACHE = Join-Path $PSScriptRoot '.cache\go-build'
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<!-- Exercise Visual Studio's actual metadata-as-source provider, not a mock. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.11.0" />
</ItemGroup>
</Project>
-116
View File
@@ -1,116 +0,0 @@
using System.Reflection;
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.CSharp.Syntax;
if (args.Length == 0)
throw new ArgumentException("Usage: GameSdk.NavigationTests <sdk-directory> [game-managed-directory] [--embedded-only]");
var hiddenSources = new List<(string Original, string Hidden)>();
try
{
// Missing local source proves that navigation uses the embedded documents.
if (args.Contains("--embedded-only"))
{
using var manifest = JsonDocument.Parse(File.ReadAllText(Path.Combine(args[0], "navigation.json")));
string root = manifest.RootElement.GetProperty("SourceRoot").GetString()!;
string fixtures = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, ".build", "game-sdk-tests")) + Path.DirectorySeparatorChar;
if (!Path.GetFullPath(root).StartsWith(fixtures, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Embedded-only tests may only move generated synthetic fixture sources.");
foreach (string source in Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories))
{
string hidden = source + ".embedded-test";
File.Move(source, hidden);
hiddenSources.Add((source, hidden));
}
}
// Reflection accesses Visual Studio's internal Roslyn service. A mock would
// miss the reference-assembly lookup and embedded-document loader failures.
var features = Assembly.Load("Microsoft.CodeAnalysis.Features");
var host = MefHostServices.Create(MefHostServices.DefaultAssemblies
.Add(features).Add(Assembly.Load("Microsoft.CodeAnalysis.CSharp.Features")));
var exportProvider = host.GetType().GetInterfaces().Single(t => t.Name == "IMefHostExportProvider");
var getExports = exportProvider.GetMethods().Single(m => m.Name == "GetExports" && m.GetGenericArguments().Length == 1);
var contract = features.GetType("Microsoft.CodeAnalysis.MetadataAsSource.IMetadataAsSourceFileService")!;
var exports = (System.Collections.IEnumerable)getExports.MakeGenericMethod(contract).Invoke(host, null)!;
object export = exports.Cast<object>().First();
object service = export.GetType().GetProperty("Value")!.GetValue(export)!;
using var workspace = new AdhocWorkspace(host);
string reference = Path.Combine(Path.GetFullPath(args[0]), "ref", "Assembly-CSharp.Readable.dll");
IEnumerable<string> referencePaths = args.Length > 1 && !args[1].StartsWith("--")
? Directory.GetFiles(Path.GetFullPath(args[1]), "*.dll").Where(p => Path.GetFileName(p) != "Assembly-CSharp.dll")
: ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator);
var references = referencePaths.Select(p => MetadataReference.CreateFromFile(p))
.Append(MetadataReference.CreateFromFile(reference));
var project = workspace.AddProject("Consumer", LanguageNames.CSharp)
.WithMetadataReferences(references)
.WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
if (!workspace.TryApplyChanges(project.Solution)) throw new InvalidOperationException("Cannot create navigation consumer project.");
project = workspace.CurrentSolution.GetProject(project.Id)!;
var compilation = (await project.GetCompilationAsync())!;
bool fixture = compilation.GetTypeByMetadataName("Readable.Agent") is not null;
var type = compilation.GetTypeByMetadataName(fixture ? "Readable.Agent" : "BDNetwork.NetworkManager")
?? throw new InvalidOperationException("Navigation test type missing.");
var optionsType = features.GetType("Microsoft.CodeAnalysis.MetadataAsSource.MetadataAsSourceOptions")!;
object options = optionsType.GetMethod("GetDefault", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!
.Invoke(null, new object[] { project.Services })!;
optionsType.GetProperty("NavigateToSourceLinkAndEmbeddedSources")!.SetValue(options, true);
optionsType.GetProperty("NavigateToDecompiledSources")!.SetValue(options, false);
var symbols = new List<ISymbol> { type };
if (fixture)
{
var ping = type.GetMembers("Ping").OfType<IMethodSymbol>().Single(m => m.Parameters[0].Type.SpecialType == SpecialType.System_Int32);
symbols.AddRange(new ISymbol[] {
ping, type.GetMembers("Ping").OfType<IMethodSymbol>().Single(m => m.Parameters[0].Type.SpecialType == SpecialType.System_String),
ping.Parameters[0], type.GetMembers("Echo").Single(), type.GetMembers("Value").Single(), type.GetMembers("Changed").Single()
});
var box = compilation.GetTypeByMetadataName("Readable.Box`1")!;
symbols.AddRange(new ISymbol[] { box, box.GetMembers("Echo").Single(), box.GetTypeMembers("Nested").Single() });
}
else
{
symbols.AddRange(new ISymbol[] {
type.GetMembers("Send").First(), type.GetMembers("Refresh").Single(), type.GetMembers("BatchCount").Single(),
type.GetMembers("UpdateNowFunc").Single(), type.GetMembers("OnRequestComplete").Single(), type.GetTypeMembers("BatchObject").Single()
});
}
foreach (var symbol in symbols)
{
var task = (Task)contract.GetMethod("GetGeneratedFileAsync")!
.Invoke(service, new object[] { workspace, project, symbol, false, options, CancellationToken.None })!;
await task;
object result = task.GetType().GetProperty("Result")!.GetValue(task)!;
string path = (string)result.GetType().GetProperty("FilePath")!.GetValue(result)!;
string source = File.ReadAllText(path);
if (!path.Contains("PdbSourceDocumentMetadataAsSourceFileProvider", StringComparison.Ordinal))
throw new InvalidDataException("Navigation fell back from PDB source: " + path);
if (!source.Contains(fixture ? "return value;" : "Path.Combine", StringComparison.Ordinal))
throw new InvalidDataException("Navigation did not load complete method bodies: " + path);
var location = (Location)result.GetType().GetProperty("IdentifierLocation")!.GetValue(result)!;
if (!location.IsInSource) throw new InvalidDataException("Navigation has no source declaration.");
string identifier = source.Substring(location.SourceSpan.Start, location.SourceSpan.Length);
if (identifier != symbol.Name)
throw new InvalidDataException($"Navigation selected '{identifier}' instead of '{symbol.Name}'.");
if (fixture && symbol is IMethodSymbol { Name: "Ping" } method)
{
var syntax = CSharpSyntaxTree.ParseText(source).GetRoot().FindToken(location.SourceSpan.Start)
.Parent!.AncestorsAndSelf().OfType<MethodDeclarationSyntax>().First();
string expected = method.Parameters[0].Type.SpecialType == SpecialType.System_Int32 ? "int" : "string";
if (syntax.ParameterList.Parameters[0].Type!.ToString() != expected)
throw new InvalidDataException("Navigation selected the wrong Ping overload.");
}
Console.WriteLine($"{symbol.ToDisplayString()} => {path}");
}
Console.WriteLine($"Visual Studio Roslyn PDB-source navigation: {symbols.Count} declarations resolved to exact identifiers and complete source; embedded-only={hiddenSources.Count > 0}.");
}
finally
{
foreach (var source in hiddenSources) File.Move(source.Hidden, source.Original);
}
+3
View File
@@ -6,6 +6,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<IsPackable>false</IsPackable>
<!-- The navigation cache hashes this DLL. Unrelated repository commits
must not change it through informational-version metadata. -->
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ICSharpCode.Decompiler" Version="9.1.0.7988" />
+1 -9
View File
@@ -246,12 +246,4 @@ dotnet $tool self-test
手动编译须引用 `ref/Assembly-CSharp.Readable.dll` 并编译同目录 `GameSdkIdentity.g.cs`,保留相邻 `lib` 以供 IDE 查找。`verify-navigation` 全量检查 PE/PDB 身份、内嵌/本地源码校验和、类型文档和全部方法体的符号。
`self-test` 生成合成游戏 DLL,验证重载、泛型、继承、嵌套/编译器生成类型、私有成员、事件、参数、表达式、字符串不变和版本拒绝;它会打印合成 SDK 目录。使用实际 Roslyn 引擎验证该目录的导航:
```powershell
dotnet run --project plugins/GameSdk.NavigationTests -- '<合成 SDK 目录>' --embedded-only
# 或验证完整游戏导航:
dotnet run --project plugins/GameSdk.NavigationTests -- '<插件 obj 中的 SDK 目录>' '<游戏 Managed 目录>'
```
嵌入源码测试临时移走合成 fixture 的 `.cs`,结束时恢复;它限制在 `.build/game-sdk-tests` 下运行。测试断言类型、重载、泛型、参数、字段、事件和嵌套类型跳到准确的源码标识符,并拒绝签名/反编译回退。`VerifyPackages.ps1` 验证仓库外 NuGet 项目。实际 Unity/Harmony 行为需在游戏启动后检查日志。
`self-test` 生成合成游戏 DLL,验证重载、泛型、继承、嵌套/编译器生成类型、私有成员、事件、参数、表达式、字符串不变和版本拒绝。`.build/game-sdk-tests` 仅保存每次自测的临时产物,不参与 SDK 构建、源码导航或客户端启动,用完可以删除;下次自测会重新生成。`VerifyPackages.ps1` 验证仓库外 NuGet 项目及完整源码/PDB 覆盖。实际 Unity/Harmony 行为需在游戏启动后检查日志。