feat(drill): the staging drill, the week before each forced wipe (stage 9c, D307-D309, D317-D319)

A Gitea job that checks each new Rust staging build in the seven days before the
monthly forced wipe:

- tools/drill/static.sh: staging's managed assemblies (DepotDownloader) and
  Oxide's staging build; whether Oxide has caught up (fieldlist --oxide-behind),
  the swap's field list compared, and the plugin compiled.
- tools/drill/drill.js: window (D318), gate (one check per depot manifest),
  live (rust-staging runs rnt.run all; rust-carbon is stopped and restarted when
  both 6000-map rigs run, D309) and report (one issue per build, state on the
  drill branch; a build waits up to three days for Oxide, D317).
- tools/drill/rig-start.sh: the rig's startup; installs the branch and Oxide's
  build for it, and refuses to start a mismatched pair.
- RunicNpcTest 0.7.2: stage 5's five fights look for a field with room for their
  280 m line (D319), and the spawn check names what did not spawn.

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-07 01:29:45 -05:00
parent 9b316c6266
commit c25f73c525
9 changed files with 1058 additions and 8 deletions

View File

@@ -45,7 +45,7 @@ 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.7.0")]
[Info("RunicNpcTest", "Runic Gateway", "0.7.2")]
[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
{
@@ -147,11 +147,17 @@ namespace Oxide.Plugins
private static MonumentInfo[] Monuments() => _monuments ?? (_monuments = UnityEngine.Object.FindObjectsOfType<MonumentInfo>());
/// <summary>A flat, dry spot on the navmesh far from every monument; fixed seed, so reruns land in the same field.</summary>
private static bool FindField(float clear, int seed, out Vector3 at)
/// <summary>
/// An open field on the navmesh. With <paramref name="line"/>, the points every <c>line / 2</c> m out to
/// ±<paramref name="line"/> along x, and 25 m past each, must be on it too: stage 5's five fights stand
/// that far apart. On a 6000 map the first field had room; on the staging drill's maps it is rare (D319).
/// </summary>
private static bool FindField(float clear, int seed, out Vector3 at, float line = 0f)
{
var rng = new System.Random(seed);
float half = World.Size / 2f - 300f;
for (int i = 0; i < 4000; i++)
// The line is rare on a small map: 5 samples in 20000 have it on the drill's 3500 map (D319).
for (int i = 0; i < (line > 0f ? 20000 : 4000); i++)
{
var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half);
p.y = TerrainMeta.HeightMap.GetHeight(p);
@@ -168,6 +174,16 @@ namespace Oxide.Plugins
open = SampleNavmesh(hit.position + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * 35f, 2f, out h2);
}
for (int k = -2; k <= 2 && open && line > 0f; k++)
{
Vector3 q = hit.position + new Vector3(k * line / 2f, 0f, 0f);
Vector3 r = q + new Vector3(0f, 0f, 25f);
q.y = TerrainMeta.HeightMap.GetHeight(q);
r.y = TerrainMeta.HeightMap.GetHeight(r);
NavMeshHit h2;
open = SampleNavmesh(q, 3f, out h2) && SampleNavmesh(r, 3f, out h2);
}
if (!open) continue;
at = hit.position;
return true;
@@ -1710,7 +1726,7 @@ namespace Oxide.Plugins
private IEnumerator Stage5()
{
Vector3 f;
if (!FindField(300f, 2025, out f))
if (!FindField(300f, 2025, out f, 140f))
{
Check("s5.setup.field", false, "no second open field on the navmesh");
yield break;
@@ -1755,7 +1771,13 @@ namespace Oxide.Plugins
ScientistNPC far = SpawnScientist(d + new Vector3(0, 0, 25));
BasePlayer victim = Spawn(e, "t_roam");
BasePlayer bully = Spawn(OnMesh(e + new Vector3(0, 0, 15)), "t_bully");
Check("s5.fight.spawned", red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully, "");
var fighters = new Dictionary<string, BaseEntity>
{
{ "red", red }, { "blue", blue }, { "hunter", hunter }, { "prey", prey }, { "neutral", neutral },
{ "bystander", bystander }, { "leashed", leashed }, { "far", far }, { "victim", victim }, { "bully", bully },
};
string[] unspawned = fighters.Where(kv => kv.Value == null).Select(kv => kv.Key).ToArray();
Check("s5.fight.spawned", unspawned.Length == 0, unspawned.Length == 0 ? "" : $"not spawned: {string.Join(", ", unspawned)} (field {f}, map {World.Size})");
if (!(red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully)) yield break;
float farHealth = far.health, bystanderHealth = bystander.health, preyHealth = prey.health;

588
tools/drill/drill.js Normal file
View File

@@ -0,0 +1,588 @@
// The staging drill (docs/runicnpc/PLAN.md stage 9c, D307-D309, D317, D318). Never shipped.
//
// .gitea/workflows/staging-drill.yml runs it daily, and it does something only in the week before the
// monthly forced wipe (D318):
//
// node tools/drill/drill.js window [--today YYYY-MM-DD] in the drill week?
// tools/drill/static.sh the static half
// node tools/drill/drill.js gate --state <drill.json> --work <dir> a build not checked yet?
// node tools/drill/drill.js live --work <dir> [--branch staging] the live half, on rust-staging
// node tools/drill/drill.js report --state <drill.json> --work <dir> one issue per build; the state
//
// A build is the manifest of Rust's Linux depot, as Steam names it to static.sh.
//
// Environment: PANEL_KEY (or PANEL_KEY_FILE, a file with a `user: ptlc_…` line) is the drill's own
// Pterodactyl client key; PANEL_URL the panel. The live half finds its servers by name: DRILL_RIG
// (rust-staging) and the two 6000-map rigs, of which it stops DRILL_STOPPABLE (rust-carbon) when both
// run, because the node has no memory for three (D309). It leaves every rig as it found it.
// `report` needs GITEA_TOKEN, and GITHUB_SERVER_URL + GITHUB_REPOSITORY as Gitea Actions sets them.
//
// The key is never printed, and neither is the server's console start line (it carries the RCON
// password): only lines this script picks are ever written out.
const fs = require('fs')
const path = require('path')
const ROOT = path.resolve(__dirname, '..', '..')
const PANEL = (process.env.PANEL_URL || 'http://192.168.0.12').replace(/\/+$/, '')
const RIG = process.env.DRILL_RIG || 'rust-staging'
const BIG = (process.env.DRILL_BIG_RIGS || 'rust-oxide,rust-carbon').split(',')
const STOPPABLE = process.env.DRILL_STOPPABLE || 'rust-carbon'
const KITS_URL = 'https://umod.org/plugins/Kits.cs'
const WAIT_DAYS = 3 // D317: how long a build waits for Oxide to catch up
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const now = () => new Date().toISOString()
const log = (...a) => console.log(`[drill ${now().slice(11, 19)}]`, ...a)
function args() {
const out = { _: [] }
const argv = process.argv.slice(2)
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const k = argv[i].slice(2)
out[k] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true
} else out._.push(argv[i])
}
return out
}
const readJson = (f, fallback) => {
try {
return JSON.parse(fs.readFileSync(f, 'utf8'))
} catch {
return fallback
}
}
function output(name, value) {
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`)
}
// ---------------------------------------------------------------- the panel
function panelKey() {
if (process.env.PANEL_KEY) return process.env.PANEL_KEY.trim()
const file = process.env.PANEL_KEY_FILE
if (!file) throw new Error('PANEL_KEY (or PANEL_KEY_FILE) is not set')
const line = fs.readFileSync(file, 'utf8').split(/\r?\n/).find((l) => l.startsWith('user:'))
if (!line) throw new Error(`${file} has no "user:" line`)
return line.slice(5).trim()
}
function panel() {
const key = panelKey()
const headers = (type = 'application/json') => ({ Authorization: 'Bearer ' + key, Accept: 'application/json', 'Content-Type': type })
const api = async (method, p, body, type) => {
const r = await fetch(`${PANEL}/api/client${p}`, { method, headers: headers(type), body })
const text = await r.text()
if (!r.ok) throw new Error(`${method} ${p.split('?')[0]}: ${r.status} ${text.slice(0, 200)}`)
return text
}
const p = {
async servers() {
const d = JSON.parse(await api('GET', '/?per_page=100'))
return Object.fromEntries(d.data.map((s) => [s.attributes.name, s.attributes.identifier]))
},
async state(id) {
return JSON.parse(await api('GET', `/servers/${id}/resources`)).attributes.current_state
},
power: (id, signal) => api('POST', `/servers/${id}/power`, JSON.stringify({ signal })),
command: (id, command) => api('POST', `/servers/${id}/command`, JSON.stringify({ command })),
read: (id, file) => api('GET', `/servers/${id}/files/contents?file=${encodeURIComponent(file)}`),
async readOr(id, file, fallback = null) {
try {
return await p.read(id, file)
} catch {
return fallback
}
},
write: (id, file, content) => api('POST', `/servers/${id}/files/write?file=${encodeURIComponent(file)}`, content, 'text/plain'),
async rm(id, file) {
const dir = file.slice(0, file.lastIndexOf('/')) || '/'
try {
await api('POST', `/servers/${id}/files/delete`, JSON.stringify({ root: dir, files: [file.slice(file.lastIndexOf('/') + 1)] }))
} catch {
// already gone
}
},
/** Sends one console command and returns the console lines that follow, for `secs` seconds. */
async console(id, command, secs = 6) {
const { data } = JSON.parse(await api('GET', `/servers/${id}/websocket`))
const strip = (s) => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
return new Promise((resolve) => {
const lines = []
let sent = false
const ws = new WebSocket(data.socket, { headers: { Origin: PANEL } })
const done = () => {
try {
ws.close()
} catch {}
resolve(lines)
}
ws.onopen = () => ws.send(JSON.stringify({ event: 'auth', args: [data.token] }))
ws.onmessage = (m) => {
const msg = JSON.parse(m.data)
if (msg.event === 'auth success' && !sent) {
sent = true
// The panel replays recent history on connect; let it pass, then send.
setTimeout(() => {
lines.length = 0
ws.send(JSON.stringify({ event: 'send command', args: [command] }))
}, 800)
setTimeout(done, secs * 1000)
} else if (msg.event === 'console output' && sent) {
lines.push(...strip(String(msg.args[0])).split('\n'))
}
}
ws.onerror = () => done()
})
},
async waitState(id, want, timeoutMs) {
const end = Date.now() + timeoutMs
while (Date.now() < end) {
if ((await p.state(id)) === want) return true
await sleep(5000)
}
return false
},
/** Stops a server: `stop`, then `kill` if it has not stopped in time. */
async stop(id, graceMs = 180000) {
if ((await p.state(id)) === 'offline') return
await p.power(id, 'stop')
if (!(await p.waitState(id, 'offline', graceMs))) {
await p.power(id, 'kill')
await p.waitState(id, 'offline', 60000)
}
},
}
return p
}
// ---------------------------------------------------------------- window: the week before a forced wipe
// Rust changes on the monthly forced wipe, the first Thursday (D318). The drill runs only in the seven
// days before it, which leaves a week to fix what it finds; outside them the job stops at once.
function nextWipe(today) {
const firstThursday = (y, m) => {
const d = new Date(Date.UTC(y, m, 1))
d.setUTCDate(1 + ((4 - d.getUTCDay() + 7) % 7))
return d
}
const t = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())
const thisMonth = firstThursday(today.getUTCFullYear(), today.getUTCMonth())
return thisMonth.getTime() > t ? thisMonth : firstThursday(today.getUTCFullYear(), today.getUTCMonth() + 1)
}
function windowDays(today = new Date()) {
const t = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())
return Math.round((nextWipe(today).getTime() - t) / 86400000)
}
async function window_(a) {
const days = windowDays(a.today ? new Date(a.today) : new Date())
const wipe = nextWipe(a.today ? new Date(a.today) : new Date()).toISOString().slice(0, 10)
const open = days >= 1 && days <= 7
log(`the next forced wipe is ${wipe}, in ${days} day(s): ${open ? 'in the drill week' : 'outside the drill week, nothing to do'}`)
output('open', open || a.force ? 'true' : 'false')
}
// ---------------------------------------------------------------- gate: is there anything to check?
// A build is Steam's manifest of the Linux depot, which static.sh reads from Steam itself.
async function gate(a) {
const state = readJson(a.state, {})
const stat = readJson(path.join(path.resolve(a.work || 'drill-work'), 'static.json'), {})
const checked = state.checked && state.checked.manifest
const isNew = !stat.manifest || stat.manifest !== checked
log(`Rust ${stat.branch || 'staging'} is manifest ${stat.manifest || '?'} (${stat.manifestDate || '?'}); last checked ${checked || 'none'}`)
output('run', isNew || a.force ? 'true' : 'false')
if (!isNew && !a.force) log('already checked: nothing to do')
}
// ---------------------------------------------------------------- live: the harness on rust-staging
async function live(a) {
const work = path.resolve(a.work || 'drill-work')
const branch = a.branch || 'staging'
const result = { branch, at: now(), ran: false, findings: [], notes: [] }
const save = () => fs.writeFileSync(path.join(work, 'live.json'), JSON.stringify(result, null, 2))
fs.mkdirSync(work, { recursive: true })
const stat = readJson(path.join(work, 'static.json'), null)
if (!stat || stat.managed !== 'ok') {
result.skipped = 'the static half did not run'
save()
return log(result.skipped)
}
if (stat.oxideBehind !== 0) {
result.skipped = `Oxide's ${branch} build is ${stat.oxideBehind} member(s) behind Rust's, so an Oxide server cannot boot on it`
save()
return log(result.skipped)
}
const p = panel()
const ids = await p.servers()
const rig = ids[RIG]
if (!rig) throw new Error(`no server named ${RIG} on the panel`)
let restart = null
const finding = (f) => {
result.findings.push(f)
log('FINDING', f)
}
try {
// Room for it (D309): with both 6000-map rigs running the node's memory is full.
const running = []
for (const name of BIG) if (ids[name] && (await p.state(ids[name])) === 'running') running.push(name)
if (running.length === BIG.length && ids[STOPPABLE]) {
log(`both ${BIG.join(' and ')} are running: saving and stopping ${STOPPABLE}`)
await p.command(ids[STOPPABLE], 'server.save').catch(() => {})
await sleep(15000)
await p.stop(ids[STOPPABLE])
restart = STOPPABLE
result.notes.push(`${STOPPABLE} was stopped for the drill and started again afterwards`)
}
await p.stop(rig, 60000)
// What runs: this commit's plugin and harness, and the startup that installs the branch.
await p.write(rig, '/staging-drill.sh', fs.readFileSync(path.join(__dirname, 'rig-start.sh'), 'utf8').replace(/\r\n/g, '\n'))
await p.write(rig, '/staging-drill.branch', branch + '\n')
await p.write(rig, '/oxide/plugins/RunicNPC.cs', fs.readFileSync(path.join(ROOT, 'plugin', 'RunicNPC.cs'), 'utf8'))
const kits = await fetch(KITS_URL)
if (!kits.ok) throw new Error(`Kits from uMod: ${kits.status}`)
await p.write(rig, '/oxide/plugins/Kits.cs', await kits.text())
await p.rm(rig, '/oxide/plugins/RunicNpcTest.cs')
await p.rm(rig, '/oxide/data/RunicNpcTest.json')
await p.rm(rig, '/staging-drill.json')
log(`starting ${RIG} on ${branch}`)
const started = Date.now()
await p.power(rig, 'start')
const up = await waitRunning(p, rig, 45 * 60000)
result.installed = readJsonText(await p.readOr(rig, '/staging-drill.json', null))
if (!up) {
finding(`The server did not start on Rust ${branch} ${result.installed?.buildid || ''} with Oxide's ${branch} build.`)
result.consoleTail = safeTail(await p.readOr(rig, '/latest.log', ''), 30)
return
}
result.bootMinutes = Math.round((Date.now() - started) / 6000) / 10
log(`running after ${result.bootMinutes} min:`, JSON.stringify(result.installed))
// RunicNPC loaded, and the navmesh ready.
const status = await waitStatus(p, rig, 30 * 60000)
if (!status) {
finding('RunicNPC did not load: `rnpc.status` never answered.')
result.oxideErrors = await oxideErrors(p, rig)
return
}
result.status = status
log('rnpc.status:', status.find((l) => l.includes('navmesh=')))
const fieldsLine = status.find((l) => l.includes('swap fields:')) || ''
const missing = /missing=(\S+)/.exec(fieldsLine)
if (missing && missing[1] !== '-') finding(`The swap's fields are missing on this build: ${missing[1]}`)
if (!fieldsLine.includes('navmesh=ready')) {
finding('The navmesh was not ready in 30 minutes.')
return
}
// The harness needs the hidden Kits kit rnhrevolver (tools/RunicNpcTest.cs).
await ensureKit(p, rig)
await p.write(rig, '/oxide/plugins/RunicNpcTest.cs', fs.readFileSync(path.join(ROOT, 'tools', 'RunicNpcTest.cs'), 'utf8'))
let accepted = false
for (let i = 0; i < 18 && !accepted; i++) {
await sleep(10000)
accepted = (await p.console(rig, 'rnt.run all', 5)).some((l) => l.includes('rnt: running all'))
}
if (!accepted) {
finding('The test harness did not load (`rnt.run all` was not answered).')
result.oxideErrors = await oxideErrors(p, rig)
return
}
result.ran = true
log('rnt.run all: running')
const lines = await waitHarness(p, rig, 45 * 60000)
result.harness = summarise(lines)
if (!result.harness.done) finding(`The harness did not finish in 45 minutes (${result.harness.pass} pass, ${result.harness.fail} fail so far).`)
for (const f of result.harness.failed) finding(`Harness: ${f}`)
log(`harness: ${result.harness.pass} pass, ${result.harness.fail} fail`)
result.oxideErrors = await oxideErrors(p, rig)
if (result.oxideErrors.length) finding(`RunicNPC logged ${result.oxideErrors.length} error line(s) in Oxide's log during the run.`)
await p.command(rig, 'rnt.clear').catch(() => {})
} catch (e) {
finding(`The live half stopped: ${e.message}`)
} finally {
try {
await p.stop(rig)
await p.rm(rig, '/oxide/plugins/RunicNpcTest.cs')
} catch (e) {
result.notes.push(`${RIG} could not be stopped cleanly: ${e.message}`)
}
if (restart) {
try {
await p.power(ids[restart], 'start')
log(`started ${restart} again`)
} catch (e) {
result.findings.push(`${restart} was stopped for the drill and could NOT be started again: ${e.message}`)
}
}
save()
}
}
async function waitRunning(p, id, timeoutMs) {
const end = Date.now() + timeoutMs
let seenStarting = false
while (Date.now() < end) {
const s = await p.state(id)
if (s === 'running') return true
if (s === 'starting') seenStarting = true
// Wings restarts a crashed server a few times; offline after starting is a crash it gave up on.
if (s === 'offline' && seenStarting) return false
await sleep(15000)
}
return false
}
async function waitStatus(p, id, timeoutMs) {
const end = Date.now() + timeoutMs
let last = null
while (Date.now() < end) {
const lines = (await p.console(id, 'rnpc.status', 6)).filter((l) => l.trim())
const at = lines.findIndex((l) => l.startsWith('RunicNPC '))
if (at >= 0) {
last = lines.slice(at, at + 14)
if (last.some((l) => l.includes('navmesh=ready'))) return last
}
await sleep(30000)
}
return last
}
async function ensureKit(p, id) {
const file = '/oxide/data/Kits/kits_data.json'
let data = null
for (let i = 0; i < 12 && !data; i++) {
data = readJsonText(await p.readOr(id, file, null))
if (!data) await sleep(5000)
}
if (!data) throw new Error(`Kits wrote no ${file}`)
data._kits = data._kits || {}
if (data._kits.rnhrevolver) return
const item = (Shortname, Amount, Position, Condition = 0) => ({ Shortname, Skin: 0, Amount, Condition, MaxCondition: Condition, Ammo: 0, Position, Frequency: -1 })
data._kits.rnhrevolver = {
Name: 'rnhrevolver',
Description: 'RunicNPC test harness: revolver + burlap (staging drill)',
RequiredPermission: '',
MaximumUses: 0,
RequiredAuth: 0,
Cooldown: 0,
Cost: 0,
IsHidden: true,
CopyPasteFile: '',
KitImage: '',
MainItems: [item('ammo.pistol', 120, 0)],
WearItems: [item('burlap.shirt', 1, 0), item('burlap.trousers', 1, 1), item('hat.boonie', 1, 2)],
BeltItems: [item('pistol.revolver', 1, 0, 100)],
}
await p.write(id, file, JSON.stringify(data, null, 2))
await p.command(id, 'oxide.reload Kits')
await sleep(10000)
log('added the rnhrevolver kit and reloaded Kits')
}
function readJsonText(t) {
try {
return t ? JSON.parse(t) : null
} catch {
return null
}
}
async function waitHarness(p, id, timeoutMs) {
const end = Date.now() + timeoutMs
let lines = []
while (Date.now() < end) {
await sleep(30000)
lines = readJsonText(await p.readOr(id, '/oxide/data/RunicNpcTest.json', null)) || lines
if (lines.some((l) => /^# done all:/.test(l))) break
}
return lines
}
function summarise(lines) {
const failed = lines.filter((l) => / FAIL( |$)/.test(l))
const passed = lines.filter((l) => / PASS( |$)/.test(l))
const done = lines.find((l) => /^# done all:/.test(l))
return { done: !!done, summary: done || null, pass: passed.length, fail: failed.length, failed }
}
/** RunicNPC's and the harness's error lines from today's Oxide log. */
async function oxideErrors(p, id) {
const day = now().slice(0, 10)
const text = (await p.readOr(id, `/oxide/logs/oxide_${day}.txt`, '')) || ''
return text
.split('\n')
.filter((l) => /RunicNPC|RunicNpcTest/.test(l) && /\[Error\]|Exception|Failed|error CS/.test(l))
.slice(-40)
}
/** The console's last lines, without the startup line (it carries the RCON password). */
function safeTail(text, n) {
return (text || '')
.split('\n')
.filter((l) => !/rcon\.password|\+rcon/i.test(l))
.slice(-n)
}
// ---------------------------------------------------------------- report: one issue per build
async function report(a) {
const work = path.resolve(a.work || 'drill-work')
const statePath = a.state
const state = readJson(statePath, {})
const stat = readJson(path.join(work, 'static.json'), null)
const liv = readJson(path.join(work, 'live.json'), null)
const branch = (stat && stat.branch) || 'staging'
const manifest = (stat && stat.manifest) || ''
const buildid = (liv && liv.installed && liv.installed.buildid) || ''
// How a build is named: Steam's build id when the rig installed it, and always the manifest's date.
const build = `${buildid ? `build ${buildid}, ` : ''}manifest ${manifest || '?'} of ${(stat && stat.manifestDate) || '?'}`
const read = (f, n = 80) => {
try {
return fs.readFileSync(path.join(work, f), 'utf8').replace(/\r/g, '').split('\n').slice(0, n).join('\n').trim()
} catch {
return ''
}
}
const findings = []
const sections = []
let outcome
let markChecked = true
if (!stat || stat.managed !== 'ok' || stat.fields === 'error' || stat.compile === 'error') {
outcome = 'could not run'
markChecked = false
findings.push(`The static half could not run: ${(stat && stat.note) || 'no static.json'}`)
if (stat && stat.fields === 'error') sections.push(['tools/fieldlist', read('fields.diff')])
} else if (stat.oxideBehind !== 0) {
const waiting = state.waiting && state.waiting.manifest === manifest ? state.waiting : { manifest, since: now() }
const days = (Date.now() - Date.parse(waiting.since)) / 86400000
if (days < WAIT_DAYS) {
outcome = `waiting for Oxide (${stat.oxideBehind} behind, day ${Math.floor(days) + 1} of ${WAIT_DAYS})`
if (branch === 'staging') {
state.waiting = { ...waiting, behind: stat.oxideBehind, lastSeen: now() }
markChecked = false
}
} else {
outcome = 'Oxide has not caught up'
findings.push(
`Oxide's staging build is still ${stat.oxideBehind} member(s) behind Rust's staging build after ${WAIT_DAYS} days, ` +
'so an Oxide server cannot boot on it and the live half did not run. The static half ran against Oxide\'s older build.'
)
sections.push(['What Oxide lacks (first 20)', read('oxide-behind.txt', 21)])
}
}
if (!outcome || outcome === 'Oxide has not caught up') {
if (stat.fields === 'differs') {
findings.push("The swap's field list differs from the one the plugin carries (D232): regenerate it with tools/fieldlist and check what changed.")
sections.push(['Field list (carried → staging)', read('fields.diff', 120)])
}
if (stat.compile === 'errors') {
findings.push("RunicNPC does not compile against this build with Oxide's staging build.")
sections.push(['Compiler output', read('compile.txt', 60)])
}
if (!liv && stat.oxideBehind === 0) findings.push('The live half did not run: see the job log (is PTERODACTYL_DRILL_KEY set?).')
if (liv && !liv.skipped) {
findings.push(...liv.findings)
if (liv.consoleTail && liv.consoleTail.length) sections.push(['Console, last lines', liv.consoleTail.join('\n')])
if (liv.oxideErrors && liv.oxideErrors.length) sections.push(["Oxide's log (RunicNPC lines)", liv.oxideErrors.join('\n')])
if (liv.status) sections.push(['rnpc.status', liv.status.join('\n')])
}
if (!outcome) outcome = findings.length ? 'fail' : 'pass'
if (branch === 'staging' && markChecked) delete state.waiting
}
const installed = liv && liv.installed ? `Rust ${liv.installed.branch} ${liv.installed.buildid}, Oxide build of ${liv.installed.oxideModified}` : ''
const lines = [
`Static: Oxide behind ${stat ? stat.oxideBehind : '?'}, fields ${stat ? stat.fields : '?'}, compile ${stat ? stat.compile : '?'}.`,
liv ? (liv.skipped ? `Live: skipped (${liv.skipped}).` : `Live: ${liv.harness ? `${liv.harness.pass} pass, ${liv.harness.fail} fail` : 'did not reach the harness'}${installed ? `, on ${installed}` : ''}${liv.bootMinutes ? `, booted in ${liv.bootMinutes} min` : ''}.`) : 'Live: did not run.',
]
log(`${build} on ${branch}: ${outcome}`)
lines.forEach((l) => log(l))
findings.forEach((f) => log('-', f))
if (process.env.GITHUB_STEP_SUMMARY) {
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Staging drill: ${build}: ${outcome}\n\n${lines.join('\n\n')}\n\n${findings.map((f) => `- ${f}`).join('\n')}\n`)
}
// A rehearsal on another branch reports here and changes nothing.
if (branch !== 'staging') return log('not the staging branch: no issue, no state')
let issue = null
if (findings.length) {
const body = [
`The staging drill (docs/runicnpc/PLAN.md stage 9c) checked Rust's staging branch (**${build}**) on ${now().slice(0, 10)}: **${outcome}**.`,
'',
...findings.map((f) => `- ${f}`),
'',
...lines,
...sections.flatMap(([title, text]) => (text ? ['', `### ${title}`, '', '```', text, '```'] : [])),
'',
process.env.GITHUB_RUN_NUMBER ? `Run: ${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_NUMBER}` : '',
].join('\n')
issue = await fileIssue(`Staging drill: Rust staging manifest ${manifest} (${(stat && stat.manifestDate) || '?'})`, body)
}
if (markChecked) state.checked = { manifest, buildid, at: now(), outcome, issue, oxideBehind: stat ? stat.oxideBehind : null }
state.lastRun = { at: now(), manifest, buildid, outcome, issue }
fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n')
output('outcome', outcome)
}
async function fileIssue(title, body) {
const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}`
const headers = { Authorization: 'token ' + process.env.GITEA_TOKEN, 'Content-Type': 'application/json', Accept: 'application/json' }
const call = async (method, p, data) => {
const r = await fetch(base + p, { method, headers, body: data ? JSON.stringify(data) : undefined })
if (!r.ok) throw new Error(`${method} ${p}: ${r.status} ${(await r.text()).slice(0, 200)}`)
return r.json()
}
const open = await call('GET', `/issues?state=open&type=issues&labels=staging-drill&limit=50`)
const same = open.find((i) => i.title === title)
if (same) {
await call('POST', `/issues/${same.number}/comments`, { body })
log(`commented on #${same.number}`)
return same.number
}
const labels = await call('GET', '/labels?limit=100')
let label = labels.find((l) => l.name === 'staging-drill')
if (!label) label = await call('POST', '/labels', { name: 'staging-drill', color: '#d93f0b', description: "Rust's staging branch breaks RunicNPC (the daily staging drill)" })
const created = await call('POST', '/issues', { title, body, labels: [label.id] })
log(`opened #${created.number}`)
return created.number
}
// ---------------------------------------------------------------- main
const a = args()
const commands = { window: window_, gate, live, report }
const command = commands[a._[0]]
if (!command) {
console.error('usage: node tools/drill/drill.js window|gate|live|report [--state f] [--work dir] [--branch b] [--force]')
process.exit(2)
}
command(a).catch((e) => {
console.error(e.message || e)
process.exit(1)
})

