From dcc17adf0f5174e27d923bfe54fb8c3e7ac35454 Mon Sep 17 00:00:00 2001
From: Pixnop <77785313+Pixnop@users.noreply.github.com>
Date: Thu, 3 Sep 2026 20:13:06 +0200
Subject: [PATCH] Trim what the audit found
The README no longer names a release version in its install lines, since the stable people download and the prereleases stacked on dev are not the same number; the day-one survey carries a historical banner listing where the shipped mod overtook it; the Stryker scaffolding that no job ran is gone; the engine window constant is declared once with the sampling cadence derived from it, which also removes a doubled summary tag; the two OTLP scenarios share one wait helper and the attribution scenario reads its share line through the scrape helper it already had; and the mutation script restores once instead of on every run.
---
.config/dotnet-tools.json | 13 ---------
Pulse.Mutation.slnx | 7 -----
Pulse.Otlp.Scenarios/Exports.cs | 27 +++++++++++++++++++
Pulse.Otlp.Scenarios/OtlpExportScenarios.cs | 21 ++-------------
.../OtlpGrpcExportScenarios.cs | 21 ++-------------
Pulse.Scenarios/AttributionScenarios.cs | 9 ++-----
Pulse.Scenarios/Scrape.cs | 3 ++-
Pulse/PulseModSystem.cs | 18 ++++++-------
README.md | 16 +++++------
docs/metrics-feasibility.md | 9 +++++++
stryker-config.json | 21 ---------------
tools/mutation-check.sh | 6 ++++-
12 files changed, 66 insertions(+), 105 deletions(-)
delete mode 100644 .config/dotnet-tools.json
delete mode 100644 Pulse.Mutation.slnx
create mode 100644 Pulse.Otlp.Scenarios/Exports.cs
delete mode 100644 stryker-config.json
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
deleted file mode 100644
index 010a8c1..0000000
--- a/.config/dotnet-tools.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "version": 1,
- "isRoot": true,
- "tools": {
- "dotnet-stryker": {
- "version": "4.16.0",
- "commands": [
- "dotnet-stryker"
- ],
- "rollForward": false
- }
- }
-}
\ No newline at end of file
diff --git a/Pulse.Mutation.slnx b/Pulse.Mutation.slnx
deleted file mode 100644
index 39ba031..0000000
--- a/Pulse.Mutation.slnx
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/Pulse.Otlp.Scenarios/Exports.cs b/Pulse.Otlp.Scenarios/Exports.cs
new file mode 100644
index 0000000..a46dde7
--- /dev/null
+++ b/Pulse.Otlp.Scenarios/Exports.cs
@@ -0,0 +1,27 @@
+using System.Diagnostics;
+
+namespace Pulse.Otlp.Scenarios;
+
+/// The one wait both collector scenarios share: pump the world until an export lands.
+internal static class Exports
+{
+ /// Pumps the world until hands back an export, or the
+ /// deadline passes.
+ /// The bound is wall clock rather than a tick count, which is why this is not
+ /// World.Until: the exporter waits on a real timer on its own thread, and it owes the
+ /// game loop nothing. Ticking is how the scenario passes that time without sleeping the thread
+ /// the world runs on.
+ public static async Task WaitFor(Func first, Func pump, TimeSpan deadline, int port)
+ where T : class
+ {
+ Stopwatch clock = Stopwatch.StartNew();
+ while (first() == null && clock.Elapsed < deadline)
+ {
+ await pump();
+ }
+
+ return first()
+ ?? throw new InvalidOperationException(
+ $"no export reached the collector on port {port} within {deadline.TotalSeconds:0}s");
+ }
+}
diff --git a/Pulse.Otlp.Scenarios/OtlpExportScenarios.cs b/Pulse.Otlp.Scenarios/OtlpExportScenarios.cs
index c079fa5..a1233f4 100644
--- a/Pulse.Otlp.Scenarios/OtlpExportScenarios.cs
+++ b/Pulse.Otlp.Scenarios/OtlpExportScenarios.cs
@@ -1,4 +1,3 @@
-using System.Diagnostics;
using System.Text;
using Atlas.Api;
using Atlas.XUnit;
@@ -75,22 +74,6 @@ public async Task Server_Keeps_Ticking_WhileExporting()
Assert.Equal("game:chest-east", World.BlockAt(pos).Code.ToString());
}
- /// Pumps the world until the collector has an export in hand.
- /// The bound is wall clock rather than a tick count, which is why this is not
- /// World.Until: the exporter waits on a real 5 s timer on its own thread, and it owes
- /// the game loop nothing. Ticking is how the scenario passes that time without sleeping the
- /// thread the world runs on.
- private async Task WaitForExport()
- {
- TimeSpan deadline = ExportInterval * 6;
- Stopwatch clock = Stopwatch.StartNew();
- while (collector.First == null && clock.Elapsed < deadline)
- {
- await World.Ticks(10);
- }
-
- return collector.First
- ?? throw new InvalidOperationException(
- $"no export reached the collector on port {CollectorPort} within {deadline.TotalSeconds:0}s");
- }
+ private Task WaitForExport()
+ => Exports.WaitFor(() => collector.First, () => World.Ticks(10), ExportInterval * 6, CollectorPort);
}
diff --git a/Pulse.Otlp.Scenarios/OtlpGrpcExportScenarios.cs b/Pulse.Otlp.Scenarios/OtlpGrpcExportScenarios.cs
index c7a9c16..dca7f18 100644
--- a/Pulse.Otlp.Scenarios/OtlpGrpcExportScenarios.cs
+++ b/Pulse.Otlp.Scenarios/OtlpGrpcExportScenarios.cs
@@ -1,4 +1,3 @@
-using System.Diagnostics;
using System.Text;
using Atlas.XUnit;
using Xunit;
@@ -76,22 +75,6 @@ public async Task Exporter_Pushes_PulsesMetrics_OverGrpc()
Assert.Contains("pulse-atlas-grpc", body, StringComparison.Ordinal);
}
- /// Pumps the world until the collector has an export in hand.
- /// The bound is wall clock rather than a tick count, which is why this is not
- /// World.Until: the exporter waits on a real 5 s timer on its own thread, and it owes
- /// the game loop nothing. Ticking is how the scenario passes that time without sleeping the
- /// thread the world runs on.
- private async Task WaitForExport()
- {
- TimeSpan deadline = ExportInterval * 12;
- Stopwatch clock = Stopwatch.StartNew();
- while (collector.First == null && clock.Elapsed < deadline)
- {
- await World.Ticks(10);
- }
-
- return collector.First
- ?? throw new InvalidOperationException(
- $"no export reached the collector on port {CollectorPort} within {deadline.TotalSeconds:0}s");
- }
+ private Task WaitForExport()
+ => Exports.WaitFor(() => collector.First, () => World.Ticks(10), ExportInterval * 12, CollectorPort);
}
diff --git a/Pulse.Scenarios/AttributionScenarios.cs b/Pulse.Scenarios/AttributionScenarios.cs
index 729c733..f2a5779 100644
--- a/Pulse.Scenarios/AttributionScenarios.cs
+++ b/Pulse.Scenarios/AttributionScenarios.cs
@@ -44,14 +44,9 @@ private static async Task Burst(IWorldSession world)
return body;
}
- /// Reads one labelled sample line, of which there is exactly one per mod.
+ /// One mod's share line, of which there is exactly one per mod.
private static double Share(string exposition, string modid)
- {
- string name = $"pulse_mod_tick_share{{modid=\"{modid}\"}}";
- string? line = exposition.Split('\n').FirstOrDefault(l => l.StartsWith(name + " ", StringComparison.Ordinal));
- Assert.True(line != null, $"{name} is not in the exposition:\n{exposition}");
- return double.Parse(line![(name.Length + 1)..], CultureInfo.InvariantCulture);
- }
+ => Scrape.Value(exposition, $"pulse_mod_tick_share{{modid=\"{modid}\"}}");
[AtlasScenario]
public async Task Attribution_Serves_ItsFamilies_FromBoot()
diff --git a/Pulse.Scenarios/Scrape.cs b/Pulse.Scenarios/Scrape.cs
index 284a899..c1ca959 100644
--- a/Pulse.Scenarios/Scrape.cs
+++ b/Pulse.Scenarios/Scrape.cs
@@ -23,7 +23,8 @@ public static async Task Metrics(int port)
return await response.Content.ReadAsStringAsync();
}
- /// Reads one unlabelled sample line out of an exposition body.
+ /// Reads one sample line out of an exposition body by its exact name, labels
+ /// included when the series has any.
public static double Value(string exposition, string name)
{
foreach (string line in exposition.Split('\n'))
diff --git a/Pulse/PulseModSystem.cs b/Pulse/PulseModSystem.cs
index 90ec181..19d60d8 100644
--- a/Pulse/PulseModSystem.cs
+++ b/Pulse/PulseModSystem.cs
@@ -15,9 +15,15 @@ public sealed class PulseModSystem : ModSystem
private const string ConfigFile = "pulse.json";
private const double SnapshotIntervalSeconds = 1.0;
- /// The engine rotates its statistics buckets every two seconds, so sampling them any
- /// faster only re-reads the same window.
- private const int EngineSampleIntervalMs = 2000;
+ /// The engine rotates its statistics ring every two seconds, a constant wired into
+ /// the tick loop, so a completed bucket nominally spans this long. A bucket cut short around a
+ /// suspend makes the rate read low for one window; the engine's own /stats has the same
+ /// approximation.
+ private const double EngineWindowSeconds = 2.0;
+
+ /// Sampling the ring any faster than it rotates only re-reads the same window, so the
+ /// engine listener runs at exactly that cadence.
+ private const int EngineSampleIntervalMs = (int)(EngineWindowSeconds * 1000);
/// How many entity codes get a series of their own before the rest are lumped into
/// one bucket. Ten covers the animals and the drifters on any world worth looking at.
@@ -576,12 +582,6 @@ private void OnServerResume()
/// Both windowed network families read the same sample once, so their two channels
/// always describe the same two seconds.
- /// The engine rotates its statistics ring every two seconds, a constant wired into
- /// the tick loop, so a completed bucket nominally spans this long. A bucket cut short around a
- /// suspend makes the rate read low for one window; the engine's own /stats has the same
- /// approximation.
- private const double EngineWindowSeconds = 2.0;
-
private IEnumerable> PacketMeasurements()
{
EngineSample? sample = engine;
diff --git a/README.md b/README.md
index 913b6ef..3b2da23 100644
--- a/README.md
+++ b/README.md
@@ -193,8 +193,8 @@ a call tree. Lithos Probe's sampling profiler is the tool for the second pass.
## Install
-Drop `pulse_0.1.0.zip` into your server's `Mods/` folder and start the server. Add
-`pulseotlp_0.1.0.zip` beside it if you want OTLP push as well; the base mod works on its own and
+Drop `pulse_x.x.x.zip` into your server's `Mods/` folder and start the server. Add
+`pulseotlp_x.x.x.zip` beside it if you want OTLP push as well; the base mod works on its own and
the OTLP one does not. On first boot Pulse writes `ModConfig/pulse.json` with its defaults:
```json
@@ -263,7 +263,7 @@ and read `/metrics`, the server sends its metrics to a collector on a timer, in
every major observability backend accepts. Grafana Cloud, Honeycomb, Datadog, New Relic and an
`otel-collector` you run yourself all take the same payload.
-It ships as a second mod, `pulseotlp_0.1.0.zip`, and both zips go in `Mods/`. The base mod stays
+It ships as a second mod, `pulseotlp_x.x.x.zip`, and both zips go in `Mods/`. The base mod stays
a single dll with no dependencies; the OTLP one carries the OpenTelemetry SDK and its
`Microsoft.Extensions.*` fan-out, eighteen dlls in all. That split is not tidiness. The game's
mod loader puts every root-level dll of every mod into one shared assembly context with no
@@ -360,8 +360,8 @@ references only; neither is copied into the mod, which still ships as one file.
export VINTAGE_STORY=/path/to/vintagestory
dotnet build Pulse.slnx -c Release
dotnet test # unit tests, then the Atlas scenarios
-dotnet build Pulse/Pulse.csproj -c Release -t:PackageMod # artifacts/pulse_0.1.0.zip
-dotnet build Pulse.Otlp/Pulse.Otlp.csproj -c Release -t:PackageMod # artifacts/pulseotlp_0.1.0.zip
+dotnet build Pulse/Pulse.csproj -c Release -t:PackageMod # artifacts/pulse_x.x.x.zip
+dotnet build Pulse.Otlp/Pulse.Otlp.csproj -c Release -t:PackageMod # artifacts/pulseotlp_x.x.x.zip
```
The scenarios in `Pulse.Scenarios` boot a real headless server in-process through
@@ -386,9 +386,9 @@ aggregates, the entity top-ten with its series retirement rule, and the suspend
them needs a server. `Pulse.Otlp.Tests` covers the config translation, which is where the OTLP
mod's only non-obvious logic lives. Mutation verification over those files runs through
`tools/mutation-check.sh`, which applies representative mutations one at a time and requires the
-suite to fail on every one; CI runs it on each push. A `stryker-config.json` sits ready for
-`dotnet stryker`, which currently finds the tests but runs mutants against the unmutated
-assembly on the .NET 10 SDK.
+suite to fail on every one; CI runs it on each push. Stryker itself is parked: on the .NET 10 SDK
+it finds the tests but runs every mutant against the unmutated assembly, so the script stays
+until a release of it reports a real score here (tracked in the issues).
## Where this is going
diff --git a/docs/metrics-feasibility.md b/docs/metrics-feasibility.md
index 3ad446c..890f3c5 100644
--- a/docs/metrics-feasibility.md
+++ b/docs/metrics-feasibility.md
@@ -1,5 +1,14 @@
# Pulse metrics feasibility report
+**Historical document.** This is the day-one survey, written on 1 September 2026 before any
+mod code existed, and kept as the record of what was known then. Where it disagrees with the
+README, the README describes what shipped. The main places it has been overtaken: the join and
+leave counters were never built, the suspend window ships as two counters rather than a
+histogram, the packaging question closed as one dll plus a separate optional OTLP mod, the
+engine's frame profiler turned out to be the per-mod attribution source (it is dismissed below
+as a tick-time source, which is still true), and Stratum's `StratumEntityBehaviorTimings` is
+not readable from another mod, so it is not the V2 route this survey imagined.
+
Survey of Vintage Story 1.22.7 server internals, done before writing any mod code. Method: the public API sources at 1.22.7 (GitHub master, which matches the shipped build; the stable branch lags at 1.20.11), the shipped `VintagestoryAPI.xml`, and decompilation of the closed `VintagestoryLib.dll` where the engine hides the interesting parts. Line references below point at the 1.22.7 sources or at decompiled engine types. Raw survey notes live in `.survey/` (not committed).
## Verdict
diff --git a/stryker-config.json b/stryker-config.json
deleted file mode 100644
index 63a407f..0000000
--- a/stryker-config.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "stryker-config": {
- "solution": "Pulse.Mutation.slnx",
- "project": "Pulse.csproj",
- "mutate": [
- "**/PrometheusText.cs",
- "**/MetricsAggregator.cs"
- ],
- "coverage-analysis": "off",
- "reporters": [
- "progress",
- "cleartext",
- "json"
- ],
- "thresholds": {
- "high": 100,
- "low": 100,
- "break": 100
- }
- }
-}
diff --git a/tools/mutation-check.sh b/tools/mutation-check.sh
index a71551b..c7c9876 100755
--- a/tools/mutation-check.sh
+++ b/tools/mutation-check.sh
@@ -20,8 +20,12 @@ TOTAL=0
# judged by the OTLP tests rather than by a suite that cannot see it.
TEST_PROJECT="Pulse.Tests/Pulse.Tests.csproj"
+# One restore up front; every test run after it skips the restore, which is most of the idle
+# time in a loop that rebuilds the same projects dozens of times.
+dotnet restore Pulse.slnx --nologo -v q >/dev/null 2>&1
+
run_tests() {
- dotnet test "$TEST_PROJECT" -c Release --nologo -v q >/dev/null 2>&1
+ dotnet test "$TEST_PROJECT" -c Release --no-restore --nologo -v q >/dev/null 2>&1
return $?
}