11 Commits
main ... edge

Author SHA1 Message Date
340f1dc8f5 Merge pull request 'test(harness): stage 5 spike, NPC targets, turrets, kit items, PVE (D231, D253)' (#7) from spike/stage-5 into edge
Reviewed-on: #7
2026-10-01 08:17:26 +00:00
9424dca6d1 test(harness): stage 5 spike, NPC targets, turrets, kit items, PVE (D231, D253)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m28s
RunicNpcHarness 0.2.0 adds the measurements stage 5 opens with:

- rnh.npcfight: our own sensing and combat state against a stock
  scientist, another of ours, a boar and a wolf, and with an ally on
  the line of fire. Rust's BaseProjectile.ServerUse drops every hit on
  an NPC from an NPC's gun unless the victim's faction is Horror; the
  prototype marks only its current target Horror for the length of
  each of its own shots.
- rnh.sense: what that sensing costs, 100 against 100 scientists and
  50 against 50 of ours.
- rnh.turrets: auto turret, flame turret and shotgun trap against a
  stand-in player, a stock scientist and ours (D259).
- rnh.items: which kit items Rust's AI uses by itself (D260).
- rnh.pve: what TruePVE or NextGenPVE does with our NPC, and with an
  answer to CanEntityTakeDamage (D261).

Never shipped: a developer tool in tools/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 00:23:47 -05:00
bf2940b819 Merge pull request 'feat: API 3, placements from a website (stage 4, D249)' (#6) from feat/stage-4-api3 into edge
Reviewed-on: #6
2026-09-30 19:52:55 +00:00
e2c0db2a5d ci(release): ask the installer to recompose the Rust bundle (stage 4, D224)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 9s
RunicNPC is the Rust bundle's third artefact from stage 4, so its release
ends as Rust-Plugins' does: a dispatch of the installer's bundle workflow,
warned rather than failed when the token cannot reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:52:36 -05:00
fe20ca41af feat: API 3, placements from a website (stage 4, D249)
RunicNpc_AddPlacement creates a placement and names it as /rnpc place
does (D241, D246). A position without y is a map point: it is put on the
ground there (terrain and rock, never a building or a tree, D245), then
checked against the navmesh like an in-game placement. It answers the id,
the grounded position, whether the spot is player-built, and the cost
warning, or the in-game refusal sentence.

RunicNpc_RenamePlacement and RunicNpc_RespawnPlacement do what rnpc
rename and rnpc respawn do. OnRunicNpcPlacementChanged(id, change,
previous) is raised on every set, remove and rename, from the API or in
game, so the bridge can tell the site at once.

rnpc place and here now share CreatePlacement with the API, so both give
the same refusals. The test harness gains an api3 group; all 157 checks
pass on the Oxide and Carbon rigs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:01:30 -05:00
9c366ab5b5 Merge pull request 'feat: stage 3, the /rnpc commands in game' (#5) from feat/stage-3-in-game into edge
Reviewed-on: #5
2026-09-30 08:20:17 +00:00
0a57d783ca feat: stage 3, the /rnpc commands in game
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 7s
The chat command /rnpc and the same verbs as rnpc.<verb> in a console
(PLAN.md §5), behind runicnpc.place and runicnpc.admin:

- place / here with key=value options in any order (D242); placements
  made in game are named <profile>-<n> and renamable (D241).
- remove, rename, near, info (the NPC you look at), profiles, tp,
  respawn, clear, and rnpc.profile create|show|set|delete for a
  standalone server (refused while the site manages profiles, D221).
- path record / point / undo / save [loop|back] / cancel / list /
  delete (D240). A point an NPC cannot walk to from the last one is
  refused, as is a loop that cannot close: Rust's path query says so.
- A roamer may stand on a player-built floor, with a warning (D239).
  If the floor is destroyed under a live NPC, Rust leaves it standing
  in the air, so the brain puts it on the nearest navmesh within 2 s;
  a placement whose spot is off the mesh respawns on the nearest.
- The API's SetPlacement now refuses a roamer off the navmesh, as the
  command does; placements report a note while they fall back.
- Pos.V and Profile.IsSentry no longer leak into the JSON files.

The harness gains the cmd and floor groups. Both rigs: 134/134 in one
rnt.run all, rnpc reload 5/5, server restart 5/5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 03:17:44 -05:00
15120ef80d Merge pull request 'feat: stage 2, the NPC and its API (API 2)' (#4) from feat/stage-2-npc-api into edge
Reviewed-on: #4
2026-09-30 06:52:37 +00:00
f8084955e8 feat: stage 2, the NPC and its API (API 2)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m38s
RunicNPC now spawns its own NPC: Rust's scientist with its ScientistNPC and
ScientistBrain swapped for ours, copying a fixed field list generated from
Rust's unmodified assembly (D232, tools/fieldlist). Profiles live in
data/RunicNPC/profiles.json in the approved shape (D238); placements persist
with each/group respawn (D236) and wait for a missing profile or route
(D237); roamers wander, follow Rust's monument paths, or walk a route (D233,
D234), with our own chase where Rust's needs an AI zone; sentries hold their
spot; NPCs walk home and sleep past 160 m of any player (D235). Spawns wait
for the navmesh and are spread over frames; caps are off by default and the
cost warning is shown instead (D227). The whole PLAN.md section 4 API and its
hooks are in, documented in docs/runicnpc/API.md.

tools/RunicNpcTest.cs is the stage 2 harness; every group passed on both
rigs, and after a plugin reload and a server restart. checkPlugin no longer
counts an override (RunicNpcPlayer.OnDied) as a hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 01:31:04 -05:00
914047f1b0 Merge pull request 'chore(tools): the stage 1 harness, RunicNpcHarness.cs' (#3) from feat/stage-1-harness into edge
Reviewed-on: #3
2026-09-30 04:26:33 +00:00
18c90961ba chore(tools): the stage 1 harness, RunicNpcHarness.cs
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 6s
A developer plugin, never shipped, that measured the six questions stage 2
depends on (docs/runicnpc/PLAN.md §9, stage 1) on both rigs: the subclass
swap, saving across a hard kill, what Rust's brain honours, navmesh coverage,
the cost of 1/10/100 idle and fighting, and the death screen and bridge frames.

It spawns stand-in players (player.prefab, a made-up user id, IsNpc false) as
targets, at the org lead's suggestion. It needs a hidden Kits kit, rnhrevolver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 23:04:46 -05:00
11 changed files with 6693 additions and 46 deletions

View File

@@ -38,9 +38,12 @@
# sidecar (PLAN.md §4): the bridge calls it in-process. What a consumer pairs # sidecar (PLAN.md §4): the bridge calls it in-process. What a consumer pairs
# on is the API version other plugins call, from plugin.toml. # on is the API version other plugins call, from plugin.toml.
# #
# 4. NO BUNDLE DISPATCH, YET. The installer does not know RunicNPC until stage 4 # 4. THE BUNDLE IS RECOMPOSED. Since stage 4 RunicNPC is the Rust bundle's third
# makes it a third artefact of the Rust bundle (D224). That stage adds the # artefact (D224): the installer's compose job carries the latest RunicNPC
# step Rust-Plugins ends with, which asks RunicGateway/installer to recompose. # release that answers the API the bridge declares (Rust-Plugins' overlay.toml
# `runicnpc_api`). The last step here asks RunicGateway/installer to
# recompose, as Rust-Plugins' release does; the installer's nightly cron covers
# a dispatch that did not arrive.
# #
# The version is stamped into the shipped copy's `[Info(…)]` attribute, so # The version is stamped into the shipped copy's `[Info(…)]` attribute, so
# `oxide.plugins` / `c.plugins` on a server names the release it runs. The # `oxide.plugins` / `c.plugins` on a server names the release it runs. The
@@ -55,7 +58,9 @@
# Prerequisites (Settings → Actions → Secrets on RunicGateway/runicnpc-rust, or # Prerequisites (Settings → Actions → Secrets on RunicGateway/runicnpc-rust, or
# the organisation's): # the organisation's):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the # REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the
# tag and create the release. # tag and create the release. The final step also dispatches
# RunicGateway/installer's bundle workflow, so the token
# needs write on that repo too; without it that step warns.
# REGISTRY_USER — the Gitea username that token belongs to. # REGISTRY_USER — the Gitea username that token belongs to.
name: Release plugin name: Release plugin
@@ -71,6 +76,7 @@ concurrency:
env: env:
GITEA_HOST: gitea.whitlocktech.com GITEA_HOST: gitea.whitlocktech.com
INSTALLER_REPO: RunicGateway/installer
REPO: RunicGateway/runicnpc-rust REPO: RunicGateway/runicnpc-rust
ARTIFACT: runicnpc ARTIFACT: runicnpc
PLUGIN: plugin/RunicNPC.cs PLUGIN: plugin/RunicNPC.cs
@@ -474,3 +480,27 @@ jobs:
fi fi
echo " uploaded ${f}" echo " uploaded ${f}"
done done
# ── Recompose the installer's bundle manifest (D224) ─────────────────
# DISPATCH, DON'T WAIT, as Rust-Plugins' release does. A dropped dispatch
# costs latency, not correctness: the installer's nightly cron recomputes
# the bundle from whatever the latest releases actually are.
- name: Ask the installer repo to recompose its bundle
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"ref":"main"}' \
"https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)"
case "$HTTP" in
20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;;
403|404)
echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;;
*)
echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;;
esac

View File

@@ -7,8 +7,9 @@ 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 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). [`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md).
> **Status: stage 0.** The plugin loads, answers its API version, and reports on itself. It spawns > **Status: stage 4.** The NPC, its profiles, placements and routes, the API other plugins call, and
> nothing yet. Stage 1 is a measuring spike; the NPC and its API arrive in stage 2. > 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).
## Requirements ## Requirements
@@ -43,6 +44,26 @@ Answers at the server console and over RCON: the version, the API version, and w
plugin's hooks have fired. A hook that never fires is the first sign a Rust or framework update has plugin's hooks have fired. A hook that never fires is the first sign a Rust or framework update has
renamed it, because neither framework reports a hook that matches nothing. renamed it, because neither framework reports a hook that matches nothing.
## In game
`/rnpc` in chat, and the same verbs as `rnpc.<verb>` in a console (F1, the server console or RCON).
`/rnpc` alone lists the ones you may use. Grant `runicnpc.place` to place and manage NPCs, and
`runicnpc.admin` for everything (it includes `runicnpc.place`); the server console has both.
```
/rnpc place bandit count=3 respawn=300 mode=group move=route:gate
> Placed bandit-1: 3 × 'bandit' (roamer, route:gate), respawn 300 s, group.
/rnpc path record gate then walk, and at each point: /rnpc path point
/rnpc path save loop (or back, to walk it back and forth)
/rnpc near · /rnpc info · /rnpc rename bandit-1 gateguards · /rnpc remove
```
`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).
## For other plugins ## For other plugins
Every call is prefixed `RunicNpc_` and reached through `Call`: Every call is prefixed `RunicNpc_` and reached through `Call`:
@@ -53,9 +74,11 @@ Every call is prefixed `RunicNpc_` and reached through `Call`:
int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0; int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0;
``` ```
Stage 0 has only `RunicNpc_ApiVersion()`. The full API is planned in PLAN.md §4 and will be The API is version 3, documented in
documented as `docs/runicnpc/API.md` in stage 2. The API version moves when a call or a raised hook [`docs/runicnpc/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/API.md):
changes shape, not on every release. 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.
The API version moves when a call or a raised hook changes shape, not on every release.
## Repository layout ## Repository layout
@@ -64,7 +87,22 @@ changes shape, not on every release.
| `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | | `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. | | `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). | | `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 (see `tools/rigs.example.json`). | | `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). |
## 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
missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since
the list was made. To regenerate it:
```bash
node tools/managed.js carbon <dir> # the Carbon rig's RustDedicated_Data/Managed
dotnet run --project tools/fieldlist -- <dir> # rewrites the block in plugin/RunicNPC.cs
```
Use the Carbon rig: Oxide's patcher makes Rust's private fields public, so its assembly no longer says which
fields Rust itself serialises.
## Releases ## Releases

