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
@@ -603,6 +603,110 @@ public sealed class AbCipDriverDiscoveryTests
cache.Count.ShouldBe(0);
}
// ---- real-controller shapes (captures from a CompactLogix 5069-L320ERS2 rev 34.11) ----
private static async Task<RecordingBuilder> DiscoverWithTemplatesAsync(
Dictionary<uint, byte[]> templates, params AbCipDiscoveredTag[] tags)
{
var builder = new RecordingBuilder();
var readers = new IdKeyedTemplateReaderFactory();
foreach (var (id, blob) in templates) readers.Templates[id] = blob;
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: new FakeEnumeratorFactory(tags), templateReaderFactory: readers);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
return builder;
}
/// <summary>
/// A controller reports every STRING tag as a struct (symbol type 0x8FCE → template 0x0FCE,
/// LEN + DATA). Browse must present it as the one String value it is — the read path already
/// reads it as a String — not as a folder holding a DINT and an SINT[82].
/// </summary>
[Fact]
public async Task Discovered_STRING_struct_emits_one_String_variable_not_LEN_and_DATA()
{
var builder = await DiscoverWithTemplatesAsync(
new() { [0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate) },
new AbCipDiscoveredTag("WW_WorkOrderNum", null, AbCipDataType.Structure, ReadOnly: false,
TemplateInstanceId: 0x0FCE));
var v = builder.Variables.ShouldHaveSingleItem();
v.Info.FullName.ShouldBe("WW_WorkOrderNum");
v.Info.DriverDataType.ShouldBe(DriverDataType.String);
v.Info.IsArray.ShouldBeFalse();
builder.Folders.ShouldNotContain(f => f.BrowseName == "WW_WorkOrderNum");
}
/// <summary>A nested STRING member of a UDT is one String leaf, addressable as Parent.Member.</summary>
[Fact]
public async Task Nested_STRING_member_emits_a_String_leaf()
{
var builder = await DiscoverWithTemplatesAsync(
new()
{
[3386] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.LookupMemberTemplate),
[0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate),
},
new AbCipDiscoveredTag("Lookup", null, AbCipDataType.Structure, ReadOnly: false,
TemplateInstanceId: 3386));
var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
byName.Keys.ShouldBe(["Lookup.Code", "Lookup.Desc", "Lookup.DataType", "Lookup.Lock"], ignoreOrder: true);
byName["Lookup.Desc"].DriverDataType.ShouldBe(DriverDataType.String);
byName["Lookup.Code"].DriverDataType.ShouldBe(DriverDataType.Int32);
}
/// <summary>
/// Logix packs BOOL members into hidden host integers (<c>ZZZZZZZZZZAlarms0</c>) and AOIs carry
/// <c>__BitHost00</c>-style backing members. Neither is user data, and a Logix name can never
/// contain a double underscore, so both are dropped from the browse.
/// </summary>
[Fact]
public async Task Hidden_BOOL_host_members_are_not_browsed()
{
var alarms = TemplateBufferBuilder.Build("Alarms", 4,
("ZZZZZZZZZZAlarms0", 0xC2, 0, 0),
("_00", 0xC1, 0, 0),
("__BitHost00", 0xC4, 0, 0));
var builder = await DiscoverWithTemplatesAsync(
new() { [3425] = alarms },
new AbCipDiscoveredTag("Alarms", null, AbCipDataType.Structure, ReadOnly: false,
TemplateInstanceId: 3425));
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["Alarms._00"]);
}
/// <summary>
/// An ARRAY of a UDT is not fanned out into <c>Tag.Member</c> leaves — those paths name no
/// element and every read of them would fail. An array of STRING is a String array.
/// </summary>
[Fact]
public async Task Discovered_struct_arrays_do_not_fan_out_and_STRING_arrays_are_String_arrays()
{
var udt = TemplateBufferBuilder.Build("Rec", 8, ("A", 0xC4, 0, 0), ("B", 0xCA, 0, 4));
var builder = await DiscoverWithTemplatesAsync(
new()
{
[0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate),
[0x55] = udt,
},
new AbCipDiscoveredTag("arrMessages", null, AbCipDataType.Structure, ReadOnly: false,
ElementCount: 50, IsArray: true, TemplateInstanceId: 0x0FCE),
new AbCipDiscoveredTag("Stored", null, AbCipDataType.Structure, ReadOnly: false,
ElementCount: 255, IsArray: true, TemplateInstanceId: 0x55));
builder.Variables.ShouldNotContain(v => v.Info.FullName.StartsWith("Stored."));
var messages = builder.Variables.Single(v => v.Info.FullName == "arrMessages").Info;
messages.DriverDataType.ShouldBe(DriverDataType.String);
messages.IsArray.ShouldBeTrue();
messages.ArrayDim.ShouldBe(50u);
}
// ---- helpers ----
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
@@ -714,34 +818,11 @@ public sealed class AbCipDriverDiscoveryTests
}
/// <summary>
/// Build a minimal CIP Template Object blob <see cref="CipTemplateObjectDecoder"/> can decode
/// into an <see cref="AbCipUdtShape"/>. Mirrors the helper in
/// <c>AbCipFetchUdtShapeTests</c>: 12-byte header + 8-byte member blocks + semicolon/NUL
/// terminated UDT-then-member name strings.
/// Build a template buffer in libplctag's real <c>@udt/id</c> layout via
/// <see cref="TemplateBufferBuilder"/>. <c>info</c> is the member's CIP type word; <c>arr</c>
/// &gt; 0 marks an array member.
/// </summary>
private static byte[] BuildSimpleTemplate(
string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
{
const int headerSize = 12;
const int blockSize = 8;
var strings = new MemoryStream();
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
Add(name);
foreach (var m in members) Add(m.n);
var stringsArr = strings.ToArray();
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + i * blockSize;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
}
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
return buf;
}
=> TemplateBufferBuilder.Build(name, instanceSize, [.. members.Select(m => (m.n, m.info, m.arr, m.off))]);
}
@@ -60,30 +60,14 @@ public sealed class AbCipFetchUdtShapeTests
}
}
private static byte[] BuildSimpleTemplate(string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
{
var headerSize = 12;
var blockSize = 8;
var strings = new MemoryStream();
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
Add(name);
foreach (var m in members) Add(m.n);
var stringsArr = strings.ToArray();
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + i * blockSize;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
}
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
return buf;
}
/// <summary>
/// Build a template buffer in libplctag's real <c>@udt/id</c> layout via
/// <see cref="TemplateBufferBuilder"/>. <c>info</c> is the member's CIP type word; <c>arr</c>
/// &gt; 0 marks an array member.
/// </summary>
private static byte[] BuildSimpleTemplate(
string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
=> TemplateBufferBuilder.Build(name, instanceSize, [.. members.Select(m => (m.n, m.info, m.arr, m.off))]);
private static Task<AbCipUdtShape?> InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId)
{
@@ -10,45 +10,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
public sealed class CipTemplateObjectDecoderTests
{
/// <summary>
/// Construct a Template Object blob — header + member blocks + semicolon-delimited
/// strings (UDT name first, then member names).
/// Construct a template buffer in libplctag's real layout (see <see cref="TemplateBufferBuilder"/>).
/// <c>info</c> here is the member's CIP type word; <c>arraySize</c> &gt; 0 marks an array member.
/// </summary>
private static byte[] BuildTemplate(
string udtName,
uint instanceSize,
params (string name, ushort info, ushort arraySize, uint offset)[] members)
{
var memberCount = (ushort)members.Length;
var headerSize = 12;
var memberBlockSize = 8;
var blocksSize = memberBlockSize * members.Length;
var stringsBuf = new MemoryStream();
void AppendString(string s)
{
var bytes = Encoding.ASCII.GetBytes(s + ";\0");
stringsBuf.Write(bytes, 0, bytes.Length);
}
AppendString(udtName);
foreach (var m in members) AppendString(m.name);
var strings = stringsBuf.ToArray();
var buf = new byte[headerSize + blocksSize + strings.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), 0);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + (i * memberBlockSize);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arraySize);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].offset);
}
Buffer.BlockCopy(strings, 0, buf, headerSize + blocksSize, strings.Length);
return buf;
}
=> TemplateBufferBuilder.Build(udtName, instanceSize,
[.. members.Select(m => (m.name, m.info, m.arraySize, m.offset))]);
/// <summary>Verifies that a simple UDT with two members decodes correctly.</summary>
[Fact]
@@ -161,8 +131,9 @@ public sealed class CipTemplateObjectDecoderTests
[Fact]
public void Zero_member_count_returns_null()
{
var buf = new byte[12];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), 0);
var buf = new byte[14];
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(6), 8); // instance size
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(10), 0); // member count
CipTemplateObjectDecoder.Decode(buf).ShouldBeNull();
}
@@ -178,19 +149,10 @@ public sealed class CipTemplateObjectDecoderTests
public void Missing_member_name_surfaces_placeholder()
{
// Header says 3 members but strings list has only UDT name + 2 member names.
var memberCount = (ushort)3;
var buf = new byte[12 + 8 * 3];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), 12);
for (var i = 0; i < 3; i++)
{
var o = 12 + i * 8;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), 0xC4);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), (uint)(i * 4));
}
// strings: only UDT + 2 members, missing the third.
var strings = Encoding.ASCII.GetBytes("MyUdt;\0A;\0B;\0");
var combined = buf.Concat(strings).ToArray();
var full = TemplateBufferBuilder.Build("MyUdt", 12,
("A", 0xC4, 0, 0), ("B", 0xC4, 0, 4), ("C", 0xC4, 0, 8));
// Drop the trailing "C\0" so only two member names remain.
var combined = full[..^2];
var shape = CipTemplateObjectDecoder.Decode(combined);
shape.ShouldNotBeNull();
@@ -198,19 +160,20 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members[2].Name.ShouldBe("<member_2>");
}
/// <summary>Verifies that semicolon-terminated string parsing handles various input shapes.</summary>
/// <param name="input">The raw semicolon-terminated string input.</param>
/// <param name="expected">The expected array of parsed strings.</param>
/// <summary>Verifies the template string section splits into the type name + NUL-terminated member names.</summary>
/// <param name="input">The raw string section.</param>
/// <param name="memberCount">Member names to read after the type name.</param>
/// <param name="expected">The expected type name followed by member names.</param>
[Theory]
[InlineData("Foo;\0Bar;\0", new[] { "Foo", "Bar" })]
[InlineData("Foo;Bar;", new[] { "Foo", "Bar" })] // no nulls
[InlineData("Only;\0", new[] { "Only" })]
[InlineData(";\0", new string[] { })] // empty
[InlineData("", new string[] { })]
public void ParseSemicolonTerminatedStrings_handles_shapes(string input, string[] expected)
[InlineData("STRING;nEAIA\0LEN\0DATA\0", 2, new[] { "STRING", "LEN", "DATA" })]
[InlineData("Udt;n\0A\0B\0\0\u0001junk", 2, new[] { "Udt", "A", "B" })] // trailing bytes ignored
[InlineData("Udt\0A\0", 1, new[] { "Udt", "A" })] // no ';' tail
[InlineData("Udt;nEA\0A\0", 3, new[] { "Udt", "A" })] // fewer names than members
[InlineData("", 1, new string[] { })]
public void ParseTemplateStrings_handles_shapes(string input, int memberCount, string[] expected)
{
var bytes = Encoding.ASCII.GetBytes(input);
var result = CipTemplateObjectDecoder.ParseSemicolonTerminatedStrings(bytes);
var result = CipTemplateObjectDecoder.ParseTemplateStrings(bytes, memberCount);
result.ShouldBe(expected);
}
}
@@ -0,0 +1,98 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Decodes <c>@udt/&lt;id&gt;</c> template buffers captured byte for byte off a real controller —
/// a CompactLogix 5069-L320ERS2 rev 34.11, read through libplctag 1.5.2's <c>Tag.GetBuffer()</c>
/// on 2026-10-07 (read-only; one template read per id).
/// </summary>
/// <remarks>
/// <para>
/// Every pre-existing template test built its own input in the layout the decoder assumed
/// (u16 member count first, a 12-byte header, <c>[info][array][offset]</c> blocks, names
/// each ending <c>;\0</c>). A real buffer agrees with none of that, so the decoder returned
/// <c>null</c> for every UDT a controller has and discovered UDTs never fanned out. The
/// same builder-shares-the-parser's-assumption trap as #468.
/// </para>
/// <para>
/// What libplctag actually hands back for <c>@udt/id</c> (cross-checked against its own
/// <c>list_tags_logix</c> example): a 14-byte header — u16 template id, u32 member-definition
/// size in 32-bit words, u32 instance size in bytes, u16 member count, u16 struct handle —
/// then one 8-byte block per member: u16 <b>info</b> (array length, or the bit number of a
/// BOOL), u16 <b>type</b> (bit 15 = struct, bit 13 = array, low 12 bits = CIP type code or
/// the nested template id), u32 byte offset. Then the strings: the type name ending at
/// <c>;</c> (followed by an encoded tail up to a NUL), then each member name NUL-terminated.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class CipTemplateObjectWireCaptureTests
{
/// <summary>The built-in STRING template (id 0x0FCE): LEN DINT + DATA SINT[82], 88 bytes.</summary>
internal const string StringTemplate =
"CE0F10000000580000000200CE0F0000C400000000005200C22004000000" +
"535452494E473B6E45414941004C454E004441544100000001000200";
/// <summary>
/// Rockwell library UDT <c>raC_UDT_LookupMember_STR0082</c> (id 3386): DINT, a nested
/// STRING member (struct type 0x8FCE), SINT, BOOL. Trailing bytes after the last name are
/// part of the capture and must be ignored.
/// </summary>
internal const string LookupMemberTemplate =
"3A0D1F000000600000000400B3810000C400000000000000CE8F040000000000C2005C0000000000C1005C000000" +
"7261435F5544545F4C6F6F6B75704D656D6265725F535452303038323B6E45414141454245430043" +
"6F64650044657363004461746154797065004C6F636B00008F92361DA1314013";
private static AbCipUdtShape DecodeHex(string hex)
{
var shape = CipTemplateObjectDecoder.Decode(Convert.FromHexString(hex));
shape.ShouldNotBeNull();
return shape;
}
/// <summary>The STRING template decodes to its two members with the real array length.</summary>
[Fact]
public void Captured_STRING_template_decodes_LEN_and_DATA()
{
var shape = DecodeHex(StringTemplate);
shape.TypeName.ShouldBe("STRING");
shape.TotalSize.ShouldBe(88);
shape.Members.Select(m => m.Name).ShouldBe(["LEN", "DATA"]);
shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt);
shape.Members[0].Offset.ShouldBe(0);
shape.Members[0].ArrayLength.ShouldBe(1);
shape.Members[1].DataType.ShouldBe(AbCipDataType.SInt);
shape.Members[1].Offset.ShouldBe(4);
shape.Members[1].ArrayLength.ShouldBe(82);
}
/// <summary>A nested struct member carries its template id; trailing bytes are ignored.</summary>
[Fact]
public void Captured_library_UDT_decodes_nested_STRING_member_and_ignores_trailing_bytes()
{
var shape = DecodeHex(LookupMemberTemplate);
shape.TypeName.ShouldBe("raC_UDT_LookupMember_STR0082");
shape.TotalSize.ShouldBe(96);
shape.Members.Select(m => m.Name).ShouldBe(["Code", "Desc", "DataType", "Lock"]);
shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt);
shape.Members[1].DataType.ShouldBe(AbCipDataType.Structure);
shape.Members[1].NestedTemplateId.ShouldBe(0x0FCEu);
shape.Members[1].Offset.ShouldBe(4);
shape.Members[2].DataType.ShouldBe(AbCipDataType.SInt);
shape.Members[2].Offset.ShouldBe(92);
shape.Members[3].DataType.ShouldBe(AbCipDataType.Bool);
shape.Members.ShouldAllBe(m => m.ArrayLength == 1);
}
/// <summary>A shape is recognised as a Logix string family (LEN DINT + DATA SINT[n]).</summary>
[Fact]
public void Captured_STRING_template_is_a_string_family_shape_and_a_plain_UDT_is_not()
{
DecodeHex(StringTemplate).IsStringFamily.ShouldBeTrue();
DecodeHex(LookupMemberTemplate).IsStringFamily.ShouldBeFalse();
}
}
@@ -0,0 +1,45 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Builds an <c>@udt/&lt;id&gt;</c> template buffer in the layout libplctag really returns —
/// the layout pinned by the real-controller captures in <c>CipTemplateObjectWireCaptureTests</c>.
/// Keep it in step with those captures, never with the decoder: a builder that restates the
/// decoder's assumption proves nothing (#468, and the template decoder after it).
/// </summary>
internal static class TemplateBufferBuilder
{
/// <summary>Build a template buffer.</summary>
/// <param name="udtName">UDT type name (written as <c>name;n</c> + an encoded tail, as a controller does).</param>
/// <param name="instanceSize">Bytes per instance.</param>
/// <param name="members">Per member: name, CIP type word (atomic code, or <c>0x8000 | nestedTemplateId</c>),
/// array length (0 = scalar; &gt; 0 sets the array flag and the info word), byte offset.</param>
/// <returns>The buffer.</returns>
public static byte[] Build(
string udtName, uint instanceSize, params (string name, ushort type, ushort arraySize, uint offset)[] members)
{
const int header = 14;
var buf = new MemoryStream();
var head = new byte[header + (8 * members.Length)];
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(0), 0x0123); // template id
BinaryPrimitives.WriteUInt32LittleEndian(head.AsSpan(2), 0); // def size (words)
BinaryPrimitives.WriteUInt32LittleEndian(head.AsSpan(6), instanceSize);
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(10), (ushort)members.Length);
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(12), 0xABCD); // struct handle
for (var i = 0; i < members.Length; i++)
{
var o = header + (i * 8);
var (_, type, arraySize, offset) = members[i];
var typeWord = arraySize > 0 ? (ushort)(type | 0x2000) : type;
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(o), arraySize);
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(o + 2), typeWord);
BinaryPrimitives.WriteUInt32LittleEndian(head.AsSpan(o + 4), offset);
}
buf.Write(head);
buf.Write(Encoding.ASCII.GetBytes(udtName + ";nEAIA\0"));
foreach (var m in members) buf.Write(Encoding.ASCII.GetBytes(m.name + "\0"));
return buf.ToArray();
}
}