using System.Globalization; using System.Text; namespace XFESystem.Kernel; public readonly record struct NativePointerTimingCounters(long Observed, long Consumed, long Lost); /// Opt-in, UI-owned timing of real completed presents; never logs or allocates in its hot path. public sealed class NativePointerFrameProbe { public const int Capacity = 4096; private const int FramesPerChunk = 256; private readonly Frame[][] frames = PrepareChunks(); private int count; private long frequency, startTicks, framesBefore, lastFrameSequence, droppedFrames, overflow, invalidInputs, duplicateCalls; private long latestInputSequence, latestInputTicks, latestDispatchTicks, firstInputTicks, lastInputTicks; private int latestX, latestY; private NativePointerTimingCounters countersBefore; public bool IsCapturing { get; private set; } public void Start(long frequency, long startTicks, long framesPresented, NativePointerTimingCounters counters) { if (IsCapturing) throw new InvalidOperationException("A native pointer timing sample is already active."); if (frequency <= 0 || startTicks < 0 || framesPresented < 0 || counters.Observed < 0 || counters.Consumed < 0 || counters.Lost < 0 || counters.Consumed > counters.Observed) throw new ArgumentOutOfRangeException(nameof(counters)); this.frequency = frequency; this.startTicks = startTicks; framesBefore = lastFrameSequence = framesPresented; countersBefore = counters; count = 0; droppedFrames = overflow = invalidInputs = duplicateCalls = 0; latestInputSequence = latestInputTicks = latestDispatchTicks = firstInputTicks = lastInputTicks = 0; latestX = latestY = 0; IsCapturing = true; } public void ObserveConsumed(long sequence, long reportTicks, long dispatchTicks, int x, int y) { if (!IsCapturing) return; if (sequence <= 0 || reportTicks < 0 || dispatchTicks < reportTicks || x < 0 || y < 0 || sequence < latestInputSequence) { invalidInputs++; return; } latestInputSequence = sequence; latestInputTicks = reportTicks; latestDispatchTicks = dispatchTicks; latestX = x; latestY = y; if (sequence > countersBefore.Observed) { if (reportTicks < startTicks) { invalidInputs++; return; } if (firstInputTicks == 0) firstInputTicks = reportTicks; lastInputTicks = reportTicks; } } /// Call after Canvas.FramesPresented has really advanced, using that counter and the actual copy bounds. public void CapturePresent(long framesPresented, long beginTicks, long endTicks) { if (!IsCapturing) return; // An aborted/yielded render or no-op Present must not fabricate a frame. if (framesPresented == lastFrameSequence) { duplicateCalls++; return; } if (framesPresented < lastFrameSequence || beginTicks < startTicks || endTicks < beginTicks || count > 0 && beginTicks < FrameAt(count - 1).EndTicks) { droppedFrames++; return; } droppedFrames += framesPresented - lastFrameSequence - 1; lastFrameSequence = framesPresented; if (count == Capacity) { droppedFrames++; overflow++; return; } frames[count / FramesPerChunk][count % FramesPerChunk] = new(framesPresented, beginTicks, endTicks, latestInputSequence, latestInputTicks, latestDispatchTicks, latestX, latestY); count++; } /// Stop first, then allocate/format the bounded dump outside the measurement window. public string Stop(long endTicks, long framesPresented, NativePointerTimingCounters counters) { var aggregate = new StringBuilder(); Stop(endTicks, framesPresented, counters, chunk => aggregate.Append(chunk)); return aggregate.ToString(); } /// Native callers use this overload: each emitted UTF-16 chunk is at most 32 KiB. public void Stop(long endTicks, long framesPresented, NativePointerTimingCounters counters, Action emit) { if (!IsCapturing) throw new InvalidOperationException("No native pointer timing sample is active."); ArgumentNullException.ThrowIfNull(emit); IsCapturing = false; const int maximumChunkCharacters = 16_000; var output = new StringBuilder(maximumChunkCharacters); output.Append("XFE_POINTER_TIMING_BEGIN|1\nMETA"); Add(output, "Frequency", frequency); Add(output, "Capacity", Capacity); Add(output, "StartTicks", startTicks); Add(output, "EndTicks", endTicks); Add(output, "FramesBefore", framesBefore); Add(output, "FramesAfter", framesPresented); Add(output, "FramesPresentedDelta", framesPresented - framesBefore); Add(output, "CapturedFrames", count); Add(output, "Overflow", overflow); Add(output, "InputsBefore", countersBefore.Observed); Add(output, "InputsAfter", counters.Observed); Add(output, "ObservedBefore", countersBefore.Observed); Add(output, "ObservedAfter", counters.Observed); Add(output, "ConsumedBefore", countersBefore.Consumed); Add(output, "ConsumedAfter", counters.Consumed); Add(output, "FirstInputTicks", firstInputTicks); Add(output, "LastInputTicks", lastInputTicks); Add(output, "LostBefore", countersBefore.Lost); Add(output, "LostAfter", counters.Lost); Add(output, "DroppedFrames", droppedFrames); Add(output, "InvalidInputReports", invalidInputs); Add(output, "IgnoredDuplicatePresentCalls", duplicateCalls); output.Append('\n'); for (int index = 0; index < count; index++) { Frame frame = FrameAt(index); var row = new StringBuilder(320); row.Append("FRAME"); Add(row, "Seq", frame.Sequence); Add(row, "BeginTicks", frame.BeginTicks); Add(row, "EndTicks", frame.EndTicks); Add(row, "InputSeq", frame.InputSequence); Add(row, "InputTicks", frame.InputTicks); Add(row, "DispatchTicks", frame.DispatchTicks); Add(row, "X", frame.X); Add(row, "Y", frame.Y); row.Append('\n'); if (output.Length + row.Length > maximumChunkCharacters) { emit(output.ToString()); output.Clear(); } output.Append(row); } if (output.Length + 23 > maximumChunkCharacters) { emit(output.ToString()); output.Clear(); } output.Append("XFE_POINTER_TIMING_END\n"); emit(output.ToString()); } private Frame FrameAt(int index) => frames[index / FramesPerChunk][index % FramesPerChunk]; private static Frame[][] PrepareChunks() { var chunks = new Frame[Capacity / FramesPerChunk][]; for (int index = 0; index < chunks.Length; index++) chunks[index] = new Frame[FramesPerChunk]; return chunks; } private static void Add(StringBuilder output, string name, long value) => output.Append('|').Append(name).Append('|').Append(value.ToString(CultureInfo.InvariantCulture)); private readonly record struct Frame(long Sequence, long BeginTicks, long EndTicks, long InputSequence, long InputTicks, long DispatchTicks, int X, int Y); }