b085360454
Review round 1 on the golden frames: - Re-captured logix-read-dint-array16 (element i = ((i+1)<<24)|(i+1)) and logix-read-bool (= 1) so neither decode oracle is all zeros. ab_server rejects libplctag's own BOOL write with CIP 0x03 (two data bytes for a one-byte BOOL), so the BOOL is seeded by a raw UCMM Write Tag (client seed-bool). - Committed the capture client as scripts/ab-capture/client (outside the slnx, CPM off, libplctag 1.5.2 pinned); capture.sh gains a `client` subcommand. - README documents simulator-only behaviour: zeroed reply sender context, STRING typed D0 00 instead of A0 02 + handle, and the BOOL write refusal. - capture.sh no longer claims UnregisterSession reaches the wire (session ends in RST). - CaptureFileTests also check the CPF walk ends at the frame end and that every reply service is its request service | 0x80.
135 lines
11 KiB
Markdown
135 lines
11 KiB
Markdown
# 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):
|
||
|
||
```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=<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.
|