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>
26 KiB
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
- DH key exchange (prime, generator, key size in
HKLM\ArchestrA\ArchestrAServices\{Solution}). - Shared secret =
DH(remote_pub, local_priv, prime). - AES key =
SHA1(shared_secret || passphrase). - Per-message HMAC (algorithm in registry: MD5/SHA1/SHA512).
- AES-128 message encryption with
PBKDF2(passphrase, salt, 1000 iters, SHA1).
Passphrase obtained via DPAPI:
HKLM\[Wow6432Node\]ArchestrA\ArchestrAServices\{SolutionName}\sharedsecretProtectedData.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 > 1in0x33frames). Provider does not currently emit them. - Generic
OperationCompleteevents 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.