Live read-only check against production CompactLogix 5069-L320ERS2 presses:
- The controller @tags listing names each program (Program:UN01_Press, a system symbol)
but none of its tags; those are listed only by Program:<name>.@tags. The enumerator read
@tags alone, so every program-scoped tag was unbrowseable — including the PackML state
(Program:UN01_Press.Out_eState) and all 51 alarm bits the Ignition project reads. The
enumerator now reads each program's listing and decodes it with that program as scope;
an unreadable program listing is skipped, not fatal.
- The Symbol Object decoder ignored the dimension bits + dim words, so every discovered
array (DINT[32], REAL[32], STRING[50], UDT[23] on the two controllers checked) browsed as
a scalar. IsArray/ElementCount are now populated (multi-dim flattened). A 1-D single
element entry stays scalar: ab_server reports every scalar that way, a real controller
sets no dimension bits on scalars.
Pinned by entries captured off the controllers (CipSymbolObjectRealControllerCaptureTests,
AbCipProgramScopeEnumerationTests).
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