Skip to content

Read Stratum's behavior timings contract, unwired - #137

Merged
Pixnop merged 6 commits into
devfrom
feat/stratum-timings-source
Oct 5, 2026
Merged

Pixnop merged 6 commits into
devfrom
feat/stratum-timings-source

Conversation

@Pixnop

@Pixnop Pixnop commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Step 5 of the plan for #6: the pieces that read Stratum's behavior timings, written and tested against fakes and not wired into the mod. Nothing is registered and nothing served changes. The reading contract it binds, StratumServer/Stratum#365, was approved and merged into Stratum's indev on 2026-10-05. The wiring (step 6) waits for a Stratum release that carries the contract.

What it adds (all internal)

  • StratumTimingsSource binds the contract by reflection. It looks for Vintagestory.API.Common.Entities.StratumEntityBehaviorTimings only in the VintagestoryAPI assembly the server loaded, reads ContractVersion as an int literal, and binds RequestRecording() and Snapshot(List<(string, long, long)>) once as typed delegates. Pulse's IL never names a Stratum type, so vanilla and Lithos are unaffected.

    • The binder requires exact parameter types. The default binder alone would also have accepted a Snapshot(object).
    • Binding runs no Stratum code: the static constructor runs only at the first call.
    • Each refusal comes with one reason: the contract is absent, older or newer, or a member is missing. MinimumStratumVersion is a marked placeholder, filled in once a Stratum release carries the contract.
  • StratumKeyParser maps an accumulator key to a family and its labels. It reads the four key forms the contract now documents, ignores the AI phase keys, and memoises its results.

    key family and labels
    entity.behavior.players.health behavior: category players, behavior health, threadsafe false, modid from ModOwners
    entity.behavior.threadsafe.creatures.done-behavior-entitypassivephysics behavior: category creatures, behavior entitypassivephysics, threadsafe true
    entity.ai.creatures.task.idle AI task: category creatures, task idle
    entity.type.wolf-eurasian-adult-male entity: type wolf, seconds and tick count
  • StratumFold turns two snapshots taken around a burst into per-series deltas in seconds, plus entity tick counts.

    • Each family is capped at 100 label sets. What spills over is summed into one overflow series per family, category and thread, where only the name reads other.
    • If either total of a key went down, the key is treated as reset.
    • modid comes from the ModOwners that attribution already uses.

Checked

  • An independent review compiled these three files together with the real contract file from the Stratum branch. The binder binds it, and a burst built with the exact string concatenations of Stratum's producers folds correctly.
  • It also parsed the 37 keys seen on a live Stratum server: 33 land in the right family and the 4 phase keys are ignored.
  • Its six remarks are applied in the last two commits:
    • overflow per category and thread;
    • a joint reset;
    • honest refusal reasons;
    • tests that hold at any Stopwatch.Frequency;
    • comments;
    • 16 low-value mutations dropped to keep the CI job's time down.
  • Pulse.Tests passes 557 tests, Pulse.Otlp.Tests 157, and the build has 0 warnings.
  • tools/mutation-check.sh kills 181 of 181 on this branch rebased onto dev, 41 of them for these pieces.

The points the wiring has to get right are listed for step 6, among them a ModOwners shared outside attribution, a fold built once, and the exact tick window.

Pixnop added 6 commits October 5, 2026 00:04
Stratum's per-behavior timings come out of its accumulator as flat strings built by concatenation
at the call site: entity.behavior.players.health,
entity.behavior.threadsafe.creatures.done-behavior-entitypassivephysics,
entity.ai.creatures.task.idle, entity.type.wolf-eurasian-adult-male. The parser turns each one
into the family it feeds and that family's label values, and nothing else. The four AI phase keys,
which nest inside each other and inside the entity totals, the sections Stratum times on its own
and anything malformed are ignored rather than guessed at, which is also what keeps a key family a
future Stratum adds from appearing under a wrong name.

A thread-safe behavior carries the engine's profiler mark, so its done-behavior- prefix is stripped
and both threads speak the same names; a main-thread name is taken as written. An entity is named
by the first part of its code, cut at the first dash like the engine's own FirstCodePart (a test
compares the two), so every variant of a wolf is one key. The parse is memoised by key text:
Stratum builds a fresh string for nearly every key at every call, and a repeated key now costs one
lookup and allocates no label strings.

Nothing reads this yet.
The accumulator's totals are cumulative, so a burst is the difference of two snapshots, one taken
when it began and one when it ended. The fold turns that difference into seconds per series
(behavior, AI task, entity type) and, for entity types, the number of ticks run, which is what
makes seconds per entity tick one division.

A key that is not in the first snapshot appeared during the burst and everything it holds is the
burst's, which is nearly every key, since Stratum empties its accumulator when the last reader lets
go. A total that went down was reset in between and counts as new; a negative one is nonsense and
adds nothing, so no series is ever told to go down. A key the burst added nothing to is not a
series and takes no place under the cap.

