diff --git a/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs b/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs
index d270b27..0028902 100644
--- a/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs
+++ b/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs
@@ -759,6 +759,16 @@ pub fn decode_tokens(
value: NbfxText::DictionaryStatic(id),
});
}
+ // 0x0A — no-prefix (default xmlns) variant of 0x0B. Sets
+ // the default namespace to a dict-resolved string. WCF
+ // emits this on the response side when the default ns is
+ // a well-known string (e.g. urn:invensys.schemas).
+ REC_SHORT_DICT_XMLNS_ATTRIBUTE => {
+ let id = decode_int31(input, &mut cursor)?;
+ tokens.push(NbfxToken::DefaultNamespace {
+ value: NbfxText::DictionaryStatic(id),
+ });
+ }
// Text records — directly produce a Text token, plus an
// implicit EndElement when the `*WithEndElement` variant was
// used (record byte LSB = 1).
diff --git a/rust/crates/mxaccess-asb/src/envelope.rs b/rust/crates/mxaccess-asb/src/envelope.rs
index 531316d..252f3ae 100644
--- a/rust/crates/mxaccess-asb/src/envelope.rs
+++ b/rust/crates/mxaccess-asb/src/envelope.rs
@@ -129,6 +129,45 @@ impl ConnectionValidator {
/// pre-built NBFX token stream for the operation-specific element
/// (``, etc.); operation-encoder helpers
/// (next F25 iteration) produce these.
+/// Serialization shape for the `` SOAP header.
+/// .NET picks one based on the operation's `[XmlSerializerFormat]`
+/// attribute (set on `AuthenticateMe`/`Disconnect`/`KeepAlive` and
+/// nothing else); the Rust port must match.
+///
+/// * `XmlSerializer` — public-property names (`ConnectionId`,
+/// `MessageNumber`, `MessageAuthenticationCode`,
+/// `SignatureInitializationVector`) in the
+/// `http://asb.contracts.data/20111111` namespace (per the
+/// `[XmlType]` on the class).
+/// * `DataContract` — private-field names (`connectionIdField`,
+/// `messageAuthenticationCodeField`, `messageNumberField`,
+/// `signatureInitializationVectorField`) in the
+/// `http://schemas.datacontract.org/2004/07/ArchestrAServices
+/// .ASBContract` namespace (per the `[DataContract]` on the class).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ValidatorWireFormat {
+ XmlSerializer,
+ DataContract,
+}
+
+impl ValidatorWireFormat {
+ /// Pick the wire format based on the SOAP action. Mirrors
+ /// `IAsbDataV2`'s `[XmlSerializerFormat]` attribute distribution
+ /// (`AsbContracts.cs:14-58`): authenticateMeIn, disconnectIn,
+ /// keepAliveIn carry the attribute; everything else uses the
+ /// default DataContract format.
+ pub fn for_action(action: &str) -> Self {
+ if action.ends_with(":authenticateMeIn")
+ || action.ends_with(":disconnectIn")
+ || action.ends_with(":keepAliveIn")
+ {
+ Self::XmlSerializer
+ } else {
+ Self::DataContract
+ }
+ }
+}
+
#[derive(Debug, Clone, PartialEq)]
pub struct SoapEnvelope {
pub action: String,
@@ -211,15 +250,42 @@ pub fn encode_envelope(
// ---- Binary header block ----
//
// Pre-populate the per-session dynamic dictionary with strings the
- // NBFX envelope below references. Each string gets an odd dict id
- // (`1, 3, 5, ...` — even ids are reserved for [MC-NBFS] static).
- // We always include action + to even when `to_uri` is None: in
- // that case we use an empty string for the To slot. WCF's default
- // service dispatcher requires both populated for net.tcp.
+ // NBFX envelope below references. The dynamic dict is *cumulative*
+ // per session — each `intern` returns a 0-based slot whose wire
+ // dict id is `slot * 2 + 1` (odd ids only; even ids are reserved
+ // for `[MC-NBFS]` static).
+ //
+ // The binary message header pre-pops only **new** strings (those
+ // not already in the dict), in order. Strings interned by an
+ // earlier message stay in the dict at their original wire id and
+ // can be referenced by later messages without being re-popped.
+ //
+ // Captured against `MxAsbClient.Probe --via …` through `asb-relay`:
+ // Connect's binary header pre-pops `[action, to]` (ids 1, 3).
+ // AuthenticateMe's binary header only pre-pops the new action
+ // (id 5); the `` element references id 3, still in the dict
+ // from Connect's pre-pop. Earlier `encode_envelope` versions
+ // hardcoded action_dict_id=1 / to_dict_id=3 every message —
+ // wrong for any non-first message in a session, because id 1 /
+ // id 3 then resolved to whatever the *first* message put there.
let to_uri = envelope.to_uri.clone().unwrap_or_default();
- let action_dict_id: u32 = 1;
- let to_dict_id: u32 = 3;
- let header_strings = [envelope.action.as_str(), to_uri.as_str()];
+ let mut header_strings: Vec = Vec::with_capacity(2);
+ let action_dict_id: u32 = match dynamic.position_of(&envelope.action) {
+ Some(slot) => slot * 2 + 1,
+ None => {
+ let slot = dynamic.intern(&envelope.action);
+ header_strings.push(envelope.action.clone());
+ slot * 2 + 1
+ }
+ };
+ let to_dict_id: u32 = match dynamic.position_of(&to_uri) {
+ Some(slot) => slot * 2 + 1,
+ None => {
+ let slot = dynamic.intern(&to_uri);
+ header_strings.push(to_uri.clone());
+ slot * 2 + 1
+ }
+ };
// ---- NBFX envelope tokens ----
let mut tokens = vec![
@@ -257,7 +323,8 @@ pub fn encode_envelope(
// (when present, comes before
// MessageID/ReplyTo per the .NET dump's element order)
if let Some(v) = &envelope.validator {
- encode_validator(&mut tokens, v, dynamic);
+ let fmt = ValidatorWireFormat::for_action(&envelope.action);
+ encode_validator(&mut tokens, v, dynamic, fmt);
}
// {16-byte UUID via UniqueIdText}
@@ -327,7 +394,8 @@ pub fn encode_envelope(
let mut nbfx_bytes = Vec::with_capacity(estimate_envelope_size(envelope));
encode_tokens(&tokens, dynamic, &mut nbfx_bytes)?;
- let header_bytes = encode_binary_header(&header_strings)?;
+ let header_strs: Vec<&str> = header_strings.iter().map(String::as_str).collect();
+ let header_bytes = encode_binary_header(&header_strs)?;
let mut out = Vec::with_capacity(header_bytes.len() + nbfx_bytes.len());
out.extend_from_slice(&header_bytes);
@@ -534,58 +602,118 @@ fn encode_validator(
out: &mut Vec,
v: &ConnectionValidator,
_dynamic: &mut DynamicDictionary,
+ fmt: ValidatorWireFormat,
) {
- //
- // guid
- // (empty when no MAC)
- // n
- //
+ //
+ // ... per-format children ...
//
- //
- // Inner element names are the .NET DataContract member names
- // (private backing fields with `[DataMember(Name = "fooField")]`),
- // NOT public PascalCase property names. Captured via
- // `MxAsbClient.Probe --dump-messages`. The `xmlns:i` declaration
- // is required even though we don't emit any `i:nil` attributes —
- // WCF does, and SMSvcHost / WCF parser consistency expects it.
out.push(NbfxToken::Element {
prefix: Some("h".to_string()),
name: NbfxName::Inline("ConnectionValidator".to_string()),
});
- out.push(NbfxToken::NamespaceDeclaration {
- prefix: "i".to_string(),
- value: NbfxText::DictionaryStatic(440), // ...XMLSchema-instance
- });
- out.push(NbfxToken::NamespaceDeclaration {
- prefix: "h".to_string(),
- value: NbfxText::Chars(asb_ns::HEADERS.to_string()),
- });
- let dc_ns = "http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBContract";
- push_dc_field(
- out,
- "connectionIdField",
- dc_ns,
- &format_uuid(&v.connection_id),
- );
- push_dc_field(out, "messageAuthenticationCodeField", dc_ns, &v.mac_base64);
- push_dc_field(
- out,
- "messageNumberField",
- dc_ns,
- &v.message_number.to_string(),
- );
- push_dc_field(
- out,
- "signatureInitializationVectorField",
- dc_ns,
- &v.iv_base64,
- );
+ match fmt {
+ ValidatorWireFormat::DataContract => {
+ // DataContractSerializer form: private "fooField" names in
+ // the ASBContract namespace, plus an `xmlns:i` declaration
+ // for the `i:nil="true"` attribute that WCF emits on empty
+ // byte[] members. Captured via .NET probe wire dump.
+ out.push(NbfxToken::NamespaceDeclaration {
+ prefix: "i".to_string(),
+ value: NbfxText::DictionaryStatic(440), // ...XMLSchema-instance
+ });
+ out.push(NbfxToken::NamespaceDeclaration {
+ prefix: "h".to_string(),
+ value: NbfxText::Chars(asb_ns::HEADERS.to_string()),
+ });
+ let dc_ns = "http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBContract";
+ push_dc_field(
+ out,
+ "connectionIdField",
+ dc_ns,
+ &format_uuid(&v.connection_id),
+ );
+ push_dc_field(out, "messageAuthenticationCodeField", dc_ns, &v.mac_base64);
+ push_dc_field(
+ out,
+ "messageNumberField",
+ dc_ns,
+ &v.message_number.to_string(),
+ );
+ push_dc_field(
+ out,
+ "signatureInitializationVectorField",
+ dc_ns,
+ &v.iv_base64,
+ );
+ }
+ ValidatorWireFormat::XmlSerializer => {
+ // XmlSerializer form: public property names (PascalCase) in
+ // the data namespace from `[XmlType]` on the class. No
+ // `xmlns:i` declaration here — XmlSerializer doesn't emit
+ // `xsi:nil` for empty byte[] (it uses self-closing
+ // elements instead, but byte[] in the wire format actually
+ // shows a Chars text record holding the base64-encoded
+ // empty content per the .NET probe capture; see
+ // push_xml_byte_array_field). Captured via
+ // `MxAsbClient.Probe --via …` for AuthenticateMe.
+ out.push(NbfxToken::NamespaceDeclaration {
+ prefix: "h".to_string(),
+ value: NbfxText::Chars(asb_ns::HEADERS.to_string()),
+ });
+ let data_ns = "http://asb.contracts.data/20111111";
+ push_xml_text_field(out, "ConnectionId", data_ns, &format_uuid(&v.connection_id));
+ push_xml_text_field(
+ out,
+ "MessageNumber",
+ data_ns,
+ &v.message_number.to_string(),
+ );
+ push_xml_byte_array_field(out, "MessageAuthenticationCode", data_ns, &v.mac_base64);
+ push_xml_byte_array_field(
+ out,
+ "SignatureInitializationVector",
+ data_ns,
+ &v.iv_base64,
+ );
+ }
+ }
out.push(NbfxToken::EndElement); //
}
+/// Emit `<{name} xmlns="{ns}">{value}{name}>` for an XmlSerializer-
+/// formatted text field. No xmlns:i (no nil semantics).
+fn push_xml_text_field(out: &mut Vec, name: &str, ns: &str, value: &str) {
+ out.push(NbfxToken::Element {
+ prefix: None,
+ name: NbfxName::Inline(name.to_string()),
+ });
+ out.push(NbfxToken::DefaultNamespace {
+ value: NbfxText::Chars(ns.to_string()),
+ });
+ out.push(NbfxToken::Text(NbfxText::Chars(value.to_string())));
+ out.push(NbfxToken::EndElement);
+}
+
+/// Emit a `byte[]` field in XmlSerializer format. Empty value →
+/// `<{name} xmlns="{ns}" />` (self-closing, NO xsi:nil — that's the
+/// DataContract behaviour). Non-empty → `<{name} xmlns="{ns}">b64…>`.
+fn push_xml_byte_array_field(out: &mut Vec, name: &str, ns: &str, b64: &str) {
+ out.push(NbfxToken::Element {
+ prefix: None,
+ name: NbfxName::Inline(name.to_string()),
+ });
+ out.push(NbfxToken::DefaultNamespace {
+ value: NbfxText::Chars(ns.to_string()),
+ });
+ if !b64.is_empty() {
+ out.push(NbfxToken::Text(NbfxText::Chars(b64.to_string())));
+ }
+ out.push(NbfxToken::EndElement);
+}
+
/// Emit a `<{name} xmlns="{dc_ns}">{value}{name}>` element. When
/// `value` is empty we emit just `<{name} xmlns="{dc_ns}"/>` to match
/// .NET's WCF DataContractSerializer output for null/empty byte arrays.
diff --git a/src/MxAsbClient.Probe/Program.cs b/src/MxAsbClient.Probe/Program.cs
index 2b89abd..f3b1632 100644
--- a/src/MxAsbClient.Probe/Program.cs
+++ b/src/MxAsbClient.Probe/Program.cs
@@ -3,6 +3,11 @@ using System.Globalization;
string endpoint = GetArg(args, "--endpoint")
?? "net.tcp://desktop-6jl3kko/ASBService/Default_ZB_MxDataProvider/IDataV2";
+// `--via` overrides the TCP destination without changing the ``
+// SOAP header (so the registered service URL still matches inside
+// SMSvcHost). Use to route the probe through `asb-relay` for wire
+// byte capture, e.g. `--via net.tcp://127.0.0.1:8088/...`.
+string? clientVia = GetArg(args, "--via");
string[] tags = GetArgs(args, "--tag");
if (tags.Length == 0)
{
@@ -300,7 +305,14 @@ if (probeConnectFailure)
{
try
{
- using MxAsbDataClient connectFailureClient = MxAsbDataClient.Connect(endpoint, solution, Console.WriteLine, dumpMessages);
+ using MxAsbDataClient connectFailureClient = MxAsbDataClient.Connect(new AsbConnectionOptions
+{
+ Endpoint = endpoint,
+ SolutionName = solution,
+ Trace = Console.WriteLine,
+ DumpMessages = dumpMessages,
+ Via = clientVia,
+});
Console.WriteLine("connect_failure_observed=False");
}
catch (Exception ex)
@@ -322,7 +334,14 @@ if (compatibilitySubscribe)
return;
}
-using MxAsbDataClient client = MxAsbDataClient.Connect(endpoint, solution, Console.WriteLine, dumpMessages);
+using MxAsbDataClient client = MxAsbDataClient.Connect(new AsbConnectionOptions
+{
+ Endpoint = endpoint,
+ SolutionName = solution,
+ Trace = Console.WriteLine,
+ DumpMessages = dumpMessages,
+ Via = clientVia,
+});
int publishedEventCount = 0;
client.PublishedValueReceived += (_, value) =>
{
diff --git a/src/MxAsbClient/AsbConnectionOptions.cs b/src/MxAsbClient/AsbConnectionOptions.cs
index 06f00fe..420bbf2 100644
--- a/src/MxAsbClient/AsbConnectionOptions.cs
+++ b/src/MxAsbClient/AsbConnectionOptions.cs
@@ -10,6 +10,16 @@ public sealed record AsbConnectionOptions
public bool DumpMessages { get; init; }
+ ///
+ /// Optional `ClientVia` URL — if set, WCF will TCP-connect to this
+ /// URL but address messages with the `Endpoint` URL (so the
+ /// `<a:To>` SOAP header still matches the registered service).
+ /// Used to route the .NET probe through the `asb-relay` middleman
+ /// without triggering an `AddressFilterMismatch` fault. Mirrors
+ /// `System.ServiceModel.Description.ClientViaBehavior`.
+ ///
+ public string? Via { get; init; }
+
public void Validate()
{
if (string.IsNullOrWhiteSpace(Endpoint))
diff --git a/src/MxAsbClient/MxAsbDataClient.cs b/src/MxAsbClient/MxAsbDataClient.cs
index d116f43..a6b3f74 100644
--- a/src/MxAsbClient/MxAsbDataClient.cs
+++ b/src/MxAsbClient/MxAsbDataClient.cs
@@ -68,7 +68,19 @@ public sealed class MxAsbDataClient : IDisposable
AsbSystemAuthenticator authenticator = new(passphrase, cryptoParameters, trace);
trace?.Invoke("asb.stage=authenticator-ready");
NetTcpBinding binding = CreateBinding();
- ChannelFactory factory = new(binding, new EndpointAddress(endpoint));
+ // Optional `ClientVia`: when set, TCP-connect to that URL but
+ // keep the `` header pointing at the registered Endpoint.
+ // Used to route through `asb-relay` for byte-level capture
+ // without triggering an `AddressFilterMismatch` fault. CoreWCF /
+ // .NET 10 dropped `ClientViaBehavior`; the equivalent is to
+ // pass the Via URL through to `CreateChannel(addr, viaUri)`.
+ EndpointAddress endpointAddress = new(endpoint);
+ ChannelFactory factory = new(binding, endpointAddress);
+ Uri? viaUri = string.IsNullOrWhiteSpace(options.Via) ? null : new Uri(options.Via);
+ if (viaUri is not null)
+ {
+ trace?.Invoke($"asb.client_via={viaUri}");
+ }
AsbDataCustomSerializer.Trace = dumpMessages ? trace : null;
int replacedSerializers = AsbCustomSerializerContractBehavior.ReplaceSerializer(factory.Endpoint.Contract);
trace?.Invoke($"asb.serializer.behaviors-replaced={replacedSerializers}");
@@ -83,7 +95,9 @@ public sealed class MxAsbDataClient : IDisposable
{
trace?.Invoke("asb.stage=open-factory");
factory.Open();
- channel = factory.CreateChannel();
+ channel = viaUri is not null
+ ? factory.CreateChannel(endpointAddress, viaUri)
+ : factory.CreateChannel();
trace?.Invoke("asb.stage=open-channel");
clientChannel = (IClientChannel)channel;