Files
mxaccess/design/10-raw-layer.md
Joseph Doherty fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
Initial project state: .NET reference, design, Rust port (M0+M1), evidence
Layout:
- src/                    .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
                          MxAsbClient, probes, tests, harnesses. Executable spec.
- design/                 Architectural plan for the Rust port (M0–M6), error
                          model, protocol invariants, risks (R1–R16), adversarial
                          review log (review.md).
- rust/                   Rust workspace. M0 skeleton + M1 codec parity.
                          mxaccess-codec: 215 unit tests + 2 cross-implementation
                          parity tests (byte-identical against .NET reference).
                          Other crates are M0 stubs awaiting M2+.
- captures/               Frida + netsh + pcap evidence per CLAUDE.md
                          ("captures are evidence, not throwaway logs").
- analysis/               Decompiled C# (frida/proxy/decompiled-*),
                          Ghidra exports for native DLLs (`exports/` only —
                          working state at `projects/` and AVEVA's input
                          binaries at `input/` are gitignored).
- docs/                   Reverse-engineering reference docs.
- tools/                  Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
                          Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/      Rust CI: fmt + build + test + clippy on Windows.
- LICENSE                 MIT (Joseph Doherty, 2026).

Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly

Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 06:21:00 -04:00

27 KiB
Raw Permalink Blame History

Raw layer

The raw layer is the byte-accurate Rust reimplementation of MXAccess. It lives in three sub-layers:

mxaccess-codec      pure encoding/decoding, no I/O
   |
   v
mxaccess-rpc        DCE/RPC + NTLMv2 + OBJREF + OXID resolution
mxaccess-callback   INmxSvcCallback RPC server (callback exporter)
   |
   v
mxaccess-nmx        INmxService2 client + Galaxy SQL resolver
mxaccess-asb        IASBIDataV2 client (alternate data plane)

Codec is pure and runtime-agnostic. Transport crates use Tokio for I/O. Neither layer exposes Tokio in the public types except through async fn signatures.

mxaccess-codec

Pure protocol codec. No I/O. Compiles on every Rust target including non-Windows. Allocations only where the protocol mandates variable-length fields (string values, array payloads, registration bodies).

MxReferenceHandle (20 bytes)

Source: src/MxNativeCodec/MxReferenceHandle.cs:5120.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MxReferenceHandle {
    pub galaxy_id: u8,
    // Byte 1 is reserved (always 0). Not exposed publicly.
    pub platform_id: u16,
    pub engine_id: u16,
    pub object_id: u16,
    object_signature: u16,    // private — CRC-16/IBM of lowercase UTF-16LE object tag
    pub primitive_id: i16,
    pub attribute_id: i16,
    pub property_id: i16,
    attribute_signature: u16, // private — CRC-16/IBM of lowercase UTF-16LE attribute name
    pub attribute_index: i16, // -1 array, 0 scalar
}

impl MxReferenceHandle {
    pub const ENCODED_LEN: usize = 20;

    /// The only constructor that derives signatures from names. Recomputes both
    /// `object_signature` and `attribute_signature` so they cannot desync from
    /// the names that produced them.
    pub fn from_names(
        galaxy_id: u8,
        platform_id: u16,
        engine_id: u16,
        object_id: u16,
        object_tag_name: &str,
        primitive_id: i16,
        attribute_id: i16,
        property_id: i16,
        attribute_name: &str,
        is_array: bool,
    ) -> Self;

    /// Parse from a captured 20-byte handle. Signatures come straight from the
    /// wire; the caller is asserting the bytes are authoritative.
    pub fn parse(bytes: &[u8; Self::ENCODED_LEN]) -> Self;
    pub fn encode(self, dst: &mut [u8; Self::ENCODED_LEN]);

    /// Read-only accessors — there is intentionally no `set_*_signature`.
    pub fn object_signature(self) -> u16 { self.object_signature }
    pub fn attribute_signature(self) -> u16 { self.attribute_signature }

