aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core/Diagnostics
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
commit39066900773e7857faf2d02e7ed51b71d219d97d (patch)
treeffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src/Godosa.Core/Diagnostics
downloadgodosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz
godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip
godosa-engine: initial public history
Diffstat (limited to 'src/Godosa.Core/Diagnostics')
-rw-r--r--src/Godosa.Core/Diagnostics/DebugFlags.cs20
-rw-r--r--src/Godosa.Core/Diagnostics/EngineDebug.cs25
-rw-r--r--src/Godosa.Core/Diagnostics/FrameTimeStats.cs139
-rw-r--r--src/Godosa.Core/Diagnostics/PerfCsv.cs27
4 files changed, 211 insertions, 0 deletions
diff --git a/src/Godosa.Core/Diagnostics/DebugFlags.cs b/src/Godosa.Core/Diagnostics/DebugFlags.cs
new file mode 100644
index 0000000..27b63bf
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/DebugFlags.cs
@@ -0,0 +1,20 @@
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>Opt-in debug categories from one environment value: a comma list,
+/// case-insensitive; <c>all</c> turns on every category but not <see cref="Exact"/>
+/// switches (perf experiments like <c>nohud</c>).</summary>
+public sealed class DebugFlags
+{
+ private readonly string[] _names;
+
+ public DebugFlags(string? value) =>
+ _names = (value ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ public bool On(string category) =>
+ Array.Exists(_names, n =>
+ n.Equals(category, StringComparison.OrdinalIgnoreCase) ||
+ n.Equals("all", StringComparison.OrdinalIgnoreCase));
+
+ public bool Exact(string name) =>
+ Array.Exists(_names, n => n.Equals(name, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/src/Godosa.Core/Diagnostics/EngineDebug.cs b/src/Godosa.Core/Diagnostics/EngineDebug.cs
new file mode 100644
index 0000000..3cad819
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/EngineDebug.cs
@@ -0,0 +1,25 @@
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>The lib's own debug categories (<c>input</c>, <c>script</c>, <c>audio</c>), read
+/// from the game's variable: call <see cref="Configure"/> first in <c>Main</c>, before any lib
+/// code runs. Gate each call on its flag so disabled logging costs one bool check and no
+/// string formatting.</summary>
+public static class EngineDebug
+{
+ public static bool Input { get; private set; }
+ public static bool Script { get; private set; }
+ public static bool Audio { get; private set; }
+
+ public static void Configure(string environmentVariable) =>
+ Apply(new DebugFlags(Environment.GetEnvironmentVariable(environmentVariable)));
+
+ public static void Apply(DebugFlags flags)
+ {
+ Input = flags.On("input");
+ Script = flags.On("script");
+ Audio = flags.On("audio");
+ }
+
+ public static void Write(string category, string message) =>
+ Console.Error.WriteLine($"[{category}] {message}");
+}
diff --git a/src/Godosa.Core/Diagnostics/FrameTimeStats.cs b/src/Godosa.Core/Diagnostics/FrameTimeStats.cs
new file mode 100644
index 0000000..2d1945c
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/FrameTimeStats.cs
@@ -0,0 +1,139 @@
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>One snapshot of the rolling frame window (game-loop spec §5). Times in ms.</summary>
+public readonly record struct FrameStatsSummary(
+ int Frames,
+ double Fps,
+ double AvgMs,
+ double P50Ms,
+ double P95Ms,
+ double P99Ms,
+ double MaxMs,
+ double OnePercentLowFps,
+ int Hitches,
+ double AvgWorkMs,
+ double SimTickRate,
+ double AvgTickMs,
+ int GpuSamples = 0,
+ double AvgGpuMs = 0);
+
+/// <summary>
+/// Rolling window of render-frame timings (game-loop spec §5). FPS is frames ÷
+/// window time, percentiles are nearest-rank over frame times, 1% low is the
+/// mean of the slowest 1% of frames, and a hitch is a frame over 2× the median.
+/// Buffers are preallocated so recording and summarizing don't allocate.
+/// </summary>
+public sealed class FrameTimeStats
+{
+ private readonly double[] _intervals;
+ private readonly double[] _work;
+ private readonly int[] _ticks;
+ private readonly double[] _sim;
+ private readonly double[] _sorted;
+ private readonly double[] _gpu;
+ private int _next, _gpuNext, _gpuCount;
+
+ public FrameTimeStats(int capacity = 240)
+ {
+ _intervals = new double[capacity];
+ _work = new double[capacity];
+ _ticks = new int[capacity];
+ _sim = new double[capacity];
+ _sorted = new double[capacity];
+ _gpu = new double[capacity];
+ }
+
+ /// <summary>GPU time of one frame (timer queries report a few frames late, so these
+ /// roll separately from <see cref="Record"/>).</summary>
+ public void RecordGpu(double seconds)
+ {
+ _gpu[_gpuNext] = seconds;
+ _gpuNext = (_gpuNext + 1) % Capacity;
+ if (_gpuCount < Capacity) _gpuCount++;
+ }
+
+ public int Capacity => _intervals.Length;
+ public int Count { get; private set; }
+
+ /// <param name="intervalSeconds">Time since the previous frame started.</param>
+ /// <param name="workSeconds">CPU time spent inside this frame's render callback.</param>
+ /// <param name="simTicks">Simulation ticks run this frame.</param>
+ /// <param name="simSeconds">CPU time those ticks took.</param>
+ public void Record(double intervalSeconds, double workSeconds, int simTicks, double simSeconds)
+ {
+ _intervals[_next] = intervalSeconds;
+ _work[_next] = workSeconds;
+ _ticks[_next] = simTicks;
+ _sim[_next] = simSeconds;
+ _next = (_next + 1) % Capacity;
+ if (Count < Capacity) Count++;
+ }
+
+ /// <summary>Copies frame intervals in ms, oldest first; returns how many were written.</summary>
+ public int CopyIntervalsMs(Span<float> destination)
+ {
+ int n = Math.Min(Count, destination.Length);
+ int start = (_next - n + Capacity) % Capacity;
+ for (int i = 0; i < n; i++)
+ destination[i] = (float)(_intervals[(start + i) % Capacity] * 1000.0);
+ return n;
+ }
+
+ public FrameStatsSummary Summarize()
+ {
+ int n = Count;
+ if (n == 0) return default(FrameStatsSummary) with { GpuSamples = _gpuCount, AvgGpuMs = GpuAverageMs() };
+
+ double total = 0, work = 0, sim = 0;
+ int ticks = 0;
+ for (int i = 0; i < n; i++)
+ {
+ total += _intervals[i];
+ work += _work[i];
+ ticks += _ticks[i];
+ sim += _sim[i];
+ _sorted[i] = _intervals[i];
+ }
+ Array.Sort(_sorted, 0, n);
+
+ double median = NearestRank(50, n);
+ int slowest = Math.Max(1, (int)Math.Ceiling(n * 0.01));
+ double slowestSum = 0;
+ for (int i = n - slowest; i < n; i++) slowestSum += _sorted[i];
+
+ int hitches = 0;
+ for (int i = 0; i < n; i++)
+ if (_intervals[i] > 2 * median) hitches++;
+
+ return new FrameStatsSummary(
+ Frames: n,
+ Fps: total > 0 ? n / total : 0,
+ AvgMs: total / n * 1000,
+ P50Ms: median * 1000,
+ P95Ms: NearestRank(95, n) * 1000,
+ P99Ms: NearestRank(99, n) * 1000,
+ MaxMs: _sorted[n - 1] * 1000,
+ OnePercentLowFps: slowestSum > 0 ? slowest / slowestSum : 0,
+ Hitches: hitches,
+ AvgWorkMs: work / n * 1000,
+ SimTickRate: total > 0 ? ticks / total : 0,
+ AvgTickMs: ticks > 0 ? sim / ticks * 1000 : 0,
+ GpuSamples: _gpuCount,
+ AvgGpuMs: GpuAverageMs());
+ }
+
+ private double GpuAverageMs()
+ {
+ if (_gpuCount == 0) return 0;
+ double sum = 0;
+ for (int i = 0; i < _gpuCount; i++) sum += _gpu[i];
+ return sum / _gpuCount * 1000;
+ }
+
+ /// <summary>Nearest-rank percentile of the first <paramref name="n"/> sorted intervals.</summary>
+ private double NearestRank(int percent, int n)
+ {
+ int rank = Math.Max(1, (int)Math.Ceiling(percent / 100.0 * n));
+ return _sorted[rank - 1];
+ }
+}
diff --git a/src/Godosa.Core/Diagnostics/PerfCsv.cs b/src/Godosa.Core/Diagnostics/PerfCsv.cs
new file mode 100644
index 0000000..6c4ef0d
--- /dev/null
+++ b/src/Godosa.Core/Diagnostics/PerfCsv.cs
@@ -0,0 +1,27 @@
+using System.Globalization;
+
+namespace Godosa.Core.Diagnostics;
+
+/// <summary>Per-frame CSV rows for perf-log stall hunts. Fixed columns first, then the
+/// caller's own (<see cref="Header"/> names, <see cref="Row"/> values, same order).
+/// Invariant culture; times in ms with 3 decimals; blank = unknown (e.g. GPU before a
+/// timer query reports).</summary>
+public static class PerfCsv
+{
+ public const string BaseHeader = "t_s,frame_ms,work_ms,sim_ticks,sim_ms,gpu_ms";
+
+ public static string Header(params string[] extraColumns) =>
+ extraColumns.Length == 0 ? BaseHeader : BaseHeader + "," + string.Join(',', extraColumns);
+
+ public static string Row(double timeSeconds, double frameSeconds, double workSeconds, int simTicks,
+ double simSeconds, double? gpuSeconds, params string[] extraValues)
+ {
+ string row = string.Create(CultureInfo.InvariantCulture,
+ $"{timeSeconds:F3},{frameSeconds * 1000:F3},{workSeconds * 1000:F3},{simTicks},{simSeconds * 1000:F3},{Ms(gpuSeconds)}");
+ return extraValues.Length == 0 ? row : row + "," + string.Join(',', extraValues);
+ }
+
+ /// <summary>Seconds → ms with 3 decimals (invariant); blank for null.</summary>
+ public static string Ms(double? seconds) =>
+ seconds is { } s ? (s * 1000).ToString("F3", CultureInfo.InvariantCulture) : "";
+}