fix(drill): staging 26341433's swap fields; a failed group no longer ends the harness (#20)

The staging drill's run 47 failed on Rust staging 26341433:

- Rust added BasePlayer.timeSinceAiming, BaseEntity.ServerPreProcessed and
  BaseNetworkable.ignoreNetworkLod to the swap's field list (D232): 55 -> 58.
  On edge now; main at the forced wipe of 2026-11-05.
- Harness 0.7.4: place.set.each was refused and the refusal was thrown
  away; place.spawned failed; reading each[0] of an empty list then killed
  the coroutine, so the drill waited 45 minutes for a run that had stopped
  after 10. Place puts its spots on the navmesh, the set checks carry the
  refusal, a failed place.spawned ends the group, and any group that throws
  is a <group>.exception FAIL line and the run goes on.
- drill.js: rust-staging boots from empty Kits and RunicNPC data, so the
  write-once example kits match this Rust (s6.examples.fullCondition); the
  wait ends after 5 minutes without a new harness line; latest.log's tail
  goes into the issue when the harness did not finish. drill.test.js
  covers the stall logic.

Refs #20, #21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-11-01 01:30:10 -06:00
parent 27bafedb03
commit bad19a668d
5 changed files with 167 additions and 28 deletions

View File

@@ -98,7 +98,7 @@ The API version moves when a call or a raised hook changes shape, not on every r
## After a Rust update: the swap's field list ## 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), 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=55/55 brain=32/32 generated from Rust's unmodified assembly. `rnpc.status` reports it as `swap fields: npc=58/58 brain=32/32
missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since
the list was made. To regenerate it: the list was made. To regenerate it:
@@ -121,7 +121,7 @@ shows it, so the drill's live half and `rnpc.status` are what catch it.
| Rust type | What RunicNPC depends on | Where (`plugin/RunicNPC.cs`) | Caught by | | Rust type | What RunicNPC depends on | Where (`plugin/RunicNPC.cs`) | Caught by |
|---|---|---|---| |---|---|---|---|
| `ScientistNPC` (prefabs `scientistnpc_*`) | Subclassed by `RunicNpcPlayer`; the swap replaces the prefab's component before spawn and copies `SwapNpcFields` (55 fields up its chain to `BaseNetworkable`) | `#region The swap`, `CreateNpc` | compile; field list | | `ScientistNPC` (prefabs `scientistnpc_*`) | Subclassed by `RunicNpcPlayer`; the swap replaces the prefab's component before spawn and copies `SwapNpcFields` (58 fields up its chain to `BaseNetworkable`) | `#region The swap`, `CreateNpc` | compile; field list |
| `ScientistBrain` | Subclassed by `RunicNpcBrain`; the swap copies `SwapBrainFields` (32 fields of `BaseAIBrain`) | `#region The swap` | compile; field list | | `ScientistBrain` | Subclassed by `RunicNpcBrain`; the swap copies `SwapBrainFields` (32 fields of `BaseAIBrain`) | `#region The swap` | compile; field list |
| `ScientistNPC` / `HumanNPC` virtuals | `displayName`, `AttackerInfo(PlayerLifeStory.DeathInfo)`, `ShotTest(float)`, `TriggerDown()`, `Hurt(HitInfo)`, `OnDied(HitInfo)` overridden | `RunicNpcPlayer` | compile | | `ScientistNPC` / `HumanNPC` virtuals | `displayName`, `AttackerInfo(PlayerLifeStory.DeathInfo)`, `ShotTest(float)`, `TriggerDown()`, `Hurt(HitInfo)`, `OnDied(HitInfo)` overridden | `RunicNpcPlayer` | compile |
| `IAIAttack`, `IAISenses` | Re-implemented on `RunicNpcPlayer`: `IsTargetInRange`, `EngagementRange`, `GetBestTarget`, `AttackTick`, `IsTarget`, `IsThreat`, `IsFriendly`. Rust's `HumanNPC.IsTarget` is not virtual, which is why | `RunicNpcPlayer` | compile; a changed meaning only at run time | | `IAIAttack`, `IAISenses` | Re-implemented on `RunicNpcPlayer`: `IsTargetInRange`, `EngagementRange`, `GetBestTarget`, `AttackTick`, `IsTarget`, `IsThreat`, `IsFriendly`. Rust's `HumanNPC.IsTarget` is not virtual, which is why | `RunicNpcPlayer` | compile; a changed meaning only at run time |
@@ -153,8 +153,10 @@ the week. A run by hand always goes ahead.
plugin against them. plugin against them.
- **Live, once per build** (`tools/drill/drill.js live`): the `rust-staging` server (Oxide, a 3500 map) - **Live, once per build** (`tools/drill/drill.js live`): the `rust-staging` server (Oxide, a 3500 map)
installs the same pair at start (`tools/drill/rig-start.sh`), loads RunicNPC, Kits and installs the same pair at start (`tools/drill/rig-start.sh`), loads RunicNPC, Kits and
`tools/RunicNpcTest.cs`, and runs `rnt.run all`. When both 6000-map rigs are running, `rust-carbon` is `tools/RunicNpcTest.cs`, and runs `rnt.run all`. It starts the server from empty Kits and RunicNPC data
stopped for it and started again. (their files are deleted first, so the example kits are written afresh) and gives up on the harness after
5 minutes with no new output (45 minutes at most), putting the tail of the server's log in the issue. When
both 6000-map rigs are running, `rust-carbon` is stopped for it and started again.
- **Report:** anything that fails or differs opens one issue for that build, labelled `staging-drill`. - **Report:** anything that fails or differs opens one issue for that build, labelled `staging-drill`.
The last build checked is kept on the `drill` branch. The last build checked is kept on the `drill` branch.

