test(ab-transport): non-zero array/BOOL captures, committed capture client, sim quirks

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.
This commit is contained in:
Joseph Doherty
2026-10-07 15:58:05 -04:00
parent 1201d5fcfb
commit b085360454
7 changed files with 293 additions and 21 deletions
@@ -50,6 +50,36 @@ public sealed class CaptureFileTests
for (var i = 0; i < frames.Count; i++) frames[i].FromClient.ShouldBe(i % 2 == 0, $"{name} frame {i}");
}
[Theory]
[MemberData(nameof(AllCaptures))]
public void Every_CPF_item_walk_ends_exactly_at_the_frame_end(string name)
{
foreach (var f in CaptureFile.Load(name).Where(f => BitConverter.ToUInt16(f.Bytes, 0) is 0x6F or 0x70))
{
var pos = 24 + 4 + 2;
var itemCount = BitConverter.ToUInt16(f.Bytes, pos);
pos += 2;
for (var i = 0; i < itemCount; i++) pos += 4 + BitConverter.ToUInt16(f.Bytes, pos + 2);
pos.ShouldBe(f.Bytes.Length, name);
}
}
[Theory]
[MemberData(nameof(AllCaptures))]
public void Every_reply_service_is_its_request_service_with_the_reply_bit(string name)
{
var frames = CaptureFile.Load(name);
for (var i = 0; i + 1 < frames.Count; i += 2)
{
var request = CaptureFile.CipPayload(frames[i]);
var reply = CaptureFile.CipPayload(frames[i + 1]);
if (request.Length == 0) continue; // RegisterSession: no CIP
reply[0].ShouldBe((byte)(request[0] | 0x80), $"{name} pair {i / 2}");
}
}
[Fact]
public void CipPayload_strips_the_connected_sequence_count()
{
@@ -21,6 +21,27 @@ Values are **little-endian on the wire** and carry live per-session values — s
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
| | |
@@ -32,9 +53,10 @@ fields it builds, not those session-assigned values.
| 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 …` then `capture.sh tohex …` |
| 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 was a ~60-line console program over libplctag 1.5.2 that mirrors how
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
@@ -42,6 +64,26 @@ tag and calls `plc_tag_shutdown` so the Forward Close reaches the wire. **One fr
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
@@ -52,9 +94,9 @@ tag string is the libplctag attribute set the wrapper was given.
| `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 |
| `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 0 |
| `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 |
@@ -70,7 +112,7 @@ tag string is the libplctag attribute set the wrapper was given.
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).
visible in one place. No session in any family sent UnregisterSession (0x66); each ends with a TCP RST after the last reply.
## Routing observed
@@ -4,8 +4,8 @@
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestBOOL
# session: logix-read-bool (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : Two connected 0x52 exchanges for a BOOL (Initialize read + explicit Read). TestBOOL held 0.
> 7000280074EA6678000000000000000000000000000000000000000001000200A1000400ABDA0000B100140001005205910854657374424F4F4C010000000000
< 70001D0074EA6678000000000000000000000000000000000000000001000200A100040039377043B10009000100D2000000C10000
> 7000280074EA6678000000000000000000000000000000000000000001000200A1000400ABDA0000B100140002005205910854657374424F4F4C010000000000
< 70001D0074EA6678000000000000000000000000000000000000000001000200A100040039377043B10009000200D2000000C10000
# frames : Two connected 0x52 exchanges for a BOOL (Initialize read + explicit Read). TestBOOL held 1, seeded by a raw UCMM Write Tag (client seed-bool) because ab_server rejects libplctag's own BOOL write with CIP 0x03.
> 70002800F7F16E35000000000000000000000000000000000000000001000200A10004001EEDEAA0B100140001005205910854657374424F4F4C010000000000
< 70001D00F7F16E35000000000000000000000000000000000000000001000200A10004004DFBAD17B10009000100D2000000C10001
> 70002800F7F16E35000000000000000000000000000000000000000001000200A10004001EEDEAA0B100140002005205910854657374424F4F4C010000000000
< 70001D00F7F16E35000000000000000000000000000000000000000001000200A10004004DFBAD17B10009000200D2000000C10001
@@ -4,8 +4,8 @@
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestArray&elem_count=16
# session: logix-read-dint-array16 (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : Two connected 0x52 exchanges for 16 DINT elements (Initialize read + explicit Read).
> 70002A00C222CFC1000000000000000000000000000000000000000001000200A1000400B0B13F6BB100160001005206910954657374417272617900100000000000
< 70005C00C222CFC1000000000000000000000000000000000000000001000200A1000400E59A1D32B10048000100D2000000C40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
> 70002A00C222CFC1000000000000000000000000000000000000000001000200A1000400B0B13F6BB100160002005206910954657374417272617900100000000000
< 70005C00C222CFC1000000000000000000000000000000000000000001000200A1000400E59A1D32B10048000200D2000000C40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
# frames : Two connected 0x52 exchanges for 16 DINT elements (Initialize read + explicit Read). Seeded first (client write ... dintseq): element i (0-based) = ((i+1) << 24) | (i+1), i.e. 0x01000001, 0x02000002 ... 0x10000010.
> 70002A00F43B6DB3000000000000000000000000000000000000000001000200A1000400A07474F5B100160001005206910954657374417272617900100000000000
< 70005C00F43B6DB3000000000000000000000000000000000000000001000200A1000400943EDA10B10048000100D2000000C4000100000102000002030000030400000405000005060000060700000708000008090000090A00000A0B00000B0C00000C0D00000D0E00000E0F00000F10000010
> 70002A00F43B6DB3000000000000000000000000000000000000000001000200A1000400A07474F5B100160002005206910954657374417272617900100000000000
< 70005C00F43B6DB3000000000000000000000000000000000000000001000200A1000400943EDA10B10048000200D2000000C4000100000102000002030000030400000405000005060000060700000708000008090000090A00000A0B00000B0C00000C0D00000D0E00000E0F00000F10000010