Files
histsdk/docs/reverse-engineering/managed-wrapper-findings.md
dohertj2 c95824a65d Initial commit: managed .NET 10 AVEVA Historian SDK + reverse-engineering toolkit
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>
2026-05-04 06:31:48 -04:00

39 KiB

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.