View File

@@ -947,6 +947,7 @@ namespace Oxide.Plugins
"BasePlayer.debugCommandedNpcs", "BasePlayer.debugCommandedNpcs",
"BasePlayer.GestureViewModel", "BasePlayer.GestureViewModel",
"BasePlayer.lastPlayerVisibility", "BasePlayer.lastPlayerVisibility",
"BasePlayer.timeSinceAiming",
"BasePlayer.eyeHistory", "BasePlayer.eyeHistory",
"BasePlayer.tickHistory", "BasePlayer.tickHistory",
"BasePlayer.fallDamageEffect", "BasePlayer.fallDamageEffect",
@@ -974,12 +975,14 @@ namespace Oxide.Plugins
"BaseEntity.model", "BaseEntity.model",
"BaseEntity.flags", "BaseEntity.flags",
"BaseEntity.HasBrain", "BaseEntity.HasBrain",
"BaseEntity.ServerPreProcessed",
"BaseNetworkable.postNetworkUpdateComponents", "BaseNetworkable.postNetworkUpdateComponents",
"BaseNetworkable.prefabID", "BaseNetworkable.prefabID",
"BaseNetworkable.globalBroadcast", "BaseNetworkable.globalBroadcast",
"BaseNetworkable.globalNetworkBehavior", "BaseNetworkable.globalNetworkBehavior",
"BaseNetworkable.globalBuildingBlock", "BaseNetworkable.globalBuildingBlock",
"BaseNetworkable.networkRange", "BaseNetworkable.networkRange",
"BaseNetworkable.ignoreNetworkLod",
}; };
private static readonly string[] SwapBrainFields = private static readonly string[] SwapBrainFields =
{ {

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. /// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage.
/// </para> /// </para>
/// </summary> /// </summary>
[Info("RunicNpcTest", "Runic Gateway", "0.7.3")] [Info("RunicNpcTest", "Runic Gateway", "0.7.4")]
[Description("RunicNPC stage 2 to 7 tests, the stage 6 spike and stage 9's cost check. A developer tool: never ship it.")] [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 internal class RunicNpcTest : RustPlugin
{ {
@@ -753,7 +753,25 @@ namespace Oxide.Plugins
continue; continue;
} }
while (inner.MoveNext()) yield return inner.Current; // A group that throws is a FAIL line, not the end of the run: yield cannot sit inside a try, so
// each step is taken inside one and the value is yielded outside it.
while (true)
{
object step;
try
{
if (!inner.MoveNext()) break;
step = inner.Current;
}
catch (Exception e)
{
Check($"{g}.exception", false, $"{e.GetType().Name}: {e.Message} at {(e.StackTrace ?? "").Split('\n').FirstOrDefault()?.Trim()}");
break;
}
yield return step;
}
Api<int>("RunicNpc_DespawnOwner", Owner); Api<int>("RunicNpc_DespawnOwner", Owner);
ClearWorld(); ClearWorld();
yield return new WaitForSeconds(1f); yield return new WaitForSeconds(1f);
@@ -1095,16 +1113,27 @@ namespace Oxide.Plugins
private IEnumerator Place(Vector3 field) private IEnumerator Place(Vector3 field)
{ {
Vector3 a = field + new Vector3(-40, 0, 0); // A roamer's spot must be on the navmesh when it is set (D219), so both spots are put there.
Vector3 b = field + new Vector3(-40, 0, 30); Vector3 a = OnMesh(field + new Vector3(-40, 0, 0));
StandIn(field + new Vector3(-40, 0, 110), 8); Vector3 b = OnMesh(field + new Vector3(-40, 0, 30));
Check("place.set.each", Api<string>("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each")) == null, ""); StandIn(OnMesh(field + new Vector3(-40, 0, 110)), 8);
Check("place.set.group", Api<string>("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group")) == null, ""); string eachRefusal = Api<string>("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each"));
Check("place.set.each", eachRefusal == null, eachRefusal ?? "");
string groupRefusal = Api<string>("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group"));
Check("place.set.group", groupRefusal == null, groupRefusal ?? "");
yield return new WaitForSeconds(4f); yield return new WaitForSeconds(4f);
List<Dictionary<string, object>> each = List("placement:t_each"); List<Dictionary<string, object>> each = List("placement:t_each");
List<Dictionary<string, object>> group = List("placement:t_group"); List<Dictionary<string, object>> group = List("placement:t_group");
Check("place.spawned", each.Count == 3 && group.Count == 3, $"each {each.Count}, group {group.Count}"); Check("place.spawned", each.Count == 3 && group.Count == 3, $"each {each.Count}, group {group.Count}");
if (each.Count != 3 || group.Count != 3)
{
// Everything below needs both sets alive. Take back what was set and end the group, so the run goes on.
Api<bool>("RunicNpc_RemovePlacement", "t_each");
Api<bool>("RunicNpc_RemovePlacement", "t_group");
Note("place: the placements did not spawn, so the rest of the group is skipped");
yield break;
}
// each: kill one; the other two stay, and it alone returns after ~20 s. // each: kill one; the other two stay, and it alone returns after ~20 s.
var survivors = new HashSet<ulong>(each.Skip(1).Select(x => (ulong)x["netId"])); var survivors = new HashSet<ulong>(each.Skip(1).Select(x => (ulong)x["netId"]));

View File

@@ -30,6 +30,20 @@ const BIG = (process.env.DRILL_BIG_RIGS || 'rust-oxide,rust-carbon').split(',')
const STOPPABLE = process.env.DRILL_STOPPABLE || 'rust-carbon' const STOPPABLE = process.env.DRILL_STOPPABLE || 'rust-carbon'
const KITS_URL = 'https://umod.org/plugins/Kits.cs' 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 WAIT_DAYS = 3 // D317: how long a build waits for Oxide to catch up
const HARNESS_CAP_MS = 45 * 60000 // the whole harness run
const HARNESS_STALL_MS = 5 * 60000 // no new line in its file for this long: it has stopped (issue #20)
// What a fresh server would not have (issue #20). RunicNPC writes its four example kits once, into
// Kits' own file, and never again, so a file kept from an older build keeps that build's kits; the drill
// starts from none of it. Files, never directories: a directory the panel's file API creates is not
// writable by the game (docs/runicnpc/PLAN.md §1.5), and the game makes these itself on its first boot.
const FRESH_DATA = [
'/oxide/data/Kits/kits_data.json',
'/oxide/data/RunicNPC/profiles.json',
'/oxide/data/RunicNPC/placements.json',
'/oxide/data/RunicNPC/routes.json',
'/oxide/data/RunicNPC/state.json',
]
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const now = () => new Date().toISOString() const now = () => new Date().toISOString()
@@ -262,6 +276,7 @@ async function live(a) {
await p.rm(rig, '/oxide/plugins/RunicNpcTest.cs') await p.rm(rig, '/oxide/plugins/RunicNpcTest.cs')
await p.rm(rig, '/oxide/data/RunicNpcTest.json') await p.rm(rig, '/oxide/data/RunicNpcTest.json')
await p.rm(rig, '/staging-drill.json') await p.rm(rig, '/staging-drill.json')
for (const f of FRESH_DATA) await p.rm(rig, f) // already gone is fine: rm swallows it
log(`starting ${RIG} on ${branch}`) log(`starting ${RIG} on ${branch}`)
const started = Date.now() const started = Date.now()
@@ -310,9 +325,13 @@ async function live(a) {
result.ran = true result.ran = true
log('rnt.run all: running') log('rnt.run all: running')
const lines = await waitHarness(p, rig, 45 * 60000) const { lines, ended } = await waitHarness(p, rig, HARNESS_CAP_MS, HARNESS_STALL_MS)
result.harness = summarise(lines) 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).`) if (!result.harness.done) {
finding(unfinishedFinding(result.harness, lines, ended))
// What the server said while it stalled (an exception in the harness's coroutine lands here, not in its file).
result.consoleTail = safeTail(await p.readOr(rig, '/latest.log', ''), 40)
}
for (const f of result.harness.failed) finding(`Harness: ${f}`) for (const f of result.harness.failed) finding(`Harness: ${f}`)
log(`harness: ${result.harness.pass} pass, ${result.harness.fail} fail`) log(`harness: ${result.harness.pass} pass, ${result.harness.fail} fail`)
result.oxideErrors = await oxideErrors(p, rig) result.oxideErrors = await oxideErrors(p, rig)
@@ -408,15 +427,38 @@ function readJsonText(t) {
} }
} }
async function waitHarness(p, id, timeoutMs) { /**
const end = Date.now() + timeoutMs * Where the harness stands, given its lines now and what was last seen of them ({ count, at }): done
* (`# done all:`), stalled (no new line for stallMs: it died without saying so, as in issue #20), or running.
*/
function harnessProgress(lines, seen, nowMs, stallMs) {
if (lines.some((l) => /^# done all:/.test(l))) return { state: 'done', seen }
if (lines.length > seen.count) return { state: 'running', seen: { count: lines.length, at: nowMs } }
if (nowMs - seen.at >= stallMs) return { state: 'stalled', seen }
return { state: 'running', seen }
}
/** Waits for the harness; `ended` is done, stalled or capped (timeoutMs passed). */
async function waitHarness(p, id, timeoutMs, stallMs) {
const start = Date.now()
let lines = [] let lines = []
while (Date.now() < end) { let seen = { count: 0, at: start }
while (Date.now() < start + timeoutMs) {
await sleep(30000) await sleep(30000)
lines = readJsonText(await p.readOr(id, '/oxide/data/RunicNpcTest.json', null)) || lines lines = readJsonText(await p.readOr(id, '/oxide/data/RunicNpcTest.json', null)) || lines
if (lines.some((l) => /^# done all:/.test(l))) break const v = harnessProgress(lines, seen, Date.now(), stallMs)
seen = v.seen
if (v.state !== 'running') return { lines, ended: v.state }
} }
return lines return { lines, ended: 'capped' }
}
/** The finding for a harness that never wrote `# done all:`. */
function unfinishedFinding(h, lines, ended) {
const so = `${h.pass} pass, ${h.fail} fail so far`
if (ended !== 'stalled') return `The harness did not finish in ${HARNESS_CAP_MS / 60000} minutes (${so}).`
const last = (lines[lines.length - 1] || 'no line at all').replace(/`/g, "'").slice(0, 200)
return `The harness stopped after \`${last}\` (no output for ${HARNESS_STALL_MS / 60000} minutes; ${so}).`
} }
function summarise(lines) { function summarise(lines) {
@@ -578,14 +620,19 @@ async function fileIssue(title, body) {
// ---------------------------------------------------------------- main // ---------------------------------------------------------------- main
const a = args() // The pure parts, for drill.test.js; run as a script, it is the command line.
const commands = { window: window_, gate, live, report } module.exports = { harnessProgress, unfinishedFinding, FRESH_DATA, HARNESS_STALL_MS }
const command = commands[a._[0]]
if (!command) { if (require.main === module) {
console.error('usage: node tools/drill/drill.js window|gate|live|report [--state f] [--work dir] [--branch b] [--force]') const a = args()
process.exit(2) 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)
})
} }
command(a).catch((e) => {
console.error(e.message || e)
process.exit(1)
})

58
tools/drill/drill.test.js Normal file
View File

@@ -0,0 +1,58 @@
// The drill's pure parts: how it decides the harness has stopped (issue #20), and what it starts clean.
// node --test tools/drill/drill.test.js
const test = require('node:test')
const assert = require('node:assert')
const { harnessProgress, unfinishedFinding, FRESH_DATA, HARNESS_STALL_MS } = require('./drill.js')
const MIN = 60000
const T0 = 1000000
test('a harness that keeps writing is running, and its clock restarts on each new line', () => {
let v = harnessProgress(['# run all'], { count: 0, at: T0 }, T0 + 30000, HARNESS_STALL_MS)
assert.equal(v.state, 'running')
assert.deepEqual(v.seen, { count: 1, at: T0 + 30000 })
// Four silent minutes, then a line: still running, and the clock restarts.
v = harnessProgress(['# run all', 'a: PASS '], v.seen, T0 + 30000 + 4 * MIN, HARNESS_STALL_MS)
assert.equal(v.state, 'running')
assert.equal(v.seen.at, T0 + 30000 + 4 * MIN)
})
test('no new line for five minutes is a stall; four minutes and a bit is not', () => {
const seen = { count: 2, at: T0 }
const lines = ['# run all', 'a: PASS ']
assert.equal(harnessProgress(lines, seen, T0 + 5 * MIN - 1, HARNESS_STALL_MS).state, 'running')
assert.equal(harnessProgress(lines, seen, T0 + 5 * MIN, HARNESS_STALL_MS).state, 'stalled')
})
test('a harness that never wrote a line stalls from when the wait began', () => {
assert.equal(harnessProgress([], { count: 0, at: T0 }, T0 + 5 * MIN, HARNESS_STALL_MS).state, 'stalled')
})
test('done wins, even when the file has been still for a long time', () => {
const lines = ['# run all', '# done all: 10 pass, 1 fail']
assert.equal(harnessProgress(lines, { count: 2, at: T0 }, T0 + 30 * MIN, HARNESS_STALL_MS).state, 'done')
})
test('the stall finding names the last line and the counts', () => {
const lines = ['# run all', 'place.set.each: FAIL position: off the navmesh', 'place.spawned: FAIL each 0, group 3']
const f = unfinishedFinding({ pass: 40, fail: 2 }, lines, 'stalled')
assert.equal(f, 'The harness stopped after `place.spawned: FAIL each 0, group 3` (no output for 5 minutes; 40 pass, 2 fail so far).')
})
test('a backtick in the last line cannot break the issue body, and a long line is cut', () => {
const f = unfinishedFinding({ pass: 0, fail: 0 }, ['x`y' + 'z'.repeat(500)], 'stalled')
assert.equal(f.split('`').length, 3)
assert.ok(f.length < 330)
assert.match(unfinishedFinding({ pass: 0, fail: 0 }, [], 'stalled'), /stopped after `no line at all`/)
})
test('a capped wait keeps the 45-minute message', () => {
assert.equal(unfinishedFinding({ pass: 3, fail: 1 }, ['# run all'], 'capped'), 'The harness did not finish in 45 minutes (3 pass, 1 fail so far).')
})
test('the clean start deletes files only: every path ends in .json and none is a directory', () => {
assert.ok(FRESH_DATA.includes('/oxide/data/Kits/kits_data.json'))
assert.ok(FRESH_DATA.includes('/oxide/data/RunicNPC/state.json'))
for (const f of FRESH_DATA) assert.match(f, /^\/oxide\/data\/(Kits|RunicNPC)\/[a-z_]+\.json$/)
})