Files
mxaccess/docs/DotNet10-Native-Library-Plan.md
T
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

1079 lines
69 KiB
Markdown

# .NET 10 x64 native LMX/NMX library plan
## Current conclusion
A full managed .NET 10 x64 library is feasible only if it replaces the native
NMX proxy/stub layer in managed code. Normal .NET COM interop is not enough.
Verified on 2026-04-25:
| Probe | Result |
| --- | --- |
| 64-bit `CoCreateInstance` / `Type.GetTypeFromProgID("NmxSvc.NmxService")` | succeeds |
| first .NET 10 x64 `INmxService2.RegisterEngine2` call | fails with `0x8007000B` / `BadImageFormatException` |
| reason | COM tries to load the installed 32-bit `NmxSvcps.dll` proxy/stub into the x64 process |
Registry verification:
| Interface | 64-bit registration | 32-bit registration |
| --- | --- | --- |
| `INmxService2` `{2630A513-A974-4B1A-8025-457A9A7C56B8}` | absent under `HKCR\Interface` | `WOW6432Node\Interface`, `ProxyStubClsid32={2630A513-A974-4B1A-8025-457A9A7C56B8}`, `NumMethods=12` |
| `INmxService` `{575008DB-845D-46C6-A906-F6F8CA86F315}` | absent under `HKCR\Interface` | `WOW6432Node\Interface`, same proxy CLSID, `NumMethods=10` |
| `INmxSvcCallback` `{B49F92F7-C748-4169-8ECA-A0670B012746}` | absent under `HKCR\Interface` | `WOW6432Node\Interface`, same proxy CLSID, `NumMethods=5` |
The proxy CLSID is registered only under
`HKCR\WOW6432Node\CLSID\{2630A513-A974-4B1A-8025-457A9A7C56B8}\InProcServer32`
and points to:
```text
C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxSvcps.dll
```
The proof project is:
```text
src\MxNativeClient\MxNativeClient.csproj
src\MxNativeClient.Probe\MxNativeClient.Probe.csproj
```
The probe output is expected at this stage:
```text
process=x64:True
cocreate=ok
bad_image_format=0x8007000B An attempt was made to load a program with an incorrect format.
```
Packet-privacy DCOM activation now proves the x86 proxy is not fundamentally
required to talk to `NmxSvc.exe`. Using Impacket only as a reference client, a
64-bit process successfully activated `NmxSvc.NmxService`, received an
`INmxService2` IPID, bound directly to `INmxService2`, and invoked
`GetPartnerVersion`:
```text
python analysis\scripts\probe_dcom_inmxservice2.py
```
Saved output:
```text
analysis\proxy\dcom-inmxservice2-getpartner-probe.txt
```
Observed result:
| Field | Value |
| --- | --- |
| DCOM auth level | packet privacy |
| activated CLSID | `{AE24BD51-2E80-44CC-905B-E5446C942BEB}` / `NmxSvc.NmxService` |
| requested IID | `{2630A513-A974-4B1A-8025-457A9A7C56B8}` / `INmxService2` |
| OXID | `0xEAF0D2B53BAB5BC2` |
| endpoint | `DESKTOP-6JL3KKO[60241]` plus IP bindings |
| method invoked | `GetPartnerVersion`, opnum `11` |
| result | `partner_version=6`, `ErrorCode=0x00000000` |
This is the first end-to-end proof that the replacement library can be a .NET
10 x64 client-side implementation instead of a 32-bit sidecar or a new native
proxy/stub DLL. There are now two viable managed routes: direct DCOM/NDR to
`NmxSvc.exe` for MXAccess-compatible LMX behavior, and direct ASB `IASBIDataV2`
to the MxDataProvider for data-service register/read/write/complete behavior.
The same path has now been reproduced in managed .NET 10 code through
`GetPartnerVersion`:
```text
dotnet run --project src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-remqi-managed --objref-only
```
Saved output:
```text
analysis\proxy\managed-remqi-and-getpartner-probe.txt
```
Observed result:
| Stage | Managed result |
| --- | --- |
| NTLMv2 packet-integrity auth | succeeds without SSPI `MakeSignature` |
| `IObjectExporter::ResolveOxid` | `ErrorCode=0x00000000`, returns OXID bindings and `IRemUnknown` IPID |
| `IRemUnknown::RemQueryInterface` | `HRESULT=0x00000000`, returns an `INmxService2` IPID |
| `INmxService2::GetPartnerVersion` | `partner_version=6`, `HRESULT=0x00000000` |
This removes the prior managed transport blocker for scalar service calls. The
remaining DCOM work is breadth: activation without local COM marshaling, packet
privacy/sealing if needed by future calls, callback object export, interface
pointer marshaling for `RegisterEngine2`, and byte-array marshaling for
`TransferData`.
`TransferData` byte-array marshaling has now been live-proven. The managed
probe sent a non-value 46-byte control body from capture `046` through
`INmxService2::TransferData`:
```text
analysis\proxy\managed-transferdata-control-probe.txt
```
The call reached `NmxSvc.exe` and returned `HRESULT=0x80041101` as an
application-level NMX error, not a DCE/RPC fault. This is the expected shape for
an unregistered probe session and confirms that the managed NDR encoding for
`int,int,int,int,byte[size]` is accepted by the service.
Normal write bodies are now generated from `MxReferenceHandle`, GR data type,
payload value, write index, and the client token field:
```text
src\MxNativeCodec\NmxWriteMessage.cs
```
The generated bodies reproduce the captured normal bool, int, float, double,
string, datetime, and array writes. They also reproduce the captured
timestamped int `Write2` body by replacing the normal no-time suffix with
`int16 0`, an 8-byte FILETIME, the client token, and the write index.
The captured secured/verified test tags (`TestMachine_001.ProtectedValue` and
`ProtectedValue1`) also use the normal bool write body; their GR
`security_classification` values are 2 and 3, and their generated handles/body
bytes now round-trip in tests.
The callback marshal probe confirms the next hard blocker:
```text
analysis\proxy\callback-marshal-probe.txt
```
`CoMarshalInterface` for `INmxSvcCallback` from the x64 managed process fails
with `0x80040154 REGDB_E_CLASSNOTREG`, because the installed
`NmxSvcps.dll` proxy/stub is only registered under WOW6432Node. Therefore
`RegisterEngine2` cannot rely on Windows COM to export the callback object; the
library needs a managed callback object exporter that advertises
`INmxSvcCallback` and handles `DataReceived` / `StatusReceived` ORPC calls.
The probe can also dump the remote-style `IUnknown` OBJREF without loading the
NMX proxy:
```text
dotnet run --project src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --dump-objref --objref-context=2 --expect-proxy-load-failure
```
Latest saved output:
```text
analysis\proxy\nmxservice-objref-context2.txt
```
The parser used by the probe is now reusable managed code:
```text
src\MxNativeClient\ComObjRef.cs
src\MxNativeClient\ComObjRefProvider.cs
```
The current OBJREF confirms:
| Field | Value from latest probe |
| --- | --- |
| signature | `0x574F454D` / `MEOW` |
| marshaled IID | `IUnknown` / `00000000-0000-0000-C000-000000000046` |
| OXID | `0xEAF0D2B53BAB5BC2` |
| bindings | `ncacn_ip_tcp` host/IP bindings plus named-pipe, local-RPC, and security towers |
The OID/IPID are activation-specific and change per run. This gives the
managed ORPC layer a concrete activation path: obtain/parse an `IUnknown`
OBJREF, resolve/query the object for `INmxService2`, then issue calls using the
decoded opnums below.
## Boundary proved by capture 046
Capture:
```text
captures\046-service-boundary-write-test-int-123456791
```
The same 86-byte value-bearing `TransferData` body appears in all three places:
| Stage | Function | Value offset |
| --- | --- | ---: |
| x86 client adapter | `CNmxAdapter.TransferData` | `64` |
| service COM entry | `CNmxService.TransferData` | `64` |
| service controller | `CNmxControler.TransferData` / `DataReceived` / `ProcessDataReceivedForEngine` | `64` |
The exact body is not present in the loopback TCP capture. That means the
private TCP/LRPC serialization is below the COM proxy/stub layer. For a .NET 10
x64 managed implementation, the target is not the visible NMX TCP stream first;
it is the `NmxSvcps.dll` MIDL proxy contract.
The NMX service body passed through `INmxService2.TransferData` is:
```text
46-byte NMX envelope + PutRequest body
```
The inner `PutRequest` length is stored as a little-endian `int32` at envelope
offset `2`. This has been moved into the .NET 10 codec as:
```text
src\MxNativeCodec\NmxTransferEnvelopeTemplate.cs
```
## Proxy/stub inventory
`NmxSvcps.dll` is a 32-bit MIDL proxy/stub DLL. It imports `NdrDllGetClassObject`,
`NdrDllRegisterProxy`, `CStdStubBuffer_*`, and `BSTR_UserMarshal`.
Extracted layout:
```text
analysis\proxy\nmxsvcps-proxy-layout.tsv
analysis\proxy\nmxsvcps-procedures.tsv
analysis\proxy\type-format-snippets\
```
Interfaces in the proxy file:
| Interface | IID | Methods including IUnknown |
| --- | --- | ---: |
| `INmxService2` | `2630A513-A974-4B1A-8025-457A9A7C56B8` | 12 |
| `INmxSvcStatistics` | `6EB90E4C-DF5C-47F0-B2CD-110549C1162A` | 5 |
| `INmxStatus` | `4CA783BC-F68E-42F4-9D76-8107C826F625` | 6 |
| `INmxService` | `575008DB-845D-46C6-A906-F6F8CA86F315` | 10 |
| `INmxNotify` | `73849AEA-472A-4715-B8C6-1C806AF12DFC` | 5 |
| `INmxSvcCallback` | `B49F92F7-C748-4169-8ECA-A0670B012746` | 5 |
## Decoded MIDL procedure formats
The procedure format string in `NmxSvcps.dll` has now been decoded into a
repeatable table:
```text
python analysis\scripts\extract_nmxsvcps_proc_formats.py
```
Important `INmxService2` opnums:
| Method | Opnum | x86 stack | Client buffer | Server buffer | Parameters including HRESULT |
| --- | ---: | ---: | ---: | ---: | ---: |
| `RegisterEngine` | 3 | 20 | 8 | 8 | 4 |
| `UnRegisterEngine` | 4 | 12 | 8 | 8 | 2 |
| `Connect` | 5 | 24 | 32 | 8 | 5 |
| `TransferData` | 6 | 28 | 32 | 8 | 6 |
| `AddSubscriberEngine` | 7 | 24 | 32 | 8 | 5 |
| `RemoveSubscriberEngine` | 8 | 24 | 32 | 8 | 5 |
| `SetHeartbeatSendInterval` | 9 | 16 | 16 | 8 | 3 |
| `RegisterEngine2` | 10 | 24 | 16 | 8 | 5 |
| `GetPartnerVersion` | 11 | 24 | 24 | 36 | 5 |
Callback opnums:
| Method | Opnum | x86 stack | Client buffer | Server buffer | Parameters including HRESULT |
| --- | ---: | ---: | ---: | ---: | ---: |
| `DataReceived` | 3 | 16 | 8 | 8 | 3 |
| `StatusReceived` | 4 | 16 | 8 | 8 | 3 |
The key type-format references are:
| Type-format offset | Meaning from usage |
| --- | --- |
| `0x0006` | `[size_is(dwBufferSize)] byte/sbyte callback buffer` |
| `0x002c` | `BSTR` user-marshaled string |
| `0x0036` | `INmxSvcCallback` interface pointer |
| `0x004c` | `[size_is(lSize)] byte TransferData buffer` |
| `0x005c` | `INmxNotify` interface pointer |
This is enough to specify the managed NDR signatures for the service calls. It
does not remove the need for a managed DCOM/ORPC transport: normal COM still
tries to load the 32-bit proxy DLL before these calls can be made from x64.
The recovered opnum table is also checked into the .NET 10 client scaffold:
```text
src\MxNativeClient\NmxProcedureMetadata.cs
```
## Managed DCE/RPC primitives added
The client scaffold now includes tested managed DCE/RPC binary primitives:
```text
src\MxNativeClient\DceRpcPdu.cs
src\MxNativeClient\DceRpcTcpClient.cs
src\MxNativeClient.Tests\MxNativeClient.Tests.csproj
```
These currently cover:
| Primitive | Status |
| --- | --- |
| common PDU header parse/write | implemented |
| bind / alter-context PDU parse/write | implemented |
| presentation context syntax IDs | implemented |
| request PDU parse | implemented |
| response/fault PDU body parse | implemented |
| authenticated RPC trailer support | pending |
| DCOM/OXID resolution with managed NTLMv2 packet integrity | implemented and live-proven |
| ORPC `this` / `that` without extensions | implemented |
| `MInterfacePointer` wrapper | implemented |
| `STDOBJREF` parse/write | implemented |
| `IRemUnknown::RemQueryInterface` request composer | implemented |
| `IRemUnknown::RemQueryInterface` response parser | partial: `REMQIRESULT` parse implemented, full NDR response wrapper pending |
| ORPC extension arrays | pending |
The tests use real bytes from capture `046` plus the current OBJREF dump. This
keeps the managed transport work tied to observed AVEVA traffic instead of only
to the protocol specification.
The capture parser has also been updated to annotate requests with recovered
presentation-context interface UUIDs:
```text
analysis\scripts\parse_dcerpc_streams.py
captures\046-service-boundary-write-test-int-123456791\dcerpc-stream-pdus.tsv
```
The visible TCP DCE/RPC stream in capture `046` binds contexts
`4E0C90DF-E39D-4164-A421-ACE89484C602` and
`1981974B-6BF7-46CB-9640-0260BBB551BA`. It does not carry the NMX
`TransferData` scalar or exact service body, so it remains a useful DCE/RPC
format source but not the NMX service method channel.
`rpcping` checks on 2026-04-25:
| Probe | Result |
| --- | --- |
| `rpcping -s 127.0.0.1 -t ncacn_ip_tcp -f 2630A513-A974-4B1A-8025-457A9A7C56B8,1` | endpoint mapper returns `1753` / no endpoint |
| `rpcping -s ::1 -t ncacn_ip_tcp -e 49704` | succeeds; this is the visible DCE/RPC stream used as a framing reference |
| `rpcping -s 10.100.0.48 -t ncacn_ip_tcp -e 5026` | connects but fails as RPC with `1727`; this listener is not a normal RPC endpoint |
So the next transport target is not endpoint-mapper lookup by `INmxService2`
IID. It is COM activation/OBJREF acquisition followed by manual ORPC against
the activated object identity.
The ORPC and `IRemUnknown` scaffolding added in this pass is:
```text
src\MxNativeClient\OrpcStructures.cs
src\MxNativeClient\RemUnknownMessages.cs
src\MxNativeClient\ObjectExporterMessages.cs
src\MxNativeClient\ObjectExporterClient.cs
```
Important details:
| Item | Current implementation |
| --- | --- |
| `ORPCTHIS` | encodes COM version, flags, CID, null extension pointer |
| `ORPCTHAT` | parses/encodes flags plus null extension pointer |
| `MInterfacePointer` | wraps/unwraps a byte-counted OBJREF |
| `STDOBJREF` | parses/encodes flags, refs, OXID, OID, IPID |
| `RemQueryInterface` | composes a request body for one target IID, including `INmxService2` |
| DCE/RPC object UUID requests | implemented; required for COM IPID calls |
| `INmxService2` scalar stubs | encodes `GetPartnerVersion`, `Connect`, `SetHeartbeatSendInterval`; parses scalar responses |
| `INmxService2::TransferData` stub | encodes correlated `byte[size]`; live call reaches service and returns application HRESULT |
| `INmxSvcCallback` stubs | parses `DataReceived` / `StatusReceived` callback requests and encodes HRESULT responses |
The first service-specific managed NDR message codec is:
```text
src\MxNativeClient\NmxService2Messages.cs
```
It covers the scalar request/response shape used by the successful reference
`GetPartnerVersion` call. `RegisterEngine2` and `TransferData` are intentionally
left for the next pass because they require interface-pointer marshaling and the
correlated `byte[size]` transfer array.
### ResolveOxid probe
Two probes now reproduce the same result:
```text
analysis\scripts\probe_resolve_oxid.py
src\MxNativeClient\ObjectExporterClient.cs
```
Saved outputs:
```text
analysis\proxy\resolve-oxid-unauth-probe.txt
analysis\proxy\resolve-oxid-managed-unauth-probe.txt
```
The managed probe:
```text
dotnet run --project src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-resolve-oxid-unauth --expect-proxy-load-failure
```
Result:
```text
resolve_oxid_unauth_status=0x00000005
```
The bind and NDR request shape are accepted by RPCSS. The failure is an
application-level `ERROR_ACCESS_DENIED` from `ResolveOxid`, matching the Python
probe and Impacket reference behavior without packet signing/privacy.
Impacket reference probes established the missing auth requirement:
| Auth level | `ResolveOxid` result |
| --- | --- |
| connect | `ERROR_ACCESS_DENIED` |
| packet integrity | succeeds |
| packet privacy | succeeds |
The managed client now implements the required packet-integrity path itself:
```text
src\MxNativeClient\ManagedNtlmClientContext.cs
src\MxNativeClient\DceRpcTcpClient.cs
```
It includes Type1/Type3 generation, NTLMv2 response calculation, session key
exchange, RC4 signing state, and DCE/RPC verifier generation. This was needed
because .NET's high-level `NegotiateAuthentication.Wrap` returns wrapped data,
not the verifier-only NTLM signature shape that DCE/RPC expects for packet
integrity.
The callback request codecs are now represented in:
```text
src\MxNativeClient\NmxSvcCallbackMessages.cs
```
They cover the two service-to-client callback opnums recovered from
`NmxSvcps.dll`: `DataReceived` opnum `3` and `StatusReceived` opnum `4`.
The remaining work is not their byte-array NDR shape; it is exposing an object
endpoint and OBJREF that `NmxSvc.exe` will accept as the `INmxSvcCallback`
argument to `RegisterEngine2`.
The managed client now replicates the object-resolution path through the point
needed for scalar service calls:
1. Obtain a remote-style `IUnknown` OBJREF for `NmxSvc.NmxService`.
2. Resolve its OXID through `IObjectExporter::ResolveOxid` using managed NTLMv2
packet integrity.
3. Bind to the returned OXID endpoint and call
`IRemUnknown::RemQueryInterface` for `INmxService2`.
4. Bind to `INmxService2` and call scalar opnums with the returned object IPID.
5. Implement fully managed activation and the callback object endpoint needed
by `RegisterEngine2`.
## Managed ASB route proof
The mixed-mode `aaMxDataConsumer.dll` route decompiles to managed ASB proxies:
`DataClientProxy.Initialize` passes the namespace/access string to
`IDataProxySelector.SelectProxyForLatestEndpoint`, and the GAC
`ASBIDataV2Adapter.dll` first searches LDS for an `IASBIDataV2` endpoint under
`domainname/<accessName>/global`.
`src\AsbProxyProbe` is a temporary x64 .NET Framework probe that directly uses
those installed MSIL ASB proxy assemblies. It proves that the data-service
route is not inherently x86-bound:
| Probe | Result |
| --- | --- |
| `--access=ZB --connect` | discovers and opens `net.tcp://desktop-6jl3kko/ASBService/Default_ZB_MxDataProvider/IDataV2` |
| `--register --read --tag=TestChildObject.TestInt` | `RegisterItems` and `Read` return `0x00000000`; ASB type `4` decodes as integer value `334` |
| `--write-int=401 --tag=TestChildObject.TestInt` | `Write` returns global success, readback returns `401`, and `PublishWriteComplete` returns the original write handle with per-item success |
The observed write status model is important for the .NET 10 API design:
| Stage | ASB code | Meaning |
| --- | ---: | --- |
| immediate per-item write status | `0x0000001F` | `ArchestrAError.OperationWouldBlock`; accepted asynchronously |
| readback after write | `0x00000000` | value changed to `401` |
| publish completion result | `0x00000020` | `ArchestrAError.PublishComplete` |
| published completion item status | `0x00000000` | final write success for handle `0xA5B20001` |
This route is the highest-confidence basis for a full managed .NET 10 data
client because it avoids `LmxProxy.dll`, `aaMxDataConsumer.dll`, COM
registration, and the 32-bit NMX proxy/stub. The next implementation slice is a
pure .NET 10 port of the ASB discovery, system-auth handshake, net.tcp WCF
contract, `RegisterItems`, `Read`, `Write`, and `PublishWriteComplete` pieces.
That pure .NET 10 port has started under:
```text
src\MxAsbClient
src\MxAsbClient.Probe
```
Current live result:
| Stage | Status |
| --- | --- |
| read ASB solution shared secret from registry/DPAPI | works |
| load registry crypto parameters | works; this VM uses `HashAlgorthim=None`, `keySize=256`, and a 768-bit DH prime |
| net.tcp WCF channel to `IASBIDataV2` | opens from .NET 10 x64 |
| ASB `Connect` + `AuthenticateMe` | works; probe reaches `connect=True` |
| `RegisterItems` | works; retries the observed one-way `AuthenticateMe` startup race and returns item status/id |
| `UnregisterItems` | implemented and matches installed proxy; this provider returns global success with per-item `0x0000000B` |
| `Read` | works for single and multi-item requests; decodes ASB `DataType.TypeInt32` (`4`) and payload to managed `int` |
| `Write` | works; immediate per-item status matches AVEVA proxy `0x0000001F` / `OperationWouldBlock` |
| `PublishWriteComplete` | works; decodes write handle and final per-item success |
The .NET 10 port now proves endpoint discovery input, DPAPI passphrase access,
registry crypto parameters, DH/AES system authentication, WCF net.tcp channel
setup, AVEVA-compatible custom `ASBIData` binary bodies, and the required
`DataContractSerializer` CLR namespaces/field ordering for IDataV2 headers and
write-completion response types. The latest repeat also corrected the normal
data-call signing mode and handles the immediate post-auth `RegisterItems`
race caused by one-way `AuthenticateMe`. The key compatibility fixes were:
- `ConnectionValidator` must serialize as
`http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBContract`, not
the local .NET namespace.
- `ItemIdentity[]`, `ItemStatus[]`, and `RuntimeValue[]` use the `ASBIData`
base64 binary custom serializer.
- `WriteValue`, `Variant`, `ASBStatus`, `ItemWriteComplete`, `ItemStatus`, and
`ItemIdentity` also need AVEVA data-contract field names/order for fallback
XML serialization paths.
- Nested mutable structs must be decoded through initialized locals and then
assigned back; mutating an auto-property copy drops the decoded `Variant` and
`ASBStatus`.
- `RegisterItems` needs a short retry on `0x00000001` immediately after
connect because the server adds the IDataV2 implementation inside the one-way
`AuthenticateMe` handler.
The latest .NET 10 evidence is:
```text
analysis\proxy\mxasbclient-probe-stage21-register-retry.txt
```
It first observed `RegisterItems` message `2` returning `0x00000001`, retried
as message `3`, then received `0x00000000` with item id
`18446462598732840961`. It read previous value `411`, wrote `412`, read back
`412`, and decoded one published completion with result `0x00000020`, handle
`0xA5B21001`, and final status `0x00000000`. The next ASB work is to broaden
the first core data-path proof: multi-item calls, scalar/array type matrix,
subscription/publish, and error/status mapping.
`UnregisterItems` was then added and compared with the installed
`ASBDataV2Proxy`:
```text
analysis\proxy\mxasbclient-probe-stage23-unregister-id.txt
analysis\proxy\asbproxyprobe-unregister-compare.txt
```
Both clients register and read `TestChildObject.TestInt`, then call
`UnregisterItems` with the registered item identity. The global result is
`0x00000000`; the provider returns per-item `0x0000000B` (`OperationFailed`) in
both implementations, so the pure .NET 10 behavior matches the AVEVA proxy for
this endpoint even though item-level cleanup is not reported as success.
Multi-item register/read evidence:
```text
analysis\proxy\mxasbclient-probe-stage24-multi-read.txt
```
The pure .NET 10 client registers and reads `TestChildObject.TestInt` and
`TestMachine_001.TestHistoryValue` in two-item requests. Both operations return
global success and per-item success for both tags; read values decode as ASB
`TypeInt32` previews `412` and `303`.
`RegisterEngine2` request marshaling is now partially implemented and live
verified in .NET 10 x64. The recovered request shape is:
```text
ORPCTHIS
localEngineId:int32
0x72657355:uint32 ; MIDL user-marshal marker before BSTR
BSTR_UserMarshal(engineName)
padding
version:int32
callback interface pointer
```
The BSTR user-marshal payload is:
```text
char_count:uint32
byte_length:uint32
char_count:uint32
utf16_payload_without_null
```
The null callback form uses a single `uint32` zero. The managed probe:
```text
analysis\proxy\managed-registerengine2-null-callback-probe.txt
```
returns:
```text
managed_register2_null_hresult=0x00000001
managed_unregister_after_register_hresult=0x00000001
```
That is a non-failing COM success code returned by `NmxSvc.exe`, proving that
the managed NDR stub is accepted for `RegisterEngine2` when no callback object
is supplied.
The non-null callback form wraps an OBJREF as:
```text
0x00020000:uint32
objref_size:uint32
objref_size:uint32
objref_bytes
```
The remaining `RegisterEngine2` work is to generate an OBJREF for a managed
`INmxSvcCallback` endpoint and implement the DCE/RPC server side that handles
`IRemUnknown`, `DataReceived`, and `StatusReceived`.
Two callback OBJREF probes refine that work:
```text
analysis\proxy\managed-registerengine2-callback-fixed-port-probe.txt
analysis\proxy\managed-registerengine2-callback-com-iunknown-objref-probe.txt
analysis\proxy\managed-registerengine2-callback-com-iunknown-self-transfer-probe.txt
```
A fully synthetic OBJREF with a managed TCP listener is rejected before any
inbound connection reaches the listener. With default security bindings it
fails as `0x800706BA`, and TCP polling confirms there is no SYN to the
advertised callback port. This implies a standard OBJREF needs a real
RPCSS/OXID resolver registration, not just a reachable TCP endpoint.
A COM-runtime-registered x64 `IUnknown` OBJREF, patched only to use the
`INmxSvcCallback` IID, is accepted by `NmxSvc.exe`:
```text
managed_register2_callback_hresult=0x00000000
managed_unregister_after_callback_register_hresult=0x00000000
managed_callback_self_transfer_hresult=0x00000000
```
No callback event was delivered in that probe, so this is a registration and
OBJREF-resolution proof, not a complete callback dispatch solution.
The x64 callback marshal problem can now be solved with Windows standard
type-library marshaling rather than an AVEVA x64 proxy/stub:
```text
analysis\scripts\register_x64_callback_typelib.ps1
analysis\proxy\callback-marshal-after-typelib-probe.txt
analysis\proxy\managed-registerengine2-callback-com-real-probe.txt
```
After registering `INmxSvcCallback` with the standard automation proxy/stub,
`CoMarshalInterface(INmxSvcCallback)` succeeds in the .NET 10 x64 probe and
`NmxSvc.exe` accepts the real non-null callback OBJREF. The matching x86 direct
harness also receives no callback from the synthetic self-transfer route:
```text
analysis\proxy\x86-registerengine2-self-transfer-callback-probe.txt
```
That moves the next blocker from COM callback marshaling to the NMX session
message bodies that cause `CNmxControler.LocalCallbackDataReceived` to invoke
`INmxSvcCallback.DataReceived` / `StatusReceived`.
## Subscription frame capture
Focused capture:
```text
captures\058-frida-subscribe-testint
```
This run used the x86 `MxTraceHarness` against `TestChildObject.TestInt` and
captured the native `NmxAdptr.dll` boundary around `AdviseSupervisory` and
cleanup.
Observed outgoing bodies:
| Stage | Size | Command | Meaning |
| --- | ---: | ---: | --- |
| `CNmxAdapter.PutRequest` | 314 | `0x17` | metadata query for `DevPlatform.GR.TimeOfLastDeploy` and `DevPlatform.GR.TimeOfLastConfigChange` |
| `CNmxAdapter.TransferData` | 360 | `0x17` | 46-byte service envelope plus the 314-byte metadata query |
| `CNmxAdapter.PutRequest` | 39 | `0x1f` | advise/supervisory item-control request |
| `CNmxAdapter.TransferData` | 85 | `0x1f` | 46-byte service envelope plus the 39-byte advise request |
| `CNmxAdapter.PutRequest` | 37 | `0x21` | unadvise item-control request |
| `CNmxAdapter.TransferData` | 83 | `0x21` | 46-byte service envelope plus the 37-byte unadvise request |
The advise/unadvise control bodies share this observed shape:
```text
command:uint8
version:uint16
itemCorrelationGuid:Guid
item/type/status suffix bytes
```
The command bytes identified so far are:
| Command | Current name |
| ---: | --- |
| `0x17` | metadata query |
| `0x1f` | advise/supervisory |
| `0x21` | unadvise |
| `0x32` | subscription/status callback body |
| `0x33` | data/update callback body |
| `0x37` | write |
Incoming `CNmxAdapter.ProcessDataReceived` buffers are not exactly the same as
client-side `TransferData` buffers: they start with a 4-byte total-length
prefix, then the 46-byte NMX service header. In this form, the declared inner
length in the service header is four bytes larger than the actual trailing
inner body. Other callback captures, including the `Write2` data update, start
directly with the 46-byte header and use the declared inner length as-is. The
.NET 10 codec now models both variants:
```text
src\MxNativeCodec\NmxObservedFrame.cs
src\MxNativeCodec\NmxSubscriptionMessage.cs
src\MxNativeCodec.Tests\Program.cs
```
The current codec can classify the capture `058` advise body, unwrapped
subscription status body, and metadata query strings. It also decodes typed
`0x32` subscription status records and `0x33` data update records from the
observed callback captures, including operation/correlation GUIDs, status,
detail status, quality `0x00c0`, FILETIME timestamps, wire kinds, and observed
scalar bool, int, float, double, string values, numeric arrays, boolean arrays,
and datetime arrays. Multi-record status bodies with wire kind `0x06` use a
length-prefixed FILETIME payload and now parse without losing record alignment.
The observed 5-byte operation-status callback body `00 00 50 80 00` is decoded
as status code `0x8050` and mapped to `MxStatus.WriteCompleteOk`, matching the
write-complete success path seen before the data-update callback.
Future captures can now be fed into the codec and compared by callback record
fields instead of by raw hex. The generated envelope encoder also reproduces
the captured 85-byte advise body and the 83-byte unadvise body exactly:
```text
src\MxNativeCodec\NmxTransferEnvelope.cs
src\MxNativeCodec.Tests\Program.cs
```
Follow-up captures:
```text
captures\059-frida-subscribe-testbool
captures\060-frida-subscribe-teststring
```
confirm the 37/39-byte item-control body layout:
| Offset | Size | Field | Status |
| ---: | ---: | --- | --- |
| `0x00` | 1 | command (`0x1f` advise, `0x21` unadvise) | decoded |
| `0x01` | 2 | version (`0x0001`) | decoded |
| `0x03` | 16 | item correlation GUID | generated per `AddItem` |
| `0x13` | 2 | advise-only zero padding | present for `0x1f` only |
| variable | 2 | object id | `MxHandle` byte offset 6 |
| variable | 2 | object signature | CRC-16/IBM over lowercase UTF-16LE object name |
| variable | 2 | primitive id | matches GR `mx_primitive_id` |
| variable | 2 | attribute id | matches GR `mx_attribute_id` |
| variable | 2 | value property id | observed as `10` for value references; do not use GR `mx_attribute_category` here |
| variable | 2 | attribute signature | CRC-16/IBM over lowercase UTF-16LE attribute name |
| variable | 2 | attribute index | `0` for scalar, `-1` for arrays |
| variable | 4 | tail (`0x00000003`) | observed stable |
For `TestChildObject`:
| Attribute | GR `mx_attribute_id` | Captured runtime code |
| --- | ---: | ---: |
| `TestBool` | `154` | `0x00007dfa` |
| `TestInt` | `155` | `0x0000da3e` |
| `TestString` | `158` | `0x0000941a` |
The item-control codec is now checked in:
```text
src\MxNativeCodec\NmxItemControlMessage.cs
```
Capture `061` adds the missing LMX-side mapping:
```text
captures\061-frida-lmx-resolve-testint
analysis\frida\mx-nmx-trace.js
analysis\ghidra\exports\Lmx.dll.prebound-decompile.md
analysis\ghidra\exports\Lmx.dll.prebound-helpers-decompile.md
```
The Ghidra helper pass identifies `FUN_1008f8b0` as
`AccessManager::FixUpMxHandle` and `FUN_1005f730` as the `IMxReference` handle
reader. The live hook shows that the resolved `TestChildObject.TestInt` handle
is this 20-byte value:
```text
01 00 01 00 02 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00
```
The LMX typelib imported from `Lmx.dll` identifies this as a structured
`MxHandle`: `MxAutomationObjectHandle` followed by `MxAttributeHandle`.
| Offset | Field | Value for `TestChildObject.TestInt` | Source |
| ---: | --- | ---: | --- |
| 0 | `galaxy` | `1` | local runtime convention |
| 2 | `platform` | `1` | `ZB.dbo.instance.mx_platform_id` |
| 4 | `engine` | `2` | `ZB.dbo.instance.mx_engine_id` |
| 6 | `object` | `5` | `ZB.dbo.instance.mx_object_id` |
| 8 | object signature | `0xd736` | CRC-16/IBM over lowercase UTF-16LE `testchildobject` |
| 10 | primitive id | `2` | `dynamic_attribute.mx_primitive_id` / `attribute_definition` path |
| 12 | attribute id | `155` | `dynamic_attribute.mx_attribute_id` |
| 14 | value property id | `10` | fixed runtime value-property id |
| 16 | attribute signature | `0xda3e` | CRC-16/IBM over lowercase UTF-16LE `testint` |
| 18 | attribute index | `0` | scalar index; arrays use `-1` |
The 37/39-byte NMX item-control body is a projection of bytes 6 through 19 of
this handle, followed by tail `0x00000003`. The managed representation and
signature synthesizer are checked in as:
```text
src\MxNativeCodec\MxReferenceHandle.cs
```
The GR correlation was obtained from the local `ZB` database using the query
notes in:
```text
C:\Users\dohertj2\Desktop\lmxopcua\gr
```
That GR path is now executable managed code:
```text
src\MxNativeClient\GalaxyRepositoryTagResolver.cs
src\MxNativeClient.Tests\Program.cs
```
The live test resolves `TestChildObject.TestInt` from SQL Server and asserts
that `GalaxyTagMetadata.ToReferenceHandle()` encodes the exact captured LMX
handle:
```text
01 00 01 00 02 00 05 00 36 d7 02 00 9b 00 0a 00 3e da 00 00
```
The static path through `LmxProxy.dll` is now decompiled into:
```text
analysis\ghidra\exports\LmxProxy.dll.selected-decompile.md
analysis\ghidra\exports\LmxProxy.dll.item-helper-decompile.md
analysis\ghidra\exports\LmxProxy.dll.item-record-decompile.md
```
The decompile shows:
- `AddItem` invokes a lower resolver through the per-server object and stores a
resolved item record keyed by local item handle.
- `AdviseSupervisory` verifies the server/item handle, calls the resolver helper
if the item has not been advised, then passes fields from the resolved item
record into the NMX callback path.
- `UnAdvise` validates an advised item and calls back through the stored
connection object, which matches the shared correlation GUID observed in the
advise/unadvise bodies.
The direct handle synthesis problem is now solved for normal deployed object
attributes: combine `instance` platform/engine/object ids, deployed package
attribute metadata, and CRC-16/IBM name signatures. This removes the previous
need to call the x86 `IMxReference` resolver for the captured dynamic scalar and
array attributes.
## Managed service client scaffold
The live managed DCOM path has been moved from `MxNativeClient.Probe` into a
reusable client:
```text
src\MxNativeClient\ManagedNmxService2Client.cs
```
It performs:
- local activation of `NmxSvc.NmxService` only to obtain an `IUnknown` OBJREF,
- managed NTLMv2 packet-integrity `ResolveOxid`,
- managed `IRemUnknown::RemQueryInterface` for `INmxService2`,
- packet-integrity calls for `GetPartnerVersion`, `RegisterEngine2`,
`UnRegisterEngine`, `Connect`, subscriber engine calls, and `TransferData`.
- generated `AdviseSupervisory` and `UnAdvise` TransferData bodies from
repository-resolved tag metadata.
- generated normal `Write` TransferData bodies from repository-resolved tag
metadata and GR value type.
- generated timestamped `Write2` TransferData bodies using FILETIME timestamps.
Verification on this node:
```text
dotnet run --project src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-remqi-managed --objref-only
```
returned `managed_getpartner_version=6`,
`managed_getpartner_hresult=0x00000000`, and
`remqi_managed_error=0x00000000`. The managed NTLM credentials are supplied by
environment variables at runtime and are not stored in the project.
The probe also contains a guarded `--probe-managed-subscribe` path. With SSPI
auth it currently faults at `ResolveOxid` with `0x00000721`, so the subscription
probe must use the managed NTLM environment path to be a valid end-to-end test.
No credential material is stored in this repository.
## Library layers
Planned managed library shape:
| Layer | Project | Status |
| --- | --- | --- |
| Message body codec | `MxNativeCodec` | generated normal and timestamped scalar/array write bodies, generated TransferData envelope, item-control codec, typed observed subscription/data-update callback records for scalar values, scalar arrays, datetime arrays, and multi-record date/status payloads, 5-byte operation-status/write-complete callback mapping, and `MxReferenceHandle` projection |
| COM contract definitions | `MxNativeClient` | scaffolded; proves x64 native proxy blocker |
| Managed NDR/DCOM proxy | `MxNativeClient` | reusable `ManagedNmxService2Client` with DCE/RPC client, NTLMv2 packet integrity, OXID resolve, RemQI, scalar calls, `TransferData`, generated write/Write2/subscribe/unsubscribe bodies, null-callback `RegisterEngine2`, and real x64 `INmxSvcCallback` OBJREF registration working after type-library setup |
| Managed ASB/IDataV2 route | Planned .NET 10 client slice | new x64 evidence shows the installed MSIL ASB proxies can discover and connect to `IASBIDataV2` for access name `ZB` without MXAccess or COM; next step is porting the required WCF contracts/proxy/authentication path into .NET 10 managed code |
| Galaxy metadata resolver | `MxNativeClient` | live-tested SQL resolver and browse query for deployed dynamic and primitive attributes |
| High-level API | `MxNativeClient` | first `MxNativeSession` facade for open/register, GR resolve/browse, transient subscription read, write, timestamped write, subscribe/unsubscribe, typed callback events, and operation-status events |
| MXAccess compatibility layer | `MxNativeClient` | `MxNativeCompatibilityServer` maps integer server/item handles onto the managed session for register, add/remove item, advise/unadvise, write, timestamped write, data-change events, write-complete operation-status events, observed `ArchestrAUserToId` behavior, invalid-reference behavior, stale item-handle `0x80070057` errors after `RemoveItem`, invalid server-handle errors, and cross-server item-handle errors; malformed/incomplete decoded callback values are not promoted to public `DataChanged` events, and the observed empty `InternationalizedString` callback is suppressed to match x86 MXAccess public-event behavior |
## Current gaps versus MXAccess
This is the explicit parity plan against the public `ILMXProxyServer5` surface
documented in `docs\MXAccess-Public-API.md`. Status values:
- `Covered`: implemented in managed code and covered by captures/tests or live
low-level probes.
- `Partial`: core mechanism exists, but behavior is not yet equivalent to
MXAccess or lacks live facade validation.
- `Missing`: not yet implemented or not yet decoded.
| MXAccess surface | Current managed status | Gap / next work |
| --- | --- | --- |
| `Register` / `Unregister` | Partially live validated | `ManagedNmxService2Client.RegisterEngine2` and `UnregisterEngine` work from .NET 10 x64 with managed NTLM. `MxNativeSession.Open` wraps them with managed callbacks; `MxNativeCompatibilityServer.Register/Unregister` expose integer server handles. Fixed local engine IDs caused stale registration collisions during repeated probes, so defaults now derive the local engine ID from the process ID. Invalid `Unregister` server handles now return `ArgumentException` `0x80070057`, matching capture `110`. Still needs production reconnect/heartbeat policy and stable diagnostic mapping for nonzero NMX statuses. |
| `AddItem` | Partial / value-handle, buffer-property, invalid-reference, and mixed-session paths corrected | `MxNativeCompatibilityServer.AddItem` exposes integer item handles backed by GR metadata resolution. Fixed captures `104`/`105` showed the native value handle must use property id `10`; using GR `mx_attribute_category` caused managed `ShortDesc` to resolve to property `11` and receive only a status/type marker. After correcting the resolver, live x64 `SubscribeAsync(TestChildObject.ShortDesc)` receives the value-bearing `0x05` callback. Captures `085`/`086` show literal `TestChildObject.TestInt.property(buffer)` resolves to the base `TestInt` handle with property id `0x32`; the resolver now recognizes that suffix, and the x64 compatibility subscribe/write probe matches native public behavior with no public data-change or write-complete. For invalid references, the MXAccess-compatible facade now mirrors captures `007`/`015`: `AddItem` returns a handle and `AdviseSupervisory` raises a public null data-change with quality `0` and `ConfigurationError/RequestingLmx/detail 6`; strict `MxNativeSession.ResolveTagAsync` still fails fast for native managed callers. A mixed-session probe with `ShortDesc`, `TestInt`, and an invalid reference validated handle routing: only the invalid item produced a public event on the current VM state. Remaining AddItem work is broader parity: property references other than value/buffer and more error classes. |
| `RemoveItem` | Covered for observed scalar lifecycle | `MxNativeCompatibilityServer.RemoveItem` removes the item handle and unsubscribes if needed. Capture `109-native-post-remove-errors` shows native x86 MXAccess returns `ArgumentException` `0x80070057` for `Advise`, `AdviseSupervisory`, `UnAdvise`, `Write`, `Write2`, `Suspend`, `Activate`, and a second `RemoveItem` after the item handle has been removed; the .NET 10 x64 `--probe-compatibility-post-remove` probe now reports the same HRESULT for every operation. Capture `110-native-invalid-handle-errors` adds invalid server and cross-server item-handle coverage; the x64 compatibility probe matches those `0x80070057` results too. |
| `Advise` | Covered for observed scalar path / needs live facade validation | X86 MXAccess API capture `mxaccess-plain-advise-testint.log` confirms public `Advise` succeeds for `TestChildObject.TestInt`. Frida capture `099-frida-plain-advise-testint` shows plain `Advise` emits the same item-control `0x1f` body as the earlier `AdviseSupervisory` subscription capture for this scalar tag. The wrapper routes both public methods through the same managed subscription path; broader item classes still need live validation. |
| `AdviseSupervisory` | Transport live validated / initial value proven for observed string-like path | Item-control body and transfer envelope are generated, typed callback decoding is implemented, and the compatibility wrapper exposes handle-based `AdviseSupervisory`. Fixed captures `104`/`105` showed native `0x1f` advise uses transfer kind `ItemControl` (`2`) and value-property id `10` even when GR `mx_attribute_category` differs. After matching those fields, live .NET 10 x64 `SubscribeAsync(TestChildObject.ShortDesc)` receives the native-equivalent value callback: wire kind `0x05`, empty string value, quality `0x00C0`. Fresh x86 MXAccess capture `106-native-subscribe-testint-current` also raises no public `TestInt` data-change on the current VM state, so the managed status-only `TestInt` result is current-native-equivalent even though older captures show that tag can emit values in other runtime states. |
| `UnAdvise` | Partially live validated | Generated body and transfer kind match captures; with unique local engine IDs, live x64 subscribe probes clean up without the earlier `0x80041101` stale-registration failure. Needs broader facade validation across multiple items and failure paths. |
| `Write` | Transport live validated / current public facade parity | Normal scalar and array write bodies match captures, including secured/verified public tags when using normal route. `MxNativeSession.WriteAsync` reaches `INmxService2.TransferData` from .NET 10 x64, and the managed client now has a regression test that normal `0x37` writes use native transfer kind `Write` (`3`) rather than item-control kind `2`. The generated `TestChildObject.ShortDesc` `"hello-native"` body matches capture `096` byte-for-byte; the live completion-only `0xef` for `ShortDesc` also matches native behavior for this `InternationalizedString` caller path. `TestChildObject.TestInt` writes return completion-only `0x00` and a status-only `0x33` callback with no scalar payload; fresh x86 MXAccess capture `107-native-write-testint-current` also raises no public data-change after a successful write on the current VM state. The .NET 10 x64 compatibility subscribe-write probe now matches current public behavior for both `TestInt` and `ShortDesc`: no public data-change and no public write-complete event from completion-only NMX statuses. A write to invalid handle `NoSuchObject_999.NoSuchAttr` returns through the compatibility facade without adding a write-complete event, matching capture `090`. Literal `property(buffer)` writes return without surfacing a write-complete, matching capture `086`. The compatibility facade now also rejects normal writes before advise and normal writes against buffered handles with `0x80070057`, matching captures `008`/`009` and the buffered-write public capture. Older value-bearing `TestInt` captures remain useful golden fixtures for decoder coverage and future runtime-state testing. |
| `Write2` | Covered for observed scalar and array types | Timestamped int, bool, float, double, string, int[], bool[], float[], double[], string[], and datetime[] `Write2` bodies match captures `042` and `066`-`076`. Timestamped bool differs from normal bool writes: it uses a one-byte bool payload followed by the normal timestamp suffix. Datetime[] outbound still uses per-element display strings, matching normal datetime-array write behavior. Reflection over `ArchestrA.MXAccess.dll` shows the public timestamp argument is `object`/VARIANT, so `MxNativeCompatibilityServer` now exposes both typed `DateTime` and object timestamp overloads. |
| `WriteSecured` | Native path appears intentionally blocked for observed secured/verified bool tags / managed stub | Captures `036`, `038`, `039`, `111`, and `112` show `CLMXProxyServer.WriteSecured` returning `0x80004021` before any value-bearing NMX body is emitted, including after `AuthenticateUser` succeeds. Headless decompile in `analysis\ghidra\exports\LmxProxy.dll.write-secured-decompile.md` shows `WriteSecured` has an extra item-record byte check at offset `0x0f` that returns `0x80004021` when set; the deployed secured/verified bool tags hit that branch. `MxNativeSession.WriteSecuredAsync` throws `NotSupportedException` to avoid inventing a body not emitted by native MXAccess. Normal `Write` and boolean `WriteSecured2` cover the successful observed write paths. |
| `WriteSecured2` | Implemented for observed timestamped secured2 body | Captures `113`-`116` show authenticated `WriteSecured2` emitting NMX command `0x38` over transfer kind `Write` (`3`) for boolean secured/verified tags, and capture `117` shows the same command for authenticated integer `TestChildObject.TestInt`. `NmxSecuredWrite2Message` now reuses the normal timestamped `0x37` value/timestamp prefix, changes the command to `0x38`, and appends current-user authenticator token, client name, verifier token, `0xffff`, client token, and write index. Live .NET 10 x64 session probes succeeded for bool, int, float, double, string, datetime, and the matching scalar-array value kinds using the generic encoder. Handle-based compatibility probes succeeded for the secured/verified bool public cases. The compatibility facade exposes typed `DateTime` and object timestamp overloads and does not synthesize `OnWriteComplete`, matching the successful native captures where no public write-complete event was observed. |
| `AuthenticateUser` | Covered for current dev-node behavior / security-enabled auth still open | `captures\mxaccess-authenticate-user-administrator-empty.log` plus Frida captures `087`/`088` show `AuthenticateUser` returning `S_OK` and user ID `1` for both `Administrator` and `DefinitelyNotAUser` with empty password on this dev node. Ghidra shows the public return is a session-local user handle mapped to an authenticator token GUID, not a GR `user_profile_id`. `MxNativeCompatibilityServer.AuthenticateUser` now validates the server handle and returns a session-local handle without storing or comparing password material. A security-enabled Galaxy still needs successful and failed captures before implementing password/hash verification. |
| `ArchestrAUserToId` | Covered for current node behavior | Direct x86 MXAccess probes show Administrator, SystemEngineer, DefaultUser, and an invalid zero GUID all return `1`; `MxNativeCompatibilityServer.ArchestrAUserToId` mirrors that observed behavior after GUID validation. `GalaxyRepositoryUserResolver` separately exposes GR profile metadata: Administrator GUID `9222FBBA-53F4-457E-8B37-C93A9A250B4A` maps to `user_profile_id` `2` and roles `Administrator`/`Default`. |
| `AddItem2` | Partial / context fallback covered | `MxNativeCompatibilityServer.AddItem2` now tries the item definition directly, then retries with the supplied context when the direct reference is missing or syntactically relative. X86 MXAccess capture `mxaccess-additem2-testint-context.log` confirms `AddItem2("TestInt", "TestChildObject")` succeeds; live x64 probes also cover `AddItem2("Alarm.TimeDeadband", "TestMachine_001.TestAlarm001")` and `AddItem2("TestInt.property(buffer)", "TestChildObject")`. More complex `strItemCtxt` semantics still need native captures. |
| `Suspend` | API-compatible local status behavior covered | X86 MXAccess `Suspend` throws `0x80070057` when called before advise, including on `TestChildObject.ScanState` and `DevAppEngine.ScanState`. After `AdviseSupervisory`, both scan-state targets return `Success=-1`, `Category=Pending`, `Source=RequestingLmx`, `Detail=0`. Frida captures `077`/`078` show no distinct NMX suspend/activate body after the advised call. Ghidra decompile of `FUN_10013d9c` shows the public method queries an `IMxScanOnDemand` interface and synchronously calls vtable offset `0x0c`; it does not call the `OperationComplete` event helper in this path. `MxNativeCompatibilityServer.Suspend` mirrors this as local status behavior and requires an advised item. |
| `Activate` | API-compatible local status behavior covered | X86 MXAccess `Activate` throws `0x80070057` when called before advise. After `AdviseSupervisory`, scan-state targets return `Success=-1`, `Category=Ok`, `Source=RequestingLmx`, `Detail=0`. Ghidra decompile of `FUN_10014028` mirrors `Suspend` and synchronously calls `IMxScanOnDemand` vtable offset `0x10`, with no public `OperationComplete` event observed. `MxNativeCompatibilityServer.Activate` mirrors this observed local behavior and requires an advised item. |
| `AddBufferedItem` | Partial implementation | X86 MXAccess `AddBufferedItem("TestInt", "TestChildObject")` succeeds and returns handle `1`; normal `Write` against that buffered handle throws `0x80070057`, now mirrored by `MxNativeCompatibilityServer`. Frida captures `079`/`080` show that advising a buffered item sends an item-control `0x10` reference-registration body for `TestInt.property(buffer)` in context `TestChildObject`, not a normal `0x1f` advise body. Ghidra confirms `AddBufferedItem` appends `.property(buffer)`, calls the normal add-item implementation, then marks the item record as buffered. Direct literal probes `085`/`086` show `TestChildObject.TestInt.property(buffer)` uses the normal path with empty context and returns an internal-error registration result, so literal suffixing is not a replacement for `AddBufferedItem`. Capture `094` reran buffered external writes using a separate MXAccess writer registration; captures `121`/`122` repeated the same approach against GR-confirmed historized integer attribute `TestMachine_001.TestHistoryValue` using both `AdviseSupervisory` and `Advise`. The context-bearing buffered `0x10`/`0x11` registration/result bodies and writer data callbacks were observed, but `Fire_OnBufferedDataChange` still was not entered. `NmxReferenceRegistrationMessage`, `NmxReferenceRegistrationResultMessage`, `MxNativeSession.RegisterBufferedItemAsync`, and `MxNativeCompatibilityServer.AddBufferedItem` now encode the request and decode the `0x11` registration result, including context-bearing buffered references. No `OnBufferedDataChange` payload has been captured, so callback parity is still missing. |
| `SetBufferedUpdateInterval` | Local/API parity covered | X86 MXAccess `SetBufferedUpdateInterval(session, 1000)` succeeds and no distinct NMX request body has been observed for it. Ghidra confirms invalid intervals below `1` return `E_INVALIDARG` and valid values are rounded up to 100 ms ticks as `(milliseconds + 99) / 100`. `MxNativeCompatibilityServer.SetBufferedUpdateInterval` mirrors that local state behavior. Need a buffered callback capture to prove whether the interval changes downstream delivery cadence. |
| `OnDataChange` | Partial / current-runtime parity improving | `NmxSubscriptionMessage` decodes observed scalar, scalar-array, datetime-array, quality, timestamp, and correlation fields. Captures `062`, `102`, and `105` cover compact empty `InternationalizedString`/string callbacks. The low-level session exposes the decoded empty `ShortDesc` value, but `MxNativeCompatibilityServer` suppresses empty `InternationalizedString` promotion because fresh native x86 public-event capture `108` also raises no `OnDataChange` for `ShortDesc`. Captures `063` and `103` cover `ElapsedTime` wire kind `0x07` as both `TimeSpan.Zero` and a non-zero 4-byte millisecond count. The managed decoder now tolerates a captured two-record metadata status frame where the second datetime value is truncated/unknown and reports that value as `null` instead of throwing. Captures `100` and `101` show string-array callback records with wire kind `0x45`, but both observed buffers stop inside the final element and MXAccess did not raise a public data-change event; the managed decoder therefore treats those callback values as incomplete. Live x64 callback parity is proven at the low-level callback layer for `TestChildObject.ShortDesc` after correcting the advise transfer kind and value-property id. Fresh current-state x86 captures `106`/`107` show `TestInt` also has no public data-change in native MXAccess now, while older captures still provide value-bearing fixtures (`003`, `011`, `012`, `017`, `018`) for decoder and future runtime-state testing. |
| `OnWriteComplete` | Partial / completion-only suppression and mixed-session routing validated | The observed successful non-length-prefixed 5-byte operation-status frame decodes to status word `0x8050` plus completion byte `0x00` and maps to `MxStatus.WriteCompleteOk`; `MxNativeSession.OperationStatusReceived` exposes it and `MxNativeCompatibilityServer.WriteCompleted` now fires only for this MXAccess-visible form. Pending write handles are tracked per server session to avoid cross-session callback mis-correlation. Captures `089`, `092`, and `093` show wrong-type string writes emitting a length-prefixed completion-only status body with completion byte `0x41`, and MXAccess does not raise `OnWriteComplete` for those frames. Capture `091` shows double-to-int emits completion-only byte `0x00` and also does not raise `OnWriteComplete`. Capture `096` and the matching managed x64 `ShortDesc` probe show an `InternationalizedString` caller write can emit completion-only byte `0xef`; the .NET 10 x64 compatibility subscribe-write probe suppresses public `WriteCompleted` for both `TestInt` completion-only `0x00` and `ShortDesc` completion-only `0xef`, matching fresh native public-event behavior. A mixed x64 compatibility write probe with normal int, `ShortDesc`, literal `property(buffer)`, and invalid-reference items confirms no public write-complete or data-change is misattributed across item handles; only the invalid reference emits its expected data-change. `NmxOperationStatusMessage` preserves both 5-byte status-word and 1-byte completion-only forms. Need more failure captures to map completion bytes to exact `MXSTATUS_PROXY[]` values and a runtime state that emits multiple public 5-byte write-complete events. |
| `OperationComplete` | Event shape/source decoded / public trigger still missing | Ghidra confirms `Fire_OperationComplete` uses the same three-argument event shape as `OnWriteComplete` but dispatches event ID `3`. Headless xref/decompile output shows `Fire_OnWriteComplete` is called from `CUserConnectionCallback::OnSetAttributeResult`, while `Fire_OperationComplete` is called from `CUserConnectionCallback::OperationComplete`; they are distinct callback paths. The decompiled interop assemblies show `IMxCallback2.OperationComplete(int lCallbackId, ref MxStatus, string)` and an `IDataConsumer.ActivateSuspend` / `ProcessActivateSuspend2` path that returns `ItemActiveResponse`, making DataConsumer activate/suspend processing the best trigger candidate. Captures `118`/`119` added direct Frida hooks on both callback entry points during 20 second advised scan-state suspend/activate runs through public MXAccess `Suspend`/`Activate`; the hooks installed, but neither callback was entered. Those public methods decompile to direct `IMxScanOnDemand` vtable calls, not the DataConsumer `ProcessActivateSuspend2` path. `aaMxDataConsumer.dll` is now imported/decompiled and `MxDataConsumerProbe` can instantiate `DataConsumerClass`, resolve namespace strings to ID `1`, and call `ActivateSuspend`; however `IsConnected(namespaceId)` remains `0`, registration/subscription queues stay empty, and `ProcessActivateSuspend2` currently returns `0x8007139F` (`ERROR_INVALID_STATE`). The mixed-mode `aaMxDataConsumer` decompile shows it should create a managed `DataClientProxy` and select `ASBDataV2Proxy` via `IDataProxySelector`, but forcing namespace `ZB` hung in the COM wrapper. Direct x64 managed ASB probing bypasses that wrapper: `ASBIDataV2Adapter.IDataProxySelector` finds the live `IASBIDataV2` endpoint for access name `ZB`, and `ASBDataV2Proxy.Connect` succeeds. No harness capture has emitted `mx.event.operation-complete`, and the compatibility wrapper intentionally does not synthesize it from write status frames. Next trigger search should use the direct ASB IDataV2 path, not the unstable standalone COM DataConsumer object. |
| `OnBufferedDataChange` | Event source decoded / live payload missing | Harness subscribes to the event for buffered scenarios, and buffered registration is now decoded, but no live buffered data-change payload has been observed. Capture `094` used a separate writer session and longer wait window against `TestChildObject.TestInt`; captures `121` and `122` used a GR-confirmed historized integer attribute, `TestMachine_001.TestHistoryValue`, and tested both supervisory and plain advise. All three produced valid buffered registration/result bodies and writer-side normal data callbacks, but no public buffered event and no `Fire_OnBufferedDataChange` entry. Ghidra xrefs show `Fire_OnBufferedDataChange` is reached from the same native `OnDataChange callback received` method as normal data changes, with the item record buffered flag selecting the buffered event path. Ghidra also shows the public buffered event emits value, quality, and timestamp SAFEARRAY variants plus the normal `MXSTATUS_PROXY[]` status array. `MxNativeCompatibilityServer.BufferedDataChanged` now keeps parsed buffered callbacks separate from normal `DataChanged`; true multi-sample decode still needs a runtime/source condition that emits the native buffered callback. Candidates include a runtime/historian setting outside plain `SetBufferedUpdateInterval`, object deployment state, or a source that emits native buffered sample batches rather than ordinary write callbacks. |
| `MxStatus` / `MXSTATUS_PROXY` mapping | Partial / detail catalog broadened | Reflection over `ArchestrA.MXAccess.dll` confirms the managed enum numeric values for `MxStatusCategory`, `MxStatusSource`, and `MxDataType`. Basic status/detail/quality fields are decoded from callbacks. `MxStatus.DataChangeOk` and `MxStatus.WriteCompleteOk` are modeled from observed success paths, and `MxStatus.DetailText` now includes the installed English `Lmx.aaDCT` detail catalog entries for details `16`-`61`, `541`, `542`, and `8017`. Nonzero completion-only operation statuses are still preserved rather than guessed, starting with wrong-type write completion `0x41` from captures `089`, `092`, and `093`; the exact MXAccess status category/source/detail mapping for completion-only bytes still needs a native public event or decompiled mapping source. |
| `MxDataType` coverage | Partial | Core bool/int/float/double/string/time and observed array forms are covered for writes and callbacks. Live GR type inventory found only two extra deployed/configured types on this node: `ElapsedTime` (`286` attributes) and `InternationalizedString` (`70` attributes). Callback decode support covers observed zero/empty forms. Captures `095` and `096` show MXAccess writing `ElapsedTime` with an `Int32` caller value as integer wire kind `0x02`, and writing `InternationalizedString` with a string caller value as normal string wire kind `0x05`; `GalaxyTagMetadata.ProjectWriteValue` now mirrors those value-based projections while `TryGetValueKind` remains false for broad type classification. Capture `098` confirms the x86 MXAccess COM automation path can project a requested `SAFEARRAY VT_BOOL` differently than the caller's original .NET `bool[]`; the managed encoder uses direct per-element 16-bit VARIANT_BOOL encoding for native .NET callers and preserves the x86 projection only as a golden compatibility case. `ReferenceType`, `StatusType`, enums, qualified structs, and big string are not present in the current GR inventory and remain unsupported. |
| Direct `Read` | Partial | `MxNativeSession.ReadAsync` is implemented as transient subscribe/read/unsubscribe. No distinct direct NMX read body has been decoded. Determine if MXAccess has a direct read path or only returns initial subscription values. |
| Browse / resolve | Partial | GR-backed browse exists and avoids x86 LMX. `ResolveAsync` now accepts `Object.Attribute`, `Object.Primitive.Attribute`, and dotted primitive attribute references such as `TestMachine_001.TestAlarm001.Alarm.TimeDeadband`, matching observed MXAccess AddItem behavior. It is still not a full MXAccess browsing clone because MXAccess exposes add-item resolution rather than a public browse method; browse needs filtering/shape hardening for OPC UA needs. |
| Connection lifecycle | Partial / explicit recovery live validated | Basic activate/connect/register/unregister and subscriber cleanup exist. `ManagedNmxService2Client` now exposes `SetHeartbeatSendInterval`, and `MxNativeClientOptions` has opt-in `HeartbeatTicksPerBeat`/`HeartbeatMaxMissedTicks` settings. The .NET 10 x64 probe `--probe-session-heartbeat --heartbeat-ticks=5 --heartbeat-max-missed=3` successfully opened a session, registered, set heartbeat, and disposed cleanly against local NmxSvc. `MxNativeSession.RecoverConnection()` now explicitly rebuilds the service client, re-registers the local engine/callback, reapplies heartbeat, reconnects publisher engines, and replays normal/buffered subscriptions. `RecoverConnectionAsync` adds an explicit caller-controlled retry loop using `MxNativeRecoveryPolicy`; automatic write retry is intentionally not enabled to avoid duplicate writes. Recovery events now report attempt start, failed attempt with exception/retry intent, and completion on both `MxNativeSession` and `MxNativeCompatibilityServer`. Callback/status/reference/unparsed and compatibility data/write/buffered event payloads carry `IsDuringRecovery`; current policy is pass-through with marking rather than suppression. `--probe-session-recover --recover-attempts=2 --recover-delay-ms=100 --tag=TestChildObject.TestInt --value=323` live validated subscribe, one started event, one completed event, preserved subscription count, and write-through on the recovered session. `--probe-session-recover-multi` replayed four active subscriptions and observed zero recovery-window callbacks while preserving all subscriptions. With `--recover-concurrent-writes`, a separate writer session wrote `TestChildObject.TestInt` values `330`-`334`; the recovering session preserved all four subscriptions and observed two data callbacks with `IsDuringRecovery=true`. The remaining production piece is cleanup/timeout behavior after partial recovery failures. |
| Diagnostics/errors | Partial | HRESULTs and some callback statuses are surfaced. Need stable exception/status model equivalent to MXAccess status structs and detail text. |
## Remaining hard pieces
1. Broaden AddItem/value-subscription validation beyond the first proven
managed value path. Current managed x64 register/connect/advise/write
transport works, native `0x1f` transfer kind/property fields are matched, and
`TestChildObject.ShortDesc` now returns the same value-bearing callback as
x86 MXAccess. Need matrix validation for bool/int/float/double/string,
arrays, elapsed time, multi-item subscriptions, and same-session write
updates.
2. Harden the MXAccess-compatible handle/session layer: live multi-item
write-complete ordering and event args that more closely mirror
`OnDataChange`, `OnWriteComplete`, and `OperationComplete`. Stale item
handles, invalid server handles, and cross-server item handles now match
native `0x80070057` behavior for the observed scalar paths.
3. Decode remaining security/user edge cases: any native scenario where
`WriteSecured` does not hit its item-record flag rejection, native captures
for the remaining scalar/array `WriteSecured2` value kinds, and
security-enabled `AuthenticateUser` password/hash
semantics, permission-denied, and verifier-denied cases. Boolean
`WriteSecured2` is now implemented and live validated for the secured and
verified tags deployed on this node. `ArchestrAUserToId` mirrors direct
MXAccess behavior observed on this node; GR profile lookup remains separate
metadata.
4. Decode lifecycle/control APIs: plain `Advise`, deeper `AddItem2` context
behavior, buffered callback delivery, and secured write variants. Current
Ghidra output proves native buffered delivery is the normal `OnDataChange`
callback path plus a buffered item-record branch to
`OnBufferedDataChange`; the managed compatibility layer now keeps buffered
callbacks separate from normal `DataChanged`. Still missing: a live
buffered sample payload that validates multi-sample value/quality/timestamp
decoding.
5. Decide whether strict x86 MXAccess COM quirks, especially the
`SAFEARRAY VT_BOOL` projection observed in capture `098`, should be exposed
as an opt-in compatibility mode rather than the default native .NET 10
behavior.
## Reference Resolver Notes
Ghidra export `Lmx.dll.reference-resolver-decompile.md` narrows the remaining
value-subscription gap to `CReferenceStringResolutionService`:
- `ProcessPendingClientRequests` builds `0x11` bind responses for pending
reference strings. It checks whether the current GR deploy/config timestamps
make a local bind usable through `FUN_1008af90`; otherwise it binds the
reference through the platform service and sends a response through
`FUN_1010ad00`.
- `FUN_1008af90` compares the reason/status detail against the subscribed GR
timestamps. Details `3`, `4`, and `8` are checked against
`TimeOfLastDeploy`; detail `6` is checked against
`TimeOfLastConfigChange`. This matches the two metadata references in the
observed pre-advise body.
- A later resolver branch sends the metadata request for
`DevPlatform.GR.TimeOfLastDeploy` and
`DevPlatform.GR.TimeOfLastConfigChange`, then attempts to read those values
back through a COM interface at vtable offset `0x14`. If the returned status
is not usable, it logs `value of m_hRefGRTimeOfLastDeploy can't be used` or
`value of m_hRefGRTimeOfLastConfigChange can't be used`.
- The successful branch calls `OnSetAttributeResult` directly after acquiring
another COM class object, then removes the pending reference request. This
local callback/state mutation is a likely missing piece in the managed-only
reproduction: sending the adapter-visible metadata request yields the same
callbacks, but does not execute the LMX in-process state update that MXAccess
gets through its `PreboundReference`/resolver objects.
- Frida captures `104` and `105` fixed the earlier stack-argument hook bug and
show the native prebind/register path returning public prebound and reference
handles of `1`. More importantly, `IMxReference.GetMxHandle` showed the
runtime value handle for `TestChildObject.ShortDesc` uses property id `10`,
not the GR `mx_attribute_category` value `11`, and native `0x1f` advise uses
transfer kind `ItemControl` (`2`). Matching those two fields in managed code
restored the `0x05` value callback for `ShortDesc` without needing to execute
the in-process `PreboundReference` objects.
5. Complete callback/status parity: string-array callback captures,
failure/error records, multi-item write-complete arrays, operation-complete mapping,
buffered data-change payloads, and exact `MXSTATUS_PROXY[]` conversion.
6. Expand type coverage beyond the OPC-UA-critical primitives: more elapsed and
internationalized-string success/failure captures, plus reference/status/enums,
qualified structs, and big strings if they appear in a target Galaxy.
7. Add integration tests against `NmxSvc.exe` using the existing
`MxTraceHarness` captures as golden fixtures, then add live facade probes
gated on managed NTLM environment variables.
## Protocol references for the managed ORPC layer
Microsoft's Open Specifications define the pieces this project now needs to
replace in managed code:
| Topic | Reference |
| --- | --- |
| DCOM/ORPC overview | <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/86b9cf84-df2e-4f0b-ac22-1b957627e1ca> |
| OBJREF header | <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/fe6c5e46-adf8-4e34-a8de-3f756c875f31> |
| STDOBJREF fields | <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/ad717638-f105-4256-b552-385b08ef8ebf> |
| OBJREF_STANDARD | <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/51312511-36e1-4ab6-993c-523643b11a29> |
| DUALSTRINGARRAY | <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/50889dd8-1960-49ca-a444-6212a73dc397> |
| ORPC invocation model | <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/a32b1703-e8dc-4940-9624-825ccc7db328> |
| `IRemUnknown::RemQueryInterface` | <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dcom/11fd5e3a-f5ef-41cc-b943-45217efdb054> |
| Windows RPC details | <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rpce/38ae9f5a-dac2-46ad-b2b7-9c43f211d9f6> |
## Non-goals for the managed-safe path
- Loading `LmxProxy.dll`, `NmxAdptr.dll`, or `NmxSvcps.dll` in the .NET 10
process.
- Requiring a 32-bit sidecar process for normal operation.
- Generating a new native x64 proxy/stub DLL unless a separate fallback is
explicitly accepted.