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

@@ -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;