    /// Returns a new handle with `attribute_name`'s signature recomputed,
    /// preserving every other field. Use this instead of mutating in place.
    pub fn with_attribute_name(self, attribute_name: &str) -> Self;
    pub fn with_object_tag_name(self, object_tag_name: &str) -> Self;

    pub fn compute_name_signature(name: &str) -> u16;
}

object_signature and attribute_signature are derived values and are the only fields not exposed pub. There is no setter that takes a raw u16 signature without a corresponding name — the only way to update a signature is to hand a name in, which forces a recomputation. This keeps (object_tag_name, object_signature) and (attribute_name, attribute_signature) consistent by construction. (The .NET reference is more permissive — MxReferenceHandle is a record with public init-only signature fields — but mirroring that in Rust would invite caller bugs that the wire format silently rejects with 0x80070057.)

compute_name_signature mirrors the .NET ComputeNameSignature exactly. For each char in name.to_lowercase(), run the byte sequence in UTF-16LE order through update_crc16_ibm (poly 0xa001, initial 0, low byte first then high byte).

Unicode lowercasing must match String.ToLowerInvariant() semantics, NOT str::to_lowercase(). The .NET ToLowerInvariant() uses the culture-invariant Unicode case map (CultureInfo.InvariantCulture.TextInfo.ToLower, ICU-derived). Rust str::to_lowercase() is locale-dependent in spirit (it follows Unicode's special-casing rules, e.g. Turkish dotless-i is mapped per the Default Casing algorithm with no tailoring, which is close to but not identical to Invariant). Worse, unicase::Ascii::to_lowercase is ASCII-only and silently passes non-ASCII characters through unchanged — for any tag containing a non-ASCII character it will produce a CRC that disagrees with the .NET reference. The Rust port should:

  1. Use icu_casemap::CaseMapper::lowercase_to_string (ICU4X) configured for Locale::UND / Root locale to match Invariant, or hand-implement Unicode Default_Lowercase via the UCD SpecialCasing.txt and UnicodeData.txt Lowercase_Mapping field with no language tailoring.
  2. Not use str::to_lowercase() (locale-flavored) and not use unicase::Ascii::to_lowercase (ASCII-only — destroys non-ASCII parity).
  3. Treat this as a divergence-test requirement: mxaccess-codec/tests/ must include fixture tag names containing characters whose ASCII-vs-invariant mapping differs (Turkish dotted-İ → , German ß → ss is not applied by ToLowerInvariant so ß → ß, Greek Σ → σ — confirm against MxReferenceHandle.cs:4759 outputs from the .NET reference and assert byte-equal CRC).

NmxTransferEnvelope (46 bytes)

Source: src/MxNativeCodec/NmxTransferEnvelope.cs:5104.

#[derive(Debug, Clone, Copy)]
pub struct NmxTransferEnvelope {
    pub version: u16,                          // 1
    pub inner_length: i32,                     // body.len() - 46
    pub reserved6_10: [u8; 4],                 // bytes 6..10; preserved verbatim by Rust port — see note below
    pub message_kind: NmxTransferMessageKind,  // 1=Metadata, 2=ItemControl, 3=Write
    pub source_galaxy_id: i32,
    pub source_platform_id: i32,
    pub local_engine_id: i32,
    pub target_galaxy_id: i32,
    pub target_platform_id: i32,
    pub target_engine_id: i32,
    pub protocol_marker: i32,                  // 0x0201
    pub timeout_ms: i32,                       // default 30000
}

NmxTransferEnvelopeTemplate is the round-trip preserver: takes a captured 46-byte buffer, exposes setters that patch only inner_length, leaves every other byte untouched. Used for ObservedWriteBodyTemplate. This is non-optional — the protocol has bytes whose semantics are not yet decoded; the .NET reference passes them through.

Reserved bytes 6..10 are NOT preserved by the .NET reference parser. NmxTransferEnvelope.Parse reads only Version, InnerLength, ProtocolMarker, MessageKind, and the engine ids; the four bytes at offset 6 are discarded (src/MxNativeCodec/NmxTransferEnvelope.cs:3975), and Encode always writes 0 there (src/MxNativeCodec/NmxTransferEnvelope.cs:91). The Rust port intentionally fixes this gap by carrying reserved6_10: [u8; 4] through parse/encode, defaulting to [0; 4] for newly-constructed envelopes. This honours CLAUDE.md's preserve-unknown-bytes rule on a layer the .NET reference does not — the Rust codec is closer to true byte-parity than the .NET parser when round-tripping captured envelopes that have non-zero bytes at offset 6.

⚠ The native adapter logs NMX Header ... buffer size pktHeader.dwDataSize N doesn't match received message size of 46 (work_remain.md:7485) when inner_length does not match the actual body size. The encoder validates inner_length == body.len() - 46 before transmitting.

Item-control bodies (advise / supervisory / unadvise)

Command Opcode Length
AdviseSupervisory 0x1f 39 bytes (HeaderLength 3 + GUID 16 + AdviseExtra 2 + Payload 18)
UnAdvise 0x21 37 bytes (HeaderLength 3 + GUID 16 + Payload 18)

The Advise enum value shares opcode 0x1f with AdviseSupervisory (src/MxNativeCodec/NmxItemControlMessage.cs:78), but NmxItemControlMessage.Parse only accepts AdviseSupervisory or UnAdvise (src/MxNativeCodec/NmxItemControlMessage.cs:4649). There is no separate 37-byte plain-Advise wire shape — the higher-level MxNativeCompatibilityServer.AdviseSupervisory simply forwards to Advise, both of which encode an AdviseSupervisory 39-byte body (src/MxNativeClient/MxNativeCompatibilityServer.cs:256258).

Layout: command(1) + version(2) + correlation_id(GUID 16) + [extra(2) when AdviseSupervisory] + handle_projection(14) + tail(4) (src/MxNativeCodec/NmxItemControlMessage.cs:2535,121142).

Source: src/MxNativeCodec/NmxItemControlMessage.cs:5154.

Write bodies (0x37 / 0x38)

Common prefix (18 bytes) ends in a wire-kind byte at offset 17. Layout of the prefix is cmd(1) + version u16(2) + handle_projection(14, bytes 6..19 of MxReferenceHandle) + wireKind(1) (src/MxNativeCodec/NmxWriteMessage.cs:1113,207213). There is no padding between version and the handle projection.

WireKind Type Total scalar size Notes
0x01 Boolean 37 4-byte value [0xff,0xff,0xff,0x00] (true) or [0x00,0xff,0xff,0x00] (false) — bytes 1 and 2 are literal 0xFF filler, NOT reserved zeros (src/MxNativeCodec/NmxWriteMessage.cs:257); 11-byte boolean suffix (7 zero bytes + 4-byte clientToken, NmxWriteMessage.cs:228238) + 4-byte writeIndex
0x02 Int32 40 4 LE + 14-byte suffix + 4 writeIndex
0x03 Float32 40 4 IEEE + 14 + 4
0x04 Float64 44 8 IEEE + 14 + 4
0x05 String 44 + N recordLength i32(4) + valueByteLength i32(4) + UTF-16LE bytes(N) + null(2) + 14-byte suffix + 4-byte writeIndex; total = KindOffset(17) + 1 + 4 + 4 + N + 14 + 4 (src/MxNativeCodec/NmxWriteMessage.cs:148157)
0x05 DateTime 44 + N Same shape as String. Value is UTF-16LE of DateTime.ToString("M/d/yyyy h:mm:ss tt", InvariantCulture) + null (src/MxNativeCodec/NmxWriteMessage.cs:262,390393)
0x410x45 Arrays 18 + 10 + N + 18 prefix(18) + 4 unused bytes + count u16 at body[22] + elementWidth u16 at body[24] + elements(N) + 14-byte suffix + 4-byte writeIndex (src/MxNativeCodec/NmxWriteMessage.cs:179186)

Write2 (timestamped) replaces the -1 i16 flag with 0 i16 and inserts an 8-byte FILETIME (DateTime.ToFileTime()) between offsets 12 and 19 of the suffix.

WriteSecured2 (0x38) appends currentUserToken(16) + clientNameLen(i32) + clientNameBytes(UTF-16LE+null) + verifierUserToken(16) before the trailing index slot.

Sources: src/MxNativeCodec/NmxWriteMessage.cs:7394, NmxSecuredWrite2Message.cs:6105. Per-type byte matrices in analysis/frida/write-body-matrix.tsv, write-array-body-matrix.tsv, write-mode-matrix.tsv.

ObservedWriteBodyTemplate mirrors the .NET helper: take a captured write body, replace only the value slot, preserve every other byte (suffix, tokens, padding).

Subscription Status (0x32) and DataUpdate (0x33)

Source: src/MxNativeCodec/NmxSubscriptionMessage.cs:5428.

The two frames share a 23-byte common header (cmd + version + recordCount + operationId) but diverge immediately after. The parser must dispatch on cmd before reading any further bytes; do not unify the two paths.

SubscriptionStatus (cmd 0x32) — header → per-message correlationId → records (src/MxNativeCodec/NmxSubscriptionMessage.cs:87115):

cmd(1=0x32) + version(2=1) + recordCount(i32) + operationId(GUID 16)   [bytes 0..23]
correlationId(GUID 16)                                                  [bytes 23..39]
records[recordCount]                                                    [from byte 39]
record: status(i32) + detailStatus(i32) + quality(u16)
      + timestamp_filetime(i64) + wireKind(u8) + value(N)

DataUpdate (cmd 0x33) — header → records, no correlationId (src/MxNativeCodec/NmxSubscriptionMessage.cs:5455, 6585):

cmd(1=0x33) + version(2=1) + recordCount(i32) + operationId(GUID 16)   [bytes 0..23]
records[recordCount]                                                    [from byte 23]
record: status(i32) + quality(u16) + timestamp_filetime(i64)
      + wireKind(u8) + value(N)

recordCount != 1 is a hard error on 0x33 DataUpdate. The .NET parser throws ArgumentException (src/MxNativeCodec/NmxSubscriptionMessage.cs:7174). The Rust port replicates this as a typed error (do not silently accept multi-record DataUpdate frames); buffered batches are listed in 70-risks-and-open-questions.md (R2/R13) as not yet wire-proven.

Wire kinds are 0x01..0x07 (scalars) and 0x41..0x46 (arrays).

Known gap — wire kind 0x47 (ElapsedTimeArray) is not enumerated by either the .NET reference or this design. The scalar ElapsedTime (0x07, src/MxNativeCodec/MxValueKind.cs) has no array counterpart in either MxValueKind or MxValue, and neither NmxWriteMessage.cs (encoder) nor NmxSubscriptionMessage.cs:270276 (decoder) has a branch for 0x47. If a future Frida capture exposes such a frame, both sides need an additive enum variant (ElapsedTimeArray0x47) plus a value carrier; the current parser will fall through to the default (wireKind, null, 0) opaque arm and the encoder will simply have no way to emit it. Document as a known gap rather than silently extending the enum without evidence.

Encoder/decoder asymmetry on the array kind byte (preserve verbatim): the write encoder collapses both StringArray and DateTimeArray to 0x45 and never emits 0x46 (src/MxNativeCodec/NmxWriteMessage.cs:107). The subscription/callback decoder treats 0x46 as DateTimeArray (src/MxNativeCodec/NmxSubscriptionMessage.cs:173,275). Therefore: writes use 0x41..0x45 only; reads accept 0x41..0x46. The Rust port's encoder must match (only emit up to 0x45); the decoder must accept the full 0x41..0x46 range and demux StringArray vs DateTimeArray from wireKind, not from any encoder-side metadata.

Reference registration (0x10 / 0x11)

Source: src/MxNativeCodec/NmxReferenceRegistrationMessage.cs:6142, NmxReferenceRegistrationResultMessage.cs:6120.

Tagged-string encoding: 4-byte length prefix where tagged ? (byteLength | 0x81000000) : byteLength, followed by UTF-16LE bytes plus a null terminator. Codec preserves the 8 zero bytes of ItemStringReservedLength (lines 4245) and the 0x81000000 marker on tagged strings.

Type model

Compatible with the .NET enums in src/MxNativeCodec/MxStatus.cs, MxValueKind.cs, MxDataType.cs.

#[repr(i16)]
pub enum MxStatusCategory {
    Unknown = -1, Ok = 0, Pending = 1, Warning = 2, CommunicationError = 3,
    ConfigurationError = 4, OperationalError = 5, SecurityError = 6,
    SoftwareError = 7, OtherError = 8,
}

#[repr(i16)]
pub enum MxStatusSource {
    Unknown = -1, RequestingLmx = 0, RespondingLmx = 1, RequestingNmx = 2,
    RespondingNmx = 3, RequestingAutomationObject = 4, RespondingAutomationObject = 5,
}

pub struct MxStatus {
    pub success: i16,
    pub category: MxStatusCategory,
    pub detected_by: MxStatusSource, // .NET field name is `DetectedBy` (`src/MxNativeCodec/MxStatus.cs:31`); not `Source`
    pub detail: i16, // i16, signed; matches .NET `short Detail` (`src/MxNativeCodec/MxStatus.cs:32`)
}

pub enum MxValueKind {
    Boolean, Int32, Float32, Float64, String, DateTime, ElapsedTime,
    BooleanArray, Int32Array, Float32Array, Float64Array,
    StringArray, DateTimeArray,
    // No ElapsedTimeArray variant — see footnote.
}

#[repr(i16)]
pub enum MxDataType {
    Unknown = -1, NoData = 0, Boolean = 1, Integer = 2, Float = 3, Double = 4,
    String = 5, Time = 6, ElapsedTime = 7, ReferenceType = 8, StatusType = 9,
    Enum = 10, SecurityClassificationEnum = 11, DataQualityType = 12,
    QualifiedEnum = 13, QualifiedStruct = 14, InternationalizedString = 15,
    BigString = 16, End = 17,
}

MxValue — typed value carrier

pub enum MxValue {
    Boolean(bool),
    Int32(i32),
    Float32(f32),
    Float64(f64),
    String(String),
    DateTime(SystemTime),     // FILETIME at codec boundary
    ElapsedTime(MxElapsedTime), // signed wire — see note below
    BooleanArray(Vec<bool>),
    Int32Array(Vec<i32>),
    Float32Array(Vec<f32>),
    Float64Array(Vec<f64>),
    StringArray(Vec<String>),
    DateTimeArray(Vec<SystemTime>),
}

Conversions:

  • SystemTime ↔ FILETIME: 100-ns intervals since 1601-01-01T00:00:00Z. time::OffsetDateTime is also acceptable; pick one and stay consistent. The codec layer accepts both via traits.
  • ElapsedTime: 4-byte signed i32 milliseconds on the wire. The .NET decoder reads BinaryPrimitives.ReadInt32LittleEndian and produces TimeSpan.FromMilliseconds(milliseconds) (src/MxNativeCodec/NmxSubscriptionMessage.cs:252), which accepts negative values. std::time::Duration is unsigned and must not be used here — it cannot represent a negative ms value and panics on conversion. The Rust port exposes a newtype pub struct MxElapsedTime(pub i64); (milliseconds, signed) — i64 rather than i32 so it can also carry the wider time::Duration/.NET TimeSpan range without precision loss when promoted at the async layer.

ASB variant lives in a parallel mxaccess_codec::asb::AsbVariant because it is wire-incompatible (different type-id space and binary layout).

Preservation rules

Every codec type that decodes a message keeps a private original: Bytes field. re_encode() returns the original bytes when no fields were mutated. This guarantees byte parity on captured fixtures even when fields' meanings are not fully understood.

For mutable round-trips (e.g. re-encoding a captured Write2 with a new value), only mutated fields are re-emitted; the rest of the buffer is patched in place. This matches ObservedWriteBodyTemplate in the .NET reference and is essential for the parity test strategy described in 60-roadmap.md.

Test strategy (codec)

  • Round-trip fixtures: every captured write/advise/subscribe body in captures/0NN-frida-* and every row in analysis/frida/*-matrix.tsv is loaded, decoded, re-encoded, and asserted byte-equal.
  • Property tests: proptest generators for each primitive (MxReferenceHandle, envelope, write body) — encode then decode, assert structural equality.
  • CRC vectors: hardcoded vectors from .NET unit tests are mirrored as Rust constants and asserted.
  • Cross-implementation: a small fixture runner shells out to dotnet run --project src\MxNativeCodec.Tests and asserts the same bytes are produced.

mxaccess-rpc

DCE/RPC over TCP, NTLMv2 packet integrity, OXID resolution, OBJREF parsing, IRemUnknown::RemQueryInterface. The minimum subset of [MS-RPCE], [MS-DCOM], and [MS-NLMP] required to drive INmxService2.

NTLM

Source: src/MxNativeClient/ManagedNtlmClientContext.cs:1389. Implements:

  • Type1 (Negotiate) — emit. Negotiate flags as set by CreateType1 (src/MxNativeClient/ManagedNtlmClientContext.cs:5363): KeyExchange (0x40000000) | Sign (0x00000010) | AlwaysSign (0x00008000) | Seal (0x00000020) | TargetInfo (0x00800000) | Ntlm (0x00000200) | ExtendedSessionSecurity (0x00080000) | Unicode (0x00000001) | RequestTarget (0x00000004) | Negotiate128 (0x20000000) | Negotiate56 (0x80000000). Bit constants per ManagedNtlmClientContext.cs:1021.
  • Type2 (Challenge) — parse. Extract server challenge, AV pairs from TargetInfo (timestamp, channel binding optional).
  • Type3 (Authenticate) — emit. NTLMv2 NT-OWF = HMAC-MD5(MD4(unicode(password)), unicode(uppercase(user) + domain)). Client challenge with AV pairs replayed from the Type2.
  • Sign / Verify — packet-integrity signature: HMAC-MD5(SignKey, sequence || plaintext) → first 8 bytes XOR with RC4 keystream.
  • Seal / Unseal — RC4 stream cipher with derived seal key.
  • Sign-key / seal-key — derived via MD5 on a magic-constant string.

Rust crates: hmac, md-5, rc4 (or hand-rolled), rand_core. Do not pull ring — it does not implement MD4. Hand-roll MD4 (~30 lines, mirroring the .NET reference).

DCE/RPC PDU

Source: src/MxNativeClient/DceRpcPdu.cs:1380, DceRpcTcpClient.cs:1420.

PDU types implemented: Bind (11), BindAck (12), Request (0), Response (2), Fault (3), AlterContext (14), AlterContextResponse (15), Auth3 (16). Authentication trailer: type=NTLMSSP (10), level=PKT_INTEGRITY (5).

Fragmentation: max transmit/receive 4280 bytes. Multi-fragment Request/Response bodies concatenate in order using the FIRST (0x01) and LAST (0x02) fragment flag bits.

OXID resolution

IObjectExporter::ResolveOxid over port 135. Returns dual-string bindings; we filter for tower id 0x0007 (ncacn_ip_tcp) and parse host[port].

Source: src/MxNativeClient/ObjectExporterClient.cs:182.

OBJREF / IRemUnknown

OBJREF is a 68-byte STDOBJREF header + dual-string array. Signature MEOW = 0x574F454D.

IRemUnknown::RemQueryInterface (opnum 3) yields a new IPID for a different IID on the same OXID. Used to obtain INmxService2 from the activated IUnknown.

Source: src/MxNativeClient/ComObjRef.cs:1145, RemUnknownMessages.cs:179.

Public surface (sketch)

pub struct DceRpcClient { /* tokio::net::TcpStream + auth + frag state */ }

