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
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.csandCipTemplateObjectWireCaptureTests.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.
45A0091C00000000inlogix-forward-open.hex), and every ab_server reply carries0000000000000000. 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 (seelogix-read-string.hex) isD2 00 00 00+ typeD0 00+ 88 data bytes: a 4-byteLEN(07 00 00 00for"HelloAB"), the 82-byteDATAfield, and 2 pad bytes. Write requests (logix-write-string.hex) echo the sameD0 00type. A real Logix STRING is a structure: typeA0 02followed by the 2-byte structure handle, then the same 88-byte body. Tasks 7 and 8 should treat theD0 00type as simulator behaviour. Byte parity on the type field holds only against this sim, and a STRING codec must accept theA0 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.cinhandle_write_request) answers CIP 0x03. That is why there is no BOOL write fixture.logix-read-bool.hexwas 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) routed01 00in 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.