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
+357
View File
@@ -0,0 +1,357 @@
# 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.