diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 10b7a813..6be5b374 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -149,14 +149,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// Atomically replaces with applied to its current value.
/// Computes the new health from the current one.
- private void UpdateHealth(Func update)
+ /// The health instance this call installed.
+ private DriverHealth UpdateHealth(Func update)
{
while (true)
{
var current = _health;
var next = update(current);
#pragma warning disable CS0420 // a volatile field passed by ref to Interlocked keeps its full-fence semantics
- if (ReferenceEquals(Interlocked.CompareExchange(ref _health, next, current), current)) return;
+ if (ReferenceEquals(Interlocked.CompareExchange(ref _health, next, current), current)) return next;
#pragma warning restore CS0420
}
}
@@ -1505,6 +1506,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
else if (status == (int)AbStatus.TypeMismatch)
{
// Final review I4: deterministic — keep the handle, leave health, warn once per tag.
+ // Only libplctag reaches this status-word branch, where it is a real controller answer: the native
+ // runtime's pre-send type gate THROWS (caught below, no LastSuccessfulRead advance) and never
+ // returns here with the status merely set (m5).
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
RecordWriteAnswered(now);
WarnTypeMismatchOnce(def.Name,
@@ -1645,23 +1649,35 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// replaced it since) is cleared back to Healthy: the device just accepted a write. A Degraded the read
/// path recorded is left for the read path to clear.
///
- private void RecordWriteAnswered(DateTime now) =>
- UpdateHealth(h => h.State == DriverState.Degraded && h.LastError is { } err
- && ReferenceEquals(err, Volatile.Read(ref _writeFaultError))
- ? new DriverHealth(DriverState.Healthy, now, null)
- : h with { LastSuccessfulRead = now });
+ private void RecordWriteAnswered(DateTime now)
+ {
+ lock (_writeHealthLock)
+ {
+ UpdateHealth(h => h.State == DriverState.Degraded && h.LastError is { } err && ReferenceEquals(err, _writeOwnedError)
+ ? new DriverHealth(DriverState.Healthy, now, null)
+ : h with { LastSuccessfulRead = now });
+ }
+ }
- // The LastError string the most recent write-side comms fault wrote (reference identity marks "write-owned").
- private string? _writeFaultError;
+ // The exact LastError string INSTANCE the most recent write-side comms fault installed (a fresh copy, so reference
+ // identity cannot alias a read-side error). A health still carrying it is write-owned: a refusal-only read batch
+ // keeps it (`with` copies the reference), while any read / poll fault installs a new string and takes ownership.
+ // Set only under _writeHealthLock TOGETHER with the CAS that installs it, so two concurrent failing writes can never
+ // leave the marker naming a different instance than the installed health (#737 m1).
+ private string? _writeOwnedError;
+ private readonly object _writeHealthLock = new();
/// A write-side comms fault: Degraded (never downgrading Faulted), LastSuccessfulRead preserved.
private void RecordWriteCommsFault(string error)
{
- var owned = new string(error.AsSpan()); // a distinct instance, so reference identity cannot alias a read error
- Volatile.Write(ref _writeFaultError, owned);
- UpdateHealth(h => h.State == DriverState.Faulted
- ? h
- : new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, owned));
+ var owned = new string(error.AsSpan());
+ lock (_writeHealthLock)
+ {
+ var installed = UpdateHealth(h => h.State == DriverState.Faulted
+ ? h
+ : new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, owned));
+ if (ReferenceEquals(installed.LastError, owned)) _writeOwnedError = owned;
+ }
}
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs
index eeb1afef..ffa3c0b1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs
@@ -7,5 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// "my own token fired" must not mistake a superseded attempt for one. The superseding operation owns the driver's
/// state; the caller (normally the runtime, which has already abandoned this attempt) can ignore the result.
///
+///
+/// Resilience accounting (#737 m2): not being an OCE, it passes the Initialize pipeline's breaker
+/// ShouldHandle (DriverResiliencePipelineBuilder) and, if its generation still matched, the actor's
+/// RecordFault. That is practically unreachable — DriverInstanceActor runs initialisation single-flight
+/// and drops generation-mismatched results — and a superseded attempt is at worst one counted failure. Recognising
+/// it there is a runtime-side (Lane A) change if it is ever wanted.
+///
/// The detail.
public sealed class AbCipInitializationSupersededException(string message) : InvalidOperationException(message);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs
index 002e8c21..7be295a6 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs
@@ -25,6 +25,10 @@ internal static class LibplctagStatusTranslation
Status.ErrorTooSmall => AbStatus.TooSmall,
// ErrorNotImplemented is the LIBRARY refusing (no I/O); the exception paths raise it as an
// AbCipLocalRefusalException so it never counts as "the device answered" (see FromException).
+ // ASSUMPTION (#737 m3): libplctag reports a failed operation by THROWING, so a NotImplemented reaches the driver
+ // only through FromException. AbStatus has no "local" member (it is Driver.Ab.Transport's, Lane K), so a
+ // NotImplemented observed only as a STATUS WORD (GetStatus after a non-throwing op) would still classify as a
+ // device Unsupported refusal; that combination is not produced by libplctag.NET 1.5.2.
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
index 33ce02d8..a666239c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
@@ -41,15 +41,34 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
private int? _implicitBit;
private byte[]? _path;
- // U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of the DWORD the request
- // addresses (n / 32). Null for every other tag, and cleared (with _path restored to _elementPath) the moment the
- // controller shows its BOOL array is element-addressed (#737 I1: a 0xC1 reply, or a refusal, to the DWORD request).
+ // U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of DWORD n / 32 when the
+ // array is packed. Null for every other tag.
private int? _boolElementBit;
- // The ORIGINAL element-addressed path ("Bits[n]") while a DWORD-addressed _path is still unconfirmed; null once
- // the layout is settled either way.
+ // Both addressings of such a tag: "Bits[n / 32]" (packed: a Logix BOOL array is stored as 0xD3 DWORDs) and the
+ // original "Bits[n]" (per-element: a family that answers 0xC1). _path is whichever _boolLayout selects.
+ private byte[]? _dwordPath;
private byte[]? _elementPath;
+ /// How a BOOL-array element tag's array is laid out, as the controller's REPLY TYPE last showed it.
+ private enum BoolLayout
+ {
+ /// No reply type seen yet (or only refusals / comms faults): request DWORD-addressed, keep asking.
+ Unsettled,
+
+ /// The controller answered 0xD3: address DWORD n / 32, decode bit n % 32.
+ Packed,
+
+ /// The controller answered 0xC1: address element n, decode its byte.
+ PerElement,
+ }
+
+ // #737 fix round 2 (N1): ONLY a reply type settles (or re-settles) the layout — 0xD3 ⇒ Packed, 0xC1 ⇒ PerElement.
+ // A refusal or comms fault says nothing about the layout and leaves it as it was, so a tag deployed before the
+ // online edit that creates its array starts reading correctly once the array exists. A reply whose type disagrees
+ // with the addressing it was asked under is DISCARDED (never decoded) and re-read under the right addressing.
+ private BoolLayout _boolLayout;
+
private ConnectionLease? _lease;
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
private LogixTagClient? _client;
@@ -130,10 +149,15 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
&& TrySplitBoolArrayElement(name, out var dwordName, out var bit))
{
_elementPath = CipPath.EncodeSymbolic(name);
- name = dwordName;
+ _dwordPath = CipPath.EncodeSymbolic(dwordName);
_boolElementBit = bit;
+ _boolLayout = BoolLayout.Unsettled;
+ path = _dwordPath;
+ }
+ else
+ {
+ path = CipPath.EncodeSymbolic(name);
}
- path = CipPath.EncodeSymbolic(name);
}
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
{
@@ -161,32 +185,43 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
}
///
- public async Task ReadAsync(CancellationToken cancellationToken)
+ public Task ReadAsync(CancellationToken cancellationToken) => ReadCoreAsync(cancellationToken, mayRelayout: true);
+
+ private async Task ReadCoreAsync(CancellationToken cancellationToken, bool mayRelayout)
{
var client = Client();
var path = Path();
try
{
var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false);
- if (_elementPath is not null)
+ if (_boolElementBit is not null)
{
- // First answer to the DWORD-addressed BOOL-array request settles the layout: 0xD3 confirms it; anything
- // else means this family addresses BOOL-array ELEMENTS (e.g. a 0xC1 per-element array) — decoding or
- // writing element n / 32 there would hit the wrong element, so go back to "Bits[n]" and read again.
- if (type != LogixTypeCode.BoolArray) { FallBackToElementPath(); await ReadAsync(cancellationToken).ConfigureAwait(false); return; }
- _elementPath = null;
+ BoolLayout? shown = type == LogixTypeCode.BoolArray ? BoolLayout.Packed
+ : type == LogixTypeCode.Bool ? BoolLayout.PerElement
+ : null; // any other type (a mistyped tag): no layout evidence; the type check below reports it
+ if (shown is { } layout)
+ {
+ var askedPacked = ReferenceEquals(path, _dwordPath);
+ SetBoolLayout(layout);
+ if ((layout == BoolLayout.Packed) != askedPacked)
+ {
+ // The reply is for the OTHER addressing (element n / 32 of a per-element array, or DWORD n of a
+ // packed one) — not this element. Discard it and ask again under the layout it just showed.
+ if (mayRelayout)
+ {
+ await ReadCoreAsync(cancellationToken, mayRelayout: false).ConfigureAwait(false);
+ return;
+ }
+ throw new AbTransportException(AbStatus.TypeMismatch,
+ $"'{_p.TagName}': the controller's BOOL-array layout changed between two reads; retrying next read.");
+ }
+ }
}
_data = data;
_typeCode = type;
_structHandle = handle;
_status = IsTypeMismatch(type) ? AbStatus.TypeMismatch : AbStatus.Ok;
}
- catch (AbTransportException ex) when (_elementPath is not null && AbCipRefusals.IsDeviceRefusal(ex))
- {
- // The DWORD index was refused (e.g. out of bounds on an element-addressed array): retry as "Bits[n]".
- FallBackToElementPath();
- await ReadAsync(cancellationToken).ConfigureAwait(false);
- }
catch (AbTransportException ex)
{
// libplctag reports a failed operation by exception AND leaves the status readable.
@@ -195,12 +230,11 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
}
}
- /// Abandons the DWORD addressing of a BOOL-array element: the original "Bits[n]" path, no bit extraction.
- private void FallBackToElementPath()
+ /// Settles a BOOL-array element tag's layout and selects the matching request path.
+ private void SetBoolLayout(BoolLayout layout)
{
- _path = _elementPath;
- _elementPath = null;
- _boolElementBit = null;
+ _boolLayout = layout;
+ _path = layout == BoolLayout.PerElement ? _elementPath : _dwordPath;
}
///
@@ -236,8 +270,9 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
try
{
// An unsettled BOOL-array element layout is settled by a read first, so a write never goes to the
- // DWORD-addressed element of an element-addressed array (#737 I1).
- if (_elementPath is not null)
+ // DWORD-addressed element of an element-addressed array (#737 I1). A packed layout's write is refused below
+ // (0xD3 DWORD — it would clobber 31 neighbours); a per-element one writes "Bits[n]" as a BOOL.
+ if (_boolElementBit is not null && _boolLayout == BoolLayout.Unsettled)
{
await ReadAsync(cancellationToken).ConfigureAwait(false);
path = Path();
@@ -323,9 +358,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
return type switch
{
- AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit)
- : offset == 0 && _boolElementBit is int element && _typeCode == LogixTypeCode.BoolArray ? GetBit(element)
- : Span(offset, 1)[0] != 0,
+ AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : DecodeScalarBool(offset),
AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0],
AbCipDataType.USInt => (int)Span(offset, 1)[0],
AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)),
@@ -471,6 +504,22 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
catch (Exception) { /* ForTest only; Dispose must not throw */ }
}
+ ///
+ /// A scalar BOOL. A packed (0xD3) reply is decoded ONLY as the element's own bit of the DWORD the packed layout
+ /// addressed; any other 0xD3 reply (no element bit, or not settled packed) is refused rather than decoded as
+ /// "byte 0 != 0" — that value would be some other bit, published as Good (#737 N1).
+ ///
+ private bool DecodeScalarBool(int offset)
+ {
+ if (offset == 0 && _typeCode == LogixTypeCode.BoolArray)
+ {
+ if (_boolElementBit is int element && _boolLayout == BoolLayout.Packed) return GetBit(element);
+ throw new AbTransportException(AbStatus.TypeMismatch,
+ $"'{_p.TagName}' is a scalar BOOL but the controller answered a packed BOOL-array DWORD (0xD3) with no element bit to select.");
+ }
+ return Span(offset, 1)[0] != 0;
+ }
+
///
/// Splits a BOOL-array element reference Name[n] (one non-negative subscript, last segment) into the
/// DWORD it lives in (Name[n / 32]) and its bit within that DWORD (n % 32). A Logix BOOL array
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs
index 9a1458c4..db68d1f5 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs
@@ -128,4 +128,48 @@ public sealed class AbCipWriteHealthTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastError.ShouldBeNull();
}
+
+ ///
+ /// m1: many concurrent failing writes leave the write-owned marker naming the installed Degraded, so one good
+ /// write still clears it.
+ ///
+ [Fact]
+ public async Task Concurrent_failing_writes_then_a_good_write_clear_the_write_side_Degraded()
+ {
+ var fail = true;
+ var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
+ for (var round = 0; round < 20; round++)
+ {
+ fail = true;
+ await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
+ drv.WriteAsync([new WriteRequest("D", 1), new WriteRequest("I", 2)], CancellationToken.None))));
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+
+ fail = false;
+ (await drv.WriteAsync([new WriteRequest("D", 3)], CancellationToken.None))[0].StatusCode
+ .ShouldBe(AbCipStatusMapper.Good);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy, $"round {round}");
+ }
+ }
+
+ ///
+ /// M3: a refusal-only read batch in between (no read comms fault happened) keeps the Degraded write-owned, so a
+ /// good write still clears it.
+ ///
+ [Fact]
+ public async Task A_refusal_only_read_between_keeps_the_Degraded_write_owned()
+ {
+ var fail = true;
+ var (drv, _) = await BuildAsync(p => p.TagName == "Broken"
+ ? new FakeAbCipTag(p) { Status = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.NotFound }
+ : fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
+ await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ (await drv.ReadAsync(["Broken"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
+
+ fail = false;
+ await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None);
+
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs
index 1d514c91..028d6e27 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs
@@ -159,6 +159,108 @@ public sealed class NativeAbCipTagRuntimeTests
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "00")); // Bits[37]
}
+ /// A channel whose reply can be switched mid-test (read replies; writes always succeed).
+ private static FakeCipChannel Switchable(Func readReply) => new()
+ {
+ Respond = req => Task.FromResult(Convert.FromHexString(req[0] == 0x4D ? "CD000000" : readReply())),
+ };
+
+ private const string Dword1 = "5204" + "910442697473" + "2801" + "0100" + "00000000"; // Bits[1] (DWORD 37/32)
+ private const string Element37 = "5204" + "910442697473" + "2825" + "0100" + "00000000"; // Bits[37] (element)
+
+ ///
+ /// #737 fix round 2 (N1): a REFUSAL of the DWORD request says nothing about the layout — it must not settle it.
+ /// Tag deployed before the online edit that creates Bits : BOOL[2048]: the init read is refused NotFound;
+ /// once the array exists the controller answers 0xD3, and the read must address DWORD n/32 and decode bit n%32
+ /// (the round-1 code had permanently fallen back to Bits[37] = DWORD 37 and decoded byte 0 as Good).
+ ///
+ /// The refusal reply the DWORD request first gets.
+ [Theory]
+ [InlineData("D2000400")] // NotFound
+ [InlineData("D2000F00")] // NotAllowed
+ [InlineData("D2000A00")] // OutOfBounds
+ public async Task A_refusal_leaves_the_BOOL_layout_unsettled_and_a_later_DWORD_reply_decodes_the_right_bit(string refusal)
+ {
+ var reply = refusal;
+ var ch = Switchable(() => reply);
+ using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
+ await rt.InitializeAsync(CancellationToken.None); // refused: completes with the refusal status
+ rt.GetStatus().ShouldNotBe((int)AbStatus.Ok);
+ ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1)); // never fell back to the element path
+
+ reply = "D2000000D300" + "20000000"; // bit 5 of DWORD 1 = element 37
+ await rt.ReadAsync(CancellationToken.None);
+
+ ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1));
+ rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
+ }
+
+ ///
+ /// N1 positive control: a refusal, then a 0xC1 reply, settles PER-ELEMENT — the n/32 element that came back is
+ /// discarded and Bits[n] is re-read and decoded.
+ ///
+ [Fact]
+ public async Task A_refusal_then_a_per_element_reply_settles_the_element_path()
+ {
+ var reply = "D2000A00";
+ var ch = Switchable(() => reply);
+ using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
+ await rt.InitializeAsync(CancellationToken.None);
+
+ reply = "D2000000C10001";
+ await rt.ReadAsync(CancellationToken.None);
+
+ ch.Sent[^2].ShouldBe(Convert.FromHexString(Dword1)); // asked DWORD-addressed, got a plain BOOL…
+ ch.Sent[^1].ShouldBe(Convert.FromHexString(Element37)); // …so element 37 is re-read and decoded
+ rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
+ }
+
+ ///
+ /// N1: a layout that changes after it was settled (an online edit re-types the array) re-settles from the reply
+ /// type — it never decodes a DWORD reply to the ELEMENT request (DWORD n, byte 0) as the element's value.
+ ///
+ [Fact]
+ public async Task A_settled_per_element_layout_resettles_when_the_reply_becomes_a_DWORD()
+ {
+ var reply = "D2000000C10000";
+ var ch = Switchable(() => reply);
+ using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[36]", AbCipDataType.Bool));
+ await rt.InitializeAsync(CancellationToken.None); // settles per-element
+
+ reply = "D2000000D300" + "01000000"; // byte 0 non-zero, bit 4 (element 36) clear
+ await rt.ReadAsync(CancellationToken.None);
+
+ ch.Sent[^1].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "2801" + "0100" + "00000000"));
+ rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(false); // bit 4 of DWORD 1, not "byte 0 != 0"
+ }
+
+ /// N1 minimum guard: a scalar BOOL with no element bit answered as a packed DWORD is refused, not byte 0.
+ [Fact]
+ public async Task A_scalar_BOOL_answered_as_a_packed_DWORD_without_an_element_bit_is_refused()
+ {
+ using var rt = await ReadAsync(Replying("D300", "01000000"), Params("Flag", AbCipDataType.Bool));
+ (Should.Throw(() => rt.DecodeValue(AbCipDataType.Bool, null))).Status.ShouldBe(AbStatus.TypeMismatch);
+ }
+
+ ///
+ /// m4: a runtime whose init read failed on the wire (layout still unsettled) settles the layout by a read BEFORE
+ /// it writes, so the write goes to the right element of a per-element array.
+ ///
+ [Fact]
+ public async Task A_write_on_an_unsettled_layout_reads_first_and_writes_the_element()
+ {
+ var reply = "D2000100"; // 0x01: not a refusal — init throws, layout unsettled
+ var ch = Switchable(() => reply);
+ using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool));
+ await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None));
+
+ reply = "D2000000C10000";
+ rt.EncodeValue(AbCipDataType.Bool, null, true);
+ await rt.WriteAsync(CancellationToken.None);
+
+ ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "01")); // Bits[37]
+ }
+
/// Positive control: a BOOL answered as a plain BOOL (0xC1) still decodes its byte.
[Fact]
public async Task A_BOOL_element_answered_as_BOOL_decodes_the_byte()