feat: stage 2, the NPC and its API (API 2)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m38s

RunicNPC now spawns its own NPC: Rust's scientist with its ScientistNPC and
ScientistBrain swapped for ours, copying a fixed field list generated from
Rust's unmodified assembly (D232, tools/fieldlist). Profiles live in
data/RunicNPC/profiles.json in the approved shape (D238); placements persist
with each/group respawn (D236) and wait for a missing profile or route
(D237); roamers wander, follow Rust's monument paths, or walk a route (D233,
D234), with our own chase where Rust's needs an AI zone; sentries hold their
spot; NPCs walk home and sleep past 160 m of any player (D235). Spawns wait
for the navmesh and are spread over frames; caps are off by default and the
cost warning is shown instead (D227). The whole PLAN.md section 4 API and its
hooks are in, documented in docs/runicnpc/API.md.

tools/RunicNpcTest.cs is the stage 2 harness; every group passed on both
rigs, and after a plugin reload and a server restart. checkPlugin no longer
counts an override (RunicNpcPlayer.OnDied) as a hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-30 01:31:04 -05:00
parent 914047f1b0
commit f8084955e8
9 changed files with 2937 additions and 42 deletions

View File

@@ -7,8 +7,9 @@ works on its own, and on a [Runic Gateway](https://gitea.whitlocktech.com/RunicG
website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is
[`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). [`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md).
> **Status: stage 0.** The plugin loads, answers its API version, and reports on itself. It spawns > **Status: stage 2.** The NPC, its profiles, placements and routes, and the API other plugins call.
> nothing yet. Stage 1 is a measuring spike; the NPC and its API arrive in stage 2. > The in-game `/rnpc` commands arrive in stage 3; until then profiles are edited in
> `data/RunicNPC/profiles.json` (then `rnpc.reload`), and placements and routes are made through the API.
## Requirements ## Requirements
@@ -53,9 +54,10 @@ Every call is prefixed `RunicNpc_` and reached through `Call`:
int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0; int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0;
``` ```
Stage 0 has only `RunicNpc_ApiVersion()`. The full API is planned in PLAN.md §4 and will be The API is version 2, documented in
documented as `docs/runicnpc/API.md` in stage 2. The API version moves when a call or a raised hook [`docs/runicnpc/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/API.md):
changes shape, not on every release. spawning and removing NPCs by owner, profiles, placements, routes, the cost warning, and the hooks it raises.
The API version moves when a call or a raised hook changes shape, not on every release.
## Repository layout ## Repository layout
@@ -64,7 +66,22 @@ changes shape, not on every release.
| `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | | `plugin/RunicNPC.cs` | The plugin. The only file a server gets. |
| `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. | | `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. |
| `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). | | `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). |
| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`) and `RunicNpcHarness.cs`, the stage 1 measurement plugin (`docs/runicnpc/PLAN.md` §9). | | `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2 test harness (`rnt.run all`, then `rnt.after` after a reload or restart); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). |
## After a Rust update: the swap's field list
Our NPC is Rust's scientist with two components swapped, and the swap copies a fixed list of fields (D232),
generated from Rust's unmodified assembly. `rnpc.status` reports it as `swap fields: npc=64/64 brain=32/32
missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since
the list was made. To regenerate it:
```bash
node tools/managed.js carbon <dir> # the Carbon rig's RustDedicated_Data/Managed
dotnet run --project tools/fieldlist -- <dir> # rewrites the block in plugin/RunicNPC.cs
```
Use the Carbon rig: Oxide's patcher makes Rust's private fields public, so its assembly no longer says which
fields Rust itself serialises.
## Releases ## Releases

View File

@@ -22,8 +22,9 @@
# a RunicNPC too old for the bridge it is pairing with. Declaring it here is what # a RunicNPC too old for the bridge it is pairing with. Declaring it here is what
# lets a bundle check the pair BEFORE an operator installs it. # lets a bundle check the pair BEFORE an operator installs it.
# #
# Current: 1 — `RunicNpc_ApiVersion()` and nothing else (stage 0). # 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else.
api = 1 # Current: 2 — stage 2's whole API, PLAN.md §4, documented in docs/runicnpc/API.md.
api = 2
# ── Framework floors ───────────────────────────────────────────────────────── # ── Framework floors ─────────────────────────────────────────────────────────
# #

File diff suppressed because it is too large Load Diff

View File

@@ -179,7 +179,11 @@ function check(source, toml) {
} }
const methods = readMethods(source) const methods = readMethods(source)
const hooks = methods.filter((x) => HOOK_NAME.test(x.name)) // An `override` is never a hook: a hook is a plugin method the framework finds by name, and the
// plugin class overrides nothing shaped like one. What does override `On*` methods is the NPC's
// own classes (RunicNpcPlayer.OnDied overrides Rust's ScientistNPC), and Rust, not the
// framework, calls those.
const hooks = methods.filter((x) => HOOK_NAME.test(x.name) && !/\boverride\b/.test(x.returns))
const hookNames = new Set(hooks.map((x) => x.name)) const hookNames = new Set(hooks.map((x) => x.name))
// 1. Every hook the plugin implements is one `rnpc.status` can report on. // 1. Every hook the plugin implements is one `rnpc.status` can report on.

View File

