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>
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.HistorianAccessArchestrA.HistorianConnectionArgsArchestrA.HistoryQueryArgsArchestrA.HistoryQueryArchestrA.HistoryQueryResultArchestrA.EventQueryArgsArchestrA.EventQueryArchestrA.HistorianEvent
Connection Facts
HistorianConnectionArgs defaults:
TcpPort = 32568ServerName = "localhost"ReadOnly = truePacketTimeout = 1000ConnectionType = ProcessCompression = falseIntegratedSecurity = false
HistorianConnectionType is a flags enum:
Process = 1Event = 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:
ushortvalue1- 8 bytes of
QueryColumnSelectorstate afterQueryColumnSelector.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 3only when option text is exactlyNoOption; otherwise it isushort 9and the option string is serialized. - offset
300is serialized as a packedCQTIFlagsushortbefore option/filter text, not the query-column selector flags. The low byte is interpolation (Noneis stored as255;SystemDefault254is normalized to255), bits8..11areHistorianTimestampRule, and bits12..15areHistorianQualityRule. Ordinary full reads therefore write0x01FF(Interpolation=None,TimestampRule=End,QualityRule=Extended). - the query-column selector is serialized later as
ushort 1followed 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.Savewrites flag byte1and three emptySaveScrambledstrings; each empty scrambled string isushort 1followed by a single zero compressed-string marker byte. - default
AutoSummaryParameters.Savewritesushort 2, two zeroint64values, five empty compressed strings, and the trailing zero GUID. CValueSelector.Save,CStateCalcSelector.Save, andCQTIFlags.Saveeach write a singleushortvalue.SRedundantEndpoint.Savewrites 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
0and 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
NoOptionsubstituted 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.jsonruntime-method-pointers-startdataquery-latest.jsonruntime-method-pointers-getnextrow-latest.jsonruntime-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.ilildasm-historyquery-movenext-excerpt-latest.ilildasm-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: length251, SHA-256543ea11af87607044067a0274b1da423cef2acbb7b4f4fab137af023a7153d7fGetNextRow.QueryHandle: value1GetNextRow.DataQueryResultRow: length512, SHA-256702f5248cf8319e3e02da33678ed97dfaa43666bddb88c42101d5290990a4198
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:
BatchSizeis not serialized inDataQueryRequest.- A reserved
uint32 0followsSkipRows. AutoSummaryParametersserializes as version1, two zeroint64values, five zero flag bytes, and a finaluint32 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
0x02is5 - first resolution field at offset
0x1Eisdouble 600000000.0 - later resolution field at offset
0xD0isint64 6000000000000 BatchSize = 3fromHistoryQueryArgsis still not serialized in theDataQueryRequestbuffer
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
0x02is3 - first resolution field at offset
0x1Eisdouble 600000000.0 - later resolution field at offset
0xD0isint64 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:uint32tag key238 - offset
0x28: FILETIME UTC start timestamp2026-05-01T14:26:51.1956318Z - offset
0x30:uint32quality133 - offset
0x34:uint32quality detail248 - offset
0x38:uint32OPC quality192 - offset
0x80:doublepercent good100
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:uint32tag key238 - offset
0x28: managedEndDateTime - offset
0x150: managedStartDateTime - offset
0x30:uint32quality - offset
0x34:uint32quality detail - offset
0x38:uint32OPC quality - offset
0x80:doublepercent 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:uint32tag key238 - offset
0x28: managedEndDateTime - offset
0x150: managedStartDateTime - offset
0x30:uint32quality0 - offset
0x34:uint32quality detail248 - offset
0x38:uint32OPC quality192 - offset
0x80:doublepercent good100
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.OpenConnectionsucceeds and setsclientHandlerArray[0] = 1for the process connection slot.HistoryQuerystarts withqueryHandle = 0,retrievalMode = Cyclic,dataVersion = Original, and no result object.HistoryQueryArgsfor the read usesRetrievalMode = Full,DataVersion = Latest,BatchSize = 1,QualityRule = Extended,TimeStampRule = End,InterpolationType = None,Filter = NoFilter,ValueSelector = Auto, andAggregationType = Total.HistoryQuery.StartQuerysucceeds, normalizesdataSourceIdfromnullto empty string, allocates aHistoryQueryResult, and assignsqueryHandle = 1.- First
MoveNextreturns one row with tag key238, value0, quality133, OPC quality192, quality detail248, and percent good100.
Later local runs show a distinction between local direct history reads and the WCF history path:
--direct-connectionsucceeds for Galaxy-discovered historized tags such asTestMachine_001.TestHistoryValue, returning one row with value0, quality133, OPC quality192, and quality detail248.- non-direct local history reads currently open and connect successfully but
throw
SEHExceptioninside nativeDataQueryResponse.LoadduringHistoryQuery.StartQuery; the hardened harness now reports this asStartQueryExceptioninstead 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 (ushortread throughint)52: quality detail (uint)56: OPC quality (ushortread throughint)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 = 0Delta = 1Full = 2Interpolated = 3BestFit = 4TimeWeightedAverage = 5MinimumWithTime = 6MaximumWithTime = 7Integral = 8Slope = 9Counter = 10ValueState = 11RoundTrip = 12StartBound = 13EndBound = 14
Other confirmed enums:
HistorianInterpolationType:StairStep = 0,Linear = 1,SystemDefault = 254,None = 255HistorianTimestampRule:Start = 0,End = 1,None = 2HistorianQualityRule:Extended = 0,Good = 1,None = 2,Optimistic = 3HistorianValueSelector:Auto = 1,First = 2,Last = 3,Integral = 4,StdDev = 5,Minimum = 6,Maximum = 7,Average = 8HistorianAggregationType: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:ushortmarker26449(0x6751) - offset
0x02:ushortversion1 - offset
0x04:uint32filter 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:uint32query handle - offset
0x04:uint32tag 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:uint32tag count - offset
0x0004: 4-byte native data-type descriptor;CTagUtil.GetDataValueTypemaps this fixture to publicHistorianDataType = 4 - offset
0x0008: type GUID - offset
0x0018:uint32tag key - offset
0x001C: compact ASCII tag name: marker0x09,uint16byte length, then bytes - offset
0x003C: compact ASCII metadata provider, observed asMDAS - 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 eventCountasuint32skipCountasuint32eventOrderasuint16queryTypeasuint16EventQueryFilters.Save- result buffer size as
uint32; native construction passes0x10000 - timezone
std::wstring; ordinary reads passUTC CMetadataNamespace.SaveEventQueryFilters.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 = Eventsucceeds and reachesConnectedToServer = true. HistorianAccessUtil.SetConnectionMode(Event, integratedSecurity: true)produces Open2 connection mode0x501(1281). The process read-only integrated mode is0x402(1026).- Native
HistorianAccess.OpenConnectioncreates the event connection atConnectionIndex.Eventand then callsCreateDefaultEventTag.CreateDefaultEventTagbuilds a syntheticHistorianTagnamedCM_EVENT, sets descriptionAnE Event, engineering unitNONE, data type10, and callsAddTagInternal; the native access snapshot then hasclientHandlerArray = [0, 1]andeventTagHandle = 10000000. EventQueryArgsuses UTCStartDateTime/EndDateTime,QueryType = Events,EventOrder = Ascending, andEventCountfrom--max-rows.EventQuery.StartQuerysucceeds with nativeSuccess.- 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
StartEventQueryprobe using this reconstructed version-5 empty-filter request andqueryRequestType = 3reaches/Retr.StartEventQuerybut returnsfalsewith no native error buffer. This remains true for process-mode Open2 (1026) and event-mode Open2 (1281), and for the returned Open2 handle, the nativeClientAppevent slot handle1, and the observed event tag handle10000000. CRetrievalConnectionWCF.StartEventQueryhas a server-interface-version branch: interface versions<= 2call/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 returnfalsewith no native error buffer, so the mismatch is likely connection/session state rather than this dispatch branch.- Because native event open performs
CreateDefaultEventTagbefore event query start, the next gap is likely theHistorianClient.AddHistorianTag/AddTagInternalsetup forCM_EVENT, not the basic event request field order or WCF contract signature. - Follow-up decompile of
aahClientCommon.CClient.RegisterTagshows this part is client-local: it callsRegisterRealtimeTag, inserts the tag id into local sets, marks the cachedCTagInfoas registered, and flips synchronization flags. There is still a server-visible registration path elsewhere (IHistoryServiceContract.RTag/ storageRTag), butCreateDefaultEventTagdoes not directly prove its byte payload. - The server-visible
RTag2WCF edge is now partially mapped. DecompiledCHistoryConnectionWCF.RegisterTagsconverts a native_GUID Handleto a string, callsIHistoryServiceContract2.RegisterTags2(RTag2), and passes a byte buffer serialized asuint32 countfollowed bycount16-byte_GUIDvalues. The local managed probewcf-register-event-tagtried 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 error51, while the bytes 4-19 .NET GUID candidate reached the server but returned native error1. This makes simpleRTag2replay unlikely to be the missing event setup by itself; the richerAddHistorianTag/CTagMetadatapath remains the lead. HistorianClient.ConvertEventTagToTagMetadatanow 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
CDataTypebyte:5 - storage type passed to
CTagMetadata.Initialize:2
- synthetic tag name:
CTagMetadata.Save<SByteStream<SCrtMemFile>>writes a one-byte serialization version, a two-byte optional-field mask, theCDataTypebyte, the 16-byte tag id, and compressed strings for present string fields. Short ASCII UTF-16 strings use the compact form09 <length> 00 <ASCII bytes>, soCM_EVENTserializes as09 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-tagopened/Hist-Integrated, tried both a rawCTagMetadatapayload and a vector-count-prefixed payload, and both returnedAddReturnCode = 4with no output buffer. The subsequentStartEventQuerystill returnedfalse. This means the missing native event setup is not reproduced by the currentAddTapproximation. - 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. TheHistorianConnectionArgs.TcpPortfield 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
OpenConnectionand 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.StartEventQuerytoken0600574Anow captures the exact nativeEventQueryRequestbuffer immediately afterEventQueryRequest.Save<SByteStream<SCrtMemFile>>andendstream. A local seven-day event query succeeded and returned three rows while logging a 65-byte request. SHA-256 is6b955b02087047a3199a8c74f3eee85c3b49aaa29b05de12eff2dd536f2da0d5, and the sanitized dump is stored instarteventquery-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>token06005965captures nativeEventQueryResultRow*memory immediately afterQuery.GetNextEventQueryResultRow<class EventQueryResultRow>returns. The successful local seven-day event query emitted three 2048-byte raw snapshots under ignoredartifacts\reverse-engineering; the sanitized summary is stored ingetnexteventrow-memory-latest.json.
Confirmed first-pass event row evidence:
- offset
0x18: managedHistorianEvent.EventTimeas FILETIME UTC for all three rows ReceivedTimewas not present as a direct FILETIME value in the captured 2048-byte native struct snapshot- managed event type was
User.Write.Securedfor 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 count1
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
StartDataQuerymessage type identifier. - Native
StartEventQuerymessage type identifier. GetNextRowrequest/response frame shape.- Error frame layout.
See native-exports.md for the first PE export-table pass over
aahClient.dll.