68
tools/drill/rig-start.sh Normal file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# The staging drill rig's startup (docs/runicnpc/PLAN.md D307). Never shipped.
#
# rust-staging runs the published egg in its vanilla mode. That image's entrypoint always runs
# `app_update 258550` before the startup, and the egg cannot be told otherwise, so the server's
# startup is this script in front of the egg's own command line:
#
# bash ./staging-drill.sh ./RustDedicated -batchmode …
#
# It moves the install to a Steam branch, lays Oxide's build for that branch over it, writes down what
# it installed (staging-drill.json), and becomes the server. The branch is `staging` unless the file
# staging-drill.branch names another (`public` rehearses the drill on a pair known to match).
# tools/drill/drill.js writes this file to the rig as /staging-drill.sh before every start, so the
# copy in git is the one that runs.
#
# Steam keeps the branch: the entrypoint's own app_update, which names none, then answers "already
# up to date" and leaves a staging install on staging (seen 2026-10-06).
cd /home/container || exit 1
branch=staging
if [ -s staging-drill.branch ]; then branch=$(tr -dc 'a-z0-9-' < staging-drill.branch); fi
if [ "$branch" = public ]; then
OXIDE_URL=https://github.com/OxideMod/Oxide.Rust/releases/latest/download/Oxide.Rust-linux.zip
else
OXIDE_URL=https://downloads.oxidemod.com/artifacts/Oxide.Rust/$branch/Oxide.Rust-linux.zip
fi
# The branch Steam has INSTALLED: MountedConfig's key (UserConfig's is only the one asked for). The
# public branch has no key.
mounted() {
sed -n '/"MountedConfig"/,/}/s/^[[:space:]]*"BetaKey"[[:space:]]*"\([^"]*\)".*/\1/p' steamapps/appmanifest_258550.acf | head -1
}
# A branch switch downloads the whole server (5.4 GB), and one can fail ("UpdateResult" 13 on
# 2026-10-06, the next attempt fine). Rust on one branch with Oxide's build for another dies at boot,
# so the server is not started on a pair that does not match.
want=$branch
[ "$want" = public ] && want=""
for attempt in 1 2; do
echo "[staging-drill] Rust: app_update 258550 -beta $branch (attempt $attempt)"
./steamcmd/steamcmd.sh +force_install_dir /home/container +login anonymous +app_update 258550 -beta "$branch" +quit
installed=$(mounted)
[ "${installed:-public}" = "${want:-public}" ] && break
done
if [ "${installed:-public}" != "${want:-public}" ]; then
echo "[staging-drill] Steam installed '${installed:-public}', not '$branch': not starting"
exit 1
fi
# Oxide's build carries its own patched Assembly-CSharp.dll, so it goes on AFTER Rust.
echo "[staging-drill] Oxide: $OXIDE_URL"
oxide_modified=$(curl -sSIL "$OXIDE_URL" | tr -d '\r' | sed -n 's/^[Ll]ast-[Mm]odified: //p' | tail -1)
if curl -sSL --fail -o oxide-drill.zip "$OXIDE_URL" && unzip -o -q oxide-drill.zip; then
oxide_ok=true
else
oxide_ok=false
echo "[staging-drill] Oxide's $branch build could not be installed"
fi
rm -f oxide-drill.zip
buildid=$(sed -n 's/^[[:space:]]*"buildid"[[:space:]]*"\([0-9]*\)".*/\1/p' steamapps/appmanifest_258550.acf | head -1)
printf '{"buildid":"%s","branch":"%s","oxideModified":"%s","oxideInstalled":%s,"at":"%s"}\n' \
"$buildid" "${installed:-public}" "$oxide_modified" "$oxide_ok" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > staging-drill.json
echo "[staging-drill] $(cat staging-drill.json)"
if [ "$oxide_ok" != true ]; then exit 1; fi
exec "$@"

