From 6a384d0a289a15a07fa63b5352c75530ebf2c4f0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 6 Oct 2026 17:33:28 -0500 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 66 ++++++++++++++---- tools/RunicNpcTest.cs | 159 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a2acee8..62c6d16 100644 --- a/README.md +++ b/README.md @@ -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-.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("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 -- # 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 diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index 9abaec9..63f7f75 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -45,8 +45,8 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [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) ---- + + /// + /// 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 rnh.cost: 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, t_cost: back up data/RunicNPC/ first, and stop any site that pushes + /// profiles. rnt.cost [seconds=60] [idle [sentry|asleep]]: idle skips the fight, + /// sentry makes the 100 stand still, and asleep 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. + /// + [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"); + } + + /// The worst stage 1 measured on either framework (PLAN.md stage 1, the cost table): D306's bar. + 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 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 refused = Api>("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(); + var medians = new Dictionary(); + var npcs = new List(); + + IEnumerator m = MeasurePhase(meter, window, "baseline", medians); + while (m.MoveNext()) yield return m.Current; + + // `rnt.cost 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 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("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 medians) + { + int queue0 = AIThinkManager._processQueue.Count; + meter.Begin(); + yield return new WaitForSeconds(window); + List ms = meter.End(); + if (ms.Count == 0) + { + Check("cost." + phase, false, "no frames"); + medians[phase] = double.NaN; + yield break; + } + + ms.Sort(); + Func 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 _frames = new List(20000); + private bool _recording; + + public void Begin() + { + _frames.Clear(); + _recording = true; + } + + public List End() + { + _recording = false; + return new List(_frames); + } + + private void Update() + { + if (_recording) + _frames.Add(Time.unscaledDeltaTime * 1000f); + } + } + private IEnumerator Run(string group) { _pass = _fail = 0; -- 2.49.1 From b5e7ecc4ed580f7843361c956696c3064b9c543c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 6 Oct 2026 21:33:49 -0500 Subject: [PATCH 2/2] feat: the cost warning says what stage 9 measured (D306) Stage 9 measured the released NPC on both frameworks (6000 map): about 3 ms of median frame per 100 idle while a player keeps them awake, 0.5 ms once they sleep with nobody near, and 9 ms per 100 fighting, with each NPC reacting about every 3 s under Rust's shared AI budget. The warning now says idle twice (awake and asleep) and uses those figures, the worst of the two rigs rounded up. The awake-idle overhead over the bare swap is runicnpc-rust#13. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin/RunicNPC.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index f01c51e..c406d32 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -3661,9 +3661,11 @@ namespace Oxide.Plugins #region The cost warning (D227) /// - /// What 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 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. /// 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."; } -- 2.49.1