refactor(plugin): share SDK artifacts by game version and require explicit version declarations

This commit is contained in:
2026-10-04 21:10:13 +08:00
parent 4ceddb4c56
commit abc8368d36
13 changed files with 175 additions and 94 deletions
@@ -6,6 +6,7 @@
<AssemblyName>BD2CaptureEnvironment</AssemblyName>
<RootNamespace>Bd2CaptureEnvironment</RootNamespace>
<BD2PluginVersionKey>capture_environment</BD2PluginVersionKey>
<BD2GameVersion>2.35.10</BD2GameVersion>
</PropertyGroup>
<ItemGroup>
<Reference Include="BepInEx">
+1
View File
@@ -17,5 +17,6 @@
<BD2SdkTool Condition="'$(BD2SdkTool)' == ''">$(BD2PluginsDir)GameSdk/bin/$(BD2SdkConfiguration)/net8.0/GameSdk.dll</BD2SdkTool>
<BD2SdkIntermediateFolder Condition="'$(BD2SdkIntermediateFolder)' == ''">game-sdk</BD2SdkIntermediateFolder>
<BD2GameSdkCache Condition="'$(BD2GameSdkCache)' == ''">$(BD2_GAME_SDK_CACHE)</BD2GameSdkCache>
<BD2GameSdkCache Condition="'$(BD2GameSdkCache)' == ''">$(BD2RepositoryDir).build/game-sdk</BD2GameSdkCache>
</PropertyGroup>
</Project>
+12 -3
View File
@@ -3,19 +3,28 @@
<Compile Include="$(BD2SdkDir)\GameSdkIdentity.g.cs" />
<ProjectReference Include="$(BD2GameNamesProject)" />
<Reference Include="Assembly-CSharp.Readable">
<HintPath>$(BD2SdkDir)\ref\Assembly-CSharp.Readable.dll</HintPath>
<HintPath>$(BD2SharedSdkDir)/ref/Assembly-CSharp.Readable.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<Import Project="$(BD2SdkDir)/GameSourceNavigation.props" Condition="Exists('$(BD2SdkDir)/GameSourceNavigation.props')" />
<Target Name="PrepareBD2GameSdk" BeforeTargets="ResolveProjectReferences;ResolveAssemblyReferences">
<Error Condition="'$(BD2GameVersion)' == ''" Text="Declare BD2GameVersion explicitly in this plugin's csproj, for example &lt;BD2GameVersion&gt;2.35.10&lt;/BD2GameVersion&gt;." />
<Error Condition="'$(GameDir)' == '' and '$(BD2ManagedDir)' == ''" Text="Set BD2LocalGameDir in plugins/Directory.Build.local.props, or pass -p:GameDir=&lt;game-directory&gt;." />
<Error Condition="!Exists('$(BD2ManagedDir)/Assembly-CSharp.dll')" Text="Assembly-CSharp.dll missing in $(BD2ManagedDir). Set GameDir or BD2ManagedDir." />
<Exec StdOutEncoding="utf-8" StdErrEncoding="utf-8" Condition="'$(DesignTimeBuild)' != 'true' or !Exists('$(BD2SdkTool)')" Command="dotnet build &quot;$(BD2SdkProject)&quot; -c $(BD2SdkConfiguration) --nologo" />
<Exec StdOutEncoding="utf-8" StdErrEncoding="utf-8" EnvironmentVariables="BD2_GAME_SDK_CACHE=$(BD2GameSdkCache)" Command="dotnet &quot;$(BD2SdkTool)&quot; prepare-embedded &quot;$(BD2ManagedDir)/Assembly-CSharp.dll&quot; &quot;$(BD2SdkDir)&quot; &quot;$(BD2VersionConfig)&quot;" />
<Exec StdOutEncoding="utf-8" StdErrEncoding="utf-8" EnvironmentVariables="BD2_GAME_SDK_CACHE=$(BD2GameSdkCache)" Command="dotnet &quot;$(BD2SdkTool)&quot; prepare-embedded &quot;$(BD2ManagedDir)/Assembly-CSharp.dll&quot; &quot;$(BD2SdkDir)&quot; &quot;$(BD2VersionConfig)&quot; --game-version &quot;$(BD2GameVersion)&quot;" />
<ReadLinesFromFile File="$(BD2SdkDir)/shared-sdk.txt"><Output TaskParameter="Lines" PropertyName="BD2SharedSdkDir" /></ReadLinesFromFile>
<ItemGroup>
<Reference Remove="Assembly-CSharp.Readable" />
<Reference Include="Assembly-CSharp.Readable">
<HintPath>$(BD2SharedSdkDir)/ref/Assembly-CSharp.Readable.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Target>
<Target Name="ReobfuscateBD2Plugin" AfterTargets="Build" Condition="'$(DesignTimeBuild)' != 'true' and '$(SkipCompilerExecution)' != 'true'">
<Exec StdOutEncoding="utf-8" StdErrEncoding="utf-8" Command="dotnet &quot;$(BD2SdkTool)&quot; reobf &quot;$(BD2SdkDir)\names.json&quot; &quot;$(IntermediateOutputPath)$(TargetFileName)&quot; &quot;$(TargetPath)&quot; &quot;$(BD2ManagedDir)\Assembly-CSharp.dll&quot; &quot;$(BD2BepInExDir)\core&quot;" />
<Exec StdOutEncoding="utf-8" StdErrEncoding="utf-8" Command="dotnet &quot;$(BD2SdkTool)&quot; reobf &quot;$(BD2SharedSdkDir)/names.json&quot; &quot;$(IntermediateOutputPath)$(TargetFileName)&quot; &quot;$(TargetPath)&quot; &quot;$(BD2ManagedDir)\Assembly-CSharp.dll&quot; &quot;$(BD2BepInExDir)\core&quot;" />
<Delete Files="$(TargetDir)$(TargetName).pdb" />
</Target>
</Project>
+7 -2
View File
@@ -37,8 +37,13 @@ $toolVersion = $PackageVersion.Split('-')[0]
Invoke-Dotnet @('publish', (Join-Path $PSScriptRoot 'GameSdk.csproj'), '-c', 'Release', '--nologo', '-o', $tool, "-p:Version=$toolVersion", '-p:UseAppHost=false')
$assembly = Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll'
$toolDll = Join-Path $tool 'GameSdk.dll'
Invoke-Dotnet @($toolDll, 'prepare-embedded', $assembly, $sdk, $VersionConfig)
$table = Join-Path $sdk 'names.json'
$previousSdkCache = $env:BD2_GAME_SDK_CACHE
try {
if (-not $env:BD2_GAME_SDK_CACHE) { $env:BD2_GAME_SDK_CACHE = Join-Path $repository '.build/game-sdk' }
Invoke-Dotnet @($toolDll, 'prepare-embedded', $assembly, $sdk, $VersionConfig)
} finally { $env:BD2_GAME_SDK_CACHE = $previousSdkCache }
$sharedSdk = [IO.File]::ReadAllText((Join-Path $sdk 'shared-sdk.txt')).Trim()
$table = Join-Path $sharedSdk 'names.json'
$runtimeProject = Join-Path $PSScriptRoot '..\GameNames\GameNames.csproj'
Invoke-Dotnet @('pack', $runtimeProject, '-c', 'Release', '--nologo', '-o', $OutputDirectory,
"-p:Version=$PackageVersion", "-p:GameNamesTable=$table", "-p:BaseIntermediateOutputPath=$runtimeObj/", "-p:OutputPath=$runtimeBin/")
@@ -10,7 +10,7 @@
<ItemGroup Condition="'$(BD2GameSdkEnabled)' == 'true'">
<Compile Include="$(BD2SdkDir)/GameSdkIdentity.g.cs" />
<Reference Include="Assembly-CSharp.Readable">
<HintPath>$(BD2SdkDir)/ref/Assembly-CSharp.Readable.dll</HintPath>
<HintPath>$(BD2SharedSdkDir)/ref/Assembly-CSharp.Readable.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="BepInEx" Condition="'$(BD2AddBepInExReferences)' != 'false'"><HintPath>$(BD2BepInExDir)/core/BepInEx.dll</HintPath><Private>false</Private></Reference>
@@ -21,14 +21,23 @@
<Import Project="$(BD2SdkDir)/GameSourceNavigation.props" Condition="Exists('$(BD2SdkDir)/GameSourceNavigation.props') and '$(BD2GameSdkEnabled)' == 'true'" />
<Target Name="PrepareBD2GameSdk" BeforeTargets="ResolveAssemblyReferences;CoreCompile"
Condition="'$(BD2GameSdkEnabled)' == 'true' and '$(IsCrossTargetingBuild)' != 'true'">
<Error Condition="'$(BD2GameVersion)' == ''" Text="BD2.GameSdk: declare BD2GameVersion explicitly in your plugin csproj, for example &lt;BD2GameVersion&gt;2.35.10&lt;/BD2GameVersion&gt;." />
<Error Condition="'$(GameDir)' == '' and '$(BD2ManagedDir)' == '/BrownDust II_Data/Managed'" Text="BD2.GameSdk: set GameDir in Directory.Build.props or pass -p:GameDir=&lt;game-directory&gt;." />
<Error Condition="!Exists('$(BD2ManagedDir)/Assembly-CSharp.dll')" Text="BD2.GameSdk: Assembly-CSharp.dll missing in $(BD2ManagedDir). Set GameDir or BD2ManagedDir." />
<Error Condition="'$(BD2AddBepInExReferences)' != 'false' and !Exists('$(BD2BepInExDir)/core/BepInEx.dll')" Text="BD2.GameSdk: install BepInEx or set BD2BepInExDir. Custom hosts can disable BD2AddBepInExReferences." />
<Exec StdOutEncoding="utf-8" StdErrEncoding="utf-8" EnvironmentVariables="BD2_GAME_SDK_CACHE=$(BD2GameSdkCache)" Command="dotnet &quot;$(BD2SdkTool)&quot; prepare-embedded &quot;$(BD2ManagedDir)/Assembly-CSharp.dll&quot; &quot;$(BD2SdkDir)&quot;" />
<Exec StdOutEncoding="utf-8" StdErrEncoding="utf-8" EnvironmentVariables="BD2_GAME_SDK_CACHE=$(BD2GameSdkCache)" Command="dotnet &quot;$(BD2SdkTool)&quot; prepare-embedded &quot;$(BD2ManagedDir)/Assembly-CSharp.dll&quot; &quot;$(BD2SdkDir)&quot; --game-version &quot;$(BD2GameVersion)&quot;" />
<ReadLinesFromFile File="$(BD2SdkDir)/shared-sdk.txt"><Output TaskParameter="Lines" PropertyName="BD2SharedSdkDir" /></ReadLinesFromFile>
<ItemGroup>
<Reference Remove="Assembly-CSharp.Readable" />
<Reference Include="Assembly-CSharp.Readable">
<HintPath>$(BD2SharedSdkDir)/ref/Assembly-CSharp.Readable.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Target>
<Target Name="ReobfuscateBD2Plugin" AfterTargets="Build"
Condition="'$(BD2GameSdkEnabled)' == 'true' and '$(DesignTimeBuild)' != 'true' and '$(IsCrossTargetingBuild)' != 'true'">
<Exec StdOutEncoding="utf-8" StdErrEncoding="utf-8" Command="dotnet &quot;$(BD2SdkTool)&quot; reobf &quot;$(BD2SdkDir)/names.json&quot; &quot;$(IntermediateOutputPath)$(TargetFileName)&quot; &quot;$(TargetPath)&quot; &quot;$(BD2ManagedDir)/Assembly-CSharp.dll&quot; &quot;$(BD2BepInExDir)/core&quot;" />
<Exec StdOutEncoding="utf-8" StdErrEncoding="utf-8" Command="dotnet &quot;$(BD2SdkTool)&quot; reobf &quot;$(BD2SharedSdkDir)/names.json&quot; &quot;$(IntermediateOutputPath)$(TargetFileName)&quot; &quot;$(TargetPath)&quot; &quot;$(BD2ManagedDir)/Assembly-CSharp.dll&quot; &quot;$(BD2BepInExDir)/core&quot;" />
<Delete Files="$(TargetDir)$(TargetName).pdb" />
</Target>
</Project>
+79 -65
View File
@@ -19,7 +19,18 @@ internal static class Program
switch (args.FirstOrDefault())
{
case "prepare" when args.Length == 5: Prepare(args[1], args[2], args[3], args[4]); break;
case "prepare-embedded" when args.Length is 3 or 4: PrepareEmbedded(args[1], args[2], args.Length == 4 ? args[3] : null); break;
case "prepare-embedded" when args.Length is >= 3 and <= 6:
var prepareOptions = args.Skip(3).ToList();
string expectedGameVersion = null;
int versionOption = prepareOptions.IndexOf("--game-version");
if (versionOption >= 0)
{
if (versionOption + 1 >= prepareOptions.Count) throw new ArgumentException("--game-version requires a version");
expectedGameVersion = prepareOptions[versionOption + 1];
prepareOptions.RemoveRange(versionOption, 2);
}
if (prepareOptions.Count > 1) throw new ArgumentException("Invalid prepare-embedded options");
PrepareEmbedded(args[1], args[2], prepareOptions.FirstOrDefault(), expectedGameVersion); break;
case "export-names" when args.Length == 2: ExportNames(args[1]); break;
case "names" when args.Length == 5: GenerateNames(args[1], args[2], args[3], args[4]); break;
case "shell" when args.Length == 4: GenerateShell(args[1], args[2], args[3]); break;
@@ -64,85 +75,88 @@ internal static class Program
gzip.CopyTo(destination);
}
internal static void PrepareEmbedded(string assembly, string output, string versions = null)
internal static string ResolveSdkDirectory(string directory)
{
byte[] compressed = EmbeddedNames();
string pointer = Path.Combine(directory, "shared-sdk.txt");
return File.Exists(pointer) ? File.ReadAllText(pointer).Trim() : Path.GetFullPath(directory);
}
internal static void PrepareEmbedded(string assembly, string output, string versions = null, string expectedGameVersion = null) =>
PrepareShared(EmbeddedNames(), assembly, output, versions, expectedGameVersion);
internal static void PreparePackage(string compressedTable, string assembly, string output) =>
PrepareShared(File.ReadAllBytes(compressedTable), assembly, output, null, null);
private static void PrepareShared(byte[] compressed, string assembly, string output, string versions, string expectedGameVersion)
{
output = Path.GetFullPath(output);
Directory.CreateDirectory(output);
// IDE loads and command-line builds may prepare the same obj directory.
// Serialize the whole transaction, including the exported table and props.
using var outputLock = AcquireCacheLock(Path.Combine(output, "prepare.lock"), "Waiting for this project's game SDK preparation to finish...");
string tablePath = Path.Combine(output, "names.json"), compressedPath = tablePath + ".gz";
// Keep the cache copy exact; the only authoritative source is the embedded resource.
if (!File.Exists(compressedPath) || !File.ReadAllBytes(compressedPath).SequenceEqual(compressed))
File.WriteAllBytes(compressedPath, compressed);
PreparePackage(compressedPath, assembly, output);
NameTable table;
using (var gzip = new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress))
table = JsonSerializer.Deserialize<NameTable>(gzip, Json);
string gameVersion = table.game_version;
if (string.IsNullOrWhiteSpace(gameVersion) || gameVersion.Any(c => !(char.IsLetterOrDigit(c) || c is '.' or '-' or '_')))
throw new InvalidDataException("Invalid game_version in names table");
if (expectedGameVersion != null && expectedGameVersion != gameVersion)
throw new InvalidDataException($"Plugin requires game {expectedGameVersion}, but SDK names target game {gameVersion}. Install the matching SDK package.");
if (Hash(assembly) != table.assembly_sha256)
throw new InvalidDataException("Game DLL does not match SDK names for game " + gameVersion);
if (versions != null)
{
using var config = JsonDocument.Parse(File.ReadAllText(versions));
string expected = config.RootElement.GetProperty("game_version").GetString();
string actual = ReadTable(tablePath).game_version;
if (expected != actual) throw new InvalidDataException($"Repository game_version {expected} does not match SDK embedded names {actual}; update the SDK names table for this version");
if (expected != gameVersion) throw new InvalidDataException($"Repository game_version {expected} does not match SDK embedded names {gameVersion}");
}
}
internal static void PreparePackage(string compressedTable, string assembly, string output)
{
Directory.CreateDirectory(output);
string tablePath = Path.Combine(output, "names.json"), shellPath = Path.Combine(output, ShellName + ".dll");
string identityPath = Path.Combine(output, "GameSdkIdentity.g.cs"), readyPath = Path.Combine(output, "package-ready.txt");
string pdbPath = Path.Combine(output, "lib", ShellName + ".pdb"), navigationPath = Path.Combine(output, "navigation.json");
string inputs = Hash(compressedTable) + "|" + GeneratorStamp() + "|" + DependencyStamp(assembly);
string[] required = { tablePath, shellPath, identityPath, pdbPath, navigationPath,
Path.Combine(output, "ref", ShellName + ".dll"), Path.Combine(output, "ref", ShellName + ".xml"),
Path.Combine(output, "lib", ShellName + ".dll"), Path.Combine(output, "lib", ShellName + ".xml") };
string Outputs() => string.Join("|", required.Select(Hash));
if (File.Exists(readyPath) && required.All(File.Exists) &&
File.ReadAllText(readyPath) == inputs + "|" + Outputs())
{
WriteNavigationItems(output);
return;
}
File.Delete(readyPath);
using (var gzip = new GZipStream(File.OpenRead(compressedTable), CompressionMode.Decompress))
using (var destination = File.Create(tablePath)) gzip.CopyTo(destination);
GenerateCachedShell(tablePath, assembly, shellPath);
WriteIdentity(tablePath, identityPath);
WriteNavigationItems(output);
File.WriteAllText(readyPath, inputs + "|" + Outputs());
}
private static void GenerateCachedShell(string tablePath, string assembly, string destination)
{
string cacheRoot = Environment.GetEnvironmentVariable("BD2_GAME_SDK_CACHE");
if (string.IsNullOrEmpty(cacheRoot)) cacheRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BD2", "GameSdk", "navigation");
// Changes to any managed dependency can affect decompilation and semantic references.
string dependencyDirectory = Path.GetDirectoryName(Path.GetFullPath(assembly));
string key = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Hash(tablePath) + "|" + GeneratorStamp() + "|" + DependencyStamp(assembly)))).ToLowerInvariant();
string cache = Path.Combine(Path.GetFullPath(cacheRoot), key);
Directory.CreateDirectory(cache);
string cachedShell = Path.Combine(cache, ShellName + ".dll"), ready = Path.Combine(cache, "ready.txt");
using (AcquireCacheLock(Path.Combine(cache, "generation.lock")))
string tableHash = Convert.ToHexString(SHA256.HashData(compressed)).ToLowerInvariant();
string inputs = tableHash + "|" + GeneratorStamp() + "|" + DependencyStamp(assembly);
// Game version is the public grouping; the fingerprint prevents incompatible
// tools or binaries within one game version from overwriting active references.
string key = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(inputs))).ToLowerInvariant();
string shared = Path.Combine(Path.GetFullPath(cacheRoot), gameVersion, key);
Directory.CreateDirectory(shared);
using (AcquireCacheLock(Path.Combine(shared, "generation.lock")))
{
string[] relativeFiles = { ShellName + ".dll", ShellName + ".xml", "navigation.json", "ref/" + ShellName + ".dll", "ref/" + ShellName + ".xml",
"lib/" + ShellName + ".dll", "lib/" + ShellName + ".pdb", "lib/" + ShellName + ".xml", "lib/navigation.json" };
string[] files = relativeFiles.Select(p => Path.Combine(cache, p)).ToArray();
bool complete = File.Exists(ready) && files.All(File.Exists) && File.ReadAllText(ready) == string.Join("|", files.Select(Hash));
if (!complete)
string tablePath = Path.Combine(shared, "names.json"), ready = Path.Combine(shared, "ready.txt");
string[] relativeFiles = { "names.json", "names.json.gz", ShellName + ".dll", ShellName + ".xml", "navigation.json",
"ref/" + ShellName + ".dll", "ref/" + ShellName + ".xml", "lib/" + ShellName + ".dll", "lib/" + ShellName + ".pdb", "lib/" + ShellName + ".xml", "lib/navigation.json" };
string[] files = relativeFiles.Select(p => Path.Combine(shared, p)).ToArray();
string StampFiles() => inputs + "|" + string.Join("|", files.Select(Hash));
if (!File.Exists(ready) || !files.All(File.Exists) || File.ReadAllText(ready) != StampFiles())
{
File.Delete(ready);
GenerateShell(tablePath, assembly, cachedShell);
File.WriteAllText(ready, string.Join("|", files.Select(Hash)));
File.WriteAllBytes(tablePath + ".gz", compressed);
using (var gzip = new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress))
using (var destination = File.Create(tablePath)) gzip.CopyTo(destination);
GenerateShell(tablePath, assembly, Path.Combine(shared, ShellName + ".dll"));
File.WriteAllText(ready, StampFiles());
}
string destinationDirectory = Path.GetDirectoryName(Path.GetFullPath(destination));
foreach (string relative in relativeFiles)
{
if (relative == ShellName + ".xml" || relative == "lib/navigation.json") continue;
string target = Path.Combine(destinationDirectory, relative);
Directory.CreateDirectory(Path.GetDirectoryName(target));
File.Copy(Path.Combine(cache, relative), target, overwrite: true);
}
File.Delete(Path.ChangeExtension(destination, ".xml"));
File.Delete(Path.Combine(destinationDirectory, "lib", "navigation.json"));
WriteNavigationItems(shared);
WriteGeneratedFile(Path.Combine(output, "GameSdkIdentity.g.cs"), System.Text.Encoding.UTF8.GetBytes(
"// Generated from the shared names table.\n[assembly: System.Reflection.AssemblyMetadataAttribute(\"BD2.GameNames\", " + JsonSerializer.Serialize(Stamp(table)) + ")]\n"));
WriteGeneratedFile(Path.Combine(output, "shared-sdk.txt"), System.Text.Encoding.UTF8.GetBytes(shared));
var properties = new System.Xml.Linq.XElement("PropertyGroup",
new System.Xml.Linq.XElement("BD2SharedSdkDir", shared));
var import = new System.Xml.Linq.XElement("Import",
new System.Xml.Linq.XAttribute("Project", Path.Combine(shared, "GameSourceNavigation.props")),
new System.Xml.Linq.XAttribute("Condition", "Exists('" + Path.Combine(shared, "GameSourceNavigation.props") + "')"));
using var props = new MemoryStream();
new System.Xml.Linq.XDocument(new System.Xml.Linq.XElement("Project", properties, import)).Save(props);
WriteGeneratedFile(Path.Combine(output, "GameSourceNavigation.props"), props.ToArray());
}
// Remove only obsolete generated copies in this SDK obj directory after
// publishing a complete shared reference. Never touch hand-written files.
foreach (string name in new[] { "names.json", "names.json.gz", ShellName + ".dll", ShellName + ".xml", "navigation.json", "package-ready.txt", "ready.txt" })
File.Delete(Path.Combine(output, name));
foreach (string folder in new[] { "ref", "lib", "runtime-obj", "runtime-bin" })
{
string target = Path.GetFullPath(Path.Combine(output, folder));
if (Path.GetDirectoryName(target) != output) throw new InvalidDataException("Invalid generated cleanup path");
if (Directory.Exists(target)) Directory.Delete(target, recursive: true);
}
Console.WriteLine($"Shared game SDK {gameVersion}: {shared}");
}
private static FileStream AcquireCacheLock(string path, string message = "Waiting for the shared game source-navigation cache to finish generating...")
+24 -15
View File
@@ -36,6 +36,7 @@ Visual Studio / Rider 也可在 NuGet 包管理界面添加该源,打开“包
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>latest</LangVersion>
<AssemblyName>MyPlugin</AssemblyName>
<BD2GameVersion>2.35.10</BD2GameVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BD2.GameSdk"
@@ -79,25 +80,32 @@ IDE 项目加载/设计时构建会准备可读引用;CLI 首次构建同样
首次全量生成会花费数分钟,日志持续报告已处理的类型数。本机 2.35.10 的一次验证生成了 **24,856 个源码文件、654,265 个声明、319,350 个带方法体的符号**。生成结果按名字表、生成器及其依赖、全部游戏 Managed DLL 的指纹缓存,同一台机器上的插件项目共用;并行构建会等待同一个缓存生成完成。后续构建不再执行全量反编译。
默认共享缓存路径为 `%LOCALAPPDATA%\BD2\GameSdk\navigation`。可用环境变量改变位置:
所有依赖 SDK 的插件必须在自己的 `.csproj` 明确声明目标**游戏版本**:
```powershell
$env:BD2_GAME_SDK_CACHE = 'D:\Caches\BD2.GameSdk'
dotnet build MyPlugin.csproj -c Release '-p:GameDir=E:\Games\BrownDustII'
```xml
<BD2GameVersion>2.35.10</BD2GameVersion>
```
保持这个路径稳定;更改路径、工具或游戏 DLL 后首次构建会重新准备缓存。删除 `obj` 不影响共享源码缓存;缺失的缓存源码可在下次准备时从 PDB 恢复。可删除共享缓存来强制重新生成,它不存放手写源码。
这个值与 SDK/API 工具版本(如 `0.2.1`)不同。构建核对声明值、SDK 内嵌表、真实游戏 DLL 指纹,以及仓库 `versions.json`(仓库插件)。缺少声明或版本不匹配会报错;不会自动选用其他版本。NuGet 项目仍需安装带对应游戏版本的包。
插件 `obj/<配置>/<框架>/bd2-game-sdk` 中的布局遵循 .NET 的引用程序集查找约定(仓库插件使用 `game-sdk` 子目录):
共享目录按 `<缓存根>/<游戏版本>/<内容指纹>/` 存放。同版本的三个插件、Debug/Release、NuGet 项目在输入一致时直接引用同一份产物;内容指纹用于隔离同一游戏版本内不同 SDK 实现或 Managed DLL,避免覆盖正在使用的旧引用。
仓库默认缓存根为 `.build/game-sdk`,与源码位于同一盘。第三方 NuGet 项目默认使用 `%LOCALAPPDATA%\BD2\GameSdk\navigation`;可在本机 `Directory.Build.props` 设置 `BD2GameSdkCache`,或设置环境变量 `BD2_GAME_SDK_CACHE`。删除插件 `obj` 不影响共享缓存。缺失的共享源码可在下次准备时从 PDB 恢复;删除共享缓存才会触发重新生成。
共享目录包含如下产物,**每个插件 obj 不再复制这些大文件**:
| 路径 | 用途 |
| --- | --- |
| `ref/Assembly-CSharp.Readable.dll` | 编译器引用,带引用程序集标记,禁止执行 |
| `lib/Assembly-CSharp.Readable.dll` + `.pdb` | IDE 对应实现和导航符号;源码内嵌,不依赖联网下载 |
| `GameSourceNavigation.props` | 把共享源码链接到项目浏览器 |
| `navigation.json` | 元数据 token 到文件/行列的全量索引、覆盖统计与 DLL/PDB 指纹;schema 2 的文件路径相对于 `SourceRoot` |
| `ref/Assembly-CSharp.Readable.dll` + XML | 编译器引用,带引用程序集标记,禁止执行 |
| `lib/Assembly-CSharp.Readable.dll` + `.pdb` | IDE 对应实现和内嵌源码符号 |
| `lib/sources` | 完整可读源码,供项目浏览与搜索 |
| `names.json` + `.gz` | 同一名字表的缓存导出,供 reobf 使用 |
| `navigation.json` | 全量 token 索引;schema 2 路径相对于 `SourceRoot` |
| `GameSourceNavigation.props` | 共享源码的 MSBuild 文件列表 |
`lib` 中的程序集仅供开发导航,**不要部署或执行**。这里展示的是从当前 DLL 重建的源码,局部变量名和语法可能与开发商原始工程不同。导航 PDB 对应可读程序集;它不能用来在真实混淆游戏 DLL 上逐行调试。
每个插件 `obj/<配置>/<框架>/game-sdk`(NuGet 为 `bd2-game-sdk`)只保留小型 `GameSdkIdentity.g.cs`、`shared-sdk.txt`、导航导入 `.props` 和锁文件。首次构建通过生成的指针设置引用路径,后续 IDE 加载直接导入共享配置;`Game Sources` 仍可浏览搜索。旧布局的大文件副本在成功准备后自动清理。
`lib` 中的程序集仅供开发导航,**不要部署或执行**。这里展示的是当前 DLL 的反编译源码,局部变量名和语法可能与原始工程不同。导航 PDB 对应可读程序集,不能用于真实混淆游戏 DLL 的逐行调试。所有开发产物都不会复制到游戏部署目录。
## 插件代码
@@ -164,7 +172,8 @@ owner.StartCoroutine(Game.MemberName(owner.GetType(), "ReadableCoroutineName"));
| MSBuild 属性 | 用途 |
| --- | --- |
| `GameDir` | 游戏根目录,常规项目唯一必填配置 |
| `BD2GameVersion` | 每个插件 csproj 必填的目标游戏版本,例如 `2.35.10`;不从 SDK 包版本或仓库版本自动推断 |
| `GameDir` | 游戏根目录,本机安装路径(另须在插件 csproj 声明 BD2GameVersion) |
| `BD2ManagedDir` | 自定义 Managed 路径,默认 `GameDir/BrownDust II_Data/Managed` |
| `BD2BepInExDir` | 自定义 BepInEx 路径,默认 `GameDir/BepInEx` |
| `BD2GameSdkCache` | 共享导航缓存目录,可在 `Directory.Build.props` 配置;默认取 `BD2_GAME_SDK_CACHE` 环境变量或用户缓存目录 |
@@ -212,7 +221,7 @@ go run .\cmd\bd2client --dev run
dotnet build plugins/LocalIdentity/LocalIdentity.csproj -c Release '-p:GameDir=<客户端目录>'
```
构建工具从自身资源导出表到 `obj` 缓存,用本机真实 DLL 的元数据生成壳,再做回映射。`GameSdk` 和 `BD2.GameNames` 的唯一名字数据源是仓库内的 `GameNames/Mappings/names.json.gz`。两份程序集内嵌的是同一份压缩字节,不维护第二份映射;`obj` 里的表只是可以删除重建的缓存。NuGet 包同样不再包含单独的 `tools/data` 表文件。
构建工具把表和可读引用生成到按游戏版本分组的共享缓存,插件 `obj` 只保存该目录的指针,再使用共享表做回映射。`GameSdk` 和 `BD2.GameNames` 的唯一名字数据源是仓库内的 `GameNames/Mappings/names.json.gz`。两份程序集内嵌的是同一份压缩字节,不维护第二份映射;共享目录里的表只是可以删除重建的缓存。NuGet 包同样不再包含单独的 `tools/data` 表文件。
表包含 `game_version`、完整类型名、成员声明类型/签名/metadata token、参数映射、真实 DLL 的 MVID/SHA-256 和官方映射 SHA-256。构建时先验证 DLL 指纹,仓库构建还核对 `versions.json`;不匹配会要求更新 SDK,不会尝试使用其他版本或猜名字。插件启动时核对表指纹、游戏 DLL 和少量已知条目。
@@ -235,7 +244,7 @@ dotnet build plugins/LocalIdentity/LocalIdentity.csproj -c Release '-p:GameDir=<
```powershell
dotnet build plugins/GameSdk/GameSdk.csproj -c Release
$tool = 'plugins/GameSdk/bin/Release/net8.0/GameSdk.dll'
dotnet $tool prepare-embedded '<Assembly-CSharp.dll>' '<输出目录>'
dotnet $tool prepare-embedded '<Assembly-CSharp.dll>' '<插件 obj 指针目录>' --game-version 2.35.10
dotnet $tool export-names '<导出的 names.json>'
dotnet $tool reobf '<names.json>' '<可读插件.dll>' '<运行插件.dll>' '<Assembly-CSharp.dll>' '<BepInEx/core>'
dotnet $tool verify '<names.json>' '<运行插件.dll>' '<Assembly-CSharp.dll>'
@@ -244,6 +253,6 @@ dotnet $tool verify-navigation '<生成的 SDK 目录>'
dotnet $tool self-test
```
手动编译须引用 `ref/Assembly-CSharp.Readable.dll` 并编译同目录 `GameSdkIdentity.g.cs`,保留相邻 `lib` 以供 IDE 查找。`verify-navigation` 全量检查 PE/PDB 身份、内嵌/本地源码校验和、类型文档和全部方法体的符号。
手动编译通过 `shared-sdk.txt` 找到共享目录,引用其中 `ref/Assembly-CSharp.Readable.dll`,并编译指针目录中的 `GameSdkIdentity.g.cs`;共享目录相邻 `lib` 供 IDE 查找。`verify-navigation` 全量检查 PE/PDB 身份、内嵌/本地源码校验和、类型文档和全部方法体的符号。
`self-test` 生成合成游戏 DLL,验证重载、泛型、继承、嵌套/编译器生成类型、私有成员、事件、参数、表达式、字符串不变和版本拒绝。`.build/game-sdk-tests` 仅保存每次自测的临时产物,不参与 SDK 构建、源码导航或客户端启动,用完可以删除;下次自测会重新生成。`VerifyPackages.ps1` 验证仓库外 NuGet 项目及完整源码/PDB 覆盖。实际 Unity/Harmony 行为需在游戏启动后检查日志。
+1
View File
@@ -146,6 +146,7 @@ internal static class SourceNavigation
internal static void Verify(string sdkDirectory)
{
sdkDirectory = Program.ResolveSdkDirectory(sdkDirectory);
string library = Path.Combine(sdkDirectory, "lib", Program.ShellName + ".dll"), pdbPath = Path.ChangeExtension(library, ".pdb");
var manifest = JsonSerializer.Deserialize<Manifest>(File.ReadAllText(Path.Combine(sdkDirectory, "navigation.json")));
if (Program.Hash(library) != manifest.AssemblySha256 || Program.Hash(pdbPath) != manifest.PdbSha256)
+33 -4
View File
@@ -14,7 +14,8 @@ if (-not $PackageVersion) {
$PackageDirectory = [IO.Path]::GetFullPath($PackageDirectory)
# A fresh external directory and package cache prevent repository imports and stale
# same-version development packages from making an invalid package appear to work.
$workspace = Join-Path ([IO.Path]::GetTempPath()) ('BD2 NuGet Test ' + [guid]::NewGuid().ToString('N'))
$verificationRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../.build/nuget-tests'))
$workspace = Join-Path $verificationRoot ([guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $workspace | Out-Null
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'samples\ExamplePlugin\ExamplePlugin.csproj'), (Join-Path $PSScriptRoot 'samples\ExamplePlugin\Plugin.cs') -Destination $workspace
$project = Join-Path $workspace 'ExamplePlugin.csproj'
@@ -27,7 +28,8 @@ function Invoke-Dotnet([string[]]$Arguments) {
& dotnet @Arguments
if ($LASTEXITCODE -ne 0) { throw "dotnet failed with exit code $LASTEXITCODE" }
}
Invoke-Dotnet @('build', $project, '-c', 'Release', '--nologo', "-p:GameDir=$GameDir")
$sharedCacheRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../.build/game-sdk'))
Invoke-Dotnet @('build', $project, '-c', 'Release', '--nologo', "-p:GameDir=$GameDir", "-p:BD2GameSdkCache=$sharedCacheRoot")
$output = Join-Path $workspace 'bin\Release\netstandard2.1'
$runtime = Join-Path $output 'BD2.GameNames.dll'
foreach ($required in @('BD2.GameNames.dll', 'ExamplePlugin.dll')) {
@@ -38,16 +40,43 @@ foreach ($forbidden in @('Assembly-CSharp.Readable.dll', 'Assembly-CSharp.Readab
}
$tool = Join-Path $workspace "packages\bd2.gamesdk\$PackageVersion\tools\net8.0\GameSdk.dll"
$sdk = Join-Path $workspace 'obj\Release\netstandard2.1\bd2-game-sdk'
$table = Join-Path $sdk 'names.json'
$sharedSdk = [IO.File]::ReadAllText((Join-Path $sdk 'shared-sdk.txt')).Trim()
$table = Join-Path $sharedSdk 'names.json'
if ((Get-ChildItem -LiteralPath $sdk -Recurse -File | Measure-Object Length -Sum).Sum -gt 65536) {
throw 'SDK obj still contains large copied artifacts instead of shared references.'
}
[xml]$exampleProject = Get-Content -LiteralPath $project -Raw
if ([IO.Path]::GetFileName([IO.Path]::GetDirectoryName($sharedSdk)) -ne $exampleProject.Project.PropertyGroup.BD2GameVersion) {
throw 'Shared SDK directory is not grouped by the plugin-declared game version.'
}
$sharedReadyTimestamp = (Get-Item -LiteralPath (Join-Path $sharedSdk 'ready.txt')).LastWriteTimeUtc
Invoke-Dotnet @($tool, 'verify-runtime', $table, $runtime)
Invoke-Dotnet @($tool, 'verify-navigation', $sdk)
Invoke-Dotnet @($tool, 'verify', $table, (Join-Path $output 'ExamplePlugin.dll'), (Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll'))
# Repeated builds must always transform the readable obj DLL, never reprocess bin.
$navigationProps = Join-Path $sdk 'GameSourceNavigation.props'
$navigationTimestamp = (Get-Item -LiteralPath $navigationProps).LastWriteTimeUtc
Invoke-Dotnet @('build', $project, '-c', 'Release', '--no-restore', '--nologo', "-p:GameDir=$GameDir")
Invoke-Dotnet @('build', $project, '-c', 'Release', '--no-restore', '--nologo', "-p:GameDir=$GameDir", "-p:BD2GameSdkCache=$sharedCacheRoot")
if ((Get-Item -LiteralPath $navigationProps).LastWriteTimeUtc -ne $navigationTimestamp) {
throw 'Unchanged navigation props were rewritten; this triggers repeated IDE reloads.'
}
if ((Get-Item -LiteralPath (Join-Path $sharedSdk 'ready.txt')).LastWriteTimeUtc -ne $sharedReadyTimestamp) {
throw 'Repeated build unexpectedly regenerated the shared SDK.'
}
Invoke-Dotnet @($tool, 'verify', $table, (Join-Path $output 'ExamplePlugin.dll'), (Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll'))
foreach ($invalidVersion in @('', '0.0.0')) {
$rejectedBuild = & dotnet build $project -c Release --no-restore --nologo "-p:GameDir=$GameDir" "-p:BD2GameSdkCache=$sharedCacheRoot" "-p:BD2GameVersion=$invalidVersion" 2>&1 | Out-String
if ($LASTEXITCODE -eq 0 -or ($invalidVersion -eq '' -and -not $rejectedBuild.Contains('BD2GameVersion')) -or
($invalidVersion -ne '' -and -not $rejectedBuild.Contains('Plugin requires game 0.0.0'))) {
throw "Missing/mismatched game version was not rejected: $invalidVersion"
}
}
Write-Host "Verified external PackageReference consumer, complete source navigation, shared runtime and incremental rebuild: $workspace"
# Successful probes are disposable. Retain a failed probe for diagnostics, but
# do not accumulate package caches after successful verification.
$resolvedProbe = [IO.Path]::GetFullPath($workspace)
if ([IO.Path]::GetDirectoryName($resolvedProbe) -ne $verificationRoot -or
[IO.Path]::GetFileName($resolvedProbe) -notmatch '^[a-f0-9]{32}$') {
throw 'Invalid verification cleanup path.'
}
Remove-Item -LiteralPath $resolvedProbe -Recurse -Force
@@ -3,6 +3,7 @@
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>latest</LangVersion>
<AssemblyName>ExamplePlugin</AssemblyName>
<BD2GameVersion>2.35.10</BD2GameVersion>
<!-- GameDir belongs in local Directory.Build.props or on the command line. -->
</PropertyGroup>
<ItemGroup>
@@ -6,6 +6,7 @@
<AssemblyName>BD2LocalIdentity</AssemblyName>
<RootNamespace>Bd2LocalIdentity</RootNamespace>
<BD2PluginVersionKey>local_identity</BD2PluginVersionKey>
<BD2GameVersion>2.35.10</BD2GameVersion>
</PropertyGroup>
<ItemGroup>
<Reference Include="BepInEx">
+1
View File
@@ -6,6 +6,7 @@
<AssemblyName>BD2LoginUI</AssemblyName>
<RootNamespace>Bd2LoginUI</RootNamespace>
<BD2PluginVersionKey>login_ui</BD2PluginVersionKey>
<BD2GameVersion>2.35.10</BD2GameVersion>
</PropertyGroup>
<ItemGroup>
<Reference Include="BepInEx">
+2 -2
View File
@@ -11,7 +11,7 @@
SDK 和共享运行时已内嵌当前游戏版本的同一份名字表。源码构建只需客户端目录,无需提供官方映射。`.NET 8 SDK` 构建工具;插件为 `netstandard2.1`,运行时为 `netstandard2.0`。构建自动生成可读引用、完整源码和内嵌源码 PDB,编译插件、执行 reobf 并验证真实 DLL 的引用。首次生成耗时数分钟,三个插件和 Debug/Release 共用本机缓存;IDE 转到定义可以查看方法体,`Game Sources` 文件夹提供全量浏览与搜索。IDE 设置见 [完整源码导航](GameSdk/README.md#完整源码导航)。
仓库公共路径和默认值集中在 `plugins/Directory.Build.props`;配置/框架相关的输出路径与构建步骤由 `Directory.Build.targets` 自动导入。三个插件 `.csproj` 只保留插件属性和需要的程序集引用。首次开发复制 `Directory.Build.local.props.example` 为 `Directory.Build.local.props`,填写 `BD2LocalGameDir` 和可选的 `BD2CaptureGameDir`;本机文件被 Git 忽略。配置后 IDE 与命令行共用这些路径,不需要在三个子目录分别放一份 `Directory.Build.props`。命令行 `-p:GameDir=...` 仍可覆盖本机设置。
仓库公共路径和默认值集中在 `plugins/Directory.Build.props`;配置/框架相关的输出路径与构建步骤由 `Directory.Build.targets` 自动导入。三个插件 `.csproj` 保留插件属性、明确的 `<BD2GameVersion>2.35.10</BD2GameVersion>` 和需要的程序集引用。目标游戏版本必须由每个插件声明,缺失或与 SDK 表不符时构建报错。首次开发复制 `Directory.Build.local.props.example` 为 `Directory.Build.local.props`,填写 `BD2LocalGameDir` 和可选的 `BD2CaptureGameDir`;本机文件被 Git 忽略。配置后 IDE 与命令行共用这些路径,不需要在三个子目录分别放一份 `Directory.Build.props`。命令行 `-p:GameDir=...` 仍可覆盖本机设置。
`BD2GameSdkCache` 可放在本机 props 中指定共享源码缓存。特殊目录布局可以设置 `BD2ManagedDir`、`BD2BepInExDir`;`BD2SdkDir` 可覆盖中间产物目录。新增子目录 `Directory.Build.props` 会阻断 MSBuild 自动查找父文件,确有需要时应显式导入公共 `plugins/Directory.Build.props`。
@@ -21,7 +21,7 @@ dotnet build .\plugins\LoginUI\LoginUI.csproj -c Release -p:GameDir="<本地服
dotnet build .\plugins\CaptureEnvironment\CaptureEnvironment.csproj -c Release -p:GameDir="<原版客户端目录>"
```
`bin` 中的插件是回映射后的运行产物,部署时同时复制相邻的 `BD2.GameNames.dll`。壳和明文表缓存在 `obj`,不部署。客户端开发入口 `go run .\cmd\bd2client --dev run` 自动使用内嵌表;发布和原版抓包启动脚本也携带共享库。
`bin` 中的插件是回映射后的运行产物,部署时同时复制相邻的 `BD2.GameNames.dll`。可读程序集、PDB、源码和名字表统一保存在 `.build/game-sdk/<游戏版本>/<内容指纹>`,各插件 `obj` 只留小型指针和生成配置,不部署。客户端开发入口 `go run .\cmd\bd2client --dev run` 自动使用内嵌表;发布和原版抓包启动脚本也携带共享库。
唯一名字数据源为 `GameNames/Mappings/names.json.gz`。只有维护者更新游戏版本的表时才需要官方 `.obfuscate`,执行 `GameSdk/UpdateNames.ps1`,验证后提交新表,再发布新版本 SDK。