fix(abcip): decode real @udt template buffers; browse STRINGs as String, hide BOOL hosts

Live read-only check against production CompactLogix 5069-L320ERS2 rev 34.11 presses
(Z-35162 / 10.209.70.160): every @udt/<id> template the controller returned decoded to
null, so controller-discovered UDTs never fanned out. The decoder assumed a 12-byte
header led by the member count and [info][array][offset] blocks with ';'-terminated
names; libplctag actually returns a 14-byte header (u16 id, u32 def words, u32 instance
size, u16 member count, u16 handle), [info][type][offset] blocks, and NUL-terminated
names after a 'Name;<tail>' type name. All three test builders restated the old
assumption, so the unit suite agreed with itself (the #468 trap again); they now share a
TemplateBufferBuilder pinned by real captures (CipTemplateObjectWireCaptureTests).

Discovery follow-ons that the working decoder exposed:
- STRING (and user string types, LEN DINT + DATA SINT[n]) browse as one String variable,
  top-level and nested, not as a LEN/DATA folder.
- Controller-generated members (ZZZZZZZZZZ* BOOL hosts, __* AOI backing) are not browsed.
- A UDT array is not fanned out into element-free Tag.Member paths.
This commit is contained in:
Joseph Doherty
2026-10-07 14:04:11 -04:00
parent e3d7a8f071
commit 3b48e05e9a
8 changed files with 428 additions and 224 deletions
@@ -1285,6 +1285,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// bogus single Structure → String placeholder Variable. When the shape can't be
// resolved (or yields no emittable leaf) we fall through to the legacy single
// Variable so nothing regresses.
// Review I-1 — a discovered array of length 1 is still an array; honour the
// explicit IsArray flag (legacy ElementCount > 1 still surfaces as an array).
var discoveredIsArray = discovered.IsArray || discovered.ElementCount > 1;
var discoveredType = discovered.DataType;
if (discovered.DataType == AbCipDataType.Structure)
{
// Pass the discovered tag's CIP template instance id (surfaced by the Symbol
@@ -1294,7 +1299,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var shape = await ResolveDiscoveredUdtShapeAsync(
device.HostAddress, discovered.Name, discovered.TemplateInstanceId, cancellationToken)
.ConfigureAwait(false);
if (shape is not null)
if (shape is { IsStringFamily: true })
{
// A controller reports every STRING (built-in or a user string type such as
// STR0016) as a LEN + DATA struct. It is one String value — the read path reads
// it as such — so it becomes one String Variable, never a LEN/DATA folder.
discoveredType = AbCipDataType.String;
}
else if (shape is not null && !discoveredIsArray)
{
// Materialise the member sub-folder LAZILY — only once a leaf is actually
// about to be emitted into it. A shape that yields no usable atomic leaf
@@ -1313,12 +1325,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
if (!emitted.Add(fullName)) continue;
// Review I-1 — a discovered array of length 1 is still an array; honour the
// explicit IsArray flag (legacy ElementCount > 1 still surfaces as an array).
var discoveredIsArray = discovered.IsArray || discovered.ElementCount > 1;
discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: discovered.DataType.ToDriverDataType(),
DriverDataType: discoveredType.ToDriverDataType(),
IsArray: discoveredIsArray,
ArrayDim: discoveredIsArray ? (uint)Math.Max(1, discovered.ElementCount) : null,
SecurityClass: discovered.ReadOnly
@@ -1327,12 +1336,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false,
NativeDataType: discovered.DataType.ToString()));
NativeDataType: discoveredType.ToString()));
}
}
}
}
/// <summary>
/// <c>true</c> for a UDT member the controller generates rather than the user declares: the
/// hidden SINT/DINT hosts Logix packs BOOL members into (<c>ZZZZZZZZZZAlarms0</c>) and AOI
/// backing members (<c>__BitHost00</c>). A user-declared Logix name can never begin with two
/// underscores, so neither rule can hide a real member.
/// </summary>
/// <param name="memberName">The member name from the template.</param>
/// <returns><c>true</c> when the member is controller-generated.</returns>
internal static bool IsHiddenUdtMember(string memberName) =>
memberName.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)
|| memberName.StartsWith("__", StringComparison.Ordinal);
/// <summary>
/// Recursively fan a controller-discovered UDT shape out into Variable nodes — one per
/// ATOMIC leaf — under the folder produced by <paramref name="folder"/>. Mirrors the
@@ -1381,37 +1402,50 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var anyEmitted = false;
foreach (var member in shape.Members)
{
if (IsHiddenUdtMember(member.Name)) continue;
var memberFullName = $"{parentFullName}.{member.Name}";
var leafType = member.DataType;
if (member.DataType == AbCipDataType.Structure)
{
// Nested struct: recurse into its sub-shape if it resolves, the depth cap allows it,
// and we're not re-entering a struct already on this path (cycle guard). The nested
// members live a level deeper, so the cap is checked against depth + 1.
if (depth + 1 > MaxUdtDepth) continue;
if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here
// The decoded parent shape carries the nested UDT's template instance id (low 12 bits of the
// member info, captured by CipTemplateObjectDecoder) — thread it so the nested shape is fetched
// off the controller via FetchUdtShapeAsync, making nested-struct members addressable in
// production (name-keyed seeded shapes still win first, for the test seam).
// Nested struct: resolve its sub-shape first. The decoded parent shape carries the nested
// UDT's template instance id (low 12 bits of the member type word, captured by
// CipTemplateObjectDecoder) — thread it so the nested shape is fetched off the controller
// via FetchUdtShapeAsync (name-keyed seeded shapes still win first, for the test seam).
var nested = await ResolveDiscoveredUdtShapeAsync(
deviceHostAddress, member.Name, templateInstanceId: member.NestedTemplateId, cancellationToken)
.ConfigureAwait(false);
if (nested is not null)
if (nested is { IsStringFamily: true })
{
// Materialise the nested sub-folder lazily under the parent folder (itself lazy),
// so a nested struct that yields no leaf leaves no empty sub-folder behind.
var parentFolder = folder;
var nestedFolder = new Lazy<IAddressSpaceBuilder>(
() => parentFolder.Value.Folder(memberFullName, member.Name));
var nestedEmitted = await FanOutDiscoveredUdtMembersAsync(
nestedFolder, deviceHostAddress, memberFullName, nested, parentReadOnly,
depth + 1, emitted, visited, cancellationToken)
.ConfigureAwait(false);
anyEmitted |= nestedEmitted;
// A nested STRING (or user string type) member is one String leaf, not LEN + DATA.
leafType = AbCipDataType.String;
}
else
{
// An array of a UDT has no element-free member path (Parent.Arr.X names no element),
// so it is not recursed into. Otherwise recurse if the depth cap allows it and we're
// not re-entering a struct already on this path (cycle guard). The nested members
// live a level deeper, so the cap is checked against depth + 1.
if (member.ArrayLength > 1) continue;
if (depth + 1 > MaxUdtDepth) continue;
if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here
if (nested is not null)
{
// Materialise the nested sub-folder lazily under the parent folder (itself lazy),
// so a nested struct that yields no leaf leaves no empty sub-folder behind.
var parentFolder = folder;
var nestedFolder = new Lazy<IAddressSpaceBuilder>(
() => parentFolder.Value.Folder(memberFullName, member.Name));
var nestedEmitted = await FanOutDiscoveredUdtMembersAsync(
nestedFolder, deviceHostAddress, memberFullName, nested, parentReadOnly,
depth + 1, emitted, visited, cancellationToken)
.ConfigureAwait(false);
anyEmitted |= nestedEmitted;
}
visited.Remove(member.Name);
continue;
}
visited.Remove(member.Name);
continue;
}
// Atomic leaf — emit one Variable. Dedup by full-name within the Discovered folder.
@@ -1420,7 +1454,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var memberIsArray = member.ArrayLength > 1;
folder.Value.Variable(member.Name, member.Name, new DriverAttributeInfo(
FullName: memberFullName,
DriverDataType: member.DataType.ToDriverDataType(),
DriverDataType: leafType.ToDriverDataType(),
IsArray: memberIsArray,
ArrayDim: memberIsArray ? (uint)Math.Max(1, member.ArrayLength) : null,
// The Symbol Object carries no per-member ExternalAccess bits, so members inherit the
@@ -1431,7 +1465,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false,
NativeDataType: member.DataType.ToString()));
NativeDataType: leafType.ToString()));
anyEmitted = true;
}
@@ -57,7 +57,19 @@ public sealed class AbCipTemplateCache
public sealed record AbCipUdtShape(
string TypeName,
int TotalSize,
IReadOnlyList<AbCipUdtMember> Members);
IReadOnlyList<AbCipUdtMember> Members)
{
/// <summary>
/// <c>true</c> when the shape is a Logix string-family type — the built-in <c>STRING</c> or
/// a user string type such as <c>STR0016</c>: exactly a <c>LEN</c> DINT at offset 0 and a
/// <c>DATA</c> SINT array at offset 4. A controller reports every string tag as a struct of
/// this shape, but it is read and presented as one String value, never as LEN + DATA leaves.
/// </summary>
public bool IsStringFamily =>
Members.Count == 2
&& Members[0] is { Name: "LEN", DataType: AbCipDataType.DInt, Offset: 0 }
&& Members[1] is { Name: "DATA", DataType: AbCipDataType.SInt, Offset: 4, ArrayLength: > 1 };
}
/// <summary>One member of a Logix UDT.</summary>
/// <param name="Name">Member name as reported by the Template Object.</param>
@@ -4,103 +4,101 @@ using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Decoder for the CIP Template Object (class 0x6C) blob returned by a <c>Read Template</c>
/// service. Produces an <see cref="AbCipUdtShape"/> describing the UDT's name, total size,
/// + ordered member list with per-member offset + type + array length.
/// Decoder for the UDT template buffer libplctag returns for an <c>@udt/&lt;id&gt;</c> tag (CIP
/// Template Object, class 0x6C). Produces an <see cref="AbCipUdtShape"/> describing the UDT's
/// name, total size, + ordered member list with per-member offset + type + array length.
/// </summary>
/// <remarks>
/// <para>Wire format per Rockwell CIP Vol 1 §5A + Logix 5000 CIP Programming Manual
/// 1756-PM019 §"Template Object", cross-checked against libplctag's <c>ab/cip.c</c>
/// <c>handle_read_template_reply</c>:</para>
/// <para>The layout below is what libplctag hands back from <c>Tag.GetBuffer()</c> — its own
/// header followed by the controller's Read Template reply — matching libplctag's
/// <c>list_tags_logix</c> example and pinned by real captures in
/// <c>CipTemplateObjectWireCaptureTests</c> (CompactLogix 5069-L320ERS2 rev 34.11). An earlier
/// version assumed a 12-byte header led by the member count and <c>[info][array][offset]</c>
/// blocks; every real buffer decoded to <c>null</c> under that assumption.</para>
///
/// <para>Header (fixed-size, little-endian):</para>
/// <para>Header (14 bytes, little-endian):</para>
/// <list type="table">
/// <item><term>u16</term><description>Member count.</description></item>
/// <item><term>u16</term><description>Struct handle (opaque id).</description></item>
/// <item><term>u16</term><description>Template instance id.</description></item>
/// <item><term>u32</term><description>Member-definition size in 32-bit words — not used here.</description></item>
/// <item><term>u32</term><description>Instance size — bytes per structure instance.</description></item>
/// <item><term>u32</term><description>Member-definition total size — not used here.</description></item>
/// <item><term>u16</term><description>Member count.</description></item>
/// <item><term>u16</term><description>Struct handle (opaque).</description></item>
/// </list>
///
/// <para>Then <c>member_count</c> member blocks (8 bytes each):</para>
/// <list type="table">
/// <item><term>u16</term><description>Member info — type code + flags (same encoding
/// as Symbol Object: bit 15 = struct, lower 12 = CIP type code).</description></item>
/// <item><term>u16</term><description>Array size — 0 for scalar members.</description></item>
/// <item><term>u32</term><description>Struct offset — byte offset from struct start.</description></item>
/// <item><term>u16</term><description>Info — the array length for an array member, the bit
/// number for a BOOL packed into a host integer, otherwise 0.</description></item>
/// <item><term>u16</term><description>Type — bit 15 = struct, bit 13 = array, low 12 bits =
/// CIP atomic type code, or for a struct the nested template instance id.</description></item>
/// <item><term>u32</term><description>Byte offset from the struct start.</description></item>
/// </list>
///
/// <para>Then strings: UDT name followed by each member name, each terminated by a
/// semicolon <c>;</c> followed by a null <c>\0</c>. The UDT name may itself contain the
/// sequence <c>UDTName;0\0</c> where <c>0</c> after the semicolon is an ASCII flag byte.
/// Decoder trims to the first semicolon.</para>
/// <para>Then the strings: the UDT name terminated by <c>;</c> (followed by an encoded tail that
/// runs to a NUL — discarded), then each member name terminated by a NUL. Anything after the
/// last member name is ignored.</para>
/// </remarks>
public static class CipTemplateObjectDecoder
{
private const int HeaderSize = 12; // u16 + u16 + u32 + u32
private const int MemberBlockSize = 8; // u16 + u16 + u32
private const int HeaderSize = 14; // u16 id + u32 def size + u32 instance size + u16 count + u16 handle
private const int MemberBlockSize = 8; // u16 info + u16 type + u32 offset
private const ushort MemberInfoStructFlag = 0x8000;
private const ushort MemberInfoTypeCodeMask = 0x0FFF;
private const ushort TypeStructFlag = 0x8000;
private const ushort TypeArrayFlag = 0x2000;
private const ushort TypeCodeMask = 0x0FFF;
/// <summary>
/// Decode the raw Template Object blob. Returns <c>null</c> when the header indicates
/// zero members or the buffer is too short to hold the fixed header.
/// Decode the raw template buffer. Returns <c>null</c> when the header indicates zero
/// members or the buffer is too short to hold the header + member blocks + type name.
/// </summary>
/// <param name="buffer">The raw Template Object buffer to decode.</param>
/// <param name="buffer">The raw template buffer to decode.</param>
/// <returns>An AbCipUdtShape describing the structure, or null if decoding fails.</returns>
public static AbCipUdtShape? Decode(byte[] buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
if (buffer.Length < HeaderSize) return null;
var memberCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(0));
// bytes 2-3: struct handle — opaque, not needed for the shape record
var instanceSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(4));
// bytes 8-11: member-definition total size — inferred from names list instead
// bytes 0-1: template instance id; bytes 2-5: definition size in words — neither needed.
var instanceSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(6));
var memberCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(10));
// bytes 12-13: struct handle — opaque.
if (memberCount == 0) return null;
var memberBlocksOffset = HeaderSize;
var namesOffset = memberBlocksOffset + MemberBlockSize * memberCount;
var namesOffset = HeaderSize + (MemberBlockSize * memberCount);
if (namesOffset > buffer.Length) return null;
var stringsSpan = buffer.AsSpan(namesOffset);
var names = ParseSemicolonTerminatedStrings(stringsSpan);
if (names.Count == 0) return null;
// Strings layout: UDT name first, then one per member (in the same order as the
// member-info blocks). Always consume the first entry as the UDT name; missing
// trailing member names get <member_N> placeholders below.
var udtName = names[0];
var memberNames = names.Skip(1).ToArray();
var strings = ParseTemplateStrings(buffer.AsSpan(namesOffset), memberCount);
if (strings.Count == 0) return null;
var udtName = strings[0];
var members = new List<AbCipUdtMember>(memberCount);
for (var i = 0; i < memberCount; i++)
{
var blockOffset = memberBlocksOffset + (i * MemberBlockSize);
var blockOffset = HeaderSize + (i * MemberBlockSize);
var info = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset));
var arraySize = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset + 2));
var type = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset + 2));
var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(blockOffset + 4));
var isStruct = (info & MemberInfoStructFlag) != 0;
// Scalar path only — Rockwell primitive type codes (0xC1-0xD0) fit a byte. Do NOT use for a
// struct member: a nested template id can exceed a byte (see nestedTemplateId below).
var typeCode = (byte)(info & MemberInfoTypeCodeMask);
var isStruct = (type & TypeStructFlag) != 0;
var isArray = (type & TypeArrayFlag) != 0;
// Atomic CIP type codes (0xC1-0xD0) fit a byte; for a struct the low 12 bits are the nested
// template id, which can exceed a byte, so it is taken from the full 12-bit mask below.
var dataType = isStruct
? AbCipDataType.Structure
: (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? AbCipDataType.Structure);
: (CipSymbolObjectDecoder.MapTypeCode((byte)(type & TypeCodeMask)) ?? AbCipDataType.Structure);
var nestedTemplateId = isStruct ? (uint?)(type & TypeCodeMask) : null;
// For a struct member the low 12 bits are the nested UDT's template instance id (same encoding as the
// Symbol Object), NOT a primitive type code — capture it so the nested shape can be fetched. Use the
// FULL 12-bit mask (not the byte-cast typeCode, which truncates a 12-bit id).
var nestedTemplateId = isStruct ? (uint?)(info & MemberInfoTypeCodeMask) : null;
// info is the element count only for an array member; for a BOOL it is the bit number,
// which must never be read as a length.
var arrayLength = isArray && info > 0 ? info : 1;
var memberName = i < memberNames.Length ? memberNames[i] : $"<member_{i}>";
var memberName = i + 1 < strings.Count ? strings[i + 1] : $"<member_{i}>";
members.Add(new AbCipUdtMember(
Name: memberName,
Offset: offset,
DataType: dataType,
ArrayLength: arraySize == 0 ? 1 : arraySize,
ArrayLength: arrayLength,
NestedTemplateId: nestedTemplateId));
}
@@ -111,41 +109,30 @@ public static class CipTemplateObjectDecoder
}
/// <summary>
/// Walk a span of <c>NAME;\0NAME;\0…</c> byte sequences. Splits at each semicolon —
/// the null byte after each semicolon is optional padding per Rockwell's string
/// encoding convention. Stops at a trailing null / end of buffer.
/// Split the template string section into the UDT name (cut at its first <c>;</c>) followed
/// by up to <paramref name="memberCount"/> NUL-terminated member names. Stops at the end of
/// the buffer; trailing bytes past the last member name are ignored.
/// </summary>
/// <param name="span">The byte span containing semicolon-terminated strings.</param>
/// <returns>List of parsed strings, one per name in the span.</returns>
internal static List<string> ParseSemicolonTerminatedStrings(ReadOnlySpan<byte> span)
/// <param name="span">The string section, starting right after the last member block.</param>
/// <param name="memberCount">Member names to read after the UDT name.</param>
/// <returns>The UDT name followed by the member names found.</returns>
internal static List<string> ParseTemplateStrings(ReadOnlySpan<byte> span, int memberCount)
{
var result = new List<string>();
var start = 0;
for (var i = 0; i < span.Length; i++)
var result = new List<string>(memberCount + 1);
var pos = 0;
while (pos < span.Length && result.Count < memberCount + 1)
{
var b = span[i];
if (b == ';')
var nul = span[pos..].IndexOf((byte)0);
var end = nul < 0 ? span.Length : pos + nul;
var raw = span[pos..end];
if (result.Count == 0)
{
if (i > start)
result.Add(Encoding.ASCII.GetString(span[start..i]));
// Skip the optional null/space padding following the semicolon.
while (i + 1 < span.Length && (span[i + 1] == '\0' || span[i + 1] == ' '))
i++;
start = i + 1;
// The type name carries an encoded tail after ';' ("STRING;nEAIA").
var semi = raw.IndexOf((byte)';');
if (semi >= 0) raw = raw[..semi];
}
else if (b == 0 && start == i)
{
// Trailing null at a string boundary — done.
break;
}
}
// Trailing name without a semicolon (unlikely but observed on some firmwares).
if (start < span.Length)
{
var zeroAt = span[start..].IndexOf((byte)0);
var end = zeroAt < 0 ? span.Length : start + zeroAt;
if (end > start)
result.Add(Encoding.ASCII.GetString(span[start..end]));
result.Add(Encoding.ASCII.GetString(raw));
pos = end + 1;
}
return result;
}