Each family keeps at most 100 series. The first label sets to appear get one and keep it for the
life of the server, and everything after is added to a single series per family whose labels all
read other. Nothing is evicted or re-ranked, because a series that moved in and out of other would
make other go down, which Prometheus reads as a counter reset. When a burst brings more new label
sets than there are places, as the first one can, the heaviest are admitted first and ties go in
label order, so the cheap tail is what gets lumped together and the same burst gives the same
series on every run. The burst says which families spilled for the first time, so the caller can
log that once.

A behavior's modid comes from the ModOwners table attribution already uses, which learns what a
behavior marks with from the live entities. It is looked up at every burst rather than remembered:
a name the table only learns later moves to its mod once, instead of staying unattributed for the
rest of the run, and the series it leaves simply stops growing. The cap counts the label sets that
carry a name, so that move does not take a second place.

Nothing reads this yet.
Stratum is adding a small public contract to its entity behavior timings: a ContractVersion
literal, a RequestRecording lease and a Snapshot of the cumulative totals that takes nothing from
the admin's own report. Pulse compiles against the vanilla API, so it cannot name that type. The
binder looks for the contract on the type the caller hands it, checks its shape exactly, and binds
the two members to delegates once, so a burst is three delegate calls and a dispose.

Nothing is guessed. A type with no ContractVersion that is an integer literal is a Stratum that
predates the contract, and the reason names the release that has it (a placeholder constant until
Stratum publishes one). Another version number, a missing member and a member of another shape
each earn a reason of their own, and a type that is absent (vanilla, Lithos, another fork) is not
a failure and has none. The version is read from metadata, so no Stratum code runs before Pulse
knows what it is looking at.

The tests bind fakes: one with the contract's exact shape, declared under Stratum's real full name
in the test assembly so that the by-name lookup has something to find, and one for each way a
Stratum can fail to have it. A last test runs a whole burst through the binder, the fake
accumulator and the fold. Nothing is wired to the mod yet.
Fifty-seven mutations over the binder, the parser and the fold, applied one at a time and each
required to fail the unit tests. The binder's are every check that stands between Pulse and a
Stratum it does not understand: a type that is absent, a ContractVersion that is not an integer
literal or is not the one Pulse reads, a member that is missing, internal, an instance method or
not the exact shape, and the plumbing of the two delegates. The parser's are the key shapes it
reads and the ones it must leave alone, the prefix strip and the first-dash cut. The fold's are the
arithmetic (the difference, the reset, the clamp, the conversion to seconds), the cap (its
comparison, its default, admission order and its tie-breaks, never evicting), the overflow series,
the once-only report of a family spilling, and the owner lookup.

The block has a list of files of its own to refuse a dirty tree on, and sits between two unrelated
blocks of the script.
An independent review of the Stratum timings pieces found no blocker and six things to settle.
This settles them.

What spills over the cap keeps its category and its thread, and only the name reads other, with
the modid that goes with a behavior's name. The thread separates CPU time summed across the
physics threads from main-thread time, so a panel that filters on threadsafe="true" no longer
loses what the cap lumped together. The overflow is one series for each category and thread a
family spilled in: at most four for behaviors, three for tasks and one for entity types.

A reset is read for the whole key. When either total went down, ticks or calls, both are taken as
the second snapshot holds them, instead of pairing an end value with a delta, which gave seconds
and a count that were not each other's.

A ContractVersion that is there but is not an integer literal (a static readonly field, a long, a
property) is a wrong shape and not an old Stratum, so it gets the reason about the members. Only a
Stratum with no public ContractVersion is told which release it needs.

The tests compared seconds exactly, on readings built as a truncated product with the stopwatch
frequency, which holds only when the frequency is a multiple of 4: at 3579545 Hz seven of them
failed on correct code. They now round to the nearest tick and compare within a few ticks, and the
burst through the binder uses whole seconds. Two tie-break tests that would have passed on the
wrong series, since the overflow series now shares its category or its thread, check the name too.

The comments said a burst usually opens on an empty accumulator. The baseline is meant to be taken
on the burst's warm-up tick, when it holds nearly every key. They say that, and the burst through
the binder takes its baseline there.

The three mutations these changes made inert (the two overflow labels and the internal
ContractVersion) go. The name of the overflow series, the joint reset and the honest reason get one
each, and the ones on the reset are aimed at the single place it is decided now.
The Build and tests job has a 30 minute limit and has timed out twice, and with the three open
branches the mutation step grows to about 197 mutations. Sixteen of the Stratum timings ones go as
low value, which leaves 41 of them.

From the fold: the three tie-breaks of the admission order (the tie-break itself stays, so the
same burst still admits the same series on every run, and its tests stay), the default cap, and
the calls of a type that sums several codes. From the binder: the text of the warning, a long
read as an int, an older version, a snapshot that returns a value, and the two binding flags that
would admit internal members or an instance method. From the parser: the five cases of an empty
category, an empty name, a type cut at the last dash, a type that is the whole code path and a
path that starts with a dash. The tests that guard them are untouched.
@Pixnop
Pixnop marked this pull request as ready for review October 5, 2026 09:42
@Pixnop
Pixnop merged commit bae6f59 into dev Oct 5, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant