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

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

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

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

358 lines
26 KiB
Markdown
Raw Permalink 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.
# 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:5080`) 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:1180`.
## 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:8592`. 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:3975, 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:5104`.
**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:7485`) 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:5256`).
Source: `src/MxNativeCodec/MxReferenceHandle.cs:5120`. Per-char loop at `MxReferenceHandle.cs:4759`; inner CRC byte step at `MxReferenceHandle.cs:108119`.
## 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:78`), but `NmxItemControlMessage.Parse` only accepts `AdviseSupervisory` or `UnAdvise` and rejects any other command byte (`src/MxNativeCodec/NmxItemControlMessage.cs:4649`). 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:256258`).
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:2535,121142`). 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:5154`.
## Write bodies (`0x37`, normal)
Common prefix (18 bytes): `cmd(1=0x37) + version u16(2=1) + handle_projection(14) + wireKind(1)` (`src/MxNativeCodec/NmxWriteMessage.cs:1113,207213`). `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:215226`).
Boolean has its own 11-byte suffix instead: `7 zero bytes + clientToken u32(4) + writeIndex i32(4)` (`src/MxNativeCodec/NmxWriteMessage.cs:228238`).
| 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:121128`) |
| `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:148157`) |
| `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,390393`) | **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:346362`) | 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:181182`). 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:264265`). 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:170186`), 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:7394`. 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:240251`). 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:223225`, `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:240251` (`WriteTimestampedSuffix`); compare to `WriteNormalSuffix` at `src/MxNativeCodec/NmxWriteMessage.cs:215226`.
## 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:6105`.
## 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:9899`, `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:5455, 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:7174``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:5428`.
## 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:8087`); all other bytes within `[0..55)` are left as zero from the freshly-allocated buffer (`NmxReferenceRegistrationMessage.cs:7178`) 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:7178`). 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:7178`). 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:4247)
itemContext(untaggedString: i32 length + UTF-16LE + null)
tail(20 bytes; first 19 zero, tail[19] = subscribe_flag, NmxReferenceRegistrationMessage.cs:5456,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, 5962`) and that every byte in it is `0``body.Slice(offset, TailLength).IndexOfAnyExcept((byte)0) >= 0` throws `ArgumentException` (`NmxReferenceRegistrationResultMessage.cs:6467`). 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:480486`, 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:6142`, `NmxReferenceRegistrationResultMessage.cs:6120`.
## 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:11691293`, `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:11061167`, `src/MxAsbClient/AsbPublishedValue.cs:87119`.
## 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:1389`. 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:8167`, `AsbRegistry.cs:867`.
## 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:209293` 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:87133`).
## 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:3126`, and the per-record decoder in `src/MxNativeCodec/NmxSubscriptionMessage.cs:117149`.
## 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:164174`). 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.