refactor(all): reorganize packages, establish player ownership and isolate accounts

This commit is contained in:
2026-10-07 22:42:16 +08:00
parent 8e8ddeddb6
commit 06a83ada28
850 changed files with 42335 additions and 79891 deletions
+179
View File
@@ -0,0 +1,179 @@
using BD2.GameNames.Internal;
using Mono.Cecil;
using Mono.Cecil.Cil;
using static BD2.GameSdk.Generation.ShellGenerator;
namespace BD2.GameSdk.Generation;
internal static class AssemblyNames
{
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 ModuleDefinition ReadModule(string path, params string[] searchDirectories)
{
var resolver = new DefaultAssemblyResolver();
resolver.AddSearchDirectory(Path.GetDirectoryName(Path.GetFullPath(path)));
foreach (string 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 string 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 (TypeDefinition t in Types(module))
{
Attributes(t);
foreach (GenericParameter gp in t.GenericParameters) Attributes(gp);
foreach (InterfaceImplementation i in t.Interfaces) Attributes(i);
foreach (FieldDefinition f in t.Fields) Attributes(f);
foreach (PropertyDefinition p in t.Properties) Attributes(p);
foreach (EventDefinition e in t.Events) Attributes(e);
foreach (MethodDefinition m in t.Methods)
{
Attributes(m); Attributes(m.MethodReturnType);
foreach (ParameterDefinition p in m.Parameters) Attributes(p);
foreach (GenericParameter gp in m.GenericParameters) Attributes(gp);
}
}
foreach (CustomAttribute attribute in attributes) memberRefs.Add(attribute.Constructor);
foreach (TypeDefinition type in Types(module))
foreach (MethodDefinition method in type.Methods)
{
foreach (MethodReference ov in method.Overrides) memberRefs.Add(ov);
if (method.HasBody) foreach (Instruction instruction in method.Body.Instructions)
if (instruction.Operand is MemberReference mr) memberRefs.Add(mr);
}
foreach (MemberReference 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;
TypeReference 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 MemberName 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 (ParameterDefinition p in fp.Parameters) Visit(p.ParameterType); return; }
if (type is TypeSpecification spec)
{
Visit(spec.ElementType);
if (type is GenericInstanceType gi) foreach (TypeReference 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 TypeName 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 (CustomAttributeArgument item in array) VisitArgument(item);
}
foreach (CustomAttribute 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 (CustomAttributeArgument argument in attribute.ConstructorArguments) VisitArgument(argument);
void NamedArguments(Mono.Collections.Generic.Collection<CustomAttributeNamedArgument> arguments, string kind)
{
for (int i = 0; i < arguments.Count; i++)
{
CustomAttributeNamedArgument argument = arguments[i]; VisitArgument(argument.Argument);
if (!IsGame(attribute.AttributeType)) continue;
string declaring = TypeKey(attribute.AttributeType);
MemberName 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 (TypeReference t in module.GetTypeReferences()) Visit(t);
foreach (MemberReference member in memberRefs)
{
Visit(member.DeclaringType);
if (member is MethodReference m) { Visit(m.ReturnType); foreach (ParameterDefinition p in m.Parameters) Visit(p.ParameterType); if (m is GenericInstanceMethod gm) foreach (TypeReference a in gm.GenericArguments) Visit(a); }
if (member is FieldReference f) Visit(f.FieldType);
}
foreach (TypeDefinition type in Types(module))
{
Visit(type.BaseType);
foreach (InterfaceImplementation i in type.Interfaces) Visit(i.InterfaceType);
foreach (FieldDefinition f in type.Fields) Visit(f.FieldType);
foreach (PropertyDefinition p in type.Properties) { Visit(p.PropertyType); foreach (ParameterDefinition a in p.Parameters) Visit(a.ParameterType); }
foreach (EventDefinition e in type.Events) Visit(e.EventType);
foreach (GenericParameter gp in type.GenericParameters) foreach (GenericParameterConstraint c in gp.Constraints) Visit(c.ConstraintType);
foreach (MethodDefinition m in type.Methods)
{
Visit(m.ReturnType);
foreach (ParameterDefinition p in m.Parameters) Visit(p.ParameterType);
foreach (GenericParameter gp in m.GenericParameters) foreach (GenericParameterConstraint c in gp.Constraints) Visit(c.ConstraintType);
if (m.HasBody) { foreach (VariableDefinition v in m.Body.Variables) Visit(v.VariableType); foreach (ExceptionHandler h in m.Body.ExceptionHandlers) Visit(h.CatchType); foreach (Instruction i in m.Body.Instructions) if (i.Operand is TypeReference t) Visit(t); }
}
}
foreach (KeyValuePair<TypeReference, string> pair in renames) SetTypeName(pair.Key, pair.Value);
}
}
@@ -0,0 +1,38 @@
using System.Security.Cryptography;
using System.Text;
using ICSharpCode.Decompiler.CSharp;
using Mono.Cecil;
namespace BD2.GameSdk.Generation;
internal static class GenerationInputs
{
internal static string Hash(string path)
{
using FileStream stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
internal static string Stamp() => SourceStamp("BD2.GameSdk.Generation.") + "|" +
Hash(typeof(CSharpDecompiler).Assembly.Location) + "|" + Hash(typeof(ModuleDefinition).Assembly.Location);
internal static string NamesStamp() => Convert.ToHexString(SHA256.HashData(
Encoding.UTF8.GetBytes(Stamp() + "|" + SourceStamp("BD2.GameSdk.Names.")))).ToLowerInvariant();
private static string SourceStamp(string prefix)
{
System.Reflection.Assembly assembly = typeof(GenerationInputs).Assembly;
string[] resources = [.. assembly.GetManifestResourceNames()
.Where(name => name.StartsWith(prefix, StringComparison.Ordinal))
.OrderBy(name => name, StringComparer.Ordinal)];
if (resources.Length == 0) throw new InvalidDataException("SDK generation inputs are missing; rebuild the SDK");
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (string name in resources)
{
hash.AppendData(Encoding.UTF8.GetBytes(name + "\0"));
using Stream stream = assembly.GetManifestResourceStream(name);
hash.AppendData(SHA256.HashData(stream));
}
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
}
+105
View File
@@ -0,0 +1,105 @@
using System.IO.Compression;
using System.Text.Json;
using BD2.GameNames.Internal;
using Mono.Cecil;
using static BD2.GameSdk.Generation.AssemblyNames;
using static BD2.GameSdk.Generation.GenerationInputs;
using static BD2.GameSdk.Generation.ShellGenerator;
namespace BD2.GameSdk.Generation;
internal static class NameGenerator
{
internal static void Generate(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 (string raw in File.ReadLines(mapping))
{
string line = raw.Trim().TrimStart('\ufeff');
if (line.Length == 0 || line.StartsWith('#') || line.StartsWith("//", StringComparison.Ordinal)) continue;
string[] 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 string 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 string 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, StringComparison.Ordinal) && 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 ModuleDefinition 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 = GenerationInputs.NamesStamp()
};
TypeDefinition[] 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 (TypeDefinition 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 (TypeDefinition 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 (ParameterDefinition 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 (MethodDefinition m in type.Methods) Add(m, "method", Signature(m), m);
foreach (FieldDefinition f in type.Fields) Add(f, "field", Signature(f));
foreach (PropertyDefinition p in type.Properties) Add(p, "property", TypeKey(p.PropertyType) + "(" + string.Join(",", p.Parameters.Select(a => TypeKey(a.ParameterType))) + ")");
foreach (EventDefinition 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}");
}
}
@@ -0,0 +1,64 @@
using System.Text.Json;
using BD2.GameNames.Internal;
using Mono.Cecil;
using static BD2.GameSdk.Generation.AssemblyNames;
using static BD2.GameSdk.Generation.GenerationInputs;
namespace BD2.GameSdk.Generation;
internal static class ShellGenerator
{
internal const string ShellName = "Assembly-CSharp.Readable";
internal static readonly JsonSerializerOptions Json = new() { IncludeFields = true };
internal static NameTable ReadTable(string path) => JsonSerializer.Deserialize<NameTable>(File.ReadAllText(path), Json);
internal static void Generate(string tablePath, string assembly, string shellPath)
{
NameTable 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 ModuleDefinition module = ReadModule(assembly);
TypeDefinition[] 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 (TypeDefinition type in definitions)
{
foreach (IMemberDefinition member in type.Methods.Cast<IMemberDefinition>().Concat(type.Fields).Concat(type.Properties).Concat(type.Events))
{
membersByToken.TryGetValue(member.MetadataToken.ToInt32(), out MemberName entry);
if (entry != null) member.Name = entry.readable;
if (member is MethodDefinition m)
{
if (entry != null) foreach (ParameterName 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}");
}
}
@@ -0,0 +1,395 @@
using System.Globalization;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using System.Reflection.PortableExecutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.CSharp.OutputVisitor;
using ICSharpCode.Decompiler.CSharp.Syntax;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.TypeSystem;
using Mono.Cecil;
using Mono.Cecil.Cil;
using EntityHandle = System.Reflection.Metadata.EntityHandle;
using ModuleDefinition = Mono.Cecil.ModuleDefinition;
using MethodDefinition = Mono.Cecil.MethodDefinition;
using MethodDebugInformation = Mono.Cecil.Cil.MethodDebugInformation;
namespace BD2.GameSdk.Generation;
/// <summary>Build local, offline external-source navigation from the readable implementation.</summary>
internal static class SourceNavigation
{
internal static readonly Guid EmbeddedSource = new("0E8A571B-6926-466E-B4AD-8AB04611F5FE");
internal static readonly Guid TypeDocuments = new("932E74BC-DBA9-4478-8D46-0F32A7BAB3D3");
private static readonly Guid CompilationOptions = new("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8");
private static readonly Guid CompilationReferences = new("7E4D4708-096E-4C5C-AEDA-CB10BA6A740D");
internal static readonly Guid CSharpLanguage = new("3F5162F8-07C6-11D3-9053-00C04FA302A1");
internal sealed record Declaration(int Token, string Name, string Kind, string File, int Line, int Column, int EndLine, int EndColumn, bool GeneratedView);
internal sealed record Point(int Offset, int Line, int Column, int EndLine, int EndColumn);
private sealed record Source(string Path, byte[] Content, Dictionary<int, Declaration> Declarations, Dictionary<int, List<Point>> Methods);
internal sealed record Manifest(int SchemaVersion, string AssemblySha256, string PdbSha256, string DecompilerVersion, string SourceRoot,
int Documents, int Declarations, int MethodsWithBodies, int MethodsWithSymbols, List<string> Diagnostics, List<Declaration> Symbols);
internal static void Generate(string shellPath, string dependencyDirectory)
{
string directory = Path.GetDirectoryName(Path.GetFullPath(shellPath));
string sourceRoot = Path.Combine(directory, "sources");
Directory.CreateDirectory(sourceRoot);
using ModuleDefinition module = AssemblyNames.ReadModule(shellPath, dependencyDirectory);
using var pe = new PEFile(shellPath, new MemoryStream(File.ReadAllBytes(shellPath)), PEStreamOptions.PrefetchEntireImage);
MetadataReader reader = pe.Metadata;
TypeDefinitionHandle[] types = reader.TypeDefinitions.Where(h => reader.GetTypeDefinition(h).GetDeclaringType().IsNil && reader.GetString(reader.GetTypeDefinition(h).Name) != "<Module>").ToArray();
var sources = new ConcurrentBag<Source>();
var timer = Stopwatch.StartNew();
int completed = 0;
var expected = module.GetTypes().ToDictionary(t => t.MetadataToken.ToInt32(), t =>
t.Methods.Cast<IMemberDefinition>().Concat(t.Fields).Concat(t.Properties).Concat(t.Events).Select(m => m.MetadataToken.ToInt32()).Append(t.MetadataToken.ToInt32()).ToHashSet());
var definitionsByToken = module.GetTypes().ToDictionary(t => t.MetadataToken.ToInt32());
// Each worker owns its decompiler and resolver. PE metadata is immutable;
// mutable decompiler state is never shared between workers.
int workers = Math.Clamp(Environment.ProcessorCount / 2, 1, 4);
Parallel.ForEach(types, new ParallelOptions { MaxDegreeOfParallelism = workers },
() => new Worker(shellPath, dependencyDirectory), (handle, _, worker) =>
{
int token = MetadataTokens.GetToken(handle);
string relative = PathFor(reader, handle);
Source primary = worker.Decompile(handle, Path.Combine(sourceRoot, relative), raw: false);
ApplyImplicitDeclarations(primary.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]);
sources.Add(primary);
int[] typeTokens = Descendants(reader, handle).Select(h => MetadataTokens.GetToken(h)).ToArray();
var wanted = typeTokens.SelectMany(t => expected[t]).ToHashSet();
var covered = primary.Declarations.Keys.ToHashSet();
if (wanted.Except(covered).Any())
{
// Keep idiomatic async/iterator/lambda code in the primary view, and
// expose the compiler-generated implementation in a companion view.
// Roslyn materializes embedded documents by basename, so primary
// and generated views must have distinct filenames as well as paths.
string generatedPath = Path.Combine(Path.GetDirectoryName(relative), Path.GetFileNameWithoutExtension(relative) + ".generated.cs");
Source raw = worker.Decompile(handle, Path.Combine(sourceRoot, "generated", generatedPath), raw: true);
ApplyImplicitDeclarations(raw.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]);
sources.Add(raw);
covered.UnionWith(raw.Declarations.Keys);
}
foreach (int missing in wanted.Except(covered))
{
// ILSpy hides some runtime-only definitions even with transformations
// disabled. Decompile them explicitly, retaining exact token identity.
Source supplemental = worker.DecompileMember(MetadataTokens.EntityHandle(missing),
Path.Combine(sourceRoot, "metadata", missing.ToString("X8", CultureInfo.InvariantCulture) + ".cs"));
sources.Add(supplemental);
if (!supplemental.Declarations.ContainsKey(missing))
throw new InvalidDataException($"Decompiler omitted declaration 0x{missing:X8}; complete navigation cannot be published");
}
int done = Interlocked.Increment(ref completed);
if (done % 500 == 0 || done == types.Length)
Console.WriteLine($"Game source navigation: {done}/{types.Length} top-level types ({timer.Elapsed.TotalSeconds:F0}s)");
return worker;
}, worker => worker.Dispose());
Source[] ordered = [.. sources.OrderBy(s => s.Path, StringComparer.Ordinal)];
var sourceByPath = ordered.ToDictionary(s => s.Path, StringComparer.Ordinal);
var declarations = new Dictionary<int, Declaration>();
var methodPoints = new Dictionary<int, (Source Source, List<Point> Points)>();
foreach (Source source in ordered.OrderBy(s => s.Declarations.Values.FirstOrDefault()?.GeneratedView == true))
{
foreach (KeyValuePair<int, Declaration> declaration in source.Declarations) declarations.TryAdd(declaration.Key, declaration.Value);
foreach (KeyValuePair<int, List<Point>> method in source.Methods) methodPoints.TryAdd(method.Key, (source, method.Value));
}
// Accessors may be folded into properties/events. Their declaration navigation
// maps to the exact accessor when present, otherwise its owning declaration.
foreach (Mono.Cecil.TypeDefinition type in module.GetTypes())
{
foreach (Mono.Cecil.PropertyDefinition property in type.Properties)
Alias(property, property.GetMethod, property.SetMethod);
foreach (Mono.Cecil.EventDefinition @event in type.Events)
Alias(@event, @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod);
}
void Alias(IMemberDefinition owner, params MethodDefinition[] accessors)
{
if (!declarations.TryGetValue(owner.MetadataToken.ToInt32(), out Declaration location)) return;
foreach (MethodDefinition method in accessors.Where(m => m != null))
declarations.TryAdd(method.MetadataToken.ToInt32(), location with { Token = method.MetadataToken.ToInt32(), Name = method.FullName, Kind = "Method" });
}
foreach (MethodDefinition method in module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody))
{
int token = method.MetadataToken.ToInt32();
if (!methodPoints.ContainsKey(token) && declarations.TryGetValue(token, out Declaration declaration))
{
Source source = sourceByPath[declaration.File];
methodPoints[token] = (source, new List<Point> { new(0, declaration.Line, declaration.Column, declaration.EndLine, declaration.EndColumn) });
}
}
int bodies = module.GetTypes().Sum(t => t.Methods.Count(m => m.HasBody));
if (methodPoints.Count < bodies) throw new InvalidDataException($"Navigation coverage incomplete: {methodPoints.Count} symbols for {bodies} method bodies");
string pdbPath = Path.ChangeExtension(shellPath, ".pdb");
(byte[] Bytes, BlobContentId Id) = BuildPdb(pe, module, ordered, declarations, methodPoints, dependencyDirectory);
File.WriteAllBytes(pdbPath, Bytes);
// Attach exactly the PDB we generated to this PE. No implementation is executed.
module.Write(shellPath, new WriterParameters { WriteSymbols = true, SymbolWriterProvider = new NavigationSymbolWriterProvider(Id, pdbPath, Bytes) });
// Store the source root once instead of repeating a long machine-specific
// cache path for hundreds of thousands of declarations.
var manifest = new Manifest(2, GenerationInputs.Hash(shellPath), GenerationInputs.Hash(pdbPath), typeof(CSharpDecompiler).Assembly.GetName().Version.ToString(), sourceRoot,
ordered.Length, declarations.Count, bodies, methodPoints.Count, [],
declarations.Values.OrderBy(d => d.Token).Select(d => d with { File = Path.GetRelativePath(sourceRoot, d.File) }).ToList());
File.WriteAllText(Path.Combine(directory, "navigation.json"), JsonSerializer.Serialize(manifest));
WriteXmlDocumentation(shellPath, pe, declarations);
Console.WriteLine($"Game source navigation ready: {ordered.Length} source files, {declarations.Count} declarations, {methodPoints.Count} method symbols");
}
private static void ApplyImplicitDeclarations(Dictionary<int, Declaration> declarations, Mono.Cecil.TypeDefinition type)
{
foreach (Mono.Cecil.PropertyDefinition property in type.Properties)
if (declarations.TryGetValue(property.MetadataToken.ToInt32(), out Declaration location))
foreach (MethodDefinition accessor in new[] { property.GetMethod, property.SetMethod }.Where(m => m != null))
declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" });
foreach (Mono.Cecil.EventDefinition @event in type.Events)
if (declarations.TryGetValue(@event.MetadataToken.ToInt32(), out Declaration location))
foreach (MethodDefinition accessor in new[] { @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod }.Where(m => m != null))
declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" });
if (declarations.TryGetValue(type.MetadataToken.ToInt32(), out Declaration declaration))
{
foreach (MethodDefinition constructor in type.Methods.Where(m => m.IsConstructor && !declarations.ContainsKey(m.MetadataToken.ToInt32())))
declarations.TryAdd(constructor.MetadataToken.ToInt32(), declaration with { Token = constructor.MetadataToken.ToInt32(), Name = constructor.FullName, Kind = "Method" });
foreach (Mono.Cecil.FieldDefinition field in type.Fields.Where(f => f.IsRuntimeSpecialName))
declarations.TryAdd(field.MetadataToken.ToInt32(), declaration with { Token = field.MetadataToken.ToInt32(), Name = field.FullName, Kind = "Field" });
}
foreach (Mono.Cecil.TypeDefinition nested in type.NestedTypes) ApplyImplicitDeclarations(declarations, nested);
}
private static IEnumerable<TypeDefinitionHandle> Descendants(MetadataReader reader, TypeDefinitionHandle handle)
{
yield return handle;
foreach (TypeDefinitionHandle child in reader.GetTypeDefinition(handle).GetNestedTypes())
foreach (TypeDefinitionHandle nested in Descendants(reader, child)) yield return nested;
}
private static string PathFor(MetadataReader reader, TypeDefinitionHandle handle)
{
System.Reflection.Metadata.TypeDefinition type = reader.GetTypeDefinition(handle);
static string Safe(string name) => string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' ? c : '_'));
string ns = reader.GetString(type.Namespace), name = reader.GetString(type.Name);
// Token suffix prevents collisions from generic arity, case-insensitive filesystems,
// and names whose invalid filename characters normalize to the same spelling.
return Path.Combine(Safe(ns), Safe(name) + "." + MetadataTokens.GetToken(handle).ToString("X8", CultureInfo.InvariantCulture) + ".cs");
}
private sealed class Worker : IDisposable
{
private readonly PEFile file;
private readonly UniversalAssemblyResolver resolver;
private readonly CSharpDecompiler primary;
private readonly CSharpDecompiler raw;
private readonly DecompilerSettings primarySettings;
private readonly DecompilerSettings rawSettings;
internal Worker(string path, string dependencies)
{
file = new PEFile(path, new MemoryStream(File.ReadAllBytes(path)), PEStreamOptions.PrefetchEntireImage);
resolver = new UniversalAssemblyResolver(path, true, file.DetectTargetFrameworkId());
resolver.AddSearchDirectory(dependencies);
primarySettings = Settings(false); rawSettings = Settings(true);
primary = new CSharpDecompiler(file, resolver, primarySettings);
raw = new CSharpDecompiler((DecompilerTypeSystem)primary.TypeSystem, rawSettings);
}
private static DecompilerSettings Settings(bool raw) => new()
{
ThrowOnAssemblyResolveErrors = true,
UseDebugSymbols = false,
ShowXmlDocumentation = false,
UseNestedDirectoriesForNamespaces = true,
AnonymousMethods = !raw,
AnonymousTypes = !raw,
AsyncAwait = !raw,
YieldReturn = !raw,
AutomaticProperties = !raw,
AutomaticEvents = !raw,
GetterOnlyAutomaticProperties = !raw,
UseExpressionBodyForCalculatedGetterOnlyProperties = !raw
};
internal Source Decompile(TypeDefinitionHandle type, string path, bool raw)
{
CSharpDecompiler decompiler = raw ? this.raw : primary;
return Render(decompiler, decompiler.DecompileTypes((TypeDefinitionHandle[])[type]), path, raw);
}
internal Source DecompileMember(EntityHandle member, string path)
{
Source source = Render(raw, raw.Decompile(member), path, true);
int token = MetadataTokens.GetToken(member);
if (!source.Declarations.ContainsKey(token))
{
string text = Encoding.UTF8.GetString(source.Content);
source.Declarations[token] = new Declaration(token, "Metadata declaration 0x" + token.ToString("X8", CultureInfo.InvariantCulture), member.Kind.ToString(), path, 2, 1, text.Count(c => c == '\n') + 1, 1, true);
}
return source;
}
private Source Render(CSharpDecompiler decompiler, SyntaxTree tree, string path, bool generated)
{
// CLR compiler-generated names such as <Run>d__0 are not legal C#.
// Keep token identity in the index/PDB while spelling those identifiers
// legally in the generated view, so one state machine cannot break the
// semantic parser and navigation for all ordinary members in its file.
foreach (Identifier identifier in tree.DescendantsAndSelf.OfType<Identifier>())
{
string name = identifier.Name;
if (name.Any(c => !(char.IsLetterOrDigit(c) || c == '_')))
identifier.Name = "__generated_" + string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c.ToString() : "u" + ((int)c).ToString("X4", CultureInfo.InvariantCulture)));
}
tree.InsertChildAfter(null, new Comment(" Decompiled from the matching game DLL using the shared BD2 names table. For navigation; not compiled or executed."), Roles.Comment);
using var text = new StringWriter(System.Globalization.CultureInfo.InvariantCulture);
var writer = TokenWriter.WrapInWriterThatSetsLocationsInAST(new TextWriterTokenWriter(text));
tree.AcceptVisitor(new CSharpOutputVisitor(writer, (generated ? rawSettings : primarySettings).CSharpFormattingOptions));
byte[] content = new UTF8Encoding(false).GetBytes(text.ToString());
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllBytes(path, content);
var declarations = new Dictionary<int, Declaration>();
foreach (AstNode node in tree.DescendantsAndSelf)
{
if (node is not EntityDeclaration && node is not VariableInitializer && node is not Accessor) continue;
if (node.GetSymbol() is not IEntity entity || entity.MetadataToken.IsNil) continue;
int token = MetadataTokens.GetToken(entity.MetadataToken);
TextLocation start = node is EntityDeclaration declaration && !declaration.NameToken.IsNull ? declaration.NameToken.StartLocation : node.StartLocation;
TextLocation end = node.EndLocation;
if (start.Line <= 0 || end.Line < start.Line) continue;
declarations.TryAdd(token, new Declaration(token, entity.ReflectionName, entity.SymbolKind.ToString(), path, start.Line, start.Column, end.Line, end.Column, generated));
}
var points = new Dictionary<int, List<Point>>();
foreach (KeyValuePair<ICSharpCode.Decompiler.IL.ILFunction, List<ICSharpCode.Decompiler.DebugInfo.SequencePoint>> function in decompiler.CreateSequencePoints(tree))
{
IMethod method = function.Key.MoveNextMethod ?? function.Key.Method;
if (method == null || method.MetadataToken.IsNil) continue;
int token = MetadataTokens.GetToken(method.MetadataToken);
var sequence = function.Value.Where(p => !p.IsHidden).Select(p => new Point(p.Offset, p.StartLine, p.StartColumn, p.EndLine, p.EndColumn)).ToList();
if (sequence.Count > 0) points.TryAdd(token, sequence);
}
return new Source(path, content, declarations, points);
}
public void Dispose() => file.Dispose();
}
private static (byte[] Bytes, BlobContentId Id) BuildPdb(PEFile pe, ModuleDefinition module, Source[] sources,
Dictionary<int, Declaration> declarations, Dictionary<int, (Source Source, List<Point> Points)> methods, string dependencies)
{
var metadata = new MetadataBuilder();
var documents = new Dictionary<string, DocumentHandle>(StringComparer.Ordinal);
var custom = new List<(EntityHandle Parent, Guid Kind, byte[] Bytes)>();
GuidHandle csharp = metadata.GetOrAddGuid(CSharpLanguage);
GuidHandle sha256 = metadata.GetOrAddGuid(new Guid("8829D00F-11B8-4213-878B-770E8597AC16"));
foreach (Source source in sources)
{
DocumentHandle document = metadata.AddDocument(metadata.GetOrAddDocumentName(source.Path), sha256, metadata.GetOrAddBlob(SHA256.HashData(source.Content)), csharp);
documents.Add(source.Path, document);
using var stream = new MemoryStream();
using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) writer.Write(source.Content.Length);
using (var deflate = new System.IO.Compression.DeflateStream(stream, System.IO.Compression.CompressionLevel.Fastest, leaveOpen: true)) deflate.Write(source.Content);
custom.Add((document, EmbeddedSource, stream.ToArray()));
}
var methodDefs = module.GetTypes().SelectMany(t => t.Methods).ToDictionary(m => m.MetadataToken.ToInt32());
foreach (MethodDefinitionHandle handle in pe.Metadata.MethodDefinitions)
{
int token = MetadataTokens.GetToken(handle);
if (!methods.TryGetValue(token, out (Source Source, List<Point> Points) mapped)) { metadata.AddMethodDebugInformation(default, default); continue; }
Point[] points = [.. mapped.Points.Where(p => p.Offset >= 0 && p.Offset < methodDefs[token].Body.CodeSize && p.Line > 0 && p.EndLine >= p.Line)
.GroupBy(p => p.Offset).Select(g => g.First()).OrderBy(p => p.Offset)];
var blob = new BlobBuilder(); blob.WriteCompressedInteger(0); // no local signature is needed for navigation
int previousOffset = 0, previousLine = 0, previousColumn = 0;
for (int i = 0; i < points.Length; i++)
{
Point p = points[i]; blob.WriteCompressedInteger(i == 0 ? p.Offset : p.Offset - previousOffset);
int lines = p.EndLine - p.Line; blob.WriteCompressedInteger(lines);
int columns = p.EndColumn - p.Column;
if (lines == 0) blob.WriteCompressedInteger(Math.Max(1, columns)); else blob.WriteCompressedSignedInteger(columns);
if (i == 0) { blob.WriteCompressedInteger(p.Line); blob.WriteCompressedInteger(p.Column); }
else { blob.WriteCompressedSignedInteger(p.Line - previousLine); blob.WriteCompressedSignedInteger(p.Column - previousColumn); }
previousOffset = p.Offset; previousLine = p.Line; previousColumn = p.Column;
}
metadata.AddMethodDebugInformation(documents[mapped.Source.Path], metadata.GetOrAddBlob(blob));
}
foreach (Mono.Cecil.TypeDefinition type in module.GetTypes())
{
if (!declarations.TryGetValue(type.MetadataToken.ToInt32(), out Declaration d)) continue;
var docs = new BlobBuilder(); docs.WriteCompressedInteger(MetadataTokens.GetRowNumber(documents[d.File]));
custom.Add((MetadataTokens.EntityHandle(type.MetadataToken.ToInt32()), TypeDocuments, docs.ToArray()));
}
byte[] options = Encoding.UTF8.GetBytes("language\0C#\0language-version\0" + "12.0\0compiler-version\0BD2.GameSdk\0output-kind\0DynamicallyLinkedLibrary\0optimization\0debug\0");
custom.Add((MetadataTokens.EntityHandle(1), CompilationOptions, options));
var references = new BlobBuilder();
foreach (Mono.Cecil.AssemblyNameReference reference in module.AssemblyReferences)
{
string path = Path.Combine(dependencies, reference.Name + ".dll");
if (!File.Exists(path)) continue;
using FileStream stream = File.OpenRead(path); using var dependency = new PEReader(stream);
MetadataReader reader = dependency.GetMetadataReader();
references.WriteBytes(Encoding.UTF8.GetBytes(Path.GetFileName(path))); references.WriteByte(0);
references.WriteByte(0); // global alias
references.WriteByte(1); // combined embedInteropTypes=false, image kind=assembly
references.WriteUInt32((uint)dependency.PEHeaders.CoffHeader.TimeDateStamp);
references.WriteUInt32((uint)dependency.PEHeaders.PEHeader.SizeOfImage);
references.WriteBytes(reader.GetGuid(reader.GetModuleDefinition().Mvid).ToByteArray());
}
custom.Add((MetadataTokens.EntityHandle(1), CompilationReferences, references.ToArray()));
foreach ((EntityHandle Parent, Guid Kind, byte[] Bytes) in custom.OrderBy(c => CodedIndex.HasCustomDebugInformation(c.Parent)))
metadata.AddCustomDebugInformation(Parent, metadata.GetOrAddGuid(Kind), metadata.GetOrAddBlob(Bytes));
var counts = Enumerable.Range(0, 64).Select(i => pe.Metadata.GetTableRowCount((TableIndex)i)).ToImmutableArray();
var builder = new PortablePdbBuilder(metadata, counts, default, blobs =>
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (Blob blob in blobs) hash.AppendData(blob.GetBytes());
return BlobContentId.FromHash(hash.GetHashAndReset());
});
var output = new BlobBuilder(); BlobContentId id = builder.Serialize(output);
return (output.ToArray(), id);
}
private static void WriteXmlDocumentation(string assembly, PEFile pe, Dictionary<int, Declaration> declarations)
{
var settings = new DecompilerSettings();
var resolver = new UniversalAssemblyResolver(assembly, false, pe.DetectTargetFrameworkId());
var decompiler = new CSharpDecompiler(pe, resolver, settings);
using var writer = System.Xml.XmlWriter.Create(Path.ChangeExtension(assembly, ".xml"), new System.Xml.XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false) });
writer.WriteStartElement("doc"); writer.WriteStartElement("assembly"); writer.WriteElementString("name", ShellGenerator.ShellName); writer.WriteEndElement(); writer.WriteStartElement("members");
// Method signatures already provide IntelliSense, and PDB supplies exact
// navigation. Repeating the same boilerplate for every member inflated
// each XML file to hundreds of MB without adding API documentation.
foreach (IEntity entity in decompiler.TypeSystem.MainModule.TypeDefinitions)
{
if (entity.MetadataToken.IsNil || !declarations.TryGetValue(MetadataTokens.GetToken(entity.MetadataToken), out Declaration location)) continue;
string id = ICSharpCode.Decompiler.Documentation.IdStringProvider.GetIdString(entity);
writer.WriteStartElement("member"); writer.WriteAttributeString("name", id);
writer.WriteElementString("summary", "Readable game type. Decompiled source: " + Path.GetRelativePath(Path.Combine(Path.GetDirectoryName(assembly), "sources"), location.File) + ":" + location.Line);
writer.WriteElementString("remarks", "Navigation-only reference; the runtime implementation is in the matching Assembly-CSharp.");
writer.WriteEndElement();
}
writer.WriteEndElement(); writer.WriteEndElement();
}
private sealed class NavigationSymbolWriterProvider(BlobContentId id, string pdbPath, byte[] pdb) : ISymbolWriterProvider
{
public ISymbolWriter GetSymbolWriter(ModuleDefinition module, string fileName) => new Writer(id, pdbPath, pdb);
public ISymbolWriter GetSymbolWriter(ModuleDefinition module, Stream symbolStream) => throw new NotSupportedException();
private sealed class Writer(BlobContentId id, string path, byte[] pdb) : ISymbolWriter
{
public ISymbolReaderProvider GetReaderProvider() => new PortablePdbReaderProvider();
public void Write(MethodDebugInformation info) { }
public void Write(ICustomDebugInformationProvider provider) { }
public void Write() { }
public void Dispose() { }
public ImageDebugHeader GetDebugHeader()
{
using var stream = new MemoryStream(); using var writer = new BinaryWriter(stream);
writer.Write(0x53445352); writer.Write(id.Guid.ToByteArray()); writer.Write(1); writer.Write(Encoding.UTF8.GetBytes(path)); writer.Write((byte)0);
byte[] codeview = stream.ToArray();
return new ImageDebugHeader([
new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.CodeView, MajorVersion = 0x100, MinorVersion = 0x504d, TimeDateStamp = (int)id.Stamp, SizeOfData = codeview.Length }, codeview),
new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.PdbChecksum, SizeOfData = 39 }, [.. Encoding.UTF8.GetBytes("SHA256\0"), .. SHA256.HashData(pdb)])
]);
}
}
}
}