feat(all): add game SDK and harden client login and game rules

- Add costume burst upgrades with durable replay and fix permanent SR equipment gacha.
- Add server game.json rules for optional collaboration UR weapons, with packaging and documentation.
- Add GameNames/GameSdk NuGet tooling, readable types, reobfuscation, complete source/PDB navigation and shared-runtime installation.
- Split plugin responsibilities, prefer typeof/nameof, centralize MSBuild settings and ignore local paths; remove the unused LoginUI test project.
- Fix login recovery and TLS through native platform HTTP and a private game relay; read device region directly from the OS time zone.

Validation: go test ./... -count=1, go vet ./..., plugin Release builds, SDK/runtime self-tests, Roslyn embedded-source navigation, gofmt check and git diff --check.
This commit is contained in:
2026-10-03 23:04:32 +08:00
parent 5600f10b31
commit 992fa47fd5
83 changed files with 8970 additions and 2828 deletions
+243
View File
@@ -0,0 +1,243 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.Serialization.Json;
using System.Security.Cryptography;
using BD2.GameNames.Internal;
namespace BD2.GameNames;
/// <summary>Readable names for string-based reflection. Unknown names retain their literal spelling.</summary>
public static class Game
{
private sealed class Index
{
internal readonly NameTable Table;
internal readonly Dictionary<string, TypeName> Types;
internal readonly Dictionary<int, MemberName> Tokens;
internal Index()
{
using (var stream = typeof(Game).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz"))
using (var gzip = new GZipStream(stream ?? throw new InvalidDataException("BD2.GameNames: embedded table missing"), CompressionMode.Decompress))
Table = (NameTable)new DataContractJsonSerializer(typeof(NameTable)).ReadObject(gzip);
if (Table.schema_version != 1) throw new InvalidDataException("BD2.GameNames: unsupported table schema");
Types = Table.types.ToDictionary(t => t.readable, StringComparer.Ordinal);
Tokens = Table.members.ToDictionary(m => m.token);
}
}
private static readonly Lazy<Index> Names = new Lazy<Index>(() => new Index());
private static readonly ConcurrentDictionary<string, Type> TypeCache = new ConcurrentDictionary<string, Type>();
private static readonly ConcurrentDictionary<string, MemberInfo> MemberCache = new ConcurrentDictionary<string, MemberInfo>();
private static readonly object ValidationLock = new object();
private static string ValidatedStamp;
/// <summary>Gets the game version embedded in this package.</summary>
public static string GameVersion => Names.Value.Table.game_version;
/// <summary>Translates a full readable type name. Unknown names retain their literal spelling.</summary>
public static string TypeName(string readableFullName)
{
if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName));
return Names.Value.Types.TryGetValue(readableFullName.Replace('/', '+'), out var entry) ? entry.original : readableFullName;
}
/// <summary>Finds a readable type in Assembly-CSharp or loaded assemblies; returns null when absent.</summary>
public static Type FindType(string readableFullName)
{
if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName));
if (TypeCache.TryGetValue(readableFullName, out var cached)) return cached;
string original = TypeName(readableFullName);
Type found = GameAssembly().GetType(original, false);
if (found == null)
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
found = assembly.GetType(original, false);
if (found != null) break;
}
if (found != null) TypeCache.TryAdd(readableFullName, found);
return found;
}
private static Assembly GameAssembly() => Assembly.Load("Assembly-CSharp");
private static bool Matches(MemberInfo member, string kind, string readable)
{
if (member == null) throw new ArgumentNullException(nameof(member));
if (readable == null) throw new ArgumentNullException(nameof(readable));
// Metadata tokens retain declaration identity through inheritance and closed generics.
// A hidden literal member must not be mistaken for a renamed member of a base class.
if (member.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(member.MetadataToken, out var entry))
return entry.kind == kind && entry.readable == readable;
return member.Name == readable;
}
/// <summary>Translate coroutine/member strings. Overloads with different runtime names require Method/MethodName(MethodInfo).</summary>
public static string MemberName(Type type, string readable, GameMemberKind kind = GameMemberKind.Method)
{
if (type == null) throw new ArgumentNullException(nameof(type));
if (readable == null) throw new ArgumentNullException(nameof(readable));
string key = kind switch
{
GameMemberKind.Method => "method",
GameMemberKind.Field => "field",
GameMemberKind.Property => "property",
GameMemberKind.Event => "event",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
var reflected = kind switch
{
GameMemberKind.Method => type.GetMethods(all).Cast<MemberInfo>(),
GameMemberKind.Field => type.GetFields(all).Cast<MemberInfo>(),
GameMemberKind.Property => type.GetProperties(all).Cast<MemberInfo>(),
GameMemberKind.Event => type.GetEvents(all).Cast<MemberInfo>(),
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
var names = reflected.Where(m => Matches(m, key, readable)).Select(m => m.Name).Distinct().ToArray();
if (names.Length == 0) return readable;
if (names.Length != 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature");
return names[0];
}
/// <summary>Returns the actual CLR method name for APIs that accept strings.</summary>
public static string MethodName(MethodInfo method) => method?.Name ?? throw new ArgumentNullException(nameof(method));
/// <summary>Tests a reflected method against its readable name, preserving overload identity.</summary>
public static bool IsGameMethod(this MethodInfo method, string readable) => Matches(method, "method", readable);
/// <summary>Translates a readable parameter name using the method declaration; unknown names remain unchanged.</summary>
public static string ParameterName(MethodBase method, string readable)
{
if (method == null) throw new ArgumentNullException(nameof(method));
if (readable == null) throw new ArgumentNullException(nameof(readable));
if (method.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(method.MetadataToken, out var entry))
{
var p = entry.parameters.SingleOrDefault(n => n.readable == readable);
if (p != null) return p.original;
}
return readable;
}
/// <summary>Extracts a MethodInfo from a call expression without executing the expression.</summary>
public static MethodInfo Method<T>(Expression<Action<T>> expression) => Call(expression);
/// <summary>Extracts a MethodInfo from a call expression without executing the expression.</summary>
public static MethodInfo Method(Expression<Action> expression) => Call(expression);
/// <summary>Extracts a property getter from an expression without evaluating the property.</summary>
public static MethodInfo Getter<T, TResult>(Expression<Func<T, TResult>> expression) => PropertyGetter(expression);
/// <summary>Extracts a property getter from an expression without evaluating the property.</summary>
public static MethodInfo Getter<TResult>(Expression<Func<TResult>> expression) => PropertyGetter(expression);
private static MethodInfo Call(LambdaExpression expression)
{
if (expression == null) throw new ArgumentNullException(nameof(expression));
return expression.Body is MethodCallExpression call ? call.Method : throw new ArgumentException("Expected a method call", nameof(expression));
}
private static MethodInfo PropertyGetter(LambdaExpression expression)
{
if (expression == null) throw new ArgumentNullException(nameof(expression));
return expression.Body is MemberExpression member && member.Member is PropertyInfo property
? property.GetGetMethod(true) : throw new ArgumentException("Expected a property access", nameof(expression));
}
private static T Cached<T>(Type type, string kind, string readable, BindingFlags flags, Type[] parameters, Func<T> resolve) where T : MemberInfo
{
if (readable == null) throw new ArgumentNullException(nameof(readable));
if (type == null) return null;
string key = type.AssemblyQualifiedName + "|" + kind + "|" + readable + "|" + (int)flags + "|" + (parameters == null ? "*" : string.Join(";", parameters.Select(p => p.AssemblyQualifiedName)));
if (MemberCache.TryGetValue(key, out var cached)) return (T)cached;
T found = resolve();
if (found != null) MemberCache.TryAdd(key, found);
return found;
}
/// <summary>Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw.</summary>
public static MethodInfo GetGameMethod(this Type type, string readable, BindingFlags flags) => Cached(type, "method", readable, flags, null,
() => Single(type.GetMethods(flags).Where(m => Matches(m, "method", readable)), type, readable));
/// <summary>Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw.</summary>
public static MethodInfo GetGameMethod(this Type type, string readable, BindingFlags flags, Binder binder, Type[] parameters, ParameterModifier[] modifiers)
{
if (readable == null) throw new ArgumentNullException(nameof(readable));
if (parameters == null) throw new ArgumentNullException(nameof(parameters));
if (parameters.Any(p => p == null)) throw new ArgumentException("Parameter types cannot contain null", nameof(parameters));
if (type == null) return null;
MethodInfo Resolve()
{
var candidates = type.GetMethods(flags).Where(m => Matches(m, "method", readable)).ToArray();
if (candidates.Length == 0) return null;
return (MethodInfo)(binder ?? Type.DefaultBinder).SelectMethod(flags, candidates, parameters, modifiers);
}
// Custom binders/modifiers can affect resolution; do not cache those calls.
if (binder != null || modifiers != null) return Resolve();
return Cached(type, "method", readable, flags, parameters, Resolve);
}
private static T Single<T>(IEnumerable<T> members, Type type, string readable) where T : MemberInfo
{
var candidates = members.Take(2).ToArray();
if (candidates.Length > 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature");
return candidates.FirstOrDefault();
}
/// <summary>Finds a field by readable name and binding flags. Returns null when absent.</summary>
public static FieldInfo GetGameField(this Type type, string readable, BindingFlags flags) => Cached(type, "field", readable, flags, null,
() => Single(type.GetFields(flags).Where(m => Matches(m, "field", readable)), type, readable));
/// <summary>Finds a property by readable name and binding flags. Returns null when absent; ambiguous matches throw.</summary>
public static PropertyInfo GetGameProperty(this Type type, string readable, BindingFlags flags) => Cached(type, "property", readable, flags, null,
() => Single(type.GetProperties(flags).Where(m => Matches(m, "property", readable)), type, readable));
/// <summary>Finds an event by readable name and binding flags. Returns null when absent.</summary>
public static EventInfo GetGameEvent(this Type type, string readable, BindingFlags flags) => Cached(type, "event", readable, flags, null,
() => Single(type.GetEvents(flags).Where(m => Matches(m, "event", readable)), type, readable));
/// <summary>Validate plugin/table version, binary fingerprint and known metadata entries before installing patches.</summary>
public static void Validate(Assembly plugin, Action<string> log = null) => Validate(plugin, GameVersion, log);
/// <summary>Validates only the game binary against the embedded table for plugins that do not use the compiler SDK.</summary>
public static void ValidateGame(Action<string> log = null) => ValidateCore(null, GameVersion, log);
/// <summary>Validates a compiler SDK plugin, its expected game version, the table and the game binary. Throws on mismatch.</summary>
public static void Validate(Assembly plugin, string expectedVersion, Action<string> log)
{
if (plugin == null) throw new ArgumentNullException(nameof(plugin));
if (expectedVersion == null) throw new ArgumentNullException(nameof(expectedVersion));
ValidateCore(plugin, expectedVersion, log);
}
private static void ValidateCore(Assembly plugin, string expectedVersion, Action<string> log)
{
try
{
var table = Names.Value.Table;
string stamp = table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256;
var metadata = plugin?.GetCustomAttributes<AssemblyMetadataAttribute>().SingleOrDefault(a => a.Key == "BD2.GameNames");
if (expectedVersion != table.game_version || plugin != null && metadata?.Value != stamp)
throw new InvalidDataException("plugin/table mismatch; rebuild the plugin and BD2.GameNames together");
lock (ValidationLock)
{
if (ValidatedStamp != stamp)
{
var assembly = GameAssembly();
if (assembly.ManifestModule.ModuleVersionId.ToString() != table.assembly_mvid)
throw new InvalidDataException("Assembly-CSharp MVID mismatch");
using (var sha = SHA256.Create())
using (var stream = File.OpenRead(assembly.Location))
{
string actual = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant();
if (actual != table.assembly_sha256) throw new InvalidDataException("Assembly-CSharp SHA-256 mismatch");
}
const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
var app = FindType("AppManager") ?? throw new TypeLoadException("AppManager");
var intro = FindType("IntroUI") ?? throw new TypeLoadException("IntroUI");
var network = FindType("BDNetwork.NetworkManager") ?? throw new TypeLoadException("BDNetwork.NetworkManager");
if (app.GetGameProperty("IsPlatformLogin", all)?.PropertyType != typeof(bool) ||
intro.GetGameMethod("SendMaintenanceInfo", all, null, new[] { typeof(bool) }, null) == null ||
network.GetGameMethod("GetPachedGameDataPath", all, null, Type.EmptyTypes, null)?.ReturnType != typeof(string))
throw new MissingMemberException("known game-name probes failed");
ValidatedStamp = stamp;
}
}
log?.Invoke("BD2.GameNames self-check passed: game=" + table.game_version + ", MVID=" + table.assembly_mvid);
}
catch (Exception ex)
{
log?.Invoke("BD2.GameNames SELF-CHECK FAILED: expected game=" + expectedVersion + "; " + ex.Message + "; game patches will not be installed");
throw;
}
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace BD2.GameNames;
/// <summary>Specifies the kind of game member whose readable name should be resolved.</summary>
public enum GameMemberKind
{
/// <summary>A method, including a coroutine entry point.</summary>
Method,
/// <summary>A field.</summary>
Field,
/// <summary>A property.</summary>
Property,
/// <summary>An event.</summary>
Event
}
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../PackageMetadata.props" />
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<AssemblyName>BD2.GameNames</AssemblyName>
<Nullable>disable</Nullable>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<Deterministic>true</Deterministic>
<DebugType>none</DebugType>
<PathMap>$(BaseIntermediateOutputPath)=/_/BD2.GameNames/obj,$(MSBuildProjectDirectory)=/_/BD2.GameNames</PathMap>
<PackageId>BD2.GameNames</PackageId>
<Description>Readable game-name resolution, reflection helpers and compatibility validation for Brown Dust II plugins. Includes a version-locked names table.</Description>
<PackageTags>BD2;BrownDust;Unity;BepInEx;Harmony;modding</PackageTags>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<GameNamesTable Condition="'$(GameNamesTable)' == ''">$(MSBuildThisFileDirectory)Mappings/names.json</GameNamesTable>
</PropertyGroup>
<Target Name="RequireNameTable" BeforeTargets="PrepareForBuild">
<Error Condition="!Exists('$(GameNamesTable).gz')" Text="Build a plugin with GameSdk.targets, or pass GameNamesTable pointing to generated names.json." />
</Target>
<ItemGroup>
<Compile Include="Game.cs;GameMemberKind.cs;NameTable.cs" />
<EmbeddedResource Include="$(GameNamesTable).gz" LogicalName="BD2.GameNames.names.json.gz" />
<None Include="README.md" Pack="true" PackagePath="README.md" />
</ItemGroup>
</Project>
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
using System.Collections.Generic;
using System.Runtime.Serialization;
// DataContractJsonSerializer populates these internal fields from the embedded table.
#pragma warning disable CS0649
namespace BD2.GameNames.Internal;
[DataContract]
internal sealed class NameTable
{
[DataMember] public int schema_version = 1;
[DataMember] public string game_version;
[DataMember] public string assembly_name;
[DataMember] public string assembly_mvid;
[DataMember] public string assembly_sha256;
[DataMember] public string mapping_sha256;
[DataMember] public string generator_sha256;
[DataMember] public List<TypeName> types = new List<TypeName>();
[DataMember] public List<MemberName> members = new List<MemberName>();
}
[DataContract]
internal sealed class TypeName
{
[DataMember] public int token;
[DataMember] public string readable;
[DataMember] public string original;
}
[DataContract]
internal sealed class MemberName
{
[DataMember] public int token;
[DataMember] public string kind;
[DataMember] public string declaring_type;
[DataMember] public string readable;
[DataMember] public string original;
[DataMember] public string signature;
[DataMember] public List<ParameterName> parameters = new List<ParameterName>();
}
[DataContract]
internal sealed class ParameterName
{
[DataMember] public int position;
[DataMember] public string readable;
[DataMember] public string original;
}
+29
View File
@@ -0,0 +1,29 @@
# BD2.GameNames
Brown Dust II 插件的共享名字解析运行时,目标框架为 `.NET Standard 2.0`。内嵌当前游戏版本的官方名字表,支持字符串反射、Harmony 目标表达式、缓存和插件启动自检。
开发插件建议引用配套的 `BD2.GameSdk` NuGet 包,它会自动引入本包并接入可读引用程序集和构建期 reobf。只有需要字符串反射、完全不引用游戏类型的插件才单独引用本包。
```xml
<PackageReference Include="BD2.GameSdk" Version="0.2.0-game.2.35.10" PrivateAssets="all" />
```
```csharp
using BD2.GameNames;
using System.Reflection;
Game.Validate(typeof(MyPlugin).Assembly, message => Logger.LogInfo(message));
var intro = Game.FindType("IntroUI");
var enter = intro?.GetGameMethod("Enter", BindingFlags.Instance | BindingFlags.NonPublic,
null, Type.EmptyTypes, null);
```
未知名字保留字面名;查不到成员返回 null,歧义抛出 `AmbiguousMatchException`。表中私有/编译器生成成员可通过字符串通道解析,公开调用可使用 `BD2.GameSdk` 生成的壳接受编译器检查。`nameof` 和协程字符串需显式经过名字表,普通字符串不会被 reobf 改写。
单独引用本包的插件使用 `Game.ValidateGame` 校验游戏和内嵌表;`Game.Validate` 还会校验 SDK 在插件中写入的编译指纹。运行环境只需要共享 DLL,无需 .NET SDK、Mono.Cecil 或构建工具。同一客户端只安装一份 `BD2.GameNames.dll`,不同表指纹的插件会在启动时明确报错。
单独引用本包的 SDK-style 类库在 `.csproj` 中设置 `<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>`,让运行时 DLL 复制到 `bin`;`BD2.GameSdk` 会自动设置此默认值。
包版本使用 `工具语义版本-game.游戏版本`,例如 `0.2.0-game.2.35.10`;游戏版本由 `Game.GameVersion` 获取。游戏版本变化后必须更新配套 SDK/运行时并重新构建插件。遵循包中附带的 LICENSE;本包并非 MIT 或其他开源许可。
完整导入和构建说明:[BD2.GameSdk 文档](https://github.com/Flechazo098/bd2/blob/main/plugins/GameSdk/README.md)。包目前由仓库打包脚本生成,通过维护者提供的 NuGet 源或本地目录安装。