# 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 -- 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): ```bash 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: ```bash CAPDIR= $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 /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.