docs(runicnpc): stage 6 spike, measured on both rigs

What rnt.spike6 found on the Oxide and Carbon rigs: press E has no
hook but OnPlayerInput; the bar can redraw in place with CUI's update;
a phase's kit swap keeps the target but needs EquipWeapon; adds cost
4-8 ms each in one frame; Kits' data file takes an added kit on reload
and keeps players' usage. Found on the way: RunicNPC's
"// Requires: Kits" makes every Kits reload take RunicNPC and its
NPCs down. Two questions for the org lead.

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-05 06:23:36 -05:00
parent a070780368
commit fe85901195

View File

@@ -1120,6 +1120,132 @@ greet, hurt or kill lines (D281). A boss's loot table is stage 7's.
the announcement in game and in the app, an event's reward reaching the boss's killers, the site's triggers firing,
press E on a passive NPC, and the example profiles working on a server with no kits of its own.
**Measured (2026-10-05, `tools/RunicNpcTest.cs` 0.6.0: `rnt.spike6`).** Both rigs, the same 6000 map, with stage 5's
RunicNPC (API 4) loaded, so the fights and adds are RunicNPC's own NPCs. Nobody was connected; the harness used
stand-ins where a player was needed. Each rig ran the whole spike twice, **26 of 26 checks passing on the final run
on each**. Every answer held on both frameworks unless it says otherwise.
**1. Press E (D278).**
- **Neither framework has a hook for it.** The Oxide rig's patched `Assembly-CSharp.dll` and Carbon's
`Carbon.Hooks.Oxide.dll` were searched: there is no `OnUseNPC` (the hook older NPC plugins used), and nothing
else that fires on a use aimed at an NPC.
- **Rust does nothing with it either.** A use on an entity is a remote call the client makes when it sees
something to use, and it sees nothing on a scientist. The server's `BasePlayer` never reads the use button.
- **So it is `OnPlayerInput`, which both frameworks raise on every player tick.** RunicNPC checks whether E was
*just* pressed, and only then casts a 3 m ray from the player's eyes. The check costs 7–8 ns a tick (one run on
Carbon read 113 ns); a ray costs 5–7 µs.
- **The ray finds our NPC.** Our NPC is one capsule on Rust's `Player (Server)` layer. Rays at its head, chest and
legs from 1.5, 2.5 and 3.5 m hit it 9 times in 9 on both rigs, with or without the world's layers in the mask.
- A real client pressing E is a walk item: no client was connected.
**2. The health bar (D274).**
- **Oxide's and Carbon's CUI both carry `update`**, so a redraw sends only the fill and the text, changed in place,
instead of destroying and re-adding the bar. The whole bar is 580 bytes; a redraw is 434.
- **Building it costs 115–200 µs.** That is once per redraw, not per player, because every viewer gets the same
bar.
- **Finding who sees it costs 6–8 µs as a distance loop over the players** (29 of 40 within 100 m). Rust's sphere
query costs 316–357 µs for the same answer, so the bar uses the loop.
- **Sending it was not measured:** with no client connected, nothing is sent.
- **Clean-up has one place to live.** RunicNPC already removes an NPC from its registry in one method on death,
despawn and unload. The bar is cleared there, for every player shown it. A client that disconnects drops its own
UI.
**3. A phase's kit swap mid-fight (D275).** Ours fought a stock scientist 15 m away with the revolver kit. Hits on
the scientist were counted by weapon. The swap stripped the inventory, gave the MP5 kit through Kits' `GiveKit`,
and called Rust's `EquipWeapon`.
| | Oxide | Carbon |
|---|---|---|
| Kept its target through the swap | yes | yes |
| Fired the new weapon | MP5 hit 5–6 times in 6–7 s; the revolver never again | MP5 hit 5–6 times in 8–9 s; the same |
| Without `EquipWeapon` | held nothing for 16 s and never fired | the same |
| A swap's cost | 2.7–7 ms; **100 ms the first time the server made an MP5** | 2.8–4 ms; **111 ms the first time** |
- **A phase must call `EquipWeapon` itself.** Rust's brain does not pick up a new weapon on its own.
- The one slow swap is Rust loading the MP5's prefab for the first time, as it would for a player's first craft.
**4. Summoned adds (D275).** While ours fought, 4 and then 10 more of the same profile were placed on a 6 m ring
around it, each point snapped to the navmesh.
| | Oxide | Carbon |
|---|---|---|
| 4 adds | all placed, on the mesh, fighting its target, in 25.6 ms | the same, in 18.6–30.3 ms |
| 10 adds | all placed, on the mesh, fighting, in 60.3 ms | the same, in 43.8–53.4 ms |
- **An add costs 4.4–7.6 ms to place (kit included), all in one frame.** Ten at once is a visible 44–60 ms hitch, so
the build places **one add per server frame**.
- No ring point fell off the navmesh in open ground. A ring point that does is snapped to the nearest point within
3 m, or skipped.
**5. Kits' data file (D280).**
- **It is `data/Kits/kits_data.json` on both frameworks** (`oxide/data`, `carbon/data`), one object `_kits` keyed by
kit name. Each kit has its fields plus `MainItems`, `WearItems` and `BeltItems`. The stage 1 harness kit's format
works as written.
- **Kits reads the file only when it loads**, so a kit written to it does nothing until Kits reloads.
- **Kits never writes this file on load or unload.** It writes it only when an admin edits a kit. Its unload saves
players' usage (`player_data.json`), unless the server is shutting down.
- **So a reload keeps every player's usage.** A stand-in claimed a kit with a 1-hour cooldown, held only in Kits'
memory. After the reload it still had 1 use and its cooldown. A real player's usage on the Carbon rig (`walkauto`,
3 uses) came through too. Every kit already in the file stayed.
- **A reload takes 3.5–15 s on Oxide, which recompiles. On Carbon (`c.reload`, W3) Kits is back in 0.4–2.3 s.**
- **`IsHidden` does not stop a claim.** It hides a kit from Kits' menu, but a player who types its name gets it: the
harness claimed a hidden kit that way. `RequiredPermission` and `RequiredAuth` do stop players. Kits' `GiveKit`
call, which RunicNPC uses, skips both.
**6. Found on the way: a Kits reload takes RunicNPC down with it.**
- **RunicNPC's `// Requires: Kits` makes both frameworks unload RunicNPC when Kits unloads, and load it again
after.**
- On Oxide this happens every time; the two compile together ("Kits and RunicNPC were compiled").
- On Carbon it happens whenever RunicNPC was freshly compiled, which includes every boot: "Unloading 'RunicNPC'
because parent 'Kits' has been unloaded". RunicNPC is back 5–6 s later. After one such reload, Carbon stops
linking the two until RunicNPC is compiled again.
- **What that does:** every NPC RunicNPC has spawned is killed. In the harness, two NPCs a plugin had placed were
there before a Kits reload and gone after it, as an event's would be. Placements come back when RunicNPC loads
again. An event's NPCs do not.
- **Anything that reloads Kits does this:** an admin's `oxide.reload Kits`, the site's config write for Kits (the
bridge reloads the plugin it wrote, `modules/rust/PLAN.md` §21), and D280's own write of the example kits.
- **RunicNPC needs nothing from Kits at compile time.** It reaches Kits only through `[PluginReference]` and `Call`.
The `Requires` line exists only to enforce D217.
- **One recovery trap on Carbon.** Unloading Kits and RunicNPC by hand with `c.unload`, then `c.load`, left both
"requested for compilation" and never loaded. `c.reload` did the same. A restart of the server cleared it.
**What the answers leave open, for the org lead:**
1. **Should a Kits reload stop taking RunicNPC down?** For example: an event has placed 8 raiders, and an admin
edits a Kits kit and reloads Kits. Today all 8 vanish at once on both frameworks, and the event cannot get them
back. The choices:
- **Drop `// Requires: Kits` (proposed).** RunicNPC stays loaded through a Kits reload, and its NPCs keep the
gear they already have. For the 1–15 s that Kits is away, a spawn is refused with a warning. When Kits comes
back, RunicNPC checks the profiles' kits again. D217 still holds: with Kits not installed at all, RunicNPC
refuses every profile and says why.
- **Keep it**, and document "never reload Kits while an event runs".
2. **When are the example kits written (D280)?** For example: an admin installs RunicNPC, and its four kits are
written once and Kits reloads. Later the admin deletes `rnpc_raider`, or edits `rnpc_juggernaut`. What happens
at the next restart?
- **Only on the first install (proposed).** A flag in RunicNPC's data records that they were written, so a kit
the admin deleted stays deleted and an edit is never overwritten.
- **On every load, for any example kit that is missing.**
- **Only while the example profile that uses it still exists.**
Proposed with it:
- the kits are named `rnpc_raider`, `rnpc_campguard`, `rnpc_sniper` and `rnpc_juggernaut`;
- they carry `RequiredPermission: runicnpc.examplekits`, which nobody is granted, so no player can claim one by
name (`IsHidden` alone does not stop that);
- on Oxide the one-time reload takes RunicNPC down and back once (finding 6) unless question 1 drops the
`Requires` line, so the kits are written before RunicNPC spawns anything.
**What this sets for the build, with nothing to decide:**
- Press E goes through `OnPlayerInput`.
- The bar redraws only on damage, at most 4 times a second per boss, with `update`. Its viewers come from a
distance loop over connected players, checked again each second for players who walk in or out.
- A phase's kit swap is strip, `GiveKit`, `EquipWeapon`.
- Adds are placed one per frame.
### Stage 7 — Loot
Loot tables authored on the site, the corpse's default loot cleared or kept, a crate on death, corpse removal.