bc353df8c4
Tag rename has no dedicated WCF op — the (old,new) name batch rides the generic History StartJob (StJb) job buffer; the server returns a job id and applies renames asynchronously. Handle is the uppercase storage-session GUID, Open2 in write mode; reuses the write orchestrator's open+priming chain. jobBuffer layout (decoded + server-validated): byte[7] zero prefix + uint32 pairCount + per pair (uint32 oldCharCount + UTF-16 oldName + uint32 newCharCount + UTF-16 newName), order (old,new). The raw instrument capture mangles the final byte with MDAS chunk markers (the R1.1 lesson), so the golden fixture pins the CLEAN byte[] the SDK handed the channel (dumped via AVEVA_HISTORIAN_RENAME_DUMP) — the exact buffer the live server accepted and renamed with. Gated server-side by the AllowRenameTags system parameter (default 0): when disabled the native client rejects pre-wire (err 132); the managed SDK surfaces it as StartJob=false -> Accepted=false. Enabling needs a Historian config reload, not just a storage-engine restart. Shipped: HistorianClient.RenameTagAsync/RenameTagsAsync -> HistorianTagRenameResult; HistorianTagRenameProtocol; orchestrator RenameTags/SendStartJobRename; golden WcfTagRenameProtocolTests (4, pins server-accepted buffer); gated live test RenameTagsAsync_AgainstLocalHistorian_RenamesSandboxTag (passed end-to-end). Native-harness `rename` scenario + Capture-RenameTags.ps1 + decode-rename-capture.py. Doc: docs/reverse-engineering/wcf-rename-tags.md. 213 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
130 lines
4.3 KiB
Python
130 lines
4.3 KiB
Python
"""Decode the RenameTags WCF request/response (HCAL R1.10).
|
|
|
|
Reads the chained WriteMessage+ReadMessage capture produced by scripts/Capture-RenameTags.ps1
|
|
and locates the rename exchange. Rename maps to the generic job framework:
|
|
|
|
StJb (StartJob): WriteMessage carries op "StJb" + a string handle + the rename jobBuffer
|
|
(the (old,new) name pairs). ReadMessage carries the returned jobId string.
|
|
GtJb (GetJobStatus): WriteMessage carries op "GtJb" + handle + jobId. ReadMessage carries
|
|
the job-status buffer.
|
|
|
|
We flag bodies by the StJb/GtJb op and by the sandbox names, then dump the buffers so the
|
|
jobBuffer layout (batch count + old/new UTF-16 framing) can be read off directly.
|
|
|
|
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-rename"
|
|
CAP = CAPDIR / "rename-capture-latest.ndjson"
|
|
|
|
# Sandbox names used by the default capture run (not secret).
|
|
FROM = "RetestSdkWriteRenameSrc"
|
|
TO = "RetestSdkWriteRenameDst"
|
|
OP_STJB = b"StJb"
|
|
OP_GTJB = b"GtJb"
|
|
|
|
|
|
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-RenameTags.ps1 first.")
|
|
return 1
|
|
|
|
records = []
|
|
for line in CAP.open(encoding="utf-8-sig"):
|
|
if line.strip():
|
|
records.append(json.loads(line))
|
|
|
|
from_u16, to_u16 = FROM.encode("utf-16-le"), TO.encode("utf-16-le")
|
|
from_a, to_a = FROM.encode("ascii"), TO.encode("ascii")
|
|
|
|
print(f"== {len(records)} MDAS bodies captured ==")
|
|
for idx, rec in enumerate(records):
|
|
body = base64.b64decode(rec["Base64"])
|
|
flags = []
|
|
if OP_STJB in body:
|
|
flags.append("StJb")
|
|
if OP_GTJB in body:
|
|
flags.append("GtJb")
|
|
if from_u16 in body or from_a in body:
|
|
flags.append("FROM")
|
|
if to_u16 in body or to_a in body:
|
|
flags.append("TO")
|
|
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== StJb request(s): WriteMessage bodies tagged StJb ==")
|
|
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.WriteMessage.Body" and OP_STJB in b):
|
|
hexdump(f"[{idx}] StJb 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== StJb / GtJb response(s) + GtJb request(s) ==")
|
|
for idx, rec, body in find(lambda r, b: (OP_STJB in b or OP_GTJB in b) and r.get("Phase") == "WCF.ReadMessage.Body"):
|
|
hexdump(f"[{idx}] {rec.get('Phase')}", body)
|
|
print(" strings:", [s for _, s in ascii_strings(body)][:16])
|
|
print()
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|