// Writes the list of fields RunicNPC's swap copies from Rust's scientist to ours (D232). // // node tools/managed.js carbon (once per Rust update) // dotnet run --project tools/fieldlist -- [plugin/RunicNPC.cs] // // must hold the CARBON rig's managed assemblies: Oxide's patcher makes Rust's private fields // public, so its Assembly-CSharp.dll no longer says what Rust itself serialises. // // The rule is stage 1's, which is Unity's own for a component's authored data: every instance // field from the component's type down to (not including) MonoBehaviour that is public and not // [NonSerialized], or carries [SerializeField]; never readonly, const or a delegate. Applied here to // the unmodified assembly, it picks the same fields on both frameworks. The result replaces the // block between the two marker lines in the plugin, and the plugin resolves each name at load. // // The staging drill (D308) also asks it whether Oxide's build has caught up with Rust's: // // dotnet run --project tools/fieldlist -- --oxide-behind // // Oxide ships its own patched Assembly-CSharp.dll, which replaces Rust's. Built from an older Rust, // it lacks what Rust has added since, and the server dies at boot ("The referenced script // (ItemModFoodVisual) on this Behaviour is missing!"). Oxide's patcher only ever adds, so every // type and member Rust's own assembly declares, compiler-generated ones aside, must be in Oxide's. // It prints "behind " and the first of them; 0 means caught up. Measured 2026-10-06: public Rust // and public Oxide, 0; staging Rust 25766353 and Oxide's staging build of 2026-10-05, 482. using System.Reflection; using System.Text; if (args.Length == 3 && args[0] == "--oxide-behind") return OxideBehind(args[1], args[2]); if (args.Length < 1) { Console.Error.WriteLine("usage: dotnet run --project tools/fieldlist -- [plugin/RunicNPC.cs]"); Console.Error.WriteLine(" dotnet run --project tools/fieldlist -- --oxide-behind "); return 2; } string managed = args[0]; string plugin = args.Length > 1 ? args[1] : Path.Combine("plugin", "RunicNPC.cs"); string[] dlls = Directory.GetFiles(managed, "*.dll"); var resolver = new PathAssemblyResolver(dlls); using var context = new MetadataLoadContext(resolver, "mscorlib"); Assembly rust = context.LoadFromAssemblyPath(Path.Combine(managed, "Assembly-CSharp.dll")); List Fields(string typeName) { Type type = rust.GetType(typeName, throwOnError: true)!; var names = new List(); for (Type? t = type; t != null && t.FullName != "UnityEngine.MonoBehaviour"; t = t.BaseType) { foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) { if (f.IsInitOnly || f.IsLiteral) continue; bool serialised = (f.IsPublic && (f.Attributes & FieldAttributes.NotSerialized) == 0) || f.GetCustomAttributesData().Any(a => a.AttributeType.FullName == "UnityEngine.SerializeField"); if (!serialised || IsDelegate(f.FieldType)) continue; names.Add($"{t.Name}.{f.Name}"); } } return names; } static bool IsDelegate(Type t) { for (Type? b = t; b != null; b = b.BaseType) if (b.FullName == "System.Delegate") return true; return false; } List npc = Fields("ScientistNPC"); List brain = Fields("ScientistBrain"); Guid mvid = rust.ManifestModule.ModuleVersionId; const string Open = " // "; const string Close = " // "; var block = new StringBuilder(); block.AppendLine(Open); block.AppendLine($" // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module {mvid}."); block.AppendLine(" // Do not edit by hand: regenerate after a Rust update (D232)."); Append(block, "SwapNpcFields", npc); Append(block, "SwapBrainFields", brain); block.Append(Close); static void Append(StringBuilder sb, string name, List fields) { sb.AppendLine($" private static readonly string[] {name} ="); sb.AppendLine(" {"); foreach (string f in fields) sb.AppendLine($" \"{f}\","); sb.AppendLine(" };"); } string text = File.ReadAllText(plugin); string nl = text.Contains("\r\n") ? "\r\n" : "\n"; int start = text.IndexOf(Open, StringComparison.Ordinal); int end = text.IndexOf(Close, StringComparison.Ordinal); if (start < 0 || end < start) { Console.Error.WriteLine($"{plugin}: the marker lines '{Open.Trim()}' and '{Close.Trim()}' were not found."); return 1; } string replacement = block.ToString().Replace("\r\n", "\n").Replace("\n", nl); File.WriteAllText(plugin, text[..start] + replacement + text[(end + Close.Length)..]); Console.WriteLine($"{plugin}: {npc.Count} NPC fields, {brain.Count} brain fields (Assembly-CSharp module {mvid})."); return 0; static int OxideBehind(string managed, string oxideAssembly) { HashSet Declared(string assembly) { IEnumerable others = Directory.GetFiles(managed, "*.dll").Where(f => Path.GetFileName(f) != "Assembly-CSharp.dll"); using var context = new MetadataLoadContext(new PathAssemblyResolver(others.Append(assembly)), "mscorlib"); var names = new HashSet(); foreach (Type t in context.LoadFromAssemblyPath(assembly).GetTypes()) { names.Add(t.FullName!); foreach (MemberInfo m in t.GetMembers(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) names.Add($"{t.FullName}::{m.Name}"); } // Compiler-generated names (lambdas, closures, iterators) are renumbered by every build. names.RemoveWhere(n => n.Contains('<')); return names; } List missing = Declared(Path.Combine(managed, "Assembly-CSharp.dll")).Except(Declared(oxideAssembly)).OrderBy(n => n).ToList(); Console.WriteLine($"behind {missing.Count}"); foreach (string n in missing.Take(20)) Console.WriteLine(" " + n); return 0; }