using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading; using System.Linq; using System.Reflection; using AlarmMgrDataProviderCOM; using aaAlarmManagedClient; using ArchestrA.MxAccess; using Xunit.Abstractions; namespace MxGateway.Worker.Tests; /// /// Runtime probe — registers as an AlarmClient consumer with a real /// hidden message-only window, subscribes to a Galaxy alarm provider, /// and logs every Win32 message that arrives during a fixed pump /// window. The intent is to identify the WM_APP / RegisterWindowMessage /// ID that AVEVA's alarm provider posts when alarms change, plus the /// wParam/lParam semantics on each. /// /// Skip-gated by default; flip Skip=null and run against the live dev /// rig to capture output. Requires: /// /// A reachable Galaxy with at least one alarmable object. /// The configured Galaxy expression below to match a real provider (default "\\Galaxy" — adjust if needed). /// An alarm trigger during the pump window (raise / ack / clear something in the Galaxy via System Platform IDE) — without one, only ambient activity is captured. /// /// public sealed class AlarmClientWmProbeTests : IDisposable { // Probe configuration. Override in the constructor below if needed. // Try multiple subscription expressions sequentially (each Subscribe call // adds to the consumer's scope). The "everything" form varies by AVEVA // version — we shotgun common forms. private static readonly string[] SubscriptionExpressions = { @"\Galaxy!", // documented "all groups under Galaxy provider" @"\Galaxy!*", // wildcard variant @"\\Galaxy!", // double-backslash UNC-style @"\Galaxy!TestArea", // explicit area where TestMachine_001 lives @"\\.\Galaxy!", // local-host prefix }; private const string SubscriptionExpression = @"\Galaxy!"; private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(60); private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500); private static readonly TimeSpan FireMarkerAt = TimeSpan.FromSeconds(10); private static readonly TimeSpan ClearMarkerAt = TimeSpan.FromSeconds(35); // Tag the operator should flip while the probe is pumping. Default // matches the dev rig's known alarmable boolean. private const string TriggerTagReference = "TestMachine_001.TestAlarm001"; [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CreateWindowExW")] private static extern IntPtr CreateWindowEx( int dwExStyle, string lpClassName, string lpWindowName, int dwStyle, int X, int Y, int nWidth, int nHeight, IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam); [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool DestroyWindow(IntPtr hWnd); [DllImport("user32.dll", SetLastError = true)] private static extern ushort RegisterClassW(ref WNDCLASSW lpWndClass); [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool UnregisterClassW(string lpClassName, IntPtr hInstance); [DllImport("user32.dll")] private static extern IntPtr DefWindowProcW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg); [DllImport("user32.dll")] private static extern IntPtr DispatchMessage(ref MSG lpMsg); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool TranslateMessage(ref MSG lpMsg); [DllImport("kernel32.dll")] private static extern IntPtr GetModuleHandle(string lpModuleName); [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern uint RegisterWindowMessage(string lpString); private const int HWND_MESSAGE = -3; private const uint PM_REMOVE = 0x0001; private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct WNDCLASSW { public uint style; public IntPtr lpfnWndProc; public int cbClsExtra; public int cbWndExtra; public IntPtr hInstance; public IntPtr hIcon; public IntPtr hCursor; public IntPtr hbrBackground; [MarshalAs(UnmanagedType.LPWStr)] public string? lpszMenuName; [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; } [StructLayout(LayoutKind.Sequential)] private struct MSG { public IntPtr hwnd; public uint message; public IntPtr wParam; public IntPtr lParam; public uint time; public int x; public int y; } private readonly ITestOutputHelper output; private readonly ConcurrentQueue log = new ConcurrentQueue(); private readonly Stopwatch elapsed = Stopwatch.StartNew(); private GCHandle wndProcHandle; private IntPtr probeWindow = IntPtr.Zero; private string? registeredClass; public AlarmClientWmProbeTests(ITestOutputHelper output) { this.output = output; } [Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture alarm-path behavior")] public void ProbeAlarmClientWmMessages() { // 1. Pre-resolve a few candidate RegisterWindowMessage strings so any // matches in the captured log can be labeled. None of these is // confirmed; we record what each resolves to so the actual AVEVA // message ID (whatever it turns out to be) can be cross-referenced. string[] candidateNames = { "WW_AlarmConsumer", "WW_AlarmManager", "WW_Alarm", "WNAL_AlarmChange", "WNAL_AlarmChanges", "WNAL_AlarmNotify", "WNAL_Notify", "WNAL_ChangeNotification", "AlarmManager.Notify", "AlarmManagerNotify", "ArchestrA.AlarmChange", "AVEVA.AlarmNotify", "aaAlarmManagedClient.Notify", "GotAlarmChanges", "OnAlarmChanges", }; foreach (string name in candidateNames) { uint id = RegisterWindowMessage(name); output.WriteLine($"RegisterWindowMessage(\"{name}\") -> 0x{id:X4} ({id})"); } output.WriteLine(""); // 2. Spin up a single STA-affinitized thread, create a hidden message- // only window owned by it, run RegisterConsumer + Subscribe against // that window's hWnd, then pump messages on that thread for the // configured duration. Threading discipline matches the worker's // StaRuntime model. Exception? threadException = null; var pumpDone = new ManualResetEventSlim(false); var thread = new Thread(() => { try { RunProbe(); } catch (Exception ex) { threadException = ex; } finally { pumpDone.Set(); } }); thread.IsBackground = false; thread.SetApartmentState(ApartmentState.STA); thread.Start(); pumpDone.Wait(); thread.Join(); // 3. Drain the log to xunit output regardless of outcome — partial // captures are still informative. output.WriteLine(""); output.WriteLine($"Captured {log.Count} log line(s):"); while (log.TryDequeue(out string? line)) { output.WriteLine(line); } if (threadException != null) { throw threadException; } } private void RunProbe() { // 3a. Register a window class and create a message-only window. WndProc wndProc = ProbeWndProc; wndProcHandle = GCHandle.Alloc(wndProc); // keep delegate alive registeredClass = "MxGatewayAlarmProbe_" + Guid.NewGuid().ToString("N"); var cls = new WNDCLASSW { style = 0, lpfnWndProc = Marshal.GetFunctionPointerForDelegate(wndProc), hInstance = GetModuleHandle(null!), lpszClassName = registeredClass, }; ushort atom = RegisterClassW(ref cls); if (atom == 0) { int err = Marshal.GetLastWin32Error(); Log($"RegisterClass failed err=0x{err:X8}"); return; } Log($"RegisterClass ok atom=0x{atom:X4} class={registeredClass}"); probeWindow = CreateWindowEx( dwExStyle: 0, lpClassName: registeredClass, lpWindowName: "AlarmProbe", dwStyle: 0, X: 0, Y: 0, nWidth: 0, nHeight: 0, hWndParent: (IntPtr)HWND_MESSAGE, hMenu: IntPtr.Zero, hInstance: cls.hInstance, lpParam: IntPtr.Zero); if (probeWindow == IntPtr.Zero) { int err = Marshal.GetLastWin32Error(); Log($"CreateWindowEx(HWND_MESSAGE) failed err=0x{err:X8}"); return; } Log($"Created message-only window hWnd=0x{probeWindow.ToInt64():X}"); // 3b. Create the AlarmClient and try the lifecycle. RegisterConsumer // accepts an int hWnd — narrow the IntPtr (sufficient on x86). AlarmClient? client = null; try { client = new AlarmClient(); // Try InitializeConsumer first — separate from RegisterConsumer // per the discovered API surface; previous probe runs skipped // it. Some AVEVA managed-client patterns require Initialize // before Register; others reverse the order. Try Initialize // first; on failure proceed to Register. try { int init = client.InitializeConsumer("AlarmProbe.Tests"); Log($"InitializeConsumer -> {init}"); } catch (Exception ex) { Log($"InitializeConsumer threw: {ex.GetType().Name}: {ex.Message}"); } int register = client.RegisterConsumer( hWnd: probeWindow.ToInt32(), szProductName: "AlarmProbe", szApplicationName: "AlarmProbe.Tests", szVersion: "1.0", bRetainHiddenAlarms: false); Log($"RegisterConsumer -> {register}"); LogProviders(client, "after Register"); // Dump the eQueryType enum so we can see what alternatives exist // beyond qtSummary, in case Summary aggregates and we need a // List/Snapshot mode instead. try { Type qt = typeof(eQueryType); Log($"eQueryType enum values: " + string.Join(", ", Enum.GetNames(qt).Select(n => $"{n}=0x{Convert.ToInt32(Enum.Parse(qt, n)):X}"))); Type af = typeof(eAlarmFilterState); Log($"eAlarmFilterState enum values: " + string.Join(", ", Enum.GetNames(af).Select(n => $"{n}=0x{Convert.ToInt32(Enum.Parse(af, n)):X}"))); } catch (Exception ex) { Log($"Enum dump threw: {ex.Message}"); } // qtHistory + state=ActiveNow: stream historical alarm transitions // including active alarms. asNone for FilterMask/Spec might // literally mean "match alarms in state 'none'" (i.e., nothing), // since the eAlarmFilterState enum is 0/1/2/3 single-states not // flag bits. Try ActiveNow explicitly. // Subscribe to every candidate expression — AVEVA accepts multiple // overlapping subscriptions; whichever matches the producer wins. foreach (string expr in SubscriptionExpressions) { try { int subscribe = client.Subscribe( szSubscription: expr, wFromPri: 0, wToPri: short.MaxValue, QueryType: eQueryType.qtSummary, SortFlags: eSortFlags.sfReturnNewestFirst, FilterMask: eAlarmFilterState.asAlarmActiveNow, FilterSpecification: eAlarmFilterState.asAlarmActiveNow); Log($"Subscribe('{expr}') -> {subscribe}"); } catch (Exception ex) { Log($"Subscribe('{expr}') threw: {ex.GetType().Name}: {ex.Message}"); } } LogProviders(client, "after Subscribe-multi"); // 3c. Pump for the configured duration. Log every message we see // (filtered light to avoid noise from WM_PAINT / WM_TIMER / // WM_GETICON spam from typical pumps). Poll GetStatistics on // a tight cadence so any alarm transition is captured. Print // "fire" / "clear" markers at fixed wallclock offsets so the // operator can flip the trigger boolean during the run. Log($"Probe running for {PumpDuration.TotalSeconds:F0}s. " + $"Observing {TriggerTagReference} alarm transitions. " + "External trigger expected from System Platform script (10s flip cadence)."); DateTime probeStart = DateTime.UtcNow; DateTime deadline = probeStart + PumpDuration; DateTime nextPoll = probeStart + PollInterval; int pollCount = 0; while (DateTime.UtcNow < deadline) { while (PeekMessage(out MSG msg, IntPtr.Zero, 0, 0, PM_REMOVE)) { LogIfInteresting(msg); TranslateMessage(ref msg); DispatchMessage(ref msg); } // Trigger is supplied externally — a System Platform script // flips TestMachine_001.TestAlarm001 every 10s. The probe // observes only. if (DateTime.UtcNow >= nextPoll) { PollGetStatistics(client, ++pollCount); LogProviders(client, $"poll #{pollCount}"); PollAllChannels(client, pollCount); nextPoll = DateTime.UtcNow + PollInterval; } Thread.Sleep(10); } Log($"Pump duration {PumpDuration.TotalSeconds:F0}s elapsed; deregistering."); try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); } catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); } } finally { try { client?.Dispose(); } catch { /* swallow */ } if (probeWindow != IntPtr.Zero) { DestroyWindow(probeWindow); probeWindow = IntPtr.Zero; } if (registeredClass != null) { UnregisterClassW(registeredClass, GetModuleHandle(null!)); } } } private string lastStatsSummary = string.Empty; private string lastProvidersSummary = string.Empty; private string lastHighPriSummary = string.Empty; private string lastSfStatsSummary = string.Empty; /// /// Try every read API the AlarmClient exposes and log when its /// output changes. AlarmClient has at least three distinct read /// surfaces — GetStatistics (current-change array), GetHighPriAlarm /// (single-record peek), and the SF (stored filter) family — and any /// of them might be the populated one. /// private static AlarmRecord NewAlarmRecord() { // The interop's auto-marshal flips DateTime fields to FILETIME on // the way IN as well as OUT. default(DateTime) (year 1) is outside // FILETIME's representable range, so initialize all DateTime fields // to the FILETIME epoch (1601-01-01 UTC) to satisfy the marshaler. AlarmRecord rec = new AlarmRecord(); DateTime epoch = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc); foreach (var f in typeof(AlarmRecord).GetFields( BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic)) { if (f.FieldType == typeof(DateTime)) { object boxed = rec; f.SetValue(boxed, epoch); rec = (AlarmRecord)boxed; } } return rec; } private void PollAllChannels(AlarmClient client, int seq) { // Channel A: GetHighPriAlarm — direct peek of highest-priority alarm. try { AlarmRecord rec = NewAlarmRecord(); int rc = client.GetHighPriAlarm(ref rec); string desc = rc == 0 ? DescribeAlarmRecord(rec) : ""; string summary = $"rc={rc} {desc}"; if (summary != lastHighPriSummary) { Log($"GetHighPriAlarm #{seq}: {summary} (changed)"); lastHighPriSummary = summary; } } catch (Exception ex) { string es = $"{ex.GetType().Name}: {ex.Message}"; if (es != lastHighPriSummary) { Log($"GetHighPriAlarm #{seq}: threw {es}"); lastHighPriSummary = es; } } // Channel C: GetAlarmExtendedRec by index. Try indices 0..3 directly; // populated alarms (if any) appear at low indices. for (int idx = 0; idx <= 2; idx++) { try { AlarmRecord rec = NewAlarmRecord(); int rc = client.GetAlarmExtendedRec(idx, ref rec); if (rc == 0) { string desc = DescribeAlarmRecord(rec); Log($"GetAlarmExtendedRec(idx={idx}) #{seq}: rc=0 -> {desc}"); break; // log first present record only } } catch (Exception ex) { if (idx == 0) { Log($"GetAlarmExtendedRec(idx=0) #{seq}: threw {ex.GetType().Name}: {ex.Message}"); } break; } } // Channel B: SF — snapshot + GetStatistics + iterate. try { uint numAlarms = 0; int sfCreate = client.SFCreateSnapshot(0, ref numAlarms); int unackRet = 0, unackAlm = 0, ackAlm = 0, others = 0, events = 0, idxNewest = 0; int sfStats = client.SFGetStatistics( ref unackRet, ref unackAlm, ref ackAlm, ref others, ref events, ref idxNewest); string summary = $"SFCreate={sfCreate} numAlarms={numAlarms} " + $"SFStats={sfStats} unackRet={unackRet} unackAlm={unackAlm} " + $"ackAlm={ackAlm} others={others} events={events} idxNewest={idxNewest}"; if (summary != lastSfStatsSummary) { Log($"SF channel #{seq}: {summary} (changed)"); lastSfStatsSummary = summary; // If non-zero, fetch the first record by index via the // standard GetAlarmExtendedRec — after SFCreateSnapshot the // indices reference the snapshot. if (numAlarms > 0) { AlarmRecord rec = new AlarmRecord(); int recRc = client.GetAlarmExtendedRec(0, ref rec); Log($" GetAlarmExtendedRec(0) [post-snapshot] rc={recRc} -> {DescribeAlarmRecord(rec)}"); } } client.SFDeleteSnapshot(); } catch (Exception ex) { Log($"SF channel #{seq}: threw {ex.GetType().Name}: {ex.Message}"); } } private void LogProviders(AlarmClient client, string when) { try { var providers = new System.Collections.Generic.List(); int rc = client.GetProviders(providers); string summary = $"count={providers.Count} list=[{string.Join(", ", providers)}]"; if (summary != lastProvidersSummary) { Log($"GetProviders [{when}] -> rc={rc} {summary} (changed)"); lastProvidersSummary = summary; } } catch (Exception ex) { Log($"GetProviders [{when}] threw: {ex.GetType().Name}: {ex.Message}"); } } /// /// Drive an MxAccess write to with the /// supplied boolean value. Creates a fresh `LMXProxyServer` COM object, /// registers, adds the item, writes the value, and tears down. Runs on /// the same STA thread the probe uses for the AlarmClient — both COM /// objects share the apartment, which matches the worker's runtime. /// private void TriggerWriteValue(bool value, int sequence) { object? lmx = null; ILMXProxyServer? srv = null; int handle = 0, itemHandle = 0; try { lmx = new LMXProxyServerClass(); srv = (ILMXProxyServer)lmx; handle = srv.Register($"AlarmProbe.Trigger.{sequence}"); Log($"Trigger write #{sequence}: Register -> handle={handle}"); itemHandle = srv.AddItem(handle, TriggerTagReference); Log($"Trigger write #{sequence}: AddItem('{TriggerTagReference}') -> itemHandle={itemHandle}"); // First time only: dump every Write* method's signature so we know // which to call. The first attempt hit TargetParameterCountException — // the LMX server has multiple Write variants and we picked wrong. if (sequence == 1) { Log($"Trigger write #{sequence}: enumerating Write* methods on {lmx.GetType().FullName}:"); foreach (var m in lmx.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance)) { if (m.IsSpecialName) continue; if (!m.Name.StartsWith("Write", StringComparison.OrdinalIgnoreCase)) continue; string ps = string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}")); Log($" {m.ReturnType.Name} {m.Name}({ps})"); } } // Late-bind Write — it isn't on ILMXProxyServer's interface but is // exposed by the COM coclass. object[] writeArgs = new object[] { handle, itemHandle, value }; object? rv = lmx.GetType().InvokeMember( "Write", BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance, binder: null, target: lmx, args: writeArgs); Log($"Trigger write #{sequence}: Write({TriggerTagReference}={value}) -> rv={rv}"); } catch (Exception ex) { Log($"Trigger write #{sequence}: FAILED: {ex.GetType().Name}: {ex.Message}"); if (ex.InnerException != null) { Log($" inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); } } finally { try { if (srv != null && itemHandle != 0) { srv.RemoveItem(handle, itemHandle); } if (srv != null && handle != 0) { srv.Unregister(handle); } } catch (Exception ex) { Log($"Trigger write #{sequence}: cleanup failure: {ex.GetType().Name}: {ex.Message}"); } if (lmx != null && System.Runtime.InteropServices.Marshal.IsComObject(lmx)) { try { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(lmx); } catch { /* swallow */ } } } } private void PollGetStatistics(AlarmClient client, int seq) { try { int percent = 0, total = 0, active = 0, suppressed = 0; int suppressedFilters = 0, newAlarms = 0, changes = 0; int[] codes = Array.Empty(); int[] positions = Array.Empty(); int[] handles = Array.Empty(); int rc = client.GetStatistics( ref percent, ref total, ref active, ref suppressed, ref suppressedFilters, ref newAlarms, ref changes, ref codes, ref positions, ref handles); string codesStr = codes != null ? string.Join(",", codes) : ""; string posStr = positions != null ? string.Join(",", positions) : ""; string handlesStr = handles != null ? string.Join(",", handles) : ""; int posLen = positions?.Length ?? 0; // Suppress duplicate-summary spam — only log when interesting // state-change is observed. The "interesting" digest excludes // percent (always 100 at steady state). string summary = $"total={total} active={active} suppressed={suppressed} " + $"new={newAlarms} changes={changes} codes=[{codesStr}] " + $"positions=[{posStr}] handles=[{handlesStr}]"; if (summary != lastStatsSummary) { Log($"GetStatistics #{seq} rc={rc} pct={percent} {summary} (changed)"); lastStatsSummary = summary; } // Always fetch records when positions has entries — records // change content even when count stays the same. if (posLen > 0 && positions != null) { for (int i = 0; i < Math.Min(posLen, 4); i++) { int idx = positions[i]; AlarmRecord rec = new AlarmRecord(); int recRc = client.GetAlarmExtendedRec(idx, ref rec); Log($" GetAlarmExtendedRec(idx={idx}) rc={recRc} -> " + DescribeAlarmRecord(rec)); } } } catch (Exception ex) { Log($"GetStatistics #{seq} threw: {ex.GetType().Name}: {ex.Message}"); } } private static string DescribeAlarmRecord(AlarmRecord rec) { // Reflect over the record's public properties so we don't have to // guess the field shape — the discovery probe already showed it has // ar_AlarmName / ar_Provider / ar_Group / ar_AlmTransition / etc. var sb = new System.Text.StringBuilder(); sb.Append("{ "); bool first = true; foreach (var prop in rec.GetType().GetProperties( System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)) { try { object? v = prop.GetValue(rec); string vs = v?.ToString() ?? ""; if (vs.Length > 50) vs = vs.Substring(0, 47) + "..."; if (!first) sb.Append(", "); sb.Append($"{prop.Name}={vs}"); first = false; } catch { // skip failing accessors } } sb.Append(" }"); return sb.ToString(); } private void LogIfInteresting(MSG m) { // Filter out the highest-volume noise (timer ticks, paint, mouse moves // from a desktop session). Keep WM_USER..WM_APP+ entirely; those are // the candidates for the AVEVA-registered message. const uint WM_PAINT = 0x000F; const uint WM_TIMER = 0x0113; const uint WM_MOUSEMOVE = 0x0200; const uint WM_NCMOUSEMOVE = 0x00A0; if (m.message == WM_PAINT || m.message == WM_TIMER || m.message == WM_MOUSEMOVE || m.message == WM_NCMOUSEMOVE) { return; } string interpreted = InterpretMessageId(m.message); Log(string.Format( "WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8} hwnd=0x{4:X}", m.message, interpreted, m.wParam.ToInt64() & 0xFFFFFFFF, m.lParam.ToInt64() & 0xFFFFFFFF, m.hwnd.ToInt64())); } private static string InterpretMessageId(uint id) { if (id < 0x0400) return "WM_"; if (id < 0x8000) return $"WM_USER+0x{id - 0x0400:X4}"; if (id < 0xC000) return $"WM_APP+0x{id - 0x8000:X4}"; return $"RegisterWindowMessage_0x{id:X4}"; } private IntPtr ProbeWndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) { // Log every WM that lands on the probe window itself. string interpreted = InterpretMessageId(msg); Log(string.Format( "WndProc WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8}", msg, interpreted, wParam.ToInt64() & 0xFFFFFFFF, lParam.ToInt64() & 0xFFFFFFFF)); return DefWindowProcW(hWnd, msg, wParam, lParam); } private void Log(string line) { log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}"); } public void Dispose() { if (wndProcHandle.IsAllocated) wndProcHandle.Free(); } }