A two-component profiling system for Space Engineers mods.
| Component | Purpose |
|---|---|
| SEProfiler.Plugin | Client plugin loaded by Pulsar. Observes a target mod at SE framework boundaries using Harmony patches. Outputs JSONL events. |
| SEProfiler.Lib | Zero-dependency library mods reference for internal instrumentation. All calls are no-ops when the plugin is absent. |
- Space Engineers (Steam)
- Pulsar — the plugin loader that replaced the discontinued SpaceEngineersLauncher
You should be able to find the plugin in the in-game pulsar UI.
- Build the solution in Release configuration.
- Copy
SEModProfiler.dllto Pulsar's plugin directory:The post-build%AppData%\Pulsar\Legacy\Local\Deploy.batdoes this automatically on a successful build. - Launch Pulsar, enable SEModProfiler in the Plugins tab, and start SE.
The plugin is now active. It records nothing until a mod is scoped (see below).
Open the plugin config dialog from Pulsar's plugin list. The dialog shows all mods that
have called Profiler.Register() at runtime. Check the mod you want to profile and click
Save — the plugin starts recording immediately.
Alternatively, drop a cmd.json file into:
%APPDATA%\SpaceEngineers\SEModProfiler\
The directory is created automatically on first plugin load.
{
"cmd": "scope",
"modId": "2938471234",
"outputPath": "%APPDATA%\\SpaceEngineers\\SEModProfiler\\session"
}| Field | Description |
|---|---|
modId |
Steam Workshop ID of the target mod. Leave empty ("") to observe all mods. |
outputPath |
Base path for output files (without extension). Omit to use the default. |
{ "cmd": "unscope" }session.jsonl: one JSON object per line for detailed tracing.
Scope events carry ms (elapsed milliseconds) and gc0_delta (gen-0 collections during the call).
Example lines:
{"t":1714000000123,"src":"framework","type":"scope","name":"UpdateAfterSimulation","ms":0.412,"gc0_delta":0}
{"t":1714000000124,"src":"etw","type":"event","name":"GCStart","data":"gen=0"}
{"t":1714000000125,"src":"mod","type":"counter","name":"tasks_dispatched","delta":1}Any mod calling Profiler.Register will show up in the plugin's config dialog. It's recommended to call Profiler.Register on your mod's init.
public override void Init(MyObjectBuilder_SessionComponent sessionComponent)
{
Profiler.Register("My Mod Display Name", ModContext.ModId);
}ModContext.ModId is the Steam Workshop ID string. When the plugin is absent the call is a no-op.
using SEProfiler;
using Sandbox.ModAPI;
using VRage.Utils;
using VRage.Game.Components;
// Minimal SessionComponent-style example.
[MySessionComponentDescriptor(MyUpdateOrder.BeforeSimulation)]
public sealed class ProfilerExampleSession : MySessionComponentBase
{
public int MyModValue = 0;
public override void Init(MyObjectBuilder_SessionComponent sessionComponent)
{
// Optional but recommended: shows your mod in the profiler UI.
Profiler.Register("Profiler Example Mod", ModContext.ModId);
}
[SEProfiler.Counter("ProfilerExample.Ticks")] // increment by 1
[SEProfiler.Counter("ProfilerExample.Ticks", 10)] // increment by 10
[SEProfiler.Scope("ProfilerExample.UpdateTick")] // scope output of entire function
[SEProfiler.Gauge("profiler_example.fixed_budget_ms", 16.67)] // compile-time constant only
[SEProfiler.Event("profiler_example.phase", "before_sim")] // compile-time constant only
public override void UpdateBeforeSimulation()
{
RecordScopeExample();
RecordCounterExample();
RecordGaugeExample();
RecordEventExample();
}
private void RecordScopeExample()
{
using (Profiler.Scope("ProfilerExample.UpdateTick"))
{
// Work you want to time.
}
}
private void RecordCounterExample()
{
Profiler.Counter("profiler_example.MyModValue_tick", MyModValue);
MyModValue++;
}
private void RecordGaugeExample()
{
// Runtime values (VRage/SE API state) cannot be used in attributes.
Profiler.Gauge("profiler_example.players_online", MyAPIGateway.Multiplayer.PlayerCount);
}
private void RecordEventExample()
{
// Runtime payload from VRage should be sent explicitly.
Profiler.Event("profiler_example.phase", MyGameTimer.SessionTimeSpan.TotalSeconds.ToString("F2"));
}
}Attribute arguments are limited to compile-time constants. In practice:
- Works in attributes: string literals, numeric literals,
constvalues,nameof(...),typeof(...). - Does not work in attributes: runtime values like
MyAPIGateway.Multiplayer.PlayerCount,DateTime.UtcNow, non-const fields/properties, method calls. - For runtime values from VRage/SE APIs, keep explicit
Profiler.*calls inside the method body.
All four methods are unconditional no-ops when the plugin is absent. The only overhead is a single null check on Profiler.Sink. No allocation occurs on the no-op path.
If you want to skip even the null check in a tight loop, use Profiler.IsActive — but check it lazily (inside your update method, not at Init time):
// CORRECT — checked each frame; plugin may set Sink after Init
public override void UpdateAfterSimulation()
{
if (Profiler.IsActive)
Profiler.Gauge("entity_count", _entities.Count);
}
// WRONG — load order is not guaranteed; Sink may be null at Init time
public override void Init(MyObjectBuilder_EntityBase builder)
{
_profilingEnabled = Profiler.IsActive; // may always be false
}The plugin sets Profiler.Sink during its own Init. Because SE's plugin load order is not guaranteed, your mod's Init may run before the profiler's Init. Always read Profiler.IsActive at the call site.
cmd |
Required fields | Effect |
|---|---|---|
scope |
modId, outputPath |
Start recording. Opens JSONL file. Resolves mod assembly lazily after world load. |
unscope |
— | Stop recording. Flushes and closes output files. |
Prerequisites: .NET SDK, Visual Studio 2022 or Rider, Space Engineers (Steam).
If SE is not installed in the default Steam library path, create user.props in the repository root (it is gitignored):
<Project>
<PropertyGroup>
<Bin64>C:\YourSteamLibrary\steamapps\common\SpaceEngineers\Bin64</Bin64>
</PropertyGroup>
</Project>Then build:
dotnet build SEProfiler.sln -c Release
Run unit tests (no SE installation required at test runtime):
dotnet test SEProfiler.Tests --filter "TestCategory!=Integration"
The repository includes a small CLI project, SEProfiler.InstrumentationStripper.Cli, that removes profiler instrumentation from C# source files.
Default mode is preview-only (prints transformed source to stdout and does not modify files):
dotnet run --project SEProfiler.InstrumentationStripper.Cli -- strip-instrumentation path/to/mod
Overwrite files in place:
dotnet run --project SEProfiler.InstrumentationStripper.Cli -- --inplace path/to/mod
The tool strips:
[SEProfiler.Scope|Counter|Gauge|Event(...)](and unqualified forms)Profiler.Counter(...),Profiler.Gauge(...),Profiler.Event(...)statementsusing (Profiler.Scope(...)) { ... }wrappers (keeps inner body)using var _ = Profiler.Scope(...);statements
You can include the stripper in a .NET build/publish pipeline. For example, in a mod .csproj you can invoke it before your release publish target:
<Target Name="StripProfilerInstrumentationForRelease" BeforeTargets="Publish" Condition="'$(Configuration)' == 'Release'">
<Exec Command="dotnet run --project ..\SEProfiler.InstrumentationStripper.Cli -- --inplace $(MSBuildProjectDirectory)\Data\Scripts" />
</Target>In CI you can run the same command as a step before packaging artifacts.
Run the manual integration test suite after a live SE session:
set SEMOD_PROFILER_OUTPUT=%APPDATA%\SpaceEngineers\SEModProfiler
dotnet test SEProfiler.Tests --filter "TestCategory=Integration"
- Harmony patches are applied once at
Initand are never removed. When unscoped, patches are silent (Sink == nullgates all recording). CallingUnpatchAllinDisposeis explicitly avoided to avoid breaking other loaded plugins. - The profiler is passive — it does not modify any game logic, only wraps framework boundary methods.
SEProfiler.Lib.dllcarries no SE or Harmony dependencies and is safe to redistribute with a mod.
- Submit an Issue/PR
- AI assisted/generated code is allowed, but try to keep the comments concise.