impl DceRpcClient {
    pub async fn connect(addr: SocketAddr) -> Result<Self, RpcError>;
    pub async fn bind(&mut self, iid: Uuid, ntlm: NtlmContext) -> Result<(), RpcError>;
    pub async fn alter_context(&mut self, iid: Uuid) -> Result<(), RpcError>;
    pub async fn call(&mut self, opnum: u16, stub: &[u8]) -> Result<Bytes, RpcError>;
}

tokio::net::TcpStream underneath. The PDU codec is a pure tokio_util::codec::Decoder so the same logic could in principle drive a different runtime if the I/O is provided.

mxaccess-callback

Server-side. Listens on an ephemeral TCP port; serves Bind / Request PDUs for two interfaces:

  • IRemUnknown (RemQueryInterface, RemAddRef, RemRelease)
  • INmxSvcCallback (DataReceived opnum 3, StatusReceived opnum 4) — names match the .NET reference exactly: src/MxNativeClient/NmxSvcCallbackMessages.cs:1112 (DataReceivedOpnum/StatusReceivedOpnum) and src/MxNativeClient/NmxProcedureMetadata.cs:89101 (NdrProcedureDescriptor DataReceived/StatusReceived). The doc previously used a Raw suffix that does not appear in the source.

Source: src/MxNativeClient/ManagedCallbackExporter.cs:1335.

