Diagnostics/PerfLog.cs

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.

File Access
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();
}