diff --git a/docs/plans/2026-10-07-native-ab-transport-gate-results.md b/docs/plans/2026-10-07-native-ab-transport-gate-results.md
index 3a856d0f..b0993d34 100644
--- a/docs/plans/2026-10-07-native-ab-transport-gate-results.md
+++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md
@@ -45,7 +45,78 @@ defect is pre-existing and libplctag-side; it is recorded, not fixed, here.
## Differential vs ab_server (T18)
-_(pending)_
+Run 2026-10-07 on branch `feat/native-ab-transport`, harness
+`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/`, against `ab_server`
+(`controllogix` profile only, `/opt/otopcua-abcip` on 10.100.0.35):
+
+```bash
+AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \
+ dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \
+ --filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"
+```
+
+Target as the harness resolved it: `10.100.0.35:44818`, path `1,0`, family `CompactLogix`
+(the defaults; libplctag attribute `compactlogix`, connection size 504).
+
+**Result: 7/7 passed, 0 unexpected divergences, 0 expected divergences.**
+
+| Check | Result |
+|---|---|
+| Browse parity (`@tags` + program listings) | 9 tags via libplctag, 9 via native, identical as multisets |
+| Template parity | vacuous: ab_server serves no UDTs, so 0 template ids. Task 21's press is the first real exercise |
+| Read parity | 9/9 tags equal on status, raw buffer and decoded value |
+| Native Forward Opens over the run (`AB_DIFF_COUNT_OPENS=1`) | **1** |
+| TCP connections to the target before the native phase | 0 (baseline 0). libplctag's session closed within 1 ms of the last handle being disposed |
+| Read-only guard (offline, runs without the endpoint) | pass |
+| Comparison falsifiability (offline) | pass |
+
+| Tag | Type | libplctag status | native status | Equal? |
+|---|---|---|---|---|
+| `Motor_Run` | Bool | Ok | Ok | yes |
+| `Line1_Count` | DInt | Ok | Ok | yes |
+| `Pump_1_Speed` | Real | Ok | Ok | yes |
+| `TestString` | String | Ok | Ok | yes |
+| `TestSINT` | SInt | Ok | Ok | yes |
+| `TestBOOL` | Bool | Ok | Ok | yes |
+| `TestREAL` | Real | Ok | Ok | yes |
+| `TestDINT` | DInt | Ok | Ok | yes |
+| `TestArray` | DInt[16] | Ok | Ok | yes |
+
+**The values were made non-zero first.** An all-zero simulator would make raw and value equality
+vacuous. Before the run, the simulator was seeded through a throwaway libplctag scratch program
+outside the repo. The harness itself never writes. Seeded values:
+
+- `TestDINT` = −123456789
+- `TestREAL` = 3.14159
+- `Pump_1_Speed` = 1234.5
+- `Line1_Count` = 987654
+- `TestArray[i]` = (i−8)·100003+7, which includes negatives
+- `TestString` = `"Hello T18"`
+
+ab_server refuses 1-byte writes (`ErrorBadParam`), so `TestBOOL`, `TestSINT` and `Motor_Run` stay 0.
+Their parity is therefore weaker evidence.
+
+**Wire capture of the same run.** tcpdump ran in the ab_server container's network namespace
+(`nicolaka/netshoot`), and the capture was decoded with tshark:
+
+| TCP stream | Session | RegisterSession | Forward Open | Forward Close | Requests | Window (s, relative) |
+|---|---|---|---|---|---|---|
+| 12 | libplctag | 1 | 1 (Large, 0x5B) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.243 → 0.336 |
+| 13 | native | 1 | 1 (0x54) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.349 → 0.452 |
+
+- Exactly two sessions, with no overlap: a 13 ms gap between them.
+- Zero write services on the wire: no 0x4D, 0x53 or 0x10.
+- The 12 earlier sub-10 ms TCP connections are the `AbServerFact`/`AbServerTheory` reachability probes. xUnit runs them during discovery for the *other* suites, even under the filter. They target `AB_SERVER_ENDPOINT` (default 10.100.0.35), never `AB_DIFF_ENDPOINT`.
+
+**Explicit-list path.** `AB_DIFF_TAGS="TestDINT,NoSuchTag,TestArray,TestString"`: 4/4 equal.
+`NoSuchTag` returned `NotFound` under both transports, and there was still exactly one native Forward Open.
+
+**Transport fixes found by the differential: none.** One harness defect was found and fixed before
+this result. libplctag closes a session as soon as its last handle is destroyed. The first version of the
+libplctag phase created, read and disposed one handle at a time, so it opened a fresh session (and a
+fresh Forward Open) per tag: 9+ sessions, which the netstat sampling showed. The libplctag phase now holds an anchor handle
+on `@tags` for its whole duration, which mirrors the native anchor lease. That leaves the one-session
+capture above.
## AdminUI /run (T19)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index 3087492a..f0655678 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -53,6 +53,12 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
///
public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize());
+ ///
+ /// A copy of the bytes of the last read, for the read-only differential harness (Task 18) to compare
+ /// against the native runtime byte for byte. Not part of .
+ ///
+ internal ReadOnlyMemory RawBuffer => LibplctagStatusTranslation.WrapSync(() => _tag.GetBuffer());
+
///
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
index f445bd2d..60910133 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
@@ -209,6 +209,12 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
///
public int GetSize() => _data.Length;
+ ///
+ /// The bytes of the last read, for the read-only differential harness (Task 18) to compare against
+ /// libplctag byte for byte. Not part of .
+ ///
+ internal ReadOnlyMemory RawBuffer => _data;
+
///
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
index 13e4c766..59436b5e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
@@ -28,6 +28,7 @@
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs
new file mode 100644
index 00000000..ee988d06
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs
@@ -0,0 +1,356 @@
+using System.Collections;
+using System.Diagnostics;
+using System.Net;
+using System.Net.NetworkInformation;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
+
+/// One tag the harness reads, with the type it decodes under (null ⇒ raw comparison only).
+internal sealed record SelectedTag(string Name, AbCipDataType? Type, int ElementCount, bool IsArray, string Origin);
+
+/// What one transport observed for one tag.
+internal sealed record ReadObservation(int Status, string? Error, byte[]? Raw, object? Decoded)
+{
+ public bool Succeeded => Status == (int)AbStatus.Ok && Error is null;
+}
+
+/// What one transport observed over its whole phase.
+internal sealed class TransportObservations
+{
+ public List Browse { get; } = [];
+ public string? BrowseError { get; set; }
+ public Dictionary Shapes { get; } = [];
+ public Dictionary TemplateErrors { get; } = [];
+ public Dictionary Reads { get; } = new(StringComparer.Ordinal);
+}
+
+/// The outcome of one whole differential run.
+internal sealed class DifferentialResult
+{
+ public required DifferentialTarget Target { get; init; }
+ public required TransportObservations Libplctag { get; init; }
+ public required TransportObservations Native { get; init; }
+ public required IReadOnlyList Selection { get; init; }
+
+ /// Native Forward Opens counted between the counter reset and the anchor release.
+ public required int NativeForwardOpens { get; init; }
+
+ /// TCP connections to the endpoint before libplctag started, and once its handles were disposed.
+ public required int? ConnectionsBaseline { get; init; }
+ public required int? ConnectionsBeforeNative { get; init; }
+ public required TimeSpan LibplctagDrainWait { get; init; }
+}
+
+///
+/// Runs the whole comparison ONCE: every libplctag operation first (handles disposed), then — only
+/// once libplctag's session is gone — every native operation under one anchor lease. Reads only:
+/// browse listings, template reads and tag reads. Nothing on this path encodes or sends a write.
+///
+internal static class DifferentialRun
+{
+ private const int MaxArrays = 20;
+ private const int MaxUdtMembers = 20;
+ private const int MaxTemplates = 256;
+
+ /// Upper bound on waiting for libplctag's session to close before native work may start.
+ private static readonly TimeSpan LibplctagDrainBudget = TimeSpan.FromSeconds(30);
+
+ /// Settle used when the platform cannot enumerate TCP connections.
+ private static readonly TimeSpan FallbackSettle = TimeSpan.FromSeconds(5);
+
+ public static async Task ExecuteAsync(DifferentialTarget target, CancellationToken ct)
+ {
+ var baseline = CountConnections(target);
+
+ // ---- Phase 1: libplctag only. Every handle is disposed before the phase ends. ----
+ // libplctag shares one session (and one Forward Open) among the handles alive at the same time, and
+ // closes it when the last one is destroyed. The per-tag create/read/dispose below would therefore open
+ // a fresh session per tag; an anchor handle on the listing pseudo-tag, held for the whole phase, keeps
+ // every libplctag operation on ONE session — the mirror of the native anchor lease.
+ var lib = new TransportObservations();
+ var libFactories = AbCipTransportFactories.For(AbCipTransport.Libplctag);
+ IReadOnlyList selection;
+ using (var libAnchor = libFactories.Tags.Create(target.Params("@tags")))
+ {
+ try
+ {
+ await libAnchor.InitializeAsync(ct).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
+ {
+ throw new InvalidOperationException(
+ $"libplctag could not open its anchor session to {target}: {Describe(ex)}", ex);
+ }
+ await BrowseAsync(target, libFactories.Enumerators, lib, ct).ConfigureAwait(false);
+ await ReadTemplatesAsync(target, libFactories.Templates, lib, ct).ConfigureAwait(false);
+ selection = Select(target, lib);
+ await ReadTagsAsync(target, libFactories.Tags, selection, lib, ct).ConfigureAwait(false);
+ }
+
+ // ---- Barrier: libplctag's session must be gone before the native side connects. ----
+ var drain = Stopwatch.StartNew();
+ var beforeNative = await WaitForLibplctagSessionToCloseAsync(target, baseline, ct).ConfigureAwait(false);
+ drain.Stop();
+
+ // ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ----
+ var native = new TransportObservations();
+ var nativeFactories = AbCipTransportFactories.For(AbCipTransport.Native);
+ var anchorParams = target.Params("@tags");
+ Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0);
+ int opens;
+ var anchor = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(anchorParams), NativeAbCipDevice.Options(anchorParams));
+ try
+ {
+ await BrowseAsync(target, nativeFactories.Enumerators, native, ct).ConfigureAwait(false);
+ await ReadTemplatesAsync(target, nativeFactories.Templates, native, ct).ConfigureAwait(false);
+ await ReadTagsAsync(target, nativeFactories.Tags, selection, native, ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ opens = Volatile.Read(ref CipConnection.ForwardOpenCount);
+ await anchor.DisposeAsync().ConfigureAwait(false);
+ }
+
+ return new DifferentialResult
+ {
+ Target = target,
+ Libplctag = lib,
+ Native = native,
+ Selection = selection,
+ NativeForwardOpens = opens,
+ ConnectionsBaseline = baseline,
+ ConnectionsBeforeNative = beforeNative,
+ LibplctagDrainWait = drain.Elapsed,
+ };
+ }
+
+ private static async Task BrowseAsync(
+ DifferentialTarget target, IAbCipTagEnumeratorFactory factory, TransportObservations into, CancellationToken ct)
+ {
+ using var enumerator = factory.Create();
+ try
+ {
+ await foreach (var tag in enumerator.EnumerateAsync(target.Params("@tags"), ct).ConfigureAwait(false))
+ into.Browse.Add(tag);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex)
+ {
+ into.BrowseError = Describe(ex);
+ }
+ }
+
+ /// Reads every template id the browse surfaced, then every nested id those shapes name.
+ private static async Task ReadTemplatesAsync(
+ DifferentialTarget target, IAbCipTemplateReaderFactory factory, TransportObservations into, CancellationToken ct)
+ {
+ var queue = new Queue(into.Browse
+ .Where(t => t.TemplateInstanceId is not null)
+ .Select(t => t.TemplateInstanceId!.Value)
+ .Distinct()
+ .Order());
+ using var reader = factory.Create();
+ while (queue.Count > 0 && into.Shapes.Count + into.TemplateErrors.Count < MaxTemplates)
+ {
+ var id = queue.Dequeue();
+ if (into.Shapes.ContainsKey(id) || into.TemplateErrors.ContainsKey(id)) continue;
+ try
+ {
+ var shape = CipTemplateObjectDecoder.Decode(await reader.ReadAsync(target.Params($"@udt/{id}"), id, ct).ConfigureAwait(false));
+ into.Shapes[id] = shape;
+ foreach (var nested in shape?.Members ?? [])
+ if (nested.NestedTemplateId is { } n) queue.Enqueue(n);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex)
+ {
+ into.TemplateErrors[id] = Describe(ex);
+ }
+ }
+ }
+
+ ///
+ /// The tags to read, chosen from the libplctag browse (the run's first phase), so both transports
+ /// read exactly the same list.
+ ///
+ internal static IReadOnlyList Select(DifferentialTarget target, TransportObservations browsed)
+ {
+ var byName = new Dictionary(StringComparer.Ordinal);
+ var scalars = new List();
+ var arrays = new List();
+ var members = new List();
+
+ foreach (var tag in browsed.Browse.Where(t => !t.IsSystemTag))
+ {
+ var name = tag.ProgramScope is null ? tag.Name : $"Program:{tag.ProgramScope}.{tag.Name}";
+ var type = tag.DataType;
+ AbCipUdtShape? shape = null;
+ if (type == AbCipDataType.Structure)
+ {
+ shape = tag.TemplateInstanceId is { } id ? browsed.Shapes.GetValueOrDefault(id) : null;
+ // A string-family struct (STRING, STR0016, ...) is read and presented as one String value.
+ if (shape is { IsStringFamily: true }) type = AbCipDataType.String;
+ }
+
+ if (type != AbCipDataType.Structure)
+ {
+ var selected = new SelectedTag(name, type, tag.IsArray ? tag.ElementCount : 1, tag.IsArray,
+ tag.IsArray ? "array" : "scalar");
+ byName.TryAdd(name, selected);
+ (tag.IsArray ? arrays : scalars).Add(selected);
+ continue;
+ }
+
+ if (tag.IsArray || shape is null) continue;
+ foreach (var member in shape.Members)
+ {
+ var memberType = member.DataType;
+ if (memberType == AbCipDataType.Structure)
+ {
+ var nested = member.NestedTemplateId is { } n ? browsed.Shapes.GetValueOrDefault(n) : null;
+ if (nested is not { IsStringFamily: true }) continue;
+ memberType = AbCipDataType.String;
+ }
+ // Hidden host bytes of packed BOOL members ("ZZZZZZZZZZ..." in Logix) are not addressable.
+ if (member.Name.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)) continue;
+ var isArray = member.ArrayLength > 1;
+ var selected = new SelectedTag($"{name}.{member.Name}", memberType, isArray ? member.ArrayLength : 1,
+ isArray, "udt member");
+ byName.TryAdd(selected.Name, selected);
+ members.Add(selected);
+ }
+ }
+
+ IEnumerable chosen = target.Tags is { } explicitNames
+ ? explicitNames.Select(n => byName.GetValueOrDefault(n) ?? new SelectedTag(n, null, 1, false, "explicit (type unknown)"))
+ : scalars.Concat(arrays.Take(MaxArrays)).Concat(members.Take(MaxUdtMembers));
+ return chosen.DistinctBy(t => t.Name).Take(target.MaxTags).ToList();
+ }
+
+ private static async Task ReadTagsAsync(
+ DifferentialTarget target, IAbCipTagFactory factory, IReadOnlyList selection,
+ TransportObservations into, CancellationToken ct)
+ {
+ foreach (var tag in selection)
+ {
+ ct.ThrowIfCancellationRequested();
+ var runtime = factory.Create(target.Params(tag.Name, tag.ElementCount, tag.IsArray, tag.Type));
+ try
+ {
+ string? error = null;
+ try
+ {
+ await runtime.InitializeAsync(ct).ConfigureAwait(false);
+ await runtime.ReadAsync(ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception ex)
+ {
+ error = Describe(ex);
+ }
+
+ var status = runtime.GetStatus();
+ byte[]? raw = null;
+ object? decoded = null;
+ if (error is null)
+ {
+ raw = Try(() => RawBufferOf(runtime).ToArray());
+ if (tag.Type is { } type)
+ decoded = Try(() => tag.IsArray ? runtime.DecodeArray(type, tag.ElementCount) : runtime.DecodeValue(type, null));
+ }
+ into.Reads[tag.Name] = new ReadObservation(status, error, raw, decoded);
+ }
+ finally
+ {
+ runtime.Dispose();
+ }
+ }
+ }
+
+ private static ReadOnlyMemory RawBufferOf(IAbCipTagRuntime runtime) => runtime switch
+ {
+ LibplctagTagRuntime l => l.RawBuffer,
+ NativeAbCipTagRuntime n => n.RawBuffer,
+ _ => throw new InvalidOperationException($"Unexpected runtime {runtime.GetType().Name}."),
+ };
+
+ ///
+ /// libplctag closes its session on a background thread some time after the last handle is
+ /// destroyed. Wait until the endpoint's TCP connection count is back at the pre-run baseline, so
+ /// the two transports are never connected to the controller at the same time.
+ ///
+ /// The connection count observed when native work may start (null when unobservable).
+ private static async Task WaitForLibplctagSessionToCloseAsync(
+ DifferentialTarget target, int? baseline, CancellationToken ct)
+ {
+ if (baseline is null)
+ {
+ await Task.Delay(FallbackSettle, ct).ConfigureAwait(false);
+ return null;
+ }
+ var deadline = Stopwatch.StartNew();
+ while (true)
+ {
+ var now = CountConnections(target);
+ if (now is null || now <= baseline) return now;
+ if (deadline.Elapsed > LibplctagDrainBudget)
+ throw new InvalidOperationException(
+ $"libplctag's session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after its " +
+ $"handles were disposed ({now} connection(s), baseline {baseline}); refusing to start the native phase alongside it.");
+ await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
+ }
+ }
+
+ /// Open TCP connections from this machine to the target, or null when the OS cannot say.
+ internal static int? CountConnections(DifferentialTarget target)
+ {
+ try
+ {
+ var addresses = Dns.GetHostAddresses(target.Host).Select(a => a.MapToIPv6()).ToHashSet();
+ return IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpConnections().Count(c =>
+ c.RemoteEndPoint.Port == target.Port
+ && addresses.Contains(c.RemoteEndPoint.Address.MapToIPv6())
+ && c.State is not (TcpState.Closed or TcpState.TimeWait or TcpState.Unknown));
+ }
+ catch (Exception ex) when (ex is PlatformNotSupportedException or NetworkInformationException or NotImplementedException)
+ {
+ return null;
+ }
+ }
+
+ /// Order-insensitive multiset difference: entries of missing from .
+ internal static List Missing(IEnumerable a, IEnumerable b) where T : notnull
+ {
+ var counts = b.GroupBy(x => x).ToDictionary(g => g.Key, g => g.Count());
+ var missing = new List();
+ foreach (var x in a)
+ {
+ if (counts.TryGetValue(x, out var c) && c > 0) counts[x] = c - 1;
+ else missing.Add(x);
+ }
+ return missing;
+ }
+
+ /// Structural equality for decoded values: arrays element-wise, NaN equal to NaN.
+ internal static bool ValuesEqual(object? a, object? b) =>
+ a is Array || b is Array
+ ? StructuralComparisons.StructuralEqualityComparer.Equals(a, b)
+ : Equals(a, b);
+
+ internal static bool ShapesEqual(AbCipUdtShape? a, AbCipUdtShape? b) =>
+ a is null || b is null
+ ? a is null && b is null
+ : a.TypeName == b.TypeName && a.TotalSize == b.TotalSize && a.Members.SequenceEqual(b.Members);
+
+ private static T? Try(Func f)
+ {
+ try { return f(); }
+ catch (Exception) { return default; }
+ }
+
+ private static string Describe(Exception ex) => ex is AbTransportException ate
+ ? $"{ate.Status}: {ate.Message}"
+ : $"{ex.GetType().Name}: {ex.Message}";
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs
new file mode 100644
index 00000000..b977d6a0
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs
@@ -0,0 +1,103 @@
+using System.Globalization;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
+
+///
+/// The one endpoint the read-only differential harness targets, read from the environment.
+///
+/// AB_DIFF_ENDPOINT — host:port (port defaults to 44818). Absent ⇒ every live test skips.
+/// AB_DIFF_PATH — CIP path, default 1,0.
+/// AB_DIFF_FAMILY — an name, default CompactLogix.
+/// AB_DIFF_TAGS — optional comma list of tag names; absent ⇒ selected from browse.
+/// AB_DIFF_MAX_TAGS — cap on the selected tags, default 200.
+/// AB_DIFF_COUNT_OPENS — 1 ⇒ assert exactly one native Forward Open over the run.
+///
+///
+internal sealed record DifferentialTarget(
+ string Host,
+ int Port,
+ string CipPath,
+ AbCipPlcFamily Family,
+ IReadOnlyList? Tags,
+ int MaxTags,
+ bool CountOpens)
+{
+ public const string EndpointVar = "AB_DIFF_ENDPOINT";
+ public const string PathVar = "AB_DIFF_PATH";
+ public const string FamilyVar = "AB_DIFF_FAMILY";
+ public const string TagsVar = "AB_DIFF_TAGS";
+ public const string MaxTagsVar = "AB_DIFF_MAX_TAGS";
+ public const string CountOpensVar = "AB_DIFF_COUNT_OPENS";
+
+ private const int DefaultPort = 44818;
+ private const string DefaultPath = "1,0";
+ private const AbCipPlcFamily DefaultFamily = AbCipPlcFamily.CompactLogix;
+ private const int DefaultMaxTags = 200;
+
+ /// Per-operation timeout for both transports.
+ public static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(5);
+
+ /// Skip reason used by every live test when the endpoint is not configured.
+ public static string SkipReason =>
+ $"{EndpointVar} is not set — point it at host:port of the one controller to compare (e.g. 10.100.0.35:44818 for ab_server).";
+
+ /// The configured target, or null when AB_DIFF_ENDPOINT is absent.
+ /// An env var is present but malformed.
+ public static DifferentialTarget? FromEnvironment()
+ {
+ var endpoint = Environment.GetEnvironmentVariable(EndpointVar)?.Trim();
+ if (string.IsNullOrEmpty(endpoint)) return null;
+
+ var parts = endpoint.Split(':', 2);
+ var host = parts[0];
+ var port = DefaultPort;
+ if (parts.Length == 2 && (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out port) || port is < 1 or > 65535))
+ throw new InvalidOperationException($"{EndpointVar}='{endpoint}' is not host:port.");
+ if (host.Length == 0) throw new InvalidOperationException($"{EndpointVar}='{endpoint}' has no host.");
+
+ // An explicitly empty AB_DIFF_PATH is honoured (direct / no-backplane targets).
+ var path = Environment.GetEnvironmentVariable(PathVar) ?? DefaultPath;
+
+ var family = DefaultFamily;
+ if (Environment.GetEnvironmentVariable(FamilyVar) is { Length: > 0 } rawFamily
+ && !Enum.TryParse(rawFamily.Trim(), ignoreCase: true, out family))
+ throw new InvalidOperationException(
+ $"{FamilyVar}='{rawFamily}' is not one of {string.Join(", ", Enum.GetNames())}.");
+
+ IReadOnlyList? tags = null;
+ if (Environment.GetEnvironmentVariable(TagsVar) is { Length: > 0 } rawTags)
+ tags = rawTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ var max = DefaultMaxTags;
+ if (Environment.GetEnvironmentVariable(MaxTagsVar) is { Length: > 0 } rawMax
+ && (!int.TryParse(rawMax, NumberStyles.None, CultureInfo.InvariantCulture, out max) || max < 1))
+ throw new InvalidOperationException($"{MaxTagsVar}='{rawMax}' is not a positive integer.");
+
+ var countOpens = Environment.GetEnvironmentVariable(CountOpensVar) == "1";
+ return new DifferentialTarget(host, port, path, family, tags, max, countOpens);
+ }
+
+ /// The family profile the driver would use for this target.
+ public AbCipPlcFamilyProfile Profile => AbCipPlcFamilyProfile.ForFamily(Family);
+
+ /// Create params for one tag (or listing pseudo-tag), built the way the driver builds them.
+ public AbCipTagCreateParams Params(
+ string tagName, int elementCount = 1, bool isArray = false, AbCipDataType? authoredType = null) => new(
+ Gateway: Host,
+ Port: Port,
+ CipPath: CipPath,
+ LibplctagPlcAttribute: Profile.LibplctagPlcAttribute,
+ TagName: tagName,
+ Timeout: OperationTimeout,
+ AllowPacking: Profile.SupportsRequestPacking,
+ ConnectionSize: Profile.DefaultConnectionSize,
+ ElementCount: elementCount < 1 ? 1 : elementCount,
+ IsArray: isArray,
+ AuthoredType: authoredType,
+ UseConnectedMessaging: Profile.SupportsConnectedMessaging);
+
+ ///
+ public override string ToString() =>
+ $"{Host}:{Port} path '{CipPath}' family {Family} ({Profile.LibplctagPlcAttribute}, connection size {Profile.DefaultConnectionSize})";
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs
new file mode 100644
index 00000000..0129a198
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs
@@ -0,0 +1,287 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
+
+/// Runs the differential alone: no other suite may hold a session to the target meanwhile.
+[CollectionDefinition(Name, DisableParallelization = true)]
+public sealed class DifferentialCollection : ICollectionFixture
+{
+ public const string Name = "AbCip transport differential";
+}
+
+/// Performs the one run all differential tests read from, on first use.
+public sealed class DifferentialRunFixture
+{
+ private static readonly TimeSpan RunBudget = TimeSpan.FromMinutes(15);
+ private readonly Lazy> _run = new(RunAsync);
+
+ internal Task GetAsync() => _run.Value;
+
+ private static async Task RunAsync()
+ {
+ var target = DifferentialTarget.FromEnvironment()
+ ?? throw new InvalidOperationException(DifferentialTarget.SkipReason);
+ using var cts = new CancellationTokenSource(RunBudget);
+ return await DifferentialRun.ExecuteAsync(target, cts.Token).ConfigureAwait(false);
+ }
+}
+
+///
+/// Read-only differential (Task 18): the same tags through libplctag and the native transport against one
+/// endpoint — browse listings, UDT templates and tag reads must agree byte for byte and value for value.
+/// Task 21 runs this against a production press, so the suite is read-only by construction (see
+/// ), libplctag and native are never connected at the
+/// same time, and the native side runs on one connection held open by an anchor lease.
+///
+[Collection(DifferentialCollection.Name)]
+[Trait("Category", "LiveIntegration")]
+public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
+{
+ [Fact]
+ public async Task Browse_listings_decode_to_identical_tag_sets_through_both_enumerators()
+ {
+ var r = await RunOrSkipAsync();
+ Log($"## Browse — {r.Target}");
+ Log($"libplctag: {r.Libplctag.Browse.Count} tag(s){ErrorSuffix(r.Libplctag.BrowseError)}; " +
+ $"native: {r.Native.Browse.Count} tag(s){ErrorSuffix(r.Native.BrowseError)}");
+
+ var onlyLib = DifferentialRun.Missing(r.Libplctag.Browse, r.Native.Browse);
+ var onlyNative = DifferentialRun.Missing(r.Native.Browse, r.Libplctag.Browse);
+ foreach (var t in onlyLib) Log($"- only libplctag: {t}");
+ foreach (var t in onlyNative) Log($"- only native: {t}");
+
+ r.Libplctag.BrowseError.ShouldBeNull();
+ r.Native.BrowseError.ShouldBeNull();
+ r.Libplctag.Browse.ShouldNotBeEmpty("a browse that returns nothing proves nothing");
+ onlyLib.ShouldBeEmpty();
+ onlyNative.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Every_template_decodes_to_an_identical_shape_through_both_readers()
+ {
+ var r = await RunOrSkipAsync();
+ var ids = r.Libplctag.Shapes.Keys.Concat(r.Libplctag.TemplateErrors.Keys)
+ .Concat(r.Native.Shapes.Keys).Concat(r.Native.TemplateErrors.Keys)
+ .Distinct().Order().ToList();
+ Log($"## Templates — {ids.Count} id(s)");
+ if (ids.Count > 0)
+ {
+ Log("| Template | libplctag | native | Equal? |");
+ Log("|---|---|---|---|");
+ }
+
+ var unequal = new List();
+ foreach (var id in ids)
+ {
+ var lib = Shape(r.Libplctag, id);
+ var nat = Shape(r.Native, id);
+ var equal = r.Libplctag.Shapes.ContainsKey(id) && r.Native.Shapes.ContainsKey(id)
+ && DifferentialRun.ShapesEqual(r.Libplctag.Shapes[id], r.Native.Shapes[id]);
+ if (!equal) unequal.Add(id);
+ Log($"| {id} | {lib} | {nat} | {(equal ? "yes" : "**NO**")} |");
+ }
+ unequal.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Every_selected_tag_reads_identically_through_both_transports()
+ {
+ var r = await RunOrSkipAsync();
+ Log($"## Reads — {r.Selection.Count} tag(s), {r.Target}");
+ Log("| Tag | Type | libplctag status | native status | Equal? |");
+ Log("|---|---|---|---|---|");
+
+ var unexpected = new List();
+ var expected = new List();
+ foreach (var tag in r.Selection)
+ {
+ var lib = r.Libplctag.Reads[tag.Name];
+ var nat = r.Native.Reads[tag.Name];
+ var verdict = Compare(tag, lib, nat);
+ if (verdict.StartsWith("expected", StringComparison.Ordinal)) expected.Add($"{tag.Name}: {verdict}");
+ else if (verdict != "yes") unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})");
+ Log($"| `{tag.Name}` | {tag.Type?.ToString() ?? "?"}{(tag.IsArray ? $"[{tag.ElementCount}]" : "")} | " +
+ $"{StatusName(lib.Status)} | {StatusName(nat.Status)} | {verdict} |");
+ }
+
+ Log("");
+ Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}.");
+ foreach (var e in expected) Log($"- expected: {e}");
+ foreach (var u in unexpected) Log($"- UNEXPECTED: {u}");
+
+ r.Selection.ShouldNotBeEmpty("a run that reads nothing proves nothing");
+ unexpected.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Libplctag_and_native_are_never_connected_at_the_same_time()
+ {
+ var r = await RunOrSkipAsync();
+ Log($"TCP connections to the target: baseline {r.ConnectionsBaseline?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}, " +
+ $"before native {r.ConnectionsBeforeNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}, " +
+ $"libplctag drain wait {r.LibplctagDrainWait.TotalMilliseconds:0} ms.");
+ if (r.ConnectionsBaseline is null)
+ Assert.Skip("This OS cannot enumerate TCP connections; the run fell back to a fixed settle before the native phase.");
+ // The run itself refuses to start the native phase while libplctag's session is open; this pins it.
+ r.ConnectionsBeforeNative.ShouldNotBeNull();
+ r.ConnectionsBeforeNative.Value.ShouldBeLessThanOrEqualTo(r.ConnectionsBaseline.Value);
+ }
+
+ [Fact]
+ public async Task The_native_side_makes_exactly_one_forward_open_over_the_whole_run()
+ {
+ var r = await RunOrSkipAsync();
+ if (!r.Target.CountOpens)
+ Assert.Skip($"{DifferentialTarget.CountOpensVar} is not 1 — the process-wide Forward Open counter is only " +
+ "trusted when the differential runs alone.");
+ Log($"Native Forward Opens over the run: {r.NativeForwardOpens}.");
+ r.NativeForwardOpens.ShouldBe(1);
+ }
+
+ ///
+ /// Offline guard — deliberately NOT gated on the endpoint, so it runs in every build. Fails if any
+ /// source file of this harness names a write-capable member, and, independently of the source text,
+ /// if any compiled method of a harness type calls one.
+ ///
+ [Fact]
+ public void The_harness_is_read_only_by_construction()
+ {
+ // Built by concatenation so this file's own text never matches itself.
+ string[] forbidden =
+ [
+ "Write" + "Async", "Encode" + "Value", "WriteBit" + "InDInt", "Send" + "Async", "AbCip" + "Driver",
+ ];
+
+ var dir = Path.GetDirectoryName(ThisFile())!;
+ var sources = Directory.GetFiles(dir, "*.cs");
+ sources.ShouldContain(f => Path.GetFileName(f) == nameof(TransportDifferentialTests) + ".cs");
+ var textHits = (from f in sources
+ let text = File.ReadAllText(f)
+ from token in forbidden
+ where text.Contains(token, StringComparison.Ordinal)
+ select $"{Path.GetFileName(f)} names {token}").ToList();
+ textHits.ShouldBeEmpty();
+
+ var harnessTypes = typeof(TransportDifferentialTests).Assembly.GetTypes()
+ .Where(t => t.Namespace == typeof(TransportDifferentialTests).Namespace).ToList();
+ harnessTypes.ShouldContain(typeof(TransportDifferentialTests));
+ var callHits = (from t in harnessTypes
+ from m in MethodsOf(t)
+ from callee in CalleesOf(m)
+ where forbidden.Contains(callee.Name, StringComparer.Ordinal)
+ select $"{t.Name}.{m.Name} calls {callee.DeclaringType?.Name}.{callee.Name}").ToList();
+ callHits.ShouldBeEmpty();
+ }
+
+ ///
+ /// Offline falsifiability check of the verdicts: a harness whose comparison could never say NO would
+ /// pass every live run vacuously.
+ ///
+ [Fact]
+ public void The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch()
+ {
+ var dint = new SelectedTag("T", AbCipDataType.DInt, 1, false, "scalar");
+ var ok = new ReadObservation((int)AbStatus.Ok, null, [1, 0, 0, 0], 1);
+ Compare(dint, ok, ok with { }).ShouldBe("yes");
+ Compare(dint, ok, ok with { Raw = [2, 0, 0, 0] }).ShouldBe("**NO: raw**");
+ Compare(dint, ok, ok with { Raw = null }).ShouldBe("**NO: raw**");
+ Compare(dint, ok, ok with { Decoded = 2 }).ShouldBe("**NO: value**");
+ Compare(dint, ok, new ReadObservation((int)AbStatus.NotFound, "NotFound", null, null)).ShouldBe("**NO: status**");
+ Compare(dint, ok, ok with { Error = "boom" }).ShouldBe("**NO: error**");
+ var notFound = new ReadObservation((int)AbStatus.NotFound, "NotFound", null, null);
+ Compare(dint, notFound, notFound with { }).ShouldBe("yes");
+ Compare(dint, ok, new ReadObservation((int)AbStatus.TypeMismatch, null, [1, 0, 0, 0], null))
+ .ShouldStartWith("expected");
+ // Only that direction is excused: libplctag refusing what native accepts is a real divergence.
+ Compare(dint, new ReadObservation((int)AbStatus.TypeMismatch, null, [1, 0, 0, 0], null), ok)
+ .ShouldBe("**NO: status**");
+
+ var array = new SelectedTag("A", AbCipDataType.Real, 2, true, "array");
+ var arr = new ReadObservation((int)AbStatus.Ok, null, new byte[8], new[] { float.NaN, 1f });
+ Compare(array, arr, arr with { Decoded = new[] { float.NaN, 1f } }).ShouldBe("yes");
+ Compare(array, arr, arr with { Decoded = new[] { float.NaN, 2f } }).ShouldBe("**NO: value**");
+ }
+
+ private async Task RunOrSkipAsync()
+ {
+ if (DifferentialTarget.FromEnvironment() is null) Assert.Skip(DifferentialTarget.SkipReason);
+ return await fixture.GetAsync();
+ }
+
+ internal static string Compare(SelectedTag tag, ReadObservation lib, ReadObservation nat)
+ {
+ // Spec §5: native refuses a returned type that disagrees with the authored one; libplctag decodes it anyway.
+ if (nat.Status == (int)AbStatus.TypeMismatch && lib.Status == (int)AbStatus.Ok)
+ return "expected (native TypeMismatch, libplctag Ok)";
+ if (lib.Status != nat.Status) return "**NO: status**";
+ if (!lib.Succeeded || !nat.Succeeded)
+ return lib.Succeeded == nat.Succeeded ? "yes" : "**NO: error**";
+ if (lib.Raw is null || nat.Raw is null || !lib.Raw.AsSpan().SequenceEqual(nat.Raw)) return "**NO: raw**";
+ if (tag.Type is not null && !DifferentialRun.ValuesEqual(lib.Decoded, nat.Decoded)) return "**NO: value**";
+ return "yes";
+ }
+
+ private static string Detail(ReadObservation o) =>
+ o.Error ?? $"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw)} = {Show(o.Decoded)}";
+
+ private static string Hex(byte[]? raw) =>
+ raw is null ? "-" : Convert.ToHexString(raw.AsSpan(0, Math.Min(raw.Length, 32))) + (raw.Length > 32 ? "…" : "");
+
+ private static string Show(object? v) => v switch
+ {
+ null => "null",
+ Array a => "[" + string.Join(",", a.Cast