New TeslaConsole.BattleTech namespace mirroring TeslaConsole.RedPlanet's mission layer: BTMission/BTFreeForAllMission (egg serializer; RP wire framing and ordinal bitmaps reused verbatim), BTParticipant/BTPlayer/BTCamera (BT participant fields: advancedDamage, experience, vehicleValue, patch, role), and the BTConfig/BTScenario/BTMap/BTVehicle/BTWeather/BTRole catalog loaded from BattleTech\BTConfig.xml (reconstructed from the Mac Console 4.10 ini BT tree). Free For All and No Return share one mission class - both send scenario=freeforall; the mode is the role assigned to each pilot. BTGoldenEggTests (7 tests, recovered-only) diff the generated egg field-by-field against two eggs captured from the original consoles (cavern.egg, TESTARN.EGG): order-independent per-section compare with font-rendered bitmap pixel rows excluded, ordinal art byte-exact vs TESTARN.EGG, EggFileMessage framing reassembly, role de-dup, No Return role semantics, and shipped-catalog contents. Invoker gains BTEggString/ BTEggFraming reflection cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
459 lines
19 KiB
C#
459 lines
19 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Full identity of the loaded assembly (for sanity assertions).</summary>
|
|
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<string>();
|
|
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<Type> 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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dispatch a named case. Returns the method's normalized result, or
|
|
/// "EXC:<ExceptionTypeName>" when the underlying call throws — so exception
|
|
/// behavior is compared too.
|
|
/// </summary>
|
|
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, string>(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}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the launch entry with the given key, builds its LaunchData with the optional
|
|
/// resolution, and returns "DisplayName|LaunchKey|Exe|Args|WorkingDirectory|AutoRestart".
|
|
/// </summary>
|
|
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}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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".
|
|
/// </summary>
|
|
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}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deterministic black/white bitmap whose pixels depend only on (x, y, seed),
|
|
/// so both assemblies receive identical input pixels to pack.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|