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

24 KiB

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:

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:

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:

DllCanUnloadNow
DllGetClassObject
DllRegisterServer
DllUnregisterServer

It imports the expected RPC proxy/stub helpers from RPCRT4.dll:

NdrDllGetClassObject
NdrDllRegisterProxy
NdrDllUnregisterProxy
CStdStubBuffer_*
IUnknown_*_Proxy

Key NMX service contracts

The already decompiled interop tree contains the type-library contracts:

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:

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:

analysis\scripts\decode_mixed_local_stream.py

Generated mixed-stream decodes:

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:

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:

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:

analysis\frida\write-body-matrix.tsv

Array writes are also captured:

analysis\frida\write-array-body-matrix.tsv

Write-mode captures are saved at:

analysis\frida\write-mode-matrix.tsv

The first combined Frida plus loopback correlation is:

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:

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:

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:

captures\046-service-boundary-write-test-int-123456791

The same 86-byte write body appears at:

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:

src\MxNativeClient.Probe

The same probe can marshal the activated object as remote IUnknown and dump a standard OBJREF:

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:

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:

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:

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:

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:

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:

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:

analysis\scripts\extract_nmxsvcps_proc_formats.py

The output is:

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:

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:

analysis\frida\nmx-com-proxy-trace.js

Captured runs:

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:

char_count:uint32
byte_length:uint32
char_count:uint32
utf16_payload_without_null

For NmxComProxyWire5, the exact captured bytes were:

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:

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:

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:

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:

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:

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:

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:

managed_register2_callback_hresult=0x00000000
managed_unregister_after_callback_register_hresult=0x00000000

A self-directed TransferData probe also returns 0x00000000:

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:

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:

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:

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.