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>
27 KiB
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:5–120.
#[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:
- Use
icu_casemap::CaseMapper::lowercase_to_string(ICU4X) configured forLocale::UND/ Root locale to matchInvariant, or hand-implement UnicodeDefault_Lowercasevia the UCDSpecialCasing.txtandUnicodeData.txtLowercase_Mappingfield with no language tailoring. - Not use
str::to_lowercase()(locale-flavored) and not useunicase::Ascii::to_lowercase(ASCII-only — destroys non-ASCII parity). - 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-İ →i̇, German ß →ssis not applied byToLowerInvariantsoß → ß, Greek Σ → σ — confirm againstMxReferenceHandle.cs:47–59outputs from the .NET reference and assert byte-equal CRC).
NmxTransferEnvelope (46 bytes)
Source: src/MxNativeCodec/NmxTransferEnvelope.cs:5–104.
#[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:39–75), 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:74–85) 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:7–8), but NmxItemControlMessage.Parse only accepts AdviseSupervisory or UnAdvise (src/MxNativeCodec/NmxItemControlMessage.cs:46–49). 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:256–258).
Layout: command(1) + version(2) + correlation_id(GUID 16) + [extra(2) when AdviseSupervisory] + handle_projection(14) + tail(4) (src/MxNativeCodec/NmxItemControlMessage.cs:25–35,121–142).
Source: src/MxNativeCodec/NmxItemControlMessage.cs:5–154.
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:11–13,207–213). 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:228–238) + 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:148–157) |
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,390–393) |
0x41–0x45 |
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:179–186) |
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:7–394, NmxSecuredWrite2Message.cs:6–105. 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:5–428.
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:87–115):
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:54–55, 65–85):
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:71–74). 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:270–276 (decoder) has a branch for 0x47. If a future Frida capture exposes such a frame, both sides need an additive enum variant (ElapsedTimeArray → 0x47) 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:6–142, NmxReferenceRegistrationResultMessage.cs:6–120.
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 42–45) 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 since1601-01-01T00:00:00Z.time::OffsetDateTimeis also acceptable; pick one and stay consistent. The codec layer accepts both via traits.ElapsedTime: 4-byte signedi32milliseconds on the wire. The .NET decoder readsBinaryPrimitives.ReadInt32LittleEndianand producesTimeSpan.FromMilliseconds(milliseconds)(src/MxNativeCodec/NmxSubscriptionMessage.cs:252), which accepts negative values.std::time::Durationis unsigned and must not be used here — it cannot represent a negative ms value and panics on conversion. The Rust port exposes a newtypepub struct MxElapsedTime(pub i64);(milliseconds, signed) —i64rather thani32so it can also carry the widertime::Duration/.NETTimeSpanrange 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 inanalysis/frida/*-matrix.tsvis loaded, decoded, re-encoded, and asserted byte-equal. - Property tests:
proptestgenerators 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.Testsand 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:1–389. Implements:
- Type1 (Negotiate) — emit. Negotiate flags as set by
CreateType1(src/MxNativeClient/ManagedNtlmClientContext.cs:53–63):KeyExchange (0x40000000) | Sign (0x00000010) | AlwaysSign (0x00008000) | Seal (0x00000020) | TargetInfo (0x00800000) | Ntlm (0x00000200) | ExtendedSessionSecurity (0x00080000) | Unicode (0x00000001) | RequestTarget (0x00000004) | Negotiate128 (0x20000000) | Negotiate56 (0x80000000). Bit constants perManagedNtlmClientContext.cs:10–21. - 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:1–380, DceRpcTcpClient.cs:1–420.
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:1–82.
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:1–145, RemUnknownMessages.cs:1–79.
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(DataReceivedopnum 3,StatusReceivedopnum 4) — names match the .NET reference exactly:src/MxNativeClient/NmxSvcCallbackMessages.cs:11–12(DataReceivedOpnum/StatusReceivedOpnum) andsrc/MxNativeClient/NmxProcedureMetadata.cs:89–101(NdrProcedureDescriptorDataReceived/StatusReceived). The doc previously used aRawsuffix that does not appear in the source.
Source: src/MxNativeClient/ManagedCallbackExporter.cs:1–335.
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 selectsBinaryMessageEncodingBindingElementby 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 exposesmpsc::Receiver<CallbackFrame>of undecoded bytes; the async layer parses them and demultiplexes by correlation. - It does not transform
MxStatusinto typed Rust errors. Status decoding is verbatim; the async layer maps toErrortypes (see50-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 fnbecause every interesting operation is I/O-bound.