using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Runtime.Serialization.Json; using System.Security.Cryptography; using BD2.GameNames.Internal; namespace BD2.GameNames; /// Readable names for string-based reflection. Unknown names retain their literal spelling. public static class Game { private sealed class Index { internal readonly NameTable Table; internal readonly Dictionary Types; internal readonly Dictionary Tokens; internal Index() { using (var stream = typeof(Game).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz")) using (var gzip = new GZipStream(stream ?? throw new InvalidDataException("BD2.GameNames: embedded table missing"), CompressionMode.Decompress)) Table = (NameTable)new DataContractJsonSerializer(typeof(NameTable)).ReadObject(gzip); if (Table.schema_version != 1) throw new InvalidDataException("BD2.GameNames: unsupported table schema"); Types = Table.types.ToDictionary(t => t.readable, StringComparer.Ordinal); Tokens = Table.members.ToDictionary(m => m.token); } } private static readonly Lazy Names = new Lazy(() => new Index()); private static readonly ConcurrentDictionary TypeCache = new ConcurrentDictionary(); private static readonly ConcurrentDictionary MemberCache = new ConcurrentDictionary(); private static readonly object ValidationLock = new object(); private static string ValidatedStamp; /// Gets the game version embedded in this package. public static string GameVersion => Names.Value.Table.game_version; /// Translates a full readable type name. Unknown names retain their literal spelling. public static string TypeName(string readableFullName) { if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName)); return Names.Value.Types.TryGetValue(readableFullName.Replace('/', '+'), out var entry) ? entry.original : readableFullName; } /// Finds a readable type in Assembly-CSharp or loaded assemblies; returns null when absent. public static Type FindType(string readableFullName) { if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName)); if (TypeCache.TryGetValue(readableFullName, out var cached)) return cached; string original = TypeName(readableFullName); Type found = GameAssembly().GetType(original, false); if (found == null) foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { found = assembly.GetType(original, false); if (found != null) break; } if (found != null) TypeCache.TryAdd(readableFullName, found); return found; } private static Assembly GameAssembly() => Assembly.Load("Assembly-CSharp"); private static bool Matches(MemberInfo member, string kind, string readable) { if (member == null) throw new ArgumentNullException(nameof(member)); if (readable == null) throw new ArgumentNullException(nameof(readable)); // Metadata tokens retain declaration identity through inheritance and closed generics. // A hidden literal member must not be mistaken for a renamed member of a base class. if (member.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(member.MetadataToken, out var entry)) return entry.kind == kind && entry.readable == readable; return member.Name == readable; } /// Translate coroutine/member strings. Overloads with different runtime names require Method/MethodName(MethodInfo). public static string MemberName(Type type, string readable, GameMemberKind kind = GameMemberKind.Method) { if (type == null) throw new ArgumentNullException(nameof(type)); if (readable == null) throw new ArgumentNullException(nameof(readable)); string key = kind switch { GameMemberKind.Method => "method", GameMemberKind.Field => "field", GameMemberKind.Property => "property", GameMemberKind.Event => "event", _ => throw new ArgumentOutOfRangeException(nameof(kind)) }; const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; var reflected = kind switch { GameMemberKind.Method => type.GetMethods(all).Cast(), GameMemberKind.Field => type.GetFields(all).Cast(), GameMemberKind.Property => type.GetProperties(all).Cast(), GameMemberKind.Event => type.GetEvents(all).Cast(), _ => throw new ArgumentOutOfRangeException(nameof(kind)) }; var names = reflected.Where(m => Matches(m, key, readable)).Select(m => m.Name).Distinct().ToArray(); if (names.Length == 0) return readable; if (names.Length != 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature"); return names[0]; } /// Returns the actual CLR method name for APIs that accept strings. public static string MethodName(MethodInfo method) => method?.Name ?? throw new ArgumentNullException(nameof(method)); /// Tests a reflected method against its readable name, preserving overload identity. public static bool IsGameMethod(this MethodInfo method, string readable) => Matches(method, "method", readable); /// Translates a readable parameter name using the method declaration; unknown names remain unchanged. public static string ParameterName(MethodBase method, string readable) { if (method == null) throw new ArgumentNullException(nameof(method)); if (readable == null) throw new ArgumentNullException(nameof(readable)); if (method.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(method.MetadataToken, out var entry)) { var p = entry.parameters.SingleOrDefault(n => n.readable == readable); if (p != null) return p.original; } return readable; } /// Extracts a MethodInfo from a call expression without executing the expression. public static MethodInfo Method(Expression> expression) => Call(expression); /// Extracts a MethodInfo from a call expression without executing the expression. public static MethodInfo Method(Expression expression) => Call(expression); /// Extracts a property getter from an expression without evaluating the property. public static MethodInfo Getter(Expression> expression) => PropertyGetter(expression); /// Extracts a property getter from an expression without evaluating the property. public static MethodInfo Getter(Expression> expression) => PropertyGetter(expression); private static MethodInfo Call(LambdaExpression expression) { if (expression == null) throw new ArgumentNullException(nameof(expression)); return expression.Body is MethodCallExpression call ? call.Method : throw new ArgumentException("Expected a method call", nameof(expression)); } private static MethodInfo PropertyGetter(LambdaExpression expression) { if (expression == null) throw new ArgumentNullException(nameof(expression)); return expression.Body is MemberExpression member && member.Member is PropertyInfo property ? property.GetGetMethod(true) : throw new ArgumentException("Expected a property access", nameof(expression)); } private static T Cached(Type type, string kind, string readable, BindingFlags flags, Type[] parameters, Func resolve) where T : MemberInfo { if (readable == null) throw new ArgumentNullException(nameof(readable)); if (type == null) return null; string key = type.AssemblyQualifiedName + "|" + kind + "|" + readable + "|" + (int)flags + "|" + (parameters == null ? "*" : string.Join(";", parameters.Select(p => p.AssemblyQualifiedName))); if (MemberCache.TryGetValue(key, out var cached)) return (T)cached; T found = resolve(); if (found != null) MemberCache.TryAdd(key, found); return found; } /// Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw. public static MethodInfo GetGameMethod(this Type type, string readable, BindingFlags flags) => Cached(type, "method", readable, flags, null, () => Single(type.GetMethods(flags).Where(m => Matches(m, "method", readable)), type, readable)); /// Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw. public static MethodInfo GetGameMethod(this Type type, string readable, BindingFlags flags, Binder binder, Type[] parameters, ParameterModifier[] modifiers) { if (readable == null) throw new ArgumentNullException(nameof(readable)); if (parameters == null) throw new ArgumentNullException(nameof(parameters)); if (parameters.Any(p => p == null)) throw new ArgumentException("Parameter types cannot contain null", nameof(parameters)); if (type == null) return null; MethodInfo Resolve() { var candidates = type.GetMethods(flags).Where(m => Matches(m, "method", readable)).ToArray(); if (candidates.Length == 0) return null; return (MethodInfo)(binder ?? Type.DefaultBinder).SelectMethod(flags, candidates, parameters, modifiers); } // Custom binders/modifiers can affect resolution; do not cache those calls. if (binder != null || modifiers != null) return Resolve(); return Cached(type, "method", readable, flags, parameters, Resolve); } private static T Single(IEnumerable members, Type type, string readable) where T : MemberInfo { var candidates = members.Take(2).ToArray(); if (candidates.Length > 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature"); return candidates.FirstOrDefault(); } /// Finds a field by readable name and binding flags. Returns null when absent. public static FieldInfo GetGameField(this Type type, string readable, BindingFlags flags) => Cached(type, "field", readable, flags, null, () => Single(type.GetFields(flags).Where(m => Matches(m, "field", readable)), type, readable)); /// Finds a property by readable name and binding flags. Returns null when absent; ambiguous matches throw. public static PropertyInfo GetGameProperty(this Type type, string readable, BindingFlags flags) => Cached(type, "property", readable, flags, null, () => Single(type.GetProperties(flags).Where(m => Matches(m, "property", readable)), type, readable)); /// Finds an event by readable name and binding flags. Returns null when absent. public static EventInfo GetGameEvent(this Type type, string readable, BindingFlags flags) => Cached(type, "event", readable, flags, null, () => Single(type.GetEvents(flags).Where(m => Matches(m, "event", readable)), type, readable)); /// Validate plugin/table version, binary fingerprint and known metadata entries before installing patches. public static void Validate(Assembly plugin, Action log = null) => Validate(plugin, GameVersion, log); /// Validates only the game binary against the embedded table for plugins that do not use the compiler SDK. public static void ValidateGame(Action log = null) => ValidateCore(null, GameVersion, log); /// Validates a compiler SDK plugin, its expected game version, the table and the game binary. Throws on mismatch. public static void Validate(Assembly plugin, string expectedVersion, Action log) { if (plugin == null) throw new ArgumentNullException(nameof(plugin)); if (expectedVersion == null) throw new ArgumentNullException(nameof(expectedVersion)); ValidateCore(plugin, expectedVersion, log); } private static void ValidateCore(Assembly plugin, string expectedVersion, Action log) { try { var table = Names.Value.Table; string stamp = table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256; var metadata = plugin?.GetCustomAttributes().SingleOrDefault(a => a.Key == "BD2.GameNames"); if (expectedVersion != table.game_version || plugin != null && metadata?.Value != stamp) throw new InvalidDataException("plugin/table mismatch; rebuild the plugin and BD2.GameNames together"); lock (ValidationLock) { if (ValidatedStamp != stamp) { var assembly = GameAssembly(); if (assembly.ManifestModule.ModuleVersionId.ToString() != table.assembly_mvid) throw new InvalidDataException("Assembly-CSharp MVID mismatch"); using (var sha = SHA256.Create()) using (var stream = File.OpenRead(assembly.Location)) { string actual = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant(); if (actual != table.assembly_sha256) throw new InvalidDataException("Assembly-CSharp SHA-256 mismatch"); } const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; var app = FindType("AppManager") ?? throw new TypeLoadException("AppManager"); var intro = FindType("IntroUI") ?? throw new TypeLoadException("IntroUI"); var network = FindType("BDNetwork.NetworkManager") ?? throw new TypeLoadException("BDNetwork.NetworkManager"); if (app.GetGameProperty("IsPlatformLogin", all)?.PropertyType != typeof(bool) || intro.GetGameMethod("SendMaintenanceInfo", all, null, new[] { typeof(bool) }, null) == null || network.GetGameMethod("GetPachedGameDataPath", all, null, Type.EmptyTypes, null)?.ReturnType != typeof(string)) throw new MissingMemberException("known game-name probes failed"); ValidatedStamp = stamp; } } log?.Invoke("BD2.GameNames self-check passed: game=" + table.game_version + ", MVID=" + table.assembly_mvid); } catch (Exception ex) { log?.Invoke("BD2.GameNames SELF-CHECK FAILED: expected game=" + expectedVersion + "; " + ex.Message + "; game patches will not be installed"); throw; } } }