Files
mxaccess/docs/MxNativeSession-API.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

427 lines
22 KiB
Markdown

# MxNativeSession API
`MxNativeSession` is the first high-level .NET 10 x64 facade over the managed
NMX protocol work in this repository. It is intended to be the consumer-facing
entry point while the lower layers remain available for protocol tests and
diagnostics.
## Runtime prerequisites
- AVEVA System Platform/NMX installed locally so `NmxSvc.NmxService` can be
activated.
- The Galaxy Repository SQL database available to the current Windows session.
- Managed NTLM runtime credentials supplied through the existing environment
variable path used by `ManagedNtlmClientContext`.
- The x64 callback COM registration/type-library setup already documented in
`DotNet10-Native-Library-Plan.md`.
The session facade does not store credentials. It relies on the process
environment and the already-authenticated Windows/SQL context.
## Basic write
```csharp
using MxNativeClient;
using var session = MxNativeSession.Open(new MxNativeClientOptions
{
LocalEngineId = 0x7fee,
EngineName = "MxNativeClient.Sample",
// Optional. Leave null unless a deployment-specific heartbeat cadence is known.
HeartbeatTicksPerBeat = null,
});
await session.WriteAsync("TestChildObject.TestInt", 123);
await session.Write2Async(
"TestChildObject.TestInt",
124,
DateTime.UtcNow);
```
`HeartbeatTicksPerBeat` and `HeartbeatMaxMissedTicks` expose
`INmxService2.SetHeartbeatSendInterval`. A .NET 10 x64 probe validated
`SetHeartbeatSendInterval(5, 3)` against local NmxSvc. The setting remains
opt-in because the exact MXAccess heartbeat cadence has not been captured on
this VM; leaving `HeartbeatTicksPerBeat` null preserves the previously
validated connection behavior.
## Recovery
`MxNativeSession.RecoverConnection()` explicitly rebuilds the managed NMX
service client, re-registers the local engine and callback, reapplies optional
heartbeat settings, reconnects known publisher engines, and replays current
normal or buffered subscriptions with their existing correlation IDs. The
compatibility facade exposes the same operation as
`MxNativeCompatibilityServer.RecoverConnection(serverHandle)`.
`RecoverConnectionAsync(policy, cancellationToken)` adds an explicit retry loop
around that primitive. `MxNativeRecoveryPolicy.MaxAttempts` defaults to `1`;
`Delay` defaults to zero. The compatibility facade exposes the same retrying
operation as `RecoverConnectionAsync(serverHandle, policy, cancellationToken)`.
Recovery progress is observable:
- `RecoveryAttemptStarted` fires before each reconnect/replay attempt.
- `RecoveryAttemptFailed` fires after a recoverable attempt failure and reports
the exception plus whether the retry loop will continue.
- `RecoveryCompleted` fires after the replacement service has been registered,
publisher endpoints have been reconnected, subscriptions have been replayed,
and the session has swapped to the recovered service.
The compatibility facade exposes the same events with the server handle added
to each event payload. A live .NET 10 x64 probe with two allowed attempts and a
100 ms delay observed one started event, zero failed events, one completed
event, preserved the existing subscription count, and wrote through the
recovered session.
Callbacks are passed through during the recovery window instead of being
suppressed or buffered. `MxNativeCallbackEvent`,
`MxNativeOperationStatusEvent`, `MxNativeReferenceRegistrationEvent`, and
`MxNativeUnparsedCallbackEvent` include `IsDuringRecovery` so callers can
separate replay-window events from steady-state events. Compatibility
`DataChanged`, `BufferedDataChanged`, and `WriteCompleted` payloads carry the
same marker.
`MxNativeClient.Probe --probe-session-recover-multi` subscribes to multiple
tags, runs explicit recovery, and reports total callbacks plus
`IsDuringRecovery` counts for data, operation-status, reference-registration,
and unparsed callback families. The first live run replayed four subscriptions
and preserved all four, with zero callback events marked as recovery-window
events on this VM.
Adding `--recover-concurrent-writes` starts a separate writer session during
the same recovery window. A live run wrote `TestChildObject.TestInt` values
`330`-`334`, preserved all four subscriptions, and observed two data callbacks
with `IsDuringRecovery=true`. This confirms that callbacks are not quiesced by
the library during recovery; consumers that require strict post-recovery
ordering should ignore or queue marked events at their boundary.
Automatic background recovery is intentionally not enabled yet. Retrying writes
inside normal write calls can duplicate writes, so callers should choose where
recovery belongs in their own operation model. A .NET 10 x64 probe validated the
current explicit path by subscribing to `TestChildObject.TestInt`, recovering
the connection, preserving the subscription count, and writing through the
recovered session.
`WriteSecured2Async` is implemented for the observed boolean secured/verified
payload shape:
```csharp
await session.WriteSecured2Async(
"TestMachine_001.ProtectedValue",
true,
DateTime.Now,
currentUserId: 1,
verifierUserId: 0);
```
The encoder emits native command `0x38` and has been live validated from .NET
10 x64 against the secured and verified boolean tags deployed on this node, as
well as authenticated bool, int, float, double, string, datetime, and
scalar-array calls against `TestChildObject` attributes. The handle-based
compatibility facade also supports `AuthenticateUser` followed by
`WriteSecured2`, and intentionally does not synthesize `OnWriteComplete` for
this path because the successful native captures did not show that event.
`WriteSecuredAsync` remains unsupported because native MXAccess still returns
`0x80004021` before emitting a value-bearing body in every captured scenario.
Additional native captures beyond bool and int would improve fixture coverage,
but the managed encoder is now generic over the timestamped write-body support.
## Browse
```csharp
IReadOnlyList<GalaxyTagMetadata> tags =
await session.BrowseAsync("TestChildObject", "Test%", maxRows: 25);
```
`BrowseAsync` uses Galaxy Repository metadata directly. It does not call the
x86 LMX resolver and returns the same metadata needed by the managed handle and
wire encoders.
`ResolveAsync` accepts full references in the observed MXAccess forms:
`Object.Attribute`, `Object.Primitive.Attribute`, and
`Object.Primitive.Dotted.Attribute` for primitive attributes such as
`TestMachine_001.TestAlarm001.Alarm.TimeDeadband`. It also recognizes the
captured literal property form `Object.Attribute.property(buffer)`, resolving it
to the base attribute handle with property id `0x32`.
Each `GalaxyTagMetadata` exposes `IsSupportedValueKind` and
`TryGetValueKind(out MxValueKind valueKind)`. Use those before write/read
projection when browsing broad GR metadata. The current wire codec supports the
live OPC-UA-critical GR types `Boolean`, `Integer`, `Float`, `Double`, `String`,
`Time`, and array forms captured so far. Live GR inspection also found
`ElapsedTime` and `InternationalizedString`. Subscribe/read callback decoding now
handles the observed `ElapsedTime` wire kind `0x07` as `TimeSpan` and the compact
empty `InternationalizedString`/string payload form `0x05 04 00 00 00` as
`string.Empty`. They are still reported as unsupported by `TryGetValueKind`
because they are not core one-to-one data kinds, but write projection now follows
captured MXAccess caller-variant behavior: `ElapsedTime` values supplied as
`TimeSpan` or integer project to integer milliseconds/wire kind `0x02`, and
`InternationalizedString` values project to normal string wire kind `0x05`.
`WriteAsync` and `Write2Async` resolve the tag from Galaxy Repository metadata,
build the managed `MxReferenceHandle`, encode the NMX write body, wrap it in
the `TransferData` envelope, and call `INmxService2::TransferData` through the
managed DCOM path.
## Subscribe
```csharp
using MxNativeClient;
using var session = MxNativeSession.Open();
session.CallbackReceived += (_, evt) =>
{
Console.WriteLine(
$"{evt.Record.TimestampUtc:O} status={evt.Status.Category} quality=0x{evt.Record.Quality:X4} value={evt.Record.Value}");
};
session.OperationStatusReceived += (_, evt) =>
{
Console.WriteLine(
$"operation_status=0x{evt.Message.StatusCode:X4} status={evt.Message.Status.Category}");
};
MxNativeSubscription sub = await session.SubscribeAsync("TestChildObject.TestInt");
await Task.Delay(TimeSpan.FromSeconds(10));
session.Unsubscribe(sub.CorrelationId);
```
## Read
```csharp
object? value = await session.ReadAsync(
"TestChildObject.TestInt",
timeout: TimeSpan.FromSeconds(10));
```
`ReadAsync` is implemented as a transient subscription read. It subscribes,
waits for the first typed value callback matching that item correlation, then
unsubscribes.
`SubscribeAsync` performs the managed equivalent of the observed MXAccess path:
1. Resolve tag metadata from the Galaxy Repository.
2. Connect the local engine to the tag's owning engine.
3. Add the local engine as a subscriber.
4. Send the generated item-control `0x1f` body using the native value handle.
5. Decode incoming `INmxSvcCallback` bodies with `NmxSubscriptionMessage`.
Capture `099-frida-plain-advise-testint` showed public `Advise` and the earlier
`AdviseSupervisory` scalar path using the same `0x1f` body shape for
`TestChildObject.TestInt`, so the compatibility wrapper maps both methods onto
this subscription path.
`CallbackReceived` is raised for typed `0x32` subscription-status and `0x33`
data-update records. Each callback exposes the raw NMX record fields plus a
first-pass `MxStatus` projection. `MxStatus.DetailText` returns the installed
English `Lmx.aaDCT` detail text for known LMX/NMX status details, including
reference, conversion, security, alarm, initializing, and secured/verified
write conditions. `OperationStatusReceived`
is raised for both observed operation-status frame forms. The non-length-prefixed
5-byte status-word form with status word `0x8050` and completion byte `0x00`
maps to `MxStatus.WriteCompleteOk` and is the captured public
`OnWriteComplete` success notification. Length-prefixed completion-only frames
are decoded separately and expose `NmxOperationStatusFormat.CompletionOnly`.
Captures `089`, `092`, and `093` produced completion byte `0x41` after
wrong-type string writes, and capture `091` produced completion byte `0x00`
after double-to-int coercion; MXAccess did not raise `OnWriteComplete` for
either completion-only form.
The managed x64 path now has a proven value-bearing subscription fixture:
`SubscribeAsync("TestChildObject.ShortDesc")` receives the same compact empty
string callback observed from x86 MXAccess (`0x32`, wire kind `0x05`, quality
`0x00C0`). The key handle detail is that value references use property id `10`;
GR `mx_attribute_category` is metadata and is not the NMX value-handle property
field.
String-array callback handling is intentionally conservative. Captures `100`
and `101` showed `0x45` string-array callback records whose buffers stopped
inside the final element, and MXAccess did not raise a public data-change event
for those runs. The decoder does not fabricate a value for malformed array
payloads; callers should treat a `null` value with a known array wire kind as an
incomplete callback until a complete/public string-array callback is captured.
`ReferenceRegistrationReceived` is raised for decoded `0x11`
reference-registration result frames. Those frames are emitted after the `0x10`
normal/buffered registration request and include the item handle, correlation
ID, reference text, context, and MX data type.
`UnparsedCallbackReceived` is a diagnostic event for callback bodies that are
not yet part of the high-level API surface. It is currently used to expose
frames such as the 92-byte operation/status-shaped body seen before scalar
subscription status callbacks, rather than silently swallowing unknown protocol
bodies during reverse engineering.
## Probe commands
The probe now has facade-level entry points:
```powershell
dotnet run --project .\src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-session-write --tag=TestChildObject.TestInt --value=123 --objref-only
dotnet run --project .\src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-session-read --tag=TestChildObject.TestInt --read-timeout-seconds=10 --objref-only
dotnet run --project .\src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-session-subscribe --tag=TestChildObject.TestInt --subscribe-hold-seconds=5 --objref-only
dotnet run --project .\src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-session-subscribe --tag=TestChildObject.ShortDesc --subscribe-hold-seconds=8 --objref-only
dotnet run --project .\src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-compatibility-subscribe --tag=TestChildObject.ShortDesc --subscribe-hold-seconds=8 --objref-only
dotnet run --project .\src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-compatibility-subscribe-write --tag=TestChildObject.TestInt --value=793 --subscribe-hold-seconds=8 --objref-only
dotnet run --project .\src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-compatibility-subscribe --tag=NoSuchObject_999.NoSuchAttr --subscribe-hold-seconds=2 --objref-only
dotnet run --project .\src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-compatibility-subscribe-multi --tag=TestChildObject.ShortDesc --tag=TestChildObject.TestInt --tag=NoSuchObject_999.NoSuchAttr --subscribe-hold-seconds=4 --objref-only
dotnet run --project .\src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-compatibility-write-multi --subscribe-hold-seconds=4 --objref-only
dotnet run --project .\src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-compatibility-write-unadvised --tag=TestChildObject.TestInt --value=99 --objref-only
dotnet run --project .\src\MxNativeClient.Probe\MxNativeClient.Probe.csproj -c Release -- --probe-managed-subscribe --tag=TestChildObject.TestInt --subscribe-hold-seconds=5 --send-observed-preadvise-metadata --objref-only
```
These commands require the same runtime managed NTLM environment as the lower
level managed probes.
## MXAccess-style compatibility wrapper
`MxNativeCompatibilityServer` provides an MXAccess-like handle model for code
that expects integer server and item handles:
```csharp
using MxNativeClient;
using var mx = new MxNativeCompatibilityServer();
mx.DataChanged += (_, evt) =>
{
Console.WriteLine(
$"server={evt.ServerHandle} item={evt.ItemHandle} value={evt.Value} quality=0x{evt.Quality:X4}");
};
mx.WriteCompleted += (_, evt) =>
{
Console.WriteLine(
$"server={evt.ServerHandle} item={evt.ItemHandle} status={evt.Statuses[0].Category}");
};
int server = mx.Register("OpcUaBridge.Native");
int item = mx.AddItem(server, "TestChildObject.TestInt");
mx.AdviseSupervisory(server, item);
mx.Write(server, item, 123);
mx.UnAdvise(server, item);
mx.RemoveItem(server, item);
mx.Unregister(server);
```
The compatibility wrapper maps `Register`, `Unregister`, `AddItem`,
`RemoveItem`, `Advise`, `AdviseSupervisory`, `UnAdvise`, `Write`, and `Write2`
onto `MxNativeSession`. `Suspend` and `Activate` mirror observed MXAccess local
behavior for advised items: unadvised calls throw, `Suspend` returns
pending/requesting-LMX status, and `Activate` returns ok/requesting-LMX status.
After `RemoveItem`, the wrapper mirrors native x86 stale-item behavior by
returning `ArgumentException` with `HResult=0x80070057` for advise, unadvise,
write, write2, suspend, activate, and repeated remove attempts.
Invalid server handles and cross-server item handles use the same
`ArgumentException`/`0x80070057` shape observed from native MXAccess.
Literal `property(buffer)` items added with `AddItem` are not treated as
`AddBufferedItem` handles. They follow the captured MXAccess literal-reference
path: add/advise succeeds, normal write returns without a public write-complete,
and no public data-change is promoted on the current VM state.
`AddItem2` resolves the item definition directly first and then retries with the
supplied context. This covers the captured simple form
`AddItem2("TestInt", "TestChildObject")` plus dotted relative forms such as
`AddItem2("Alarm.TimeDeadband", "TestMachine_001.TestAlarm001")` and
`AddItem2("TestInt.property(buffer)", "TestChildObject")`.
`AddBufferedItem` and `SetBufferedUpdateInterval` now cover the decoded public
API path: buffered add creates a handle, the interval is stored as local session
state, and advising the buffered handle sends the observed NMX `0x10`
registration for `itemDefinition.property(buffer)` in the supplied item
context. Headless Ghidra analysis shows native MXAccess routes buffered item
callbacks through the same `OnDataChange` callback method as normal items, then
branches to `_ILMXProxyServerEvents2::OnBufferedDataChange` when the item record
is marked buffered. `MxNativeCompatibilityServer.BufferedDataChanged` mirrors
that separation for any parsed buffered callback and does not promote buffered
items through `DataChanged`. Captures against `TestChildObject.TestInt` and
GR-confirmed historized `TestMachine_001.TestHistoryValue` prove the outbound
context-bearing registration/result bodies, but this VM has not emitted a live
`OnBufferedDataChange` payload. True multi-sample buffered payload decoding
still needs that runtime condition.
`ArchestrAUserToId` follows the observed MXAccess x86
behavior on this node: known user GUIDs and an invalid zero GUID all return `1`.
`AuthenticateUser` follows the same session-local handle model for the observed
dev-node behavior: MXAccess returned `S_OK` and user ID `1` for both
`Administrator` and an invalid user name with an empty password. The managed
compatibility method validates the server handle and user string, returns a
session-local handle, and does not store or compare password material.
`WriteCompleted` is raised only for the captured MXAccess-visible 5-byte
`0x8050/0x00` operation-status frame. Completion-only frames are still available
through `MxNativeSession.OperationStatusReceived`, but the compatibility wrapper
does not convert them into `OnWriteComplete` events because x86 MXAccess did not
fire that event in those captures. Pending write handles are queued per server
session, so concurrent compatibility sessions do not consume each other's
completion callbacks. The mixed `--probe-compatibility-write-multi` path
validates that normal int, internationalized string, literal buffer-property,
and invalid-reference items in one server session do not leak public data-change
or write-complete events across item handles; only the invalid reference emits
the expected configuration-error data-change. `OperationCompleted` keeps the public event
shape, but no trigger has been modeled yet because the capture set contains no
`mx.event.operation-complete` events.
`DataChanged` is also suppressed when the lower-level decoder reports a known
wire kind with a `null` value, which is how malformed/incomplete callback
payloads such as the observed string-array records from captures `100` and
`101` are represented.
`GalaxyRepositoryUserResolver` is separate metadata support: it resolves
`dbo.user_profile` rows, profile IDs, default security group, InTouch access
level, and decoded role names from the GR role blob. `AddItem2` currently
combines context and item text only for simple relative references; the real
MXAccess context behavior still needs captures. Unsupported MXAccess methods
throw `NotSupportedException` with the missing capture/protocol reason.
For lower-level use, `GalaxyRepositoryUserResolver` exposes:
```csharp
var users = new GalaxyRepositoryUserResolver();
GalaxyUserProfile profile = await users.ResolveByNameAsync("Administrator");
int profileId = await users.ResolveUserProfileIdByGuidAsync(profile.UserGuid);
IReadOnlyList<string> roles = profile.Roles;
```
Security-enabled `AuthenticateUser` password verification remains intentionally
out of scope until successful and failed captures from a security-enabled Galaxy
show whether MXAccess delegates to OS/domain auth, GR hashes, or another token
provider.
## Current limitations
- Managed NTLM live probes now validate .NET 10 x64 activation, RemQI,
`GetPartnerVersion`, direct `WriteAsync` transport, callback delivery, and
unsubscribe cleanup with unique local engine IDs.
- Value-bearing managed `OnDataChange` is proven for
`TestChildObject.ShortDesc` after matching native advise transfer kind and
value-handle property id. The lower-level session exposes that callback, but
the MXAccess-style compatibility wrapper suppresses the empty
`InternationalizedString` public `DataChanged` event because fresh x86
MXAccess capture `108` also raises no public event for `ShortDesc`. Fresh
current-state x86 MXAccess captures also raise no public `TestInt`
data-change for subscribe-only or subscribe-then-write, matching the managed
status-only `TestInt` callbacks on this VM. Broader scalar/array parity still
needs live validation against a runtime state that emits public values.
Replaying the captured
adapter-visible `0x17` metadata body is accepted by `NmxSvc` and produces
decoded `0x40` metadata-response plus `0x32` metadata-status callbacks, but
the response still contains an internal base-object error and does not appear
to be the gate for the `ShortDesc` value callback.
- `ReadAsync` currently uses a transient subscription. A lower-latency direct
read path can be added if a distinct NMX request/response read body is found;
tags with no initial value can still time out after receiving only status
callbacks.
- String-array callbacks are partially modeled from the element format, but the
available Frida string-array callback line is truncated before all values.
- Error/failure callback bodies still need more captures for exact
`MXSTATUS_PROXY[]` parity.
- `ElapsedTime` and `InternationalizedString` writes are value-projected from
captures `095` and `096`. The managed `ShortDesc` write body now matches
capture `096` byte-for-byte and returns the same completion-only `0xef` as
native MXAccess on this node, while `TestInt` returns completion-only `0x00`
and a status-only update callback. Public write-complete semantics for these
completion-only frames still need more captures.
- `MxNativeCompatibilityServer` provides source-level migration help but is not
a binary COM replacement for `ArchestrA.MxAccess`.
- For invalid references, the compatibility wrapper intentionally differs from
strict `MxNativeSession`: it returns an item handle and raises the observed
public configuration-error data-change (`null`, quality `0`, detail `6`) on
advise, matching x86 MXAccess captures.