docs, test: the update surface in the README, and rnt.cost (stage 9, D306)

- README: status is stage 9; the installer and egg install it now; API 6; the
  swap reports 55/55 npc fields; COMMANDS.md and INSTALL.md are linked; a new
  "update surface" table lists every Rust type and member the swap and the
  brain depend on, where, and what catches a change (compile, the field list,
  or only a running server). The staging drill checks exactly that list.
- tools/RunicNpcTest.cs 0.7.0: rnt.cost measures 100 of RunicNPC's own NPCs
  against the same session's empty server, idle (awake) and fighting 20
  invulnerable stand-ins, with a second empty baseline at the end and the bar
  taken against the lower. `idle` skips the fight, `sentry` makes them stand
  still, `asleep` leaves out the keeper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-06 17:33:28 -05:00
parent d5c9f04f2f
commit 6a384d0a28
2 changed files with 211 additions and 14 deletions

View File

@@ -7,9 +7,11 @@ 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 4.** The NPC, its profiles, placements and routes, the API other plugins call, and > **Status: stage 9, hardening for v1.0.0.** The NPC, its profiles, placements and routes; factions,
> the `/rnpc` commands admins use in game. On a Runic Gateway server, the website takes over the > fights, escorts and tethers; bosses and passive NPCs; loot. Admins use the `/rnpc` commands, other
> profiles and lists, edits and creates placements through the bridge (API 3). > plugins the API (version 6). On a Runic Gateway server RunicNPC is **required** (D310): the website
> authors its profiles and faction table, edits its placements, and every NPC an event places is
> RunicNPC's.
## Requirements ## Requirements
@@ -22,8 +24,8 @@ website authors its NPC profiles and events use its NPCs. The plan of record, st
## Installing it ## Installing it
On a Runic Gateway server, the [installer](https://gitea.whitlocktech.com/RunicGateway/installer) On a Runic Gateway server, the [installer](https://gitea.whitlocktech.com/RunicGateway/installer)
and the Pterodactyl egg will install it from the Rust bundle, pinned and checksummed (D224, from and the Pterodactyl egg install it from the Rust bundle, pinned and checksummed (D224). On any other
stage 4). Until then, and on any other server: server:
1. Download `runicnpc-<version>.tar.gz` and `SHA256SUMS` from this repository's 1. Download `runicnpc-<version>.tar.gz` and `SHA256SUMS` from this repository's
[releases](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/releases), and check the [releases](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/releases), and check the
@@ -61,8 +63,10 @@ renamed it, because neither framework reports a hook that matches nothing.
`place` uses the spot you look at, `here` the spot you stand on; the server console gives `place` uses the spot you look at, `here` the spot you stand on; the server console gives
`at=x,y,z`. A roamer must stand on Rust's navmesh; only a sentry may stand off it. Every placement `at=x,y,z`. A roamer must stand on Rust's navmesh; only a sentry may stand off it. Every placement
answers with what the server's NPCs cost. On a standalone server, profiles are made from a console answers with what the server's NPCs cost. On a standalone server, profiles are made from a console
with `rnpc.profile create|set|delete`. The full list is in with `rnpc.profile create|set|delete`. Every command and its permission is in
[PLAN.md §5](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). [`docs/runicnpc/COMMANDS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/COMMANDS.md),
and installing and running it in
[`docs/runicnpc/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/INSTALL.md).
## For other plugins ## For other plugins
@@ -74,10 +78,11 @@ 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;
``` ```
The API is version 3, documented in The API is version 6, documented in
[`docs/runicnpc/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/API.md): [`docs/runicnpc/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/API.md):
spawning and removing NPCs by owner, profiles, placements (created and named as in game, from a map point), spawning and removing NPCs by owner, profiles and the faction table, placements (created and named as in
routes, the cost warning, and the hooks it raises. game, from a map point), routes, escorts, allies and tethers, bosses, loot, 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. The API version moves when a call or a raised hook changes shape, not on every release.
## Repository layout ## Repository layout
@@ -87,12 +92,12 @@ The API version moves when a call or a raised hook changes shape, not on every r
| `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`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2, 3 and 4 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). | | `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 and 5 measurement plugin; `RunicNpcTest.cs`, the test harness (`rnt.run all`, then `rnt.after` after a reload or restart) and stage 9's performance check (`rnt.cost`, below); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). |
## After a Rust update: the swap's field list ## 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), 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 generated from Rust's unmodified assembly. `rnpc.status` reports it as `swap fields: npc=55/55 brain=32/32
missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since
the list was made. To regenerate it: the list was made. To regenerate it:
@@ -104,6 +109,43 @@ dotnet run --project tools/fieldlist -- <dir> # rewrites the block
Use the Carbon rig: Oxide's patcher makes Rust's private fields public, so its assembly no longer says which Use the Carbon rig: Oxide's patcher makes Rust's private fields public, so its assembly no longer says which
fields Rust itself serialises. fields Rust itself serialises.
## The update surface: what a Rust update can break
Everything RunicNPC takes from Rust's own classes for the swap and the brain, in one place. A Rust update
that changes any of it breaks RunicNPC; the staging drill (PLAN.md stage 9, D308) checks exactly this list
against every new staging build. **Caught by** says how: *compile* means the plugin no longer compiles
against the new assembly; *field list* means `tools/fieldlist`'s regenerated list differs from the
plugin's (and `rnpc.status` says `missing=` or `added=` at run time); *run time* means only a running server
shows it, so the drill's live half and `rnpc.status` are what catch it.
| Rust type | What RunicNPC depends on | Where (`plugin/RunicNPC.cs`) | Caught by |
|---|---|---|---|
| `ScientistNPC` (prefabs `scientistnpc_*`) | Subclassed by `RunicNpcPlayer`; the swap replaces the prefab's component before spawn and copies `SwapNpcFields` (55 fields up its chain to `BaseNetworkable`) | `#region The swap`, `CreateNpc` | compile; field list |
| `ScientistBrain` | Subclassed by `RunicNpcBrain`; the swap copies `SwapBrainFields` (32 fields of `BaseAIBrain`) | `#region The swap` | compile; field list |
| `ScientistNPC` / `HumanNPC` virtuals | `displayName`, `AttackerInfo(PlayerLifeStory.DeathInfo)`, `ShotTest(float)`, `TriggerDown()`, `Hurt(HitInfo)`, `OnDied(HitInfo)` overridden | `RunicNpcPlayer` | compile |
| `IAIAttack`, `IAISenses` | Re-implemented on `RunicNpcPlayer`: `IsTargetInRange`, `EngagementRange`, `GetBestTarget`, `AttackTick`, `IsTarget`, `IsThreat`, `IsFriendly`. Rust's `HumanNPC.IsTarget` is not virtual, which is why | `RunicNpcPlayer` | compile; a changed meaning only at run time |
| `BaseAIBrain` | `AddStates()` and `Think(float)` overridden; `states`, `CurrentState`, `Navigator`, `Senses`, `Events` used; `AIState` values `Roam`, `Chase`, `Combat`, `TakeCover`, `Cover`, `MoveTowards`, `MoveToVector3`, `FollowPath`, `NavigateHome`, `Flee`, `Blinded` replaced or held | `RunicNpcBrain` | compile; a state Rust stops adding only at run time |
| `BaseAIBrain.BasicAIState` | Subclassed six times (`WanderState`, `RnRoamState`, `RnGuardState`, `RouteState`, `PursueState`, `HoldState`): `StateEnter`, `StateThink`, `StateLeave` | `#region The NPC` | compile |
| `BaseNavigator` | `SetDestination`, `Stop`, `Moving`, `Agent`, `IsOnNavMeshLink`, `NavigationSpeed` | the states | compile |
| `AIMemory` / `AIBrainSenses` | `Memory.Targets`, `SetKnown`, `IsLOS`, `Entity`; `DelaySenseUpdate` | the states, the fight | compile |
| `BaseCombatEntity.faction` | Our NPC's target is set to `Faction.Horror` for the length of our own shot, because `BaseProjectile.ServerUse` drops NPC-on-NPC hits otherwise (stage 5) | `RunicNpcPlayer.Mark` | run time (the drill's fight checks) |
| `NPCAutoTurret` | Harmony prefixes on the private `Ignore(BasePlayer)` and `IsEntityHostile(BaseCombatEntity)`, found by reflection (D267) | `#region Factions`, `SentryPatch` | run time: `rnpc.status` says whether the patch applied |
| `HackableLockedCrate` | The private `hackSeconds`, set by reflection for a loot table's locked crate (stage 7) | `#region Loot` | run time |
| `RelationshipManager`, `ClanManager` | Teams and clans for allies (D257) | `#region Factions` | compile |
| `Rust.Ai.Gen2.RustNavMeshHelpers` | `SamplePosition` for every placement's navmesh check | `#region Rust's facts` | compile |
The **hooks** RunicNPC listens to are listed by `rnpc.status`, which names any that has never fired; one
that stays silent after an update has probably been renamed (neither framework reports a hook that matches
nothing).
## Performance: `rnt.cost`
Stage 9's check (D306): 100 of RunicNPC's NPCs, idle and fighting 20 stand-in players, against the same
session's empty server, on a 6000 map. It passes when they add no more than stage 1 measured, +1.6 ms idle
and +5.2 ms fighting to the median frame. Load `tools/RunicNpcTest.cs` on a rig (it needs the hidden Kits kit
`rnhrevolver`), back up `data/RunicNPC/` (it replaces the profiles), stop the other rig, and run
`rnt.cost [seconds]`; the result is in `data/RunicNpcTest.json`.
## Releases ## Releases
Work lands on `edge` and is cut over to `main`; every releasable push to `main` tags and publishes a Work lands on `edge` and is cut over to `main`; every releasable push to `main` tags and publishes a

View File

@@ -45,8 +45,8 @@ namespace Oxide.Plugins
/// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage. /// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage.
/// </para> /// </para>
/// </summary> /// </summary>
[Info("RunicNpcTest", "Runic Gateway", "0.6.0")] [Info("RunicNpcTest", "Runic Gateway", "0.7.0")]
[Description("RunicNPC stage 2, 3, 4, 5 tests and the stage 6 spike. A developer tool: never ship it.")] [Description("RunicNPC stage 2 to 7 tests, the stage 6 spike and stage 9's cost check. A developer tool: never ship it.")]
internal class RunicNpcTest : RustPlugin internal class RunicNpcTest : RustPlugin
{ {
[PluginReference] private Plugin RunicNPC; [PluginReference] private Plugin RunicNPC;
@@ -543,6 +543,161 @@ namespace Oxide.Plugins
arg.ReplyWith("rnt: cleared"); arg.ReplyWith("rnt: cleared");
} }
// ---- cost: stage 9's performance check (D306) ----
/// <summary>
/// Stage 9's performance check (D306): what 100 of RunicNPC's own NPCs add to the median
/// server frame, idle and fighting, over this session's empty baseline. Stage 1 measured its
/// harness's stand-in NPC; this measures the NPC RunicNPC ships, brain, fight layer and all.
/// Same shape as stage 1's <c>rnh.cost</c>: a baseline, 100 awake but idle (a stand-in 85 m
/// away keeps them from sleeping and is beyond their senses), then 100 shooting 20
/// invulnerable stand-ins 12 m in front of the grid. It replaces RunicNPC's profiles with
/// one, <c>t_cost</c>: back up <c>data/RunicNPC/</c> first, and stop any site that pushes
/// profiles. <c>rnt.cost [seconds=60] [idle [sentry|asleep]]</c>: <c>idle</c> skips the fight,
/// <c>sentry</c> makes the 100 stand still, and <c>asleep</c> leaves out the keeper, so nobody is
/// within their sleep distance. Each run ends with a second empty baseline, and the bar is
/// measured against the lower of the two.
/// </summary>
[ConsoleCommand("rnt.cost")]
private void CmdCost(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
_running = ServerMgr.Instance.StartCoroutine(Cost(arg.GetFloat(0, 60f), arg.GetString(1) == "idle", arg.GetString(2) == "sentry", arg.GetString(2) == "asleep"));
arg.ReplyWith("rnt: measuring cost");
}
/// <summary>The worst stage 1 measured on either framework (PLAN.md stage 1, the cost table): D306's bar.</summary>
private const double IdleBudgetMs = 1.6, FightBudgetMs = 5.2;
private IEnumerator Cost(float window, bool idleOnly = false, bool still = false, bool asleep = false)
{
_pass = _fail = 0;
Note($"cost on {(AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}, " +
$"players {BasePlayer.activePlayerList.Count}, {window:0} s a phase, ai budget {AIThinkManager.framebudgetms} ms");
Vector3 field;
if (!FindField(400f, 606, out field))
{
Check("cost.field", false, "no open field on the navmesh");
_running = null;
yield break;
}
// `rnt.cost <s> idle sentry`: the same NPCs standing still, to tell movement from thinking.
JObject prof = still ? Prof(role: "sentry", name: "TCost") : Prof(radius: 10f, name: "TCost");
prof["ranges"] = JObject.FromObject(new { sense = 50f, loseTarget = 60f, chase = 40f, attack = 40f });
Dictionary<string, string> refused = Api<Dictionary<string, string>>("RunicNpc_SetProfiles", new JObject { ["t_cost"] = prof });
if (refused == null || refused.Count > 0)
{
Check("cost.profile", false, refused == null ? "no answer" : string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()));
_running = null;
yield break;
}
var meterHost = new GameObject("RunicNpcTest.FrameMeter");
FrameMeter meter = meterHost.AddComponent<FrameMeter>();
var medians = new Dictionary<string, double>();
var npcs = new List<BasePlayer>();
IEnumerator m = MeasurePhase(meter, window, "baseline", medians);
while (m.MoveNext()) yield return m.Current;
// `rnt.cost <s> idle asleep`: no keeper, so with nobody within 160 m they walk home and sleep (D235).
if (!asleep)
StandIn(OnMesh(field + new Vector3(13f, 0f, 110f)), 90);
for (int i = 0; i < 100; i++)
{
BasePlayer npc = Spawn(OnMesh(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f)), "t_cost");
if (npc != null) npcs.Add(npc);
if (i % 10 == 9) yield return null;
}
Check("cost.spawned", npcs.Count == 100, $"{npcs.Count} of 100");
yield return new WaitForSeconds(10f);
m = MeasurePhase(meter, window, "idle100", medians);
while (m.MoveNext()) yield return m.Current;
// `rnt.cost <s> idle` stops here: a quicker run for finding what idle NPCs cost.
if (!idleOnly)
{
_hitsOnStandIns = 0;
for (int i = 1; i <= 20; i++)
StandIn(OnMesh(field + new Vector3((i - 1) * 1.5f, 0f, -12f)), 90 + i);
yield return new WaitForSeconds(10f);
int hitsBefore = _hitsOnStandIns;
m = MeasurePhase(meter, window, "fight100", medians);
while (m.MoveNext()) yield return m.Current;
Note($"cost.fight100 hits on stand-ins while measured: {_hitsOnStandIns - hitsBefore}");
Check("cost.fighting", _hitsOnStandIns - hitsBefore > 0, $"{_hitsOnStandIns - hitsBefore} hits");
}
Api<int>("RunicNpc_DespawnOwner", Owner);
ClearWorld();
// A second empty window after everything is gone: one baseline at the start can carry a hitch
// (the first run on the Oxide rig did: a 2.4 s frame, and idle read below it). The bar is
// measured against the worse — the lower — of the two, so noise can only make it stricter.
yield return new WaitForSeconds(10f);
m = MeasurePhase(meter, window, "baseline.after", medians);
while (m.MoveNext()) yield return m.Current;
UnityEngine.Object.Destroy(meterHost);
double baseline = Math.Min(medians["baseline"], medians["baseline.after"]);
double idle = medians["idle100"] - baseline;
double fight = idleOnly ? double.NaN : medians["fight100"] - baseline;
Note($"cost baselines {medians["baseline"]:0.00} and {medians["baseline.after"]:0.00} ms; the lower is the bar's");
Check("cost.idle", idle <= IdleBudgetMs, $"{idle:+0.00;-0.00} ms median over the baseline (bar {IdleBudgetMs} ms)");
if (!idleOnly) Check("cost.fight", fight <= FightBudgetMs, $"{fight:+0.00;-0.00} ms median over the baseline (bar {FightBudgetMs} ms)");
Note($"done cost: {_pass} pass, {_fail} fail");
_running = null;
}
private IEnumerator MeasurePhase(FrameMeter meter, float window, string phase, Dictionary<string, double> medians)
{
int queue0 = AIThinkManager._processQueue.Count;
meter.Begin();
yield return new WaitForSeconds(window);
List<float> ms = meter.End();
if (ms.Count == 0)
{
Check("cost." + phase, false, "no frames");
medians[phase] = double.NaN;
yield break;
}
ms.Sort();
Func<double, double> pct = p => ms[Math.Min(ms.Count - 1, (int)Math.Ceiling(p * ms.Count) - 1)];
double mean = ms.Average();
medians[phase] = pct(0.5);
Note($"cost.{phase} frames={ms.Count} p50={pct(0.5):0.00} ms fps={1000.0 / mean:0.0} p95={pct(0.95):0.00} p99={pct(0.99):0.00} " +
$"max={ms[ms.Count - 1]:0.00} aiQueue={queue0}->{AIThinkManager._processQueue.Count}");
}
public class FrameMeter : MonoBehaviour
{
private readonly List<float> _frames = new List<float>(20000);
private bool _recording;
public void Begin()
{
_frames.Clear();
_recording = true;
}
public List<float> End()
{
_recording = false;
return new List<float>(_frames);
}
private void Update()
{
if (_recording)
_frames.Add(Time.unscaledDeltaTime * 1000f);
}
}
private IEnumerator Run(string group) private IEnumerator Run(string group)
{ {
_pass = _fail = 0; _pass = _fail = 0;