A static performance logger that samples per-frame metrics, CPU scope timings, scene census and player/camera positions and writes buffered CSV rows to FileSystem.Data. It provides commands to start/stop/list logs and a GameObject component (PerfLogDriver) that calls Sample and Tick each frame.
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Globalization;
using System.Linq;
using System.Text;
namespace NZombies;
/// <summary>
/// A PER-FRAME PERFORMANCE LOG, WRITTEN TO DISK.
///
/// ⛔ IT EXISTS SO THE ANALYSIS IS NOT DONE FROM MEMORY. An fps readout answers "how fast right
/// now"; the question is "how does it FLUCTUATE, and what was I doing" — which needs every frame
/// recorded with the camera angle beside it, because the whole symptom is that the cost depends on
/// which way you look. A csv on disk is also the only form I can read back and analyse.
///
/// ⛔ BUFFERED, NEVER WRITTEN PER FRAME. Touching the disk 60 times a second would make the profiler
/// the bottleneck it is trying to find — the one failure a perf tool must not have. Samples go into
/// a list and are appended in blocks.
///
/// ⚠️ THE CAMERA ANGLE AND POSITION ARE COLUMNS. Without them a dip in the graph is unattributable:
/// "31 fps" at row 4000 means nothing unless you can see the yaw was 90 and the position was the
/// courtyard.
///
/// ⚠️ MARKERS ARE THE POINT OF THE FILE. `PerfLog.Mark("perk machines hidden")` stamps the next row,
/// so an A/B test is two labelled halves of one log rather than two numbers someone wrote down.
///
/// Written to FileSystem.Data, which on this machine is
/// `D:/SteamLibrary/steamapps/common/sbox/data/cifosi2500/nzombies_sbox#local/perf/`.
///
/// ⛔ THAT PATH MOVED, AND THE OLD LOGS DID NOT. Publishing rewrote the .sbproj `Org` from
/// `local` to `cifosi2500`, and FileSystem.Data is keyed on the org -- so every log written before
/// the publish is still under `.../data/local/nzombies_sbox#local/perf/` and new ones are not.
/// Both directories exist side by side, which is exactly how a run looks like it produced no log.
/// </summary>
public static class PerfLog
{
const string Dir = "perf";
/// <summary>
/// Every damage-path scope, in column order.
///
/// ⛔ ONE LIST DRIVES BOTH THE HEADER AND THE ROW. Building those independently is exactly how
/// a CSV ends up with a header that does not describe its own columns -- the same defect that put
/// 71 malformed tables in WEAPON_BALANCE.md. Add a scope here and both sides move together.
///
/// ⚠️ OUTERS CONTAIN INNERS, so these do NOT sum. `dmg.hit` wraps the whole per-hit block,
/// `dmg.ondamage` wraps Health.OnDamage, `dmg.apply` wraps Health.Apply. Outer minus the sum of
/// its inners is the cost still unaccounted for -- which is how the next one gets found.
///
/// ⚠️ PER HIT MEANS PER PELLET, PER PENETRATED BODY. A shotgun through a crowd runs all of
/// this once per pellet per zombie, so `dmg_hits` is the column to normalise by: a millisecond
/// total means nothing without knowing how many hits produced it.
/// </summary>
static readonly string[] DamageScopes =
{
// the three outers
"dmg.hit", "dmg.ondamage", "dmg.apply",
// bullet side, before damage exists
"dmg.trace", "dmg.trace.wide", "dmg.tags", "dmg.ricochet", "dmg.effects",
// Health.OnDamage, grouped by the system that owns the call
"dmg.phd", "dmg.firedby", "dmg.headshot", "dmg.headscale", "dmg.part",
"dmg.deadshot", "dmg.vigor", "dmg.tortoise", "dmg.helmet", "dmg.boss",
"dmg.mulekick", "dmg.time", "dmg.perk", "dmg.status", "dmg.fire",
"dmg.ammomods", "dmg.tech",
// Health.Apply and what it fires
"dmg.absorb", "dmg.revive", "dmg.react", "dmg.numbers", "dmg.die",
"dmg.award",
// ⛔ EVERY NAME HERE IS ACTUALLY INSTRUMENTED. A scope that is listed but never
// Measure()d logs a column of zeroes, and a zero reads as "this system is free" rather
// than "this was never wired" -- so this is the instrumented set, not the wish list.
// Three planned columns were dropped rather than shipped as zeroes: dmg.dmgfor and
// dmg.isflesh are call ARGUMENTS that cannot be timed without hoisting them out of their
// argument lists, and dmg.armor turned out to be the same call as dmg.absorb.
};
/// <summary>
/// The FIRING path, as opposed to the damage path.
///
/// ⛔ THIS IS WHERE THE FRAMERATE ACTUALLY WENT, and the damage columns are what proved it.
/// Measured: not shooting = 78.5 fps; a shot hitting 2 zombies = 42.0; a shot hitting 20 = 33.7.
/// So pulling the trigger costs 36.5 fps and every additional zombie the bullet passes through
/// costs, together, 8.3. The damage path explains 3.75 ms of the 10.59 ms a light hit-frame
/// adds, leaving 6.69 ms that no scope could see.
///
/// ⚠️ shot.total IS THE ONE TO READ FIRST. If it is much smaller than 6.69 ms then the cost is
/// not the firing CODE at all -- it is what firing hands to the engine for the rest of the frame
/// (audio mixing, particle simulation, viewmodel animation evaluation), and the next move is a
/// bisect by switching features off rather than more scopes.
///
/// ⚠️ shot.bullet CONTAINS dmg.hit, and shot.total contains shot.bullet. Three nested outers.
/// </summary>
static readonly string[] ShotScopes =
{
"shot.total", "shot.bullet", "shot.probe",
"shot.sound", "shot.effects", "shot.anim", "shot.bolt",
"shot.recoil", "shot.shake", "shot.vmrecoil", "shot.eyeangles", "shot.ui",
};
/// <summary>
/// Double Tap, and the fire-rate path it rides on.
///
/// ⛔ EVERY `DtapAugments.*For( weapon )` RESOLVER WALKS THE HIERARCHY --
/// `Components.Get<NZPlayer>( InAncestors | Enabled )` -- then does HasPerk plus a
/// PerkAugments string lookup, on every call. And wep.rpm is NOT per-shot: Weapon.Shoot
/// consults it from the per-frame input handling and IsShooting() calls it twice more, so it
/// runs on every frame the trigger is held. That is precisely the population that lost 36.5 fps.
///
/// ⚠️ THESE NEST: dtap.charge > wep.isshooting > wep.rpm > dtap.rate. Do not sum them.
/// </summary>
/// <summary>
/// UI work that happens AFTER the damage path, later in the same frame.
///
/// ⛔ THIS IS THE SHAPE OF EVERY COST THAT HAS ESCAPED SO FAR. A hitting shot was
/// measured dumping 0.99MB and 13ms onto the FOLLOWING frame while a missing shot dumped
/// 0.14MB and nothing -- deferred work, invisible to any scope inside Shoot or Health.
/// ui.dmgnum is the first candidate of that kind to get a column.
/// </summary>
static readonly string[] UiScopes =
{
"ui.dmgnum", "ui.dmgnum.sync", "ui.dmgnum.place",
"ui.tracer",
"ui.pointspop",
};
static readonly string[] DtapScopes =
{
"wep.rpm", "wep.isshooting", "dtap.charge",
"dtap.rate", "dtap.shotmult", "dtap.twin", "dtap.pierce", "dtap.recoil",
};
/// <summary>`dmg.hit` -> `cpu_dmg_hit`, so the header stays legal CSV and greppable.</summary>
static string ColumnName( string scope ) => "cpu_" + scope.Replace( '.', '_' );
/// <summary>
/// `dmg.hit` -> `n_dmg_hit` — how many TIMES the scope ran, beside how long it took.
///
/// ⛔ A TOTAL WITHOUT A DIVISOR CANNOT ANSWER "DOES THIS SCALE?", which is the only question
/// this log exists to answer. dmg.effects is the case that proves it: it wraps
/// CreateBulletImpact, which runs once per BODY HIT, and TracerEffects, which runs once per
/// BULLET — `hasTracer` is rolled before the penetration loop and only the final segment draws
/// one. One bullet through ten zombies is ten impacts and ONE tracer. With only a millisecond
/// total those two are indistinguishable, and dividing by hit count silently attributes a
/// fixed per-shot cost to every body.
/// </summary>
static string CallColumnName( string scope ) => "n_" + scope.Replace( '.', '_' );
/// <summary>How many rows to hold before appending them. ~1s at 60fps.</summary>
const int FlushRows = 64;
// ⛔ NULLABLE-BACKED / PLAIN STATICS — a static's VALUE survives a hotload but its initialiser
// does not re-run. INSTRUCTIONS.md §1. Nothing here seeds to a value that would be wrong if
// carried forward, and `_rows` being carried is harmless: it flushes on the next block.
static readonly List<string> _rows = new();
/// <summary>The file being written, or null when not logging.</summary>
public static string Path { get; private set; }
/// <summary>Is a log open?</summary>
public static bool Active => !string.IsNullOrEmpty( Path );
/// <summary>Rows written so far, including buffered ones.</summary>
public static int Rows { get; private set; }
/// <summary>Label stamped onto the next row, then cleared.</summary>
static string _pending;
/// <summary>The last label stamped, so every row carries the state it was taken under.</summary>
static string _state = "";
// The census is a full scene walk, so it is sampled on a timer and carried between rows.
static int _draws, _lights, _shadows;
static float _sinceCensus;
static int _ticks;
/// <summary>
/// Count a fixed-update tick.
///
/// ⚠️ TICKS ARE COUNTED, NOT LOGGED SEPARATELY. A row per tick and a row per frame in one file
/// would need a "which kind is this" column and half the fields blank on every other line. The
/// useful fact is how many ticks a frame covered — a frame that swallowed four is a hitch.
/// </summary>
public static void Tick() => _ticks++;
/// <summary>
/// Stamp the next row with a label.
///
/// ⚠️ IT STICKS. The label becomes the `state` column for every subsequent row until it is
/// changed, because that is what makes an A/B readable — the interesting thing is not the frame
/// the button was clicked, it is the two hundred frames after it.
/// </summary>
public static void Mark( string what )
{
_pending = what ?? "";
_state = _pending;
if ( Active ) Log.Info( $"[nz-perf] marked '{_state}' at row {Rows}" );
}
/// <summary>Start a new log. Returns the path, or null if it could not be opened.</summary>
public static string Start( string label = "" )
{
Stop();
// ⛔ THE LOG OWNS ITS OWN DRIVER. Sample() has to be called every frame by SOMETHING, and the
// first attempt hung it off PerfHud's update — which does not compile: a generated razor
// class is not reachable from a .cs file, the same constraint that put CherryShockState and
// MapBrowserState where they are. Hanging it off the UI would have been wrong anyway: the
// log must keep running with the overlay closed, and `nz_perf_log` alone would otherwise
// open a file, write a header and record not one row while reporting success.
EnsureDriver();
if ( !FileSystem.Data.DirectoryExists( Dir ) )
FileSystem.Data.CreateDirectory( Dir );
// ⚠️ NUMBERED, NOT TIMESTAMPED. DateTime is not something to rely on inside the sandbox, and
// a counter that probes for the first free name cannot collide or come out unsorted.
var map = MapNameOrUnknown();
var tag = string.IsNullOrWhiteSpace( label ) ? "" : $"_{Sanitise( label )}";
string path = null;
for ( int i = 1; i < 10000; i++ )
{
var candidate = $"{Dir}/{map}{tag}_{i:000}.csv";
if ( FileSystem.Data.FileExists( candidate ) ) continue;
path = candidate;
break;
}
if ( path is null ) { Log.Warning( "[nz-perf] could not find a free log name" ); return null; }
_rows.Clear();
Rows = 0;
_ticks = 0;
_sinceCensus = 99f;
_state = string.IsNullOrWhiteSpace( label ) ? "start" : label;
_pending = _state;
// ⚠️ THE HEADER IS WRITTEN IMMEDIATELY, so a log that captures nothing still exists and says
// so, rather than looking like the command silently failed.
FileSystem.Data.WriteAllText( path,
"row,time,ticks,fps,frame_ms,gpu_ms,bound,ram_mb,vram_mb,alloc_mb,"
+ "gen0,gen1,gen2,gc_ms,yaw,pitch,roll,"
+ "cam_x,cam_y,cam_z,ply_x,ply_y,ply_z,"
+ "draws,lights,shadows,zombies,sounds,"
// ⛔ THE CPU BREAKDOWN, WHICH IS WHAT THIS FILE COULD NOT ANSWER. frame_ms and gpu_ms
// together prove a frame was CPU-bound; they cannot say WHICH CPU work it was. These are
// millisecond totals for the frame, summed across every zombie, from CpuScope.
//
// ⚠️ cpu_zupd IS AN OUTER SCOPE and contains the others, so these columns do NOT sum to
// frame_ms. cpu_zupd minus the sum of the rest is the zombie work still unaccounted for,
// which is how the next unmeasured cost gets found rather than hidden.
//
// ⚠️ gc_ms ABOVE IS NANOSECONDS DESPITE ITS NAME — found while reading a survival log, where
// 89240 looked like 89 seconds of GC and is actually 0.089ms. Left unrenamed so older logs
// stay comparable; every cpu_* column here is milliseconds, like frame_ms.
+ "cpu_zupd,cpu_think,cpu_anim,cpu_face,cpu_voice,cpu_folv,cpu_step,cpu_sep,"
+ "cpu_vert,cpu_tscan,"
// ⛔ POSITION IS LOAD-BEARING, AND IT WAS WRONG ONCE. These columns must sit exactly
// where the row emits them: AFTER the cpu_* zombie block and BEFORE state. They were
// first added beside `sounds`, which gave a header with the right NUMBER of columns in
// the wrong ORDER -- so all 30 damage values would have been logged under the zombie
// headers. A field-count check passes that happily; only comparing ORDER catches it.
//
// ⚠️ dmg_hits IS THE NORMALISER, and every cpu_dmg_* column is meaningless without it:
// it is how many times Health.OnDamage ran this frame, i.e. pellets x bodies. One
// shotgun blast into a crowd is a single trigger pull and can be 100+ hits.
+ "dmg_hits,"
+ string.Join( ",", DamageScopes.Select( ColumnName ) ) + ","
// ⚠️ SAME LIST, SAME ORDER, IMMEDIATELY AFTER THE MS COLUMNS — so n_dmg_x always sits
// a fixed 30 columns to the right of cpu_dmg_x and the pair can be read together.
+ string.Join( ",", DamageScopes.Select( CallColumnName ) ) + ","
// ⚠️ SAME PATTERN AS THE DAMAGE COLUMNS: ms for every scope, then the call count for
// every scope, both walked from ShotScopes so header and row cannot drift.
+ string.Join( ",", ShotScopes.Select( ColumnName ) ) + ","
+ string.Join( ",", ShotScopes.Select( CallColumnName ) ) + ","
+ string.Join( ",", DtapScopes.Select( ColumnName ) ) + ","
+ string.Join( ",", DtapScopes.Select( CallColumnName ) ) + ","
+ string.Join( ",", UiScopes.Select( ColumnName ) ) + ","
+ string.Join( ",", UiScopes.Select( CallColumnName ) ) + ","
+ "state\n" );
Path = path;
Log.Info( $"[nz-perf] logging to {path}" );
return path;
}
/// <summary>Flush and close.</summary>
public static void Stop()
{
if ( !Active ) return;
Flush();
Log.Info( $"[nz-perf] closed {Path} — {Rows} row(s)" );
Path = null;
// ⚠️ THE DRIVER GOES WITH IT. Left running it would call Sample() every frame forever, and
// Sample's own Active check would make that free but invisible — a component nobody can see
// doing nothing is how a scene accumulates junk.
_driver?.Destroy();
_driver = null;
}
static GameObject _driver;
/// <summary>
/// Make sure something is calling Sample every frame.
///
/// ⚠️ REBUILT WHENEVER THE OBJECT IS GONE, not once — a GameObject created from code does not
/// survive a hotload, and a logger that silently stops recording after a code edit hands back a
/// truncated file that looks complete.
/// </summary>
static void EnsureDriver()
{
if ( _driver.IsValid() ) return;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-perf] no scene — nothing to log" ); return; }
_driver = scene.CreateObject();
_driver.Name = "Perf Log Driver";
_driver.Flags |= GameObjectFlags.NotSaved;
_driver.Components.Create<PerfLogDriver>();
}
/// <summary>Record one frame. Called from PerfHud's update, which runs every frame.</summary>
public static void Sample()
{
if ( !Active ) return;
var s = PerfProbe.Read();
// ⚠️ Census on a timer, carried between rows — it walks every component in the scene and
// running it per frame would dominate the very measurement being taken.
_sinceCensus += Time.Delta;
if ( _sinceCensus >= 3f )
{
_sinceCensus = 0f;
var rows = PerfCensus.Take();
_draws = rows.Sum( r => r.DrawCalls );
_lights = rows.Sum( r => r.Lights );
_shadows = rows.Sum( r => r.Shadowing );
}
// ⛔ BOTH POSITIONS, AND THEY ARE NOT THE SAME PLACE. The camera is what renders, so its
// ANGLES decide the cost — but "which spots on the map are bad" is a question about where
// the PLAYER stood, and this controller can put the camera well away from them. Logging one
// and calling it position produces a heatmap of camera positions labelled as player ones.
//
// ⚠️ PlayerCharacters.Local, not GetAllComponents<NZPlayer>().First() — that accessor exists
// because the plain query misses a disabled player, a trap this project has hit three times.
var cam = Game.ActiveScene?.Camera;
var ang = cam.IsValid() ? cam.WorldRotation.Angles() : default;
var camPos = cam.IsValid() ? cam.WorldPosition : Vector3.Zero;
var me = PlayerCharacters.Local();
var plyPos = me.IsValid() ? me.WorldPosition : Vector3.Zero;
// ⛔ ZOMBIE COUNT AND LIVE SOUND COUNT, EVERY FRAME. Without them a survival log cannot
// attribute anything: the first one showed 2.5-second GPU stalls at round boundaries and
// single-frame CPU spikes in between, and there was no column that could say whether either
// tracked the number of zombies or the number of sounds playing. Both hypotheses were
// untestable from the file, which is the one thing a diagnostic log must never be.
//
// ⚠️ ZombieAI.All IS A STATIC LIST, so this is free. GetAllComponents<ZombieAI>() every
// frame would be a scene walk, and the logger must not become the cost it is measuring.
var zombies = ZombieAI.All.Count;
// ⛔ THE GAMEMODE'S OWN VOICES, NOT THE ENGINE'S TOTAL. SoundHandle.CopyActiveUnfiltered and
// Audio.AudioMeter.Frame.VoiceCount both exist in the docs and neither is reachable from
// game code — internal. That turned out better: an engine-wide count would include gunfire,
// music and UI, while the suspicion is specifically about ZOMBIE voices, which are ours to
// count and ours to cap.
var sounds = ZombieAI.TotalVoices;
var bound = s.GpuMs <= 0.01 ? "?"
: s.GpuMs > s.FrameMs * 0.85 ? "gpu"
: s.GpuMs < s.FrameMs * 0.5 ? "cpu" : "even";
Rows++;
_rows.Add( string.Join( ",",
Rows,
$"{Time.Now:0.###}",
_ticks,
$"{s.Fps:0.##}",
$"{s.FrameMs:0.###}",
$"{s.GpuMs:0.###}",
bound,
$"{s.RamBytes / 1048576.0:0.#}",
$"{s.VramBytes / 1048576.0:0.#}",
$"{s.AllocBytes / 1048576.0:0.##}",
s.Gen0, s.Gen1, s.Gen2,
$"{s.GcPauseMs:0.###}",
// ⚠️ ROLL IS LOGGED TOO. It should be zero on a normal camera, so a non-zero column is
// the cheapest possible signal that something is tilting the view — which would make
// every yaw/pitch correlation drawn from the file meaningless.
$"{ang.yaw:0.#}",
$"{ang.pitch:0.#}",
$"{ang.roll:0.#}",
$"{camPos.x:0}", $"{camPos.y:0}", $"{camPos.z:0}",
$"{plyPos.x:0}", $"{plyPos.y:0}", $"{plyPos.z:0}",
_draws, _lights, _shadows,
zombies, sounds,
// ⚠️ THREE DECIMALS. One zombie's animation update is tens of MICROseconds, so at the
// two decimals the rest of this row uses every per-scope column would print 0.00 and
// read as the instrument being broken rather than the cost being small.
$"{CpuScope.Get( "zombie.update" ):0.###}",
$"{CpuScope.Get( "zombie.think" ):0.###}",
$"{CpuScope.Get( "zombie.anim" ):0.###}",
$"{CpuScope.Get( "zombie.face" ):0.###}",
$"{CpuScope.Get( "zombie.voice" ):0.###}",
$"{CpuScope.Get( "zombie.followvoices" ):0.###}",
$"{CpuScope.Get( "zombie.footsteps" ):0.###}",
$"{CpuScope.Get( "zombie.separation" ):0.###}",
$"{CpuScope.Get( "zombie.vertgate" ):0.###}",
$"{CpuScope.Get( "zombie.targetscan" ):0.###}",
// ⚠️ ONE JOINED FIELD, not one argument per scope. string.Join flattens it into the row
// exactly as separate arguments would, and it cannot fall out of step with the header
// because both sides walk DamageScopes.
$"{CpuScope.Calls( "dmg.ondamage" )}",
string.Join( ",", DamageScopes.Select(
// ⚠️ INVARIANT CULTURE, EXPLICITLY. This is a Portuguese Windows install, where the
// culture decimal separator is a COMMA -- which in a CSV would split every one of these
// 30 floats into two fields and shift the whole row. The rest of this file relies on the
// runtime happening to be invariant (it is; a survival log parsed fine), but that is
// luck rather than a guarantee, and it costs nothing to not depend on it here.
sc => CpuScope.Get( sc ).ToString( "0.###", CultureInfo.InvariantCulture ) ) ),
// ⚠️ THE DIVISORS. Integers, so no culture concern here.
string.Join( ",", DamageScopes.Select(
sc => CpuScope.Calls( sc ).ToString( CultureInfo.InvariantCulture ) ) ),
// the firing path, ms then calls -- same order the header emits them
string.Join( ",", ShotScopes.Select(
sc => CpuScope.Get( sc ).ToString( "0.###", CultureInfo.InvariantCulture ) ) ),
string.Join( ",", ShotScopes.Select(
sc => CpuScope.Calls( sc ).ToString( CultureInfo.InvariantCulture ) ) ),
// Double Tap and the fire-rate path, ms then calls
string.Join( ",", DtapScopes.Select(
sc => CpuScope.Get( sc ).ToString( "0.###", CultureInfo.InvariantCulture ) ) ),
string.Join( ",", DtapScopes.Select(
sc => CpuScope.Calls( sc ).ToString( CultureInfo.InvariantCulture ) ) ),
// deferred UI work, ms then calls
string.Join( ",", UiScopes.Select(
sc => CpuScope.Get( sc ).ToString( "0.###", CultureInfo.InvariantCulture ) ) ),
string.Join( ",", UiScopes.Select(
sc => CpuScope.Calls( sc ).ToString( CultureInfo.InvariantCulture ) ) ),
Csv( _pending ?? _state ) ) );
_ticks = 0;
_pending = null;
if ( _rows.Count >= FlushRows ) Flush();
}
/// <summary>
/// Append the buffer.
///
/// ⚠️ APPEND, NOT REWRITE. WriteAllText on a growing file would re-serialise the whole log every
/// flush — a few MB by minute two — and the cost would climb as the session went on, which is
/// precisely the shape of bug this tool is meant to catch.
/// </summary>
static void Flush()
{
if ( _rows.Count == 0 || !Active ) return;
var block = string.Concat( _rows.Select( r => r + "\n" ) );
_rows.Clear();
try
{
using var stream = FileSystem.Data.OpenWrite( Path, FileMode.Append );
var bytes = Encoding.UTF8.GetBytes( block );
stream.Write( bytes, 0, bytes.Length );
}
catch ( Exception e )
{
// ⛔ SAID OUT LOUD AND THE LOG IS CLOSED. A logger that silently stops writing hands
// back a truncated file that looks complete, and every conclusion drawn from it is
// wrong in a way nothing on screen indicates.
Log.Warning( $"[nz-perf] log write failed, stopping: {e.Message}" );
Path = null;
}
}
static string MapNameOrUnknown()
{
var m = NZMap.Current;
return string.IsNullOrWhiteSpace( m ) ? "unknown" : Sanitise( m );
}
static string Sanitise( string s )
=> new string( s.Select( c => char.IsLetterOrDigit( c ) ? c : '_' ).ToArray() ).Trim( '_' );
/// <summary>A csv field that cannot break the column count.</summary>
static string Csv( string s )
=> string.IsNullOrEmpty( s ) ? "" : s.Replace( ",", ";" ).Replace( "\n", " " );
// ── commands ─────────────────────────────────────────────────────────────
/// <summary>`nz_perf_log [label]` — start a log, or stop the one that is running.</summary>
[ConCmd( "nz_perf_log" )]
public static void LogCmd( string label = "" )
{
if ( Active ) { Stop(); return; }
Start( label );
}
/// <summary>`nz_perf_mark <what>` — label every row from here on.</summary>
[ConCmd( "nz_perf_mark" )]
public static void MarkCmd( string what = "" )
{
if ( !Active ) { Log.Info( "[nz-perf] not logging — nz_perf_log first" ); return; }
Mark( what );
}
/// <summary>`nz_perf_logs` — what has been written, so it can be found and read.</summary>
[ConCmd( "nz_perf_logs" )]
public static void ListCmd()
{
if ( !FileSystem.Data.DirectoryExists( Dir ) )
{
Log.Info( "[nz-perf] no logs yet — nz_perf_log to start one" );
return;
}
var files = FileSystem.Data.FindFile( Dir, "*.csv" ).ToList();
Log.Info( $"[nz-perf] {files.Count} log(s) in {Dir}/"
+ (Active ? $" (writing {Path}, {Rows} rows)" : "") );
foreach ( var f in files )
Log.Info( $"[nz-perf] {f}" );
}
}
/// <summary>
/// Calls <see cref="PerfLog.Sample"/> every frame and counts ticks.
///
/// ⛔ A COMPONENT OF ITS OWN, NOT A HOOK ON THE HUD. PerfLog is a static in a .cs file and the perf
/// panel is a generated razor class, which a .cs file cannot name — so the logger cannot reach into
/// the UI even if it wanted to. It should not want to: the log has to keep recording with the
/// overlay closed, and coupling the two would stop the file exactly when someone hid the panel to
/// look at whatever caused the dip.
/// </summary>
public sealed class PerfLogDriver : Component
{
protected override void OnUpdate() => PerfLog.Sample();
protected override void OnFixedUpdate() => PerfLog.Tick();
}