diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md
index ef0c95a..3c067d5 100644
--- a/docs/AlarmClientDiscovery.md
+++ b/docs/AlarmClientDiscovery.md
@@ -87,35 +87,97 @@ a result — `RaiseAlarmRecordReceived` is `internal` for tests and
never gets invoked at runtime. Until A.2 lands a WM_APP pump,
`MX_EVENT_FAMILY_ON_ALARM_TRANSITION` cannot carry events.
-## Implications for A.2
+## Live runtime probe — 2026-05-01
-The original plan banner was right: A.2 needs a hidden message-only
-window inside the worker's STA whose hWnd is passed to
-`RegisterConsumer`, with a `WindowProc` that intercepts the AVEVA
-WM_APP message and routes change-enumeration into
-`MxAccessAlarmEventSink.EnqueueTransition`. Open questions before
-implementation:
+`MxGateway.Worker.Tests.AlarmClientWmProbeTests.ProbeAlarmClientWmMessages`
+is a Skip-gated runtime probe that creates a real message-only
+window, calls `AlarmClient.RegisterConsumer(hWnd, …)` +
+`Subscribe(@"\Galaxy!", …)`, and pumps for 20s while logging every
+window message that arrives. Run results below — this turned the
+"WM_APP pump" design assumption upside down.
-1. **WM_APP message ID.** Not in the public surface — needs either
- AVEVA's C++ Toolkit reference (canonical doc per `gateway.md`) or
- a runtime probe (subclass a window, log every WM arriving while a
- live alarm is fired, identify the AVEVA one). Worth doing once on
- the dev rig and checking the result in.
-2. **`wParam` / `lParam` semantics.** Probably none — the pattern is
- "got poked; pull state via `GetStatistics`." Confirm during the
- probe.
-3. **Threading.** AVEVA almost certainly delivers the WM on the
- thread that owns the window. The worker's STA is the natural
- home; the existing `StaRuntime` already runs a pump there. If
- AVEVA assumes a UI thread (MTA-incompatible call paths inside
- `GetStatistics`), the alarm path may need its own STA.
-4. **Subscription scope.** `AlarmClient.Subscribe(szSubscription,
- …)` takes an AVEVA-syntax string for the alarm provider (e.g.
- `"\Galaxy!"` for a whole Galaxy or `"\Galaxy!Group.Tag"` for a
- subset). The configured Galaxy name is already known to the
- worker via the existing data session — reuse it.
+**`RegisterConsumer` and `Subscribe` both returned 0 (success).** The
+calls are valid against the deployed assembly; no parameter pinning
+needed.
+
+**A registered-message-class WM (ID `0xC275` in this OS session)
+fired every ~1s after `Subscribe` completed.** Constant
+`wParam = 0x00001100`, constant `lParam = 0x079E46D8` (looks like a
+stable pointer into AVEVA-internal state) for all 20 hits. The
+constant payload across hits with no Galaxy alarm being fired
+suggests this is a **heartbeat/keepalive**, not a per-change
+notification.
+
+**Critically: this WM is delivered to AVEVA's own internal window
+(`hwnd=0x18032E`) — NOT to the consumer's `hWnd` we passed in.** The
+consumer window's `WndProc` received only the standard creation
+sequence (`WM_GETMINMAXINFO`, `WM_NCCREATE`, `WM_NCCALCSIZE`,
+`WM_CREATE`) and the destruction sequence (`WM_NCDESTROY`,
+`WM_DESTROY`, `WM_NCCALCSIZE`) — nothing in between. AVEVA's
+notification path runs entirely against AVEVA's internal window;
+it never forwards to the user-supplied hWnd.
+
+The message ID itself is dynamic (a `RegisterWindowMessage`
+allocation in the >= 0xC000 range), so it cannot be hard-coded —
+each consumer process must call `RegisterWindowMessage` with the
+correct *string* and use whatever ID the OS returns.
+
+## What this means for A.2
+
+The "WM_APP pump on the user hWnd" design — what the original plan
+banner described and what the previous version of this doc
+recommended — does not match how AVEVA actually delivers
+notifications. The hWnd parameter to `RegisterConsumer` does not
+appear to receive any of AVEVA's alarm traffic; it's likely used
+only as a registration identity (and perhaps as a parent for modal
+dialogs).
+
+Two viable A.2 designs given the probe data:
+
+1. **Polling.** Just call `GetStatistics` on a timer (e.g. every
+ 500ms in the worker's STA) and react to the change set it
+ reports. No window plumbing needed. Trade-off: latency floor =
+ poll period; modest CPU floor because the call is cheap. Matches
+ the heartbeat-style WM 0xC275 semantics — AVEVA itself runs a
+ poll loop internally.
+2. **Hook AVEVA's internal window.** Discover AVEVA's own window
+ (`hwnd=0x18032E` in the probe), `SetWindowsHookEx` or
+ `SetWindowSubclass` on it, and intercept WM 0xC275 on AVEVA's
+ thread. Higher fidelity, near-zero latency, but invasive,
+ fragile across AVEVA upgrades, and requires running on the same
+ process / thread as the AVEVA window. Probably a non-starter
+ without further AVEVA documentation.
+
+**Recommendation:** the polling path (option 1) is cheaper to
+implement, more robust against AVEVA-internal change, and
+acceptable for a typical alarm cadence. The worker's existing STA
+already provides a thread-affinitized timer surface. The unanswered
+question is whether `GetStatistics` can be safely called outside
+AVEVA's own message-pump thread — confirmable by extending the
+probe to fire `GetStatistics` on its own thread and check the
+result.
+
+## Open follow-up probes
+
+Each can be added to `AlarmClientWmProbeTests` as a separate
+Skip-gated test:
+
+1. **Fire a real Galaxy alarm during the pump window.** Confirms
+ whether the WM 0xC275 cadence changes (becomes per-change rather
+ than periodic) and whether `GetStatistics` returns a non-empty
+ `ChangeCodes / ChangePos / hAlarm` triple.
+2. **Call `GetStatistics` on a different thread from the
+ `RegisterConsumer` thread** to test threading affinity.
+3. **Hook AVEVA's internal window** to log what WMs it actually
+ processes (would resolve option 2 above).
+4. **Decompile `aaAlarmManagedClient.dll`'s IL** for the
+ `RegisterConsumer` method to find what `RegisterWindowMessage`
+ string is used and whether there's a callback-registration
+ surface on `WNAL_Register` that the managed client wraps. The
+ alarmlst.dll strings (`WNAL_CallBack`, "Invalid callbacks" error)
+ suggest the underlying C API takes callbacks, but the managed
+ wrapper exposes none of them.
PR A.5's `Subscribe` / `AcknowledgeByGuid` / `SnapshotActiveAlarms`
-are correct — they're pull-style and don't depend on the missing
-event surface. The event-subscription wiring is what has to be
-replaced.
+are correct — they're pull-style and don't depend on the
+notification mechanism.
diff --git a/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs
new file mode 100644
index 0000000..a9ba7b3
--- /dev/null
+++ b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs
@@ -0,0 +1,328 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Threading;
+using AlarmMgrDataProviderCOM;
+using aaAlarmManagedClient;
+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.
+ private const string SubscriptionExpression = @"\Galaxy!";
+ private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(20);
+
+ [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 (with live Galaxy) to capture AVEVA WM_APP message IDs")]
+ 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();
+ int register = client.RegisterConsumer(
+ hWnd: probeWindow.ToInt32(),
+ szProductName: "AlarmProbe",
+ szApplicationName: "AlarmProbe.Tests",
+ szVersion: "1.0",
+ bRetainHiddenAlarms: false);
+ Log($"RegisterConsumer -> {register}");
+
+ int subscribe = client.Subscribe(
+ szSubscription: SubscriptionExpression,
+ wFromPri: 1, wToPri: 999,
+ QueryType: eQueryType.qtSummary,
+ SortFlags: eSortFlags.sfReturnNewestFirst,
+ FilterMask: eAlarmFilterState.asNone,
+ FilterSpecification: eAlarmFilterState.asNone);
+ Log($"Subscribe('{SubscriptionExpression}') -> {subscribe}");
+
+ // 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).
+ DateTime deadline = DateTime.UtcNow + PumpDuration;
+ while (DateTime.UtcNow < deadline)
+ {
+ while (PeekMessage(out MSG msg, IntPtr.Zero, 0, 0, PM_REMOVE))
+ {
+ LogIfInteresting(msg);
+ TranslateMessage(ref msg);
+ DispatchMessage(ref msg);
+ }
+ 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 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();
+ }
+}
diff --git a/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj b/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj
index 47796e3..bb2f949 100644
--- a/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj
+++ b/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj
@@ -25,4 +25,17 @@
+
+
+ C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll
+ true
+ false
+
+
+ C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll
+ true
+ false
+
+
+