Files
mxaccess/docs/NMX-COM-Contracts.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

671 lines
24 KiB
Markdown

# NMX COM contracts and managed-client implications
This note captures the COM/type-library layer that sits below MXAccess and above
the local NMX transport.
## Native binaries inspected
Primary installed files:
```text
C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll
C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxAdptr.dll
C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxSvc.exe
C:\Program Files (x86)\ArchestrA\Framework\Bin\NmxSvcps.dll
C:\Program Files (x86)\ArchestrA\Framework\Bin\WWProxyStub.dll
```
No x64 equivalents were found under `C:\Program Files`; only x86 installed
copies and Galaxy file-repository copies were present.
Generated inspection reports:
```text
analysis\native\LmxProxy.dll.md
analysis\native\NmxAdptr.dll.md
analysis\native\NmxSvcps.dll.md
analysis\native\WWProxyStub.dll.md
```
`NmxSvcps.dll` is a MIDL COM proxy/stub DLL. It exports:
```text
DllCanUnloadNow
DllGetClassObject
DllRegisterServer
DllUnregisterServer
```
It imports the expected RPC proxy/stub helpers from `RPCRT4.dll`:
```text
NdrDllGetClassObject
NdrDllRegisterProxy
NdrDllUnregisterProxy
CStdStubBuffer_*
IUnknown_*_Proxy
```
## Key NMX service contracts
The already decompiled interop tree contains the type-library contracts:
```text
analysis\decompiled-interop\Interop.NmxSvc
analysis\decompiled-interop\Interop.NmxAdptr
```
Important interfaces:
| Interface | GUID | Important methods |
| --- | --- | --- |
| `INmxService` | `575008DB-845D-46C6-A906-F6F8CA86F315` | `RegisterEngine`, `UnRegisterEngine`, `Connect`, `TransferData`, subscriber and heartbeat methods |
| `INmxService2` | `2630A513-A974-4B1A-8025-457A9A7C56B8` | `RegisterEngine2`, `GetPartnerVersion` |
| `INmxSvcCallback` | `B49F92F7-C748-4169-8ECA-A0670B012746` | `DataReceived`, `StatusReceived` |
| `INmxNotify` | `73849AEA-472A-4715-B8C6-1C806AF12DFC` | `ConnectionEstablished`, `ConnectionClosed` |
| `INmx4` | `84168012-B544-4217-A145-32819C607435` | `PutRequest2`, `GetResponse2`, `Initialize2`, `InitializeAnonymous2` |
Core transport methods:
```csharp
void INmxService.TransferData(
int lRemoteGalaxyID,
int lRemotePlatformID,
int lRemoteEngineID,
int lSize,
ref byte pMsgBody);
void INmxSvcCallback.DataReceived(
int dwBufferSize,
ref sbyte lpDataBuffer);
void INmxSvcCallback.StatusReceived(
int dwBufferSize,
ref sbyte lpStatusBuffer);
void INmx4.PutRequest2(
int dwClusterId,
int dwPlatformId,
int dwEngineId,
byte byPriority,
byte byType,
int dwSize,
ref byte pData,
out int pdwRequestHandle);
void INmx4.GetResponse2(
byte byType,
out int pdwResponseCode,
out int pdwRequestHandle,
out int pdwSize,
IntPtr pData);
```
## Local mixed stream
The localhost `127.0.0.1:57415 <-> 127.0.0.1:57433` stream is not plain
DCE/RPC. It is a compact mixed protocol:
- 12-byte control records: `int32 code_or_status`, `int32 token_low`,
`int32 token_high`.
- Data records: `uint32 body_length`, followed by `body_length` bytes.
- A positive control `code_or_status` often announces the total byte count of
one or more following data records.
- `-1` appears as a normal acknowledgement/status control.
- `-2` appears as a bidirectional status/control marker around write windows.
The parser for this stream is:
```text
analysis\scripts\decode_mixed_local_stream.py
```
Generated mixed-stream decodes:
```text
captures\016-loopback-write-test-int-advised\mixed-stream-57415-to-57433.tsv
captures\016-loopback-write-test-int-advised\mixed-stream-57433-to-57415.tsv
captures\017-loopback-write-test-int-100\mixed-stream-57415-to-57433.tsv
captures\017-loopback-write-test-int-100\mixed-stream-57433-to-57415.tsv
captures\020-loopback-write-test-int-102\mixed-stream-57415-to-57433.tsv
captures\020-loopback-write-test-int-102\mixed-stream-57433-to-57415.tsv
```
Write-window extraction and diff helpers:
```text
analysis\scripts\analyze_write_window.py
analysis\scripts\diff_write_window_records.py
```
## Controlled write captures
Usable value-change captures:
| Folder | Write path |
| --- | --- |
| `captures\017-loopback-write-test-int-100` | `TestInt` changed from `99` to `100` |
| `captures\020-loopback-write-test-int-102` | `TestInt` changed from `101` to `102` |
| `captures\021-loopback-write-test-int-sequence-103-105` | `TestInt` changed from `102` to `103`, `104`, then `105` in one session |
`captures\018-loopback-write-test-int-101` has a successful harness log but a
header-only pcap, so it should not be used for packet analysis. The rerun
`captures\019-loopback-write-test-int-101-rerun` captured correctly, but it was
a same-value write because the attribute was already `101`.
The same-session sequence shows that the decoded write-window records do not
carry the requested `int32` values as plain little-endian scalar payloads. The
visible moving fields are mostly local sequence tokens and opaque body fields.
For a managed implementation, this raises the priority of tracing the in-process
native API boundary before the payload enters the localhost transport.
That boundary has now been traced with headless Ghidra-derived Frida hooks. See:
```text
docs\Ghidra-Headless-Analysis.md
captures\023-frida-write-test-int-sequence-109-111\frida-events.tsv
```
The key result is that `CLMXProxyServer::Write` receives the raw `int32` scalar
directly, and `CNmxAdapter::PutRequest` receives a 40-byte body with the scalar
at offset `18`. `CNmxAdapter::TransferData` wraps that body in an 86-byte
message, placing the scalar at offset `64`. The corresponding
`ProcessDataReceived` update body carries the scalar at offset `84`.
Additional Frida captures generalized the write body across common scalar
types:
| Type | `PutRequest` body | `TransferData` body | Callback/update body | Encoding |
| --- | --- | --- | --- | --- |
| bool | size `37`, value offset `18` | size `83`, value offset `64` | size `85`, value offset `84` | `VT_BOOL`; true `ff ff ff 00` in write body and `ff` in data-change body; false `00 ff ff 00` and `00` |
| int | size `40`, value offset `18` | size `86`, value offset `64` | size `88`, value offset `84` | little-endian `int32` |
| float | size `40`, value offset `18` | size `86`, value offset `64` | size `88`, value offset `84` | little-endian `float32` |
| double | size `44`, value offset `18` | size `90`, value offset `64` | size `92`, value offset `84` | little-endian `float64` |
| string | size `58` or `60`, value offset `26` | size `104` or `106`, value offset `72` | size `106` or `108`, value offset `92` | UTF-16LE |
| datetime | size `86`, value offset `26` | size `132`, value offset `72` | size `98`, value offset `88` | outbound UTF-16LE display string; callback/update FILETIME |
The matrix is saved at:
```text
analysis\frida\write-body-matrix.tsv
```
Array writes are also captured:
```text
analysis\frida\write-array-body-matrix.tsv
```
Write-mode captures are saved at:
```text
analysis\frida\write-mode-matrix.tsv
```
The first combined Frida plus loopback correlation is:
```text
captures\043-frida-loopback-write-test-int-115
captures\043-frida-loopback-write-test-int-115\frida-to-tcp-map.tsv
captures\044-frida-loopback-write-test-int-123456789
captures\044-frida-loopback-write-test-int-123456789\frida-to-tcp-map.tsv
```
Numeric arrays use an array descriptor at body offset `17`, then packed values
at offset `28`. The descriptor is:
```text
kind_byte 00 00 00 00 element_count:uint16 element_width_or_code:uint32
```
Observed kind bytes are `0x41` bool, `0x42` int, `0x43` float, `0x44` double,
and `0x45` variable-width string/date. String and datetime arrays use
per-element variable records; outbound datetime array writes encode display
strings, while callback/update bodies encode FILETIME values. The bool-array
capture succeeded but did not preserve the requested alternating pattern, so
that path is documented as unresolved pending a targeted follow-up.
Secured and verified attributes did not require the COM `WriteSecured` methods.
Those methods returned before value-bearing NMX requests in the tested cases.
The supported public path was normal `Write` with the fourth argument set to the
Galaxy security classification (`2` for `SecuredWrite`, `3` for
`VerifiedWrite`). Timestamped `Write2` keeps the scalar value slot and embeds a
FILETIME after the value.
The adapter body is still not the wire format. Capture `043` proves that exact
`PutRequest`, `TransferData`, and callback bodies are absent from the
reassembled TCP streams. Capture `044` uses the distinctive value `123456789`
and shows the raw scalar is absent from the full pcap payload scan, parsed
DCE/RPC stubs, and mixed local stream. A native managed client therefore needs a
structural DCE/RPC/NDR decoder in addition to the adapter-body codec.
The initial managed codec is:
```text
src\MxNativeCodec\MxNativeCodec.csproj
src\MxNativeCodec.Tests\MxNativeCodec.Tests.csproj
```
It does not yet synthesize every unknown header field. It uses captured
`PutRequest` bodies as templates and proves that scalar typed value slots,
timestamped scalar bodies, packed numeric arrays, string-array records, and the
write index can be decoded and re-encoded in .NET 10 x64 managed code.
## Implication for .NET 10 x64
The public COM contracts are useful as a schema, but the installed runtime path
is x86. A full managed .NET 10 x64 implementation cannot simply load these
in-proc COM components.
The service boundary was traced directly in capture `046`:
```text
captures\046-service-boundary-write-test-int-123456791
```
The same 86-byte write body appears at:
```text
CNmxAdapter.TransferData
CNmxService.TransferData
CNmxControler.TransferData
CNmxControler.DataReceived
CNmxControler.ProcessDataReceivedForEngine
```
The distinctive scalar `123456791` is at body offset `64` in every one of those
86-byte bodies.
The `TransferData` service body is a 46-byte NMX envelope followed by the
adapter `PutRequest` body. The envelope stores the inner body length at offset
`2`. For the observed `TestInt=123456791` write:
Do not send a bare 46-byte envelope as a normal probe payload. If the adapter
receives a header-only or length-mismatched `TransferData` body, System Platform
can log `NMX Header ... buffer size pktHeader.dwDataSize ... doesn't match
received message size ...`. The local COM and managed DCE/RPC harnesses now
validate this envelope length before sending.
| Body | Size | Value offset |
| --- | ---: | ---: |
| `CNmxAdapter.PutRequest` | 40 | 18 |
| `INmxService2.TransferData` / `CNmxAdapter.TransferData` | 86 | 64 |
The managed codec now includes `NmxTransferEnvelopeTemplate` to decode and
re-encode this observed envelope form.
The .NET 10 x64 probe can activate `NmxSvc.NmxService`, but the first
`INmxService2` method call fails with `0x8007000B` because COM tries to load the
32-bit `NmxSvcps.dll` proxy/stub into the x64 process. The probe is:
```text
src\MxNativeClient.Probe
```
The same probe can marshal the activated object as remote `IUnknown` and dump a
standard OBJREF:
```text
analysis\proxy\nmxservice-objref-context2.txt
```
The OBJREF exposes a stable OXID plus per-activation OID/IPID values and
binding towers. This proves a managed implementation can obtain the object
identity without loading `NmxSvcps.dll`; the remaining step is to implement the
ORPC `QueryInterface` and method call flow manually.
The .NET 10 client scaffold now has the first managed protocol primitives:
```text
src\MxNativeClient\DceRpcPdu.cs
src\MxNativeClient\DceRpcTcpClient.cs
src\MxNativeClient\OrpcStructures.cs
src\MxNativeClient\RemUnknownMessages.cs
src\MxNativeClient\ObjectExporterMessages.cs
src\MxNativeClient\ObjectExporterClient.cs
src\MxNativeClient.Tests\MxNativeClient.Tests.csproj
```
Those tests parse and re-encode real bind/alter-context style traffic from
capture `046` and parse a captured request PDU. The capture parser now records
presentation-context UUIDs in `dcerpc-stream-pdus.tsv`, which makes it easier to
separate useful DCE/RPC structure from traffic that is unrelated to the NMX
service body.
The visible DCE/RPC stream does not begin request stubs with `ORPCTHIS`, so it
is not the direct `INmxService2` ORPC method channel. The managed ORPC work is
therefore being built from the DCOM specification plus the OBJREF returned by
local COM activation. `IRemUnknown::RemQueryInterface` composition is now
represented in code; a live RPC binding and response validation remain pending.
The first live managed RPC probe binds to `IObjectExporter` on RPCSS and sends
`ResolveOxid` for the OBJREF OXID. The request is accepted, but the unauthenticated
call returns `0x00000005` (`ERROR_ACCESS_DENIED`). That matches the Python and
Impacket reference behavior, so the next blocker is RPC authentication rather
than the OXID NDR layout.
The DCOM activation and scalar call path has now been proven with an Impacket
reference probe:
```text
analysis\scripts\probe_dcom_inmxservice2.py
analysis\proxy\dcom-inmxservice2-getpartner-probe.txt
```
That probe uses packet privacy, activates CLSID
`{AE24BD51-2E80-44CC-905B-E5446C942BEB}`, requests
`INmxService2` `{2630A513-A974-4B1A-8025-457A9A7C56B8}`, binds to the returned
OXID endpoint, and calls `GetPartnerVersion` opnum `11`. The service returns
`partner_version=6` and `ErrorCode=0x00000000`.
This changes the remaining work from "prove DCOM can reach NmxSvc without the
x86 proxy" to "port packet-private DCOM authentication and the decoded NDR
method stubs into the .NET 10 client." The public method schema, target CLSID,
service endpoint, object identity flow, and at least one working service method
are now verified.
The .NET 10 managed client now reproduces the critical scalar path without the
AVEVA x86 proxy and without using SSPI `MakeSignature`:
```text
analysis\proxy\managed-remqi-and-getpartner-probe.txt
src\MxNativeClient\ManagedNtlmClientContext.cs
src\MxNativeClient\DceRpcTcpClient.cs
src\MxNativeClient\NmxService2Messages.cs
```
The managed probe obtains an `IUnknown` OBJREF, resolves the OXID with a
managed NTLMv2 packet-integrity DCE/RPC call, calls
`IRemUnknown::RemQueryInterface` for `INmxService2`, and then invokes
`INmxService2::GetPartnerVersion`. The live result is
`managed_getpartner_version=6` and `managed_getpartner_hresult=0x00000000`.
The remaining client-side proxy work is now concentrated on the non-scalar COM
methods: marshaling a managed callback object for `RegisterEngine2`, encoding
the correlated `byte[size]` parameter for `TransferData`, and implementing the
callback endpoint that receives `DataReceived` and `StatusReceived`.
`TransferData` has since been encoded and live-probed:
```text
analysis\proxy\managed-transferdata-control-probe.txt
```
The service returned `0x80041101`, which is an application-level HRESULT after
the ORPC call reached `NmxSvc.exe`; it was not a DCE/RPC/NDR failure. That
confirms the `byte[size]` request shape.
The x64 callback marshal probe is:
```text
analysis\proxy\callback-marshal-probe.txt
```
It fails with `0x80040154 REGDB_E_CLASSNOTREG` when trying to marshal
`INmxSvcCallback`. This is the same architecture problem in reverse: the
client-side x64 process has no registered `NmxSvcps.dll` proxy/stub for the
callback IID. A full managed client therefore needs to export the callback
object itself over DCE/RPC/ORPC.
Therefore the viable managed path is to implement the observed local contracts
directly:
1. Recreate enough of the NMX service/session behavior represented by
`INmxService`, `INmxSvcCallback`, and `INmx4`.
2. Encode/decode the NMX adapter message bodies identified by the Frida trace.
3. Replace the 32-bit `NmxSvcps.dll` MIDL proxy with a managed NDR/DCOM proxy
for `NmxSvc.exe`.
4. Use the Galaxy repository for tag/type/security metadata rather than
depending on the x86 MXAccess wrapper.
The next hard blocker is no longer the basic `int32` value location. It is the
managed replacement for the MIDL proxy/stub plus synthesis of add-item,
advise/unadvise, remove-item, and status/error request bodies.
See also:
```text
docs\DotNet10-Native-Library-Plan.md
analysis\proxy\nmxsvcps-proxy-layout.tsv
analysis\proxy\nmxsvcps-procedures.tsv
```
## Decoded proxy/stub procedure table
The proxy/stub MIDL procedure bytecode is extracted by:
```text
analysis\scripts\extract_nmxsvcps_proc_formats.py
```
The output is:
```text
analysis\proxy\nmxsvcps-procedures.tsv
analysis\proxy\type-format-snippets\
```
The core service opnums recovered from `NmxSvcps.dll` are:
| Interface | Method | Opnum | Parameter shape |
| --- | --- | ---: | --- |
| `INmxService2` | `RegisterEngine` | 3 | `int`, `BSTR`, `INmxSvcCallback*`, `HRESULT` |
| `INmxService2` | `UnRegisterEngine` | 4 | `int`, `HRESULT` |
| `INmxService2` | `Connect` | 5 | `int`, `int`, `int`, `int`, `HRESULT` |
| `INmxService2` | `TransferData` | 6 | `int`, `int`, `int`, `int size`, `byte[size]`, `HRESULT` |
| `INmxService2` | `AddSubscriberEngine` | 7 | `int`, `int`, `int`, `int`, `HRESULT` |
| `INmxService2` | `RemoveSubscriberEngine` | 8 | `int`, `int`, `int`, `int`, `HRESULT` |
| `INmxService2` | `SetHeartbeatSendInterval` | 9 | `int`, `int`, `HRESULT` |
| `INmxService2` | `RegisterEngine2` | 10 | `int`, `BSTR`, `int version`, `INmxSvcCallback*`, `HRESULT` |
| `INmxService2` | `GetPartnerVersion` | 11 | `int`, `int`, `int`, `out int`, `HRESULT` |
| `INmxSvcCallback` | `DataReceived` | 3 | `int size`, `sbyte[size]`, `HRESULT` |
| `INmxSvcCallback` | `StatusReceived` | 4 | `int size`, `sbyte[size]`, `HRESULT` |
Important NDR type-format offsets:
| Offset | Usage |
| --- | --- |
| `0x0006` | callback byte array correlated to `dwBufferSize` |
| `0x002c` | `BSTR_UserMarshal` string |
| `0x0036` | `INmxSvcCallback` interface pointer |
| `0x004c` | `TransferData` byte array correlated to `lSize` |
| `0x005c` | `INmxNotify` interface pointer |
This confirms that the missing x64/full-managed layer is not the public method
schema. The remaining native dependency is the DCOM/ORPC transport and the NDR
interpreter behavior normally provided by `NmxSvcps.dll`.
## RegisterEngine2 marshaling findings
The direct x86 COM harness is:
```text
src\NmxComHarness\NmxComHarness.csproj
src\NmxComHarness\Program.cs
```
It bypasses `ArchestrA.MXAccess.dll` and invokes
`NmxSvc.NmxService.RegisterEngine2` directly through the installed 32-bit
`NmxSvcps.dll` proxy. The focused Frida hook is:
```text
analysis\frida\nmx-com-proxy-trace.js
```
Captured runs:
```text
captures\052-frida-direct-nmx-registerengine2-marshals-retry
captures\053-frida-direct-nmx-registerengine2-null-stub
captures\054-frida-direct-nmx-registerengine2-callback-stub
analysis\proxy\x86-callback-objref-probe.txt
analysis\proxy\x86-registerengine2-null-callback-probe.txt
analysis\proxy\managed-registerengine2-null-callback-probe.txt
```
`BSTR_UserMarshal` writes the string as:
```text
char_count:uint32
byte_length:uint32
char_count:uint32
utf16_payload_without_null
```
For `NmxComProxyWire5`, the exact captured bytes were:
```text
10 00 00 00 20 00 00 00 10 00 00 00
4e 00 6d 00 78 00 43 00 6f 00 6d 00 50 00 72 00
6f 00 78 00 79 00 57 00 69 00 72 00 65 00 35 00
```
The generated proxy also writes a 4-byte user-marshal marker before that BSTR
payload:
```text
55 73 65 72
```
Interpreted little-endian, that value is `0x72657355` (`"User"`).
The managed encoder in `src\MxNativeClient\NmxService2Messages.cs` now
reproduces the null-callback `RegisterEngine2` request:
```text
ORPCTHIS
localEngineId:int32
0x72657355:uint32
BSTR_UserMarshal(engineName)
padding to 4-byte boundary
version:int32
callback:null-interface-pointer:uint32 = 0
```
The live .NET 10 x64 probe reaches `NmxSvc.exe` and returns a non-failing COM
success code:
```text
managed_register2_null_hresult=0x00000001
managed_unregister_after_register_hresult=0x00000001
```
This confirms that the managed DCOM/NDR path can perform the service
registration lifecycle without the x86 proxy when no callback endpoint is
required.
For a non-null callback, the x86 proxy wraps the callback OBJREF as:
```text
0x00020000:uint32
objref_size:uint32
objref_size:uint32
objref_bytes
```
The same capture showed `objref_size=0x44` for a compact same-machine standard
OBJREF. A separately marshaled x86 callback stream produced a 366-byte standard
OBJREF with dual-string bindings. The managed callback exporter can therefore
use the same MInterfacePointer wrapper around an OBJREF that advertises the
managed callback endpoint.
## Callback OBJREF experiments
Two managed callback OBJREF strategies have now been tested.
### Synthetic managed TCP OBJREF
`ManagedCallbackExporter` can build a standard OBJREF that advertises a managed
TCP listener:
```text
src\MxNativeClient\ManagedCallbackExporter.cs
analysis\proxy\managed-registerengine2-callback-probe.txt
analysis\proxy\managed-registerengine2-callback-loopback-probe.txt
analysis\proxy\managed-registerengine2-callback-fixed-port-probe.txt
analysis\proxy\managed-callback-fixed-port-tcp-poll.txt
analysis\proxy\managed-callback-nmxsvc-tcp-poll.txt
```
Without security bindings the service rejects the callback OBJREF with
`0x8001011D`. Adding the default security binding sequence seen in x86
`CoMarshalInterface` changes the failure to `0x800706BA` (`RPC server
unavailable`), but the managed listener sees no inbound connection. TCP polling
also shows no SYN to the advertised port.
Inference: for standard OBJREFs, COM is not treating the embedded string binding
as a direct object endpoint. It is resolving the OXID through the local COM/OXID
resolver machinery. A purely synthetic OXID that is not registered with RPCSS is
not enough.
### COM-registered IUnknown OBJREF patched to callback IID
The probe can also ask the local x64 COM runtime to marshal a managed
`IUnknown` OBJREF, then replace only the OBJREF IID with `INmxSvcCallback`:
```text
analysis\proxy\managed-registerengine2-callback-com-iunknown-objref-probe.txt
analysis\proxy\managed-registerengine2-callback-com-iunknown-self-transfer-probe.txt
```
That OBJREF is backed by a real RPCSS-registered OXID/OID/IPID. With this form,
`NmxSvc.exe` accepts a non-null callback pointer:
```text
managed_register2_callback_hresult=0x00000000
managed_unregister_after_callback_register_hresult=0x00000000
```
A self-directed `TransferData` probe also returns `0x00000000`:
```text
managed_callback_self_transfer_hresult=0x00000000
```
No managed callback event was delivered during that self-transfer probe. This
means the COM-registered patched OBJREF is currently a registration proof, not
the final callback solution. The final managed implementation still needs an
endpoint whose OXID/IPID can be resolved by COM and whose request dispatch is
handled by managed code rather than the missing x64 `NmxSvcps.dll` proxy/stub.
### x64 type-library marshaling for INmxSvcCallback
The x64 callback marshal failure can be removed without AVEVA's 32-bit
`NmxSvcps.dll` by registering type-library metadata for the callback interface
and using the Windows standard automation proxy/stub:
```text
analysis\scripts\register_x64_callback_typelib.ps1
analysis\proxy\typelib\NmxComHarness.tlb
analysis\proxy\callback-marshal-after-typelib-probe.txt
```
The script exports `src\NmxComHarness\bin\Release\net481\NmxComHarness.exe` to
a TLB, registers it with `LoadTypeLibEx(REGKIND_REGISTER)`, and sets:
```text
HKLM\SOFTWARE\Classes\Interface\{B49F92F7-C748-4169-8ECA-A0670B012746}
ProxyStubClsid32 = {00020424-0000-0000-C000-000000000046}
TypeLib = {4DBF23F3-069E-3D29-B67F-4C7850F588B3}, Version 1.0
NumMethods = 5
```
After that registration, the x64 managed process can call
`CoMarshalInterface` for `INmxSvcCallback` directly. The probe now emits a
standard 366-byte OBJREF for the callback IID instead of
`REGDB_E_CLASSNOTREG`.
Using the real marshaled callback OBJREF, `NmxSvc.exe` accepts non-null
`RegisterEngine2`, `Connect`, `AddSubscriberEngine`, `TransferData`, and
`UnRegisterEngine` calls:
```text
analysis\proxy\managed-registerengine2-callback-com-real-probe.txt
analysis\proxy\x86-registerengine2-self-transfer-callback-probe.txt
```
The same synthetic self-transfer route does not produce a callback in the x86
harness either. Therefore the absence of a callback event in the current probe
is caused by the synthetic NMX message/session body, not by inability to marshal
the callback interface after the type-library registration.