Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures
Joseph Doherty 7892eb13a8 fix(abcip): harden Ab.Transport CIP codec and EncapsulationClient (#564, #565)
CIP codec (#564): PatchU16 bounds guard; CipReply content equality; CipPath
port/length/ASCII/odd/oversize validation with ArgumentException for malformed
tag names and routes; CipPath.MessageRouter/ConnectionManager are now
ReadOnlySpan<byte>; refused Forward Open surfaces ForwardOpenRefusedException
with structured general/extended status; test CaptureFile uses BinaryPrimitives.

EncapsulationClient (#565): frame write bounded by the exchange timeout, which
faults (never cancels) the connection; finite positive timeouts enforced at
every entry point; gate wait bounded by the timeout; deterministic reorder of
the 300 ms test; new dispose-during-drain, gate-wait and RegisterSession
zero-context tests.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:54:32 -04:00
..

Golden frame captures — libplctag ⇄ ab_server

These .hex files are the exact EtherNet/IP frames libplctag exchanged with the ab_server simulator. The native AB transport's codecs (plan Tasks 7 and 8) assert byte parity against them.

⚠️ They prove parity with libplctag, not correctness against a controller. The independent reference is the real-press captures in tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs and CipTemplateObjectWireCaptureTests.cs.

Format

One frame per line: > = client→server, < = server→client, then the full encapsulation frame (24-byte header + data) as uppercase hex with no spaces. # lines are comments; each file opens with a comment block naming its client, server, profile, tag string and what the frames are. CaptureFile.Load reads them; CaptureFileTests checks every frame's header length field against its data length and that requests and replies alternate.

Values are little-endian on the wire and carry live per-session values — session handle, connection IDs, connection serial, PCCC TNS. A parity test must compare structure and the fields it builds, not those session-assigned values.

Simulator behaviour that is NOT controller behaviour

  • ab_server zeroes the reply sender context. libplctag sends a non-zero 8-byte sender context on every SendRRData request (e.g. 45A0091C00000000 in logix-forward-open.hex), and every ab_server reply carries 0000000000000000. A real target echoes the request's context. Consequences: parity tests must exclude the sender-context field (header bytes 12–19), and the native transport must not depend on the echo to correlate replies, or it will pass against a real controller and fail against the sim, or the reverse.
  • ab_server types a STRING as atomic D0 00, not as a structure. A STRING read reply (see logix-read-string.hex) is D2 00 00 00 + type D0 00 + 88 data bytes: a 4-byte LEN (07 00 00 00 for "HelloAB"), the 82-byte DATA field, and 2 pad bytes. Write requests (logix-write-string.hex) echo the same D0 00 type. A real Logix STRING is a structure: type A0 02 followed by the 2-byte structure handle, then the same 88-byte body. Tasks 7 and 8 should treat the D0 00 type as simulator behaviour. Byte parity on the type field holds only against this sim, and a STRING codec must accept the A0 02 + handle form from a controller.
  • ab_server refuses libplctag's BOOL write. libplctag sends two data bytes (01 00) for a one-byte BOOL, and ab_server's "too much data" check (cip.c in handle_write_request) answers CIP 0x03. That is why there is no BOOL write fixture. logix-read-bool.hex was seeded by a raw UCMM Write Tag carrying one data byte (client seed-bool, below). Incidentally, that write shows ab_server accepts an Unconnected Send (0x52) routed 01 00 in ControlLogix mode.

How they were taken

Date 2026-10-07
Repo commit a0897fda (fixture sources identical to the files on the docker host, checked by md5)
Client libplctag 1.5.2 (libplctag.NET NuGet, Directory.Packages.props) → libplctag.NativeImport 1.0.41 → native core 2.6.0 (read via plc_tag_get_int_attribute(0,"version_*"))
Server image otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + vendored patches 0001 @tags listing and 0002 tag-not-found → CIP 0x04; both confirmed live — see logix-tags-listing.hex / logix-read-unknown-tag.hex)
AbCip compose /opt/otopcua-abcip on 10.100.0.35 (= tests/.../AbCip.IntegrationTests/Docker/), host :44818
AbLegacy compose ~/otopcua-ablegacy on 10.100.0.35 (= tests/.../AbLegacy.IntegrationTests/Docker/), host :44819 slc500 / :44820 micrologix / :44821 plc5
Capture point tcpdump -i eth0 tcp port 44818 in a nicolaka/netshoot:v0.13 sidecar sharing the simulator container's network namespace; tshark 4.2.4 in the same image; segments re-joined into frames by the 24-byte header length field
Recipe scripts/ab-capture/capture.sh run <container> <name> -- scripts/ab-capture/capture.sh client …, then capture.sh tohex …
Client scripts/ab-capture/client/ (ab-capture-client.csproj). Not in the solution; pins libplctag 1.5.2 with CPM off. capture.sh client … builds it once and runs it

The client (scripts/ab-capture/client/Program.cs) is a small console program over libplctag 1.5.2 that mirrors how LibplctagTagRuntime / LibplctagLegacyTagRuntime / LibplctagTagEnumerator build a tag (Gateway, Path, PlcType, Protocol=ab_eip, Name, Timeout=5s, ElementCount when > 1; no other attributes), then runs InitializeAsync + one ReadAsync or WriteAsync, disposes the tag and calls plc_tag_shutdown so the Forward Close reaches the wire. One fresh session per capture, one profile up at a time. Note that InitializeAsync itself performs a read, so a "read" capture holds two read exchanges and a "write" capture holds a read then the write.

Values were seeded before the reads so that no decode oracle is all zeros. Seeding commands (they were not captured):

C=scripts/ab-capture/capture.sh
$C client write 10.100.0.35:44818 1,0 ControlLogix TestDINT   dint    1  123456
$C client write 10.100.0.35:44818 1,0 ControlLogix TestString string  1  HelloAB
$C client write 10.100.0.35:44818 1,0 ControlLogix TestArray  dintseq 16 x   # element i (0-based) = ((i+1)<<24)|(i+1)
$C client seed-bool 10.100.0.35 44818 TestBOOL 1                              # raw UCMM, see above

The logix-write-* captures are themselves the seeds for TestDINT and TestString. Each read capture is then, for example:

CAPDIR=<fresh dir> $C run otopcua-ab-server-controllogix logix-read-dint-array16 -- \
    $C client read 10.100.0.35:44818 1,0 ControlLogix TestArray raw 16
$C tohex <fresh dir>/logix-read-dint-array16.pcap logix-read-dint-array16.full.hex

Per-file record

All files: libplctag 1.5.2 (native 2.6.0), image otopcua-ab-server:libplctag-release. The tag string is the libplctag attribute set the wrapper was given.

File Profile Tag string Frames
logix-register-session.hex controllogix protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestDINT RegisterSession (frames 1–2 of the logix-read-dint session)
logix-forward-open.hex controllogix same as above Large Forward Open 0x5B (frames 3–4)
logix-read-dint.hex controllogix …&plc=controllogix&name=TestDINT 2× connected Read Tag Fragmented 0x52 (value 123456)
logix-read-dint-array16.hex controllogix …&name=TestArray&elem_count=16 2× 0x52, 16 elements; element i (0-based) = ((i+1)<<24)|(i+1), i.e. 0x01000001 … 0x10000010 (LE on the wire: 01 00 00 01, 02 00 00 02 …)
logix-read-string.hex controllogix …&name=TestString 2× 0x52, STRING "HelloAB"
logix-read-bool.hex controllogix …&name=TestBOOL 2× 0x52, BOOL 1 (seeded via client seed-bool)
logix-write-dint.hex controllogix …&name=TestDINT 0x52 read, then Write Tag 0x4D of 123456
logix-write-string.hex controllogix …&name=TestString 0x52 read, then 0x4D of "HelloAB"
logix-read-unknown-tag.hex controllogix …&name=NoSuchTag 0x52 → general status 0x04 (patch 0002); libplctag raised ErrorNotFound, no retry
logix-tags-listing.hex controllogix …&name=@tags Get Instance Attribute List 0x55 on Symbol class 0x6B (patch 0001); all 9 tags in one reply, status 0x00
logix-forward-close.hex controllogix …&name=TestDINT Forward Close 0x4E (last 2 frames of the logix-read-dint session)
micro800-read-dint.hex micro800 protocol=ab_eip&gateway=10.100.0.35:44818&plc=micro800&name=TestDINT Full session: RegisterSession, Large FO, 2× 0x52 (value 987654), Forward Close
slc500-read-n7.hex slc500 protocol=ab_eip&gateway=10.100.0.35:44819&path=1,0&plc=slc500&name=N7:0 Full session: RegisterSession, 2× Execute PCCC 0x4B / PCCC 0x0F-0xA2 (value 4321)
slc500-write-n7.hex slc500 same Full session: 0x0F-0xA2 read, then 0x0F-0xAA write of 4321
slc500-read-f8.hex slc500 …:44819&path=1,0&plc=slc500&name=F8:0 Full session: 2× 0x0F-0xA2 (value 3.5)
micrologix-read-n7.hex micrologix protocol=ab_eip&gateway=10.100.0.35:44820&plc=micrologix&name=N7:0 Full session: 2× 0x0F-0xA2 (value 2468)
plc5-read-n7.hex plc5 protocol=ab_eip&gateway=10.100.0.35:44821&path=1,0&plc=plc5&name=N7:0 Full session: 2× PCCC 0x0F-0x01 PLC-5 typed read (value 1357)

Logix files other than register-session/forward-open/forward-close hold only the operation's exchanges; the handshake frames of their sessions are byte-for-byte the same shape as those three files. The Micro800 and PCCC files hold the whole session so their routing is visible in one place. No session in any family sent UnregisterSession (0x66); each ends with a TCP RST after the last reply.

Routing observed

What libplctag 2.6.0 actually put on the wire. Tasks 9, 12 and 14 take their Routing choice from this table.

Family ForwardOpen CIP request wrapped in Unconnected Send (0x52) Route bytes Requestor-id bytes (PCCC)
controllogix (path=1,0) yes — Large FO 0x5B only (0x54 never sent; the 0x5B succeeded). FO itself is sent direct to the Connection Manager 20 06 24 01, not wrapped. Connection path 01 00 20 02 24 01 (3 words), net params 0x42000FA2, RPI 1 000 000 µs, transport 0xA3 no — requests ride the connection (SendUnitData, item 0xB1) 01 00 (port 1, slot 0), carried only inside the FO connection path n/a
micro800 (no path) yes — Large FO 0x5B, connection path 20 02 24 01 (2 words, no port segment). ⚠️ Not UCMM; see concerns no — connected none n/a
slc500 (path=1,0) no no — Execute PCCC 0x4B sent unconnected (SendRRData, item 0xB2) straight to 20 67 24 01 (PCCC object) none — the 1,0 path was given but never appeared on the wire 07 3D F3 45 43 50 21 (length 7, vendor 0xF33D, serial 0x21504345)
micrologix (no path) no no — as slc500 none 07 3D F3 45 43 50 21
plc5 (path=1,0) no no — as slc500 none — 1,0 not emitted 07 3D F3 45 43 50 21
logixpccc (path=1,0, against the controllogix profile) no yes — Unconnected Send 0x52 to 20 06 24 01, priority/tick 0A 05, embedded 0x4B (len 0x1C) 01 00 (path size 1 word, reserved, then 01 00) 07 3D F3 45 43 50 21

logixpccc is not reachable: ab_server's ControlLogix mode has no PCCC object and answers CB 00 08 00 (service not supported). Its request shape above is read from the client side of that rejected exchange; no .hex fixture was committed for it, since a parity fixture whose reply is an error would teach nothing about the success path.