@@ -126,6 +126,25 @@ test('a method that is not shaped like a hook is left alone', () => {
assert.ok(HOOK_NAME.test('CanBeTargeted')) assert.ok(HOOK_NAME.test('CanBeTargeted'))
}) })
test('an override shaped like a hook belongs to the NPC classes, not the framework, and is left alone', () => {
const methods = ` private void OnServerInitialized()
{
}
public class RunicNpcPlayer : ScientistNPC
{
public override void OnDied(HitInfo info)
{
}
protected override void OnStateChanged()
{
}
}`
assert.deepEqual(check(source({ methods }), toml()), [])
})
// ── The API ──────────────────────────────────────────────────────────────── // ── The API ────────────────────────────────────────────────────────────────
test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => { test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => {
@@ -287,12 +306,13 @@ test('the parser actually reads the real plugin, rather than quietly matching no
// Raise these floors as the plugin grows; they are what stops a regex that // Raise these floors as the plugin grows; they are what stops a regex that
// matches nothing from passing every case above. // matches nothing from passing every case above.
assert.ok(methods.length >= 5, `only found ${methods.length} methods in the real plugin`) assert.ok(methods.length >= 40, `only found ${methods.length} methods in the real plugin`)
assert.ok(names.has('OnServerInitialized'), 'OnServerInitialized was not found by the method parser') assert.ok(names.has('OnServerInitialized'), 'OnServerInitialized was not found by the method parser')
assert.ok(names.has('CmdStatus'), 'CmdStatus (under an attribute) was not found by the method parser') assert.ok(names.has('CmdStatus'), 'CmdStatus (under an attribute) was not found by the method parser')
const api = methods.filter((m) => API_NAME.test(m.name)).map((m) => m.name) const api = methods.filter((m) => API_NAME.test(m.name)).map((m) => m.name)
assert.ok(api.includes('RunicNpc_ApiVersion'), 'RunicNpc_ApiVersion was not found as an API call') assert.ok(api.includes('RunicNpc_ApiVersion'), 'RunicNpc_ApiVersion was not found as an API call')
assert.ok(api.includes('RunicNpc_Spawn'), 'RunicNpc_Spawn was not found as an API call')
assert.ok(readExpectedHooks(real).length >= 1) assert.ok(readExpectedHooks(real).length >= 1)
assert.deepEqual(readRequires(real), ['Kits']) assert.deepEqual(readRequires(real), ['Kits'])

802
tools/RunicNpcTest.cs Normal file
View File

@@ -0,0 +1,802 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
using UnityEngine.AI;
namespace Oxide.Plugins
{
/// <summary>
/// RunicNPC's stage 2 test harness (docs/runicnpc/PLAN.md §9, stage 2). NEVER SHIPPED: it lives
/// in tools/ and is loaded on a rig by hand. It drives RunicNPC only through its API and hooks,
/// as any other plugin would, and prints one line per assertion:
///
/// <code>[rnt] &lt;group&gt;.&lt;check&gt;: PASS|FAIL &lt;detail&gt;</code>
///
/// Every line is also kept in <c>data/RunicNpcTest.json</c>, read back over the panel.
///
/// <para>
/// <c>rnt.run &lt;group&gt;</c> runs one group: <c>api</c>, <c>hooks</c>, <c>move</c>,
/// <c>sentry</c>, <c>sleep</c>, <c>place</c>, or <c>all</c> for each in turn.
/// <c>rnt.run place</c> leaves two placements behind for the restart check, and
/// <c>rnt.after</c> checks, after a reload or restart, that every placement is back and nothing
/// else is. <c>rnt.spawn [n]</c> spawns plugin-owned NPCs, for the check that they die with this plugin, and
/// <c>rnt.clear</c> removes everything the harness made.
/// </para>
///
/// <para>
/// It needs the hidden Kits kit <c>rnhrevolver</c> (stage 1's: a revolver, pistol ammo and
/// burlap). It REPLACES RunicNPC's profiles with its own test set, which marks the server
/// managed: back up <c>data/RunicNPC/</c> first and restore it afterwards.
/// </para>
///
/// <para>
/// Targets and wake-ups are <b>stand-ins</b>, stage 1's fake players: <c>player.prefab</c> with
/// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage.
/// </para>
/// </summary>
[Info("RunicNpcTest", "Runic Gateway", "0.2.0")]
[Description("RunicNPC stage 2 tests. A developer tool: never ship it.")]
internal class RunicNpcTest : RustPlugin
{
[PluginReference] private Plugin RunicNPC;
private const string Kit = "rnhrevolver";
private const string Owner = "plugin:RunicNpcTest";
private const string PlayerPrefab = "assets/prefabs/player/player.prefab";
private const ulong StandInId = 11400000101UL;
private readonly List<string> _lines = new List<string>();
private readonly HashSet<BasePlayer> _standIns = new HashSet<BasePlayer>();
private readonly List<BaseEntity> _built = new List<BaseEntity>();
private Coroutine _running;
private int _pass, _fail;
// What RunicNPC's hooks told us.
private readonly List<string> _spawned = new List<string>();
private readonly List<string> _died = new List<string>();
private readonly List<string> _health = new List<string>();
private readonly List<string> _despawned = new List<string>();
private readonly Dictionary<ulong, float> _lastContributors = new Dictionary<ulong, float>();
private int _hitsOnStandIns;
private static readonly MethodInfo LifeStoryStart =
typeof(BasePlayer).GetMethod("LifeStoryStart", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
// ---- output ----
private void Check(string name, bool ok, string detail = "")
{
if (ok) _pass++; else _fail++;
string line = $"{name}: {(ok ? "PASS" : "FAIL")} {detail}";
_lines.Add(line);
Puts("[rnt] " + line);
Interface.Oxide.DataFileSystem.WriteObject(Name, _lines);
}
private void Note(string line)
{
_lines.Add("# " + line);
Puts("[rnt] # " + line);
Interface.Oxide.DataFileSystem.WriteObject(Name, _lines);
}
// ---- lifecycle ----
private void Unload()
{
if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
ClearWorld();
}
private void ClearWorld()
{
foreach (BasePlayer p in _standIns.ToList())
if (p != null && !p.IsDestroyed) p.Kill();
_standIns.Clear();
foreach (BaseEntity e in _built)
if (e != null && !e.IsDestroyed) e.Kill();
_built.Clear();
}
// ---- RunicNPC's API, typed ----
private T Api<T>(string call, params object[] args)
{
object r = RunicNPC?.Call(call, args);
return r is T ? (T)r : default(T);
}
private BasePlayer Spawn(Vector3 at, string profile, JObject overrides = null, string owner = Owner) =>
Api<BasePlayer>("RunicNpc_Spawn", at, profile, owner, overrides);
private List<Dictionary<string, object>> List(string owner) =>
Api<List<Dictionary<string, object>>>("RunicNpc_List", owner) ?? new List<Dictionary<string, object>>();
private Dictionary<string, object> Describe(BasePlayer npc) =>
npc == null ? null : List(null).FirstOrDefault(d => (ulong)d["netId"] == npc.net.ID.Value);
private static Vector3 V(object o)
{
var d = (Dictionary<string, object>)o;
return new Vector3(Convert.ToSingle(d["x"]), Convert.ToSingle(d["y"]), Convert.ToSingle(d["z"]));
}
private static float Flat(Vector3 a, Vector3 b) => new Vector2(a.x - b.x, a.z - b.z).magnitude;
// ---- places ----
private static bool SampleNavmesh(Vector3 p, float r, out NavMeshHit hit) =>
Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, r, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas);
private static MonumentInfo[] _monuments;
/// <summary>The map's monuments. (TerrainMeta.Path.Monuments is internal in Rust's own assembly.)</summary>
private static MonumentInfo[] Monuments() => _monuments ?? (_monuments = UnityEngine.Object.FindObjectsOfType<MonumentInfo>());
/// <summary>A flat, dry spot on the navmesh far from every monument; fixed seed, so reruns land in the same field.</summary>
private static bool FindField(float clear, int seed, out Vector3 at)
{
var rng = new System.Random(seed);
float half = World.Size / 2f - 300f;
for (int i = 0; i < 4000; i++)
{
var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half);
p.y = TerrainMeta.HeightMap.GetHeight(p);
if (p.y < TerrainMeta.WaterMap.GetHeight(p) + 1f || TerrainMeta.HeightMap.GetSlope(p) > 12f) continue;
if (Monuments().Any(m => Vector3.Distance(m.transform.position, p) < clear)) continue;
NavMeshHit hit;
if (!SampleNavmesh(p, 2f, out hit)) continue;
// The whole 35 m around it must be walkable too, for the wander and route tests.
bool open = true;
for (int k = 0; k < 8 && open; k++)
{
float a = k * Mathf.PI / 4f;
NavMeshHit h2;
open = SampleNavmesh(hit.position + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * 35f, 2f, out h2);
}
if (!open) continue;
at = hit.position;
return true;
}
at = Vector3.zero;
return false;
}
/// <summary>A monument with an AI zone, where Rust's own roam has move points to follow.</summary>
private static bool FindMonument(out Vector3 at, out string name)
{
foreach (MonumentInfo m in Monuments().OrderBy(m => m.name.Contains("military") ? 0 : 1))
{
if (m == null) continue;
AIInformationZone zone = AIInformationZone.GetForPoint(m.transform.position, false);
if (zone == null) continue;
NavMeshHit hit;
if (!SampleNavmesh(m.transform.position, 20f, out hit)) continue;
at = hit.position;
name = System.IO.Path.GetFileNameWithoutExtension(m.name);
return true;
}
at = Vector3.zero;
name = null;
return false;
}
private BasePlayer StandIn(Vector3 at, int index)
{
BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, at) as BasePlayer;
if (p == null) return null;
ulong id = StandInId + (ulong)index;
p.userID = id;
p.UserIDString = id.ToString();
p.displayName = "RntStandIn" + index;
p.enableSaving = false;
p.Spawn();
p.EndSleeping();
LifeStoryStart?.Invoke(p, null);
_standIns.Add(p);
return p;
}
private void RemoveStandIns()
{
foreach (BasePlayer p in _standIns.ToList())
if (p != null && !p.IsDestroyed) p.Kill();
_standIns.Clear();
}
// ---- hooks: RunicNPC's, counted; and the stand-ins made invulnerable ----
private void OnRunicNpcSpawned(BasePlayer npc, string profile, string owner) => _spawned.Add($"{profile}/{owner}");
private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)
{
_died.Add($"{profile}/{owner}");
_lastContributors.Clear();
foreach (KeyValuePair<ulong, float> kv in contributors) _lastContributors[kv.Key] = kv.Value;
}
private void OnRunicNpcHealth(BasePlayer npc, string profile, float fraction) => _health.Add($"{profile}@{fraction}");
private void OnRunicNpcDespawned(BasePlayer npc, string owner) => _despawned.Add(owner);
private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info)
{
BasePlayer p = victim as BasePlayer;
if (p == null || !_standIns.Contains(p) || info == null) return;
if (info.Initiator is BasePlayer && ((BasePlayer)info.Initiator).IsNpc) _hitsOnStandIns++;
info.damageTypes.ScaleAll(0f);
}
// ---- the profiles and routes the tests use ----
private static JObject Prof(string movement = "wander", float radius = 15f, string role = "roamer", string kit = Kit, string name = "TRoam") =>
JObject.FromObject(new
{
names = new[] { name },
kits = new[] { kit },
prefab = "scientistnpc_roam",
role,
movement = new { mode = movement, radius },
health = 250f,
damageDealt = 1f,
damageTaken = new { head = 2f, body = 0.5f, legs = 1f },
aimCone = 2f,
ranges = new { sense = 30f, loseTarget = 40f, chase = 40f, attack = 30f },
visionCone = -0.8f,
sleepDistance = 160f,
healthThresholds = new[] { 0.5f }
});
private Dictionary<string, string> SetTestProfiles(bool withEach = true)
{
var all = new JObject
{
["t_roam"] = Prof(),
["t_sentry"] = Prof(role: "sentry", name: "TSentry"),
["t_mon"] = Prof("monument", name: "TMon"),
["t_route"] = Prof("route:t_loop", name: "TRoute"),
["t_bad"] = Prof(kit: "rnt_nokit", name: "TBad")
};
JObject leash = Prof(radius: 4f, name: "TLeash");
leash["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 10f, attack = 4f });
all["t_leash"] = leash;
if (withEach)
all["t_each"] = Prof(name: "TEach");
return Api<Dictionary<string, string>>("RunicNpc_SetProfiles", all) ?? new Dictionary<string, string> { ["?"] = "no answer" };
}
private string SetLoop(Vector3 centre)
{
var points = new JArray();
foreach (Vector3 o in new[] { new Vector3(12, 0, 12), new Vector3(-12, 0, 12), new Vector3(-12, 0, -12), new Vector3(12, 0, -12) })
{
NavMeshHit hit;
SampleNavmesh(centre + o, 3f, out hit);
points.Add(JObject.FromObject(new { x = hit.position.x, y = hit.position.y, z = hit.position.z }));
}
return Api<string>("RunicNpc_SetRoute", "t_loop", JObject.FromObject(new { points, loop = true }));
}
// ---- commands ----
[ConsoleCommand("rnt.run")]
private void CmdRun(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
string group = arg.GetString(0, "all");
if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
_running = ServerMgr.Instance.StartCoroutine(Run(group));
arg.ReplyWith($"rnt: running {group}");
}
[ConsoleCommand("rnt.after")]
private void CmdAfter(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
After();
}
/// <summary>Spawns n plugin-owned NPCs, for the check that a plugin's NPCs die with it.</summary>
[ConsoleCommand("rnt.spawn")]
private void CmdSpawn(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
Vector3 field;
if (!FindField(300f, 2024, out field)) return;
int n = 0;
for (int i = 0; i < arg.GetInt(0, 3); i++)
if (Spawn(field + new Vector3(i * 2f, 0f, -30f), "t_roam") != null) n++;
arg.ReplyWith($"rnt: spawned {n} as {Owner}");
}
[ConsoleCommand("rnt.clear")]
private void CmdClear(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
ClearWorld();
Api<int>("RunicNpc_DespawnOwner", Owner);
foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2" })
Api<bool>("RunicNpc_RemovePlacement", id);
Api<bool>("RunicNpc_RemoveRoute", "t_loop");
arg.ReplyWith("rnt: cleared");
}
private IEnumerator Run(string group)
{
_pass = _fail = 0;
Note($"run {group} on {(AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}");
Vector3 field;
if (!FindField(300f, 2024, out field))
{
Check("setup.field", false, "no open field on the navmesh");
yield break;
}
Dictionary<string, string> refused = SetTestProfiles();
Check("setup.profiles", refused.Count == 1 && refused.ContainsKey("t_bad"), "refused: " + string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()));
string route = SetLoop(field + new Vector3(60f, 0f, 0f));
Check("setup.route", route == null, route ?? "t_loop set");
string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "place" } : new[] { group };
foreach (string g in groups)
{
IEnumerator inner = g == "api" ? Api(field)
: g == "hooks" ? Hooks(field)
: g == "move" ? Move(field)
: g == "sentry" ? Sentry(field)
: g == "sleep" ? Sleep(field)
: g == "place" ? Place(field)
: null;
if (inner == null)
{
Check("setup.group", false, "no group " + g);
continue;
}
while (inner.MoveNext()) yield return inner.Current;
Api<int>("RunicNpc_DespawnOwner", Owner);
RemoveStandIns();
yield return new WaitForSeconds(1f);
}
Note($"done {group}: {_pass} pass, {_fail} fail");
_running = null;
}
// ---- api: every call's answer ----
private IEnumerator Api(Vector3 field)
{
Check("api.version", Api<int>("RunicNpc_ApiVersion") == 2, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
JObject profiles = Api<JObject>("RunicNpc_Profiles");
Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]);
Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]);
string warning = Api<string>("RunicNpc_CostWarning", 10);
Check("api.costWarning", warning != null && warning.Contains("NPC(s)"), warning);
BasePlayer npc = Spawn(field, "t_roam");
Check("api.spawn", npc != null, npc != null ? npc.GetType().Name : "null");
if (npc == null) yield break;
yield return new WaitForSeconds(2f);
Check("api.isRunicNpc", Api<bool>("RunicNpc_IsRunicNpc", npc), "");
Check("api.profileOf", Api<string>("RunicNpc_ProfileOf", npc) == "t_roam", Api<string>("RunicNpc_ProfileOf", npc));
Check("api.name", npc.displayName == "TRoam", npc.displayName);
Check("api.health", Mathf.Approximately(npc.health, 250f) && Mathf.Approximately(npc.MaxHealth(), 250f), $"{npc.health}/{npc.MaxHealth()}");
Check("api.notSaved", !npc.enableSaving, "enableSaving=" + npc.enableSaving);
Check("api.isNpc", npc.IsNpc, "IsNpc=" + npc.IsNpc);
HeldEntity held = npc.GetHeldEntity();
Check("api.kit", held != null && held.ShortPrefabName.Contains("revolver"), held != null ? held.ShortPrefabName : "nothing held");
Dictionary<string, object> d = Describe(npc);
Check("api.list", d != null && (string)d["owner"] == Owner && (string)d["movement"] == "wander", d != null ? $"{d["owner"]} {d["movement"]} {d["state"]}" : "not listed");
BasePlayer ov = Spawn(field + new Vector3(4, 0, 0), "t_roam", JObject.FromObject(new { health = 500f, names = new[] { "Ov" } }));
yield return new WaitForSeconds(0.5f);
Check("api.overrides", ov != null && ov.displayName == "Ov" && Mathf.Approximately(ov.MaxHealth(), 500f), ov != null ? $"{ov.displayName} {ov.MaxHealth()}" : "null");
Check("api.refuse.placementOwner", Spawn(field, "t_roam", null, "placement:x") == null, "");
Check("api.refuse.badOwner", Spawn(field, "t_roam", null, "nobody") == null, "");
Check("api.refuse.unknownProfile", Spawn(field, "nope") == null, "");
Check("api.refuse.refusedProfile", Spawn(field, "t_bad") == null, "");
Check("api.refuse.badOverride", Spawn(field, "t_roam", JObject.FromObject(new { health = -5f })) == null, "");
Check("api.refuse.offMesh", Spawn(field + Vector3.up * 40f, "t_roam") == null, "40 m in the air");
int listed = List(Owner).Count;
Check("api.listOwner", listed == 2, "listed " + listed);
Check("api.despawn", Api<int>("RunicNpc_Despawn", ov.net.ID.Value) == 1, "");
Check("api.despawnOwner", Api<int>("RunicNpc_DespawnOwner", Owner) == 1, "");
Check("api.gone", List(Owner).Count == 0, "listed " + List(Owner).Count);
}
// ---- hooks: spawned, health threshold with contributors, damage by body part, died, despawned ----
private IEnumerator Hooks(Vector3 field)
{
_spawned.Clear(); _died.Clear(); _health.Clear(); _despawned.Clear();
BasePlayer shooter = StandIn(field + new Vector3(0, 0, 60), 0);
BasePlayer npc = Spawn(field, "t_roam");
yield return new WaitForSeconds(1f);
Check("hooks.spawned", _spawned.Contains("t_roam/" + Owner), string.Join(",", _spawned.ToArray()));
// 100 bullet damage with no bone: body, scaled 0.5 → 50 (then Rust's own protection).
float before = npc.health;
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 100f));
float body = before - npc.health;
Check("hooks.damageTaken.body", body > 5f && body <= 50.5f, $"took {body:0.0} of 100 (body scale 0.5)");
// To below half: the 0.5 threshold fires, once.
while (npc.health / npc.MaxHealth() > 0.45f)
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 60f));
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 1f));
Check("hooks.health", _health.Count == 1 && _health[0] == "t_roam@0.5", string.Join(",", _health.ToArray()));
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f));
yield return new WaitForSeconds(1f);
Check("hooks.died", _died.Contains("t_roam/" + Owner), string.Join(",", _died.ToArray()));
Check("hooks.contributors", _lastContributors.ContainsKey(StandInId) && _lastContributors[StandInId] > 240f && _lastContributors[StandInId] <= 250.5f,
string.Join(",", _lastContributors.Select(kv => kv.Key + "=" + kv.Value.ToString("0")).ToArray()));
Check("hooks.diedNotDespawned", _despawned.Count == 0, "despawned: " + string.Join(",", _despawned.ToArray()));
Check("hooks.unregisteredOnDeath", List(Owner).Count == 0, "listed " + List(Owner).Count);
BasePlayer second = Spawn(field, "t_roam");
yield return new WaitForSeconds(0.5f);
Api<int>("RunicNpc_Despawn", second.net.ID.Value);
Check("hooks.despawned", _despawned.Count == 1 && _despawned[0] == Owner, string.Join(",", _despawned.ToArray()));
}
// ---- move: wander stays within its radius, a route visits every point, a monument roams, a leash holds ----
private IEnumerator Move(Vector3 field)
{
// A stand-in 100 m off keeps them awake (inside 160 m) without being sensed (30 m).
StandIn(field + new Vector3(0, 0, 100), 1);
BasePlayer wander = Spawn(field, "t_roam");
BasePlayer route = Spawn(field + new Vector3(60, 0, 0), "t_route");
Vector3 monument;
string monumentName;
BasePlayer mon = null;
if (FindMonument(out monument, out monumentName))
{
StandIn(monument + new Vector3(0, 0, 100), 2);
mon = Spawn(monument, "t_mon");
}
Vector3 home = wander.transform.position;
Vector3 monHome = mon != null ? mon.transform.position : Vector3.zero;
float wanderMax = 0f, wanderMoved = 0f, monMax = 0f;
Route loop = JObject.FromObject(Api<JObject>("RunicNpc_Routes")["t_loop"]).ToObject<Route>();
var reached = new bool[loop.points.Count];
var order = new List<int>();
for (int t = 0; t < 90; t++)
{
yield return new WaitForSeconds(1f);
if (wander != null && !wander.IsDestroyed)
{
float d = Flat(wander.transform.position, home);
wanderMax = Mathf.Max(wanderMax, d);
wanderMoved = Mathf.Max(wanderMoved, d);
}
if (route != null && !route.IsDestroyed)
for (int i = 0; i < loop.points.Count; i++)
if (Flat(route.transform.position, loop.points[i].V) < 2.5f)
{
reached[i] = true;
if (order.Count == 0 || order[order.Count - 1] != i) order.Add(i);
}
if (mon != null && !mon.IsDestroyed)
monMax = Mathf.Max(monMax, Flat(mon.transform.position, monHome));
}
Check("move.wander.moves", wanderMoved > 2f, $"furthest {wanderMoved:0.0} m from its spot");
Check("move.wander.radius", wanderMax <= 15f + 4f, $"furthest {wanderMax:0.0} m, radius 15");
Check("move.route.everyPoint", reached.All(x => x), "reached " + string.Join("", reached.Select(x => x ? "1" : "0").ToArray()) + " order " + string.Join(">", order.Select(x => x.ToString()).ToArray()));
bool inOrder = true;
for (int i = 1; i < order.Count; i++)
if (order[i] != (order[i - 1] + 1) % loop.points.Count) inOrder = false;
Check("move.route.inOrder", order.Count >= 3 && inOrder, string.Join(">", order.Select(x => x.ToString()).ToArray()));
if (mon != null)
Check("move.monument.roams", monMax > 5f, $"{monumentName}: furthest {monMax:0.0} m (Rust's own roam)");
else
Check("move.monument.roams", false, "no monument with an AI zone");
Api<int>("RunicNpc_DespawnOwner", Owner);
RemoveStandIns();
// The leash: chase 10 m from home. A stand-in 25 m off draws it out; past 10 m it must turn back.
BasePlayer leash = Spawn(field, "t_leash");
yield return new WaitForSeconds(1f);
Vector3 leashHome = leash.transform.position;
StandIn(leashHome + new Vector3(25, 0, 0), 3);
float leashMax = 0f;
for (int t = 0; t < 40; t++)
{
yield return new WaitForSeconds(1f);
if (leash == null || leash.IsDestroyed) break;
leashMax = Mathf.Max(leashMax, Flat(leash.transform.position, leashHome));
}
Dictionary<string, object> d2 = Describe(leash);
int leashes = d2 != null ? Convert.ToInt32(d2["leashes"]) : -1;
Check("move.chase.pursues", leashMax >= 6f, $"went {leashMax:0.0} m toward a target 25 m off (attack 4, chase 10)");
Check("move.chase.bounded", leashMax <= 10f + 4f, $"furthest {leashMax:0.0} m, chase 10");
Check("move.leash.givesUp", leashes >= 1, $"gave up {leashes} time(s) on a target it could not reach");
}
// ---- sentry: holds its spot, shoots, and may stand off the navmesh ----
private IEnumerator Sentry(Vector3 field)
{
BasePlayer sentry = Spawn(field, "t_sentry");
yield return new WaitForSeconds(1f);
Vector3 spot = sentry.transform.position;
_hitsOnStandIns = 0;
StandIn(spot + new Vector3(12, 0, 0), 4);
float moved = 0f;
for (int t = 0; t < 40; t++)
{
yield return new WaitForSeconds(1f);
moved = Mathf.Max(moved, Flat(sentry.transform.position, spot));
}
Check("sentry.holds", moved < 1f, $"moved {moved:0.00} m in 40 s with a target 12 m off");
Check("sentry.shoots", _hitsOnStandIns > 0, $"{_hitsOnStandIns} hit(s) on the stand-in");
Dictionary<string, object> d = Describe(sentry);
Note("sentry state: " + (d != null ? d["state"] : "?"));
Api<int>("RunicNpc_DespawnOwner", Owner);
RemoveStandIns();
// A player-built floor 4 m up, then a spot truly off the navmesh.
BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", field + new Vector3(0, 4f, 20f));
if (floor == null)
{
Check("sentry.offMesh.floor", false, "the floor prefab made nothing");
yield break;
}
try
{
floor.enableSaving = false;
// An unsupported floor collapses at once under Rust's stability; grounded, it stays up.
StabilityEntity stable = floor as StabilityEntity;
if (stable != null) stable.grounded = true;
floor.Spawn();
}
catch (Exception e)
{
Check("sentry.offMesh.floor", false, e.ToString());
yield break;
}
_built.Add(floor);
// Stage 1 read a new floor as off the navmesh, sampling in the frame it spawned. How long
// does Rust's navmesh take to cover it?
Vector3 top = floor.transform.position + Vector3.up * 0.1f;
float spawnedAt = Time.realtimeSinceStartup;
bool covered = false;
int frames = 0;
while (!covered && Time.realtimeSinceStartup - spawnedAt < 10f)
{
NavMeshHit onTop;
covered = SampleNavmesh(top, 1f, out onTop) && Mathf.Abs(onTop.position.y - top.y) < 0.6f;
if (!covered)
{
frames++;
yield return null;
}
}
if (floor == null || floor.IsDestroyed)
{
Check("sentry.floor", false, "the floor did not stay up");
yield break;
}
Check("sentry.floor.meshCovers", covered, covered
? $"the navmesh covered a new floor 4 m up after {Time.realtimeSinceStartup - spawnedAt:0.00} s ({frames} frames)"
: "not covered within 10 s");
BasePlayer roamer = Spawn(top, "t_roam");
Check("sentry.floor.roamerStandsOnIt", !covered || (roamer != null && Mathf.Abs(roamer.transform.position.y - top.y) < 0.6f),
roamer != null ? $"a roamer placed at y={roamer.transform.position.y:0.00}, floor {top.y:0.00}" : "refused");
if (roamer != null) Api<int>("RunicNpc_Despawn", roamer.net.ID.Value);
// Truly off the navmesh: 40 m up. A roamer is refused; a sentry is placed and stays.
Vector3 air = field + new Vector3(0f, 40f, -20f);
Check("sentry.offMesh.roamerRefused", Spawn(air, "t_roam") == null, "40 m up");
BasePlayer up = Spawn(air, "t_sentry");
Check("sentry.offMesh.sentryPlaced", up != null, "");
if (up != null)
{
StandIn(field + new Vector3(0, 0, 100), 5);
yield return new WaitForSeconds(20f);
bool there = up != null && !up.IsDestroyed && Vector3.Distance(up.transform.position, air) < 1.5f;
Check("sentry.offMesh.stays", there, up != null && !up.IsDestroyed ? $"{Vector3.Distance(up.transform.position, air):0.00} m from its spot after 20 s" : "gone");
}
}
// ---- sleep: walk home past 160 m of any player, then sleep; wake when one comes back ----
private IEnumerator Sleep(Vector3 field)
{
BasePlayer keeper = StandIn(field + new Vector3(0, 0, 100), 6);
BasePlayer npc = Spawn(field, "t_roam", JObject.FromObject(new { movement = new { mode = "wander", radius = 30f } }));
yield return new WaitForSeconds(1f);
Vector3 home = npc.transform.position;
// Let it wander off its spot.
float away = 0f;
for (int t = 0; t < 60 && away < 8f; t++)
{
yield return new WaitForSeconds(1f);
away = Flat(npc.transform.position, home);
}
Check("sleep.awayFirst", away >= 3f, $"{away:0.0} m from home before the players leave");
keeper.Kill();
_standIns.Remove(keeper);
string rest = "";
bool sawGoingHome = false;
float maxStep = 0f;
Vector3 last = npc.transform.position;
for (int t = 0; t < 90; t++)
{
yield return new WaitForSeconds(1f);
maxStep = Mathf.Max(maxStep, Flat(npc.transform.position, last));
last = npc.transform.position;
rest = (string)Describe(npc)["rest"];
if (rest == "GoingHome") sawGoingHome = true;
if (rest == "Asleep") break;
}
float fromHome = Flat(npc.transform.position, home);
Check("sleep.walksHome", sawGoingHome || away < 3f, "saw GoingHome=" + sawGoingHome);
Check("sleep.asleep", rest == "Asleep", "rest=" + rest);
Check("sleep.atHome", fromHome <= 3f, $"{fromHome:0.0} m from home");
Check("sleep.walkedNotTeleported", maxStep < 8f, $"largest step {maxStep:0.0} m per second");
// Still for 10 s asleep.
Vector3 sleptAt = npc.transform.position;
yield return new WaitForSeconds(10f);
Check("sleep.still", Flat(npc.transform.position, sleptAt) < 0.5f, "");
StandIn(field + new Vector3(0, 0, 100), 7);
yield return new WaitForSeconds(4f);
rest = (string)Describe(npc)["rest"];
Check("sleep.wakes", rest == "Awake", "rest=" + rest);
}
// ---- place: each and group respawn, a missing profile waits, a missing route waits ----
private IEnumerator Place(Vector3 field)
{
Vector3 a = field + new Vector3(-40, 0, 0);
Vector3 b = field + new Vector3(-40, 0, 30);
StandIn(field + new Vector3(-40, 0, 110), 8);
Check("place.set.each", Api<string>("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each")) == null, "");
Check("place.set.group", Api<string>("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group")) == null, "");
yield return new WaitForSeconds(4f);
List<Dictionary<string, object>> each = List("placement:t_each");
List<Dictionary<string, object>> group = List("placement:t_group");
Check("place.spawned", each.Count == 3 && group.Count == 3, $"each {each.Count}, group {group.Count}");
// each: kill one; the other two stay, and it alone returns after ~20 s.
var survivors = new HashSet<ulong>(each.Skip(1).Select(x => (ulong)x["netId"]));
Kill((ulong)each[0]["netId"]);
yield return new WaitForSeconds(2f);
Check("place.each.down", List("placement:t_each").Count == 2, "" + List("placement:t_each").Count);
yield return new WaitForSeconds(22f);
List<Dictionary<string, object>> after = List("placement:t_each");
Check("place.each.back", after.Count == 3 && survivors.All(id => after.Any(x => (ulong)x["netId"] == id)), $"{after.Count} alive, survivors kept={survivors.All(id => after.Any(x => (ulong)x["netId"] == id))}");
// group: kill one; nothing returns while two live. Kill the rest; all three return together.
Kill((ulong)group[0]["netId"]);
yield return new WaitForSeconds(24f);
Check("place.group.waitsForAll", List("placement:t_group").Count == 2, "" + List("placement:t_group").Count);
foreach (Dictionary<string, object> x in List("placement:t_group")) Kill((ulong)x["netId"]);
yield return new WaitForSeconds(10f);
Check("place.group.notEarly", List("placement:t_group").Count == 0, "" + List("placement:t_group").Count);
yield return new WaitForSeconds(14f);
Check("place.group.back", List("placement:t_group").Count == 3, "" + List("placement:t_group").Count);
// D237: remove t_each's profile. Its NPCs despawn and the placement waits; restore it and they return.
SetTestProfiles(withEach: false);
yield return new WaitForSeconds(2f);
Dictionary<string, object> row = Row("t_each");
Check("place.missingProfile.waits", List("placement:t_each").Count == 0 && row != null && row["waiting"] != null, row != null ? "" + row["waiting"] : "no row");
SetTestProfiles();
yield return new WaitForSeconds(4f);
Check("place.missingProfile.returns", List("placement:t_each").Count == 3, "" + List("placement:t_each").Count);
JObject waitRoute = Placement("t_roam", a, 1, 20f, "each");
waitRoute["movement"] = JObject.FromObject(new { mode = "route:t_nope", radius = 0f });
Check("place.set.routeOverride", Api<string>("RunicNpc_SetPlacement", "t_waitroute", waitRoute) == null, "");
yield return new WaitForSeconds(2f);
row = Row("t_waitroute");
Check("place.missingRoute.waits", row != null && ("" + row["waiting"]).Contains("route"), row != null ? "" + row["waiting"] : "no row");
Api<bool>("RunicNpc_RemovePlacement", "t_waitroute");
Check("place.remove", Api<bool>("RunicNpc_RemovePlacement", "t_group") && List("placement:t_group").Count == 0, "");
Check("place.refuse.badRespawnMode", Api<string>("RunicNpc_SetPlacement", "t_x", Placement("t_roam", a, 1, 20f, "sometimes")) != null, "");
// Leave two for rnt.after, across a reload or a restart.
Api<bool>("RunicNpc_RemovePlacement", "t_each");
Api<string>("RunicNpc_SetPlacement", "t_keep1", Placement("t_roam", a, 2, 30f, "each"));
Api<string>("RunicNpc_SetPlacement", "t_keep2", Placement("t_sentry", b, 1, 30f, "each"));
Note("left placements t_keep1 (2) and t_keep2 (1) for rnt.after");
}
private static JObject Placement(string profile, Vector3 at, int count, float respawn, string mode) =>
JObject.FromObject(new { profile, position = new { x = at.x, y = at.y, z = at.z }, yaw = 0f, count, respawn, respawnMode = mode });
private Dictionary<string, object> Row(string id) =>
(Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).FirstOrDefault(r => (string)r["id"] == id);
private static void Kill(ulong netId)
{
BasePlayer p = BaseNetworkable.serverEntities.Find(new NetworkableId(netId)) as BasePlayer;
if (p != null && !p.IsDead()) p.Hurt(new HitInfo(null, p, Rust.DamageType.Bullet, 10000f));
}
/// <summary>
/// After a reload or restart: every placement is back to its count, every RunicNPC entity in
/// the world is one RunicNPC lists, and no plain scientist stands at a placement's spot (the
/// saved-subclass symptom stage 1 found).
/// </summary>
private void After()
{
_pass = _fail = 0;
List<Dictionary<string, object>> rows = Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>();
foreach (Dictionary<string, object> r in rows)
{
JObject p = (JObject)r["placement"];
Check($"after.placement.{r["id"]}", Convert.ToInt32(r["alive"]) == (int)p["count"], $"{r["alive"]}/{p["count"]} alive, waiting={r["waiting"] ?? "-"}");
}
var listed = new HashSet<ulong>(List(null).Select(d => (ulong)d["netId"]));
List<BasePlayer> world = BaseNetworkable.serverEntities.OfType<BasePlayer>().Where(p => p.GetType().Name == "RunicNpcPlayer").ToList();
Check("after.worldMatchesRegistry", world.Count == listed.Count && world.All(p => listed.Contains(p.net.ID.Value)), $"world {world.Count}, listed {listed.Count}");
int strays = 0;
foreach (Dictionary<string, object> r in rows)
{
Vector3 at = V(((JObject)r["placement"])["position"].ToObject<Dictionary<string, object>>());
strays += BaseNetworkable.serverEntities.OfType<ScientistNPC>().Count(s => s.GetType() == typeof(ScientistNPC) && Vector3.Distance(s.transform.position, at) < 25f);
}
Check("after.noSavedScientists", strays == 0, strays + " plain scientist(s) at placement spots");
Check("after.pluginOwnedGone", List(Owner).Count == 0, List(Owner).Count + " left for " + Owner);
Note($"after: {_pass} pass, {_fail} fail");
}
/// <summary>A route, as RunicNpc_Routes returns it.</summary>
private sealed class Route
{
public List<P> points = new List<P>();
public bool loop;
}
private sealed class P
{
public float x, y, z;
public Vector3 V => new Vector3(x, y, z);
}
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Generates the swap's field list (docs/runicnpc/PLAN.md D232) from Rust's unmodified
Assembly-CSharp.dll. A developer tool: never shipped, not built by CI. See Program.cs.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,98 @@
// Writes the list of fields RunicNPC's swap copies from Rust's scientist to ours (D232).
//
// node tools/managed.js carbon <dir> (once per Rust update)
// dotnet run --project tools/fieldlist -- <dir> [plugin/RunicNPC.cs]
//
// <dir> 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.
using System.Reflection;
using System.Text;
if (args.Length < 1)
{
Console.Error.WriteLine("usage: dotnet run --project tools/fieldlist -- <managed dir> [plugin/RunicNPC.cs]");
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<string> Fields(string typeName)
{
Type type = rust.GetType(typeName, throwOnError: true)!;
var names = new List<string>();
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<string> npc = Fields("ScientistNPC");
List<string> brain = Fields("ScientistBrain");
Guid mvid = rust.ManifestModule.ModuleVersionId;
const string Open = " // <fieldlist>";
const string Close = " // </fieldlist>";
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<string> 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;

49
tools/managed.js Normal file
View File

@@ -0,0 +1,49 @@
// Downloads a rig's managed assemblies (RustDedicated_Data/Managed/*.dll) into a local directory,
// for tools/fieldlist, which reads Rust's own field declarations from them (D232).
//
// node tools/managed.js <rig> <out dir>
//
// Use the CARBON rig. Oxide's patcher rewrites Assembly-CSharp.dll and makes private fields public,
// so the Oxide rig's copy no longer says which fields Rust itself serialises. Carbon leaves the
// assembly as Facepunch shipped it.
const fs = require('fs')
const path = require('path')
const { load, serverId, unmsys } = require('./panel')
const config = load()
const [rig, out] = process.argv.slice(2).map(unmsys)
if (!rig || !out) {
console.error('usage: node tools/managed.js <rig> <out dir>')
process.exit(2)
}
const base = `${config.panel}/api/client/servers/${serverId(config, rig)}`
const headers = { Authorization: 'Bearer ' + config.key, Accept: 'application/json' }
const dir = '/RustDedicated_Data/Managed'
async function main() {
const list = await fetch(`${base}/files/list?directory=${encodeURIComponent(dir)}`, { headers })
if (!list.ok) throw new Error(`list ${list.status}: ${await list.text()}`)
const files = (await list.json()).data
.map((f) => f.attributes)
.filter((f) => f.is_file && f.name.endsWith('.dll'))
fs.mkdirSync(out, { recursive: true })
let bytes = 0
for (const f of files) {
const target = path.join(out, f.name)
if (fs.existsSync(target) && fs.statSync(target).size === f.size) continue
const link = await fetch(`${base}/files/download?file=${encodeURIComponent(`${dir}/${f.name}`)}`, { headers })
if (!link.ok) throw new Error(`${f.name}: ${link.status} ${await link.text()}`)
const body = await fetch((await link.json()).attributes.url)
fs.writeFileSync(target, Buffer.from(await body.arrayBuffer()))
bytes += f.size
}
console.log(`${files.length} assemblies in ${out} (${(bytes / 1e6).toFixed(1)} MB downloaded)`)
}
main().catch((e) => {
console.error(e.message || e)
process.exit(1)
})