pub struct CallbackExporter { /* TcpListener + dispatcher + frame channel */ }

impl CallbackExporter {
    pub async fn bind(addr: SocketAddr) -> Result<Self, CallbackError>;
    pub fn obj_ref(&self) -> ObjRef;
    pub fn frames(&self) -> impl Stream<Item = CallbackFrame>;
}

pub enum CallbackFrame {
    Data(Bytes),    // INmxSvcCallback::DataReceived payload   (opnum 3)
    Status(Bytes),  // INmxSvcCallback::StatusReceived payload (opnum 4)
}

Frames are not decoded here — they're forwarded to mxaccess-codec::NmxSubscriptionMessage::parse upstream. This keeps the callback exporter a transport, not a parser.

mxaccess-nmx

INmxService2 client. Sits on top of mxaccess-rpc and mxaccess-callback.

pub struct NmxClient { /* DceRpcClient + CallbackExporter handle + state */ }

impl NmxClient {
    pub async fn connect(host: &str, ids: EngineIds) -> Result<Self, NmxError>;
    pub async fn register_engine_2(
        &mut self, engine_id: i32, name: &str, version: i32, callback: ObjRef,
    ) -> Result<(), NmxError>;
    pub async fn unregister_engine(&mut self, engine_id: i32) -> Result<(), NmxError>;
    pub async fn get_partner_version(&mut self, ids: EngineIds) -> Result<i32, NmxError>;
    pub async fn transfer_data(&mut self, ids: EngineIds, body: &[u8]) -> Result<(), NmxError>;
    pub async fn add_subscriber_engine(&mut self, ...) -> Result<(), NmxError>;
    pub async fn remove_subscriber_engine(&mut self, ...) -> Result<(), NmxError>;
    pub async fn set_heartbeat_send_interval(
        &mut self, ticks_per_beat: i32, max_missed_ticks: i32,
    ) -> Result<(), NmxError>;
}

