c95824a65d
Full read-only SDK (src/AVEVA.Historian.Client) implementing the CLAUDE.md required
surface against AVEVA Historian's binary WCF protocol — no native AVEVA runtime
dependency. All operations live-verified against a local Historian:
- ProbeAsync, ReadRawAsync, ReadAggregateAsync, ReadAtTimeAsync, ReadEventsAsync
- BrowseTagNamesAsync, GetTagMetadataAsync (17 native data-type codes mapped)
- GetConnectionStatusAsync, GetStoreForwardStatusAsync, GetSystemParameterAsync
- 108/108 unit + integration tests pass
Includes the reverse-engineering toolkit (tools/AVEVA.Historian.ReverseEngineering)
used to decode the protocol: WCF probes, IL inspection via dnlib, and IL-rewrite
instrumentation (instrument-wcf-{write,read}message etc.) plus the .NET Framework
trace harness (tools/AVEVA.Historian.NativeTraceHarness) for parity testing.
Sanitized handoff evidence under docs/reverse-engineering/. Native AVEVA binaries
(current/, aveva-install-x64/, aveva-install-x86/) are gitignored — fetch separately
from the AVEVA installer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
824 lines
39 KiB
Markdown
824 lines
39 KiB
Markdown
# Managed Wrapper Findings
|
|
|
|
Source assembly: `current\aahClientManaged.dll`
|
|
|
|
Tooling used: `ilspycmd 10.0.0.8330`
|
|
|
|
## Confirmed Shape
|
|
|
|
`aahClientManaged.dll` is a mixed-mode C++/CLI wrapper. The public managed API is
|
|
under the `ArchestrA` namespace, but query execution calls native
|
|
`HistorianClient` methods through C++ pointers rather than plain P/Invoke
|
|
methods.
|
|
|
|
`dumpbin /dependents` confirms the deployed wrapper does not import
|
|
`aahClient.dll` or `aahClientCommon.dll`. It is a self-contained mixed-mode
|
|
wrapper over system DLLs and the CLR (`mscoree.dll`), so the active wrapper path
|
|
must be captured inside `aahClientManaged.dll` or at system/WCF boundaries.
|
|
|
|
The production read surface maps to these wrapper types:
|
|
|
|
- `ArchestrA.HistorianAccess`
|
|
- `ArchestrA.HistorianConnectionArgs`
|
|
- `ArchestrA.HistoryQueryArgs`
|
|
- `ArchestrA.HistoryQuery`
|
|
- `ArchestrA.HistoryQueryResult`
|
|
- `ArchestrA.EventQueryArgs`
|
|
- `ArchestrA.EventQuery`
|
|
- `ArchestrA.HistorianEvent`
|
|
|
|
## Connection Facts
|
|
|
|
`HistorianConnectionArgs` defaults:
|
|
|
|
- `TcpPort = 32568`
|
|
- `ServerName = "localhost"`
|
|
- `ReadOnly = true`
|
|
- `PacketTimeout = 1000`
|
|
- `ConnectionType = Process`
|
|
- `Compression = false`
|
|
- `IntegratedSecurity = false`
|
|
|
|
`HistorianConnectionType` is a flags enum:
|
|
|
|
- `Process = 1`
|
|
- `Event = 2`
|
|
|
|
The wrapper keeps separate client slots for process/history and event
|
|
connections.
|
|
|
|
`HistorianAccess.OpenConnection` can return `true` while the native connection
|
|
is still pending. Callers must poll `HistorianAccess.GetConnectionStatus` and
|
|
wait for `ConnectedToServer = true` and `Pending = false` before starting
|
|
queries. Starting `HistoryQuery.StartQuery` immediately after `OpenConnection`
|
|
can return native `notConnected` (`242`).
|
|
|
|
## History Query Contract
|
|
|
|
`HistoryQuery.StartQuery` eventually calls native `HistorianClient.StartDataQuery`.
|
|
It does not build or call `RetrievalServiceContract.StartQuery2` directly from
|
|
managed C#. The managed wrapper converts `HistoryQueryArgs` into native
|
|
strings, arrays, selector buffers, and enum values, then hands those to the
|
|
native `HistorianClient` implementation. That native boundary is the next
|
|
capture point for exact data-query request bytes.
|
|
|
|
Confirmed arguments include:
|
|
|
|
- Retrieval mode: cast from `ArchestrA.HistorianRetrievalMode`.
|
|
- Query format: `0`.
|
|
- Summary type: `0`.
|
|
- Tag count and UTF-16 tag pointer array.
|
|
- Start/end timestamps converted with `DateTime.ToUniversalTime().ToFileTime()`.
|
|
- Resolution, value deadband, time deadband.
|
|
- Time zone string: `UTC`.
|
|
- Version type from `DataVersion`.
|
|
- Interpolation, timestamp, quality, value selector, and aggregation enums.
|
|
- Option and filter strings converted as UTF-16 buffers.
|
|
- Selected query-column buffer.
|
|
- Max states.
|
|
- Output query handle.
|
|
|
|
Decompiled selected-column stream construction is:
|
|
|
|
- `ushort` value `1`
|
|
- 8 bytes of `QueryColumnSelector` state after
|
|
`QueryColumnSelector.SelectNonSummaryColumns`
|
|
|
|
Focused IL extraction of `DataQueryRequest.Save<SByteStream<SCrtMemFile>>`
|
|
confirmed several request-buffer details used by the .NET 10 serializer probe:
|
|
|
|
- request packet version is `ushort 3` only when option text is exactly
|
|
`NoOption`; otherwise it is `ushort 9` and the option string is serialized.
|
|
- offset `300` is serialized as a packed `CQTIFlags` `ushort` before
|
|
option/filter text, not the query-column selector flags. The low byte is
|
|
interpolation (`None` is stored as `255`; `SystemDefault` `254` is normalized
|
|
to `255`), bits `8..11` are `HistorianTimestampRule`, and bits `12..15` are
|
|
`HistorianQualityRule`. Ordinary full reads therefore write `0x01FF`
|
|
(`Interpolation=None`, `TimestampRule=End`, `QualityRule=Extended`).
|
|
- the query-column selector is serialized later as `ushort 1` followed by the
|
|
exact 8-byte selector state.
|
|
- after the MDS and storage redundant endpoint blocks, the request writes the
|
|
raw 8-byte resolution tick field again.
|
|
- empty `CMetadataNamespace.Save` writes flag byte `1` and three empty
|
|
`SaveScrambled` strings; each empty scrambled string is `ushort 1` followed
|
|
by a single zero compressed-string marker byte.
|
|
- default `AutoSummaryParameters.Save` writes `ushort 2`, two zero `int64`
|
|
values, five empty compressed strings, and the trailing zero GUID.
|
|
- `CValueSelector.Save`, `CStateCalcSelector.Save`, and `CQTIFlags.Save` each
|
|
write a single `ushort` value.
|
|
- `SRedundantEndpoint.Save` writes endpoint name and then endpoint entries as
|
|
node-name and pipe-name strings; there is no numeric port field in this block.
|
|
|
|
The resulting byte count and pointer are passed to `StartDataQuery` as the
|
|
selected query-column buffer arguments.
|
|
|
|
The decompiled call site passes the following high-level arguments to
|
|
`HistorianClient.StartDataQuery`:
|
|
|
|
- client pointer from the process connection slot
|
|
- retrieval mode as `INSQL_QUERYTYPE`
|
|
- query format `0`
|
|
- summary type `0`
|
|
- tag count and native UTF-16 tag array
|
|
- source count `0` and null source array
|
|
- start/end as UTC FILETIME values
|
|
- resolution, value deadband, and time deadband
|
|
- literal timezone `UTC`
|
|
- data version, interpolation type, timestamp rule, quality rule
|
|
- option text, with `NoOption` substituted when the option is not available
|
|
- value selector, aggregation type, selected-column byte count and buffer
|
|
- filter text and max states
|
|
- output query handle and native error structure
|
|
|
|
This explains the current managed WCF negative evidence: the reconstructed
|
|
`DataQueryRequest.Save` buffer is only one downstream native serialization
|
|
artifact, while the public wrapper path reaches that serializer through
|
|
`HistorianClient.StartDataQuery`.
|
|
|
|
`BaseQueryArgs.ProcessQueryArgs` clears `Resolution` to `0` for `Full`, `Delta`,
|
|
and `Slope` retrieval modes.
|
|
|
|
`HistoryQuery.MoveNext` calls native `HistorianClient.GetNextRow<DataQueryResultRow>`.
|
|
|
|
## Runtime Method Pointer Evidence
|
|
|
|
`tools\AVEVA.Historian.NativeTraceHarness` can now dump prepared CLR runtime
|
|
method pointers with `--dump-method-pointers <filter>`. This is reverse-
|
|
engineering-only evidence for hook targeting; it is not production SDK code.
|
|
|
|
Important artifacts:
|
|
|
|
- `runtime-method-pointers-startquery-latest.json`
|
|
- `runtime-method-pointers-startdataquery-latest.json`
|
|
- `runtime-method-pointers-getnextrow-latest.json`
|
|
- `runtime-method-pointers-starteventquery-latest.json`
|
|
|
|
The dump must include `assembly.ManifestModule.GetMethods(...)` because many
|
|
useful mixed-mode functions are global `<Module>` methods rather than ordinary
|
|
managed type methods. Confirmed runtime-discoverable methods include:
|
|
|
|
- `<Module>.Query.StartDataQuery`
|
|
- `<Module>.HistorianClient.StartDataQuery`
|
|
- `<Module>.ClientApp.StartDataQuery`
|
|
- `<Module>.CRetrievalConnectionWCF.StartQuery2`
|
|
- `<Module>.Query.GetNextRow`
|
|
- `<Module>.HistorianClient.GetNextRow<class DataQueryResultRow>`
|
|
- `<Module>.Query.StartEventQuery`
|
|
- `<Module>.HistorianClient.StartEventQuery`
|
|
|
|
The prepared function pointers are process-specific CLR/JIT entry addresses.
|
|
They are outside the loaded `aahClientManaged.dll` image in the current runs
|
|
(`FunctionPointerInModule = false`, `FunctionPointerRva = null`), so they must
|
|
not be treated as stable DLL RVAs. They can still be useful if discovered inside
|
|
the same target process immediately before attaching a hook, or if a CLR
|
|
profiler/rewrite pass resolves the runtime target directly.
|
|
|
|
A same-process Frida pass using these absolute runtime pointers is also
|
|
negative evidence so far. `Attach-NativeTraceHarnessRuntimePointerCapture.ps1`
|
|
launched the native trace harness, wrote the runtime pointer snapshot before
|
|
`HistoryQuery.StartQuery`, paused, generated a temporary Frida script from those
|
|
exact addresses, and installed 37 hooks. The native direct history read still
|
|
succeeded, but no hook `enter`/`leave` callbacks fired. This suggests
|
|
`MethodHandle.GetFunctionPointer()` is not the callable edge used by the
|
|
mixed-mode wrapper path in a way Frida can intercept directly.
|
|
|
|
## IL Instrumentation Evidence
|
|
|
|
Focused `ildasm` excerpts are stored as:
|
|
|
|
- `ildasm-historyquery-startquery-excerpt-latest.il`
|
|
- `ildasm-historyquery-movenext-excerpt-latest.il`
|
|
- `ildasm-classlist-filtered-latest.txt`
|
|
|
|
These excerpts confirm `ArchestrA.HistoryQuery.StartQuery` is IL with a normal
|
|
metadata-token call to the mixed-mode target:
|
|
|
|
- call site: `IL_02d2`
|
|
- target: `HistorianClient.StartDataQuery`
|
|
- metadata token: `060055E4`
|
|
|
|
`ArchestrA.HistoryQuery.MoveNext` has the same shape for row retrieval:
|
|
|
|
- call site: `IL_0054`
|
|
- target: `HistorianClient.GetNextRow<class DataQueryResultRow>`
|
|
- metadata token: `0600588D`
|
|
|
|
This is now a proven instrumentation path. A dnlib `NativeWrite` copy of
|
|
`aahClientManaged.dll` loads and runs in the native trace harness when placed in
|
|
a disposable copied DLL folder. A reverse-only IL rewrite of
|
|
`<Module>.Query.StartDataQuery` token `0600574B` inserted a logger immediately
|
|
after `DataQueryRequest.Save<SByteStream<SCrtMemFile>>` and
|
|
`endstream`. The instrumented wrapper completed a local direct read and emitted
|
|
the serialized `StartDataQuery.Request` buffer before the native WCF call.
|
|
|
|
The read-boundary instrumentation now also logs the explicit
|
|
`HistorianClient.GetNextRow<class DataQueryResultRow>` `arg.1` query handle
|
|
before dumping the `arg.2` row memory. A combined local read captured these
|
|
records in one session:
|
|
|
|
- `StartDataQuery.Request`: length `251`, SHA-256
|
|
`543ea11af87607044067a0274b1da423cef2acbb7b4f4fab137af023a7153d7f`
|
|
- `GetNextRow.QueryHandle`: value `1`
|
|
- `GetNextRow.DataQueryResultRow`: length `512`, SHA-256
|
|
`702f5248cf8319e3e02da33678ed97dfaa43666bddb88c42101d5290990a4198`
|
|
|
|
The harness snapshot from the same run shows `HistoryQuery.queryHandle = 1`,
|
|
so the managed wrapper field, native `GetNextRow` argument, and row buffer are
|
|
now correlated without relying on inferred state.
|
|
|
|
The next successful run instrumented the native WCF retrieval boundary itself.
|
|
`CRetrievalConnectionWCF.StartQuery2` sent the same 251-byte request through
|
|
`RetrievalServiceContract.IRetrievalServiceContract2.StartQuery2` with
|
|
`clientHandle = 256524568` and `queryRequestType = 1`. The server returned a
|
|
31-byte response buffer, SHA-256
|
|
`4c062b5ce8181308f0f46bfd8c6088acb52e6ade94401651b7d3ccc8952edfb5`, and WCF
|
|
query handle `2967`. `CRetrievalConnectionWCF.GetNextQueryResultBuffer2` then
|
|
used `clientHandle = 256524568` and `queryHandle = 2967` and returned a
|
|
570-byte result buffer, SHA-256
|
|
`f4126e0610a4c63b3dff0e41e8d15e51d75fdbc736f3ec490e7c66d1bb31638d`.
|
|
|
|
This separates the two handles: `HistoryQuery.queryHandle = 1` is the
|
|
client-side `HistorianClient` query table handle, while `/Retr` uses the server
|
|
query handle returned from `StartQuery2`.
|
|
|
|
A later correlation run added scalar logging inside
|
|
`<Module>.HistorianClient.OpenConnection` and `<Module>.Query.StartDataQuery`.
|
|
`OpenConnection` wrote legacy native handle `2`, and `Query.StartDataQuery`
|
|
read the same `*(HistorianClient + 8)` value immediately before the vtable call
|
|
into the common retrieval layer. The WCF transport method still called
|
|
`IRetrievalServiceContract2.StartQuery2` with a different transient retrieval
|
|
client handle. That means the managed read blocker is not the public
|
|
`HistoryQuery.queryHandle`, not the legacy native handle, and not the
|
|
`DataQueryRequest` bytes; it is the session mapping maintained by the native
|
|
common retrieval layer before the WCF contract call. The sanitized correlation
|
|
summary is stored in `query-handle-correlation-latest.json`.
|
|
|
|
`aahClientCommon.CServerClient.GetHandle` returns `*(this + 2528)`, but an
|
|
instrumented copy of that accessor emitted no records during the same successful
|
|
history read path. A direct metadata-token reference scan also found no IL call
|
|
sites for token `060017F9`. The handle translation is therefore either direct
|
|
field access or a virtual/calli continuation below `CRetrieval.StartQuery2`, not
|
|
this accessor.
|
|
|
|
Additional probes ruled out that assumed `CRetrieval` branch for the successful
|
|
local WCF read path: instrumented copies of
|
|
`aahClientCommon.CRetrieval.StartQuery2`,
|
|
`aahClientCommon.CSrvRetrievalConnection.StartQuery`, and
|
|
`CRetrievalConsoleClient.StartQuery` emitted no records while the WCF boundary
|
|
hooks still fired. The active handoff is `aahClientCommon.CClientCommon.StartQuery`
|
|
token `06002E86`.
|
|
|
|
`CClientCommon.StartQuery` performs the missing handle lookup immediately before
|
|
calling the WCF retrieval method. The value returned by the `CClient` vtable
|
|
call at IL offset `0x01A3` exactly matches the
|
|
`CRetrievalConnectionWCF.StartQuery2` client handle. After the WCF call returns,
|
|
the `queryHandle` pointer contains the same server query handle later passed to
|
|
`GetNextQueryResultBuffer2`. This narrows the remaining session problem to
|
|
reproducing or replacing the native `CClient` open/session state that produces
|
|
the `/Retr` client handle, not rebuilding the data-query request envelope.
|
|
|
|
`CClientBase.OpenConnection` uses the same vtable offset `24` as a handle
|
|
accessor. At the beginning of open it returns `0`; after the secondary open
|
|
branch at IL offset `0x06D4` succeeds, the accessor returns the same transient
|
|
handle consumed by `CClientCommon.StartQuery` and WCF `StartQuery2`. The primary
|
|
open branch did not fire in the local integrated read capture. The next concrete
|
|
reverse-engineering target is the secondary open vtable call and the WCF/native
|
|
contract behind it.
|
|
|
|
That secondary branch is `CHistoryConnectionWCF.OpenConnection3`, which wraps
|
|
`IHistoryServiceContract2.OpenConnection2`. The successful local read capture
|
|
logged a 1346-byte OpenConnection3 request, a 42-byte response, and an empty
|
|
error buffer. In that response, byte `0` is `0x03` and the transient `/Retr`
|
|
client handle is UInt32 little-endian at offset `1`. Deserializing the 42-byte
|
|
response initializes the vtable offset `24` handle. Raw request bytes include
|
|
local identity/process metadata, so only hashes and redacted handle relationships
|
|
are documented in
|
|
`openconnection3-correlation-latest.json`.
|
|
|
|
The first captured full-history request for deterministic tag
|
|
`OtOpcUaParityTest_001.Counter` is 251 bytes with SHA-256
|
|
`3581ef3b42b59b46503d1aa0127fa60fe4b40943e419aeab99e47e4683888851`.
|
|
The sanitized byte dump is stored in
|
|
`startdataquery-request-buffer-latest.json`; raw generated binaries and runtime
|
|
capture files stay under ignored `artifacts\reverse-engineering`.
|
|
|
|
This fixture corrected the managed serializer in three places:
|
|
|
|
- `BatchSize` is not serialized in `DataQueryRequest`.
|
|
- A reserved `uint32 0` follows `SkipRows`.
|
|
- `AutoSummaryParameters` serializes as version `1`, two zero `int64` values,
|
|
five zero flag bytes, and a final `uint32 0`.
|
|
|
|
The production-side WCF request serializer now has a byte-for-byte test against
|
|
this native buffer.
|
|
|
|
The same instrumentation captured a `TimeWeightedAverage` aggregate
|
|
`StartDataQuery.Request` for a one-minute resolution window. It is also 251
|
|
bytes; SHA-256 is
|
|
`954874bf851bdea6333b8a8159f036e19b124b7a5febefb0cb9c9a8564b20981`, and the
|
|
sanitized dump is stored in
|
|
`startdataquery-timeweightedaverage-request-buffer-latest.json`.
|
|
|
|
Confirmed aggregate differences versus full-history:
|
|
|
|
- request query type at offset `0x02` is `5`
|
|
- first resolution field at offset `0x1E` is `double 600000000.0`
|
|
- later resolution field at offset `0xD0` is `int64 6000000000000`
|
|
- `BatchSize = 3` from `HistoryQueryArgs` is still not serialized in the
|
|
`DataQueryRequest` buffer
|
|
|
|
The managed serializer now has a second byte-for-byte fixture test for this
|
|
aggregate request.
|
|
|
|
An `Interpolated` request with the same one-minute resolution also serializes
|
|
to 251 bytes. SHA-256 is
|
|
`fc3a2fcc28d1926d2bd1de477e306cb0930e80a3327be6309b6e834e2951ca26`, and the
|
|
sanitized dump is stored in
|
|
`startdataquery-interpolated-request-buffer-latest.json`.
|
|
|
|
Confirmed interpolated request details:
|
|
|
|
- request query type at offset `0x02` is `3`
|
|
- first resolution field at offset `0x1E` is `double 600000000.0`
|
|
- later resolution field at offset `0xD0` is `int64 6000000000000`
|
|
- the rest of the default filter, metadata namespace, endpoint, and
|
|
auto-summary tail match the full-history and aggregate fixtures
|
|
|
|
The managed serializer now has byte-for-byte fixture coverage for full history,
|
|
time-weighted aggregate, and interpolated data requests.
|
|
|
|
A second IL rewrite instrumented
|
|
`<Module>.HistorianClient.GetNextRow<class DataQueryResultRow>` token
|
|
`0600588D` immediately after the call to
|
|
`Query.GetNextQueryResultRow<class DataQueryResultRow>` token `060058AF`.
|
|
The successful one-row local read emitted a 512-byte snapshot of the native
|
|
`DataQueryResultRow*` output structure. The sanitized first-pass dump is stored
|
|
in `getnextrow-dataqueryresultrow-memory-latest.json`; the artifact hash is
|
|
`2c2cb06988187c1bd7793a52a71f33599542a69d5e83885c583de8bf3df5d43b`.
|
|
|
|
Confirmed basic row-memory offsets for this full-history sample:
|
|
|
|
- offset `0x00`: `uint32` tag key `238`
|
|
- offset `0x28`: FILETIME UTC start timestamp
|
|
`2026-05-01T14:26:51.1956318Z`
|
|
- offset `0x30`: `uint32` quality `133`
|
|
- offset `0x34`: `uint32` quality detail `248`
|
|
- offset `0x38`: `uint32` OPC quality `192`
|
|
- offset `0x80`: `double` percent good `100`
|
|
|
|
The managed `HistoryQueryResult` reflection snapshot from the same run maps
|
|
these fields to tag name `OtOpcUaParityTest_001.Counter`, value `0`, quality
|
|
`133`, OPC quality `192`, quality detail `248`, and percent good `100`.
|
|
|
|
For `TimeWeightedAverage`, `GetNextRow` emits a different row-memory layout for
|
|
time bounds. The sanitized summary is in
|
|
`getnextrow-timeweightedaverage-memory-latest.json`.
|
|
|
|
Confirmed aggregate row offsets from the two returned rows:
|
|
|
|
- offset `0x00`: `uint32` tag key `238`
|
|
- offset `0x28`: managed `EndDateTime`
|
|
- offset `0x150`: managed `StartDateTime`
|
|
- offset `0x30`: `uint32` quality
|
|
- offset `0x34`: `uint32` quality detail
|
|
- offset `0x38`: `uint32` OPC quality
|
|
- offset `0x80`: `double` percent good
|
|
|
|
The third logged row snapshot duplicated the second row after the managed
|
|
enumerator reached native result code `30` (`No more data`), so row-result
|
|
instrumentation should correlate captures with the managed `MoveNext` return
|
|
status before treating every logged struct as a logical row.
|
|
|
|
`Interpolated` row memory follows the same time-bound layout in the captured
|
|
fixture. The sanitized summary is in
|
|
`getnextrow-interpolated-memory-latest.json`.
|
|
|
|
- offset `0x00`: `uint32` tag key `238`
|
|
- offset `0x28`: managed `EndDateTime`
|
|
- offset `0x150`: managed `StartDateTime`
|
|
- offset `0x30`: `uint32` quality `0`
|
|
- offset `0x34`: `uint32` quality detail `248`
|
|
- offset `0x38`: `uint32` OPC quality `192`
|
|
- offset `0x80`: `double` percent good `100`
|
|
|
|
The second logged interpolated row snapshot duplicated the first after
|
|
`No more data`, matching the aggregate instrumentation behavior.
|
|
|
|
Integrated-auth native harness evidence (`tools\AVEVA.Historian.NativeTraceHarness`,
|
|
latest JSON artifact
|
|
`docs\reverse-engineering\native-trace-harness-integrated-read-latest.json`)
|
|
confirms these runtime state transitions for
|
|
`OtOpcUaParityTest_001.Counter`:
|
|
|
|
- `HistorianAccess.OpenConnection` succeeds and sets
|
|
`clientHandlerArray[0] = 1` for the process connection slot.
|
|
- `HistoryQuery` starts with `queryHandle = 0`, `retrievalMode = Cyclic`,
|
|
`dataVersion = Original`, and no result object.
|
|
- `HistoryQueryArgs` for the read uses `RetrievalMode = Full`,
|
|
`DataVersion = Latest`, `BatchSize = 1`, `QualityRule = Extended`,
|
|
`TimeStampRule = End`, `InterpolationType = None`, `Filter = NoFilter`,
|
|
`ValueSelector = Auto`, and `AggregationType = Total`.
|
|
- `HistoryQuery.StartQuery` succeeds, normalizes `dataSourceId` from `null` to
|
|
empty string, allocates a `HistoryQueryResult`, and assigns
|
|
`queryHandle = 1`.
|
|
- First `MoveNext` returns one row with tag key `238`, value `0`,
|
|
quality `133`, OPC quality `192`, quality detail `248`, and
|
|
percent good `100`.
|
|
|
|
Later local runs show a distinction between local direct history reads and the
|
|
WCF history path:
|
|
|
|
- `--direct-connection` succeeds for Galaxy-discovered historized tags such as
|
|
`TestMachine_001.TestHistoryValue`, returning one row with value `0`, quality
|
|
`133`, OPC quality `192`, and quality detail `248`.
|
|
- non-direct local history reads currently open and connect successfully but
|
|
throw `SEHException` inside native `DataQueryResponse.Load` during
|
|
`HistoryQuery.StartQuery`; the hardened harness now reports this as
|
|
`StartQueryException` instead of crashing.
|
|
- event queries still succeed over the local non-direct path, so the Historian
|
|
service is reachable and the failure is specific to data query response
|
|
handling on the local WCF history path.
|
|
|
|
The harness accepts `--retrieval-mode`, `--start-utc`, `--end-utc`, and
|
|
`--resolution-ticks` so scenarios can be repeated with fixed UTC windows. Latest
|
|
native wrapper evidence artifacts:
|
|
|
|
- `native-trace-harness-full-latest.json`: `Full`, one raw row.
|
|
- `native-trace-harness-cyclic-latest.json`: `Cyclic`, 60-second resolution,
|
|
two rows.
|
|
- `native-trace-harness-interpolated-latest.json`: `Interpolated`, 60-second
|
|
resolution, one row.
|
|
- `native-trace-harness-timeweightedaverage-latest.json`:
|
|
`TimeWeightedAverage`, 60-second resolution, two rows.
|
|
|
|
`HistoryQueryResult` allocates a native `DataQueryResultRow` of 544 bytes.
|
|
Confirmed offsets used by the managed wrapper:
|
|
|
|
- `0`: tag key (`uint`)
|
|
- `4`: data type discriminator (`int`)
|
|
- `8`: tag name (`std::wstring`)
|
|
- `40`: end timestamp (`FILETIME`)
|
|
- `48`: quality (`ushort` read through `int`)
|
|
- `52`: quality detail (`uint`)
|
|
- `56`: OPC quality (`ushort` read through `int`)
|
|
- `72`: string value (`std::wstring`)
|
|
- `104`: numeric value (`double`)
|
|
- `128`: percent good (`double`)
|
|
- `336`: start timestamp (`FILETIME`)
|
|
- `400`: interpolation/timestamp/quality packed flags (`ushort`)
|
|
- `416`: source tag (`std::wstring`)
|
|
- `448`: source server (`std::wstring`)
|
|
- `488`: actual resolution in FILETIME ticks
|
|
|
|
## Enum Values Mirrored Into The .NET 10 Scaffold
|
|
|
|
`HistorianRetrievalMode`:
|
|
|
|
- `Cyclic = 0`
|
|
- `Delta = 1`
|
|
- `Full = 2`
|
|
- `Interpolated = 3`
|
|
- `BestFit = 4`
|
|
- `TimeWeightedAverage = 5`
|
|
- `MinimumWithTime = 6`
|
|
- `MaximumWithTime = 7`
|
|
- `Integral = 8`
|
|
- `Slope = 9`
|
|
- `Counter = 10`
|
|
- `ValueState = 11`
|
|
- `RoundTrip = 12`
|
|
- `StartBound = 13`
|
|
- `EndBound = 14`
|
|
|
|
Other confirmed enums:
|
|
|
|
- `HistorianInterpolationType`: `StairStep = 0`, `Linear = 1`,
|
|
`SystemDefault = 254`, `None = 255`
|
|
- `HistorianTimestampRule`: `Start = 0`, `End = 1`, `None = 2`
|
|
- `HistorianQualityRule`: `Extended = 0`, `Good = 1`, `None = 2`,
|
|
`Optimistic = 3`
|
|
- `HistorianValueSelector`: `Auto = 1`, `First = 2`, `Last = 3`,
|
|
`Integral = 4`, `StdDev = 5`, `Minimum = 6`, `Maximum = 7`,
|
|
`Average = 8`
|
|
- `HistorianAggregationType`: `Minimum = 0`, `Maximum = 1`, `Average = 2`,
|
|
`Total = 3`, `Percent = 4`, `MinContained = 5`, `MaxContained = 6`,
|
|
`TotalContained = 7`, `AverageContained = 8`, `PercentContained = 9`
|
|
|
|
## Retrieval Metadata Contract
|
|
|
|
Decompiled WCF retrieval contract signatures in `aahClientManaged.dll` match
|
|
the .NET 10 scaffold for the scalar and byte-buffer metadata calls:
|
|
|
|
- `GetTagTypeFromName(uint clientHandle, string tagName, out uint tagType)`
|
|
- `IsOriginalAllowed(uint clientHandle, out bool isAllowed)`
|
|
- `IsManualTag(uint clientHandle, string tagName, out bool isManual)`
|
|
- `GetTagInfoFromName(uint clientHandle, string tagName,
|
|
out uint tagMetadataByteCnt, out byte[] tagMetadata)`
|
|
- `GetTagInfosFromId(uint handle, uint tagIdsSize, byte[] tagIds,
|
|
ref uint sequence, out uint tagInfosSize, out byte[] tagInfos)`
|
|
- `GetTagInfosFromName(uint handle, uint tagNamesSize, byte[] tagNames,
|
|
ref uint sequence, out uint tagInfosSize, out byte[] tagInfos)`
|
|
|
|
Remote managed evidence against `10.100.0.48:32568` confirms the integrated
|
|
session handle works for scalar retrieval calls. For
|
|
`OtOpcUaParityTest_001.Counter`, `GetTagTypeFromName` returns type `1`,
|
|
`IsManualTag` returns `false`, and `GetTagInfoFromName` returns `238` with no
|
|
metadata bytes. `GetTgByNm` and `GetTg` buffer probes currently return the same
|
|
`238` and no output bytes for all tried encodings. That keeps exact metadata
|
|
buffer construction unresolved.
|
|
|
|
`ArchestrA.TagQuery.StartQuery` is a separate tag browse/query path. It calls
|
|
`HistorianClient.StartTagQuery` with a UTF-16 tag-filter pointer, an output
|
|
query handle, an output tag count, and `SError*`. The WCF continuation in
|
|
`CRetrievalConnectionWCF.StartTagQuery` serializes a request buffer before
|
|
calling `IRetrievalServiceContract3.StartTagQuery(handle, byte[], out byte[],
|
|
out byte[])`.
|
|
|
|
IL instrumentation of `CRetrievalConnectionWCF.StartTagQuery` token `06004A15`
|
|
captured the exact WCF request buffer for deterministic OData filter
|
|
`TagName eq 'OtOpcUaParityTest_001.Counter'`. The request is 92 bytes with
|
|
SHA-256 `af1dbcdd3eb0ad91a18882c22252aa74aff82998e96a39b63415ab4792a962ac`;
|
|
sanitized field notes are stored in `starttagquery-request-buffer-latest.json`.
|
|
|
|
Confirmed tag-query request fields:
|
|
|
|
- offset `0x00`: `ushort` marker `26449` (`0x6751`)
|
|
- offset `0x02`: `ushort` version `1`
|
|
- offset `0x04`: `uint32` filter character length
|
|
- offset `0x08`: UTF-16LE filter text without a trailing null
|
|
|
|
The same instrumentation also captures the successful WCF response byte array.
|
|
For the current local run the response is 8 bytes with SHA-256
|
|
`db49223a2cf9616171322e5325816a7a579582ebdce91c2f89df8df7aa8aac01`.
|
|
|
|
Confirmed start-tag-query response fields:
|
|
|
|
- offset `0x00`: `uint32` query handle
|
|
- offset `0x04`: `uint32` tag count
|
|
|
|
The local native tag query succeeds with that OData filter and returns
|
|
`TagCount = 1`, tag name
|
|
`OtOpcUaParityTest_001.Counter`, and metadata summary `TagKey = 238`,
|
|
`TagDataType = 4`, `TagStorageType = 3`, `EngineeringUnit = None`. Plain tag
|
|
name filters reach the server but fail with native failure code `1`, while
|
|
wildcard filters fail as invalid OData/WWFilter syntax. Response buffers and
|
|
the native `CTagMetadata` vector layout for `GetTagInfo` still need byte-level
|
|
instrumentation.
|
|
|
|
`TagQuery.GetTagNames` calls `HistorianClient.GetTagNames`, while
|
|
`TagQuery.GetTagInfo` calls `HistorianClient.GetTagInfos` and then
|
|
`HistorianTag.LoadFromTagMetadata` for each 224-byte `CTagMetadata` entry in
|
|
the returned native vector. The separate wildcard-style browse path is not
|
|
`TagQuery`; method inventory shows it as
|
|
`HistorianClient.StartLikeTagNameSearch` / `GetLikeTagnames` with WCF
|
|
continuations `CRetrievalConnectionWCF.StartLikeTagNameSearch` /
|
|
`GetLikeTagnames`.
|
|
|
|
IL instrumentation of `aahClientCommon.CClientCommon.GetTagInfos` token
|
|
`06002EC9` captured the pre-deserialization response stream used by
|
|
`TagQuery.GetTagInfo`. For the deterministic tag
|
|
`OtOpcUaParityTest_001.Counter`, the stream is 106 bytes with SHA-256
|
|
`77b2bf720d8888f08a1499a8162e706c2cef567a1f6d74d7e92efe0cd3e3e34b`;
|
|
sanitized details are stored in `tagquery-gettaginfo-response-latest.json`.
|
|
|
|
Confirmed `GetTagInfo` response stream fields for the current fixture:
|
|
|
|
- offset `0x0000`: `uint32` tag count
|
|
- offset `0x0004`: 4-byte native data-type descriptor; `CTagUtil.GetDataValueType`
|
|
maps this fixture to public `HistorianDataType = 4`
|
|
- offset `0x0008`: type GUID
|
|
- offset `0x0018`: `uint32` tag key
|
|
- offset `0x001C`: compact ASCII tag name: marker `0x09`, `uint16` byte length,
|
|
then bytes
|
|
- offset `0x003C`: compact ASCII metadata provider, observed as `MDAS`
|
|
- offset `0x0043`: native tag class byte
|
|
- offset `0x0044`: storage type byte
|
|
- offset `0x0045`: deadband type byte
|
|
- offset `0x0046`: interpolation type byte
|
|
|
|
The same instrumentation captured the native `std::vector<CTagMetadata>` after
|
|
`Load<CTagMetadata,SByteStream<SCrtMemFile>>`; the vector has 224-byte elements.
|
|
`HistorianTag.LoadFromTagMetadata` reads tag key from vector offset `0x0030`,
|
|
tag name from `0x0038`, description from `0x0058`, storage type from `0x00C1`,
|
|
deadband type from `0x00C2`, and inactive status from vector offset `0x0034`.
|
|
|
|
The wildcard browse path is confirmed separately through managed WCF calls to
|
|
`Retr.StartLikeTagNameSearch` and `Retr.GetLikeTagnames`. With integrated
|
|
Windows `Open2` and filter `OtOpcUaParityTest%`, `StartLikeTagNameSearch`
|
|
returns `0`, and `GetLikeTagnames` returns one 66-byte buffer with SHA-256
|
|
`2d450a55f392aed0026e9a957fefa3b116aab6ec81912c5d824c6b9a1ff5a4a1`.
|
|
The buffer layout for the one-tag fixture is `uint32 count`, then for each tag
|
|
`uint32 UTF-16 character length` and UTF-16LE tag name bytes. Sanitized details
|
|
are stored in `like-tag-browse-response-latest.json`.
|
|
|
|
## Event Query Contract
|
|
|
|
`EventQuery.StartQuery` calls native `HistorianClient.StartEventQuery`.
|
|
|
|
Confirmed arguments include:
|
|
|
|
- Start/end timestamps converted to FILETIME.
|
|
- Event count.
|
|
- Skip count.
|
|
- Event order as `ushort`.
|
|
- Filter block pointer.
|
|
- Time zone string: `UTC`.
|
|
- Output query handle.
|
|
|
|
The decompiled wrapper passes event query type value `1` into
|
|
`HistorianClient.StartEventQuery` for ordinary event reads. This is distinct
|
|
from the WCF retrieval `queryRequestType`: `Query.StartEventQuery` stores
|
|
`ushort 3` immediately before calling
|
|
`CRetrievalConnectionWCF.StartEventQuery`, so the WCF operation must use
|
|
`queryRequestType = 3`.
|
|
|
|
`EventQueryRequest.Save<SByteStream<SCrtMemFile>>` now has a byte-level field
|
|
order from `ildasm`:
|
|
|
|
- constant request version `ushort 5`
|
|
- start and end FILETIME values from the embedded `TIMERANGE`
|
|
- `eventCount` as `uint32`
|
|
- `skipCount` as `uint32`
|
|
- `eventOrder` as `uint16`
|
|
- `queryType` as `uint16`
|
|
- `EventQueryFilters.Save`
|
|
- result buffer size as `uint32`; native construction passes `0x10000`
|
|
- timezone `std::wstring`; ordinary reads pass `UTC`
|
|
- `CMetadataNamespace.Save`
|
|
- `EventQueryFilters.SaveSelect`
|
|
|
|
`EventQueryFilters.Save` writes `ushort 0`, a `uint32` filter count, each
|
|
filter, a continuation flag byte, and only writes continuation FILETIME/GUID
|
|
when that flag is non-zero. Empty event reads therefore serialize the filter
|
|
block as `00 00 00 00 00 00 00`. `SaveSelect` writes a `uint32` selected
|
|
property count followed by compressed strings; default event reads write count
|
|
zero.
|
|
|
|
`EventQuery` allocates `EventQueryFilters` as a 72-byte native object. It rejects
|
|
filters on `EventTime`, limits filter count to 500, and converts filter property
|
|
names and values to UTF-16 strings.
|
|
|
|
`EventQuery.MoveNext` calls `HistorianClient.GetNextRow<EventQueryResultRow>` and
|
|
then `HistorianEvent.CreateHistorianEvent`.
|
|
|
|
Integrated-auth native harness event evidence
|
|
(`native-trace-harness-event-latest.json`) confirms:
|
|
|
|
- Opening with `HistorianConnectionType = Event` succeeds and reaches
|
|
`ConnectedToServer = true`.
|
|
- `HistorianAccessUtil.SetConnectionMode(Event, integratedSecurity: true)`
|
|
produces Open2 connection mode `0x501` (`1281`). The process read-only
|
|
integrated mode is `0x402` (`1026`).
|
|
- Native `HistorianAccess.OpenConnection` creates the event connection at
|
|
`ConnectionIndex.Event` and then calls `CreateDefaultEventTag`.
|
|
`CreateDefaultEventTag` builds a synthetic `HistorianTag` named
|
|
`CM_EVENT`, sets description `AnE Event`, engineering unit `NONE`, data type
|
|
`10`, and calls `AddTagInternal`; the native
|
|
access snapshot then has `clientHandlerArray = [0, 1]` and
|
|
`eventTagHandle = 10000000`.
|
|
- `EventQueryArgs` uses UTC `StartDateTime` / `EndDateTime`, `QueryType =
|
|
Events`, `EventOrder = Ascending`, and `EventCount` from `--max-rows`.
|
|
- `EventQuery.StartQuery` succeeds with native `Success`.
|
|
- A seven-day local-dev window returned three sanitized event rows; raw event
|
|
IDs and opaque event source details are not written by the harness.
|
|
- A managed WCF `StartEventQuery` probe using this reconstructed version-5
|
|
empty-filter request and `queryRequestType = 3` reaches
|
|
`/Retr.StartEventQuery` but returns `false` with no native error buffer. This
|
|
remains true for process-mode Open2 (`1026`) and event-mode Open2 (`1281`),
|
|
and for the returned Open2 handle, the native `ClientApp` event slot handle
|
|
`1`, and the observed event tag handle `10000000`.
|
|
- `CRetrievalConnectionWCF.StartEventQuery` has a server-interface-version
|
|
branch: interface versions `<= 2` call `/Retr.StartQuery2`; newer versions
|
|
call `/Retr.StartEventQuery`. The managed probe now tries both operations
|
|
with the same reconstructed request. On the local 2020 server, both operation
|
|
paths still return `false` with no native error buffer, so the mismatch is
|
|
likely connection/session state rather than this dispatch branch.
|
|
- Because native event open performs `CreateDefaultEventTag` before event query
|
|
start, the next gap is likely the `HistorianClient.AddHistorianTag` /
|
|
`AddTagInternal` setup for `CM_EVENT`, not the basic event request field
|
|
order or WCF contract signature.
|
|
- Follow-up decompile of `aahClientCommon.CClient.RegisterTag` shows this part
|
|
is client-local: it calls `RegisterRealtimeTag`, inserts the tag id into
|
|
local sets, marks the cached `CTagInfo` as registered, and flips
|
|
synchronization flags. There is still a server-visible registration path
|
|
elsewhere (`IHistoryServiceContract.RTag` / storage `RTag`), but
|
|
`CreateDefaultEventTag` does not directly prove its byte payload.
|
|
- The server-visible `RTag2` WCF edge is now partially mapped. Decompiled
|
|
`CHistoryConnectionWCF.RegisterTags` converts a native `_GUID Handle` to a
|
|
string, calls `IHistoryServiceContract2.RegisterTags2` (`RTag2`), and passes a
|
|
byte buffer serialized as `uint32 count` followed by `count` 16-byte `_GUID`
|
|
values. The local managed probe `wcf-register-event-tag` tried empty,
|
|
zero-GUID, and event-handle-shaped GUID vectors with Open2-derived handle
|
|
candidates. All failed before event query start could succeed: first-16
|
|
handle candidates returned native error `51`, while the bytes 4-19 .NET GUID
|
|
candidate reached the server but returned native error `1`. This makes simple
|
|
`RTag2` replay unlikely to be the missing event setup by itself; the richer
|
|
`AddHistorianTag`/`CTagMetadata` path remains the lead.
|
|
- `HistorianClient.ConvertEventTagToTagMetadata` now has a concrete event-tag
|
|
metadata map from IL/native data:
|
|
- synthetic tag name: `CM_EVENT`
|
|
- description from `CreateDefaultEventTag`: `AnE Event`
|
|
- event tag id: `353b8145-5df0-4d46-a253-871aef49b321`
|
|
- common ArchestrA event type id:
|
|
`5f59ae42-3bb6-4760-91a5-ab0be01f2f27`
|
|
- ArchestrA event type id:
|
|
`f3ef1a17-fd27-4c3c-84cf-bac55c0e47de`
|
|
- converted `CDataType` byte: `5`
|
|
- storage type passed to `CTagMetadata.Initialize`: `2`
|
|
- `CTagMetadata.Save<SByteStream<SCrtMemFile>>` writes a one-byte serialization
|
|
version, a two-byte optional-field mask, the `CDataType` byte, the 16-byte tag
|
|
id, and compressed strings for present string fields. Short ASCII UTF-16
|
|
strings use the compact form `09 <length> 00 <ASCII bytes>`, so `CM_EVENT`
|
|
serializes as `09 08 00 43 4d 5f 45 56 45 4e 54`.
|
|
- A direct managed replay of this approximate event metadata through
|
|
`IHistoryServiceContract.AddTags` (`AddT`) is negative evidence:
|
|
`wcf-add-event-tag` opened `/Hist-Integrated`, tried both a raw
|
|
`CTagMetadata` payload and a vector-count-prefixed payload, and both returned
|
|
`AddReturnCode = 4` with no output buffer. The subsequent
|
|
`StartEventQuery` still returned `false`. This means the missing native event
|
|
setup is not reproduced by the current `AddT` approximation.
|
|
- A fake WCF capture server on an alternate port exposed `/Hist`,
|
|
`/Hist-Integrated`, and `/Retr`, but the native event harness still reached
|
|
the real local Historian and the fake server saw no calls. The
|
|
`HistorianConnectionArgs.TcpPort` field does not redirect this native WCF
|
|
path sufficiently for server-side capture.
|
|
- The expanded Frida event add-tag pass hooked the candidate MethodDef RVAs
|
|
before `OpenConnection` and the native event query still succeeded, but no
|
|
enter/leave callbacks fired. MethodDef RVAs remain insufficient hook targets
|
|
for this mixed-mode wrapper; use CLR method rewriting/profiling or lower-level
|
|
transport/API capture next.
|
|
- IL instrumentation of `<Module>.Query.StartEventQuery` token `0600574A`
|
|
now captures the exact native `EventQueryRequest` buffer immediately after
|
|
`EventQueryRequest.Save<SByteStream<SCrtMemFile>>` and `endstream`. A local
|
|
seven-day event query succeeded and returned three rows while logging a
|
|
65-byte request. SHA-256 is
|
|
`6b955b02087047a3199a8c74f3eee85c3b49aaa29b05de12eff2dd536f2da0d5`, and
|
|
the sanitized dump is stored in `starteventquery-request-buffer-latest.json`.
|
|
The managed event query serializer now has a byte-for-byte test against this
|
|
native fixture.
|
|
- A matching IL rewrite of
|
|
`<Module>.HistorianClient.GetNextRow<class EventQueryResultRow>` token
|
|
`06005965` captures native `EventQueryResultRow*` memory immediately after
|
|
`Query.GetNextEventQueryResultRow<class EventQueryResultRow>` returns. The
|
|
successful local seven-day event query emitted three 2048-byte raw snapshots
|
|
under ignored `artifacts\reverse-engineering`; the sanitized summary is
|
|
stored in `getnexteventrow-memory-latest.json`.
|
|
|
|
Confirmed first-pass event row evidence:
|
|
|
|
- offset `0x18`: managed `HistorianEvent.EventTime` as FILETIME UTC for all
|
|
three rows
|
|
- `ReceivedTime` was not present as a direct FILETIME value in the captured
|
|
2048-byte native struct snapshot
|
|
- managed event type was `User.Write.Secured` for all three rows, but the raw
|
|
struct contains pointer-like fields and no safely decoded inline event-type
|
|
string yet
|
|
|
|
This keeps event result parsing unresolved, but it proves the IL rewrite path
|
|
can capture both event request bytes and native event row memory without adding
|
|
production dependencies.
|
|
|
|
## Tag Query Session Evidence
|
|
|
|
`CRetrievalConnectionWCF.StartTagQuery` does not carry the tag filter in the
|
|
latest successful WCF call. Combined IL instrumentation of native
|
|
`OpenConnection` plus tag-query start captured a successful local tag metadata
|
|
query where the WCF `StartTagQuery` request was only:
|
|
|
|
- marker/version bytes: `51 67 01 00`
|
|
- SHA-256:
|
|
`17956e4fbe53d5edc0f9170203b013432e4afcc0591c795a10522a98d9fce926`
|
|
- response bytes: `0C 00 00 00 01 00 00 00`
|
|
- parsed response: query handle `12`, tag count `1`
|
|
|
|
The tag filter `TagName eq 'OtOpcUaParityTest_001.Counter'` was already
|
|
processed before this WCF request. The native WCF handle string is an uppercase
|
|
GUID generated for the native retrieval session; it is not the numeric Open2
|
|
handle, the Open2 storage session id, the first 16 Open2 bytes, or the full
|
|
Open2 output. Managed replay of both the older 92-byte filter-shaped candidate
|
|
and the latest 4-byte header-only request with all Open2-derived handle
|
|
candidates returned native errors `51` or `1`.
|
|
|
|
Conclusion: public metadata should not be wired through `StartTagQuery`.
|
|
`BrowseTagNamesAsync` can stay on the proven `StartLikeTagNameSearch` /
|
|
`GetLikeTagnames` path. `GetTagMetadataAsync` now uses the direct
|
|
`GetTagInfoFromName` WCF path, which returned a 98-byte metadata record for the
|
|
deterministic parity tag. More guessed `StartTagQuery` envelopes are not the
|
|
useful next target; the missing evidence is the native session registration or
|
|
the earlier metadata-layer call that binds the filter to the retrieval GUID.
|
|
|
|
## Immediate Unknowns
|
|
|
|
These still require native ABI inspection and packet captures:
|
|
|
|
- Session handshake frame.
|
|
- Authentication frame.
|
|
- Query frame envelope and length/correlation fields.
|
|
- Native `StartDataQuery` message type identifier.
|
|
- Native `StartEventQuery` message type identifier.
|
|
- `GetNextRow` request/response frame shape.
|
|
- Error frame layout.
|
|
|
|
See `native-exports.md` for the first PE export-table pass over
|
|
`aahClient.dll`.
|