145
tools/drill/static.sh Normal file
View File

@@ -0,0 +1,145 @@
#!/bin/bash
# The staging drill's static half (docs/runicnpc/PLAN.md D308). Never shipped.
#
# tools/drill/static.sh (WORK=drill-work BRANCH=staging by default)
#
# Needs no server. It downloads only RustDedicated_Data/Managed/*.dll from a Steam branch, and
# Oxide's build for that branch, and:
#
# 1. asks whether Oxide's build has caught up with Rust's (tools/fieldlist --oxide-behind). Until it
# has, an Oxide server on that branch dies at boot, so the drill waits for it (D317);
# 2. regenerates the swap's field list (tools/fieldlist, D232) from Rust's own Assembly-CSharp.dll
# and compares it with the one plugin/RunicNPC.cs carries;
# 3. compiles plugin/RunicNPC.cs against those assemblies with Oxide's laid over them, as an Oxide
# server would. Oxide's patched Assembly-CSharp.dll is needed: the plugin uses members Rust
# ships non-public (IAISenses, for one).
#
# It writes $WORK/static.json and, beside it, oxide-behind.txt, fields.diff and compile.txt. In
# static.json, oxideBehind is a count (-1: not measured), fields "same"/"differs", compile
# "ok"/"errors", and either is "error" when its step could not run. The script exits 0 whenever it
# ran; what it found is in static.json. DEPOTDOWNLOADER and DOTNET point at other copies (on
# Windows, DEPOTDOWNLOADER=…/DepotDownloader.exe from Git Bash).
set -uo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
WORK=${WORK:-$ROOT/drill-work}
BRANCH=${BRANCH:-staging}
if [ "$BRANCH" = public ]; then
OXIDE_URL=${OXIDE_URL:-https://github.com/OxideMod/Oxide.Rust/releases/latest/download/Oxide.Rust-linux.zip}
else
OXIDE_URL=${OXIDE_URL:-https://downloads.oxidemod.com/artifacts/Oxide.Rust/$BRANCH/Oxide.Rust-linux.zip}
fi
DD_URL=https://github.com/SteamRE/DepotDownloader/releases/download/DepotDownloader_3.4.0/DepotDownloader-linux-x64.zip
DOTNET=${DOTNET:-dotnet}
mkdir -p "$WORK"
rm -rf "$WORK/depot" "$WORK/oxide" "$WORK/refs" "$WORK/fields"
rm -f "$WORK"/static.json "$WORK"/oxide-behind.txt "$WORK"/fields.diff "$WORK"/compile.txt
managed=error
behind=-1
fields=error
compile=error
note=""
oxide_modified=""
manifest=""
manifest_date=""
finish() {
printf '{"branch":"%s","manifest":"%s","manifestDate":"%s","managed":"%s","oxideBehind":%s,"fields":"%s","compile":"%s","oxideModified":"%s","note":"%s"}\n' \
"$BRANCH" "$manifest" "$manifest_date" "$managed" "$behind" "$fields" "$compile" "$oxide_modified" "$note" > "$WORK/static.json"
cat "$WORK/static.json"
exit 0
}
# Git Bash does not translate paths inside a response file; elsewhere this is the path as it is.
native() { if command -v cygpath > /dev/null; then cygpath -m "$1"; else printf '%s' "$1"; fi; }
# ---- Rust's managed assemblies, from Steam (no login: the dedicated server is anonymous) ----
DD=${DEPOTDOWNLOADER:-}
if [ -z "$DD" ]; then
curl -sSL --fail -o "$WORK/dd.zip" "$DD_URL" && unzip -oq "$WORK/dd.zip" -d "$WORK/dd" && chmod +x "$WORK/dd/DepotDownloader" \
|| { note="DepotDownloader could not be fetched"; finish; }
DD=$WORK/dd/DepotDownloader
fi
printf 'regex:^RustDedicated_Data/Managed/[^/]+\\.dll$\n' > "$WORK/files.txt"
"$DD" -app 258550 -depot 258552 -branch "$BRANCH" -filelist "$WORK/files.txt" -dir "$WORK/depot" -os linux \
> "$WORK/depot.txt" 2>&1
MANAGED=$WORK/depot/RustDedicated_Data/Managed
# Which build this is, from Steam itself: the Linux depot's manifest and its date. (api.steamcmd.net
# answered a build id a day stale on 2026-10-06, so the drill does not ask it.)
manifest=$(tr -d '\r' < "$WORK/depot.txt" | sed -n 's/^Manifest \([0-9]*\) (\(.*\))$/\1/p' | head -1)
manifest_date=$(tr -d '\r' < "$WORK/depot.txt" | sed -n 's/^Manifest \([0-9]*\) (\(.*\))$/\2/p' | head -1)
if [ ! -f "$MANAGED/Assembly-CSharp.dll" ]; then
note="no Assembly-CSharp.dll from Steam's $BRANCH branch: $(tail -3 "$WORK/depot.txt" | tr '\n"' ' ')"
finish
fi
managed=ok
# ---- Oxide's build for the branch ----
oxide_modified=$(curl -sSIL "$OXIDE_URL" | tr -d '\r' | sed -n 's/^[Ll]ast-[Mm]odified: //p' | tail -1)
if ! { curl -sSL --fail -o "$WORK/oxide.zip" "$OXIDE_URL" && unzip -oq "$WORK/oxide.zip" -d "$WORK/oxide"; }; then
note="Oxide's $BRANCH build could not be downloaded"
finish
fi
OXIDE_MANAGED=$WORK/oxide/RustDedicated_Data/Managed
# The tool is built once, here, so a build failure is not mistaken for anything it reports.
if ! "$DOTNET" build "$ROOT/tools/fieldlist" -c Release -o "$WORK/fieldlist" > "$WORK/fieldlist-build.txt" 2>&1; then
note="tools/fieldlist did not build: $(grep -m1 'error' "$WORK/fieldlist-build.txt" | tr '"' ' ')"
finish
fi
FIELDLIST=("$DOTNET" "$WORK/fieldlist/FieldList.dll")
# ---- 1. Has Oxide caught up? ----
if "${FIELDLIST[@]}" --oxide-behind "$MANAGED" "$OXIDE_MANAGED/Assembly-CSharp.dll" > "$WORK/oxide-behind.txt" 2>&1; then
behind=$(sed -n 's/^behind \([0-9]*\)$/\1/p' "$WORK/oxide-behind.txt" | head -1)
behind=${behind:--1}
fi
# ---- 2. The swap's field list (D232) ----
mkdir -p "$WORK/fields"
cp "$ROOT/plugin/RunicNPC.cs" "$WORK/fields/RunicNPC.cs"
block() { sed -n '/\/\/ <fieldlist>/,/\/\/ <\/fieldlist>/p' "$1" | grep -v "Generated by tools/fieldlist" | tr -d '\r'; }
if "${FIELDLIST[@]}" "$MANAGED" "$WORK/fields/RunicNPC.cs" > "$WORK/fields/run.txt" 2>&1; then
if diff -u --label carried <(block "$ROOT/plugin/RunicNPC.cs") --label "$BRANCH" <(block "$WORK/fields/RunicNPC.cs") > "$WORK/fields.diff"; then
fields=same
else
fields=differs
fi
else
cp "$WORK/fields/run.txt" "$WORK/fields.diff"
fi
# ---- 3. Compile against the branch with Oxide's build laid over ----
mkdir -p "$WORK/refs"
cp "$MANAGED"/*.dll "$WORK/refs/"
cp "$OXIDE_MANAGED"/*.dll "$WORK/refs/"
# Oxide.References.dll bundles its own older copy of Newtonsoft.Json, which Oxide's compiler never
# references; with it, every JObject is ambiguous.
rm -f "$WORK/refs/Oxide.References.dll"
DOTNET_DIR=$(dirname "$(readlink -f "$(command -v "$DOTNET")")")
CSC=$(ls "$DOTNET_DIR"/sdk/*/Roslyn/bincore/csc.dll 2>/dev/null | tail -1)
if [ -z "$CSC" ]; then
note="no csc.dll under $DOTNET_DIR/sdk"
finish
fi
# A response file: some 250 references outgrow a Windows command line.
for f in "$WORK/refs"/*.dll; do printf '"-r:%s"\n' "$(native "$f")"; done > "$WORK/refs.rsp"
if "$DOTNET" "$CSC" -nologo -noconfig -nostdlib -target:library -nowarn:1701,1702 -warn:0 \
-out:"$WORK/RunicNPC.dll" "@$WORK/refs.rsp" "$ROOT/plugin/RunicNPC.cs" > "$WORK/compile.txt" 2>&1; then
compile=ok
else
compile=errors
fi
finish

View File

@@ -12,12 +12,28 @@
// 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.
//
// The staging drill (D308) also asks it whether Oxide's build has caught up with Rust's:
//
// dotnet run --project tools/fieldlist -- --oxide-behind <managed dir> <Oxide's Assembly-CSharp.dll>
//
// Oxide ships its own patched Assembly-CSharp.dll, which replaces Rust's. Built from an older Rust,
// it lacks what Rust has added since, and the server dies at boot ("The referenced script
// (ItemModFoodVisual) on this Behaviour is missing!"). Oxide's patcher only ever adds, so every
// type and member Rust's own assembly declares, compiler-generated ones aside, must be in Oxide's.
// It prints "behind <n>" and the first of them; 0 means caught up. Measured 2026-10-06: public Rust
// and public Oxide, 0; staging Rust 25766353 and Oxide's staging build of 2026-10-05, 482.
using System.Reflection;
using System.Text;
if (args.Length == 3 && args[0] == "--oxide-behind")
return OxideBehind(args[1], args[2]);
if (args.Length < 1)
{
Console.Error.WriteLine("usage: dotnet run --project tools/fieldlist -- <managed dir> [plugin/RunicNPC.cs]");
Console.Error.WriteLine(" dotnet run --project tools/fieldlist -- --oxide-behind <managed dir> <Oxide's Assembly-CSharp.dll>");
return 2;
}
@@ -96,3 +112,28 @@ 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;
static int OxideBehind(string managed, string oxideAssembly)
{
HashSet<string> Declared(string assembly)
{
IEnumerable<string> others = Directory.GetFiles(managed, "*.dll").Where(f => Path.GetFileName(f) != "Assembly-CSharp.dll");
using var context = new MetadataLoadContext(new PathAssemblyResolver(others.Append(assembly)), "mscorlib");
var names = new HashSet<string>();
foreach (Type t in context.LoadFromAssemblyPath(assembly).GetTypes())
{
names.Add(t.FullName!);
foreach (MemberInfo m in t.GetMembers(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
names.Add($"{t.FullName}::{m.Name}");
}
// Compiler-generated names (lambdas, closures, iterators) are renumbered by every build.
names.RemoveWhere(n => n.Contains('<'));
return names;
}
List<string> missing = Declared(Path.Combine(managed, "Assembly-CSharp.dll")).Except(Declared(oxideAssembly)).OrderBy(n => n).ToList();
Console.WriteLine($"behind {missing.Count}");
foreach (string n in missing.Take(20))
Console.WriteLine(" " + n);
return 0;
}

View File

@@ -3,6 +3,7 @@
"tokenFile": "/path/to/pterodactyl_api_token",
"rigs": {
"oxide": "00000000",
"carbon": "00000000"
"carbon": "00000000",
"staging": "00000000"
}
}