Attribution
Tick busy time tells you the server is working hard. Attribution tells you what it is working on. Turned on, it reports a per-mod share of the main thread, on a continuous series you can graph and alert on, rather than in a one-off profiling report.
It is off by default, because it is not free. Add an Attribution block to ModConfig/pulse.json:
{
"Attribution": {
"Enabled": true,
"BurstTicks": 10,
"IntervalSeconds": 10
}
}BurstTicks is how many consecutive ticks each measurement covers, IntervalSeconds how long the
server runs unmeasured between two of them. The defaults measure about one tick in thirty. Both are
clamped on read: at least a second between bursts, at most 300 ticks in one.
Turning it on without a restart #
The moment you want attribution is usually while the server is struggling, and restarting it throws
away the thing you wanted to look at. So the config file is not the only way in. Four commands, all
behind the controlserver privilege, so admins and a panel console can run them:
/pulse attribution onstarts the duty cycle straight away, on whateverBurstTicksandIntervalSecondsare in force. The first burst lands one interval later./pulse attribution offstops it and puts the engine's frame profiler back down, unless the engine's own/debug logticksstill wants it running. A burst in progress is dropped rather than published half-measured./pulse attribution statusreports whether it is running, the cycle it is using, how many ticks it has profiled and whether it is inside a burst right now./pulse reloadre-readspulse.jsonand applies theAttributionblock live. The reply names any other key whose value in the file has drifted from what the server is running, since those still need a restart, and a file that does not parse changes nothing at all.
on and off act on the running server and never write pulse.json, which is deliberate: a ten
minute look should not become permanent because somebody forgot to turn it off. Restart the server
and the file decides again. To make a change stick, edit the file and either restart or run
/pulse reload.
This works even on a server that booted with Attribution.Enabled false. Pulse registers the four
families and primes the engine's profiler at startup either way, because a profiler switched on
part-way through a tick that has never completed one takes the server down with it (there is more
on that below). Priming costs two profiled ticks at boot and nothing after; an instrument nothing
has recorded into is not a series, so an idle server serves exactly the exposition it did before.
Four families appear once it is on:
pulse_mod_tick_share{modid}(gauge): the fraction of profiled main-thread busy time that went to one mod over the last completed burst. The shares add up to 1 across everymodid, including the two Pulse adds:enginefor the server's own systems and for the time no marker named, andunattributedfor work that was marked but that no loaded mod claims.pulse_mod_tick_seconds_total{modid}(counter): main-thread seconds attributed to one mod. Sampled, not total: this is time measured inside the bursts, not time since startup. Divide by the tick counter below to compare two servers, or takerate()of it againstrate(pulse_attribution_ticks_total)for seconds per profiled tick.pulse_attribution_ticks_total(counter): ticks actually profiled, which is what makes the sampled seconds mean anything.pulse_attribution_dropped_samples_total(counter): profiler readings thrown away because they overflowed. The engine accumulates each marker's time into a 32 bit counter of stopwatch ticks, which wraps negative somewhere past two seconds inside a single tick. A wrapped reading is not a large number, it is garbage, so it is dropped and counted here instead of being published as data. Anything but a flat zero means the server had a tick so bad that a single marker ran for over two seconds.
How it works, and what it costs #
The engine already contains a per-mod tick attributor and simply never switches it on. With its frame profiler enabled, the server stamps a marker after every game tick listener, every delayed callback and every main-thread entity behaviour, keyed by the type that declared the handler or by the behaviour's registered code. Pulse turns the profiler on for a burst, reads the tree the tick left behind, maps each key back to a mod through the mod loader, and turns it off again. No Harmony, no engine patch, no bundled dependency.
The cost is measured, not estimated from a mark count. Pulse.Scenarios/AttributionCostScenarios.cs
joins a test player, spawns four thousand chickens (dense cluster and, separately, spread across
the loaded area, to tell density from entity count apart), and reads tick busy time with
World.MeasureTicks across off, on, off, on, off, on, off: four off windows bracketing three on
windows, each on compared against the mean of the two off windows next to it rather than just the
one before it, so a baseline that drifts across the run cannot bias every delta the same direction.
On a quiet run, both load shapes agreed: the off baseline wobbled by a couple of milliseconds with
no clear drift, and a profiled tick's own share came out to about 8.75 ms, roughly 26% of the 33 ms
budget. A second scenario splits that further, forcing the engine's frame profiler on without
letting Pulse fold what it records: about 9 ms (27% of budget) is the engine's own cost of writing
the marks. Pulse's own share, read from its own attribution at the stopwatch resolution that
measures at rather than from a millisecond-rounded difference too small for that resolution to see,
comes to about 0.02 ms, under a tenth of a percent of the budget: there is nothing here for Pulse
itself to usefully optimise. At the shipped default (10 ticks every 10 seconds) the measured share
blends down to about 0.9% of the budget, a slow window of roughly a third of a second every 10
seconds. The previous default was 30 ticks; the same measurement puts that at about 2.5%
amortised, and the shorter burst is what shipped once the real cost of the longer one was known.
All of these replace the mark-count estimate this section used to carry (2.8% burst, 0.3%
amortised at the old default), measured too low, likely because a dictionary write and a clock
read cost more in practice than its per-operation guess. Markers scale with loaded entities times
their behaviours, not with how many mods you run, so raising BurstTicks or lowering
IntervalSeconds moves the amortised share in the obvious direction, and on an idle server it is
nothing at all. Run it yourself, with VINTAGE_STORY set: PULSE_MEASURE_ATTRIBUTION_COST=1 dotnet test Pulse.Scenarios --filter "Category=Cost"; CI filters the Cost trait out, and locally
it is a no-op unless that variable is set, because spawning four thousand entities, three times
over, is slow.
One visible side effect: the engine logs "Over 400ms tick. Skipping N physics ticks" only while its
frame profiler is on, and Pulse is what turns it on. It does so for the first tick after every
start, attribution enabled or not, to prime the profiler so attribution can be switched on later
without a restart. That first tick loads the spawn area and usually runs long, so one such line
at startup is normal and harmless; it also counts once in pulse_log_entries_total{level="warning"}.
During attribution bursts the same line appears whenever physics falls behind: that is the engine
reporting a real condition it otherwise keeps to itself.
What it cannot see #
Say this out loud before reading a dashboard built on it.
Broadcast events carry no markers. Roughly forty of them, PlayerJoin, DidBreakBlock,
OnEntityDeath and the rest, are plain C# events the engine invokes without timing. A mod that
does all its work in an event handler shows up as a rounding error here, and the time it spends
lands in the engine bucket. The listener-and-behaviour half is what this measures.
It is a main-thread share, not a total. Entity behaviours that declare themselves thread-safe run across several threads, and only the main thread's slice is marked. A mod whose behaviour is thread-safe therefore reads low, by roughly the thread count.
Mapping is by assembly. A mod that ships several dlls only has the one its ModSystem lives in
claimed, so a listener registered from a side library reads as unattributed. So does a handler
on a static method, which the engine marks with no identity at all.
And it is a sample. Ten ticks every ten seconds describe a steady server well and a spiky one badly. The share is an average over the burst, so a mod that stalls for 200 ms once a minute may well be profiled during a quiet stretch and read as harmless.
If the numbers matter enough to act on, this is a first pass that says which mod to look at, not a call tree. Lithos Probe's sampling profiler is the tool for the second pass.
Source: README.md