Merge pull request 'feat: stage 9b hardening — cost warning, update surface, rnt.cost (D306, D315)' (#14) from feat/stage-9 into edge

Reviewed-on: #14
This commit is contained in:
2026-10-07 02:42:36 +00:00
3 changed files with 220 additions and 20 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
[`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
> the `/rnpc` commands admins use in game. On a Runic Gateway server, the website takes over the
> profiles and lists, edits and creates placements through the bridge (API 3).
> **Status: stage 9, hardening for v1.0.0.** The NPC, its profiles, placements and routes; factions,
> fights, escorts and tethers; bosses and passive NPCs; loot. Admins use the `/rnpc` commands, other
> 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
@@ -22,8 +24,8 @@ website authors its NPC profiles and events use its NPCs. The plan of record, st
## Installing it
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
stage 4). Until then, and on any other server:
and the Pterodactyl egg install it from the Rust bundle, pinned and checksummed (D224). On any other
server:
1. Download `runicnpc-<version>.tar.gz` and `SHA256SUMS` from this repository's
[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
`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
with `rnpc.profile create|set|delete`. The full list is in
[PLAN.md §5](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md).
with `rnpc.profile create|set|delete`. Every command and its permission is in
[`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
@@ -74,10 +78,11 @@ Every call is prefixed `RunicNpc_` and reached through `Call`:
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):
spawning and removing NPCs by owner, profiles, placements (created and named as in game, from a map point),
routes, the cost warning, and the hooks it raises.
spawning and removing NPCs by owner, profiles and the faction table, placements (created and named as in
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.
## 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.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). |
| `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
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
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
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
Work lands on `edge` and is cut over to `main`; every releasable push to `main` tags and publishes a

View File

@@ -3661,9 +3661,11 @@ namespace Oxide.Plugins
#region The cost warning (D227)
/// <summary>
/// What <paramref name="total"/> NPCs cost the server, from stage 1's measurements on both
/// frameworks (PLAN.md §9, stage 1, Q5). The rig ran about 50 fps empty, so the warning
/// says what NPCs ADD to a frame, not an fps.
/// What <paramref name="total"/> NPCs cost the server, from stage 9's measurements of the
/// released NPC on both frameworks (PLAN.md §9, stage 9, D306; stage 1, Q5, first): the worst
/// of the two rigs, rounded up. Idle is said twice, because an NPC with no player within its
/// sleep distance sleeps (D235) and costs a sixth of an awake one. The warning says what NPCs
/// ADD to a frame, not an fps, since a server's own frame time varies far more than that.
/// </summary>
private static string CostWarning(int total)
{
@@ -3672,9 +3674,10 @@ namespace Oxide.Plugins
float hundreds = total / 100f;
string estimate = total > 100 ? " (measured up to 100; beyond that this is an estimate)" : "";
return $"RunicNPC: {total} NPC(s) on this server{estimate}. Measured on a test server, they add about " +
$"{Mathf.Max(0.1f, hundreds * 1.5f):0.#} ms to every server frame while idle. If all fight at once they add about " +
$"{hundreds * 5f:0.#} ms, and Rust's shared 2 ms AI budget then lets each react only every " +
$"{Mathf.Max(0.3f, hundreds * 2.5f):0.#} s. Fighting other NPCs (a faction setting) was measured to cost about " +
$"{Mathf.Max(0.1f, hundreds * 3f):0.#} ms to every server frame while idle with a player near enough to wake them, " +
$"and about {Mathf.Max(0.1f, hundreds * 0.5f):0.#} ms once they sleep with nobody near. If all fight at once they add about " +
$"{hundreds * 9f:0.#} ms, and Rust's shared 2 ms AI budget then lets each react only every " +
$"{Mathf.Max(0.3f, hundreds * 3f):0.#} s. Fighting other NPCs (a faction setting) was measured to cost about " +
$"what fighting players does; on a map whose own AI is busy, each then reacts only every " +
$"{Mathf.Max(0.5f, hundreds * 7f):0.#} s.";
}

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.
/// </para>
/// </summary>
[Info("RunicNpcTest", "Runic Gateway", "0.6.0")]
[Description("RunicNPC stage 2, 3, 4, 5 tests and the stage 6 spike. A developer tool: never ship it.")]
[Info("RunicNpcTest", "Runic Gateway", "0.7.0")]
[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
{
[PluginReference] private Plugin RunicNPC;
@@ -543,6 +543,161 @@ namespace Oxide.Plugins
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)
{
_pass = _fail = 0;