Files
mxaccess/design/10-raw-layer.md
T
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

442 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.