View File

@@ -22,8 +22,11 @@
# a RunicNPC too old for the bridge it is pairing with. Declaring it here is what # a RunicNPC too old for the bridge it is pairing with. Declaring it here is what
# lets a bundle check the pair BEFORE an operator installs it. # lets a bundle check the pair BEFORE an operator installs it.
# #
# Current: 1 — `RunicNpc_ApiVersion()` and nothing else (stage 0). # 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else.
api = 1 # 2 was stage 2's whole API, PLAN.md §4.
# Current: 3 — stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the
# OnRunicNpcPlacementChanged hook, documented in docs/runicnpc/API.md.
api = 3
# ── Framework floors ───────────────────────────────────────────────────────── # ── Framework floors ─────────────────────────────────────────────────────────
# #

File diff suppressed because it is too large Load Diff

View File

@@ -179,7 +179,11 @@ function check(source, toml) {
} }
const methods = readMethods(source) const methods = readMethods(source)
const hooks = methods.filter((x) => HOOK_NAME.test(x.name)) // An `override` is never a hook: a hook is a plugin method the framework finds by name, and the
// plugin class overrides nothing shaped like one. What does override `On*` methods is the NPC's
// own classes (RunicNpcPlayer.OnDied overrides Rust's ScientistNPC), and Rust, not the
// framework, calls those.
const hooks = methods.filter((x) => HOOK_NAME.test(x.name) && !/\boverride\b/.test(x.returns))
const hookNames = new Set(hooks.map((x) => x.name)) const hookNames = new Set(hooks.map((x) => x.name))
// 1. Every hook the plugin implements is one `rnpc.status` can report on. // 1. Every hook the plugin implements is one `rnpc.status` can report on.

