# Native AB transport gate results ## ab_server suites (T17) Every ab_server integration test now runs once per transport (`TransportMatrix` in each integration project). Run 2026-10-07 against `10.100.0.35`, one compose profile at a time (AbCip `/opt/otopcua-abcip`, `AB_SERVER_COMPOSE_PROFILE=
`; AbLegacy `~/otopcua-ablegacy`, `AB_LEGACY_COMPOSE_PROFILE=
`); every stack was brought down afterwards.
- **Image:** `otopcua-ab-server:libplctag-release` (`sha256:19e814747d85…`, built 2026-08-01; ab_server from
libplctag `v2.7.0` + patches 0001/0002). Host compose files md5-identical to the repo's.
- **Commit:** fixes `82d011c7` + `2171fc8c`, test matrix `8c68694d`.
Counts are pass / fail / skip for the transport-parameterised rows only. Each AbCip run also passed the
6 transport-independent `AbServerProfileTests`. The AbCip skips are the 3 Emulate-tier tests
(`AB_SERVER_PROFILE=emulate` only). The AbLegacy skips are `Slc500_write_then_read…`, which skips itself
on both transports when the slc500 port (`:44819`) is down.
| Profile | Libplctag | Native | Native ⊇ Libplctag |
|---|---|---|---|
| controllogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
| compactlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
| micro800 | 0 / 1 / 3 | 1 / 0 / 3 | yes (libplctag fails, see below) |
| guardlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
| slc500 | 2 / 0 / 0 | 2 / 0 / 0 | yes |
| micrologix | 1 / 0 / 1 | 1 / 0 / 1 | yes |
| plc5 | 1 / 0 / 1 | 1 / 0 / 1 | yes |
**Native-only failures found and fixed:**
- `82d011c7` (controllogix): RegisterSession carried a non-zero sender context, and ab_server rejects that
with encapsulation status 1. Every native session failed.
- `2171fc8c` (slc500): ab_server answers Execute PCCC with a fixed requestor id
(`07 3D F3 45 43 50 21`), so the requestor-echo check rejected every native PCCC reply. Native now sends
libplctag's identity.
**Skips added:** none. The PLC-5 integration test only reads N7, so the PLC-5 Unsupported paths
(non-N reads, all writes) and status-bit sub-element writes are never exercised by the suite.
**micro800:** the smoke test used to send `/1,0` for every family, and both transports failed against
`--plc=Micro800`. It now uses an empty path for Micro800. With the empty path, native passes. libplctag
still fails with BadCommunicationError, because the driver's libplctag runtime passes `Path = ""` and
libplctag answers that with `ErrorBadData`. A `null` path works (checked with the capture client). That
defect is pre-existing and libplctag-side; it is recorded, not fixed, here.
## Differential vs ab_server (T18)
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.
### Review round 1 re-run (press-safety fixes)
Re-run 2026-10-07 after the review-r1 fixes, with the default pacing. `AB_SERVER_ENDPOINT=127.0.0.1:1`
kept the sibling suites' discovery probes off the network:
```bash
AB_SERVER_ENDPOINT=127.0.0.1:1 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"
```
**Result: 12/12 passed — 7 live tests and 5 offline safety tests — with 0 unexpected and 0 expected divergences.**
| Check | Result |
|---|---|
| Settings | pace 20 ms, max tags 200, max templates 50 |
| Browse | 9 = 9 |
| Selection (array round-robin first) | 1 array + 8 scalars. ab_server has no UDTs, so there were no UDT members and the coverage check had nothing to require |
| Reads | 9/9 equal |
| Templates | 0 ids, not truncated. Not vacuous by the new rule: ab_server's `STRING` is not a structure, so browse found no structured tags |
| Native Forward Opens | **1** |
| TCP connections to the target before native | 0 (baseline 0) |
**Wire capture of this run** (tcpdump in the ab_server container's namespace, decoded with tshark):
| TCP stream | Session | Forward Open | Forward Close | Requests | Window (s) |
|---|---|---|---|---|---|
| 0 | libplctag | 1 (Large, 0x5B) | 1 | 1× 0x55, 18× 0x52 | 0.000 → 0.487 |
| 1 | native | 1 (0x54) | 1 | 1× 0x55, 18× 0x52 | 0.510 → 0.984 |
- No discovery probes.
- Zero write services: no 0x4D, 0x53, 0x4B or 0x10.
- The two sessions are 23 ms apart and do not overlap.
**The Forward Opens differ between the transports, and that is expected.** libplctag always uses a *Large*
Forward Open. Native uses a regular one because the `CompactLogix` profile's connection size (504) is at most 511. Task 21 will
see the same split on the press.
**What changed for press safety (review r1):**
- **Stop on the first connection-level failure, on either transport.** That covers a refused connect,
RegisterSession or Forward Open, a timeout, and a lost session. Any status other than
NotFound/NotAllowed/OutOfBounds/TooLarge/TooSmall/Unsupported/TypeMismatch aborts the whole run with the
failing operation and status. Nothing is retried.
- **The native phase is capped at one open attempt.** It sets the new process-wide
`CipConnection.OpenAttemptBudget`, so a reconnect cannot reach the wire at all, even from inside an
enumerator that swallows a per-program failure.
- **The run also aborts the moment `ForwardOpenCount` passes 1.** This is checked after every browse entry,
template and tag.
- **Offline pin:** against a loopback listener that accepts and hangs up, the native phase aborts at browse after exactly
**one** TCP connect. Removing the harness abort turns the test red. Removing both the abort and the budget
turns the connect count red.
- **Pacing:** `AB_DIFF_PACE_MS` (default 20) is applied before every harness-issued request: browse,
each template read, each tag's create-read and read. Listing *pages* inside the enumerators are not
paced.
- **Volume caps:** `AB_DIFF_MAX_TEMPLATES` defaults to 50. `AB_DIFF_MAX_TAGS` and `AB_DIFF_MAX_TEMPLATES` must be at most 500;
a higher value fails fast instead of being clamped.
- **Selection:** the default selection takes arrays (up to 20), UDT members (up to 20) and scalars round-robin, so any cap
of 3 or more exercises all three kinds. The reads test fails if browse offered arrays or UDT members
but the selection holds none.
- **Template parity** fails when browse found structured tags but no template id was compared, and treats an
undecodable shape on either side as a mismatch.
- **Write-capable integration tests** now require `AB_SERVER_ALLOW_WRITES=1` and refuse while
`AB_DIFF_ENDPOINT` is set. Those tests are the Logix Emulate ALMD test and the AbLegacy SLC500 N7:5 round trip.
The ab_server controllogix matrix is read-only and unaffected: 15 passed and 11 skipped with
`AB_SERVER_COMPOSE_PROFILE=controllogix`.
### Latency harness proven on ab_server (T21 Step 1)
`Differential/PollLatencyComparisonTests.cs` (+ `PollLatencyRun.cs`) ran 2026-10-07 against ab_server
(`controllogix` image, host port 44828 because an unrelated outbound socket held 44818 on the docker host),
under the same `--filter "FullyQualifiedName~Differential"` invocation as the differential. The mechanics work:
the timed phase reuses the differential's browse, the anchors, the open budget and the barriers; **1** native Forward Open
in the latency phase; 30 passes per transport, 0 tag failures; native packs 100 concurrent reads into **6** requests per pass.
ab_server has 7 atomic scalars, so the 100-tag set was **repeated cyclically** (reported in the output).
| Family | libplctag p50 / p95 ms | native p50 / p95 ms | native requests/pass | Criterion |
|---|---|---|---|---|
| CompactLogix (504, the press default) | 7.7 / 10.9 | 22.1 / 25.3 | 6 | **FAIL** |
| ControlLogix (4002) | 7.3 / 9.1 | 7.7 / 9.2 | 2 | pass |
The CompactLogix result was a real finding, not a harness fault. Native opened a 504-byte connection there, while libplctag
opened a Large one (see the T18 capture above), so native needed 3× the round trips per pass.
**Fixed:** the CompactLogix profile now defaults to 4002, so native opens Large like libplctag. A refusal still falls back to
a regular 504 open. Re-run 2026-10-07 23:30–23:32Z against the `compactlogix` compose profile on :44818 (3 distinct atomic
tags, repeated to 100), with a capture in the container's network namespace:
| Run | libplctag p50 / p95 ms | native p50 / p95 ms | native req/pass | Criterion |
|---|---|---|---|---|
| 1 | — | — | — | aborted in the latency libplctag phase (see below) |
| 2 | 7.6 / 9.6 | 7.5 / 10.8 | 2 | FAIL (limit 10.6) |
| 3 | 7.8 / 9.3 | 8.1 / 9.4 | 2 | PASS (limit 10.2) |
| 4 | 7.7 / 10.0 | 7.8 / 11.7 | 2 | FAIL (limit 11.1) |
| 5 | — | — | — | aborted in the latency libplctag phase |
The structural gap is gone: p50s are equal, and both transports now send 2 requests per pass. At ~10 ms the p95 margin is about
1 ms, which is within this network path's jitter, so the ab_server verdict varies from run to run.
Capture: every session opened with **0x5B (Large Forward Open)** and was accepted (0xDB, status 0). That covers 15 sessions:
four per complete invocation and three in the aborted run 5. Each session had one Forward Close (0x4E to Connection Manager 6/1).
There were **zero** 0x4D/0x53/0x4B. Both aborts (runs 1 and 5) were the simulator: libplctag's ~2 KB packed request was split
across TCP segments (1448 + 606), and ab_server answered with FIN. The harness aborted on the first connection-level failure,
as designed. A real controller reassembles TCP; ab_server does not.
The T21 press result is recorded under `### Pass 3 — 2026-10-08 02:27–02:30 UTC (HEAD `8e2dbc67`, after the final-review fixes; same settings as pass 2) — with packet capture
**17/17 passed.** Browse 3162 = 3162 tags; 20 UDT templates identical; 200 tags read with 0 unexpected divergences
(6 live); native 1 Forward Open, Forward Closes sent/acked 1/1; latency native p95 105.1 ms ≤ libplctag p95 197.3 ms
×1.10 = 217.1 ms → PASS. Raw output: [pass 3](2026-10-07-native-ab-transport-t21/t21-pass3.txt).
Packet capture (`tcpdump -i utun4 host 10.209.70.160 and port 44818`, analysed with TCP sequence reassembly):
| Session | Order | Window (s) | CIP services seen (requests) | Forward Close target |
|---|---|---|---|---|
| libplctag differential | 1 | 0.0 – 82.5 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
| native differential | 2 | 82.5 – 126.3 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
| libplctag latency | 3 | 126.5 – 152.4 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
| native latency | 4 | 152.4 – 176.5 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
- **Zero write-class services** (no 0x4D Write Tag, 0x53 Write Tag Fragmented, 0x4B Execute PCCC, and no 0x4E to
anything but the Connection Manager).
- **Four sessions, strictly sequential** — each starts after the previous one's last packet; never two at once.
- Every session opened exactly one Large Forward Open and closed it with one Forward Close.
## Latency (T21)` and is not filled in here.
## AdminUI /run (T19)
Run 2026-10-07 on the local docker-dev rig (OrbStack), against the ab_server `controllogix` fixture
on `10.100.0.35:44818`. Every container ran the same freshly built image
(`otopcua-host:dev` `sha256:91bffbb0…`, checked per container because `:9200` round-robins). The
modal was driven with the Chrome tools against `http://localhost:9200/raw`.
**Authoring (UI).**
- Created the AbCip driver `t19-abcip` (`DRV-8ea0f0a8cfde`) in the MAIN cluster.
- Configure → Transport = "Native (managed)" → Save. `DriverInstance.DriverConfig` then held
`"transport":"Native"`, a string.
- Reopened Configure. The dropdown showed "Native (managed)". Changed only Timeout from 2 to 3 and
saved. The row then held `"transport":"Native"` and `"timeout":"00:00:03"`, so the save-reverts-transport
defect is fixed live, not only in unit tests.
- Created device `Device1plc1` with host `ab://10.100.0.35:44818/1,0` (ControlLogix). Test Connect
returned `OK · 62 ms`.
- Added the raw tag `TestDINT` (Int32, read-only, tag path `TestDINT`, DInt).
- Seeded `TestDINT = 4242` on the simulator so a non-zero value proves the read path.
- Raw namespace: `ns=2`, the shared `s=OtOpcUa` root plus RawPath ids. UNS uses `ns=3`.
- RawPath: `t19-abcip/Device1plc1/TestDINT`.
**Leg 1: Native.** The deploy sealed with `acks=6`. Both central nodes logged:
```
AbCip driver DRV-8ea0f0a8cfde initializing: transport=Native tagFactory=NativeAbCipTagFactory
```
CLI read on central-1 (`:4840`) and central-2 (`:4841`):
```
Node: ns=2;s=t19-abcip/Device1plc1/TestDINT
Value: 4242
Status: 0x00000000
```
The simulator value was then changed to 5150 through the libplctag CLI. The native-served node
followed it (`Value: 5150`, `Status: 0x00000000`, source time `23:02:09.60Z`).
**Leg 2: inverted gate (Native, dead port).**
- Device host changed to `ab://10.100.0.35:44819/1,0`. Nothing listens on 44819
(`ss -ltn` count 0).
- The node went `BadNoCommunication` (value retained). A CLI read failed with
`ServiceResultException: [80050000]` (BadCommunicationError). The CLI `read` throws on a Bad
status rather than printing it.
- The central-1 log showed the native error text, raised from `NativeAbCipTagRuntime.ReadAsync`:
```
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: TCP connect to 10.100.0.35:44819 failed: ConnectionRefused
---> System.Net.Sockets.SocketException (111): Connection refused
```
- For contrast, the same dead port under libplctag logged different wording:
```
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: ErrorTimeout
---> libplctag.LibPlcTagException: ErrorTimeout
```
So the switch changes behaviour and is not inert.
**Leg 3: back to libplctag.**
- Device restored to port 44818. Configure → Transport = "libplctag (default)" → Save. The row held
`"transport":"Libplctag"`.
- The redeploy sealed with `acks=6`. Both centrals logged
`initializing: transport=Libplctag tagFactory=LibplctagTagFactory`.
- CLI reads on `:4840` and `:4841`: `Value: 5150`, `Status: 0x00000000`.
**Fix found by the gate.** The first deploy logged no transport line. `AbCipDriverFactoryExtensions.Register`
took no `ILoggerFactory`, and `DriverFactoryBootstrap` registered AbCip without one, so every production
AbCip driver ran on `NullLogger`. That hid the new line and every existing AbCip warning or error. AbCip
now takes the host logger factory the same way AbLegacy and Modbus do. `AbCipTransportInitLogTests` is
falsified by reverting that wiring: 2/2 tests fail.
**Cleanup.**
- The `t19-abcip` driver, device and tag rows were deleted and the change redeployed (sealed,
`acks=6`). Both centrals logged `stopped driver child DRV-8ea0f0a8cfde`.
- The rig has no test driver pointing at the fixture.
- The fixture was brought down (`docker compose --profile controllogix down`).
- The simulator's `TestDINT` was left at 5150 until teardown. The container is gone, so the value
does not persist.
## Real press (T21)
Target: **Z-35162**, production CompactLogix 5069-L320ERS2, `10.209.70.160:44818`, path `1,0`, family CompactLogix
(native connection size 4002, Large Forward Open). Read-only. User approved the press and each time window; VPN up;
no other client on this Mac connected to the press. Driven from the Mac over VPN (≈44 ms RTT).
### Pass 1 — 2026-10-07 23:35–23:36 UTC (HEAD `c306c6f6`; 25 tags, 20 templates, pace 50 ms, latency 100×30)
12/14 passed. **Safety held**: packet capture (`tcpdump` on `utun4`) shows 4 sequential sessions, each one Large
Forward Open (0x5B) and one Forward Close (0x4E, Connection Manager 6/1, acked); services seen only 0x03, 0x0A, 0x4C,
0x52, 0x55, 0x5B, 0x4E; **zero writes** (no 0x4D/0x53/0x4B). Browse parity and all 20 UDT template shapes identical;
native Forward Opens = 1.
Failures, and what they led to:
- **Read parity, 2 tags** — live analog values moved between the sequential libplctag and native phases
(`WW_M4ActualTemp` 384.887 → 385.138). Harness defect: it could not tell drift from divergence. Fixed by a volatility
probe (`82a0c298`, tightened element-wise in `43b81b66`): a value that moves between two libplctag reads is
compared by status, length, type, shape and **every element that held still**.
- **Latency** — native p95 142.8 ms vs libplctag 99.1 ms ×1.10 = 109.1 ms (FAIL), medians native 93.7 / libplctag
95.2 ms; three native outliers in 30 passes. Criterion unchanged; pass count raised to 50 and per-pass GC/RTT
diagnostics added.
- **Test host crashed at exit** — `A callback was made on a garbage collected delegate … callback_func_ex`. Root cause
is in libplctag.NET 1.5.2: a failed tag create leaves its native callback registered and `Dispose()` doesn't release
it. **Production-reachable** on the default transport (a nonexistent tag name crashes the server node at
stop/restart). Fixed in the adapters (`6cd68762`, hardened `6ae62873`); Gitea #578.
- Found before pass 1, on ab_server: native CompactLogix opened a 504-byte connection (the family default libplctag
never honoured) and needed ~3× the round-trips. Native default changed to 4002 with the existing Large→regular
fallback (`c306c6f6`).
### Pass 2 — 2026-10-08 00:21–00:24 UTC (HEAD `df776170`, fresh build; 200 tags, 20 templates, pace 50 ms, latency 100×50)
**17/17 passed.**
| Check | Result |
|---|---|
| Browse parity | libplctag 3162 tags, native 3162 — identical sets |
| UDT templates | 20 compared (of 74 found, capped) — identical shapes |
| Read parity | 200 tags: **0 unexpected**, 0 expected (TypeMismatch) divergences; 6 live tags (below) |
| Native Forward Opens | 1 per native phase |
| Native Forward Closes sent/acked | 1/1 per native phase; 0 connections left |
| Sessions | libplctag and native never connected at the same time |
| Writes | none — read-only source guard passes; `grep -c WriteAsync` = 0 over every Differential file |
| libplctag failed-create orphans | 15 destroyed by the #578 fix, 0 rooted — no crash at exit |
⚠️ **Pass 2 has no independent packet capture** (pass 3 below re-ran it with one): the operator's `tcpdump` had stopped at 23:45 UTC, before pass 2 began,
and this was not noticed until afterwards. The read-only evidence for pass 2 is the source guard and the harness's
own Forward Open/Close counters; pass 1's capture is the wire-level proof for the same code paths.
Live tags (value moved between the two libplctag reads; every element that held still matched native):
```
- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
```
Test summary:
```
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Total tests: 17
Passed: 17
```
Full raw `dotnet test` output: [pass 1](2026-10-07-native-ab-transport-t21/t21-pass1.txt), [pass 2](2026-10-07-native-ab-transport-t21/t21-pass2.txt).
## Latency (T21)
Pass 2 (100 atomic tags × 50 passes per transport, 200 ms gap, all reads in a pass issued concurrently):
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
**Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS.** Native was faster at the median
and much steadier (max 107.5 ms vs 164.8 ms), packing 95 reads into 2 CIP requests per pass. Five pad/config tags that
libplctag refused at setup were removed from both sets (`Local:*:I.Pad1`, `Local:5:C.CfgSize`, `Local:5:C.CfgIDNum`).
Pass 1 (30 passes) failed this criterion on three native outliers (p95 142.8 vs limit 109.1; medians native 93.7 /
libplctag 95.2) — see above.
Per-pass detail (pass 2):
```
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
```
## hw4 evidence batch — 2026-10-09 (Gitea #592; #582, #572, ab_server bit write)
Branch `hw4-evidence`, harness `Differential/EvidenceTests.cs` (same folder, same read-only source/IL guard, extra opt-in
env gates so a standard `Differential` run never repeats them). Press: Z-35162, CompactLogix 5069-L320ERS2, VPN, pace 50 ms,
one short-lived native session per leg (1 Forward Open, 1 Forward Close acked), reads only. A diagnostics-only tap
(`ConnectionDiagnostics.ExchangeObserver`, null in production, exceptions swallowed) lets the harness see each CIP request and
its raw reply; no packet capture was used because `tcpdump` needs sudo on this Mac.
### Real-controller scalar writes (user-approved one-off, 2026-10-09)
Done by the controller session, not by this harness, through the AbCip CLI on the **native** transport against Z-35162. The
tags were chosen because a census of the L5X export shows no routine references them and they are absent from the Galaxy map.
Every write and read-back returned Good, and each tag was restored:
| Tag | Type | Sequence |
|---|---|---|
| `test2` | DINT | 0 -> 4242 -> 0 |
| `Program:UN01_Press.xxtest` | BOOL | false -> true -> false |
| `Program:ES03_EM03_Zone3._ff_test` | REAL | 0 -> 42.5 -> 0 |
This is the first native write on a real controller: scalar DINT/BOOL/REAL Write Tag is proven on a CompactLogix. The user's
write approval covered only that one-off and is consumed; every other leg of this batch was read-only. Still unproven on
hardware: fragmented wide-struct writes (#567), array writes, STRING writes, UDT member writes.
### #582 - mixed STRING / array / scalar Multiple Service Packet (PASS)
`AB_EVID_MIX=1 AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_PACE_MS=50` -> `EvidenceTests.Mixed_string_array_and_scalar_reads_travel_as_one_multiple_service_packet`
(raw output: [hw4-mix-msp.txt](2026-10-07-native-ab-transport-t21/hw4-mix-msp.txt)). Five reads were issued concurrently on one native session,
five times (>= 1 s apart), bracketed by sequential single reads of the same tags:
| Tag | Type | Value (single before / all 5 batches / single after) |
|---|---|---|
| `Program:UN01_Press.Sts_CycleTime_STR` | STRING | `'2:23:37'` throughout (matches the L5X export) |
| `Program:EM01_PowerUnit._SEQ_EP[1]` | DINT element | `300` throughout |
| `Program:EM01_PowerUnit._SEQ_EP` | DINT[32] | `[0,300,0 x30]` throughout |
| `test2` | DINT | `0` |
| `Program:UN01_Press.xxtest` | BOOL | `False` |
- **5/5 attempts put all five reads in ONE channel send**: a single 204 B Multiple Service Packet (service 0x0A) answered by a
273 B reply with `embedded=5`, outer status 0x00, embedded statuses all 0x00 with data lengths 92 B (STRING), 6 B, 130 B
(32 x DINT + type header), 6 B, 3 B. No embedded 0x06, no fragment re-fetch, `RequestsSent` delta = 1 each time.
- Every batch value equalled both bracketing singles, so reply packing/offsets decode correctly for a mix of 3-byte, 6-byte,
92-byte and 130-byte replies. The values are stable (static config/test tags), so equality is not a coincidence of drift.
- The tags are non-zero where it matters (the string and the array element), so decode equality is not vacuous; `test2` /
`xxtest` are zero by design and are only there to pin the scalar slots.
- On `ab_server` (loopback) the same harness showed the pump can wake mid-queue and split a batch (attempt 1 of 5: an MSP of 4
plus a bare read). That is a scheduling race, not a packing defect, which is why the leg repeats the batch and requires at
least one whole-batch MSP; on the press all five attempts were whole.
### #572 - does a real controller list symbol instances out of order? (observed: no)
`AB_EVID_BROWSE=1` -> `EvidenceTests.Symbol_listing_pages_are_ascending_on_a_real_controller` (raw: [hw4-listing-order.txt](2026-10-07-native-ab-transport-t21/hw4-listing-order.txt)).
One native `@tags` browse of Z-35162 through the production enumerator (default paging and pacing): 3162 tags, **41 listing
pages** (controller scope 8 pages, then each of the 15 programs, up to 6 pages for `ES03_TemperatureControl`).
- **0 non-ascending pages.** Within every page the instance ids were strictly ascending, and each page's first id was above the
previous page's maximum (e.g. controller scope 1..105, 106..207, ... 670..671).
- Instance ids are not contiguous: ids can be skipped (e.g. `EM02_Clamp` page ends `...,281,282,288,289`; `UN01_Press` page 1
ends at 109 and page 2 starts at 111). The reader's "next start = highest id + 1" rule handles that and the out-of-order case
alike; this is evidence only, no code changed.
- One empty page (`Program:Auxiliary`, 0 entries) was observed and handled.
### ab_server PCCC bit-file write (`B3:0/5`): the documented gap does not reproduce (no patch)
Provenance: `AbLegacy.IntegrationTests/Docker/README.md` ("Bit-file writes on ab_server", May 2026, libplctag + `--plc=SLC500`,
before the native transport) recorded `B3:0/5` -> `0x803D0000`. New `AbLegacyBitWriteTests` (writes `B3:0` = 0x0A0A, sets
bit 5, clears bit 3, asserts the whole word each time so neighbour bits are proven preserved) against local ab_server
(libplctag v2.7.0 + patches 0001/0002, OrbStack, `AB_LEGACY_ENDPOINT` pinned to a lane-local port):
| Family | Libplctag | Native |
|---|---|---|
| SLC500 | pass | pass |
| MicroLogix | pass | pass |
| PLC-5 | pass | `BadNotSupported` (0x803D0000) |
- **Finding:** the originally documented gap does not reproduce on the current fixture and driver; the cause of the original
failure is undetermined. No ab_server patch or Dockerfile change is needed. The driver does a per-parent-word read-modify-write
(typed read, typed write of the word), not a masked FNC 0xAB write (ab_server does implement 0xAB for 16-bit elements).
- The PLC-5 native `0x803D0000` is a **separate, intended** behaviour that merely shares the status code: all native PLC-5
writes are refused before the wire (#571, `NativeAbLegacyTagRuntime.WriteRefusal`). The test pins it. It is not the original
observation.
- No fix, so no revert-to-red proof; the test would fail if the word RMW clobbered neighbour bits.
## #611 UDT member-write gate — 2026-10-09 (user-approved, native transport)
Target: Z-35162, controller tag `_EmptyTemperatureSegment` (`UDT_TemperatureProfile_Segment`; no routine, alias or
trend references it in the 2026-09-11 L5X export; not in the Galaxy map). Tool: AbCip CLI from master `6c6800e5`,
`ab://10.209.70.160/1,0`, family CompactLogix, default (native) transport. Each leg: write, snapshot all ten members,
restore, snapshot again.
| Member | Type | Before | Wrote | Read back | Restored | Other 9 members |
|---|---|---|---|---|---|---|
| `ID` | DINT | 0 | 4242 | 4242 | 0 | unchanged (0/false) |
| `Enable` | BIT (BOOL member) | false | true | true | false | unchanged |
| `HoldTime` | REAL | 0 | 42.5 | 42.5 | 0 | unchanged |
Every write and read returned Good (`0x00000000`); the final snapshot equals the pre-gate snapshot. Not covered:
sibling-bit preservation inside one hidden host SINT (this UDT has a single BIT member) and the libplctag
member-write path. Raw output: the session scratchpad `hw4/gate611.txt`.
## AbLegacy on a real MicroLogix 1400 — 2026-10-09 (Z-33323, idle, user-directed)
Press selection: Z-33470 was mid-cycle (`N10:150/0` = `B3:0/4` = true) and was left alone; Z-33323 read idle on both, with the pump input `I:0/0` off. The other North presses time out on TCP from the VPN (ICMP answers). AbLegacy CLI from master, `ab://10.201.44.231/`, `-P MicroLogix`.
```
=== 10.201.44.231
ST14:10 Value: "Z-33323" Status: 0x00000000 (Good)
N10:150/0 Value: false Status: 0x00000000 (Good)
B3:0/4 Value: false Status: 0x00000000 (Good)
I:0/0 Value: