Merge pull request 'docs(runicnpc): stage 6 spike, measured on both rigs' (#314) from docs/runicnpc-stage6-spike into main

Reviewed-on: #314
This commit is contained in:
2026-10-05 14:26:08 +00:00

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, 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. 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 ### Stage 7 — Loot
Loot tables authored on the site, the corpse's default loot cleared or kept, a crate on death, corpse removal. Loot tables authored on the site, the corpse's default loot cleared or kept, a crate on death, corpse removal.