RE: resolve R1.8/R1.9 analog/state summary via request+response capture

Captured the native StartQuery2 pRequestBuff and the GetNextQueryResultBuffer2
response (instrument-wcf-writemessage + chained instrument-wcf-readmessage) and
decoded both against AnalogSummaryHistory SQL ground truth. Conclusion: the rich
multi-aggregate analog/state summary struct is NOT delivered over the 2020 WCF
binary protocol — the response is the ordinary version-9 row buffer the existing
aggregate parser already handles, carrying one value per cycle selected by
RetrievalMode (QueryType 5-8), not ValueSelector (inert on this path). So
"analog summary" == the existing ReadAggregateAsync; no new src/ code warranted.

Tooling (tools/ + scripts/ only, nothing in src/):
- NativeTraceHarness: drive summary knobs via --value-selector /
  --aggregation-type / --max-states (uint16) / --filter
- Capture-SummaryRequest.ps1: repeatable instrument+stage+matrix capture,
  -WithResponse chains the ReadMessage hook
- decode-summary-capture.py: StartQuery2 request diff vs baseline
- decode-summary-response.py: response decode vs SQL ground truth

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-06-20 17:01:42 -04:00
parent 362fcb0ef4
commit 1a7519c803
5 changed files with 531 additions and 38 deletions
+123
View File
@@ -0,0 +1,123 @@
"""Decode the GetNextQueryResultBuffer2 *response* for an analog summary (HCAL R1.8).
Reads the both-hooks capture produced by
scripts/Capture-SummaryRequest.ps1 -OnlyConfig analog-avg -WithResponse
finds the ReadMessage record carrying GetNextQueryResultBuffer2Response, extracts the
`pResultBuff` payload, hex-dumps it, and annotates every 8-byte window that decodes to a
known ground-truth value (the AnalogSummaryHistory row for SysTimeSec) so the field offsets
of CAnalogSummaryValue can be read off directly.
Output is diagnostic; the only printed strings are the SDK-chosen system tag name and field
markers. Sanitize before copying into docs/.
"""
import base64
import json
import struct
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# Config name (analog-avg / analog-min / analog-max / …) selectable via argv[1].
CONFIG = sys.argv[1] if len(sys.argv) > 1 else "analog-avg"
CAPTURE = (REPO_ROOT / "artifacts" / "reverse-engineering"
/ "instrumented-wcf-writemessage-summary" / f"summary-capture-{CONFIG}-latest.ndjson")
RESP = b"GetNextQueryResultBuffer2Response"
PARAM = b"pResultBuff"
# Ground-truth values from AnalogSummaryHistory(SysTimeSec, 1h cycle) — used to label offsets.
KNOWN_DOUBLES = {
31.0: "31.0 (First/Last/Average)",
100.0: "100.0 (PercentGood)",
0.031: "0.031 (Integral)",
111600.0: "111600.0 (Integral, full-cycle)",
1.0: "1.0 (ValueCount as double?)",
}
KNOWN_U32 = {
1: "ValueCount=1",
192: "OPCQuality=192",
100: "PercentGood=100",
9: "version=9",
}
def extract_param(body, param):
i = body.find(param)
if i < 0:
return None
i += len(param)
marker = body[i]
if marker == 0x9E:
length = body[i + 1]
return body[i + 2:i + 2 + length]
if marker == 0x9F:
length = int.from_bytes(body[i + 1:i + 3], "little")
return body[i + 3:i + 3 + length]
if marker == 0xA0:
length = int.from_bytes(body[i + 1:i + 3], "little")
return body[i + 3:i + 3 + length + 1]
return None
def main() -> int:
if not CAPTURE.exists():
print(f"Capture not found: {CAPTURE}")
print("Run: scripts/Capture-SummaryRequest.ps1 -OnlyConfig analog-avg -WithResponse")
return 1
with CAPTURE.open(encoding="utf-8-sig") as fh:
records = [json.loads(line) for line in fh if line.strip()]
payload = None
for rec in records:
if rec.get("Phase") != "WCF.ReadMessage.Body":
continue
body = base64.b64decode(rec["Base64"])
if RESP not in body:
continue
payload = extract_param(body, PARAM)
break
if payload is None:
print("No GetNextQueryResultBuffer2Response / pResultBuff found in capture.")
return 2
print(f"pResultBuff: {len(payload)} bytes")
if len(payload) >= 6:
version = int.from_bytes(payload[0:2], "little")
row_count = int.from_bytes(payload[2:6], "little")
print(f" header: version={version} rowCount={row_count}")
print()
# Annotated hex dump.
for off in range(0, len(payload), 16):
chunk = payload[off:off + 16]
hp = " ".join(f"{c:02X}" for c in chunk)
ap = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk)
print(f" {off:04X} {hp:<48} |{ap}|")
# Scan every 8-byte window for known doubles, and every 4-byte window for known u32s.
print("\n== Known-value hits (offset -> field) ==")
for off in range(0, len(payload) - 7):
val = struct.unpack_from("<d", payload, off)[0]
for known, label in KNOWN_DOUBLES.items():
if val == known or (known != 0 and abs(val - known) < 1e-9 * max(1.0, abs(known))):
print(f" 0x{off:04X} double {val!r:>14} -> {label}")
for off in range(0, len(payload) - 3):
val = int.from_bytes(payload[off:off + 4], "little")
if val in KNOWN_U32:
print(f" 0x{off:04X} uint32 {val:>14} -> {KNOWN_U32[val]}")
# FILETIME windows (plausible 2026 timestamps: 0x01DC.. high dword).
print("\n== Plausible FILETIME windows (Int64, year ~2020-2030) ==")
for off in range(0, len(payload) - 7):
ft = int.from_bytes(payload[off:off + 8], "little")
# FILETIME for 2020-01-01 ~= 0x01D5BF.. ; 2030 ~= 0x01E5.. — gate by high word.
if 0x01D5_0000_0000_0000 <= ft <= 0x01E6_0000_0000_0000:
print(f" 0x{off:04X} filetime 0x{ft:016X}")
return 0
if __name__ == "__main__":
sys.exit(main())