R0.1 browse over gRPC SHIPPED — QueryTag cracked, M0 gRPC parity complete

Wires HistorianClient.BrowseTagNamesAsync over gRPC (Transport==RemoteGrpc) via
Grpc/HistorianGrpcTagClient.BrowseTagNamesAsync: StartTagQuery(OData) -> paged
QueryTag -> EndTagQuery. Live-verified against a real 2023 R2 server (returns Sys* tags).

QueryTag packet-id recovered WITHOUT native disassembly: a .rdata packet-descriptor
table in aahClientManaged.dll lists {0x6751,1}=StartTagQuery immediately followed by
{0x6752,1}=QueryTag (found via pefile byte-scan of .rdata), confirmed live.

Wire format (live-verified):
- request btRequest = u16 0x6752 + u16 version(1) + u16 queryType(1=names) + u32 startIndex + u32 count
- response btResonse = u32 count + per-name(u32 charCount + UTF-16LE) + trailer (NextIndex/metadata, ignored)
- new HistorianTagQueryProtocol.ParseTagNameQueryPage tolerates the trailer
- GlobToODataFilter translates the SDK glob filter to OData (Pre*->startswith, *suf->endswith,
  *mid*->contains, exact->eq); the 2023 R2 metadata-server parses filters as OData.

Replaces the earlier RE probe helpers with the shipped browse path. Adds golden-byte
(BuildQueryTagRequest) + 8 glob-translation unit tests + gated live browse test.
226 unit tests pass; 5/5 live gRPC tests pass (read, probe, system-param, metadata, browse).

Milestone 0 (full gRPC parity) is complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-21 16:01:15 -04:00
parent 630295bd18
commit 85ff1b48df
7 changed files with 244 additions and 115 deletions
@@ -28,45 +28,30 @@ Success response `btResponse` is the 8-byte `(queryHandle:uint, tagCount:uint)`
`*` → empty, `Pre*` → `startswith(TagName,'Pre')`, `*sub*` → `contains(TagName,'sub')`,
exact → `TagName eq '...'`. (Escaping single-quotes in names still TBD.)
## QueryTag — OPEN (needs native RE of aahClient.dll)
## QueryTag — CRACKED (2026-06-21), browse SHIPPED
`QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that returns the tag-name rows.
Every `btRequest` shape tried returns the constant native error **type 4 / code 72 =
`InvalidPacketId`** (`ArchestrA.CloudHistorian.Contract.ErrorCode.InvalidPacketId = 72`; the empty
buffer alone gives a different code). So the `btRequest` must carry a **packet-id header specific to
QueryTag** that we don't have — the generic `0x6751`/version-1 header (which StartTagQuery accepts) is
rejected here.
The blocker was the packet id: every guessed `btRequest` returned native error **type 4 / code 72 =
`InvalidPacketId`** (`ArchestrA.CloudHistorian.Contract.ErrorCode.InvalidPacketId`). The generic
`0x6751` header that StartTagQuery accepts is the **wrong** id for QueryTag.
**Semantic fields are known** from `ArchestrA.CloudHistorian.Contract`:
- request `QueryTagRequest` = `QueryHandle:uint("q") + QueryType:ushort("t") + StartIndex:uint("s") + TagCount:uint("c")`
- response `QueryTagResponse` = `QueryHandle:uint + TagNames:string[]("t") + NextIndex:uint("i") + TagMetadataBuffer:byte[]("tb")`
— so QueryTag returns the names directly (plus an optional metadata buffer), and pages via StartIndex/NextIndex.
**How it was found (no Ghidra needed):** a `.rdata` **packet-descriptor table** in
`aahClientManaged.dll` lists consecutive `{uint marker, uint version}` entries —
`{0x6751, 1}` (StartTagQuery) immediately followed by **`{0x6752, 1}`** (the paired op). Found by
`pefile` byte-scan of `.rdata` for `51 67 00 00` and dumping the surrounding dwords. Testing `0x6752`
live confirmed it.
What's missing is the **binary `btRequest` packet framing** (the QueryTag packet id + how those fields
are laid out). That serializer lives in **native `aahClient.dll`** — `aahClientManaged.dll` is
**mixed-mode (C++/CLI)** so ilspycmd cannot decompile it, and no managed assembly builds the buffer.
Completing QueryTag therefore requires **native RE (Ghidra/IDA on `aahClient.dll`)** or a **live gRPC
capture** of the stock 2023 R2 client browsing. Do not ship a guessed QueryTag request (project
discipline: no guessed wire bytes).
**QueryTag wire format (live-verified):**
- request `btRequest` = `u16 marker(0x6752) + u16 version(1) + u16 queryType + u32 startIndex + u32 count`
— `queryType = 1` returns tag-name rows (`queryType = 0` returns an empty/count-only page).
- response `btResonse` = `u32 count + per-name(u32 charCount + UTF-16LE) + trailer`
(the trailer is the CloudHistorian `NextIndex`/`TagMetadataBuffer` region — ignored by
`HistorianTagQueryProtocol.ParseTagNameQueryPage`).
- Semantic fields match `ArchestrA.CloudHistorian.Contract.QueryTagRequest`
(`QueryType/StartIndex/TagCount`; the QueryHandle travels in the protobuf `uiQueryHandle`).
Probe helpers live in `Grpc/HistorianGrpcTagClient` (`ProbeStartTagQuery`, `ProbeTagQuerySequence`)
and the gated `StartTagQuery_OverGrpc_AcceptsODataFilter` test pins the StartTagQuery+OData result.
### Native-RE attempt (2026-06-21) — needs Ghidra/IDA
The QueryTag packet-id is built in native code (the C++ HCAL inside the **mixed-mode**
`aahClientManaged.dll`). Lightweight tooling was exhausted and is insufficient:
- **ilspycmd** cannot decompile or even list the mixed-mode assembly (throws).
- **capstone** byte-search: the StartTagQuery marker `0x6751` is **not** a plain `mov` immediate in
`.text` (no `66 C7 … 51 67` and no clean `B8 51 67 00 00`); the three `51 67 00 00` hits in `.text`
are coincidental jump-table data (disassembly around them is garbage). The constant lives in the
`.rdata` pool (which holds the `51 67` bytes) and is loaded RIP-relative — so the serializer can't be
found by immediate-scanning without cross-reference analysis.
- Managed metadata (`ArchestrA.CloudHistorian.Contract`) gives the field semantics —
`QueryTagRequest{SessionId, QueryHandle:uint, QueryType:ushort, StartIndex:uint, TagCount:uint}`,
DataContract code `QTLQ` (StartTagQuery = `TLQR`) — but **not** the binary packet-id/framing.
Finishing QueryTag therefore requires **Ghidra/IDA** (decompiler + xref to the `.rdata` marker constant
to find the serializer and read the QueryTag packet-id + byte layout), or the **live capture** option
(IL-rewrite `Archestra.Historian.GrpcClient.QueryTag` to log `requestBuffer` while a real 2023 R2
client browses). StartTagQuery (the hard OData part) remains cracked and live-verified.
**Browse is shipped:** `HistorianClient.BrowseTagNamesAsync` routes over gRPC when
`Transport==RemoteGrpc` via `Grpc/HistorianGrpcTagClient.BrowseTagNamesAsync`
(StartTagQuery(OData) → paged QueryTag(0x6752) → EndTagQuery), with the SDK glob filter translated by
`GlobToODataFilter`. Golden-byte + glob unit tests and a gated live test
(`BrowseTagNamesAsync_OverGrpc_ReturnsSystemTags`) cover it. **M0 gRPC parity is complete.**