fix(abcip): unblock the controller-browse gate, and fix the decoder it exposed (#468)
v2-ci / build (push) Successful in 4m17s
v2-ci / unit-tests (push) Failing after 14m43s

The AbCip controller browse could never be verified end to end: libplctag's
ab_server answers CIP service 0x55 (Get Instance Attribute List) on the Symbol
Object class with "unsupported", so the `@tags` walk had no backend. #468's own
definition of done offered a sim that serves `@tags` as an alternative to real
hardware; this is that.

The fixture now applies a vendored patch teaching ab_server to serve the Symbol
Object listing, with partial-transfer paging. Correctness is checked against
libplctag's own `list_tags_logix` rather than against our decoder — our decoder
is the thing under test, so its agreement would prove nothing. The clone is also
pinned to a concrete tag: the Dockerfile called `--branch release` "pinned" while
tracking a branch that moves.

That immediately exposed a real defect. CipSymbolObjectDecoder advanced to an
even byte boundary after each symbol name, but entries are packed with no
padding — libplctag walks them by sizeof(entry) + string_len, and a captured
reply confirms it. Any odd-length name therefore desynchronised every entry
behind it, and odd-length names are ordinary. In the live browse below,
`Motor_Run` is 9 bytes and first on the wire, so the old decoder would have
shown that one tag and silently dropped the other eight.

Every existing multi-entry test passed throughout, because the test builder
padded exactly as the decoder did: the two agreed with each other and neither
agreed with a controller. The builder no longer pads, and the layout is pinned
by a captured reply (CipSymbolObjectWireCaptureTests) instead of by a second
statement of the assumption. Restoring the alignment step reddens exactly the
three new tests and none of the 29 others.

Live gate on docker-dev, all three of #468's criteria:
  - populated tree — all nine seeded tags render as leaves, in wire order
  - refresh re-captures
  - leaf commit — Pump_1_Speed + TestDINT saved with their CIP tag paths

The gate also surfaced two things it was not asked about: browse-commit drops
the discovered data type, so a browsed REAL commits as a DINT and the driver
decodes it as one (filed as #536), and Test Connect fails against this fixture
regardless of the device because it probes `@raw_cpu_type` (documented).

TwinCAT and FOCAS still have no browsable backend; #468's AbCip half is closed,
theirs is not.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 12:25:15 -04:00
parent 1a1dbc3f00
commit c2c3c52274
8 changed files with 485 additions and 24 deletions
@@ -11,18 +11,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// </summary>
/// <remarks>
/// <para>Entry layout (little-endian) per Rockwell CIP Vol 1 + Logix 5000 CIP Programming
/// Manual (1756-PM019 chapter "Symbol Object"), cross-checked against libplctag's
/// <c>ab/cip.c</c> <c>handle_listed_tags_reply</c>:</para>
/// Manual (1756-PM019 chapter "Symbol Object"), checked against a captured controller reply —
/// see <c>CipSymbolObjectWireCaptureTests</c>:</para>
/// <list type="table">
/// <item><term>u32</term><description>Symbol Instance ID — opaque identifier for the tag.</description></item>
/// <item><term>u16</term><description>Symbol Type — lower 12 bits = CIP type code (0xC1 BOOL,
/// 0xC2 SINT, …, 0xD0 STRING). Bit 12 = system-tag flag. Bit 13 = reserved.
/// 0xC2 SINT, …, 0xD0 STRING). Bit 12 = system-tag flag. Bits 13-14 = array dimension
/// count, so an ordinary one-dimensional DINT arrives as <c>0x20C4</c> and the low 12
/// bits must be masked off before the type is read.
/// Bit 15 = struct flag; when set, the lower 12 bits are the template instance id
/// (not a primitive type code).</description></item>
/// <item><term>u16</term><description>Element length — bytes per element (e.g. 4 for DINT).</description></item>
/// <item><term>u32 × 3</term><description>Array dimensions — zero for scalar tags.</description></item>
/// <item><term>u16</term><description>Symbol name length in bytes.</description></item>
/// <item><term>u8 × N</term><description>ASCII symbol name, padded to an even byte boundary.</description></item>
/// <item><term>u8 × N</term><description>ASCII symbol name. <b>Not padded</b> — entries are
/// packed back to back, so an odd-length name leaves the next entry on an odd
/// offset.</description></item>
/// </list>
///
/// <para><c>Program:</c>-scope tags arrive with their scope prefix baked into the name
@@ -66,8 +70,12 @@ public static class CipSymbolObjectDecoder
if (pos + nameLength > buffer.Length) break;
var name = Encoding.ASCII.GetString(buffer, pos, nameLength);
// No alignment step: the next entry begins on the byte immediately after the last name
// byte, even when that is an odd offset. This used to advance to an even boundary, which
// read a correct first entry and then garbage for everything following an odd-length
// name — and odd-length names are ordinary (Gitea #468).
pos += nameLength;
if ((pos & 1) != 0) pos++; // even-align for the next entry
if (string.IsNullOrWhiteSpace(name)) continue;