# 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: Status: 0x80050000 (BadCommunicationError) I:0/1 Value: Status: 0x80050000 (BadCommunicationError) === 10.201.44.249 ST14:10 Value: "Z-33470" Status: 0x00000000 (Good) N10:150/0 Value: true Status: 0x00000000 (Good) B3:0/4 Value: true Status: 0x00000000 (Good) I:0/0 Value: Status: 0x80050000 (BadCommunicationError) I:0/1 Value: Status: 0x80050000 (BadCommunicationError) IDLE CHECK N10:150/0=false 0x00000000 (Good) B3:0/4=false 0x00000000 (Good) S:0 Int | lib: 0x80050000 (BadCommunicationError) | nat: 4 0x00000000 (Good) S:57 Int | lib: 0x80050000 (BadCommunicationError) | nat: 1400 0x00000000 (Good) S:4 Int | lib: 0x80050000 (BadCommunicationError) | nat: 9952 0x00000000 (Good) N9:0 Int | lib: 2331 0x00000000 (Good) | nat: 2331 0x00000000 (Good) N9:4 Int | lib: 3867 0x00000000 (Good) | nat: 3867 0x00000000 (Good) N7:255 Int | lib: 32000 0x00000000 (Good) | nat: 32000 0x00000000 (Good) F8:226 Float | lib: 2.1 0x00000000 (Good) | nat: 2.1 0x00000000 (Good) F8:214 Float | lib: 200 0x00000000 (Good) | nat: 200 0x00000000 (Good) B3:24 Int | lib: 1 0x00000000 (Good) | nat: 1 0x00000000 (Good) B3:24/0 Bit | lib: true 0x00000000 (Good) | nat: true 0x00000000 (Good) B3:28 Int | lib: 2309 0x00000000 (Good) | nat: 2309 0x00000000 (Good) B3:28/8 Bit | lib: true 0x00000000 (Good) | nat: true 0x00000000 (Good) B3:28/9 Bit | lib: false 0x00000000 (Good) | nat: false 0x00000000 (Good) B3:132/14 Bit | lib: true 0x00000000 (Good) | nat: true 0x00000000 (Good) T4:15.PRE Int | lib: 6000 0x00000000 (Good) | nat: 6000 0x00000000 (Good) T4:15.ACC Int | lib: 6000 0x00000000 (Good) | nat: 6000 0x00000000 (Good) T4:15.DN Bit | lib: true 0x00000000 (Good) | nat: true 0x00000000 (Good) T4:93.PRE Int | lib: 7000 0x00000000 (Good) | nat: 7000 0x00000000 (Good) T4:93.DN Bit | lib: false 0x00000000 (Good) | nat: false 0x00000000 (Good) T4:14.PRE Int | lib: 3600 0x00000000 (Good) | nat: 3600 0x00000000 (Good) T4:14.ACC Int | lib: 2922 0x00000000 (Good) | nat: 2922 0x00000000 (Good) T4:11.PRE Int | lib: 32767 0x00000000 (Good) | nat: 32767 0x00000000 (Good) T40:3.PRE Int | lib: 500 0x00000000 (Good) | nat: 500 0x00000000 (Good) T40:12.PRE Int | lib: 500 0x00000000 (Good) | nat: 500 0x00000000 (Good) C5:0.PRE Int | lib: 999 0x00000000 (Good) | nat: 999 0x00000000 (Good) C5:0.ACC Int | lib: 111 0x00000000 (Good) | nat: 111 0x00000000 (Good) C5:8.DN Bit | lib: true 0x00000000 (Good) | nat: true 0x00000000 (Good) C5:1.PRE Int | lib: 32767 0x00000000 (Good) | nat: 32767 0x00000000 (Good) R6:0.LEN Int | lib: 32 0x00000000 (Good) | nat: 32 0x00000000 (Good) R6:0.POS Int | lib: 0 0x00000000 (Good) | nat: 0 0x00000000 (Good) R6:0.DN Bit | lib: 0x80050000 (BadCommunicationError) | nat: false 0x00000000 (Good) ST14:10 String | lib: "Z-33323" 0x00000000 (Good) | nat: "Z-33323" 0x00000000 (Good) ST14:0 String | lib: "EQ_TEST_LARGE_MOLD" 0x00000000 (Good) | nat: "EQ_TEST_LARGE_MOLD" 0x00000000 (Good) O:0 Int | lib: 0x80050000 (BadCommunicationError) | nat: 0 0x00000000 (Good) O:0/3 Bit | lib: 0x80050000 (BadCommunicationError) | nat: false 0x00000000 (Good) I:0 Int | lib: 0x80050000 (BadCommunicationError) | nat: -29376 0x00000000 (Good) I:0/0 Bit | lib: 0x80050000 (BadCommunicationError) | nat: false 0x00000000 (Good) IDLE RECHECK N10:150/0=false 0x00000000 (Good) pre: cycle=falseG pump=falseG -- N7:23 write 4242: 0x00000000 (Good) -> N7:22=0G N7:23=4242G N7:24=0G -- N7:23 restore 0: 0x00000000 (Good) -> N7:23=0G -- F8:190 write 42.5: 0x00000000 (Good) -> F8:190=42.5G -- F8:190 restore 0: 0x00000000 (Good) -> F8:190=0G -- B3:200/5 set: 0x00000000 (Good) -> B3:200=32G (exp 32) -- B3:200/3 set: 0x00000000 (Good) -> B3:200=40G (exp 40) -- B3:200/5 clear: 0x00000000 (Good) -> B3:200=8G (exp 8) -- B3:200/3 clear: 0x00000000 (Good) -> B3:200=0G (exp 0) -- neighbours: B3:199=0G B3:201=0G post: cycle=falseG pump=falseG N7:23=0G F8:190=0G B3:200=0G ``` ### 2026-10-09 (later) — libplctag S/O/I + control status bits fixed (#659), re-read on Z-33323 Root cause, from libplctag v2.6.0 `src/protocols/ab/pccc.c` (the native core inside `libplctag.NativeImport` 1.0.41, pulled by the `libplctag` 1.5.2 package): `parse_pccc_file_num` (L881) requires a digit after the file letter and `parse_pccc_file_type` (L828-835) only treats `S` as the status file when a digit follows, so `S:0`/`O:0`/`I:0` never parse; `sub_element_lookup` (L936-1064) has only `con`/`len`/`pos` for R files, and `parse_pccc_bit_num` (L1162) refuses a `/bit` on a non-2-byte element, so neither `R6:0.DN` nor `R6:0/13` parses. The libplctag adapter now translates (`LibplctagLegacyName`): `S:n`→`S2:n`, `O:n`→`O0:n`, `I:n`→`I1:n` (the native encoder's implicit file numbers) and an unknown status bit → `R6:0.CON/`. Press idle beforehand (`N10:150/0` = `B3:0/4` = false, Native) and afterwards (both false again). AbLegacy CLI built from branch `i659`, reads only, one short session per read, ≥ 1.2 s apart: ``` S:0 Int Libplctag value=4 status=0x00000000 (Good) S:0 Int Native value=4 status=0x00000000 (Good) S:57 Int Libplctag value=1400 status=0x00000000 (Good) S:57 Int Native value=1400 status=0x00000000 (Good) S:4 Int Libplctag value=12688 status=0x00000000 (Good) S:4 Int Native value=-31956 status=0x00000000 (Good) O:0 Int Libplctag value=0 status=0x00000000 (Good) O:0 Int Native value=0 status=0x00000000 (Good) O:0/3 Bit Libplctag value=false status=0x00000000 (Good) O:0/3 Bit Native value=false status=0x00000000 (Good) I:0 Int Libplctag value=-29376 status=0x00000000 (Good) I:0 Int Native value=-29376 status=0x00000000 (Good) I:0/0 Bit Libplctag value=false status=0x00000000 (Good) I:0/0 Bit Native value=false status=0x00000000 (Good) I:0/1 Bit Libplctag value=false status=0x00000000 (Good) I:0/1 Bit Native value=false status=0x00000000 (Good) R6:0.DN Bit Libplctag value=false status=0x00000000 (Good) R6:0.DN Bit Native value=false status=0x00000000 (Good) R6:0.EN Bit Libplctag value=false status=0x00000000 (Good) R6:0.EN Bit Native value=false status=0x00000000 (Good) R6:0.LEN Int Libplctag value=32 status=0x00000000 (Good) R6:0.LEN Int Native value=32 status=0x00000000 (Good) N9:0 Int Libplctag value=2331 status=0x00000000 (Good) N9:0 Int Native value=2331 status=0x00000000 (Good) F8:226 Float Libplctag value=2.1 status=0x00000000 (Good) F8:226 Float Native value=2.1 status=0x00000000 (Good) B3:28 Int Libplctag value=2309 status=0x00000000 (Good) B3:28 Int Native value=2309 status=0x00000000 (Good) T4:15.PRE Int Libplctag value=6000 status=0x00000000 (Good) T4:15.PRE Int Native value=6000 status=0x00000000 (Good) C5:8.DN Bit Libplctag value=true status=0x00000000 (Good) C5:8.DN Bit Native value=true status=0x00000000 (Good) ST14:10 String Libplctag value="Z-33323" status=0x00000000 (Good) ST14:10 String Native value="Z-33323" status=0x00000000 (Good) 05:31:09 I:0/6 Libplctag Value: true | Status: 0x00000000 (Good) 05:31:11 I:0/6 Native Value: true | Status: 0x00000000 (Good) 05:31:13 I:0/15 Libplctag Value: true | Status: 0x00000000 (Good) 05:31:16 I:0/15 Native Value: true | Status: 0x00000000 (Good) 05:31:18 I:0/7 Libplctag Value: false | Status: 0x00000000 (Good) 05:31:20 I:0/7 Native Value: false | Status: 0x00000000 (Good) 05:31:22 S:4 Libplctag Value: 17831 | Status: 0x00000000 (Good) 05:31:24 S:4 Native Value: -26549 | Status: 0x00000000 (Good) 05:31:26 S:4 Libplctag Value: -4646 | Status: 0x00000000 (Good) 05:31:28 S:4 Native Value: 17335 | Status: 0x00000000 (Good) ``` Every address reads identically on both transports. `S:4` is the free-running clock, so its value differs read to read on each transport (both Good). `I:0` = -29376 = 0x8D40, and bits `/6` and `/15` read true on both while `/0`, `/1`, `/7` read false — the bit is extracted from the word libplctag read, not defaulted. ab_server has no S/O/I files, so this real-controller read is the only wire evidence; the translation is unit-pinned against a model of the `pccc.c` parser (`LibplctagLegacyNameTests`). Caveats: `R6:0.DN` and `R6:0.EN` read `false` on both transports, so this read does not discriminate the control-word bit number (a wrong bit would very likely also read false) — the R bit mapping rests on the native table and the `pccc.c` parser model, not on this wire evidence. Writes: on libplctag every address the translation changes (`S:`/`O:`/`I:`, R/UA status bits) is refused `BadNotSupported` (#659 review), so this fix adds no libplctag write path. ## hw5 — #566 idle-connection close and a native STRING write on Z-35162 — 2026-10-09 (user-approved) Press: Z-35162, CompactLogix 5069-L320ERS2 rev 34.11, `ab://10.209.70.160/1,0`, family CompactLogix, Mac VPN. The user approved exactly two things: (A) one read-only idle-connection session for #566 and (B) native array and STRING writes on logic-unreferenced tags. Harness: `AbCip.IntegrationTests/HardwareGate/` (its own namespace, outside the read-only `Differential` guard), both tests env-gated. No packet capture (`tcpdump` needs sudo); the diagnostics tap (`ConnectionDiagnostics.ExchangeObserver`, now also fed the Forward Open reply so the granted RPIs are visible) and the transport's exception text are the evidence. ### A. #566 — what a real Logix does with a class-3 connection idle past its timeout: it drops TCP, no CIP status `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_EVID_IDLE=1 AB_EVID_IDLE_SECONDS=60` → `IdleConnectionEvidenceTests` (raw: [hw5-idle566.txt](2026-10-07-native-ab-transport-t21/hw5-idle566.txt)). One private pool, keep-alive pushed out to 1 h so nothing touched the connection, open-attempt budget 2, three Read Tag requests of `test2` (DINT) and nothing else. 10:04:49Z–10:05:54Z UTC. | t (s) | Event | |---|---| | 0.26 | Large Forward Open (0x5B) granted: O→T API = T→O API = 2 000 000 µs (the requested RPI, unchanged). Multiplier 1 (×8) ⇒ connection timeout **16 s** | | 0.32 | read 1: general status 0x00 | | 0.32 – 60.33 | idle, no traffic (3.75 × the timeout) | | 60.33 | read 2: **failed in 9 ms on the write**: `IOException: Broken pipe` ← `SocketException(Shutdown)` → `AbTransportException(BadConnection)` → OPC UA `BadCommunicationError` (0x80050000). No CIP reply, no encapsulation status, `ConnectionsClosedByTarget` 0 | | 62.47 | read 3: fresh Large Forward Open granted (new O→T id), read general 0x00 — **recovered on the next request**, open attempts 2/2 | | 62.56 | lease released: Forward Close sent/acked 1/1 | - **Answer to #566: neither 0x0107 nor 0x0203.** The controller closed the TCP session; the next request's write hit a dead socket. EPIPE on the *first* write after the idle (not a successful write followed by EOF) means the close was already complete on the Mac's side, which is consistent with an RST (or FIN + RST) from the controller; without a capture the segment type and the exact moment within the 60 s window are not observed — only that it happened after the 16 s timeout and before 60 s. - **The transport handles it correctly as it stands, so no production code changed.** The failed write faults the session (`EncapsulationClient.Fault`, `IsConnected` false), the failing request alone fails `BadConnection` (fast, not an exchange timeout), the driver maps that to `BadCommunicationError` and evicts the runtime / reports `Degraded` for that poll, and the next send reopens through the Forward Open throttle. The 0x0107/0x0203 recycle (#566) stays: it is correct for a target that keeps TCP up, and harmless here (not exercised). - **In production this path needs more than idleness.** The keep-alive fires at half the expiry (8 s), so a connection the driver holds never idles 16 s; the shape above arises after a controller restart / re-download or a network gap. - **Pinned offline:** `CipConnectionTests.A_target_that_resets_an_idle_session_fails_the_next_request_fast_and_the_one_after_reopens` replays the shape (a `FakeEipServer.ResetAllConnections` RST with no request in flight): `BadConnection` in < 4 s, `ConnectionsClosedByTarget` 0, one Forward Open before / two after. Proven able to fail: with `EnsureOpenAsync` made to reuse a non-connected session it goes red (the reopen never happens). ### B. Native writes: STRING PASS; array NOT RUN (no tag qualifies) **STRING — `Program:ES03_TemperatureControl._StrTmp` (PASS, restored byte-exact).** Census: refs 0, extrefs 0, not in the Galaxy map, program not HMI/Wonderware; raw grep of `SN3244.L5X` finds the name only at its declaration (line 69426). `AB_HW_WRITE_GATEWAY=ab://10.209.70.160/1,0 AB_HW_WRITE_APPROVED=1 AB_HW_WRITE_STRING_TAG=Program:ES03_TemperatureControl._StrTmp` → `StringWriteGateTests`, through the production `AbCipDriver` on the native transport, 10:10:54Z–10:11:29Z UTC (raw: [hw5-string-write.txt](2026-10-07-native-ab-transport-t21/hw5-string-write.txt)). A read-only dry run (`AB_HW_WRITE_DRYRUN=1`) first confirmed the preflight. The raw bytes come from the tap on each Read Tag Fragmented reply. | Step | Status | Value | Raw (type, handle, LEN, DATA…) | |---|---|---|---| | snapshot 1 | Good | `ES03_TemperatureControl:Fault` | `A002 CE0F 1D000000 4553…6C74 00…` (92 B) | | snapshot 2 (+30 s) | Good | same | identical to snapshot 1 | | preflight | — | — | built-in STRING (0x02A0 / handle 0x0FCE / 88 B), LEN 29, every byte past LEN zero | | write `OTOPCUA-TEST` | Good (0x00000000) | — | — | | read back | Good | `OTOPCUA-TEST` | `A002 CE0F 0C000000 4F544F504355412D54455354 00…` | | restore original | Good | — | — | | read back | Good | `ES03_TemperatureControl:Fault` | **byte-equal to snapshot 1** | | final snapshot (+2 s) | Good | same | byte-equal to snapshot 1 | The original matches the L5X export's value. The write is one unfragmented Write Tag (88 B of data plus the path is far below the 4000-byte request limit of the Large Forward Open). **Array — not run; no candidate passes the brief's own rule.** The only logic-unreferenced, Read/Write atomic arrays in the census are `Program:EM01_PowerUnit._SEQ_EP` and `Program:EM02_Clamp._SEQ_EP` (DINT[32]), and the raw grep shows they are **not** unreferenced: aliases `_SEQ_EP01_AutoCycle → _SEQ_EP[1]` (EM01) and `_SEQ_EP01_Jog / _SEQ_EP02_Auto / _SEQ_EP03_Stopping → _SEQ_EP[1..3]` (EM02) are the step registers of the power-unit auto cycle and the clamp jog / auto / stopping sequencers, used by dozens of rungs (`MOV(…,_SEQ_EP02_Auto)`, `EQU(_SEQ_EP01_AutoCycle,300)`…). The census counts references by tag name, so references through an alias were invisible to it. A whole-array write would rewrite live sequencer state, and even a single unaliased element fails the "name appears only at its declaration" check. The other arrays are referenced (`_DisableStates`, `_ModeTransitions`, `_ModeNames`, `_VacuumCalibration_*`, `Stored`, `_monitorID`), `ExternalAccess="Read Only"` (`_SEQ_UP`, `Inp_CycleTime`) or framework lookup tables (`Inf_LookupFwkB`). No array write was made. An element write needs a tag the user picks (or an unaliased element they approve explicitly). **#567 (fragmented wide-struct write) — still cannot be done here, and a DINT[32] would not have done it.** AbCip has no whole-array write at the driver layer (array tags are read-only, `DriverArrayWriteSupport`); and at the transport layer a 128-byte array (or this 88-byte STRING) is a single Write Tag: `LogixTagClient.WriteAsync` fragments only when the request exceeds `MaxRequestBytes` (4000 B on the Large Forward Open this controller grants). A fragmented write needs a value of roughly 4 KB. ### Native array-element write — 2026-10-09 (user-approved, Z-35162) `Program:EM01_PowerUnit._SEQ_EP` (DINT[32]). L5X check: its only alias is `_SEQ_EP01_AutoCycle -> _SEQ_EP[1]`; no routine references `_SEQ_EP` directly (no index, COP or FLL); no cross-program reference. AbCip CLI (native) from master `98c00904`, one element per read, two snapshots 30 s apart before writing. `[1]` = -1 is the idle auto-cycle step. ``` SNAP1: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 SNAP2: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 WRITE [31]=4242: Write Program:EM01_PowerUnit._SEQ_EP[31]: 0x00000000 (Good) AFTER: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4242 RESTORE [31]=0: Write Program:EM01_PowerUnit._SEQ_EP[31]: 0x00000000 (Good) FINAL: 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ```