using System.Diagnostics; using System.IO.Pipes; using BepInEx.Logging; using TravelEar.Core; namespace TravelEar; /// /// Offset measurement, mod side (REQ-OFFSET-MEASURE, docs/DESIGN.md "Offset"): a named-pipe /// server on the return pipe (TravelEar.Sink.Back) that the Helper connects to and writes /// one per Sink frame whose first sample it rendered: the capture /// timestamp that travelled with the frame and the render timestamp, both on the machine's /// performance counter. The difference is Offset. A rolling 10 s average is logged every 10 s and /// kept in ; the Helper's window shows the same figure from its own /// pairs (M3 T4d). Every failure is absorbed and the /// server re-arms on the same cadence as the Sink pipe (docs/KNOWN-HAZARDS.md 2.1). /// internal sealed class OffsetMonitor : IDisposable { private const double LogIntervalMs = 10_000; private readonly ManualLogSource _log; private readonly string _pipeName; private readonly OffsetAverager _averager = new(); private readonly HelperLifecycle _lifecycle = new(); private readonly CancellationTokenSource _stop = new(); private Thread _thread; private double _lastAverageMs = double.NaN; public long Reports; public volatile bool Connected; public OffsetMonitor(ManualLogSource log, string pipeName) { _log = log; _pipeName = pipeName; } /// The last logged rolling average, in milliseconds; NaN before the first. public double LastAverageMs => Volatile.Read(ref _lastAverageMs); public void Start() { _thread = new Thread(Run) { Name = "TravelEar.OffsetMonitor", IsBackground = true }; _thread.Start(); } private static double NowMs() => Stopwatch.GetTimestamp() * 1000.0 / Stopwatch.Frequency; // [impl->REQ-OFFSET-MEASURE] private void Run() { var token = _stop.Token; var scratch = new byte[OffsetReportFrame.Size]; var nextLog = NowMs() + LogIntervalMs; while (!token.IsCancellationRequested) { NamedPipeServerStream server = null; try { var delay = (int)_lifecycle.DelayBeforeArmMs(NowMs()); if (delay > 0 && token.WaitHandle.WaitOne(delay)) break; server = new NamedPipeServerStream(_pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); server.WaitForConnectionAsync(token).GetAwaiter().GetResult(); Connected = true; _log.LogInfo("Offset: Helper connected to the return pipe."); while (!token.IsCancellationRequested) { if (!OffsetReportFrame.TryReadFrom(server, out var report, scratch)) break; var now = NowMs(); var offsetMs = (report.RenderTimestamp - report.CaptureTimestamp) * 1000.0 / Stopwatch.Frequency; _averager.Add(offsetMs, now); Interlocked.Increment(ref Reports); if (now >= nextLog) { nextLog = now + LogIntervalMs; if (_averager.TryAverage(now, out var avg, out var min, out var max, out var count)) { Volatile.Write(ref _lastAverageMs, avg); _log.LogInfo($"Offset: {avg:F0} ms rolling 10 s average ({count} frames, {min:F0}-{max:F0} ms)."); } } } } catch (OperationCanceledException) { } catch (Exception e) when (e is IOException or ObjectDisposedException or InvalidDataException) { if (Connected) _log.LogInfo($"Offset: return pipe closed ({e.GetType().Name}); waiting for a new connection."); } catch (Exception e) { _log.LogWarning($"Offset: monitor error, re-arming: {e.Message}"); } finally { Connected = false; server?.Dispose(); } } } public void Dispose() { _stop.Cancel(); _thread?.Join(500); } }