Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions .config/dotnet-tools.json

This file was deleted.

7 changes: 0 additions & 7 deletions Pulse.Mutation.slnx

This file was deleted.

27 changes: 27 additions & 0 deletions Pulse.Otlp.Scenarios/Exports.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Diagnostics;

namespace Pulse.Otlp.Scenarios;

/// <summary>The one wait both collector scenarios share: pump the world until an export lands.</summary>
internal static class Exports
{
/// <summary>Pumps the world until <paramref name="first"/> hands back an export, or the
/// deadline passes.</summary>
/// <remarks>The bound is wall clock rather than a tick count, which is why this is not
/// <c>World.Until</c>: 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.</remarks>
public static async Task<T> WaitFor<T>(Func<T?> first, Func<Task> 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");
}
}
21 changes: 2 additions & 19 deletions Pulse.Otlp.Scenarios/OtlpExportScenarios.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Diagnostics;
using System.Text;
using Atlas.Api;
using Atlas.XUnit;
Expand Down Expand Up @@ -75,22 +74,6 @@ public async Task Server_Keeps_Ticking_WhileExporting()
Assert.Equal("game:chest-east", World.BlockAt(pos).Code.ToString());
}

/// <summary>Pumps the world until the collector has an export in hand.</summary>
/// <remarks>The bound is wall clock rather than a tick count, which is why this is not
/// <c>World.Until</c>: 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.</remarks>
private async Task<FakeCollector.Export> 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<FakeCollector.Export> WaitForExport()
=> Exports.WaitFor(() => collector.First, () => World.Ticks(10), ExportInterval * 6, CollectorPort);
}
21 changes: 2 additions & 19 deletions Pulse.Otlp.Scenarios/OtlpGrpcExportScenarios.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Diagnostics;
using System.Text;
using Atlas.XUnit;
using Xunit;
Expand Down Expand Up @@ -76,22 +75,6 @@ public async Task Exporter_Pushes_PulsesMetrics_OverGrpc()
Assert.Contains("pulse-atlas-grpc", body, StringComparison.Ordinal);
}

/// <summary>Pumps the world until the collector has an export in hand.</summary>
/// <remarks>The bound is wall clock rather than a tick count, which is why this is not
/// <c>World.Until</c>: 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.</remarks>
private async Task<FakeGrpcCollector.Export> 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<FakeGrpcCollector.Export> WaitForExport()
=> Exports.WaitFor(() => collector.First, () => World.Ticks(10), ExportInterval * 12, CollectorPort);
}
9 changes: 2 additions & 7 deletions Pulse.Scenarios/AttributionScenarios.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,9 @@ private static async Task<string> Burst(IWorldSession world)
return body;
}

/// <summary>Reads one labelled sample line, of which there is exactly one per mod.</summary>
/// <summary>One mod's share line, of which there is exactly one per mod.</summary>
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()
Expand Down
3 changes: 2 additions & 1 deletion Pulse.Scenarios/Scrape.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ public static async Task<string> Metrics(int port)
return await response.Content.ReadAsStringAsync();
}

/// <summary>Reads one unlabelled sample line out of an exposition body.</summary>
/// <summary>Reads one sample line out of an exposition body by its exact name, labels
/// included when the series has any.</summary>
public static double Value(string exposition, string name)
{
foreach (string line in exposition.Split('\n'))
Expand Down
18 changes: 9 additions & 9 deletions Pulse/PulseModSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@ public sealed class PulseModSystem : ModSystem
private const string ConfigFile = "pulse.json";
private const double SnapshotIntervalSeconds = 1.0;

/// <summary>The engine rotates its statistics buckets every two seconds, so sampling them any
/// faster only re-reads the same window.</summary>
private const int EngineSampleIntervalMs = 2000;
/// <summary>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.</summary>
private const double EngineWindowSeconds = 2.0;

/// <summary>Sampling the ring any faster than it rotates only re-reads the same window, so the
/// engine listener runs at exactly that cadence.</summary>
private const int EngineSampleIntervalMs = (int)(EngineWindowSeconds * 1000);

/// <summary>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.</summary>
Expand Down Expand Up @@ -576,12 +582,6 @@ private void OnServerResume()

/// <summary>Both windowed network families read the same sample once, so their two channels
/// always describe the same two seconds.</summary>
/// <summary>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.</summary>
private const double EngineWindowSeconds = 2.0;

private IEnumerable<Measurement<double>> PacketMeasurements()
{
EngineSample? sample = engine;
Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
9 changes: 9 additions & 0 deletions docs/metrics-feasibility.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
21 changes: 0 additions & 21 deletions stryker-config.json

This file was deleted.

6 changes: 5 additions & 1 deletion tools/mutation-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 $?
}

Expand Down
Loading