View File

@@ -126,6 +126,25 @@ test('a method that is not shaped like a hook is left alone', () => {
assert.ok(HOOK_NAME.test('CanBeTargeted')) assert.ok(HOOK_NAME.test('CanBeTargeted'))
}) })
test('an override shaped like a hook belongs to the NPC classes, not the framework, and is left alone', () => {
const methods = ` private void OnServerInitialized()
{
}
public class RunicNpcPlayer : ScientistNPC
{
public override void OnDied(HitInfo info)
{
}
protected override void OnStateChanged()
{
}
}`
assert.deepEqual(check(source({ methods }), toml()), [])
})
// ── The API ──────────────────────────────────────────────────────────────── // ── The API ────────────────────────────────────────────────────────────────
test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => { test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => {
@@ -287,12 +306,13 @@ test('the parser actually reads the real plugin, rather than quietly matching no
// Raise these floors as the plugin grows; they are what stops a regex that // Raise these floors as the plugin grows; they are what stops a regex that
// matches nothing from passing every case above. // matches nothing from passing every case above.
assert.ok(methods.length >= 5, `only found ${methods.length} methods in the real plugin`) assert.ok(methods.length >= 40, `only found ${methods.length} methods in the real plugin`)
assert.ok(names.has('OnServerInitialized'), 'OnServerInitialized was not found by the method parser') assert.ok(names.has('OnServerInitialized'), 'OnServerInitialized was not found by the method parser')
assert.ok(names.has('CmdStatus'), 'CmdStatus (under an attribute) was not found by the method parser') assert.ok(names.has('CmdStatus'), 'CmdStatus (under an attribute) was not found by the method parser')
const api = methods.filter((m) => API_NAME.test(m.name)).map((m) => m.name) const api = methods.filter((m) => API_NAME.test(m.name)).map((m) => m.name)
assert.ok(api.includes('RunicNpc_ApiVersion'), 'RunicNpc_ApiVersion was not found as an API call') assert.ok(api.includes('RunicNpc_ApiVersion'), 'RunicNpc_ApiVersion was not found as an API call')
assert.ok(api.includes('RunicNpc_Spawn'), 'RunicNpc_Spawn was not found as an API call')
assert.ok(readExpectedHooks(real).length >= 1) assert.ok(readExpectedHooks(real).length >= 1)
assert.deepEqual(readRequires(real), ['Kits']) assert.deepEqual(readRequires(real), ['Kits'])

2063
tools/RunicNpcHarness.cs Normal file

File diff suppressed because it is too large Load Diff

1204
tools/RunicNpcTest.cs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Generates the swap's field list (docs/runicnpc/PLAN.md D232) from Rust's unmodified
Assembly-CSharp.dll. A developer tool: never shipped, not built by CI. See Program.cs.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,98 @@
// Writes the list of fields RunicNPC's swap copies from Rust's scientist to ours (D232).
//
// node tools/managed.js carbon <dir> (once per Rust update)
// dotnet run --project tools/fieldlist -- <dir> [plugin/RunicNPC.cs]
//
// <dir> must hold the CARBON rig's managed assemblies: Oxide's patcher makes Rust's private fields
// public, so its Assembly-CSharp.dll no longer says what Rust itself serialises.
//
// The rule is stage 1's, which is Unity's own for a component's authored data: every instance
// field from the component's type down to (not including) MonoBehaviour that is public and not
// [NonSerialized], or carries [SerializeField]; never readonly, const or a delegate. Applied here to
// the unmodified assembly, it picks the same fields on both frameworks. The result replaces the
// block between the two marker lines in the plugin, and the plugin resolves each name at load.
using System.Reflection;
using System.Text;
if (args.Length < 1)
{
Console.Error.WriteLine("usage: dotnet run --project tools/fieldlist -- <managed dir> [plugin/RunicNPC.cs]");
return 2;
}
string managed = args[0];
string plugin = args.Length > 1 ? args[1] : Path.Combine("plugin", "RunicNPC.cs");
string[] dlls = Directory.GetFiles(managed, "*.dll");
var resolver = new PathAssemblyResolver(dlls);
using var context = new MetadataLoadContext(resolver, "mscorlib");
Assembly rust = context.LoadFromAssemblyPath(Path.Combine(managed, "Assembly-CSharp.dll"));
List<string> Fields(string typeName)
{
Type type = rust.GetType(typeName, throwOnError: true)!;
var names = new List<string>();
for (Type? t = type; t != null && t.FullName != "UnityEngine.MonoBehaviour"; t = t.BaseType)
{
foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (f.IsInitOnly || f.IsLiteral)
continue;
bool serialised = (f.IsPublic && (f.Attributes & FieldAttributes.NotSerialized) == 0) ||
f.GetCustomAttributesData().Any(a => a.AttributeType.FullName == "UnityEngine.SerializeField");
if (!serialised || IsDelegate(f.FieldType))
continue;
names.Add($"{t.Name}.{f.Name}");
}
}
return names;
}
static bool IsDelegate(Type t)
{
for (Type? b = t; b != null; b = b.BaseType)
if (b.FullName == "System.Delegate")
return true;
return false;
}
List<string> npc = Fields("ScientistNPC");
List<string> brain = Fields("ScientistBrain");
Guid mvid = rust.ManifestModule.ModuleVersionId;
const string Open = " // <fieldlist>";
const string Close = " // </fieldlist>";
var block = new StringBuilder();
block.AppendLine(Open);
block.AppendLine($" // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module {mvid}.");
block.AppendLine(" // Do not edit by hand: regenerate after a Rust update (D232).");
Append(block, "SwapNpcFields", npc);
Append(block, "SwapBrainFields", brain);
block.Append(Close);
static void Append(StringBuilder sb, string name, List<string> fields)
{
sb.AppendLine($" private static readonly string[] {name} =");
sb.AppendLine(" {");
foreach (string f in fields)
sb.AppendLine($" \"{f}\",");
sb.AppendLine(" };");
}
string text = File.ReadAllText(plugin);
string nl = text.Contains("\r\n") ? "\r\n" : "\n";
int start = text.IndexOf(Open, StringComparison.Ordinal);
int end = text.IndexOf(Close, StringComparison.Ordinal);
if (start < 0 || end < start)
{
Console.Error.WriteLine($"{plugin}: the marker lines '{Open.Trim()}' and '{Close.Trim()}' were not found.");
return 1;
}
string replacement = block.ToString().Replace("\r\n", "\n").Replace("\n", nl);
File.WriteAllText(plugin, text[..start] + replacement + text[(end + Close.Length)..]);
Console.WriteLine($"{plugin}: {npc.Count} NPC fields, {brain.Count} brain fields (Assembly-CSharp module {mvid}).");
return 0;

49
tools/managed.js Normal file
View File

@@ -0,0 +1,49 @@
// Downloads a rig's managed assemblies (RustDedicated_Data/Managed/*.dll) into a local directory,
// for tools/fieldlist, which reads Rust's own field declarations from them (D232).
//
// node tools/managed.js <rig> <out dir>
//
// Use the CARBON rig. Oxide's patcher rewrites Assembly-CSharp.dll and makes private fields public,
// so the Oxide rig's copy no longer says which fields Rust itself serialises. Carbon leaves the
// assembly as Facepunch shipped it.
const fs = require('fs')
const path = require('path')
const { load, serverId, unmsys } = require('./panel')
const config = load()
const [rig, out] = process.argv.slice(2).map(unmsys)
if (!rig || !out) {
console.error('usage: node tools/managed.js <rig> <out dir>')
process.exit(2)
}
const base = `${config.panel}/api/client/servers/${serverId(config, rig)}`
const headers = { Authorization: 'Bearer ' + config.key, Accept: 'application/json' }
const dir = '/RustDedicated_Data/Managed'
async function main() {
const list = await fetch(`${base}/files/list?directory=${encodeURIComponent(dir)}`, { headers })
if (!list.ok) throw new Error(`list ${list.status}: ${await list.text()}`)
const files = (await list.json()).data
.map((f) => f.attributes)
.filter((f) => f.is_file && f.name.endsWith('.dll'))
fs.mkdirSync(out, { recursive: true })
let bytes = 0
for (const f of files) {
const target = path.join(out, f.name)
if (fs.existsSync(target) && fs.statSync(target).size === f.size) continue
const link = await fetch(`${base}/files/download?file=${encodeURIComponent(`${dir}/${f.name}`)}`, { headers })
if (!link.ok) throw new Error(`${f.name}: ${link.status} ${await link.text()}`)
const body = await fetch((await link.json()).attributes.url)
fs.writeFileSync(target, Buffer.from(await body.arrayBuffer()))
bytes += f.size
}
console.log(`${files.length} assemblies in ${out} (${(bytes / 1e6).toFixed(1)} MB downloaded)`)
}
main().catch((e) => {
console.error(e.message || e)
process.exit(1)
})