fe2a6db786
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>
358 lines
26 KiB
Markdown
358 lines
26 KiB
Markdown
# 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.
|