refactor(all): reorganize packages, establish player ownership and isolate accounts
This commit is contained in:
@@ -7,12 +7,6 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>disable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- The navigation cache hashes this DLL. Unrelated repository commits
|
||||
must not change it through informational-version metadata. -->
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<!-- Source Link embeds commit URLs in the tool PDB. Its checksum is also
|
||||
embedded in this DLL, so it must not vary with unrelated commits. -->
|
||||
<EnableSourceLink>false</EnableSourceLink>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ICSharpCode.Decompiler" Version="9.1.0.7988" />
|
||||
@@ -20,5 +14,10 @@
|
||||
<Compile Include="../GameNames/NameTable.cs" Link="NameTable.cs" />
|
||||
<Compile Remove="samples/**/*.cs;Package/**/*.cs" />
|
||||
<EmbeddedResource Include="../GameNames/Mappings/names.json.gz" LogicalName="BD2.GameNames.names.json.gz" />
|
||||
<!-- Embed the complete generation source set so packaged tools fingerprint
|
||||
generation logic independently of CLI, validation and assembly metadata. -->
|
||||
<EmbeddedResource Include="Generation/**/*.cs" Exclude="Generation/NameGenerator.cs" LogicalName="BD2.GameSdk.Generation.%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
<EmbeddedResource Include="../GameNames/NameTable.cs" LogicalName="BD2.GameSdk.Generation.NameTable.cs" />
|
||||
<EmbeddedResource Include="Generation/NameGenerator.cs" LogicalName="BD2.GameSdk.Names.NameGenerator.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)])
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-347
@@ -1,17 +1,18 @@
|
||||
using System.IO.Compression;
|
||||
using BD2.GameSdk.Generation;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using BD2.GameNames.Internal;
|
||||
using Mono.Cecil;
|
||||
using Mono.Cecil.Cil;
|
||||
|
||||
using static BD2.GameSdk.Generation.AssemblyNames;
|
||||
using static BD2.GameSdk.Generation.GenerationInputs;
|
||||
using static BD2.GameSdk.Generation.ShellGenerator;
|
||||
|
||||
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
|
||||
@@ -23,28 +24,23 @@ internal static class Program
|
||||
if (args.Length == 5 && args[3] != "--game-version") throw new ArgumentException("Invalid prepare-embedded options");
|
||||
PrepareEmbedded(args[1], args[2], args.Length == 5 ? args[4] : 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 "names" when args.Length == 5: NameGenerator.Generate(args[1], args[2], args[3], args[4]); break;
|
||||
case "shell" when args.Length == 4: ShellGenerator.Generate(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 "verify-navigation" when args.Length == 2: SourceNavigationVerifier.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> [--game-version version] | 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");
|
||||
|
||||
default: throw new ArgumentException("Usage: prepare-embedded <Assembly-CSharp.dll> <output-dir> [--game-version version] | 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]");
|
||||
}
|
||||
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()
|
||||
@@ -96,7 +92,7 @@ internal static class Program
|
||||
string cacheRoot = Environment.GetEnvironmentVariable("BD2_GAME_SDK_CACHE");
|
||||
if (string.IsNullOrEmpty(cacheRoot)) cacheRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BD2", "GameSdk", "navigation");
|
||||
string tableHash = Convert.ToHexString(SHA256.HashData(compressed)).ToLowerInvariant();
|
||||
string inputs = tableHash + "|" + GeneratorStamp() + "|" + DependencyStamp(assembly);
|
||||
string inputs = tableHash + "|" + GenerationInputs.Stamp() + "|" + DependencyStamp(assembly);
|
||||
// Game version is the public grouping; the fingerprint prevents incompatible
|
||||
// tools or binaries within one game version from overwriting active references.
|
||||
string key = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(inputs))).ToLowerInvariant();
|
||||
@@ -115,12 +111,12 @@ internal static class Program
|
||||
File.WriteAllBytes(tablePath + ".gz", compressed);
|
||||
using (var gzip = new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress))
|
||||
using (FileStream destination = File.Create(tablePath)) gzip.CopyTo(destination);
|
||||
GenerateShell(tablePath, assembly, Path.Combine(shared, ShellName + ".dll"));
|
||||
ShellGenerator.Generate(tablePath, assembly, Path.Combine(shared, ShellName + ".dll"));
|
||||
File.WriteAllText(ready, StampFiles());
|
||||
}
|
||||
else Console.WriteLine($"Game source navigation cache hit: game={gameVersion}");
|
||||
WriteNavigationItems(shared);
|
||||
WriteGeneratedFile(Path.Combine(output, "GameSdkIdentity.g.cs"), System.Text.Encoding.UTF8.GetBytes(
|
||||
"// Generated from the shared names table.\n[assembly: System.Reflection.AssemblyMetadataAttribute(\"BD2.GameNames\", " + JsonSerializer.Serialize(Stamp(table)) + ")]\n"));
|
||||
WriteIdentity(Path.Combine(output, "GameSdkIdentity.g.cs"), table);
|
||||
WriteGeneratedFile(Path.Combine(output, "shared-sdk.txt"), System.Text.Encoding.UTF8.GetBytes(shared));
|
||||
var properties = new System.Xml.Linq.XElement("PropertyGroup",
|
||||
new System.Xml.Linq.XElement("BD2SharedSdkDir", shared));
|
||||
@@ -163,7 +159,7 @@ internal static class Program
|
||||
{
|
||||
SourceNavigation.Manifest 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);
|
||||
SourceNavigationVerifier.RestoreSources(output);
|
||||
var items = new System.Xml.Linq.XElement("ItemGroup");
|
||||
foreach (string 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),
|
||||
@@ -196,339 +192,16 @@ internal static class Program
|
||||
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)
|
||||
};
|
||||
private static void WriteIdentity(string path, NameTable table) =>
|
||||
WriteGeneratedFile(path, System.Text.Encoding.UTF8.GetBytes(
|
||||
"// Generated from the shared names table.\n[assembly: System.Reflection.AssemblyMetadataAttribute(\"BD2.GameNames\", " + JsonSerializer.Serialize(Stamp(table)) + ")]\n"));
|
||||
|
||||
internal static void Prepare(string assembly, string mapping, string versions, string output)
|
||||
{
|
||||
string 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 (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 = Hash(typeof(Program).Assembly.Location)
|
||||
};
|
||||
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}");
|
||||
}
|
||||
|
||||
internal static void GenerateShell(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}");
|
||||
}
|
||||
|
||||
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);
|
||||
NameGenerator.Generate(assembly, mapping, versions, tablePath);
|
||||
PreparePackage(tablePath + ".gz", assembly, output);
|
||||
}
|
||||
|
||||
internal static void Reobfuscate(string tablePath, string input, string output, string originalAssembly = null, string dependencyDirectory = null)
|
||||
@@ -542,6 +215,7 @@ internal static class Program
|
||||
VerifyStamp(module, table);
|
||||
Rewrite(module, table, false);
|
||||
foreach (AssemblyNameReference a in module.AssemblyReferences.Where(a => a.Name == ShellName)) a.Name = table.assembly_name;
|
||||
RuntimeInitialization.Inject(module, Stamp(table));
|
||||
// Preserve compiler PDBs in obj; rewritten runtime DLL deliberately has no stale symbols.
|
||||
string absoluteOutput = Path.GetFullPath(output);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(absoluteOutput));
|
||||
@@ -562,6 +236,7 @@ internal static class Program
|
||||
using ModuleDefinition 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);
|
||||
RuntimeInitialization.Verify(module, Stamp(table));
|
||||
if (originalAssembly != null)
|
||||
{
|
||||
if (Hash(originalAssembly) != table.assembly_sha256) throw new InvalidDataException("Verification game binary does not match the names table");
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
```powershell
|
||||
dotnet nuget add source 'D:\NuGet\BD2' --name BD2
|
||||
dotnet add MyPlugin.csproj package BD2.GameSdk --version 0.2.2-game.2.35.10
|
||||
dotnet add MyPlugin.csproj package BD2.GameSdk --version 0.2.3-game.2.35.10
|
||||
```
|
||||
|
||||
Visual Studio / Rider 也可在 NuGet 包管理界面添加该源。
|
||||
@@ -41,7 +41,7 @@ Visual Studio / Rider 也可在 NuGet 包管理界面添加该源。
|
||||
<BD2GameVersion>2.35.10</BD2GameVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BD2.GameSdk" Version="0.2.2-game.2.35.10" PrivateAssets="all" />
|
||||
<PackageReference Include="BD2.GameSdk" Version="0.2.3-game.2.35.10" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
@@ -82,7 +82,7 @@ IDE 项目加载时构建会准备可读引用,CLI 首次构建同样会自动
|
||||
|
||||
构建核对声明值、SDK 内嵌表、真实游戏 DLL 指纹。缺少声明或版本不匹配会报错;不会自动选用其他版本。NuGet 项目仍需安装带对应游戏版本的包。
|
||||
|
||||
仓库默认缓存根为 `.build/game-sdk`,与源码位于同一盘。第三方 NuGet 项目默认使用 `%LOCALAPPDATA%\BD2\GameSdk\navigation`;可在本机 `Directory.Build.props` 设置 `BD2GameSdkCache`,或设置环境变量 `BD2_GAME_SDK_CACHE`。 `obj` 不影响共享缓存。缺失的共享源码可在下次准备时从 PDB 恢复,只有删除共享缓存才会触发重新生成。
|
||||
仓库默认缓存根为 `.build/game-sdk`,与源码位于同一盘。第三方 NuGet 项目默认使用 `%LOCALAPPDATA%\BD2\GameSdk\navigation`;可在本机 `Directory.Build.props` 设置 `BD2GameSdkCache`,或设置环境变量 `BD2_GAME_SDK_CACHE`。 `obj` 不影响共享缓存。缺失的共享源码可在下次准备时从 PDB 恢复。真实游戏 DLL、映射表、游戏依赖、生成逻辑或反编译依赖改变时会生成新的缓存。
|
||||
|
||||
`lib` 中的程序集仅供开发导航,**不要部署或执行**。这里展示的是当前 DLL 的反编译源码,局部变量名和语法可能与原始工程不同。导航 PDB 对应可读程序集,不能用于真实混淆游戏 DLL 的逐行调试。
|
||||
|
||||
@@ -109,7 +109,7 @@ owner.StartCoroutine(Game.MemberName(owner.GetType(), "ReadableCoroutineName"));
|
||||
|
||||
`GetGameMethod/GetGameField/GetGameProperty/GetGameEvent` 采用 .NET 反射约定:查不到返回 null,重载歧义抛出 `AmbiguousMatchException`,null 名字参数抛出 `ArgumentNullException`。`MemberName` 的成员种类使用 `GameMemberKind` 枚举。未知映射保留字面名,普通 UI 文案、`nameof` 字符串和协程常量都不会被 reobf 自动替换。
|
||||
|
||||
`Game.FindType` 保留给运行时才知道类型名称的查询。只做字符串反射的项目可单独安装 `BD2.GameNames`,启动时用 `Game.ValidateGame(...)`。这种方式没有可读游戏类型引用,也没有编译指纹,使用 SDK 的项目必须用 `Game.Validate(typeof(MyPlugin).Assembly, ...)`。
|
||||
`Game.FindType` 保留给运行时才知道类型名称的查询。只做字符串反射的项目可单独安装 `BD2.GameNames`,首次调用 `Game` API 时自动核对游戏 DLL 与名字表。SDK 项目在 reobf 时自动写入模块初始化器,在插件代码执行前核对插件编译指纹、运行时名字表和游戏 DLL;即使插件只使用可读类型、没有调用 `Game` API,也会自动检查,无需编写启动校验代码。
|
||||
|
||||
## 构建与部署
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Mono.Cecil;
|
||||
using Mono.Cecil.Cil;
|
||||
|
||||
namespace BD2.GameSdk;
|
||||
|
||||
internal static class RuntimeInitialization
|
||||
{
|
||||
private const string RuntimeAssembly = "BD2.GameNames";
|
||||
private const string RuntimeType = "BD2.GameNames.RuntimeCompatibility";
|
||||
private const string RuntimeMethod = "InitializePlugin";
|
||||
|
||||
internal static void Inject(ModuleDefinition module, string stamp)
|
||||
{
|
||||
TypeDefinition moduleType = module.Types.Single(t => t.Name == "<Module>");
|
||||
MethodDefinition initializer = moduleType.Methods.SingleOrDefault(m => m.Name == ".cctor");
|
||||
if (initializer == null)
|
||||
{
|
||||
initializer = new MethodDefinition(".cctor", MethodAttributes.Private | MethodAttributes.Static |
|
||||
MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, module.TypeSystem.Void);
|
||||
moduleType.Methods.Add(initializer);
|
||||
initializer.Body.Instructions.Add(Instruction.Create(OpCodes.Ret));
|
||||
}
|
||||
if (initializer.HasBody && initializer.Body.Instructions.Count >= 2 && IsRuntimeCall(initializer.Body.Instructions[1]))
|
||||
{
|
||||
Verify(module, stamp);
|
||||
return;
|
||||
}
|
||||
AssemblyNameReference runtime = module.AssemblyReferences.SingleOrDefault(a => a.Name == RuntimeAssembly);
|
||||
if (runtime == null)
|
||||
{
|
||||
runtime = new AssemblyNameReference(RuntimeAssembly, new Version(1, 0, 0, 0));
|
||||
module.AssemblyReferences.Add(runtime);
|
||||
}
|
||||
var type = new TypeReference("BD2.GameNames", "RuntimeCompatibility", module, runtime);
|
||||
var method = new MethodReference(RuntimeMethod, module.TypeSystem.Void, type);
|
||||
method.Parameters.Add(new ParameterDefinition(module.TypeSystem.String));
|
||||
initializer.Body.Instructions.Insert(0, Instruction.Create(OpCodes.Ldstr, stamp));
|
||||
initializer.Body.Instructions.Insert(1, Instruction.Create(OpCodes.Call, method));
|
||||
}
|
||||
|
||||
internal static void Verify(ModuleDefinition module, string stamp)
|
||||
{
|
||||
MethodDefinition initializer = module.Types.Single(t => t.Name == "<Module>").Methods.SingleOrDefault(m => m.Name == ".cctor");
|
||||
if (initializer?.HasBody != true || initializer.Body.Instructions.Count < 3 ||
|
||||
initializer.Body.Instructions[0].OpCode != OpCodes.Ldstr || initializer.Body.Instructions[0].Operand as string != stamp ||
|
||||
!IsRuntimeCall(initializer.Body.Instructions[1]))
|
||||
throw new InvalidDataException("Missing/mismatched SDK runtime initializer: rebuild this plugin with BD2.GameSdk");
|
||||
}
|
||||
|
||||
private static bool IsRuntimeCall(Instruction instruction) => instruction.OpCode == OpCodes.Call &&
|
||||
instruction.Operand is MethodReference method && method.Name == RuntimeMethod && !method.HasThis &&
|
||||
method.DeclaringType.FullName == RuntimeType && method.DeclaringType.Scope is AssemblyNameReference assembly &&
|
||||
assembly.Name == RuntimeAssembly && method.ReturnType.MetadataType == MetadataType.Void &&
|
||||
method.Parameters.Count == 1 && method.Parameters[0].ParameterType.MetadataType == MetadataType.String;
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.Loader;
|
||||
using Mono.Cecil;
|
||||
using Mono.Cecil.Cil;
|
||||
|
||||
namespace BD2.GameSdk;
|
||||
|
||||
/// <summary>End-to-end tests with synthetic binaries. Never execute the production game or readable shell.</summary>
|
||||
internal static class SelfTest
|
||||
{
|
||||
private static readonly string[] PublicRuntimeTypes = ["BD2.GameNames.Game", "BD2.GameNames.GameMemberKind"];
|
||||
private static void Assert(bool condition, string message)
|
||||
{
|
||||
if (!condition) throw new InvalidOperationException("Self-test: " + message);
|
||||
}
|
||||
private static void Build(string project, params string[] properties)
|
||||
{
|
||||
var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
foreach (string arg in new[] { "build", project, "--nologo", "-c", "Release" }.Concat(properties)) start.ArgumentList.Add(arg);
|
||||
using var process = Process.Start(start);
|
||||
Task<string> stdout = process.StandardOutput.ReadToEndAsync(); Task<string> stderr = process.StandardError.ReadToEndAsync();
|
||||
process.WaitForExit();
|
||||
string output = stdout.GetAwaiter().GetResult() + stderr.GetAwaiter().GetResult();
|
||||
if (process.ExitCode != 0) throw new InvalidOperationException(output);
|
||||
}
|
||||
private static string Project(string name, string references = "") => $"""
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>netstandard2.1</TargetFramework><LangVersion>latest</LangVersion><AssemblyName>{name}</AssemblyName></PropertyGroup>
|
||||
<ItemGroup>{references}</ItemGroup>
|
||||
</Project>
|
||||
""";
|
||||
internal static void Run()
|
||||
{
|
||||
string repository = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../"));
|
||||
Assert(File.Exists(Path.Combine(repository, "versions.json")), "run from a built GameSdk project");
|
||||
string root = Path.Combine(repository, ".build", "game-sdk-tests", Guid.NewGuid().ToString("N"));
|
||||
string gameDir = Path.Combine(root, "game"), sdkDir = Path.Combine(root, "sdk"), probeDir = Path.Combine(root, "probe");
|
||||
Directory.CreateDirectory(gameDir); Directory.CreateDirectory(probeDir);
|
||||
string exported = Path.Combine(root, "embedded", "names.json");
|
||||
Program.ExportNames(exported);
|
||||
Assert(File.ReadAllBytes(exported + ".gz").SequenceEqual(Program.EmbeddedNames()), "SDK exports the exact embedded table");
|
||||
Assert(Program.ReadTable(exported).schema_version == 1, "embedded names have supported schema");
|
||||
string gameProject = Path.Combine(gameDir, "Game.csproj");
|
||||
File.WriteAllText(gameProject, Project("Assembly-CSharp"));
|
||||
File.WriteAllText(Path.Combine(gameDir, "Game.cs"), """
|
||||
using System;
|
||||
public class AppManager { public bool IsPlatformLogin { get { return true; } } }
|
||||
public class IntroUI { public void SendMaintenanceInfo(bool force) {} }
|
||||
namespace BDNetwork { public class NetworkManager { public string GetPachedGameDataPath() { return "path"; } } }
|
||||
namespace Readable {
|
||||
public interface IThing { string Name { get; } }
|
||||
public class Agent : IThing {
|
||||
string IThing.Name { get { return "agent"; } }
|
||||
public event EventHandler Changed;
|
||||
public int Value;
|
||||
public int Ping(int value) { return value; }
|
||||
public string Ping(string value) { return value; }
|
||||
public void Act() {}
|
||||
public void Awake() {}
|
||||
private void PauseRoutine() {}
|
||||
private int secret = 3;
|
||||
public class State {}
|
||||
public T Echo<T>(T value) { return value; }
|
||||
public Agent[,] Grid(Agent[,] value) { return value; }
|
||||
public void Ref(ref Agent value) {}
|
||||
}
|
||||
public class Box<T> { public T Item; public T Echo(T value) { return value; } public class Nested<U> { public U Item; } }
|
||||
public class Derived : Box<Agent> {}
|
||||
}
|
||||
""");
|
||||
Build(gameProject);
|
||||
string gamePath = Path.Combine(gameDir, "bin/Release/netstandard2.1/Assembly-CSharp.dll");
|
||||
string mappingPath = Path.Combine(root, "ObfuscationTranslation_9.8.7.obfuscate"), versionPath = Path.Combine(root, "versions.json");
|
||||
File.WriteAllText(versionPath, "{\"game_version\":\"9.8.7\"}");
|
||||
var rows = new List<string> { "#ReverseOrder", "#Classes", "#Methods", "#Fields", "#Properties", "#Events", "#Parameters" };
|
||||
using (ModuleDefinition game = Program.ReadModule(gamePath))
|
||||
{
|
||||
var table = new BD2.GameNames.Internal.NameTable { assembly_name = "Assembly-CSharp" };
|
||||
TypeDefinition[] defs = game.GetTypes().ToArray(); int counter = 0;
|
||||
string Obfuscated() => "ὠ" + string.Concat((counter++).ToString(CultureInfo.InvariantCulture).Select(c => (char)('ὠ' + c - '0')));
|
||||
var newNames = new Dictionary<TypeDefinition, string>();
|
||||
string Full(TypeDefinition t) => t.DeclaringType != null ? Full(t.DeclaringType) + "+" + newNames[t] : newNames.TryGetValue(t, out string n) ? n : Program.TypeKey(t);
|
||||
foreach (TypeDefinition t in defs)
|
||||
if (t.Namespace == "Readable" || t.DeclaringType?.Namespace == "Readable")
|
||||
{
|
||||
newNames[t] = Obfuscated();
|
||||
string meaning = t.DeclaringType == null ? Program.TypeKey(t) : newNames[t.DeclaringType] + "/" + (t.Name == "State" ? "<Run>d__0" : t.Name);
|
||||
rows.Add(newNames[t] + "⇨" + meaning);
|
||||
}
|
||||
foreach (TypeDefinition t in defs) table.types.Add(new BD2.GameNames.Internal.TypeName { token = t.MetadataToken.ToInt32(), original = Program.TypeKey(t), readable = Full(t) });
|
||||
foreach (TypeDefinition t in defs)
|
||||
{
|
||||
void Rename(IMemberDefinition member, string kind, string signature = null, MethodDefinition method = null)
|
||||
{
|
||||
bool renamed = member.Name != ".ctor" && member.Name != "Awake" && t.Name != "<Module>";
|
||||
string original = member.Name, target = renamed ? Obfuscated() : original;
|
||||
if (renamed) rows.Add(target + "⇨" + original);
|
||||
var entry = new BD2.GameNames.Internal.MemberName { token = member.MetadataToken.ToInt32(), declaring_type = Program.TypeKey(t), original = original, readable = target, kind = kind, signature = signature };
|
||||
if (method != null) foreach (ParameterDefinition p in method.Parameters)
|
||||
{
|
||||
if (string.IsNullOrEmpty(p.Name)) continue;
|
||||
string name = Obfuscated(); rows.Add(name + "⇨" + p.Name);
|
||||
entry.parameters.Add(new BD2.GameNames.Internal.ParameterName { position = p.Index, original = p.Name, readable = name });
|
||||
}
|
||||
table.members.Add(entry);
|
||||
}
|
||||
foreach (MethodDefinition m in t.Methods) Rename(m, "method", Program.Signature(m), m);
|
||||
foreach (FieldDefinition f in t.Fields) Rename(f, "field", Program.Signature(f));
|
||||
foreach (PropertyDefinition p in t.Properties) Rename(p, "property");
|
||||
foreach (EventDefinition e in t.Events) Rename(e, "event", Program.TypeKey(e.EventType));
|
||||
}
|
||||
Program.Rewrite(game, table, true);
|
||||
var entries = table.members.ToDictionary(m => m.token);
|
||||
foreach (TypeDefinition t in defs)
|
||||
{
|
||||
foreach (MethodDefinition m in t.Methods)
|
||||
{
|
||||
GameNames.Internal.MemberName entry = entries[m.MetadataToken.ToInt32()]; m.Name = entry.readable;
|
||||
foreach (GameNames.Internal.ParameterName p in entry.parameters) m.Parameters[p.position].Name = p.readable;
|
||||
}
|
||||
foreach (FieldDefinition f in t.Fields) f.Name = entries[f.MetadataToken.ToInt32()].readable;
|
||||
foreach (PropertyDefinition p in t.Properties) p.Name = entries[p.MetadataToken.ToInt32()].readable;
|
||||
foreach (EventDefinition e in t.Events) e.Name = entries[e.MetadataToken.ToInt32()].readable;
|
||||
Program.SetTypeName(t, table.types.Single(e => e.token == t.MetadataToken.ToInt32()).readable);
|
||||
}
|
||||
game.Write(gamePath);
|
||||
}
|
||||
File.WriteAllLines(mappingPath, rows);
|
||||
bool embeddedMismatchRejected = false;
|
||||
try { Program.PrepareEmbedded(gamePath, Path.Combine(root, "mismatched-embedded")); }
|
||||
catch (InvalidDataException ex) { embeddedMismatchRejected = ex.Message.Contains("does not match SDK names for game"); }
|
||||
Assert(embeddedMismatchRejected, "embedded table rejects another game binary without seeking an external mapping");
|
||||
Program.Prepare(gamePath, mappingPath, versionPath, sdkDir);
|
||||
SourceNavigation.Verify(sdkDir);
|
||||
string tablePath = Path.Combine(sdkDir, "names.json");
|
||||
GameNames.Internal.NameTable generated = Program.ReadTable(tablePath);
|
||||
Assert(generated.types.Any(t => t.readable == "Readable.Agent+<Run>d__0"), "compiler-generated scoped type");
|
||||
using (ModuleDefinition shell = Program.ReadModule(Path.Combine(sdkDir, Program.ShellName + ".dll")))
|
||||
{
|
||||
Assert(shell.Assembly.CustomAttributes.Any(a => a.AttributeType.Name == "ReferenceAssemblyAttribute"), "reference assembly marker");
|
||||
Assert(shell.GetType("Readable.Agent").Methods.First(m => m.Name == "Ping").Body.Instructions.Any(i => i.OpCode == OpCodes.Ret), "readable IL retained for offline decompiler fallback");
|
||||
Assert(shell.GetType("Readable.Agent").Methods.Count(m => m.Name == "Ping") == 2, "overload names restored");
|
||||
}
|
||||
string runtimeProject = Path.Combine(repository, "plugins/GameNames/GameNames.csproj"), runtimeBin = Path.Combine(root, "runtime-bin") + Path.DirectorySeparatorChar;
|
||||
Build(runtimeProject, "-p:GameNamesTable=" + tablePath, "-p:BaseIntermediateOutputPath=" + Path.Combine(root, "runtime-obj") + Path.DirectorySeparatorChar, "-p:OutputPath=" + runtimeBin);
|
||||
string runtimePath = Path.Combine(runtimeBin, "BD2.GameNames.dll");
|
||||
string references = $"<Compile Include=\"{System.Security.SecurityElement.Escape(Path.Combine(sdkDir, "GameSdkIdentity.g.cs"))}\" /><Reference Include=\"{Program.ShellName}\"><HintPath>{System.Security.SecurityElement.Escape(Path.Combine(sdkDir, "ref", Program.ShellName + ".dll"))}</HintPath><Private>false</Private></Reference><Reference Include=\"BD2.GameNames\"><HintPath>{System.Security.SecurityElement.Escape(runtimePath)}</HintPath></Reference>";
|
||||
string probeProject = Path.Combine(probeDir, "Probe.csproj");
|
||||
File.WriteAllText(probeProject, Project("Probe", references));
|
||||
File.WriteAllText(Path.Combine(probeDir, "Probe.cs"), """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using BD2.GameNames;
|
||||
using Readable;
|
||||
public sealed class MarkerAttribute : Attribute { public Type Target; public MarkerAttribute(Type target) { Target = target; } }
|
||||
[Marker(typeof(Box<Agent>))]
|
||||
public class Probe {
|
||||
private static void Check(bool condition, string name) { if (!condition) throw new Exception(name); }
|
||||
public static string Run() {
|
||||
var log = new List<string>();
|
||||
Game.Validate(typeof(Probe).Assembly, "9.8.7", log.Add);
|
||||
Game.Validate(typeof(Probe).Assembly, log.Add);
|
||||
Game.ValidateGame(log.Add);
|
||||
var a = new Agent(); a.Value = 42;
|
||||
Check(a.Value == 42 && a.Ping(7) == 7 && a.Ping("ok") == "ok", "fields and overloads");
|
||||
Check(((IThing)a).Name == "agent", "explicit interface property");
|
||||
a.Changed += (s, e) => {};
|
||||
Check(typeof(Probe).GetCustomAttribute<MarkerAttribute>().Target == typeof(Box<Agent>), "custom attribute type arguments");
|
||||
Check(a.Echo(a) == a && new Box<Agent>().Echo(a) == a, "generic methods/types");
|
||||
var box = new Box<Agent>(); box.Item = a; Check(box.Item == a, "generic field");
|
||||
var derived = new Derived(); derived.Item = a; Check(derived.Echo(a) == a && derived.Item == a, "inherited generic members");
|
||||
var nested = new Box<Agent>.Nested<string>(); nested.Item = "nested"; Check(nested.Item == "nested", "nested generic field");
|
||||
var grid = new Agent[1,1]; grid[0,0] = a; Check(a.Grid(grid)[0,0] == a, "array pseudo methods");
|
||||
a.Ref(ref a);
|
||||
Check(typeof(Derived).BaseType == typeof(Box<Agent>), "generic base");
|
||||
Check(Game.Getter<AppManager, bool>(x => x.IsPlatformLogin).Invoke(new AppManager(), null) is true, "property expression");
|
||||
Check(Game.Method<Agent>(x => x.Act()).Name != "Act", "Harmony method expression");
|
||||
Check(nameof(Agent.Ping) == "Ping", "nameof is unchanged");
|
||||
const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||
var ping = typeof(Agent).GetGameMethod(nameof(Agent.Ping), all, null, new[] { typeof(int) }, null);
|
||||
Check((int)ping.Invoke(a, new object[] { 12 }) == 12, "overloaded runtime lookup");
|
||||
Check(Game.ParameterName(ping, "value") == ping.GetParameters()[0].Name, "parameter mapping");
|
||||
Check(typeof(Derived).GetGameMethod("Echo", all, null, new[] { typeof(Agent) }, null).Invoke(derived, new object[] { a }) == a, "inherited generic runtime method");
|
||||
Check(typeof(Derived).GetGameField("Item", all).GetValue(derived) == a, "inherited generic runtime field");
|
||||
bool ambiguous = false;
|
||||
try { typeof(Agent).GetGameMethod("Ping", all); } catch (AmbiguousMatchException) { ambiguous = true; }
|
||||
Check(ambiguous, "ambiguous overloads require a signature");
|
||||
Check(Game.MemberName(typeof(Agent), "PauseRoutine") != "PauseRoutine", "coroutine string mapping");
|
||||
Check(typeof(Agent).GetGameField("secret", all).GetValue(a) is 3, "private field");
|
||||
Check(Game.MemberName(typeof(Agent), "secret", GameMemberKind.Field) != "secret", "typed member kind");
|
||||
bool nullRejected = false;
|
||||
try { Game.TypeName(null); } catch (ArgumentNullException) { nullRejected = true; }
|
||||
Check(nullRejected, "null input uses .NET exception contract");
|
||||
Check(typeof(Agent).GetGameEvent("Changed", all).Name != "Changed", "event string mapping");
|
||||
Check(Game.FindType("Readable.Agent+<Run>d__0").DeclaringType == typeof(Agent), "generated nested type");
|
||||
Check(typeof(Agent).GetGameMethod("Awake", all).Name == "Awake", "Unity literal fallback");
|
||||
Check(Game.TypeName("Unmapped.Type") == "Unmapped.Type" && Game.MemberName(typeof(Agent), "Missing") == "Missing", "unknown literal fallback");
|
||||
Check(typeof(string).GetGameMethod("Contains", all, null, new[] { typeof(string) }, null) != null, "external member unaffected");
|
||||
bool rejected = false;
|
||||
try { Game.Validate(typeof(Probe).Assembly, "wrong", log.Add); } catch { rejected = true; }
|
||||
Check(rejected && log.Exists(s => s.Contains("SELF-CHECK FAILED")), "version mismatch diagnosed");
|
||||
return "All SDK/runtime integration probes passed; UI Ping remains Ping";
|
||||
}
|
||||
public static Agent[] Signature(Agent[] value) { return value; }
|
||||
}
|
||||
""");
|
||||
Build(probeProject);
|
||||
string readablePlugin = Path.Combine(probeDir, "bin/Release/netstandard2.1/Probe.dll"), pluginPath = Path.Combine(root, "Probe.dll");
|
||||
using (ModuleDefinition plugin = Program.ReadModule(readablePlugin))
|
||||
{
|
||||
var foreign = new TypeReference("Readable", "Agent", plugin, new AssemblyNameReference("Foreign", new Version(1, 0)));
|
||||
var holder = new TypeDefinition("", "ExternalHolder", TypeAttributes.Public, plugin.TypeSystem.Object);
|
||||
holder.Fields.Add(new FieldDefinition("Other", FieldAttributes.Public, foreign)); plugin.Types.Add(holder);
|
||||
plugin.Write(readablePlugin);
|
||||
}
|
||||
Program.Reobfuscate(tablePath, readablePlugin, pluginPath, gamePath);
|
||||
using (ModuleDefinition rewritten = Program.ReadModule(pluginPath))
|
||||
{
|
||||
Assert(rewritten.GetType("ExternalHolder").Fields[0].FieldType.FullName == "Readable.Agent", "external assembly scope isolation");
|
||||
Assert(!rewritten.AssemblyReferences.Any(a => a.Name == "System.Private.CoreLib"), "target framework preserved");
|
||||
Assert(Strings(readablePlugin).SequenceEqual(Strings(pluginPath)), "all ldstr operands preserved byte-for-byte");
|
||||
}
|
||||
var context = new AssemblyLoadContext("BD2 GameSdk self-test", isCollectible: true);
|
||||
context.Resolving += (_, name) => name.Name switch
|
||||
{
|
||||
"Assembly-CSharp" => context.LoadFromAssemblyPath(gamePath),
|
||||
"BD2.GameNames" => context.LoadFromAssemblyPath(runtimePath),
|
||||
_ => null
|
||||
};
|
||||
try { Console.WriteLine(context.LoadFromAssemblyPath(pluginPath).GetType("Probe").GetMethod("Run").Invoke(null, null)); }
|
||||
finally { context.Unload(); }
|
||||
string wrongTable = Path.Combine(root, "wrong-names.json");
|
||||
generated.game_version = "wrong";
|
||||
File.WriteAllText(wrongTable, System.Text.Json.JsonSerializer.Serialize(generated, Program.Json));
|
||||
bool mismatchRejected = false;
|
||||
string protectedOutput = Path.Combine(root, "protected-output.dll");
|
||||
File.WriteAllText(protectedOutput, "untouched");
|
||||
try { Program.Reobfuscate(wrongTable, readablePlugin, protectedOutput); } catch (InvalidDataException) { mismatchRejected = true; }
|
||||
Assert(mismatchRejected && File.ReadAllText(protectedOutput) == "untouched", "mismatched table rejected before publishing");
|
||||
File.AppendAllText(mappingPath, "\nὠὡ⇨Conflicting.Name\n");
|
||||
bool conflictRejected = false;
|
||||
try { Program.Prepare(gamePath, mappingPath, versionPath, sdkDir); } catch (InvalidDataException) { conflictRejected = true; }
|
||||
Assert(conflictRejected, "ambiguous mapping rejected");
|
||||
Console.WriteLine("Self-test artifacts: " + root);
|
||||
}
|
||||
private static string[] Strings(string path)
|
||||
{
|
||||
using ModuleDefinition module = Program.ReadModule(path);
|
||||
return module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody).SelectMany(m => m.Body.Instructions).Where(i => i.OpCode == OpCodes.Ldstr).Select(i => (string)i.Operand).ToArray();
|
||||
}
|
||||
internal static void VerifyRuntime(string tablePath, string runtimePath)
|
||||
{
|
||||
GameNames.Internal.NameTable table = Program.ReadTable(tablePath);
|
||||
using (ModuleDefinition runtime = Program.ReadModule(runtimePath))
|
||||
{
|
||||
var resource = (EmbeddedResource)runtime.Resources.Single(r => r.Name == "BD2.GameNames.names.json.gz");
|
||||
Assert(resource.GetResourceData().SequenceEqual(File.ReadAllBytes(tablePath + ".gz")), "embedded runtime table equals the shared table");
|
||||
}
|
||||
var context = new AssemblyLoadContext("BD2 full runtime-table check", isCollectible: true);
|
||||
try
|
||||
{
|
||||
System.Reflection.Assembly runtime = context.LoadFromAssemblyPath(Path.GetFullPath(runtimePath));
|
||||
Assert(runtime.GetExportedTypes().Select(t => t.FullName).OrderBy(n => n).SequenceEqual(PublicRuntimeTypes), "serialization models are not public API");
|
||||
Type game = runtime.GetType("BD2.GameNames.Game");
|
||||
Assert((string)game.GetProperty("GameVersion").GetValue(null) == table.game_version, "runtime deserializes the full table");
|
||||
Func<string, string> translate = game.GetMethod("TypeName").CreateDelegate<Func<string, string>>();
|
||||
foreach (GameNames.Internal.TypeName type in table.types) Assert(translate(type.readable) == type.original, "runtime type translation: " + type.readable);
|
||||
Assert(translate("Unknown.Type") == "Unknown.Type", "runtime literal fallback");
|
||||
Console.WriteLine($"Verified embedded runtime table: game={table.game_version}, {table.types.Count} type lookups; {runtimePath}");
|
||||
}
|
||||
finally { context.Unload(); }
|
||||
}
|
||||
}
|
||||
@@ -1,156 +1,23 @@
|
||||
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;
|
||||
|
||||
using BD2.GameSdk.Generation;
|
||||
using static BD2.GameSdk.Generation.SourceNavigation;
|
||||
|
||||
namespace BD2.GameSdk;
|
||||
|
||||
/// <summary>Build local, offline external-source navigation from the readable implementation.</summary>
|
||||
internal static class SourceNavigation
|
||||
internal static class SourceNavigationVerifier
|
||||
{
|
||||
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");
|
||||
private 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 = Program.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, Program.Hash(shellPath), Program.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");
|
||||
}
|
||||
|
||||
internal static void Verify(string sdkDirectory)
|
||||
{
|
||||
sdkDirectory = Program.ResolveSdkDirectory(sdkDirectory);
|
||||
string library = Path.Combine(sdkDirectory, "lib", Program.ShellName + ".dll"), pdbPath = Path.ChangeExtension(library, ".pdb");
|
||||
string library = Path.Combine(sdkDirectory, "lib", ShellGenerator.ShellName + ".dll"), pdbPath = Path.ChangeExtension(library, ".pdb");
|
||||
Manifest manifest = JsonSerializer.Deserialize<Manifest>(File.ReadAllText(Path.Combine(sdkDirectory, "navigation.json")));
|
||||
if (Program.Hash(library) != manifest.AssemblySha256 || Program.Hash(pdbPath) != manifest.PdbSha256)
|
||||
if (GenerationInputs.Hash(library) != manifest.AssemblySha256 || GenerationInputs.Hash(pdbPath) != manifest.PdbSha256)
|
||||
throw new InvalidDataException("Source navigation manifest, DLL and PDB do not match");
|
||||
using FileStream peStream = File.OpenRead(library); using var pe = new PEReader(peStream);
|
||||
using FileStream pdbStream = File.OpenRead(pdbPath); using var provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream);
|
||||
@@ -200,7 +67,7 @@ internal static class SourceNavigation
|
||||
|
||||
internal static void RestoreSources(string sdkDirectory)
|
||||
{
|
||||
using FileStream stream = File.OpenRead(Path.Combine(sdkDirectory, "lib", Program.ShellName + ".pdb"));
|
||||
using FileStream stream = File.OpenRead(Path.Combine(sdkDirectory, "lib", ShellGenerator.ShellName + ".pdb"));
|
||||
using var provider = MetadataReaderProvider.FromPortablePdbStream(stream);
|
||||
MetadataReader reader = provider.GetMetadataReader();
|
||||
foreach (DocumentHandle handle in reader.Documents)
|
||||
@@ -224,251 +91,4 @@ internal static class SourceNavigation
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
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", Program.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)])
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<!-- GameDir belongs in local Directory.Build.props or on the command line. -->
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BD2.GameSdk" Version="0.2.2-game.2.35.10" PrivateAssets="all" />
|
||||
<PackageReference Include="BD2.GameSdk" Version="0.2.3-game.2.35.10" PrivateAssets="all" />
|
||||
<!-- Adjust these references as needed. -->
|
||||
<Reference Include="Unity.TextMeshPro">
|
||||
<HintPath>$(BD2ManagedDir)/Unity.TextMeshPro.dll</HintPath>
|
||||
|
||||
@@ -15,7 +15,6 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Game.Validate(typeof(Plugin).Assembly);
|
||||
MethodInfo target = Game.Method<IntroUI>(ui => ui.SetVersionText());
|
||||
versionTextField = typeof(IntroUI).GetGameField("_textVersion", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new MissingFieldException("IntroUI._textVersion");
|
||||
|
||||
Reference in New Issue
Block a user