using System; using System.Collections.Generic; using System.Drawing; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Xml; namespace TeslaConsole.DiffTests { /// /// Lives inside a child AppDomain whose base directory is the directory of one /// TeslaConsole.exe. It loads that exe and invokes deterministic, dependency-free /// methods by reflection, returning a normalized string so results marshal cleanly /// across the AppDomain boundary. /// /// Only methods whose code path touches nothing but the framework + the exe itself /// are exercised, so the original (which ships without its proprietary dependency /// DLLs) loads and runs them fine. /// public sealed class Invoker : MarshalByRefObject { private readonly Assembly _asm; private readonly string _probeDir; public Invoker(string exePath, string probeDir) { _probeDir = probeDir; // The original ships without its proprietary dependency DLLs; resolve any // referenced assembly out of the recovered build's output directory so both // assemblies expose the same metadata. AppDomain.CurrentDomain.AssemblyResolve += ResolveFromProbeDir; _asm = Assembly.LoadFrom(exePath); } private Assembly ResolveFromProbeDir(object sender, ResolveEventArgs args) { var name = new AssemblyName(args.Name); if (string.Equals(name.Name, "TeslaConsole", StringComparison.OrdinalIgnoreCase)) return _asm; // self-references bind to the assembly under test if (string.IsNullOrEmpty(_probeDir)) return null; foreach (var ext in new[] { ".dll", ".exe" }) { string candidate = Path.Combine(_probeDir, name.Name + ext); if (File.Exists(candidate)) return Assembly.LoadFrom(candidate); } return null; } /// Full identity of the loaded assembly (for sanity assertions). public string AssemblyFullName => _asm.FullName; // ---- Structural (public API surface) enumeration, run inside this domain ---- public string[] GetPublicTypeNames() { return SafeGetTypes() .Where(t => (t.IsPublic || t.IsNestedPublic) && !IsCompilerGenerated(t)) .Select(t => t.FullName) .Distinct() .ToArray(); } public string[] GetPublicMemberSignatures() { var set = new HashSet(); const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; foreach (var t in SafeGetTypes()) { if (!(t.IsPublic || t.IsNestedPublic) || IsCompilerGenerated(t)) continue; foreach (var m in t.GetMembers(flags)) { if (m.Name.IndexOf('<') >= 0 || IsCompilerGenerated(m)) continue; // Skip property/event accessor methods: the property/event itself is // compared via its own entry, and Roslyn vs. the original compiler mark // these accessors' [CompilerGenerated] differently. Real operators // (op_*) are kept. if (IsAccessorMethod(m)) continue; try { set.Add(t.FullName + " :: " + DescribeMember(m)); } catch { /* unresolved member metadata; skip symmetrically */ } } } return set.ToArray(); } private IEnumerable SafeGetTypes() { try { return _asm.GetTypes(); } catch (ReflectionTypeLoadException ex) { return ex.Types.Where(t => t != null); } } private static string DescribeMember(MemberInfo m) { switch (m) { case MethodInfo mi: return "M " + mi.ReturnType.FullName + " " + mi.Name + "(" + string.Join(",", mi.GetParameters().Select(p => p.ParameterType.FullName)) + ")"; case ConstructorInfo ci: return "C .ctor(" + string.Join(",", ci.GetParameters().Select(p => p.ParameterType.FullName)) + ")"; case PropertyInfo pi: return "P " + pi.PropertyType.FullName + " " + pi.Name; case FieldInfo fi: return "F " + fi.FieldType.FullName + " " + fi.Name; case EventInfo ei: return "E " + ei.EventHandlerType.FullName + " " + ei.Name; case Type nt: return "T " + nt.FullName; default: return m.MemberType + " " + m.Name; } } private static bool IsAccessorMethod(MemberInfo m) { if (!(m is MethodInfo mi) || !mi.IsSpecialName) return false; string n = mi.Name; return n.StartsWith("get_") || n.StartsWith("set_") || n.StartsWith("add_") || n.StartsWith("remove_"); } private static bool IsCompilerGenerated(MemberInfo m) { try { return m.IsDefined(typeof(CompilerGeneratedAttribute), false); } catch { return false; } } /// /// Dispatch a named case. Returns the method's normalized result, or /// "EXC:<ExceptionTypeName>" when the underlying call throws — so exception /// behavior is compared too. /// public string Run(string caseName, string[] args) { try { return Dispatch(caseName, args); } catch (TargetInvocationException tie) { return "EXC:" + (tie.InnerException ?? tie).GetType().Name; } catch (Exception ex) { return "EXC:" + ex.GetType().Name; } } private string Dispatch(string caseName, string[] args) { switch (caseName) { case "GetTimeString": return RPStringsGetTimeString(long.Parse(args[0], CultureInfo.InvariantCulture)); case "HostTypeParse": return HostTypeParse(args[0]); case "ConvertBitmap": return ConvertBitmap( int.Parse(args[0], CultureInfo.InvariantCulture), int.Parse(args[1], CultureInfo.InvariantCulture), int.Parse(args[2], CultureInfo.InvariantCulture)); case "GenerateString": return GenerateString( int.Parse(args[0], CultureInfo.InvariantCulture), int.Parse(args[1], CultureInfo.InvariantCulture), args[2], args[3]); case "RPMapParse": return ParseRedPlanetNode("TeslaConsole.RedPlanet.RPMap", args[0]); case "RPVehicleParse": return ParseRedPlanetNode("TeslaConsole.RedPlanet.RPVehicle", args[0]); case "SiteManagementGuid": return SiteManagementGuid(args[0]); case "TupleCreate": return TupleCreate(args[0], args[1]); case "CatalogSummary": return CatalogSummary(args[0]); case "CatalogEntry": return CatalogEntry(args[0], args[1], args[2], args[3]); case "BTEggString": return BTEggString(args); case "BTEggFraming": return BTEggFraming(args); default: throw new ArgumentException("Unknown case: " + caseName); } } private Type T(string fullName) { return _asm.GetType(fullName, throwOnError: true); } private string RPStringsGetTimeString(long ticks) { var m = T("TeslaConsole.RPStrings").GetMethod( "GetTimeString", BindingFlags.Public | BindingFlags.Static); return (string)m.Invoke(null, new object[] { TimeSpan.FromTicks(ticks) }); } private string HostTypeParse(string input) { var helperType = T("TeslaConsole.HostTypeHelper"); var parse = helperType.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static); object helper = parse.Invoke(null, new object[] { input }); return helper.ToString(); } private string ConvertBitmap(int width, int height, int seed) { using (var bmp = MakePatternBitmap(width, height, seed)) { var m = T("TeslaConsole.PlasmaBitmaps").GetMethod( "ConvertBitmap", BindingFlags.Public | BindingFlags.Static); return (string)m.Invoke(null, new object[] { bmp }); } } private string GenerateString(int width, int height, string font, string text) { var m = T("TeslaConsole.PlasmaBitmaps").GetMethod( "GenerateString", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(int), typeof(int), typeof(string), typeof(string) }, null); return (string)m.Invoke(null, new object[] { width, height, font, text }); } private string ParseRedPlanetNode(string typeName, string xml) { var doc = new XmlDocument(); doc.LoadXml(xml); XmlNode node = doc.DocumentElement; var type = T(typeName); var ctor = type.GetConstructor( BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(XmlNode) }, null); object instance = ctor.Invoke(new object[] { node }); string key = (string)type.GetProperty("Key").GetValue(instance, null); string name = (string)type.GetProperty("Name").GetValue(instance, null); string toStr = instance.ToString(); return key + "|" + name + "|" + toStr; } private string SiteManagementGuid(string fieldName) { var f = T("TeslaConsole.SiteManagement").GetField( fieldName, BindingFlags.Public | BindingFlags.Static); return ((Guid)f.GetValue(null)).ToString("D"); } private string TupleCreate(string a, string b) { // Tuple.Create(int.Parse(a), b) -> "A|B" var tupleType = T("TeslaConsole.Tuple"); MethodInfo create = null; foreach (var m in tupleType.GetMethods(BindingFlags.Public | BindingFlags.Static)) { if (m.Name == "Create" && m.IsGenericMethodDefinition && m.GetGenericArguments().Length == 2 && m.GetParameters().Length == 2) { create = m; break; } } var closed = create.MakeGenericMethod(typeof(int), typeof(string)); object tuple = closed.Invoke(null, new object[] { int.Parse(a, CultureInfo.InvariantCulture), b }); Type tt = tuple.GetType(); object av = tt.GetProperty("A").GetValue(tuple, null); object bv = tt.GetProperty("B").GetValue(tuple, null); return Convert.ToString(av, CultureInfo.InvariantCulture) + "|" + Convert.ToString(bv, CultureInfo.InvariantCulture); } private System.Collections.IEnumerable LoadCatalog(string path) { var reg = T("TeslaConsole.AppRegistry"); reg.GetMethod("LoadFromFile", BindingFlags.Public | BindingFlags.Static) .Invoke(null, new object[] { path }); return (System.Collections.IEnumerable)reg.GetProperty("Products").GetValue(null); } private string CatalogSummary(string path) { int products = 0, entries = 0; foreach (var product in LoadCatalog(path)) { products++; var list = (System.Collections.IEnumerable)product.GetType().GetField("Entries").GetValue(product); foreach (var _ in list) entries++; } return $"products={products};entries={entries}"; } /// /// Finds the launch entry with the given key, builds its LaunchData with the optional /// resolution, and returns "DisplayName|LaunchKey|Exe|Args|WorkingDirectory|AutoRestart". /// private string CatalogEntry(string path, string launchKey, string resW, string resH) { Guid key = Guid.Parse(launchKey); object entry = null; foreach (var product in LoadCatalog(path)) { var entries = (System.Collections.IEnumerable)product.GetType().GetField("Entries").GetValue(product); foreach (var en in entries) { if ((Guid)en.GetType().GetField("LaunchKey").GetValue(en) == key) { entry = en; break; } } if (entry != null) break; } if (entry == null) return "NOTFOUND"; object resolution = null; if (resW.Length > 0 && resH.Length > 0) resolution = new Size(int.Parse(resW, CultureInfo.InvariantCulture), int.Parse(resH, CultureInfo.InvariantCulture)); object ld = entry.GetType().GetMethod("ToLaunchData").Invoke(entry, new object[] { resolution }); Type t = ld.GetType(); object pair = t.GetField("LaunchPair").GetValue(ld); string disp = (string)pair.GetType().GetField("DisplayName").GetValue(pair); Guid lkey = (Guid)pair.GetType().GetField("LaunchKey").GetValue(pair); string exe = (string)t.GetField("ExeFile").GetValue(ld); string margs = (string)t.GetField("Arguments").GetValue(ld); string wd = (string)t.GetField("WorkingDirectory").GetValue(ld); bool ar = (bool)t.GetField("AutoRestart").GetValue(ld); return $"{disp}|{lkey:D}|{exe}|{margs}|{wd}|{ar}"; } /// /// Builds a TeslaConsole.BattleTech.BTFreeForAllMission from flat args: /// [0]=mapKey [1]=timeOfDayKey [2]=weatherKey [3]=temperature [4]=lengthSeconds, /// then one pilot per 10 args: podHost, name, vehicle, camo, patch, emblem, /// experience, dropzone, roleKey, roleModel. The BT types are internal to the /// exe, so construction goes through reflection like every other case. /// private object BuildBTMission(string[] args) { var missionType = T("TeslaConsole.BattleTech.BTFreeForAllMission"); object mission = Activator.CreateInstance(missionType, new object[] { args[0], args[1], args[2], int.Parse(args[3], CultureInfo.InvariantCulture), TimeSpan.FromSeconds(double.Parse(args[4], CultureInfo.InvariantCulture)), }); var roleType = T("TeslaConsole.BattleTech.BTRole"); var playerType = T("TeslaConsole.BattleTech.BTPlayer"); object players = missionType.GetProperty("BattlePlayers").GetValue(mission, null); MethodInfo add = players.GetType().GetMethod("Add"); for (int i = 5; i < args.Length; i += 10) { object role = Activator.CreateInstance(roleType, new object[] { args[i + 8], args[i + 9], args[i + 8] }); object player = Activator.CreateInstance(playerType, new object[] { args[i], args[i + 1], args[i + 2], args[i + 3], args[i + 4], args[i + 5], args[i + 6], role, /* advancedDamage */ true, /* dropZoneKey */ args[i + 7], /* vehicleValue */ 0, }); add.Invoke(players, new[] { player }); } return mission; } private string BTEggString(string[] args) { object mission = BuildBTMission(args); return (string)mission.GetType().GetMethod("ToEggString").Invoke(mission, null); } /// /// Exercises BTMission.ToEggFileMessages and verifies the RP-inherited wire /// framing: newlines become NULs, ASCII bytes go out in 1000-byte chunks with /// contiguous sequence numbers, and the chunks reassemble byte-exactly to the /// ToEggString content. Returns "messages=N;total=L;framing=B;reassembled=B". /// private string BTEggFraming(string[] args) { object mission = BuildBTMission(args); Type mt = mission.GetType(); string egg = (string)mt.GetMethod("ToEggString").Invoke(mission, null); byte[] expected = Encoding.ASCII.GetBytes( egg.Replace("\r\n", "\0").Replace('\n', '\0')); var messages = (Array)mt.GetMethod("ToEggFileMessages").Invoke(mission, null); if (messages.Length == 0) return "no-messages"; Type et = messages.GetValue(0).GetType(); FieldInfo seq = et.GetField("SequenceNumber"); FieldInfo total = et.GetField("NotationFileLength"); FieldInfo len = et.GetField("ThisMessageLength"); FieldInfo data = et.GetField("NotationData"); bool framing = true; var reassembled = new MemoryStream(); for (int i = 0; i < messages.Length; i++) { object m = messages.GetValue(i); int chunk = (int)len.GetValue(m); framing &= (int)seq.GetValue(m) == i; framing &= (int)total.GetValue(m) == expected.Length; framing &= chunk == (i < messages.Length - 1 ? 1000 : expected.Length - 1000 * i); reassembled.Write((byte[])data.GetValue(m), 0, chunk); } bool bytesMatch = reassembled.ToArray().SequenceEqual(expected); return $"messages={messages.Length};total={expected.Length};framing={framing};reassembled={bytesMatch}"; } /// /// Deterministic black/white bitmap whose pixels depend only on (x, y, seed), /// so both assemblies receive identical input pixels to pack. /// private static Bitmap MakePatternBitmap(int width, int height, int seed) { var bmp = new Bitmap(width, height); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { bool on = ((x * 31 + y * 17 + seed * 7) & 3) == 0; bmp.SetPixel(x, y, on ? Color.White : Color.Black); } } return bmp; } // Keep the proxy alive for the life of the AppDomain. public override object InitializeLifetimeService() => null; } }