Initial project state: .NET reference, design, Rust port (M0+M1), evidence
rust / build / test / clippy / fmt (push) Has been cancelled

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>
This commit is contained in:
Joseph Doherty
2026-05-05 06:21:00 -04:00
parent 43733699b0
commit fe2a6db786
3849 changed files with 352975 additions and 0 deletions
+441
View File
@@ -0,0 +1,441 @@
# 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`.
```rust
#[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-İ → `i̇`, 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`.
```rust
#[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 (`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: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`.
```rust
#[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
```rust
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)
```rust
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`.
```rust
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`.
```rust
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`):
```rust
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.
```rust
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.