# Protocol invariants — bill of materials This is the wire-level spec the Rust port must hit byte-for-byte. Every entry cites its evidence in `src/`, `docs/`, `analysis/`, or `captures/`. ## COM identifiers | Name | GUID | Source | |---|---|---| | `NmxServiceClass` (CLSID) | `AE24BD51-2E80-44CC-905B-E5446C942BEB` | `src/MxNativeClient/NmxComContracts.cs:7` | | `INmxService` (IID) | `575008DB-845D-46C6-A906-F6F8CA86F315` | `src/MxNativeClient/NmxComContracts.cs:24` | | `INmxService2` (IID) | `2630A513-A974-4B1A-8025-457A9A7C56B8` | `src/MxNativeClient/NmxComContracts.cs:51` | | `INmxSvcCallback` (IID) | `B49F92F7-C748-4169-8ECA-A0670B012746` | `src/MxNativeClient/NmxComContracts.cs:84` | | DCE/RPC bind context UUID (initial bind, opnum 0 calls) | `4e0c90df-e39d-4164-a421-ace89484c602` | `docs/Loopback-Protocol-Findings.md:63` | | DCE/RPC service UUID (altered context, main opnums 0/2/3/5) | `1981974b-6bf7-46cb-9640-0260bbb551ba` | `docs/Loopback-Protocol-Findings.md:64` | | Standard NDR transfer syntax v2.0 | `8a885d04-1ceb-11c9-9fe8-08002b104860` | [MS-RPCE] §14.3 | ## INmxService2 opnums (after IUnknown's 0/1/2) `INmxService2` inherits from `INmxService`. Opnums are sequential across the inheritance. In the IDL/COM proxy these opnums are sequential because `INmxService2` extends `INmxService` and the derived interface continues the same vtable. In the .NET interop interface (`src/MxNativeClient/NmxComContracts.cs:50–80`) the methods are re-declared with the `new` modifier (`new void RegisterEngine(...)`, `new void UnRegisterEngine(...)`, etc.) so the managed `INmxService2` carries its own vtable slots distinct from the base interface — that managed shadowing is a C# interop detail and does **not** affect the wire opnum table. The Rust port targets the IDL/wire opnums (3..11 below), not the .NET interop vtable. | Opnum | Method | Inputs | Outputs | |---|---|---|---| | 3 | `RegisterEngine` | engineId(i32), engineName(BSTR), callback(*INmxSvcCallback) | hresult | | 4 | `UnRegisterEngine` | engineId(i32) | hresult | | 5 | `Connect` | localEngineId(i32), remoteGalaxyId(i32), remotePlatformId(i32), remoteEngineId(i32) | hresult | | 6 | `TransferData` | remoteGalaxyId(i32), remotePlatformId(i32), remoteEngineId(i32), size(i32), messageBody(byte[size]) | hresult | | 7 | `AddSubscriberEngine` | localEngineId(i32), subscriberGalaxyId(i32), subscriberPlatformId(i32), subscriberEngineId(i32) | hresult | | 8 | `RemoveSubscriberEngine` | same as Add | hresult | | 9 | `SetHeartbeatSendInterval` | ticksPerBeat(i32), maxMissedTicks(i32) | hresult | | 10 | `RegisterEngine2` | engineId(i32), engineName(BSTR), version(i32), callback(*INmxSvcCallback) | hresult | | 11 | `GetPartnerVersion` | galaxyId(i32), platformId(i32), engineId(i32) | hresult, version(out i32) | Source: `src/MxNativeClient/NmxComContracts.cs:11–80`. ## INmxSvcCallback opnums | Opnum | Method | Inputs | Outputs | |---|---|---|---| | 3 | `DataReceived` | bufferSize(i32), dataBuffer(sbyte[bufferSize]) | hresult | | 4 | `StatusReceived` | bufferSize(i32), statusBuffer(sbyte[bufferSize]) | hresult | Source: `src/MxNativeClient/NmxComContracts.cs:85–92`. Method names match the MIDL signatures at `src/MxNativeClient/NmxSvcCallbackMessages.cs:11-12` and `src/MxNativeClient/NmxProcedureMetadata.cs:89-101` exactly — the `Raw` suffix used in earlier drafts was doc-invented and has been removed. ## Network ports | Endpoint | Port | Notes | |---|---|---| | `IObjectExporter` (RPCSS endpoint mapper) | 135/tcp | DCE/RPC over TCP | | `NmxSvc.exe` static endpoint registration | 5026/tcp+udp | Registered with RPCSS; actual listening socket resolved via OXID at runtime (observed dynamic ports e.g. 49704). | | Callback server (`mxaccess-callback`) | ephemeral | Embedded in OBJREF dual-string | | ASB endpoint | configurable, typical `net.tcp://host:5021/...` | Read from `HKLM\ArchestrA\ArchestrAServices\{Solution}` | ## NMX TransferData envelope (46 bytes) | Offset | Size | Field | Encoding | |---|---|---|---| | 0 | 2 | Version | u16 LE = `1` | | 2 | 4 | InnerLength | i32 LE = `body.len() - 46` | | 6 | 4 | Reserved | 4 bytes. **Not preserved by the .NET reference**: `Parse` skips bytes 6..10 and `Encode` always writes `0` there (`src/MxNativeCodec/NmxTransferEnvelope.cs:39–75, 91`). The Rust port intentionally **adds** preservation by carrying these four bytes as `reserved6_10: [u8; 4]` through parse/encode (default `[0; 4]` for new envelopes), per the `10-raw-layer.md` envelope section — this fixes a CLAUDE.md "preserve unknown bytes" gap that the .NET reference does not. | | 10 | 4 | MessageKind | i32 LE: 1=Metadata, 2=ItemControl, 3=Write | | 14 | 4 | SourceGalaxyId | i32 LE | | 18 | 4 | SourcePlatformId | i32 LE | | 22 | 4 | LocalEngineId | i32 LE | | 26 | 4 | TargetGalaxyId | i32 LE | | 30 | 4 | TargetPlatformId | i32 LE | | 34 | 4 | TargetEngineId | i32 LE | | 38 | 4 | ProtocolMarker | i32 LE = `0x0000_0201`; on-wire byte sequence `01 02 00 00` (low byte first). Encoded by `BinaryPrimitives.WriteInt32LittleEndian(... ProtocolMarker)` (`src/MxNativeCodec/NmxTransferEnvelope.cs:99`). | | 42 | 4 | TimeoutMilliseconds | i32 LE (default `30000`) | Source: `src/MxNativeCodec/NmxTransferEnvelope.cs:5–104`. ⚠ **InnerLength must match actual body size.** 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 it does not. The encoder validates the relationship before transmitting; envelope-only sends with `InnerLength == 0` are rejected unless explicitly opted into for diagnostics. ## MxReferenceHandle (20 bytes) | Offset | Size | Field | Encoding | |---|---|---|---| | 0 | 1 | GalaxyId | u8 | | 1 | 1 | Reserved | u8 = `0` | | 2 | 2 | PlatformId | u16 LE | | 4 | 2 | EngineId | u16 LE | | 6 | 2 | ObjectId | u16 LE | | 8 | 2 | ObjectSignature | u16 LE = CRC-16/IBM(lowercase UTF-16LE objectTagName) | | 10 | 2 | PrimitiveId | i16 LE | | 12 | 2 | AttributeId | i16 LE | | 14 | 2 | PropertyId | i16 LE | | 16 | 2 | AttributeSignature | u16 LE = CRC-16/IBM(lowercase UTF-16LE attributeName) | | 18 | 2 | AttributeIndex | i16 LE = `-1` if array, `0` otherwise | CRC-16/IBM polynomial `0xa001` (right-shifted variant). **Initial value `0`** (`src/MxNativeCodec/MxReferenceHandle.cs:51` — literal `ushort crc = 0`, not `0xFFFF`). For each `char` of `name.ToLowerInvariant()`, apply the low byte then the high byte of the UTF-16LE representation (`src/MxNativeCodec/MxReferenceHandle.cs:52–56`). Source: `src/MxNativeCodec/MxReferenceHandle.cs:5–120`. Per-char loop at `MxReferenceHandle.cs:47–59`; inner CRC byte step at `MxReferenceHandle.cs:108–119`. ## Item-control bodies | 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 enum defines `Advise = 0x1f` and `AdviseSupervisory = 0x1f` as the same opcode (`src/MxNativeCodec/NmxItemControlMessage.cs:7–8`), but `NmxItemControlMessage.Parse` only accepts `AdviseSupervisory` or `UnAdvise` and rejects any other command byte (`src/MxNativeCodec/NmxItemControlMessage.cs:46–49`). A 37-byte `0x1f` "plain advise" body is **not** a wire shape the codec produces or accepts. The compatibility layer's `AdviseSupervisory` method forwards to `Advise`, both encoded as the 39-byte AdviseSupervisory body (`src/MxNativeClient/MxNativeCompatibilityServer.cs:256–258`). AdviseSupervisory layout: `cmd(1) + version u16(2) + correlation(GUID 16) + adviseExtra(2) + handle_projection(14, bytes 6..19 of MxReferenceHandle) + tail u32(4)` (`src/MxNativeCodec/NmxItemControlMessage.cs:25–35,121–142`). The 2-byte `adviseExtra` is omitted for `UnAdvise`. **`tail` constant value: `3` (u32 LE = `03 00 00 00`).** `NmxItemControlMessage.FromReferenceHandle` defaults the parameter to `tail = 3` (`src/MxNativeCodec/NmxItemControlMessage.cs:88`) and every call site in the .NET reference relies on that default. The Rust port must emit the literal value `3` for both `AdviseSupervisory` and `UnAdvise`; emitting any other value will be rejected by the responding NMX. Source: `src/MxNativeCodec/NmxItemControlMessage.cs:5–154`. ## Write bodies (`0x37`, normal) Common prefix (18 bytes): `cmd(1=0x37) + version u16(2=1) + handle_projection(14) + wireKind(1)` (`src/MxNativeCodec/NmxWriteMessage.cs:11–13,207–213`). `HandleProjectionOffset = 3`, `HandleProjectionLength = 14`, `KindOffset = 17`. There is **no** padding between version and the handle projection; the handle projection is bytes 6..19 of the 20-byte `MxReferenceHandle` written directly at offset 3. Normal scalar suffix (14 bytes + writeIndex): `[-1 i16] + filler(8 zero bytes) + clientToken u32(4) + writeIndex i32(4)` (`src/MxNativeCodec/NmxWriteMessage.cs:215–226`). Boolean has its own 11-byte suffix instead: `7 zero bytes + clientToken u32(4) + writeIndex i32(4)` (`src/MxNativeCodec/NmxWriteMessage.cs:228–238`). | WireKind | Type | Value section | Total | |---|---|---|---| | `0x01` | Boolean | 4 bytes literal `[0xff,0xff,0xff,0x00]` (true) or `[0x00,0xff,0xff,0x00]` (false). Bytes 1 and 2 are `0xFF` filler, NOT reserved zeros (`src/MxNativeCodec/NmxWriteMessage.cs:257`). | 37 (`KindOffset(17) + 1 + 4 + 11 + 4`, `src/MxNativeCodec/NmxWriteMessage.cs:121–128`) | | `0x02` | Int32 | 4 bytes LE | 40 | | `0x03` | Float32 | 4 bytes IEEE | 40 | | `0x04` | Float64 | 8 bytes IEEE | 44 | | `0x05` | String | recordLength i32(4) + valueByteLength i32(4) + UTF-16LE bytes(N) + null(2) | **44 + N** (`KindOffset(17) + 1 + 4 + 4 + N + 14 + 4`, `src/MxNativeCodec/NmxWriteMessage.cs:148–157`) | | `0x05` | DateTime | 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`) | **44 + N** | | `0x41` | BoolArray | 4 unused bytes + count u16 at body[22] + elementWidth u16 at body[24] + elements at body[28] | 18 (prefix) + 10 (array header) + 2N + 14-byte suffix + 4 writeIndex | **BoolArray element encoding (writes and reads agree):** each element is a little-endian `i16`, **not** a single byte. `true` → `-1` → `[0xFF, 0xFF]`; `false` → `0` → `[0x00, 0x00]`. Encoder: `BinaryPrimitives.WriteInt16LittleEndian(..., values[i] ? (short)-1 : (short)0)` (`src/MxNativeCodec/NmxWriteMessage.cs:307`). Decoder: `BinaryPrimitives.ReadInt16LittleEndian(...) != 0` (`src/MxNativeCodec/NmxSubscriptionMessage.cs:282, 290`). Element width is therefore 2 bytes (the `2N` in the BoolArray total), and the array-header `elementWidth` field carries `2`. | `0x42` | Int32Array | same shape, 4-byte elements | 18 + 10 + 4N + 14 + 4 | | `0x43` | Float32Array | same | 18 + 10 + 4N + 14 + 4 | | `0x44` | Float64Array | same, 8-byte elements | 18 + 10 + 8N + 14 + 4 | | `0x45` | StringArray / DateTimeArray | per-element length-prefixed records (`src/MxNativeCodec/NmxWriteMessage.cs:346–362`) | 18 + 10 + Σ(per-element) + 14 + 4 | **Encoder vs decoder asymmetry for array headers (preserve verbatim):** The encoder writes `count` as **u16 at body[22]** and `elementWidth` as **u16 at body[24]** — both 2-byte little-endian values (`src/MxNativeCodec/NmxWriteMessage.cs:181–182`). The subscription/callback decoder, however, reads `count` as **u16 at body+4** and `elementWidth` as **i32 at body+6** — a 4-byte little-endian read (`src/MxNativeCodec/NmxSubscriptionMessage.cs:264–265`). Because the high u16 of the encoder's `[count, elementWidth]` slot is the small `elementWidth` value and the bytes at offsets 26..27 are zero (no other writes target that slot — `NmxWriteMessage.cs:170–186`), an i32 read at body+6 of a captured write body sees the same numeric value. A Rust port must replicate the asymmetry exactly: write u16/u16, read u16+i32, do not normalize either side. Source: `src/MxNativeCodec/NmxWriteMessage.cs:7–394`. Per-type matrices: `analysis/frida/write-body-matrix.tsv`, `write-array-body-matrix.tsv`, `write-mode-matrix.tsv`. ## Write2 (timestamped) Same as `Write` but the 18-byte trailer (14-byte suffix + 4-byte writeIndex) is rewritten by `WriteTimestampedSuffix` (`src/MxNativeCodec/NmxWriteMessage.cs:240–251`). The trailer is **not** lengthened — bytes are repacked, not inserted: | Trailer offset | Size | Normal `Write` | `Write2` (timestamped) | |---|---|---|---| | 0 | 2 | `-1 i16` (`NmxWriteMessage.cs:222`) | **`0 i16`** (`NmxWriteMessage.cs:247`) | | 2 | 8 | 8 zero filler bytes (`NmxWriteMessage.cs:223–225`, `WriteNormalSuffix` zero-init) | **8-byte `FILETIME` from `timestamp.ToFileTime()`** (`NmxWriteMessage.cs:248`) | | 10 | 4 | `clientToken u32` (`NmxWriteMessage.cs:225`) | `clientToken u32` (`NmxWriteMessage.cs:249`) | | 14 | 4 | `writeIndex i32` (`NmxWriteMessage.cs:226`) | `writeIndex i32` (`NmxWriteMessage.cs:250`) | The FILETIME **replaces** the eight-byte filler that `WriteNormalSuffix` would otherwise leave zero; nothing is inserted between offsets 12 and 19. Total body size is identical to the corresponding non-timestamped `Write`. Source: `src/MxNativeCodec/NmxWriteMessage.cs:240–251` (`WriteTimestampedSuffix`); compare to `WriteNormalSuffix` at `src/MxNativeCodec/NmxWriteMessage.cs:215–226`. ## WriteSecured2 (`0x38`) `Write2` body (without trailing clientToken+writeIndex), then: ``` currentUserToken(16) + clientNameLen(i32) + clientNameBytes(UTF-16LE+null) + verifierUserToken(16) + (-1 i16) + clientToken(u32) + writeIndex(i32) ``` Observed authenticated user token (sample): `07 b9 a9 f4 72 6e ae 48 83 b5 bb de 91 8c 89 0f` (`captures/036-frida-secured*`). Source: `src/MxNativeCodec/NmxSecuredWrite2Message.cs:6–105`. ## SubscriptionStatus (`0x32`) ``` cmd(1=0x32) + version(2=1) + recordCount(i32) + operationId(GUID 16) + correlationId(GUID 16) + records[recordCount] record: status(i32) + detailStatus(i32) + quality(u16) + timestamp_filetime(i64) + wireKind(u8) + value(N) ``` **Header length: 39 bytes** = cmd(1) + version(2) + recordCount(4) + operationId(16) + correlationId(16). Records start at byte offset 39 (`src/MxNativeCodec/NmxSubscriptionMessage.cs:98–99`, `for (int i = 0; i < recordCount; i++)` over `offset = 39`). ## DataUpdate (`0x33`) ``` cmd(1=0x33) + version(2=1) + recordCount(i32) + operationId(GUID 16) + records[recordCount] record: status(i32) + quality(u16) + timestamp_filetime(i64) + wireKind(u8) + value(N) ``` **Header length: 23 bytes** = cmd(1) + version(2) + recordCount(4) + operationId(16). There is **no** per-message correlationId on `0x33`; the record starts at byte offset 23 (`src/MxNativeCodec/NmxSubscriptionMessage.cs:54–55, 76` — `recordCount` read at offset 3, `operationId` at offset 7, `recordOffset = 23`). The 16-byte difference between the two header lengths (39 − 23) is exactly the `0x32`-only correlationId slot. ⚠ **Hard invariant: `recordCount == 1` for `0x33` DataUpdate.** The .NET parser throws `ArgumentException` on any other value (`src/MxNativeCodec/NmxSubscriptionMessage.cs:71–74` — `if (recordCount != 1) throw`). The Rust port replicates this as a typed error rather than degrading to opaque-bytes preservation, matching the executable spec. Multi-sample buffered batches are tracked as not-yet-wire-proven in `70-risks-and-open-questions.md` (R2/R13); only single-record DataUpdate frames have been observed in `captures/`. Wire kinds **0x01..0x07** (scalars) and **0x41..0x46** (arrays). The set is asymmetric across encode/decode: the write-side encoder collapses both `StringArray` and `DateTimeArray` to `0x45` and never emits `0x46` (`src/MxNativeCodec/NmxWriteMessage.cs:107`); the subscription/callback decoder accepts and demuxes `0x46` as `DateTimeArray` (`src/MxNativeCodec/NmxSubscriptionMessage.cs:173,275`). **Writes use `0x41..0x45` only; reads/callbacks accept `0x41..0x46`.** Source: `src/MxNativeCodec/NmxSubscriptionMessage.cs:5–428`. ## Reference registration (`0x10` / `0x11`) Request (`0x10`) — fixed 55-byte header, then variable strings, then 20-byte tail. `HeaderLength = 55` (`src/MxNativeCodec/NmxReferenceRegistrationMessage.cs:15`). The codec only writes at six explicit offsets inside the header (`NmxReferenceRegistrationMessage.cs:80–87`); all other bytes within `[0..55)` are left as zero from the freshly-allocated buffer (`NmxReferenceRegistrationMessage.cs:71–78`) and are preserved verbatim per CLAUDE.md unknown-bytes rule. | Offset | Size | Field | Encoding / source | |---|---|---|---| | 0 | 1 | Command | u8 = `0x10` (`NmxReferenceRegistrationMessage.cs:80`) | | 1 | 2 | Version | u16 LE = `1` (`NmxReferenceRegistrationMessage.cs:81`) | | 3 | 4 | ItemHandle | i32 LE (`NmxReferenceRegistrationMessage.cs:82`) | | 7 | 16 | ItemCorrelationId | GUID bytes (`NmxReferenceRegistrationMessage.cs:83`) | | 23 | 2 | `-1 i16` marker | i16 LE = `-1` (`NmxReferenceRegistrationMessage.cs:85`) | | 25 | 2 | Reserved gap | preserved as `0` — never written explicitly; zero-init from `new byte[]` (`NmxReferenceRegistrationMessage.cs:71–78`). Preserved verbatim per CLAUDE.md unknown-bytes rule. | | 27 | 4 | Constant `1 i32` | i32 LE = `1` (`NmxReferenceRegistrationMessage.cs:86`) | | 31 | 24 | Reserved gap | preserved as `0` — never written explicitly; zero-init from `new byte[]` (`NmxReferenceRegistrationMessage.cs:71–78`). The `Parse` method reads these bytes implicitly only via `body.Slice(offset, ItemStringReservedLength)` further on; the prefix range itself is round-tripped untouched. Preserved verbatim per CLAUDE.md unknown-bytes rule. | After the 55-byte prefix: ``` itemDefinition(taggedString: i32 length with high byte 0x81 + UTF-16LE + null) itemStringReserved(8 bytes; Parse asserts all-zero, NmxReferenceRegistrationMessage.cs:42–47) itemContext(untaggedString: i32 length + UTF-16LE + null) tail(20 bytes; first 19 zero, tail[19] = subscribe_flag, NmxReferenceRegistrationMessage.cs:54–56,92) ``` Result (`0x11`): ``` cmd(1) + version(2) + itemHandle(i32) + correlation(GUID 16) + firstTimestamp(i64) + secondTimestamp(i64) + statusCategory(u8) + statusDetail(u8) + blockLength(i32) + itemDefinition(tagged) + mxDataType(i32) + reserved(6) + itemContext(untagged) + tail(16 zero) ``` **Tail = 16 zero bytes is a hard parser invariant.** `NmxReferenceRegistrationResultMessage.Parse` asserts both that the trailing slice is exactly `TailLength = 16` (`src/MxNativeCodec/NmxReferenceRegistrationResultMessage.cs:21, 59–62`) and that every byte in it is `0` — `body.Slice(offset, TailLength).IndexOfAnyExcept((byte)0) >= 0` throws `ArgumentException` (`NmxReferenceRegistrationResultMessage.cs:64–67`). Wire-confirmed against the `0x11` registration-result frames captured under `captures/080-frida-buffered-external-write-testint/` (per `docs/Capture-Run-2026-04-25.md:480–486`, which records that this capture supplied the stable normal-and-buffered `0x10` registration bodies *plus the matching `0x11` registration-result frames* used by the `NmxReferenceRegistrationResultMessage` tests — the same parser whose all-zero-tail assertion would have rejected those captures had the wire bytes been non-zero). Per CLAUDE.md's preserve-unknown-bytes rule the Rust port still carries them as 16 explicit bytes (not skipped) so non-zero tails surface as a typed parse error rather than silent acceptance. Tagged-string encoding: `4-byte length: tagged ? (byteLength | 0x81000000) : byteLength` followed by UTF-16LE bytes + null terminator. Source: `src/MxNativeCodec/NmxReferenceRegistrationMessage.cs:6–142`, `NmxReferenceRegistrationResultMessage.cs:6–120`. ## ASB Variant | Offset | Size | Field | |---|---|---| | 0 | 2 | Type ID (u16 LE, AsbDataType) | | 2 | 4 | Length (i32 LE, logical) | | 6 | 4 | PayloadLength (i32 LE, byte count) | | 10 | N | Payload | AsbDataType IDs (live-proven): Bool=17, Int32=4, Float=8, Double=9, String=10 (UTF-16LE), DateTime=11 (FILETIME), Duration=12 (.NET Ticks), Int32Array=44, FloatArray=48, DoubleArray=49, StringArray=50, DateTimeArray=51, DurationArray=52, BoolArray=57. Source: `src/MxAsbClient/AsbContracts.cs:1169–1293`, `docs/ASB-Variant-Wire-Format.md`. ## ASB AsbStatus | Offset | Size | Field | |---|---|---| | 0 | 1 | Count (sbyte: -1 marker-only, 0..N elements) | | 1 | 4 | PayloadLength (u32 LE) | | 5 | N | Payload (packed status elements) | Status element: marker byte (high bit clear = value follows), low 7 bits = type ID. Known IDs: 5=MxStatusCategory, 6=MxStatusDetail, 7=MxQuality. If value present, 2-byte u16 LE follows. Quality bits (mask `0x00C0`): `0xC0`=Good, `0x40`=Uncertain, `0x00`=Bad. Source: `src/MxAsbClient/AsbContracts.cs:1106–1167`, `src/MxAsbClient/AsbPublishedValue.cs:87–119`. ## Authentication ### NTLMv2 (NMX/DCE-RPC) - Negotiate flags: Unicode | RequestTarget | Sign | Seal | ExtendedSessionSecurity | Negotiate128 | KeyExchange - NTLMv2 NT-OWF = `HMAC-MD5(MD4(unicode(password)), unicode(uppercase(user) + domain))` - Type3 with AV pairs from server's TargetInfo (channel binding optional) - Packet integrity: `HMAC-MD5(SignKey, sequence || plaintext)` → first 8 bytes XOR with RC4 keystream - Sign-key / seal-key: MD5 over magic constants Source: `src/MxNativeClient/ManagedNtlmClientContext.cs:1–389`. Reference: [MS-NLMP]. ### ASB application-level 1. DH key exchange (prime, generator, key size in `HKLM\ArchestrA\ArchestrAServices\{Solution}`). 2. Shared secret = `DH(remote_pub, local_priv, prime)`. 3. AES key = `SHA1(shared_secret || passphrase)`. 4. Per-message HMAC (algorithm in registry: MD5/SHA1/SHA512). 5. AES-128 message encryption with `PBKDF2(passphrase, salt, 1000 iters, SHA1)`. Passphrase obtained via DPAPI: - `HKLM\[Wow6432Node\]ArchestrA\ArchestrAServices\{SolutionName}\sharedsecret` - `ProtectedData.Unprotect(bytes, Unicode("wonderware"), LocalMachine)` Source: `src/MxAsbClient/AsbSystemAuthenticator.cs:8–167`, `AsbRegistry.cs:8–67`. ## Galaxy SQL schema (subset) | Table | Columns of interest | |---|---| | `dbo.gobject` | tag_name, gobject_id, package_id | | `dbo.instance` | mx_platform_id, mx_object_id, mx_engine_id | | `dbo.dynamic_attribute` | mx_data_type, is_array | | `dbo.package` | inheritance chain (recursive CTE) | | `dbo.user_profile` | user_guid, user_profile_name, intouch_access_level, roles | `GalaxyRepositoryTagResolver.cs:209–293` is the canonical query (recursive CTE for `deployed_package_chain` → `ranked_dynamic` → `primitive_attributes`). User-role parsing: hex-encoded UTF-16LE blob, scan for null-terminated wide-char strings (`GalaxyRepositoryUserResolver.cs:87–133`). ## HRESULT / status codes (observed) | HRESULT | Meaning | Source | |---|---|---| | `0x00000000` | S_OK | trivially | | `0x00000001` | S_FALSE (pending/retry) | observed in callbacks | | `0x80004021` | Returned by `MxNativeSession.WriteSecuredAsync` (the .NET native reimplementation) before reaching the wire — `src/MxNativeClient/MxNativeSession.cs:218-221`. **NOT** a real LMX-proxy constraint: `wwtools/mxaccesscli/` verifies the production LMX `WriteSecured` always takes two user ids `(currentUserId, verifierUserId, value)` and accepts single-user secured writes as `currentUserId == verifierUserId`. See R6 in `70-risks-and-open-questions.md`. | `docs/DotNet10-Native-Library-Plan.md`; `wwtools/mxaccesscli/docs/api-notes.md:60-72` | | `0x80070057` | E_INVALIDARG | observed for stale handles | | `0x8007139F` | `ERROR_INVALID_STATE` — observed from `IDataConsumer.ProcessActivateSuspend2` while the namespace is not yet activated. Despite the canonical Win32 name, the codebase elsewhere has labelled this "uninitialized object" / `EngineNotRegistered`; the canonical Win32 mapping is `ERROR_INVALID_STATE` per `docs/Capture-Run-2026-04-25.md:888` and `docs/MXAccess-Public-API.md:326`. | `docs/Capture-Run-2026-04-25.md:872,886,888`; `docs/MXAccess-Public-API.md:313,326`; `docs/Current-Sprint-State.md:119,122` | | `0x80040154` | REGDB_E_CLASSNOTREG | callback proxy/stub missing | | `0x8001011D` | ORPC callback OBJREF rejected (security binding) | observed in callback flows | | `0x800706BA` | RPC server unavailable | NmxSvc not running | ## Status detail codes (subset) From `MxStatusSource.RespondingNmx`-side callbacks: - 16 = timeout - 17 = platform-comm failure - 18 = invalid platform id - 21 = invalid reference - 22 = no Galaxy Repository - 23 = invalid object id - 30 = type mismatch - 31 = not readable - 32 = not writeable - 33 = access denied - 56 = secured-write related - 57 = verified-write related ⚠ **Three different on-wire widths carry "detail"-shaped numbers; do not conflate them.** | Field | Width | Signedness | Where it lives | Source | |---|---|---|---|---| | `MxStatus.Detail` | 2 bytes | **`i16`** (signed `short`) | `MxStatus` record, the canonical promoted-status type | `src/MxNativeCodec/MxStatus.cs:32` (`short Detail`) | | DataUpdate / SubscriptionStatus record `quality` | 2 bytes | **`u16`** (unsigned, bitmask, mask `0x00C0`) | per-record header in `0x32`/`0x33` frames | `src/MxNativeCodec/NmxSubscriptionMessage.cs:136` | | Record `status` and (SubscriptionStatus only) `detailStatus` | 4 bytes each | **`i32`** (signed) | per-record header in `0x32`/`0x33` frames | `src/MxNativeCodec/NmxSubscriptionMessage.cs:126` (`status`), `:132` (`detailStatus`) | When promoting a record to an `MxStatus`, the i32 `detailStatus` is **narrowed** to `i16` — sign-extension applies on the way out, but values outside `i16` range are not representable. The Rust port must preserve the on-wire width on each layer (don't widen everything to `i32` on parse) so that an out-of-range `detailStatus` is a typed error rather than a silent truncation. Source: `docs/MXAccess-Public-API.md`, the MxStatus parsing in `src/MxNativeCodec/MxStatus.cs:3–126`, and the per-record decoder in `src/MxNativeCodec/NmxSubscriptionMessage.cs:117–149`. ## Completion-only frames 5-byte completion frame `00 00 50 80 00` → `MxStatus.WriteCompleteOk` (the only proven mapping). 1-byte completion frames (`0x00`, `0x41`, `0xEF`) are preserved as raw, unpromoted statuses (`work_remain.md:164–174`). Do not synthesise typed completion events from them. ## Items not yet wire-proven - Multi-sample buffered batches (`recordCount > 1` in `0x33` frames). Provider does not currently emit them. - Generic `OperationComplete` events outside the proven 5-byte completion frame. - Activate/Suspend transition events. - ASB write timestamp + status fields in publish responses. Listed with mitigation strategy in `70-risks-and-open-questions.md`. The codec must accept these as opaque bytes and not promote them.