Files
lmxopcua/docs/plans/2026-10-07-native-ab-transport-gate-results.md
T
Joseph Doherty 7ba86f7857 fix(ablegacy): refuse every libplctag write the #659 translation changes (fix round 2)
Review M1: round 1 refused only the rewritten status bits, so S:/O:/I: writes - which
never worked on libplctag because tag creation failed - would have started reaching
the PLC (outputs, status-file words) on the default transport. WriteRefusal now keys
on LibplctagLegacyName.IsTranslated: file number supplied (S/O/I, incl. bit writes
like O:0/3, refused ahead of the parent-word RMW) or status bit rewritten. Reads
unaffected. Docs/comments now true; gate doc notes R bit-number wire evidence is
non-discriminating (L1). 12 new cases fail with the round-1 predicate.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 05:43:50 -04:00

49 KiB
Raw Blame History

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=<p>; AbLegacy ~/otopcua-ablegacy, AB_LEGACY_COMPOSE_PROFILE=<p>); 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):

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:

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.

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, pass 2.

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). 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). 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: <null> Status: 0x80050000 (BadCommunicationError) 
I:0/1      Value: <null> 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: <null> Status: 0x80050000 (BadCommunicationError) 
I:0/1      Value: <null> Status: 0x80050000 (BadCommunicationError) 

IDLE CHECK N10:150/0=false 0x00000000 (Good)  B3:0/4=false 0x00000000 (Good) 
S:0          Int      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: 4 0x00000000 (Good) 
S:57         Int      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: 1400 0x00000000 (Good) 
S:4          Int      | lib: <null> 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: <null> 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: <null> 0x80050000 (BadCommunicationError)     | nat: 0 0x00000000 (Good) 
O:0/3        Bit      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: false 0x00000000 (Good) 
I:0          Int      | lib: <null> 0x80050000 (BadCommunicationError)     | nat: -29376 0x00000000 (Good) 
I:0/0        Bit      | lib: <null> 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/<bit>. 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.