4da5287d01
Execute HCAL roadmap R1.2 (GetRuntimeParameterAsync) end-to-end, and in doing so
discover that the "string-handle wall" blocking R1.1/R1.4/R1.5/R1.6 was a handle
FORMAT bug, not a missing native session/filter registration.
R1.2 (shipped, live-verified):
- Captured native GetRuntimeParameter -> WCF op aa/Stat/GETRP (string-handle op,
GETHI's shape), via scripts/Capture-RuntimeParam.ps1 + instrument-wcf-{write,read}message.
- HistorianRuntimeParameterProtocol serializes pRequestBuff (54 67 01 00 + uint
nameCount + per-name uint charCount + UTF-16) and parses pResponseBuff (version +
uint resultCount + CRetVariant 0x43 VT_BSTR + uint16 len + uint16 charCount + UTF-16).
- IStatusServiceContract2.GetRuntimeParameter (GETRP) op; HistorianWcfStatusClient
passes the Open2 storage-session GUID as the string handle, UPPERCASE.
- Public HistorianClient.GetRuntimeParameterAsync(name) via the dialect.
- Golden WcfRuntimeParameterProtocolTests + gated live test; returns HistorianVersion.
String-handle wall RESOLVED (proven, public APIs deferred):
- The Open2 storage GUID works as the string handle when sent UPPERCASE
(ToString("D").ToUpperInvariant()); earlier "blocked" probes used lowercase.
- Live-probed GETHI (R1.4) -> returns data; ExeC (R1.1) -> Retr.GetV prime -> ExeC ->
GetR returns a BinaryFormatter-serialized .NET DataTable. Gated
StringHandleProbeDiagnosticTests + scripts/Capture-ExecSql.ps1 + exec-sql harness scenario.
- Docs flipped: wcf-string-handle-wall.md RESOLVED banner; roadmap R1.1/R1.4 reachable,
R1.5/R1.6 likely; wcf-status-localhost.md GETRP section.
- R1.1/R1.4 public APIs NOT shipped: ExeC needs a GetR paging loop + a BinaryFormatter-
stream parser (BinaryFormatter is removed from .NET 10); GETHI full-info struct needs
its own capture.
223 unit tests pass; gated live tests green against the local 2020 Historian.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
129 lines
4.4 KiB
Python
129 lines
4.4 KiB
Python
"""Decode the GetRuntimeParameter WCF request/response (HCAL R1.2).
|
|
|
|
Reads the chained WriteMessage+ReadMessage capture produced by
|
|
scripts/Capture-RuntimeParam.ps1 and locates the GetRuntimeParameter exchange by
|
|
searching every MDAS body for the parameter name (UTF-16) on the request side and the
|
|
returned value on the response side. Dumps the surrounding bytes so the op name, the
|
|
leading handle parameter, and the btRequest/btResponse buffer layout can be read off.
|
|
|
|
Output is diagnostic. Sanitize before copying into docs/.
|
|
"""
|
|
import base64
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-runtime-param"
|
|
CAP = CAPDIR / "runtime-param-capture-latest.ndjson"
|
|
|
|
# Markers we expect on the wire for the default "HistorianVersion" capture.
|
|
NAME = "HistorianVersion"
|
|
NAME_U16 = NAME.encode("utf-16-le")
|
|
NAME_ASCII = NAME.encode("ascii")
|
|
VALUE = "20,0,000,000" # server runtime "HistorianVersion" value (version-shaped, not secret)
|
|
VALUE_U16 = VALUE.encode("utf-16-le")
|
|
VALUE_ASCII = VALUE.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-RuntimeParam.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 NAME_U16 in body or NAME_ASCII in body:
|
|
flags.append("NAME")
|
|
if VALUE_U16 in body or VALUE_ASCII in body:
|
|
flags.append("VALUE")
|
|
# The WS-Addressing action is the most reliable op label; show any string that
|
|
# looks like an op (contains a slash or is short and capitalized).
|
|
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 containing the NAME ==")
|
|
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.WriteMessage.Body"
|
|
and (NAME_U16 in b or NAME_ASCII 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 containing the VALUE ==")
|
|
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.ReadMessage.Body"
|
|
and (VALUE_U16 in b or VALUE_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}")
|
|
print(" ASCII strings:")
|
|
for off, s in ascii_strings(body):
|
|
print(f" 0x{off:04X} {s!r}")
|
|
print()
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|