diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index 11ef9b26..b11cbd49 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -79,8 +79,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
// accounts for STRING capacity + atomic widths. ElementSize is int? on the wrapper — fall
// back to a static size table when it is null/<=0 (e.g. a zero-length read) so the stride
// is never 0.
- var elementSize = _tag.ElementSize ?? 0;
- var stride = elementSize > 0 ? elementSize : ElementByteSize(type);
+ var stride = ResolveArrayStride(_tag.ElementSize, _tag.GetSize(), count, type);
return type switch
{
@@ -99,6 +98,26 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
};
}
+ ///
+ /// Byte stride between array elements. libplctag's ElementSize wins when populated, but
+ /// after a read it is null (observed live, libplctag.NET 1.5.2) — and the static table
+ /// assumes the built-in 88-byte STRING, which is wrong for a user string type: a STR0016
+ /// element is 20 bytes, so element 1 was read at offset 88 of an 80-byte buffer and the whole
+ /// read failed. When the read buffer divides evenly by the element count, that quotient IS the
+ /// element size, so it is used before the static table.
+ ///
+ /// libplctag's reported element size, if any.
+ /// Bytes in the read buffer.
+ /// Element count requested.
+ /// Element type.
+ /// The stride in bytes.
+ internal static int ResolveArrayStride(int? elementSize, int bufferSize, int count, AbCipDataType type)
+ {
+ if (elementSize is > 0) return elementSize.Value;
+ if (count > 0 && bufferSize > 0 && bufferSize % count == 0) return bufferSize / count;
+ return ElementByteSize(type);
+ }
+
private bool[] BuildBoolArray(int count)
{
var arr = new bool[count];
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayStrideTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayStrideTests.cs
new file mode 100644
index 00000000..86111824
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayStrideTests.cs
@@ -0,0 +1,45 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// Array element stride. Seen live on a production CompactLogix: reading
+/// Program:UN01_Press._ModeNames (STR0016[32], 4 elements requested) returned an
+/// 80-byte buffer with libplctag's ElementSize unset; the driver strode 88 bytes (built-in
+/// STRING) and the read failed ErrorOutOfBounds. The real values were
+/// "undefined" / "Production" / "Maintenance" / "Manual" at a 20-byte stride.
+///
+[Trait("Category", "Unit")]
+public sealed class AbCipArrayStrideTests
+{
+ /// A user string type's stride comes from the buffer, not the built-in STRING size.
+ [Fact]
+ public void User_string_array_strides_by_buffer_size_over_count()
+ {
+ LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 80, count: 4, AbCipDataType.String).ShouldBe(20);
+ }
+
+ /// The built-in STRING still strides 88.
+ [Fact]
+ public void Builtin_STRING_array_strides_88()
+ {
+ LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 3 * 88, count: 3, AbCipDataType.String).ShouldBe(88);
+ }
+
+ /// libplctag's own element size wins when it is populated.
+ [Fact]
+ public void Reported_element_size_wins()
+ {
+ LibplctagTagRuntime.ResolveArrayStride(4, bufferSize: 80, count: 4, AbCipDataType.DInt).ShouldBe(4);
+ }
+
+ /// An empty or uneven buffer falls back to the static per-type size.
+ [Fact]
+ public void Empty_or_uneven_buffer_falls_back_to_the_type_size()
+ {
+ LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 0, count: 4, AbCipDataType.Real).ShouldBe(4);
+ LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 81, count: 4, AbCipDataType.String).ShouldBe(88);
+ }
+}