R1.4 GetHistorianInfo: bounded out on 2020 WCF (named-value-only, no struct)

Captured the native HistorianAccess.GetHistorianInfo(out HistorianInfo, out err)
and decoded the wire: over 2020 WCF, GETHI is a named-value query whose only
working key is "HistorianVersion" (response ~30 bytes = the version string).
Probed 7 storage-mode key names -> all ok=False/err. The 518-byte HISTORIAN_INFO
struct + EventStorageMode@514 is the 2023R2 HCAL-native/gRPC model (confirmed
from the decompiled 2023R2 source); on 2020 the native client derives the mode
outside the WCF wire.

Version is already exposed (ProbeAsync/GetRuntimeParameterAsync), so no hollow
GetHistorianInfoAsync is shipped (same disposition as R1.3 timezone). This
completes the reachable 2020-WCF M1 read surface; remaining M1 = config writes
(gated on explicit request) or gRPC/2023R2-only items.

RE aids kept: harness `historian-info` scenario, Capture-HistorianInfo.ps1,
decode-historian-info-capture.py, and StringHandleProbeDiagnosticTests
.GETHI_CandidateInfoNames (asserts the named-value-only finding; gated).
Docs: wcf-historian-info.md (new) + roadmap/matrix/wall-doc updates. 230 tests green.

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-20 23:42:27 -04:00
parent 1a539882d0
commit fbd839077b
8 changed files with 441 additions and 7 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ blob needs RE).
|---|---|---|---|---|---|
| System parameter | `GetSystemParameter` | `Status.GetSystemParameter` | ✅ | DONE | |
| Runtime parameter | `GetRuntimeParameter` | `Status.GetRuntimeParameter` | ⬜ | TRIVIAL | same shape as GetSystemParameter |
| Historian info | `GetHistorianInfo` | `Status.GetHistorianInfo` | 🟗 | BOUNDED | GETHI buffer; partially decoded (incl. EventStorageMode @ offset 514) |
| Historian info | `GetHistorianInfo` | `Status.GetHistorianInfo` | 🟗 | BOUNDED | **2020 WCF = version-only** (GETHI is a named-value query; `EventStorageMode` not on the wire). 518-byte struct + `EventStorageMode`@514 is gRPC/2023R2-only. See `wcf-historian-info.md` |
| Server timezone | `GetSystemTimeZoneInfo` | `Status.GetSystemTimeZoneName` | ⬜ | TRIVIAL | |
| Historization status | `GetHistorizationStatus` | `Status` op | ⬜ | BOUNDED | |
| Store-and-forward status | `GetStoreForwardStatus` | (push events / pull GETHI) | 🟗 | HARD | currently synthesized; real read needs duplex push or a decoded pull endpoint — see store-forward plan |
+9 -5
View File
@@ -41,10 +41,14 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from
> (✅ 2026-06-20 — `ReadEventsAsync(…, HistorianEventFilter)`, live-honored). M2 event send is
> also done (✅ WCF `AddS2`). **R1.2 `GetRuntimeParameterAsync` is also done** (✅ 2026-06-20,
> `aa/Stat/GETRP`, live-verified) — notably a *string-handle* op that punches through the wall
> using the Open2 storage-session GUID as an **uppercase** string handle, which is a strong lead
> that the GETHI/ExeC failures are (at least partly) a handle-*format* issue rather than only a
> missing native registration. **Cheap high-value follow-up: retry GETHI/ExeC with the uppercased
> storage GUID** before assuming the registration wall (see `wcf-string-handle-wall.md` §Update).
> using the Open2 storage-session GUID as an **uppercase** string handle, which proved the
> GETHI/ExeC failures were a handle-*format* issue rather than a missing native registration.
> **Follow-up done:** R1.1 `ExecuteSqlCommandAsync` shipped; R1.5 extended-property read shipped
> (R1.6 collapsed into it — no distinct localized op). **R1.4 `GetHistorianInfo` bounded out on
> 2020 WCF** — GETHI there is a named-value query (only `HistorianVersion`); `EventStorageMode` is
> 2023R2-gRPC-only (see `wcf-historian-info.md`). Net: the **reachable 2020-WCF M1 read surface is
> complete**; what remains is config *writes* (M1c — gated on an explicit user request) and the
> gRPC/2023R2-only items (R1.3 timezone, R1.4 EventStorageMode — need a live 2023 R2 server).
## Guiding principles
@@ -106,7 +110,7 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
### 1b. Bounded (decode one `bytes` payload; SM each)
| ID | Capability | gRPC op | Payload to decode | Depends |
|---|---|---|---|---|
| R1.4 | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | **REACHABLE (2026-06-20, live-probed)** via the uppercase storage GUID — `GETHI` returns data (`StringHandleProbeDiagnosticTests`). The version-keyed request returns `uint charCount + UTF-16`; the full info struct (incl. `EventStorageMode`@514) needs its own request capture. **Public API not yet shipped.** | uppercase string handle |
| R1.4 | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | **Bounded out on 2020 WCF (2026-06-20) — `EventStorageMode` not on the wire; version-only.** Captured the native `GetHistorianInfo(out HistorianInfo, out err)`: over 2020 WCF, `GETHI` is a **named-value query** whose only working key is `HistorianVersion` (returns `uint charCount + UTF-16` version, ~30 bytes). Probed 7 storage-mode key names → all `ok=False`/err. The 518-byte `HISTORIAN_INFO` struct + `EventStorageMode`@514 is the **2023R2 HCAL-native/gRPC** model (confirmed from the decompiled 2023R2 source); on 2020 the native client derives `EventStorageMode` **outside the WCF wire**. Version is already exposed (`ProbeAsync`/`GetRuntimeParameterAsync`), so **not shipped** here — same status as R1.3. Ship `HistorianInfo`/`HistorianEventStorageMode` on the gRPC path against a live 2023 R2 server. RE aids: `scripts/Capture-HistorianInfo.ps1`, `scripts/decode-historian-info-capture.py`, harness `historian-info` scenario, `StringHandleProbeDiagnosticTests.GETHI_CandidateInfoNames`. See `docs/reverse-engineering/wcf-historian-info.md`. | gRPC/2023R2 |
| R1.5 | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` | 🟡 **Likely reachable** via uppercase string handle (GetTepByNm family) — not yet individually re-probed. TEP result buffer. | uppercase string handle |
| R1.6 | Localized-property **read** | `Retrieval.GetTagLocalizedPropertiesFromName` | 🟡 **Likely reachable** (same family) — not yet re-probed. | uppercase string handle |
| ~~R1.7~~ | Event **filters** | filter bytes in `Retrieval.StartEventQuery` | ✅ **DONE (2026-06-20), live-honored.** `ReadEventsAsync(start, end, HistorianEventFilter)`. The filter rides `StartEventQuery`'s `pRequestBuff` (captured via `EventQuery.AddEventFilter` + instrument-wcf-writemessage; Equal vs Contains diffed to isolate the op). Filter block: `ushort 0 + uint filterCount + uint condCount + uint nameLen + name(UTF-16) + uint 1 + ushort op + uint 1 + value(0x09-len-0x00 compact-ASCII) + byte 0`. **REAL, not inert** (a non-matching predicate returns 0 events; matching returns the subset). Single string-valued predicate only; multi-filter (OR) / multi-condition (AND via `AddEventFilterCondition`) framing not yet fully captured. See `HistorianEventFilter`, golden `WcfEventQueryProtocolTests`. | — |
@@ -0,0 +1,68 @@
# GetHistorianInfo over 2020 WCF — GETHI is named-value-only (HCAL R1.4)
**Status: ⚠ Bounded out on 2020 WCF (2026-06-20).** `GetHistorianInfoAsync` is **not shipped**:
the one field that motivates it — `EventStorageMode` — is **not on the 2020 WCF wire**. The
version field that GETHI *does* return over WCF is already exposed (`ProbeAsync`,
`GetRuntimeParameterAsync("HistorianVersion")`), so there is nothing new to ship here without a
2023 R2 gRPC server. This parallels R1.3 (`GetServerTimeZone`), which is likewise 2023R2-only.
## What the capture showed
`scripts/Capture-HistorianInfo.ps1` drives the native `HistorianAccess.GetHistorianInfo(out
HistorianInfo, out error)` through the instrumented (`instrument-wcf-{write,read}message`)
`current/aahClientManaged.dll`. The native call **succeeds** and returns
`EventStorageMode = Blocks`, `ServerVersion = 20,0,000,000`, no error.
But the wire tells a different story (`scripts/decode-historian-info-capture.py`):
- The only `GETHI` op on the wire is **`aa/Stat/GETHI(handle, pRequestBuff)`** with
`pRequestBuff = 53 67 02 00` (sig `0x6753` + version `2`) `+ uint charCount(16) + UTF-16
"HistorianVersion"` — i.e. the **named-value request**, identical to the GETRP/version shape.
- Its response `pResponseBuff` is **~30 bytes**: `uint charCount(12) + UTF-16 "20,0,000,000"`
(+ a `02 00 01 00` trailer). **Just the version** — not a 518-byte struct.
- The post-GETHI ops in the same capture are `Hist/UpdC3` + a run of `Stat/GetSystemParameter`
(`AllowOriginals`, `HistorianPartner`, `HistorianVersion`, `MaxCyclicStorageTimeout`,
`RealTimeWindow`, `FutureTimeThreshold`, `AllowRenameTags`). **None carries a storage-mode
value.** So the native wrapper's `EventStorageMode` is derived by the C++ HCAL **outside the
WCF wire**, not fetched over it.
## Probe: does GETHI expose storage mode under any name?
`StringHandleProbeDiagnosticTests.GETHI_CandidateInfoNames_AgainstLocalHistorian` (gated on
`HISTORIAN_HOST=localhost`) issues GETHI for `HistorianVersion` plus seven storage-mode name
guesses. Result on the live 2020 server:
| GETHI parameter name | result |
|---|---|
| `HistorianVersion` | **ok=True**, respLen=32 (version) |
| `EventStorageMode`, `EventStorageType`, `StorageType`, `HistorianEventStorageMode`, `EventStorage`, `StorageMode`, `HistorianInfo` | **ok=False**, errLen=5, empty |
So GETHI on 2020 WCF is a strict named-value lookup with exactly one known-good key
(`HistorianVersion`). There is no storage-mode key, no full-struct request.
## Why the 518-byte struct doesn't apply here
The 2023 R2 decompiled `ArchestrA.HistorianAccess.GetHistorianInfo` (analysis folder) allocates
a **518-byte `HISTORIAN_INFO`** struct, pre-inits `int32 @514` to `-1`, calls native HCAL
(vtable+648) which fills it, then reads version (UTF-16 @0) + `EventStorageMode` (`@514`:
`-1`=Unsupported, `0`=Database, else=Blocks). That is the **HCAL-native / 2023R2 gRPC**
front-door model (`StatusService.GetHistorianInfo` returns `bytes btHistorianInfo`). On **2020
WCF** that struct is never marshaled across the wire — only the version named-value is. The
native client's `EventStorageMode` therefore comes from C++-internal state the managed WCF
replay cannot observe or reproduce.
## Conclusion / where it lands
- **2020 WCF:** `GetHistorianInfoAsync` would add nothing over existing surface (version only) and
could not report a real `EventStorageMode` — so it is intentionally **not shipped** (no hollow
`Unsupported`-returning API; project discipline: don't ship misleading behavior).
- **2023 R2 gRPC:** `Status.GetHistorianInfo` returns the full 518-byte `btHistorianInfo`; decode
version@0 + `EventStorageMode`@514 there. Build + verify against a live 2023 R2 server. The
`HistorianInfo` / `HistorianEventStorageMode` public types should land alongside that path.
## Tooling kept as RE aids
- `tools/AVEVA.Historian.NativeTraceHarness` `historian-info` scenario (drives the native call).
- `scripts/Capture-HistorianInfo.ps1` + `scripts/decode-historian-info-capture.py`.
- `StringHandleProbeDiagnosticTests.GETHI_CandidateInfoNames_AgainstLocalHistorian` (locks the
named-value-only finding; gated).
@@ -111,7 +111,11 @@ GETHI and **ExeC both return data with the uppercased storage-session GUID**.
read-only with `System.Formats.Nrbf` + `XDocument` (BinaryFormatter is gone from .NET 10).
Shipped: `HistorianSqlResultProtocol`, `HistorianWcfSqlClient`, golden `WcfSqlResultProtocolTests`,
gated live tests. See `docs/reverse-engineering/wcf-exec-sql.md`.
- **GETHI (R1.4)** also returns data with the uppercase handle (probe; public API not yet shipped).
- **GETHI (R1.4)** returns data with the uppercase handle, **but only the named `HistorianVersion`
value** — over 2020 WCF GETHI is a named-value query (the only working key), *not* a full-struct
read. `EventStorageMode` (the 518-byte-struct `@514` field) is **not on the 2020 WCF wire**; it is
the 2023R2 HCAL-native/gRPC model. So R1.4 is **bounded out on WCF / gRPC-2023R2-only** and the
public API is intentionally not shipped. Full analysis: `docs/reverse-engineering/wcf-historian-info.md`.
So the "wall" collapses to the handle **format** for the Retrieval/Status string-handle ops.
**Exception — QTB/QTG:** `StartTagQuery` does *not* punch through; captured with a correctly
+102
View File
@@ -0,0 +1,102 @@
<#
.SYNOPSIS
Captures the native AVEVA client's GetHistorianInfo wire traffic (HCAL roadmap R1.4)
so the WCF GETHI request that returns the FULL HISTORIAN_INFO struct can be decoded
instead of guessed.
.DESCRIPTION
Drives the .NET-Framework NativeTraceHarness's `historian-info` scenario against the live
Historian with an IL-rewritten copy of aahClientManaged.dll whose
ClientMessageEncoder.WriteMessage AND ReadMessage are instrumented to log every MDAS body
(the same pipeline that produced every other proven request/response shape). The harness
opens a normal authenticated process connection and calls
HistorianAccess.GetHistorianInfo(out HistorianInfo, out err).
Decode with scripts/decode-historian-info-capture.py: locate the WCF.WriteMessage.Body
whose op is GETHI -> that is the GetHistorianInfo request; read off the leading string
handle and the pRequestBuff layout (distinct from the named-value "HistorianVersion"
request). The paired WCF.ReadMessage.Body is the pResponseBuff = the 518-byte
HISTORIAN_INFO struct (version string @0 UTF-16 null-terminated, EventStorageMode int32 @514).
.NOTES
Read-only status call; no data is written. Artifacts are diagnostic and gitignored.
Sanitize before copying anything into docs/ -- never commit raw capture NDJSON,
credentials, hostnames, or customer tag names.
#>
[CmdletBinding()]
param(
[string]$ServerName = "localhost",
[int]$TcpPort = 32568,
[string]$Configuration = "Debug"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-historian-info"
$currentCopy = Join-Path $captureDir "current-copy"
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
$capturePath = Join-Path $captureDir "historian-info-capture-latest.ndjson"
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
# Chain via a distinct intermediate file (reading+writing the same path drops the second
# hook on the mixed-mode native image). Final dll carries both hooks with distinct Phase
# strings: WCF.WriteMessage.Body and WCF.ReadMessage.Body.
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
Write-Host "== Capturing historian-info ==" -ForegroundColor Green
$harnessArgs = @(
"--scenario", "historian-info",
"--server-name", $ServerName,
"--tcp-port", "$TcpPort",
"--current-dir", $currentCopy,
"--managed-dll-path", $harnessDll
)
$harnessJson = $null
try {
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
} catch {
Write-Host " (historian-info raised: $($_.Exception.Message))" -ForegroundColor Yellow
} finally {
$ErrorActionPreference = $prevEap
}
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
Write-Host " -> $recCount records -> $capturePath"
Write-Host "Harness output (GetHistorianInfoReturned / HistorianInfo):" -ForegroundColor Cyan
$harnessJson | Select-Object -Last 24
Write-Host "`nDecode with: python scripts\decode-historian-info-capture.py" -ForegroundColor Cyan
+142
View File
@@ -0,0 +1,142 @@
"""Decode the GetHistorianInfo (GETHI) WCF request/response (HCAL R1.4).
Reads the chained WriteMessage+ReadMessage capture produced by
scripts/Capture-HistorianInfo.ps1 and locates the GetHistorianInfo exchange. The goal is
to learn (a) the pRequestBuff that returns the FULL HISTORIAN_INFO struct (distinct from the
named-value "HistorianVersion" request) and (b) the response struct layout: the analysis
folder says it's 518 bytes with the version string (UTF-16, null-terminated) at offset 0 and
EventStorageMode (int32) at offset 514.
We flag candidate bodies by the GETHI op action, by the server version value, and by a
response length near 518, then dump bytes + the int32 at offset 514 so the layout can be
read off directly.
Output is diagnostic. Sanitize before copying into docs/.
"""
import base64
import json
import struct
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-historian-info"
CAP = CAPDIR / "historian-info-capture-latest.ndjson"
# The GETHI op action (WS-Addressing) the native client sends. The server version value is
# version-shaped, not secret; used only to locate the response.
OP_ASCII = b"GetHistorianInfo"
OP_GETHI = b"GETHI"
VERSION = "20,0,000,000"
VERSION_U16 = VERSION.encode("utf-16-le")
VERSION_ASCII = VERSION.encode("ascii")
def hexdump(label, buf, base=0):
print(f"=== {label}: {len(buf)} bytes ===")
for off in range(0, len(buf), 16):
c = buf[off:off + 16]
hp = " ".join(f"{x:02X}" for x in c)
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
print(f" {base + off:04X} {hp:<48} |{ap}|")
print()
def ascii_strings(buf, minlen=3):
out, cur, start = [], [], 0
for i, x in enumerate(buf):
if 32 <= x < 127:
if not cur:
start = i
cur.append(chr(x))
else:
if len(cur) >= minlen:
out.append((start, "".join(cur)))
cur = []
if len(cur) >= minlen:
out.append((start, "".join(cur)))
return out
def u16_strings(buf, minlen=3):
out, i = [], 0
while i < len(buf) - 1:
j, chars = i, []
while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0:
chars.append(chr(buf[j]))
j += 2
if len(chars) >= minlen:
out.append((i, "".join(chars)))
i = j
else:
i += 1
return out
def main() -> int:
if not CAP.exists():
print(f"Missing capture: {CAP}\nRun scripts/Capture-HistorianInfo.ps1 first.")
return 1
records = []
for line in CAP.open(encoding="utf-8-sig"):
if line.strip():
records.append(json.loads(line))
print(f"== {len(records)} MDAS bodies captured ==")
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
flags = []
if OP_ASCII in body or OP_GETHI in body:
flags.append("GETHI-OP")
if VERSION_U16 in body or VERSION_ASCII in body:
flags.append("VERSION")
# A ~518-byte embedded struct is the tell for the full-info response.
if 500 <= len(body) <= 4096:
flags.append(f"len={len(body)}")
print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}")
def find(predicate):
hits = []
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
if predicate(rec, body):
hits.append((idx, rec, body))
return hits
print("\n== Request candidate(s): WriteMessage bodies tagged GETHI-OP ==")
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.WriteMessage.Body"
and (OP_ASCII in b or OP_GETHI in b)):
hexdump(f"[{idx}] WriteMessage", body)
print(" UTF-16 strings:")
for off, s in u16_strings(body):
print(f" 0x{off:04X} {s!r}")
print(" ASCII strings:")
for off, s in ascii_strings(body):
print(f" 0x{off:04X} {s!r}")
print()
print("\n== Response candidate(s): ReadMessage bodies carrying VERSION ==")
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.ReadMessage.Body"
and (VERSION_U16 in b or VERSION_ASCII in b)):
hexdump(f"[{idx}] ReadMessage", body)
print(" UTF-16 strings:")
for off, s in u16_strings(body):
print(f" 0x{off:04X} {s!r}")
# The analysis folder pins EventStorageMode @ offset 514 (int32) inside the
# 518-byte struct. The struct is embedded in the MDAS body at some base; scan for
# a plausible version@0 run and print the int32 514 bytes after each candidate base.
print(" Candidate struct decodes (version@base, int32 @ base+514):")
for base_off, s in u16_strings(body):
if any(ch.isdigit() for ch in s) and "," in s:
idx514 = base_off + 514
if idx514 + 4 <= len(body):
mode = struct.unpack_from("<i", body, idx514)[0]
print(f" base=0x{base_off:04X} version={s!r} int32@+514={mode}")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -66,6 +66,80 @@ public sealed class StringHandleProbeDiagnosticTests
});
}
/// <summary>
/// R1.4 probe: GETHI on 2020 WCF is a named-value query (capture showed the native client
/// only ever asks for "HistorianVersion"; the 518-byte struct + EventStorageMode@514 is the
/// 2023R2 HCAL-native/gRPC model). This probes whether the 2020 server exposes any
/// storage-mode / extended info value through GETHI under other parameter names, so we can
/// decide honestly what GetHistorianInfoAsync can return over WCF.
/// </summary>
[Fact]
public void GETHI_CandidateInfoNames_AgainstLocalHistorian()
{
if (!ShouldRun(out string host)) return;
HistorianClientOptions options = new()
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
};
string[] candidates =
{
"HistorianVersion", "EventStorageMode", "EventStorageType", "StorageType",
"HistorianEventStorageMode", "EventStorage", "StorageMode", "HistorianInfo",
};
Dictionary<string, (bool ok, int respLen)> results = new();
ProbeOnStatusChannel(options, (channel, handle) =>
{
foreach (string name in candidates)
{
using MemoryStream ms = new();
using (BinaryWriter w = new(ms, Encoding.Unicode, leaveOpen: true))
{
w.Write(new byte[] { 0x53, 0x67, 0x02, 0x00 });
w.Write((uint)name.Length);
w.Write(Encoding.Unicode.GetBytes(name));
}
byte[] req = ms.ToArray();
byte[]? resp = null;
byte[]? err = null;
bool ok;
try
{
ok = channel.GetHistorianInfo(handle, req, out resp, out err);
}
catch (Exception ex)
{
_output.WriteLine($" {name,-26} EXCEPTION {ex.GetType().Name}: {ex.Message}");
results[name] = (false, -1);
continue;
}
string respHex = resp is { Length: > 0 }
? Convert.ToHexString(resp.AsSpan(0, Math.Min(48, resp.Length)))
: "(empty)";
_output.WriteLine($" {name,-26} ok={ok} respLen={resp?.Length ?? 0} errLen={err?.Length ?? 0} resp={respHex}");
results[name] = (ok, resp?.Length ?? 0);
}
});
// Locked finding (2026-06-20): on 2020 WCF, GETHI is a named-value query whose only
// working key is HistorianVersion. No storage-mode key is honored — EventStorageMode is
// not on the 2020 WCF wire (it is the 2023R2 HCAL-native/gRPC 518-byte-struct path).
Assert.True(results["HistorianVersion"].ok);
Assert.True(results["HistorianVersion"].respLen > 0);
foreach (string storageName in new[]
{
"EventStorageMode", "EventStorageType", "StorageType",
"HistorianEventStorageMode", "EventStorage", "StorageMode",
})
{
Assert.False(results[storageName].ok, $"unexpected: GETHI honored '{storageName}'");
}
}
[Fact]
public void ExeC_WithUppercaseStorageGuid_AgainstLocalHistorian()
{
@@ -246,6 +246,33 @@ internal static class Program
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsHistorianInfoScenario(scenario))
{
// R1.4 capture: drive HistorianAccess.GetHistorianInfo(out HistorianInfo, out error)
// so instrument-wcf-{write,read}message can observe the WCF GETHI pRequestBuff that
// returns the full 518-byte HISTORIAN_INFO struct (version@0 + EventStorageMode@514),
// distinct from the named-value "HistorianVersion" request. Pure status read.
MethodInfo getInfoMethod = accessType.GetMethods()
.First(m => m.Name == "GetHistorianInfo"
&& m.GetParameters().Length == 2
&& m.GetParameters()[0].ParameterType.IsByRef
&& m.GetParameters()[1].ParameterType.IsByRef);
object infoError = Activator.CreateInstance(errorType)!;
object?[] infoArgs = new object?[] { null, infoError };
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-get-historian-info");
bool infoOk = (bool)getInfoMethod.Invoke(access, infoArgs)!;
Console.WriteLine(Serialize(new
{
Scenario = scenario,
GetHistorianInfoReturned = infoOk,
HistorianInfoType = getInfoMethod.GetParameters()[0].ParameterType.GetElementType()?.FullName,
HistorianInfo = infoArgs[0] is null ? null : SnapshotObject(infoArgs[0]!),
Error = SnapshotObject(infoArgs[1]!),
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsEventSendScenario(scenario))
{
// R2.1 capture: drive AddStreamedValue(HistorianEvent) and let instrument-wcf-*
@@ -1502,6 +1529,19 @@ internal static class Program
|| scenario.Equals("runtime-parameter", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Historian-info scenario (R1.4 capture): opens a normal authenticated process connection and
/// calls <c>GetHistorianInfo(out HistorianInfo, out error)</c> so instrument-wcf-{write,read}message
/// can observe the WCF GETHI <c>pRequestBuff</c>/<c>pResponseBuff</c> that returns the full
/// 518-byte HISTORIAN_INFO struct (version@0 + EventStorageMode@514). Pure status read.
/// </summary>
private static bool IsHistorianInfoScenario(string scenario)
{
return scenario.Equals("historian-info", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("hist-info", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("gethi", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// SQL-command scenario (R1.1 capture): opens a normal authenticated process connection and
/// calls <c>ExecuteSqlCommand</c> (Retr.ExeC + Retr.GetR) so the string-handle SQL surface