transfer_data accepts a pre-encoded body from mxaccess-codec. It does not decode; it forwards.

Includes the SQL tag resolver (mxaccess-galaxy):

pub struct GalaxyResolver { /* tiberius client */ }

impl GalaxyResolver {
    pub async fn connect(connection_string: &str) -> Result<Self, GalaxyError>;
    pub async fn resolve(&mut self, full_reference: &str) -> Result<GalaxyTagMetadata, GalaxyError>;
    pub async fn resolve_user(&mut self, guid: Uuid) -> Result<GalaxyUser, GalaxyError>;
}

pub struct GalaxyTagMetadata {
    pub object_tag_name: String,
    pub attribute_name: String,
    pub platform_id: u16,
    pub engine_id: u16,
    pub object_id: u16,
    pub mx_data_type: MxDataType,
    pub security_classification: SecurityClassification,
    pub is_array: bool,
}

The resolver does not compute the CRC — consumers do that via MxReferenceHandle::compute_name_signature so the codec stays self-contained and the resolver stays a thin SQL layer.

mxaccess-asb

IASBIDataV2 client. SOAP over net.tcp framing. Independent of mxaccess-rpc and mxaccess-nmx; parallel data plane.

The wire is:

  • Net.Tcp framing (binary length-prefixed, see [MS-NMF]).
  • WCF binary message encoding ([MC-NBFX] tokenised XML + [MC-NBFS] static dictionary), not SOAP/XML on the wire — the .NET reference uses new NetTcpBinding(SecurityMode.None) with no encoder override (src/MxAsbClient/MxAsbDataClient.cs:660-685), which selects BinaryMessageEncodingBindingElement by default.
  • Custom binary inside <ASBIData> base64 elements (Variant, AsbStatus, MonitoredItem...) — the inner application payload, distinct from the message-envelope encoding.
  • Application-level auth: DH key exchange + per-message HMAC + AES-128.

