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

69 KiB

.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:

C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxSvcps.dll

The proof project is:

src\MxNativeClient\MxNativeClient.csproj
src\MxNativeClient.Probe\MxNativeClient.Probe.csproj

The probe output is expected at this stage:

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:

python analysis\scripts\probe_dcom_inmxservice2.py

Saved output:

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:

dotnet run --project src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-remqi-managed --objref-only

Saved output:

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:

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:

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:

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:

dotnet run --project src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --dump-objref --objref-context=2 --expect-proxy-load-failure

Latest saved output:

analysis\proxy\nmxservice-objref-context2.txt

The parser used by the probe is now reusable managed code:

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:

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:

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:

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:

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:

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:

src\MxNativeClient\NmxProcedureMetadata.cs

Managed DCE/RPC primitives added

The client scaffold now includes tested managed DCE/RPC binary primitives:

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:

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:

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:

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:

analysis\scripts\probe_resolve_oxid.py
src\MxNativeClient\ObjectExporterClient.cs

Saved outputs:

analysis\proxy\resolve-oxid-unauth-probe.txt
analysis\proxy\resolve-oxid-managed-unauth-probe.txt

The managed probe:

dotnet run --project src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-resolve-oxid-unauth --expect-proxy-load-failure

Result:

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:

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:

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:

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:

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:

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:

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:

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:

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:

analysis\proxy\managed-registerengine2-null-callback-probe.txt

returns:

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:

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:

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:

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:

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:

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:

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:

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:

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:

src\MxNativeCodec\NmxTransferEnvelope.cs
src\MxNativeCodec.Tests\Program.cs

Follow-up captures:

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:

src\MxNativeCodec\NmxItemControlMessage.cs

Capture 061 adds the missing LMX-side mapping:

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:

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:

src\MxNativeCodec\MxReferenceHandle.cs

The GR correlation was obtained from the local ZB database using the query notes in:

C:\Users\dohertj2\Desktop\lmxopcua\gr

That GR path is now executable managed code:

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:

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:

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:

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:

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.
  1. 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.
  2. 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.
  3. 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.