Files
bd2/plugins/GameSdk/Program.cs
T
Flechazo 992fa47fd5 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.
2026-10-03 23:04:32 +08:00

574 lines
37 KiB
C#

using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using BD2.GameNames.Internal;
using Mono.Cecil;
using Mono.Cecil.Cil;
namespace BD2.GameSdk;
internal static class Program
{
internal const string ShellName = "Assembly-CSharp.Readable";
internal static readonly JsonSerializerOptions Json = new() { IncludeFields = true };
private static int Main(string[] args)
{
try
{
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 "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;
case "source-navigation" when args.Length == 3: SourceNavigation.Generate(args[1], args[2]); break;
case "verify-navigation" when args.Length == 2: SourceNavigation.Verify(args[1]); break;
case "reobf" when args.Length is 4 or 5 or 6: Reobfuscate(args[1], args[2], args[3], args.Length >= 5 ? args[4] : null, args.Length == 6 ? args[5] : null); break;
case "verify" when args.Length is 3 or 4: Verify(args[1], args[2], args.Length == 4 ? args[3] : null); break;
case "verify-runtime" when args.Length == 3: SelfTest.VerifyRuntime(args[1], args[2]); break;
case "self-test": SelfTest.Run(); break;
default: throw new ArgumentException("Usage: prepare-embedded <Assembly-CSharp.dll> <output-dir> [versions.json] | export-names <names.json> | names <Assembly-CSharp.dll> <mapping.obfuscate> <versions.json> <names.json> | shell <names.json> <Assembly-CSharp.dll> <output.dll> | source-navigation <readable-implementation.dll> <dependency-directory> | verify-navigation <sdk-directory> | reobf <names.json> <input.dll> <output.dll> [Assembly-CSharp.dll] [dependency-directory] | verify <names.json> <plugin.dll> [Assembly-CSharp.dll] | verify-runtime <names.json> <BD2.GameNames.dll> | self-test");
}
return 0;
}
catch (Exception ex) { Console.Error.WriteLine("BD2 GameSdk: " + ex); return 1; }
}
internal static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant();
private static string GeneratorStamp() => Hash(typeof(Program).Assembly.Location) + "|" +
Hash(typeof(ICSharpCode.Decompiler.CSharp.CSharpDecompiler).Assembly.Location) + "|" + Hash(typeof(ModuleDefinition).Assembly.Location);
private static string DependencyStamp(string assembly) => string.Join("|",
Directory.EnumerateFiles(Path.GetDirectoryName(Path.GetFullPath(assembly)), "*.dll").OrderBy(p => p, StringComparer.Ordinal)
.Select(p => Path.GetFileName(p) + ":" + Hash(p)));
internal static NameTable ReadTable(string path) => JsonSerializer.Deserialize<NameTable>(File.ReadAllText(path), Json);
internal static string Stamp(NameTable table) => table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256;
internal static byte[] EmbeddedNames()
{
using var stream = typeof(Program).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz")
?? throw new InvalidDataException("SDK embedded names table is missing; rebuild the SDK");
using var output = new MemoryStream();
stream.CopyTo(output);
return output.ToArray();
}
internal static void ExportNames(string tablePath)
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(tablePath)));
byte[] compressed = EmbeddedNames();
File.WriteAllBytes(tablePath + ".gz", compressed);
using var gzip = new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress);
using var destination = File.Create(tablePath);
gzip.CopyTo(destination);
}
internal static void PrepareEmbedded(string assembly, string output, string versions = null)
{
byte[] compressed = EmbeddedNames();
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);
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");
}
}
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[] 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)
{
File.Delete(ready);
GenerateShell(tablePath, assembly, cachedShell);
File.WriteAllText(ready, string.Join("|", files.Select(Hash)));
}
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"));
}
}
private static FileStream AcquireCacheLock(string path, string message = "Waiting for the shared game source-navigation cache to finish generating...")
{
var elapsed = System.Diagnostics.Stopwatch.StartNew();
bool announced = false;
while (true)
{
try { return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); }
catch (IOException) when (elapsed.Elapsed < TimeSpan.FromMinutes(30))
{
if (!announced) { Console.WriteLine(message); announced = true; }
Thread.Sleep(500);
}
}
}
private static void WriteNavigationItems(string output)
{
var manifest = JsonSerializer.Deserialize<SourceNavigation.Manifest>(File.ReadAllText(Path.Combine(output, "navigation.json")));
if (!Directory.Exists(manifest.SourceRoot) || Directory.EnumerateFiles(manifest.SourceRoot, "*.cs", SearchOption.AllDirectories).Count() != manifest.Documents)
SourceNavigation.RestoreSources(output);
var items = new System.Xml.Linq.XElement("ItemGroup");
foreach (var path in Directory.EnumerateFiles(manifest.SourceRoot, "*.cs", SearchOption.AllDirectories).OrderBy(p => p, StringComparer.Ordinal))
items.Add(new System.Xml.Linq.XElement("None", new System.Xml.Linq.XAttribute("Include", path),
new System.Xml.Linq.XElement("Link", "Game Sources/" + Path.GetRelativePath(manifest.SourceRoot, path)),
new System.Xml.Linq.XElement("CopyToOutputDirectory", "Never")));
using var stream = new MemoryStream();
new System.Xml.Linq.XDocument(new System.Xml.Linq.XElement("Project", items)).Save(stream);
WriteGeneratedFile(Path.Combine(output, "GameSourceNavigation.props"), stream.ToArray());
}
private static void WriteGeneratedFile(string path, byte[] content)
{
// Rewriting an imported props file triggers another IDE project reload.
// Keep its timestamp stable when the source list has not changed.
if (File.Exists(path) && File.ReadAllBytes(path).SequenceEqual(content)) return;
string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.WriteAllBytes(temporary, content);
var elapsed = System.Diagnostics.Stopwatch.StartNew();
while (true)
{
try { File.Move(temporary, path, overwrite: true); break; }
catch (IOException ex) when (ex.HResult == unchecked((int)0x80070020) && elapsed.Elapsed < TimeSpan.FromSeconds(10))
{
Thread.Sleep(100);
}
}
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
private static void WriteIdentity(string tablePath, string identityPath) =>
File.WriteAllText(identityPath, "// Generated from the shared names table.\n[assembly: System.Reflection.AssemblyMetadataAttribute(\"BD2.GameNames\", " + JsonSerializer.Serialize(Stamp(ReadTable(tablePath))) + ")]\n");
internal static IEnumerable<TypeDefinition> Types(ModuleDefinition module) => module.GetTypes();
internal static string TypeKey(TypeReference type) => type switch
{
GenericParameter p => (p.Type == GenericParameterType.Method ? "!!" : "!") + p.Position,
GenericInstanceType g => TypeKey(g.ElementType) + "<" + string.Join(",", g.GenericArguments.Select(TypeKey)) + ">",
ArrayType a => TypeKey(a.ElementType) + "[" + new string(',', a.Rank - 1) + "]",
ByReferenceType b => TypeKey(b.ElementType) + "&",
PointerType p => TypeKey(p.ElementType) + "*",
OptionalModifierType m => TypeKey(m.ElementType) + " modopt(" + TypeKey(m.ModifierType) + ")",
RequiredModifierType m => TypeKey(m.ElementType) + " modreq(" + TypeKey(m.ModifierType) + ")",
FunctionPointerType f => "fnptr(" + TypeKey(f.ReturnType) + ":" + string.Join(",", f.Parameters.Select(p => TypeKey(p.ParameterType))) + ")",
_ => type.FullName.Replace('/', '+')
};
internal static string Signature(MemberReference member) => member switch
{
MethodReference m => $"{m.GenericParameters.Count}:{TypeKey(m.ReturnType)}({string.Join(",", m.Parameters.Select(p => TypeKey(p.ParameterType)))})",
FieldReference f => TypeKey(f.FieldType),
_ => throw new ArgumentException("Unsupported member " + member)
};
internal static void Prepare(string assembly, string mapping, string versions, string output)
{
var version = JsonDocument.Parse(File.ReadAllText(versions)).RootElement.GetProperty("game_version").GetString();
if (!Path.GetFileName(mapping).Contains(version, StringComparison.Ordinal))
throw new InvalidDataException("Mapping filename must identify game_version " + version);
Directory.CreateDirectory(output);
string tablePath = Path.Combine(output, "names.json");
string shellPath = Path.Combine(output, ShellName + ".dll");
string assemblyHash = Hash(assembly), mappingHash = Hash(mapping), generatorHash = Hash(typeof(Program).Assembly.Location);
string identityPath = Path.Combine(output, "GameSdkIdentity.g.cs"), readyPath = Path.Combine(output, "ready.txt");
string inputStamp = version + "|" + assemblyHash + "|" + mappingHash + "|" + generatorHash;
if (File.Exists(readyPath) && File.Exists(tablePath) && File.Exists(shellPath) && File.Exists(tablePath + ".gz") && File.Exists(identityPath))
{
string completeStamp = inputStamp + "|" + Hash(shellPath) + "|" + Hash(tablePath) + "|" + Hash(tablePath + ".gz") + "|" + Hash(identityPath);
if (File.ReadAllText(readyPath) == completeStamp) return;
}
File.Delete(readyPath);
GenerateNames(assembly, mapping, versions, tablePath);
GenerateShell(tablePath, assembly, shellPath);
WriteIdentity(tablePath, identityPath);
File.WriteAllText(readyPath, inputStamp + "|" + Hash(shellPath) + "|" + Hash(tablePath) + "|" + Hash(tablePath + ".gz") + "|" + Hash(identityPath));
}
internal static void GenerateNames(string assembly, string mapping, string versions, string tablePath)
{
using var config = JsonDocument.Parse(File.ReadAllText(versions));
string version = config.RootElement.GetProperty("game_version").GetString();
if (string.IsNullOrWhiteSpace(version) || !Path.GetFileName(mapping).Contains(version, StringComparison.Ordinal))
throw new InvalidDataException("Mapping filename must identify game_version " + version);
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var raw in File.ReadLines(mapping))
{
string line = raw.Trim().TrimStart('\ufeff');
if (line.Length == 0 || line.StartsWith('#') || line.StartsWith("//")) continue;
var parts = line.Split('⇨');
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
throw new InvalidDataException("Invalid mapping row: " + line);
string key = parts[0].Trim(), value = parts[1].Trim();
if (map.TryGetValue(key, out var old) && old != value) throw new InvalidDataException("Ambiguous mapping: " + key);
map[key] = value;
}
string Translate(string name)
{
// Dots in explicit interface members are part of the CLR name, not a scope prefix.
if (map.TryGetValue(name, out var value)) return value.Split('/').Last();
// Accessors can retain their CLR prefix while the property/event is renamed.
foreach (string prefix in new[] { "get_", "set_", "add_", "remove_" })
if (name.StartsWith(prefix) && map.TryGetValue(name[prefix.Length..], out value))
{
string property = value.Split('/').Last();
int dot = property.LastIndexOf('.');
return property[..(dot + 1)] + prefix + property[(dot + 1)..];
}
return name;
}
using var module = ReadModule(assembly);
if (module.Assembly.Name.Name != "Assembly-CSharp") throw new InvalidDataException("Expected Assembly-CSharp");
var table = new NameTable { game_version = version, assembly_name = module.Assembly.Name.Name,
assembly_mvid = module.Mvid.ToString(), assembly_sha256 = Hash(assembly), mapping_sha256 = Hash(mapping), generator_sha256 = Hash(typeof(Program).Assembly.Location) };
var definitions = Types(module).ToArray();
var originalTypes = definitions.ToDictionary(t => t, TypeKey);
string ReadableType(TypeDefinition t)
{
if (t.DeclaringType != null) return ReadableType(t.DeclaringType) + "+" + Translate(t.Name);
if (map.TryGetValue(t.Name, out string full)) return full.Replace('/', '+');
return string.IsNullOrEmpty(t.Namespace) ? Translate(t.Name) : t.Namespace + "." + Translate(t.Name);
}
foreach (var type in definitions)
table.types.Add(new TypeName { token = type.MetadataToken.ToInt32(), original = originalTypes[type], readable = ReadableType(type) });
if (table.types.GroupBy(t => t.readable).Any(g => g.Count() > 1)) throw new InvalidDataException("Readable type collision");
var memberKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var type in definitions)
{
void Add(IMemberDefinition member, string kind, string signature = null, MethodDefinition method = null)
{
var entry = new MemberName { token = member.MetadataToken.ToInt32(), kind = kind,
declaring_type = originalTypes[type], original = member.Name, readable = Translate(member.Name), signature = signature };
if (!memberKeys.Add(originalTypes[type] + "|" + kind + "|" + entry.readable + "|" + signature))
throw new InvalidDataException("Readable member collision: " + originalTypes[type] + "." + entry.readable + " " + signature);
if (method != null)
foreach (var p in method.Parameters)
if (Translate(p.Name) != p.Name) entry.parameters.Add(new ParameterName { position = p.Index, original = p.Name, readable = Translate(p.Name) });
table.members.Add(entry);
}
foreach (var m in type.Methods) Add(m, "method", Signature(m), m);
foreach (var f in type.Fields) Add(f, "field", Signature(f));
foreach (var p in type.Properties) Add(p, "property", TypeKey(p.PropertyType) + "(" + string.Join(",", p.Parameters.Select(a => TypeKey(a.ParameterType))) + ")");
foreach (var e in type.Events) Add(e, "event", TypeKey(e.EventType));
}
// Keep unrenamed overloads that share a readable name with a renamed member.
// Otherwise runtime lookup would accidentally omit the literal overload.
var renamedGroups = table.members.Where(m => m.readable != m.original)
.Select(m => m.declaring_type + "|" + m.kind + "|" + m.readable).ToHashSet(StringComparer.Ordinal);
table.members = table.members.Where(m => m.readable != m.original || m.parameters.Count != 0 || renamedGroups.Contains(m.declaring_type + "|" + m.kind + "|" + m.readable)).ToList();
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(tablePath)));
File.WriteAllText(tablePath, JsonSerializer.Serialize(table, Json));
using (var gz = new GZipStream(File.Create(tablePath + ".gz"), CompressionLevel.SmallestSize))
gz.Write(File.ReadAllBytes(tablePath));
Console.WriteLine($"Game names {version}: {table.types.Count} types, {table.members.Count} member deltas => {tablePath}");
}
internal static void GenerateShell(string tablePath, string assembly, string shellPath)
{
var table = ReadTable(tablePath);
if (Hash(assembly) != table.assembly_sha256) throw new InvalidDataException($"Assembly-CSharp does not match SDK names for game {table.game_version}; install the matching client or update the SDK names table");
using var module = ReadModule(assembly);
var definitions = Types(module).ToArray();
// Rename references while their declaring types still have original names.
Rewrite(module, table, toReadable: true);
var membersByToken = table.members.ToDictionary(m => m.token);
var typesByToken = table.types.ToDictionary(t => t.token);
foreach (var type in definitions)
{
foreach (var member in type.Methods.Cast<IMemberDefinition>().Concat(type.Fields).Concat(type.Properties).Concat(type.Events))
{
membersByToken.TryGetValue(member.MetadataToken.ToInt32(), out var entry);
if (entry != null) member.Name = entry.readable;
if (member is MethodDefinition m)
{
if (entry != null) foreach (var p in entry.parameters) m.Parameters[p.position].Name = p.readable;
// Retain IL only for offline source navigation/decompilation.
// ReferenceAssemblyAttribute prevents this assembly from executing.
}
}
SetTypeName(type, typesByToken[type.MetadataToken.ToInt32()].readable);
}
module.Assembly.Name.Name = ShellName;
module.Name = ShellName + ".dll";
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(shellPath)));
// Visual Studio resolves reference-source navigation via the standard ref -> lib
// layout. The companion is never a compiler/runtime dependency; it is only for IDEs.
string root = Path.GetDirectoryName(Path.GetFullPath(shellPath));
string implementation = Path.Combine(root, "lib", ShellName + ".dll");
Directory.CreateDirectory(Path.GetDirectoryName(implementation));
module.Write(implementation);
SourceNavigation.Generate(implementation, Path.GetDirectoryName(Path.GetFullPath(assembly)));
// The CLR refuses to execute a reference assembly even if it is copied accidentally.
var marker = new TypeReference("System.Runtime.CompilerServices", "ReferenceAssemblyAttribute", module, module.TypeSystem.CoreLibrary);
module.Assembly.CustomAttributes.Add(new CustomAttribute(new MethodReference(".ctor", module.TypeSystem.Void, marker) { HasThis = true }));
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(shellPath)));
module.Write(shellPath);
string reference = Path.Combine(root, "ref", ShellName + ".dll");
Directory.CreateDirectory(Path.GetDirectoryName(reference));
File.Copy(shellPath, reference, overwrite: true);
File.Copy(Path.ChangeExtension(implementation, ".xml"), Path.ChangeExtension(reference, ".xml"), overwrite: true);
File.Copy(Path.ChangeExtension(implementation, ".xml"), Path.ChangeExtension(shellPath, ".xml"), overwrite: true);
File.Copy(Path.Combine(root, "lib", "navigation.json"), Path.Combine(root, "navigation.json"), overwrite: true);
Console.WriteLine($"Readable reference assembly {table.game_version} => {shellPath}");
}
internal static ModuleDefinition ReadModule(string path, params string[] searchDirectories)
{
var resolver = new DefaultAssemblyResolver();
resolver.AddSearchDirectory(Path.GetDirectoryName(Path.GetFullPath(path)));
foreach (var directory in searchDirectories) resolver.AddSearchDirectory(directory);
return ModuleDefinition.ReadModule(path, new ReaderParameters { InMemory = true, AssemblyResolver = resolver });
}
internal static void SetTypeName(TypeReference type, string full)
{
if (type.DeclaringType != null) { type.Name = full[(full.LastIndexOf('+') + 1)..]; return; }
int dot = full.LastIndexOf('.');
type.Namespace = dot < 0 ? "" : full[..dot];
type.Name = full[(dot + 1)..];
}
internal static void Rewrite(ModuleDefinition module, NameTable table, bool toReadable)
{
var types = table.types.ToDictionary(t => toReadable ? t.original : t.readable);
var readableByOriginal = table.types.ToDictionary(t => t.original, t => t.readable);
string TranslateSignature(string sig)
{
// Type references in signatures are tokenized, so substring collisions are avoided.
return System.Text.RegularExpressions.Regex.Replace(sig, @"[^,:()\[\]&*<>\s]+", m => readableByOriginal.TryGetValue(m.Value, out var v) ? v : m.Value);
}
var members = table.members.Where(m => m.kind is "method" or "field").ToDictionary(
m => (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) + "|" + m.kind + "|" + (toReadable ? m.original : m.readable) + "|" + (toReadable ? m.signature : TranslateSignature(m.signature)));
var namedMembers = table.members.Where(m => m.original != m.readable && m.kind is "method" or "field")
.Select(m => (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) + "|" + m.kind + "|" + (toReadable ? m.original : m.readable)).ToHashSet(StringComparer.Ordinal);
bool IsGame(TypeReference type)
{
while (type is TypeSpecification s) type = s.ElementType;
while (type.DeclaringType != null) type = type.DeclaringType;
return type.Scope is AssemblyNameReference a ? a.Name == (toReadable ? table.assembly_name : ShellName) : toReadable && type.Scope == module;
}
var memberRefs = module.GetMemberReferences().ToList();
var attributes = new List<CustomAttribute>();
void Attributes(ICustomAttributeProvider provider)
{
if (provider.HasCustomAttributes) attributes.AddRange(provider.CustomAttributes);
}
Attributes(module); Attributes(module.Assembly);
foreach (var t in Types(module))
{
Attributes(t);
foreach (var gp in t.GenericParameters) Attributes(gp);
foreach (var i in t.Interfaces) Attributes(i);
foreach (var f in t.Fields) Attributes(f);
foreach (var p in t.Properties) Attributes(p);
foreach (var e in t.Events) Attributes(e);
foreach (var m in t.Methods)
{
Attributes(m); Attributes(m.MethodReturnType);
foreach (var p in m.Parameters) Attributes(p);
foreach (var gp in m.GenericParameters) Attributes(gp);
}
}
foreach (var attribute in attributes) memberRefs.Add(attribute.Constructor);
foreach (var type in Types(module))
foreach (var method in type.Methods)
{
foreach (var ov in method.Overrides) memberRefs.Add(ov);
if (method.HasBody) foreach (var instruction in method.Body.Instructions)
if (instruction.Operand is MemberReference mr) memberRefs.Add(mr);
}
foreach (var reference in memberRefs.Distinct())
{
MemberReference member = reference is GenericInstanceMethod gm ? gm.ElementMethod : reference;
if (member is not MethodReference && member is not FieldReference || member is IMemberDefinition || !IsGame(member.DeclaringType)) continue;
var declaring = member.DeclaringType is GenericInstanceType gi ? gi.ElementType : member.DeclaringType;
// CLR array Get/Set/Address pseudo-methods have no metadata definitions.
if (declaring is ArrayType) continue;
string key = TypeKey(declaring) + "|" + (member is MethodReference ? "method" : "field") + "|" + member.Name + "|" + Signature(member);
if (members.TryGetValue(key, out var entry)) member.Name = toReadable ? entry.readable : entry.original;
else if (namedMembers.Contains(key[..key.LastIndexOf('|')])) throw new InvalidDataException("Game member signature does not match the names table: " + key);
}
// Snapshot names before renaming a parent of a nested type.
var renames = new Dictionary<TypeReference, string>();
void Visit(TypeReference type)
{
if (type == null || type is GenericParameter) return;
if (type is FunctionPointerType fp) { Visit(fp.ReturnType); foreach (var p in fp.Parameters) Visit(p.ParameterType); return; }
if (type is TypeSpecification spec)
{
Visit(spec.ElementType);
if (type is GenericInstanceType gi) foreach (var a in gi.GenericArguments) Visit(a);
if (type is IModifierType modifier) Visit(modifier.ModifierType);
return;
}
if (!renames.ContainsKey(type) && IsGame(type))
{
if (!types.TryGetValue(TypeKey(type), out var entry)) throw new InvalidDataException("Unmapped game type: " + TypeKey(type));
renames[type] = toReadable ? entry.readable : entry.original;
}
Visit(type.DeclaringType);
}
void VisitArgument(CustomAttributeArgument argument)
{
Visit(argument.Type);
if (argument.Value is TypeReference type) Visit(type);
if (argument.Value is CustomAttributeArgument boxed) VisitArgument(boxed);
if (argument.Value is CustomAttributeArgument[] array) foreach (var item in array) VisitArgument(item);
}
foreach (var attribute in attributes)
{
// Decode blobs before changing assembly scopes. System.Type arguments are typed
// metadata even though the ECMA-335 blob stores assembly-qualified text.
foreach (var argument in attribute.ConstructorArguments) VisitArgument(argument);
void NamedArguments(Mono.Collections.Generic.Collection<CustomAttributeNamedArgument> arguments, string kind)
{
for (int i = 0; i < arguments.Count; i++)
{
var argument = arguments[i]; VisitArgument(argument.Argument);
if (!IsGame(attribute.AttributeType)) continue;
string declaring = TypeKey(attribute.AttributeType);
var entry = table.members.SingleOrDefault(m => m.kind == kind &&
(toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) == declaring &&
(toReadable ? m.original : m.readable) == argument.Name);
if (entry != null) arguments[i] = new CustomAttributeNamedArgument(toReadable ? entry.readable : entry.original, argument.Argument);
}
}
NamedArguments(attribute.Fields, "field"); NamedArguments(attribute.Properties, "property");
}
foreach (var t in module.GetTypeReferences()) Visit(t);
foreach (var member in memberRefs)
{
Visit(member.DeclaringType);
if (member is MethodReference m) { Visit(m.ReturnType); foreach (var p in m.Parameters) Visit(p.ParameterType); if (m is GenericInstanceMethod gm) foreach (var a in gm.GenericArguments) Visit(a); }
if (member is FieldReference f) Visit(f.FieldType);
}
foreach (var type in Types(module))
{
Visit(type.BaseType);
foreach (var i in type.Interfaces) Visit(i.InterfaceType);
foreach (var f in type.Fields) Visit(f.FieldType);
foreach (var p in type.Properties) { Visit(p.PropertyType); foreach (var a in p.Parameters) Visit(a.ParameterType); }
foreach (var e in type.Events) Visit(e.EventType);
foreach (var gp in type.GenericParameters) foreach (var c in gp.Constraints) Visit(c.ConstraintType);
foreach (var m in type.Methods)
{
Visit(m.ReturnType);
foreach (var p in m.Parameters) Visit(p.ParameterType);
foreach (var gp in m.GenericParameters) foreach (var c in gp.Constraints) Visit(c.ConstraintType);
if (m.HasBody) { foreach (var v in m.Body.Variables) Visit(v.VariableType); foreach (var h in m.Body.ExceptionHandlers) Visit(h.CatchType); foreach (var i in m.Body.Instructions) if (i.Operand is TypeReference t) Visit(t); }
}
}
foreach (var pair in renames) SetTypeName(pair.Key, pair.Value);
}
internal static void Reobfuscate(string tablePath, string input, string output, string originalAssembly = null, string dependencyDirectory = null)
{
var table = ReadTable(tablePath);
var dependencies = new List<string> { Path.GetDirectoryName(Path.GetFullPath(tablePath)), Path.GetDirectoryName(Path.GetFullPath(output)) };
if (originalAssembly != null) dependencies.Add(Path.GetDirectoryName(Path.GetFullPath(originalAssembly)));
if (dependencyDirectory != null) dependencies.Add(Path.GetFullPath(dependencyDirectory));
using var module = ReadModule(input, dependencies.ToArray());
if (module.Assembly.Name.HasPublicKey) throw new InvalidDataException("Signed plugins require an explicit signing workflow");
VerifyStamp(module, table);
Rewrite(module, table, false);
foreach (var a in module.AssemblyReferences.Where(a => a.Name == ShellName)) a.Name = table.assembly_name;
// Preserve compiler PDBs in obj; rewritten runtime DLL deliberately has no stale symbols.
string absoluteOutput = Path.GetFullPath(output);
Directory.CreateDirectory(Path.GetDirectoryName(absoluteOutput));
string temporary = absoluteOutput + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
module.Write(temporary);
Verify(tablePath, temporary, originalAssembly);
File.Move(temporary, absoluteOutput, overwrite: true);
}
finally { File.Delete(temporary); }
Console.WriteLine("Reobfuscated runtime plugin: " + output);
}
internal static void Verify(string tablePath, string plugin, string originalAssembly = null)
{
var table = ReadTable(tablePath);
using var module = originalAssembly == null ? ReadModule(plugin) : ReadModule(plugin, Path.GetDirectoryName(Path.GetFullPath(originalAssembly)));
if (module.AssemblyReferences.Any(a => a.Name == ShellName)) throw new InvalidDataException("Runtime DLL still references the readable shell");
VerifyStamp(module, table);
if (originalAssembly != null)
{
if (Hash(originalAssembly) != table.assembly_sha256) throw new InvalidDataException("Verification game binary does not match the names table");
bool IsGame(TypeReference t) => t.GetElementType().Scope is AssemblyNameReference a && a.Name == table.assembly_name;
foreach (var type in module.GetTypeReferences().Where(IsGame))
if (type.Resolve() == null) throw new InvalidDataException("Unresolvable game type reference: " + type.FullName);
foreach (var member in module.GetMemberReferences().Where(m => IsGame(m.DeclaringType)))
{
if (member.DeclaringType is ArrayType) continue;
if (member is MethodReference method && method.Resolve() == null || member is FieldReference field && field.Resolve() == null)
throw new InvalidDataException("Unresolvable game member reference: " + member.FullName);
}
}
}
private static void VerifyStamp(ModuleDefinition module, NameTable table)
{
var markers = module.Assembly.CustomAttributes.Where(a => a.AttributeType.FullName == "System.Reflection.AssemblyMetadataAttribute" && a.ConstructorArguments.Count == 2 && a.ConstructorArguments[0].Value as string == "BD2.GameNames").ToArray();
if (markers.Length != 1 || markers[0].ConstructorArguments[1].Value as string != Stamp(table))
throw new InvalidDataException("Missing/mismatched SDK stamp: compile with GameSdkIdentity.g.cs from this names table");
}
}