Implementation: hand-rolled [MS-NMF] framing + [MC-NBFX]/[MC-NBFS] binary-XML codec in mxaccess-asb-nettcp (workspace-internal, published alongside the rest of the workspace), public surface in mxaccess-asb. Cross-platform reach is theoretically possible (no DCOM) but blocked by DPAPI for the shared secret on Windows AVEVA installs.

pub struct AsbClient { /* TcpStream + SOAP codec + auth + subscription state */ }

impl AsbClient {
    pub async fn connect(endpoint: Url, options: AsbConnectionOptions) -> Result<Self, AsbError>;
    pub async fn register(&mut self, item: &ItemIdentity) -> Result<RegisterResponse, AsbError>;
    pub async fn read(&mut self, item: &ItemIdentity) -> Result<AsbValue, AsbError>;
    pub async fn write(
        &mut self, item: &ItemIdentity, value: AsbValue, opts: WriteOptions,
    ) -> Result<WriteHandle, AsbError>;
    pub async fn create_subscription(
        &mut self, opts: SubscriptionOptions,
    ) -> Result<SubscriptionId, AsbError>;
    pub async fn add_monitored_items(
        &mut self, sid: SubscriptionId, items: &[MonitoredItem],
    ) -> Result<(), AsbError>;
    pub async fn publish(&mut self, sid: SubscriptionId) -> Result<Vec<PublishedValue>, AsbError>;
    pub async fn disconnect(&mut self) -> Result<(), AsbError>;
}

AsbClient is async-native (unlike the .NET reference, which is sync) because Tokio + non-blocking sockets is the natural fit for a long-poll subscription API.

What the raw layer does not do

  • It does not own a Tokio Runtime. It uses one when handed sockets but does not start one.
  • It does not surface Stream<Item = DataChange>. That is an async-layer concern. The raw callback exporter exposes mpsc::Receiver<CallbackFrame> of undecoded bytes; the async layer parses them and demultiplexes by correlation.
  • It does not transform MxStatus into typed Rust errors. Status decoding is verbatim; the async layer maps to Error types (see 50-error-model.md).
  • It does not reconnect or retry. Recovery is an async-layer policy.
  • It does not expose any sync wrappers. The raw types use async fn because every interesting operation is I/O-bound.