Files
histsdk/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
T
Joseph Doherty 08b950caee R1.11 AddTagExtendedPropertiesAsync: extended-property write via AddTEx
Adds user-defined extended properties to an existing tag via the 2020 WCF
AddTEx (AddTagExtendedProperties) op. Write-enabled connection + uppercase
storage-session GUID handle; reuses the write orchestrator open/priming chain.

The AddTEx inBuff is the exact inverse of the R1.5 GetTepByNm read-response
framing, so the serializer mirrors the read parser:
  uint32 groupCount + 0x01(group) + [0x09+u16+ASCII tag] + uint32 propCount
  + per prop{ 0x02 + [0x09+u16+ASCII name] + 0x43 VT_BSTR + u16 payloadLen
  + u16 charCount + UTF-16 value } + 0x01(group trailer) + 0x00(terminator).
The trailing 0x00 is required — without it inBuff is one byte short and the
server throws SErrorException in CHistStorage::AddTagExtendedProperties. The
golden fixture pins the clean inBuff the live server accepted (dumped via
AVEVA_HISTORIAN_TEP_DUMP); read-back verified via R1.5. String (0x43) values only.

Delete (DelTep) is deferred: the native DeleteTagExtendedPropertiesByName does a
client-side sync check and returns err 229 for a just-added property, so the
DelTep request never reaches the wire and its inBuff can't be captured yet.

Shipped: HistorianClient.AddTagExtendedPropertiesAsync/AddTagExtendedPropertyAsync;
HistorianTagExtendedPropertyProtocol.SerializeAddRequest; orchestrator path;
golden WcfTagExtendedPropertyWriteProtocolTests (4); gated live write/read-back test;
native-harness `add-tep` scenario + Capture-AddTagExtendedProperties.ps1 +
decode-add-tep-capture.py. Doc: wcf-add-tag-extended-properties.md. 233 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
2026-06-21 01:43:19 -04:00

1999 lines
98 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Web.Script.Serialization;
namespace AVEVA.Historian.NativeTraceHarness;
internal static class Program
{
private static int Main(string[] args)
{
string repoRoot = FindRepoRoot();
Directory.SetCurrentDirectory(repoRoot);
string tagName = GetArg(args, "--tag") ?? "OtOpcUaParityTest_001.Counter";
string serverName = GetArg(args, "--server-name") ?? "localhost";
int tcpPort = int.TryParse(GetArg(args, "--tcp-port"), out int parsedTcpPort) ? parsedTcpPort : 32568;
int lookbackMinutes = int.TryParse(GetArg(args, "--lookback-minutes"), out int parsedLookback) ? parsedLookback : 1440;
int maxRows = int.TryParse(GetArg(args, "--max-rows"), out int parsedMaxRows) ? parsedMaxRows : 1;
int waitSeconds = int.TryParse(GetArg(args, "--connection-wait-seconds"), out int parsedWait) ? parsedWait : 15;
int preLoadSleepSeconds = int.TryParse(GetArg(args, "--pre-load-sleep-seconds"), out int parsedPreLoadSleep) ? parsedPreLoadSleep : 0;
int preOpenSleepSeconds = int.TryParse(GetArg(args, "--pre-open-sleep-seconds"), out int parsedPreOpenSleep) ? parsedPreOpenSleep : 0;
int preStartSleepSeconds = int.TryParse(GetArg(args, "--pre-start-sleep-seconds"), out int parsedPreStartSleep) ? parsedPreStartSleep : 0;
string scenario = GetArg(args, "--scenario") ?? "history";
string retrievalModeName = GetArg(args, "--retrieval-mode") ?? "Full";
bool directConnection = HasFlag(args, "--direct-connection");
bool integratedSecurity = !HasFlag(args, "--no-integrated-security");
string? proxyServer = GetArg(args, "--proxy-server");
string? runtimeMethodPointerOutput = GetArg(args, "--runtime-method-pointer-output");
string runtimeMethodPointerFilters = GetArg(args, "--runtime-method-pointer-filters")
?? "StartDataQuery;StartQuery;GetNextRow;StartEventQuery";
ulong resolutionTicks = ulong.TryParse(GetArg(args, "--resolution-ticks"), out ulong parsedResolutionTicks) ? parsedResolutionTicks : 0;
// Summary-query knobs on HistoryQueryArgs (R1.8/R1.9 capture). Left null/0 = not set,
// so a normal Full read is unaffected. ValueSelector/AggregationType/MaxStates/Filter
// are the native properties that turn a Cyclic/Full query into an analog/state summary.
string? valueSelectorName = GetArg(args, "--value-selector");
string? aggregationTypeName = GetArg(args, "--aggregation-type");
uint maxStates = uint.TryParse(GetArg(args, "--max-states"), out uint parsedMaxStates) ? parsedMaxStates : 0;
string? historyFilter = GetArg(args, "--filter");
// R1.5 capture: flip the TagQueryArgs flag that makes the native client retrieve extended
// properties in the index-based TagQuery path. (The dedicated tag-extended-properties
// scenario, which drives the name-based GetTagExtendedPropertiesByName, is the reliable
// GetTepByNm capture path; the TagQuery path is gated behind QTB, which fails server-side
// here.) Off by default so the normal tag-query scenario is unchanged.
bool retrieveExtendedProperties = HasFlag(args, "--retrieve-extended-properties");
DateTime endUtc = TryParseUtc(GetArg(args, "--end-utc")) ?? DateTime.UtcNow;
DateTime startUtc = TryParseUtc(GetArg(args, "--start-utc")) ?? endUtc.AddMinutes(-lookbackMinutes);
string current = Path.GetFullPath(GetArg(args, "--current-dir") ?? Path.Combine(repoRoot, "current"));
string managedDll = Path.GetFullPath(GetArg(args, "--managed-dll-path") ?? Path.Combine(current, "aahClientManaged.dll"));
if (!File.Exists(managedDll))
{
throw new FileNotFoundException("Missing aahClientManaged.dll.", managedDll);
}
if (!Directory.Exists(current))
{
throw new DirectoryNotFoundException($"Missing dependency folder: {current}");
}
AppDomain.CurrentDomain.AssemblyResolve += (_, eventArgs) =>
{
AssemblyName name = new(eventArgs.Name);
string candidate = Path.Combine(current, name.Name + ".dll");
return File.Exists(candidate) ? Assembly.LoadFrom(candidate) : null!;
};
Directory.CreateDirectory(Path.Combine(repoRoot, "docs", "reverse-engineering"));
TryDelete(Path.Combine(repoRoot, "docs", "reverse-engineering", "native-wcf-message-log.svclog"));
TraceSource diagnosticProbe = new("System.ServiceModel");
diagnosticProbe.TraceInformation("NativeTraceHarness diagnostics probe");
diagnosticProbe.Flush();
Directory.SetCurrentDirectory(current);
if (preLoadSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preLoadSleepSeconds));
}
Assembly assembly = Assembly.LoadFrom(managedDll);
string? methodPointerFilter = GetArg(args, "--dump-method-pointers");
if (methodPointerFilter is not null)
{
Console.WriteLine(Serialize(DumpRuntimeMethodPointers(assembly, methodPointerFilter)));
return 0;
}
string? dumpTypeName = GetArg(args, "--dump-type-members");
if (dumpTypeName is not null)
{
Type dumpType = GetType(assembly, dumpTypeName);
if (dumpType.IsEnum)
{
var values = Enum.GetValues(dumpType).Cast<object>()
.Select(v => $"{v} = {Convert.ToInt64(v)}").OrderBy(s => s).ToArray();
Console.WriteLine(Serialize(new { Type = dumpType.FullName, EnumValues = values }));
return 0;
}
BindingFlags df = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
Console.WriteLine(Serialize(new
{
Type = dumpType.FullName,
Properties = dumpType.GetProperties(df).Select(p => $"{p.PropertyType.Name} {p.Name}").OrderBy(s => s).ToArray(),
Fields = dumpType.GetFields(df).Select(f => $"{f.FieldType.Name} {f.Name}").OrderBy(s => s).ToArray(),
Methods = dumpType.GetMethods(df)
.Where(m => !m.IsSpecialName)
.Select(m => $"{m.ReturnType.Name} {m.Name}({string.Join(", ", m.GetParameters().Select(p => p.ParameterType.Name))})")
.OrderBy(s => s).ToArray(),
}));
return 0;
}
Type accessType = GetType(assembly, "ArchestrA.HistorianAccess");
Type connectionArgsType = GetType(assembly, "ArchestrA.HistorianConnectionArgs");
Type connectionStatusType = GetType(assembly, "ArchestrA.HistorianConnectionStatus");
Type connectionType = GetType(assembly, "ArchestrA.HistorianConnectionType");
Type historyQueryArgsType = GetType(assembly, "ArchestrA.HistoryQueryArgs");
Type eventQueryArgsType = GetType(assembly, "ArchestrA.EventQueryArgs");
Type tagQueryArgsType = GetType(assembly, "ArchestrA.TagQueryArgs");
Type eventQueryTypeType = GetType(assembly, "ArchestrA.HistorianEventQueryType");
Type eventOrderType = GetType(assembly, "ArchestrA.HistorianEventOrder");
Type errorType = GetType(assembly, "ArchestrA.HistorianAccessError");
Type retrievalModeType = GetType(assembly, "ArchestrA.HistorianRetrievalMode");
object access = Activator.CreateInstance(accessType)!;
object connectionArgs = Activator.CreateInstance(connectionArgsType)!;
SetProperty(connectionArgs, "ServerName", serverName);
SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort));
SetProperty(connectionArgs, "ReadOnly", !(IsWriteScenario(scenario) || IsEventSendScenario(scenario) || IsAddTagExtendedPropertiesScenario(scenario)));
SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity);
SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventConnectionScenario(scenario) ? "Event" : "Process"));
if (directConnection)
{
SetProperty(connectionArgs, "DirectConnection", true);
SetField(connectionArgs, "directConnection", true);
}
if (!string.IsNullOrWhiteSpace(proxyServer))
{
SetProperty(connectionArgs, "ProxyServer", proxyServer!);
}
Dictionary<string, object?> snapshots = [];
if (preOpenSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preOpenSleepSeconds));
}
object openError = Activator.CreateInstance(errorType)!;
MethodInfo openMethod = accessType.GetMethod("OpenConnection", new[] { connectionArgsType, errorType.MakeByRefType() })
?? throw new MissingMethodException("HistorianAccess.OpenConnection");
object?[] openArgs = [connectionArgs, openError];
bool openSuccess = (bool)openMethod.Invoke(access, openArgs)!;
openError = openArgs[1]!;
snapshots["ConnectionArgs"] = SnapshotObject(connectionArgs);
snapshots["AccessAfterOpen"] = SnapshotObject(access);
ConnectionStatusSnapshot status = WaitForConnection(access, accessType, connectionStatusType, waitSeconds);
bool startSuccess = false;
object? startError = null;
string? startQueryException = null;
string? moveTerminalDescription = null;
List<object> rows = [];
if (openSuccess && status.ConnectedToServer && IsExecSqlScenario(scenario))
{
// R1.1 capture: drive HistorianAccess.ExecuteSqlCommand(sql, option, out retval,
// out DataTable, out error) so instrument-wcf-{write,read}message can observe the
// Retr.ExeC + Retr.GetR wire shape (handle format, command/option encoding, Retr
// priming, result byte stream). Read-only benign query.
string sql = GetArg(args, "--sql") ?? "SELECT 1 AS ProbeValue";
Type sqlOptionType = GetType(assembly, "ArchestrA.HistorianSqlExecuteOption");
object sqlOption = Enum.Parse(sqlOptionType, GetArg(args, "--sql-option") ?? "ExecuteRecord");
MethodInfo execMethod = accessType.GetMethods()
.First(m => m.Name == "ExecuteSqlCommand" && m.GetParameters().Length == 5);
object?[] execArgs = new object?[] { sql, sqlOption, 0, null, Activator.CreateInstance(errorType) };
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-exec-sql");
bool execOk = (bool)execMethod.Invoke(access, execArgs)!;
int rowCount = -1, colCount = -1;
if (execArgs[3] is System.Data.DataTable table)
{
rowCount = table.Rows.Count;
colCount = table.Columns.Count;
}
Console.WriteLine(Serialize(new
{
Scenario = scenario,
Sql = sql,
ExecuteSqlCommandReturned = execOk,
ReturnValue = execArgs[2],
RowCount = rowCount,
ColumnCount = colCount,
Error = SnapshotObject(execArgs[4]!),
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsRuntimeParamScenario(scenario))
{
// R1.2 capture: drive HistorianAccess.GetRuntimeParameter(List<string> names,
// out List<object> results, out error) so instrument-wcf-{write,read}message can
// observe the WCF op name, handle type (uint vs string-handle wall), and the
// btRequest/btResponse buffer format. Pure status read — no write mode needed.
string namesArg = GetArg(args, "--runtime-param-names") ?? "HistorianVersion";
string[] names = namesArg.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
MethodInfo getRtParamMethod = accessType.GetMethods()
.First(m => m.Name == "GetRuntimeParameter" && m.GetParameters().Length == 3);
ParameterInfo[] rtParams = getRtParamMethod.GetParameters();
Type namesListType = rtParams[0].ParameterType; // List<string>
Type resultsListType = rtParams[1].ParameterType.GetElementType()!; // List<...>& -> List<...>
object namesList = Activator.CreateInstance(namesListType)!;
MethodInfo addName = namesListType.GetMethod("Add")!;
foreach (string n in names) addName.Invoke(namesList, new object?[] { n });
object rtError = Activator.CreateInstance(errorType)!;
object?[] rtArgs = new object?[] { namesList, Activator.CreateInstance(resultsListType), rtError };
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-get-runtime-parameter");
bool rtOk = (bool)getRtParamMethod.Invoke(access, rtArgs)!;
object? resultsList = rtArgs[1];
var resultItems = new List<object?>();
if (resultsList is System.Collections.IEnumerable en)
{
foreach (object? item in en)
{
resultItems.Add(new
{
Type = item?.GetType().FullName,
Value = item?.ToString(),
});
}
}
Console.WriteLine(Serialize(new
{
Scenario = scenario,
Names = names,
GetRuntimeParameterReturned = rtOk,
ResultsListType = resultsListType.FullName,
Results = resultItems,
Error = SnapshotObject(rtArgs[2]!),
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsTagExtendedPropertiesScenario(scenario))
{
// R1.5 capture: drive HistorianAccess.GetTagExtendedPropertiesByName(string tagName,
// bool fetchFromServer, out TagExtendedPropertyGroup, out error) directly. This is the
// NAME-based entry point that issues the GetTepByNm WCF op WITHOUT a prior
// StartTagQuery (QTB) — the index-based TagQuery.GetTagExtendedPropertyInfo path is
// blocked here because QTB fails server-side (CMdServer StartActiveTagnamesQuery).
// The second GetTagExtendedPropertiesByName arg forces a server fetch (issues GetTepByNm)
// when true; when false the C++ client reads its local cache and returns err 41 if the
// tag's properties were never fetched. Default true so the scenario captures GetTepByNm;
// pass --tep-cache-only to exercise the cache-read (no WCF op) path.
bool fetchFromServer = !HasFlag(args, "--tep-cache-only");
// Prime the tag identity table first (ProcessTagNameIdentity inside
// GetTagExtendedPropertiesByName fails with err 41 if the tag was never resolved on
// this connection). GetTagInfoByName(tagName, cache, out HistorianTag, out err) is the
// proven uint-handle metadata path that registers the tag.
string? primeResult = null;
MethodInfo? getTagInfoByName = accessType.GetMethods()
.FirstOrDefault(m => m.Name == "GetTagInfoByName" && m.GetParameters().Length == 4);
if (getTagInfoByName is not null)
{
ParameterInfo[] tibParams = getTagInfoByName.GetParameters();
Type tagOutType = tibParams[2].ParameterType.GetElementType()!;
object tibError = Activator.CreateInstance(errorType)!;
object?[] tibArgs = new object?[] { tagName, true, null, tibError };
try
{
bool tibOk = (bool)getTagInfoByName.Invoke(access, tibArgs)!;
primeResult = $"GetTagInfoByName={tibOk} err={GetPropertyText(tibArgs[3], "ErrorDescription")}";
}
catch (TargetInvocationException ex)
{
primeResult = "GetTagInfoByName threw: " + FormatException(ex.InnerException ?? ex);
}
}
MethodInfo getTepByName = accessType.GetMethods()
.First(m => m.Name == "GetTagExtendedPropertiesByName" && m.GetParameters().Length == 4);
ParameterInfo[] tepParams = getTepByName.GetParameters();
Type groupType = tepParams[2].ParameterType.GetElementType()!; // TagExtendedPropertyGroup& -> TagExtendedPropertyGroup
object tepError = Activator.CreateInstance(errorType)!;
object?[] tepArgs = new object?[] { tagName, fetchFromServer, null, tepError };
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-get-tag-extended-properties");
bool tepOk = false;
string? tepException = null;
try
{
tepOk = (bool)getTepByName.Invoke(access, tepArgs)!;
}
catch (TargetInvocationException ex)
{
tepException = FormatException(ex.InnerException ?? ex);
}
Console.WriteLine(Serialize(new
{
Scenario = scenario,
TagName = tagName,
FetchFromServer = fetchFromServer,
Prime = primeResult,
GroupType = groupType.FullName,
GetTagExtendedPropertiesByNameReturned = tepOk,
Exception = tepException,
Group = ToSerializableValue(tepArgs[2]),
GroupSnapshot = tepArgs[2] is null ? null : SnapshotObject(tepArgs[2]!),
Error = SnapshotObject(tepArgs[3]!),
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsAddTagExtendedPropertiesScenario(scenario))
{
// R1.11 capture: drive AddTagExtendedProperties(TagExtendedPropertyGroupList, out err)
// — and optionally DeleteTagExtendedPropertiesByName — so instrument-wcf-writemessage can
// observe the AddTEx / DelTep inBuff (tag + property name/value framing). Sandbox-guarded.
string tepTag = GetArg(args, "--tep-tag") ?? "RetestSdkWriteTepTag";
if (!tepTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"add-tep scenario refuses tags that don't start with 'RetestSdkWrite'. Pass --tep-tag RetestSdkWrite...");
}
string propName = GetArg(args, "--tep-name") ?? "SdkTestProp";
string propValue = GetArg(args, "--tep-value") ?? "SdkTestValue";
var tepRows = new List<object>();
// 1) Ensure the tag exists (AddTag) unless --tep-skip-create.
if (!HasFlag(args, "--tep-skip-create"))
{
Type tagDefType = GetType(assembly, "ArchestrA.HistorianTag");
Type tagDataTypeEnum = GetType(assembly, "ArchestrA.HistorianDataType");
Type tagStorageTypeEnum = GetType(assembly, "ArchestrA.HistorianStorageType");
object tag = Activator.CreateInstance(tagDefType)!;
SetProperty(tag, "TagName", tepTag);
SetProperty(tag, "TagDescription", "SDK ext-property write RE sandbox tag");
SetProperty(tag, "EngineeringUnit", "test");
SetProperty(tag, "TagDataType", Enum.Parse(tagDataTypeEnum, "Float", ignoreCase: true));
SetProperty(tag, "TagStorageType", Enum.Parse(tagStorageTypeEnum, "Cyclic", ignoreCase: true));
SetProperty(tag, "MinEU", 0.0);
SetProperty(tag, "MaxEU", 100.0);
SetProperty(tag, "MinRaw", 0.0);
SetProperty(tag, "MaxRaw", 100.0);
SetProperty(tag, "StorageRate", 1000u);
SetProperty(tag, "ApplyScaling", false);
object addError = Activator.CreateInstance(errorType)!;
MethodInfo addTagMethod = accessType.GetMethod("AddTag", new[] { tagDefType, typeof(uint).MakeByRefType(), errorType.MakeByRefType() })!;
object?[] addTagArgs = [tag, 0u, addError];
bool addOk = (bool)addTagMethod.Invoke(access, addTagArgs)!;
tepRows.Add(new { Kind = "AddTag", Success = addOk, ErrorDescription = GetPropertyText(addTagArgs[2]!, "ErrorDescription") });
}
// Prime the tag identity (same reason as the read scenario — server-side resolution).
MethodInfo? getTagInfoByName = accessType.GetMethods()
.FirstOrDefault(m => m.Name == "GetTagInfoByName" && m.GetParameters().Length == 4);
if (getTagInfoByName is not null)
{
object tibError = Activator.CreateInstance(errorType)!;
object?[] tibArgs = new object?[] { tepTag, true, null, tibError };
try { getTagInfoByName.Invoke(access, tibArgs); } catch { }
}
// 2) Build TagExtendedPropertyGroupList { TagExtendedPropertyGroup { TagName, [TagExtendedProperty] } }
Type listType = GetType(assembly, "ArchestrA.TagExtendedPropertyGroupList");
Type groupType = GetType(assembly, "ArchestrA.TagExtendedPropertyGroup");
Type propType = GetType(assembly, "ArchestrA.TagExtendedProperty");
Type propDataTypeEnum = GetType(assembly, "ArchestrA.TagExtendedPropertyDataType");
object list = Activator.CreateInstance(listType)!;
object group = Activator.CreateInstance(groupType)!;
SetProperty(group, "TagName", tepTag);
object prop = Activator.CreateInstance(propType)!;
SetProperty(prop, "PropertyName", propName);
SetProperty(prop, "Type", Enum.Parse(propDataTypeEnum, "String", ignoreCase: true));
SetProperty(prop, "Value", propValue);
groupType.GetMethod("Add", new[] { propType })!.Invoke(group, [prop]);
listType.GetMethod("Add", new[] { groupType })!.Invoke(list, [group]);
MethodInfo addTepMethod = accessType.GetMethods()
.First(m => m.Name == "AddTagExtendedProperties" && m.GetParameters().Length == 2);
object addTepError = Activator.CreateInstance(errorType)!;
object?[] addTepArgs = [list, addTepError];
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-tep");
bool addTepOk = false;
string? addTepEx = null;
try { addTepOk = (bool)addTepMethod.Invoke(access, addTepArgs)!; }
catch (TargetInvocationException ex) { addTepEx = FormatException(ex.InnerException ?? ex); }
tepRows.Add(new
{
Kind = "AddTagExtendedProperties",
Success = addTepOk,
Exception = addTepEx,
ErrorDescription = GetPropertyText(addTepArgs[1]!, "ErrorDescription"),
ErrorCode = GetPropertyText(addTepArgs[1]!, "ErrorCode"),
});
// 3) Optional delete (DelTep) to capture its inBuff too.
if (HasFlag(args, "--tep-delete"))
{
MethodInfo? delTepMethod = accessType.GetMethods()
.FirstOrDefault(m => m.Name == "DeleteTagExtendedPropertiesByName" && m.GetParameters().Length == 4);
if (delTepMethod is not null)
{
Type namesColType = delTepMethod.GetParameters()[1].ParameterType; // StringCollection
object names = Activator.CreateInstance(namesColType)!;
namesColType.GetMethod("Add", new[] { typeof(string) })!.Invoke(names, [propName]);
object delErr = Activator.CreateInstance(errorType)!;
object?[] delArgs = [tepTag, names, true, delErr];
bool delOk = false; string? delEx = null;
try { delOk = (bool)delTepMethod.Invoke(access, delArgs)!; }
catch (TargetInvocationException ex) { delEx = FormatException(ex.InnerException ?? ex); }
tepRows.Add(new { Kind = "DeleteTagExtendedPropertiesByName", Success = delOk, Exception = delEx, ErrorDescription = GetPropertyText(delArgs[3]!, "ErrorDescription") });
}
}
Console.WriteLine(Serialize(new
{
Scenario = scenario,
TepTag = tepTag,
PropertyName = propName,
PropertyValue = propValue,
Rows = tepRows,
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsEventSendScenario(scenario))
{
// R2.1 capture: drive AddStreamedValue(HistorianEvent) and let instrument-wcf-*
// observe whether the event delivery rides the WCF MDAS path or the storage-engine
// pipe. Gated behind --event-send-confirm because it writes a real (clearly-marked)
// test event into the historian's event history.
if (!HasFlag(args, "--event-send-confirm"))
{
throw new InvalidOperationException(
"Event-send scenario writes a test event to the historian. Pass --event-send-confirm to proceed.");
}
Type historianEventType = GetType(assembly, "ArchestrA.HistorianEvent");
string eventTypeName = GetArg(args, "--event-type") ?? "User.Write";
string eventNamespace = GetArg(args, "--event-namespace") ?? "RetestSdkEventSend";
string eventSource = GetArg(args, "--event-source") ?? "RetestSdkEventSend";
object historianEvent = Activator.CreateInstance(historianEventType)!;
SetProperty(historianEvent, "ID", Guid.NewGuid());
SetProperty(historianEvent, "Type", eventTypeName);
SetProperty(historianEvent, "EventTime", DateTime.UtcNow);
SetProperty(historianEvent, "ReceivedTime", DateTime.UtcNow);
SetProperty(historianEvent, "Namespace", eventNamespace);
AddEventStringProperty(historianEvent, historianEventType, errorType, "Source", eventSource);
AddEventStringProperty(historianEvent, historianEventType, errorType, "TestMarker", "histsdk-R2.1-capture");
snapshots["HistorianEventBeforeSend"] = SnapshotObject(historianEvent);
// AddStreamedValue(HistorianEvent, out HistorianAccessError)
MethodInfo addEventMethod = accessType.GetMethods()
.First(m => m.Name == "AddStreamedValue"
&& m.GetParameters().Length == 2
&& m.GetParameters()[0].ParameterType == historianEventType);
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-event");
object addEventError = Activator.CreateInstance(errorType)!;
object?[] addEventArgs = [historianEvent, addEventError];
bool addEventSuccess = (bool)addEventMethod.Invoke(access, addEventArgs)!;
addEventError = addEventArgs[1]!;
snapshots["AddEventError"] = SnapshotObject(addEventError);
rows.Add(new
{
Kind = "AddStreamedEvent",
Success = addEventSuccess,
Type = eventTypeName,
ErrorType = GetPropertyText(addEventError, "ErrorType"),
ErrorCode = GetPropertyText(addEventError, "ErrorCode"),
ErrorDescription = GetPropertyText(addEventError, "ErrorDescription"),
});
// Force the queued event onto the wire. CloseStorageConnection flushes all memory
// buffers to storage and starts forwarding snapshots.
MethodInfo? closeStorageMethod = accessType.GetMethod("CloseStorageConnection", new[] { errorType.MakeByRefType() });
if (closeStorageMethod is not null)
{
object closeStorageError = Activator.CreateInstance(errorType)!;
object?[] closeStorageArgs = [closeStorageError];
bool closeStorageSuccess = (bool)closeStorageMethod.Invoke(access, closeStorageArgs)!;
closeStorageError = closeStorageArgs[0]!;
rows.Add(new
{
Kind = "CloseStorageConnection",
Success = closeStorageSuccess,
ErrorDescription = GetPropertyText(closeStorageError, "ErrorDescription"),
});
}
// Let the background sender / store-forward flush push bytes before teardown.
int flushWait = int.TryParse(GetArg(args, "--event-send-flush-seconds"), out int fw) ? fw : 6;
if (flushWait > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(flushWait));
}
}
else if (openSuccess && status.ConnectedToServer && IsEventScenario(scenario))
{
object query = accessType.GetMethod("CreateEventQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
Type queryType = query.GetType();
snapshots["EventQueryAfterCreate"] = SnapshotObject(query);
object queryArgs = Activator.CreateInstance(eventQueryArgsType)!;
SetProperty(queryArgs, "StartDateTime", startUtc);
SetProperty(queryArgs, "EndDateTime", endUtc);
SetProperty(queryArgs, "EventCount", checked((uint)Math.Max(maxRows, 1)));
SetProperty(queryArgs, "QueryType", Enum.Parse(eventQueryTypeType, "Events"));
SetProperty(queryArgs, "EventOrder", Enum.Parse(eventOrderType, "Ascending"));
snapshots["EventQueryArgsBeforeStart"] = SnapshotObject(queryArgs);
// R1.7 event-filter capture: --event-filter "Property:Op:Value" (repeatable via ';').
// Calls EventQuery.AddEventFilter(name, HistorianComparisionType, value, out err) so the
// filter predicate rides StartEventQuery's request buffer for instrument-wcf capture.
string? eventFilterSpec = GetArg(args, "--event-filter");
if (!string.IsNullOrWhiteSpace(eventFilterSpec))
{
Type comparisonType = GetType(assembly, "ArchestrA.HistorianComparisionType");
MethodInfo addFilterMethod = queryType.GetMethod("AddEventFilter",
new[] { typeof(string), comparisonType, typeof(object), errorType.MakeByRefType() })
?? throw new MissingMethodException("EventQuery.AddEventFilter");
foreach (string clause in eventFilterSpec!.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
{
string[] parts = clause.Split(new[] { ':' }, 3);
if (parts.Length < 3)
{
throw new ArgumentException($"--event-filter clause '{clause}' must be Property:Op:Value.");
}
object filterError = Activator.CreateInstance(errorType)!;
object?[] addFilterArgs = [parts[0], Enum.Parse(comparisonType, parts[1], ignoreCase: true), parts[2], filterError];
object addFilterResult = addFilterMethod.Invoke(query, addFilterArgs)!;
filterError = addFilterArgs[3]!;
rows.Add(new
{
Kind = "AddEventFilter",
Property = parts[0],
Op = parts[1],
Value = parts[2],
FilterId = addFilterResult,
ErrorDescription = GetPropertyText(filterError, "ErrorDescription"),
});
}
snapshots["EventQueryAfterAddFilter"] = SnapshotObject(query);
}
startError = Activator.CreateInstance(errorType)!;
MethodInfo startMethod = queryType.GetMethod("StartQuery", new[] { eventQueryArgsType, errorType.MakeByRefType() })
?? throw new MissingMethodException("EventQuery.StartQuery");
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-event-start");
if (preStartSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preStartSleepSeconds));
}
object?[] startArgs = [queryArgs, startError];
try
{
startSuccess = (bool)startMethod.Invoke(query, startArgs)!;
}
catch (TargetInvocationException ex)
{
startQueryException = FormatException(ex.InnerException ?? ex);
}
startError = startArgs[1];
snapshots["EventQueryAfterStart"] = SnapshotObject(query);
snapshots["EventQueryArgsAfterStart"] = SnapshotObject(queryArgs);
if (startSuccess)
{
MethodInfo moveMethod = queryType.GetMethod("MoveNext", new[] { errorType.MakeByRefType() })
?? throw new MissingMethodException("EventQuery.MoveNext");
for (int i = 0; i < maxRows; i++)
{
object moveError = Activator.CreateInstance(errorType)!;
object?[] moveArgs = [moveError];
bool hasRow = (bool)moveMethod.Invoke(query, moveArgs)!;
moveError = moveArgs[0]!;
if (!hasRow)
{
moveTerminalDescription = GetPropertyText(moveError, "ErrorDescription");
break;
}
object result = GetPropertyValue(query, "QueryResult")!;
snapshots["EventQueryAfterFirstMove"] = SnapshotObject(query);
snapshots["EventResultAfterFirstMove"] = SnapshotObject(result);
rows.Add(new
{
EventTime = FormatDateProperty(result, "EventTime"),
ReceivedTime = FormatDateProperty(result, "ReceivedTime"),
EventType = TryGetPropertyValue(result, "EventType"),
Type = TryGetPropertyValue(result, "Type"),
DisplayText = TryGetPropertyValue(result, "DisplayText"),
Area = TryGetPropertyValue(result, "Area"),
Source = TryGetPropertyValue(result, "Source"),
System = TryGetPropertyValue(result, "System"),
Severity = TryGetPropertyValue(result, "Severity"),
Priority = TryGetPropertyValue(result, "Priority"),
IsAlarm = TryGetPropertyValue(result, "IsAlarm")
});
}
}
MethodInfo? endMethod = queryType.GetMethod("EndQuery", new[] { errorType.MakeByRefType() });
if (endMethod is not null)
{
object endError = Activator.CreateInstance(errorType)!;
object?[] endArgs = [endError];
_ = endMethod.Invoke(query, endArgs);
}
if (query is IDisposable disposableQuery)
{
disposableQuery.Dispose();
}
}
else if (openSuccess && status.ConnectedToServer && IsWriteScenario(scenario))
{
// Per docs/plans/write-commands-reverse-engineering.md safety §1, refuse to run
// unless the sandbox tag name is whitelisted.
string sandboxTag = GetArg(args, "--write-sandbox-tag") ?? "RetestSdkWriteSandbox";
if (!sandboxTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Write scenario refuses to run against tags whose name doesn't start with 'RetestSdkWrite'. Pass --write-sandbox-tag RetestSdkWriteSandbox.");
}
string writeDataTypeName = GetArg(args, "--write-data-type") ?? "Float";
double writeValue = double.TryParse(GetArg(args, "--write-value"), out double parsedValue) ? parsedValue : 42.5;
double writeMinEu = double.TryParse(GetArg(args, "--write-min-eu"), out double parsedMinEu) ? parsedMinEu : 0.0;
double writeMaxEu = double.TryParse(GetArg(args, "--write-max-eu"), out double parsedMaxEu) ? parsedMaxEu : 100.0;
double writeMinRaw = double.TryParse(GetArg(args, "--write-min-raw"), out double parsedMinRaw) ? parsedMinRaw : 0.0;
double writeMaxRaw = double.TryParse(GetArg(args, "--write-max-raw"), out double parsedMaxRaw) ? parsedMaxRaw : 100.0;
// --write-skip-add-tag lets the value-only second pass run without re-creating
// the sandbox. The connection's tag cache is bound at OpenConnection time, so the
// server-cache refresh after a fresh AddTag requires a NEW process / connection.
bool skipAddTag = HasFlag(args, "--write-skip-add-tag");
bool skipAddValue = HasFlag(args, "--write-skip-add-value");
bool writeApplyScaling = HasFlag(args, "--write-apply-scaling");
string writeStorageTypeName = GetArg(args, "--write-storage-type") ?? "Cyclic";
// Decoded via dnlib — actual enum field types on HistorianTag:
// set_TagDataType stfld ArchestrA.HistorianDataType HistorianTag::dataType
// set_TagStorageType stfld ArchestrA.HistorianStorageType HistorianTag::tagStorageType
Type tagDefType = GetType(assembly, "ArchestrA.HistorianTag");
Type tagDataTypeEnum = GetType(assembly, "ArchestrA.HistorianDataType");
Type tagStorageTypeEnum = GetType(assembly, "ArchestrA.HistorianStorageType");
Type dataValueType = GetType(assembly, "ArchestrA.HistorianDataValue");
Type dataValueDataTypeEnum = GetType(assembly, "ArchestrA.HistorianDataType");
Type connectionIndexEnum = GetType(assembly, "ArchestrA.ConnectionIndex");
// Build HistorianTag for the sandbox.
object tag = Activator.CreateInstance(tagDefType)!;
SetProperty(tag, "TagName", sandboxTag);
SetProperty(tag, "TagDescription", "SDK write-RE sandbox tag");
SetProperty(tag, "EngineeringUnit", "test");
SetProperty(tag, "TagDataType", Enum.Parse(tagDataTypeEnum, writeDataTypeName, ignoreCase: true));
SetProperty(tag, "TagStorageType", Enum.Parse(tagStorageTypeEnum, writeStorageTypeName, ignoreCase: true));
SetProperty(tag, "MinEU", writeMinEu);
SetProperty(tag, "MaxEU", writeMaxEu);
SetProperty(tag, "MinRaw", writeMinRaw);
SetProperty(tag, "MaxRaw", writeMaxRaw);
SetProperty(tag, "StorageRate", 1000u);
SetProperty(tag, "ApplyScaling", writeApplyScaling);
uint tagKey = 0;
if (!skipAddTag)
{
object addError = Activator.CreateInstance(errorType)!;
MethodInfo addTagMethod = accessType.GetMethod("AddTag", new[] { tagDefType, typeof(uint).MakeByRefType(), errorType.MakeByRefType() })
?? throw new MissingMethodException("HistorianAccess.AddTag");
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-tag");
object?[] addTagArgs = [tag, tagKey, addError];
bool addTagSuccess = (bool)addTagMethod.Invoke(access, addTagArgs)!;
tagKey = (uint)addTagArgs[1]!;
addError = addTagArgs[2]!;
snapshots["TagAfterAddTag"] = SnapshotObject(tag);
snapshots["AddTagError"] = SnapshotObject(addError);
rows.Add(new
{
Kind = "AddTag",
Success = addTagSuccess,
TagKey = tagKey,
ErrorDescription = GetPropertyText(addError, "ErrorDescription"),
});
}
// ALWAYS look up the real wwTagKey from SQL — AddTag returns a synthetic
// placeholder key (~10000000) when the tag is freshly created, but the server
// session cache only recognizes the real Runtime.dbo.Tag.wwTagKey value
// (small int). Using the synthetic key in AddStreamedValue causes server-side
// error 168 "Tag not added to server".
using (System.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;"))
{
sql.Open();
using System.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT wwTagKey FROM Tag WHERE TagName = @t";
cmd.Parameters.AddWithValue("@t", sandboxTag);
object? result = cmd.ExecuteScalar();
if (result is int existingKey)
{
uint realKey = (uint)existingKey;
if (realKey != tagKey)
{
rows.Add(new { Kind = "TagKeyOverride", Synthetic = tagKey, RealFromSql = realKey });
tagKey = realKey;
}
}
using System.Data.SqlClient.SqlCommand analogCmd = sql.CreateCommand();
analogCmd.CommandText = "SELECT MinEU, MaxEU, MinRaw, MaxRaw, Scaling FROM AnalogTag WHERE TagName = @t";
analogCmd.Parameters.AddWithValue("@t", sandboxTag);
using System.Data.SqlClient.SqlDataReader analogReader = analogCmd.ExecuteReader();
if (analogReader.Read())
{
rows.Add(new
{
Kind = "AnalogTagPersisted",
TagName = sandboxTag,
MinEU = analogReader.IsDBNull(0) ? (object)"<null>" : analogReader.GetDouble(0),
MaxEU = analogReader.IsDBNull(1) ? (object)"<null>" : analogReader.GetDouble(1),
MinRaw = analogReader.IsDBNull(2) ? (object)"<null>" : analogReader.GetDouble(2),
MaxRaw = analogReader.IsDBNull(3) ? (object)"<null>" : analogReader.GetDouble(3),
Scaling = analogReader.IsDBNull(4) ? (object)"<null>" : analogReader.GetValue(4),
InputApplyScaling = writeApplyScaling,
});
}
}
// Server cache may not pick up new tags immediately. Allow a wait between AddTag
// and AddStreamedValue so the server side has time to add the new tag to its
// in-memory cache. Configurable via --write-resync-wait-seconds (default 8).
int resyncWait = int.TryParse(GetArg(args, "--write-resync-wait-seconds"), out int w) ? w : 8;
if (resyncWait > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(resyncWait));
}
// Build HistorianDataValue + push it (drives AddS2 on the wire).
if (tagKey != 0 && !skipAddValue)
{
object value = Activator.CreateInstance(dataValueType)!;
SetProperty(value, "TagKey", tagKey);
SetProperty(value, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true));
SetProperty(value, "OpcQuality", (ushort)192); // Good
SetProperty(value, "Value", writeValue);
SetProperty(value, "StartDateTime", DateTime.UtcNow);
SetProperty(value, "EndDateTime", DateTime.UtcNow);
SetProperty(value, "ApplyScaling", false);
// The public AddStreamedValue overloads (per dnlib) are:
// 0x0600618C — public instance, locals(HistorianDataValue, DateTime)
// 0x0600618D — public instance, no locals (simplest dispatcher)
// The 0x0600618E impl is private and not reachable by reflection.
// Pick the public instance overload whose parameters are
// (HistorianDataValue, [bool/DateTime], HistorianAccessError&) — the
// 0x0600618D dispatcher matches 3 params.
MethodInfo addValueMethod = accessType.GetMethods()
.Where(m => m.Name == "AddStreamedValue")
.OrderBy(m => m.GetParameters().Length)
.First(m =>
{
ParameterInfo[] ps = m.GetParameters();
return ps.Length >= 2 && ps[0].ParameterType == dataValueType
&& ps[ps.Length - 1].ParameterType.IsByRef
&& ps[ps.Length - 1].ParameterType.GetElementType() == errorType;
});
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-value");
object addValueError = Activator.CreateInstance(errorType)!;
ParameterInfo[] addValueParams = addValueMethod.GetParameters();
object?[] addValueArgs = new object?[addValueParams.Length];
addValueArgs[0] = value;
for (int i = 1; i < addValueParams.Length - 1; i++)
{
Type pt = addValueParams[i].ParameterType;
addValueArgs[i] = pt == typeof(bool) ? false
: pt == typeof(DateTime) ? DateTime.UtcNow
: pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
addValueArgs[addValueParams.Length - 1] = addValueError;
bool addValueSuccess = (bool)addValueMethod.Invoke(access, addValueArgs)!;
addValueError = addValueArgs[addValueArgs.Length - 1]!;
snapshots["ValueAfterAddStreamedValue"] = SnapshotObject(value);
snapshots["AddValueError"] = SnapshotObject(addValueError);
rows.Add(new
{
Kind = "AddStreamedValue",
Success = addValueSuccess,
Value = writeValue,
ErrorDescription = GetPropertyText(addValueError, "ErrorDescription"),
});
}
// --write-revision-values triggers the revision (non-streamed) write path.
// Calls SendValues with a HistorianDataValueList populated via
// NonStreamedValuesBegin / AddNonStreamedValue / AddNonStreamedValuesEnd.
// Captures whatever happens — success, server-side rejection, or client
// gate — for protocol decoding purposes.
if (HasFlag(args, "--write-revision-values"))
{
try
{
Type dataValueListType = GetType(assembly, "ArchestrA.HistorianDataValueList");
Type dataCategoryEnum = GetType(assembly, "ArchestrA.HistorianDataCategory");
// Use HistorianAccess.CreateHistorianDataValueList — the public factory that
// binds the list to the live HistorianClient* via GetClient(ConnectionIndex).
MethodInfo createListMethod = accessType.GetMethods()
.Where(m => m.Name == "CreateHistorianDataValueList")
.OrderBy(m => m.GetParameters().Length)
.First();
rows.Add(new
{
Kind = "CreateHistorianDataValueListSig",
Params = createListMethod.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}").ToArray(),
});
rows.Add(new
{
Kind = "EnumValues",
DataCategory = Enum.GetValues(dataCategoryEnum).Cast<object>().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(),
ConnectionIndex = Enum.GetValues(connectionIndexEnum).Cast<object>().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(),
});
// Pick non-zero values where appropriate. Common AVEVA convention:
// HistorianDataCategory.Real or Process is typically the first non-zero entry
// ConnectionIndex.Process is the first non-zero entry
object?[] createListArgs = createListMethod.GetParameters().Select(p =>
{
if (p.ParameterType == dataCategoryEnum)
{
// Prefer first declared (likely Process or Real)
return Enum.GetValues(dataCategoryEnum).Cast<object>().FirstOrDefault();
}
if (p.ParameterType == connectionIndexEnum)
{
return Enum.Parse(connectionIndexEnum, "Process", ignoreCase: true);
}
return p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null;
}).ToArray();
object listInstance = createListMethod.Invoke(access, createListArgs)!;
snapshots["DataValueListBeforeBegin"] = SnapshotObject(listInstance);
System.Reflection.BindingFlags allInstance =
System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Instance;
MethodInfo beginMethod = dataValueListType.GetMethods(allInstance)
.First(m => m.Name == "NonStreamedValuesBegin");
object?[] beginArgs = beginMethod.GetParameters()
.Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null)
.ToArray();
object beginResult = beginMethod.Invoke(listInstance, beginArgs)!;
rows.Add(new
{
Kind = "NonStreamedValuesBegin",
Result = beginResult?.ToString(),
ParameterCount = beginArgs.Length,
});
// Optional override: target a different tag (e.g. SysTimeSec) by name.
// Used to investigate whether the cache gate is per-tag (i.e. tags
// already in the runtime cache pass validation while client-created
// sandbox tags don't).
string? targetTagOverride = GetArg(args, "--write-revision-target-tag");
uint revTagKey = tagKey;
if (!string.IsNullOrWhiteSpace(targetTagOverride))
{
using System.Data.SqlClient.SqlConnection sqlT = new("Server=.;Database=Runtime;Integrated Security=SSPI;");
sqlT.Open();
using System.Data.SqlClient.SqlCommand cmdT = sqlT.CreateCommand();
cmdT.CommandText = "SELECT wwTagKey FROM Tag WHERE TagName = @t";
cmdT.Parameters.AddWithValue("@t", targetTagOverride);
object? overrideKey = cmdT.ExecuteScalar();
if (overrideKey is int k)
{
revTagKey = (uint)k;
rows.Add(new { Kind = "RevisionTargetTagOverride", Tag = targetTagOverride, TagKey = revTagKey });
}
else
{
rows.Add(new { Kind = "RevisionTargetTagOverrideNotFound", Tag = targetTagOverride });
}
}
// Build a single HistorianDataValue for the revision sample.
object revValue = Activator.CreateInstance(dataValueType)!;
SetProperty(revValue, "TagKey", revTagKey);
SetProperty(revValue, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true));
SetProperty(revValue, "OpcQuality", (ushort)192);
SetProperty(revValue, "Value", writeValue);
SetProperty(revValue, "StartDateTime", DateTime.UtcNow.AddSeconds(-30));
SetProperty(revValue, "EndDateTime", DateTime.UtcNow.AddSeconds(-30));
SetProperty(revValue, "ApplyScaling", false);
MethodInfo[] addMethodCandidates = dataValueListType.GetMethods(allInstance)
.Where(m => m.Name == "AddNonStreamedValue")
.ToArray();
rows.Add(new
{
Kind = "AddNonStreamedValueCandidates",
Count = addMethodCandidates.Length,
Sigs = addMethodCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(),
});
MethodInfo addMethod = addMethodCandidates
.OrderBy(m => m.GetParameters().Length)
.First();
// (HistorianDataValue value, bool validate, HistorianAccessError& error)
// --write-revision-skip-validate flips the bool to false to see if the
// cache gate is enforced inside this function or elsewhere downstream.
bool validateFlag = !HasFlag(args, "--write-revision-skip-validate");
object addError0 = Activator.CreateInstance(errorType)!;
object?[] addArgs = new object?[addMethod.GetParameters().Length];
addArgs[0] = revValue;
for (int i = 1; i < addArgs.Length - 1; i++)
{
Type pt = addMethod.GetParameters()[i].ParameterType;
addArgs[i] = pt == typeof(bool) ? validateFlag : pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
addArgs[addArgs.Length - 1] = addError0;
object addResult = addMethod.Invoke(listInstance, addArgs)!;
object addErrorAfter = addArgs[addArgs.Length - 1]!;
rows.Add(new
{
Kind = "AddNonStreamedValue",
Result = addResult?.ToString(),
ErrorDescription = GetPropertyText(addErrorAfter, "ErrorDescription"),
ErrorCode = GetPropertyText(addErrorAfter, "ErrorCode"),
ErrorType = GetPropertyText(addErrorAfter, "ErrorType"),
});
MethodInfo endListMethod = dataValueListType.GetMethods(allInstance)
.First(m => m.Name == "AddNonStreamedValuesEnd");
object?[] endListArgs = endListMethod.GetParameters()
.Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null)
.ToArray();
object endListResult = endListMethod.Invoke(listInstance, endListArgs)!;
rows.Add(new { Kind = "AddNonStreamedValuesEnd", Result = endListResult?.ToString() });
snapshots["DataValueListBeforeSend"] = SnapshotObject(listInstance);
// Try the DIRECT public AddNonStreamedValue overload on HistorianAccess —
// (ConnectionIndex, HistorianDataValue, bool, ref error). This bypasses
// the DataValueList layer and goes straight to HistorianClient.AddNonStreamedValueAsync.
// If it succeeds where the list path failed, the cache gate is in the list-side
// ValidateValue rather than the native client.
if (HasFlag(args, "--write-revision-direct"))
{
try
{
MethodInfo[] directCandidates = accessType.GetMethods(allInstance)
.Where(m => m.Name == "AddNonStreamedValue")
.ToArray();
rows.Add(new
{
Kind = "DirectAddNonStreamedValueCandidates",
Count = directCandidates.Length,
Sigs = directCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(),
});
// Pick the 4-param overload — (ConnectionIndex, HistorianDataValue,
// bool, error&). Drop the IsPublic filter; reflection with
// NonPublic binding flags can call internal methods.
MethodInfo direct = directCandidates.First(m => m.GetParameters().Length == 4);
object directError = Activator.CreateInstance(errorType)!;
object?[] directArgs = new object?[4];
// ConnectionIndex enum values are internal — list with NonPublic
// flags first, then probe both 0 and 1 (most enums use these for
// primary connection slots). For Process scenario it's typically 0.
System.Reflection.FieldInfo[] ciFields = connectionIndexEnum.GetFields(
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
rows.Add(new
{
Kind = "ConnectionIndexFields",
Fields = ciFields.Where(f => f.IsLiteral)
.Select(f => $"{f.Name}={Convert.ToInt32(f.GetRawConstantValue())}").ToArray(),
});
// Default: index 0 (cast int -> enum)
directArgs[0] = Enum.ToObject(connectionIndexEnum, 0);
directArgs[1] = revValue;
directArgs[2] = false; // skip validate
directArgs[3] = directError;
bool directSuccess = (bool)direct.Invoke(access, directArgs)!;
object directErrorAfter = directArgs[3]!;
rows.Add(new
{
Kind = "DirectAddNonStreamedValue",
Success = directSuccess,
ErrorDescription = GetPropertyText(directErrorAfter, "ErrorDescription"),
ErrorCode = GetPropertyText(directErrorAfter, "ErrorCode"),
ErrorType = GetPropertyText(directErrorAfter, "ErrorType"),
});
}
catch (Exception ex)
{
rows.Add(new { Kind = "DirectAddNonStreamedValueException", Type = ex.GetType().Name, Message = ex.Message, Inner = ex.InnerException?.Message });
}
}
// Safety: require explicit --write-revision-commit to actually fire
// SendValues. Without it, the harness validates the path (cache gate,
// value validation) but does NOT push anything to the wire. Important
// when targeting system tags via --write-revision-target-tag.
bool commitRevision = HasFlag(args, "--write-revision-commit");
if (!commitRevision)
{
rows.Add(new { Kind = "RevisionSendValuesSkipped", Reason = "Pass --write-revision-commit to actually call SendValues." });
goto skipSendValues;
}
// SendValues drives the on-the-wire revision flow:
// AddRevisionValuesBegin → AddRevisionValue × N → AddRevisionValuesEnd
// → SendNonStreamedValues (the actual WCF push).
object sendError = Activator.CreateInstance(errorType)!;
MethodInfo[] sendValuesCandidates = accessType.GetMethods(allInstance)
.Where(m => m.Name == "SendValues")
.ToArray();
rows.Add(new
{
Kind = "SendValuesCandidates",
Count = sendValuesCandidates.Length,
Sigs = sendValuesCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(),
});
MethodInfo sendValuesMethod = sendValuesCandidates
.OrderBy(m => m.GetParameters().Length)
.First();
// (HistorianDataValueList list, HistorianAccessError& error)
object?[] sendArgs = new object?[sendValuesMethod.GetParameters().Length];
sendArgs[0] = listInstance;
for (int i = 1; i < sendArgs.Length - 1; i++)
{
Type pt = sendValuesMethod.GetParameters()[i].ParameterType;
sendArgs[i] = pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
sendArgs[sendArgs.Length - 1] = sendError;
bool sendSuccess = (bool)sendValuesMethod.Invoke(access, sendArgs)!;
sendError = sendArgs[sendArgs.Length - 1]!;
rows.Add(new
{
Kind = "SendValues",
Success = sendSuccess,
SignatureParamCount = sendArgs.Length,
ErrorDescription = GetPropertyText(sendError, "ErrorDescription"),
ErrorCode = GetPropertyText(sendError, "ErrorCode"),
ErrorType = GetPropertyText(sendError, "ErrorType"),
});
snapshots["SendValuesError"] = SnapshotObject(sendError);
skipSendValues:;
}
catch (Exception ex)
{
rows.Add(new { Kind = "RevisionFlowException", Type = ex.GetType().Name, Message = ex.Message, Inner = ex.InnerException?.Message });
}
}
// DeleteTags runs independently of AddStreamedValue success (write-RE
// sandbox cleanup); guarded by --write-delete-after to keep the default
// run non-destructive.
if (HasFlag(args, "--write-delete-after"))
{
Type tagStatusType = GetType(assembly, "ArchestrA.HistorianTagStatus");
Type tagStatusListType = GetType(assembly, "ArchestrA.HistorianTagStatusList");
object tagsToDelete = Activator.CreateInstance(tagStatusListType)!;
object tagStatus = Activator.CreateInstance(tagStatusType)!;
SetProperty(tagStatus, "TagName", sandboxTag);
MethodInfo addItem = tagStatusListType.GetMethod("Add", new[] { tagStatusType })
?? throw new MissingMethodException("HistorianTagStatusList.Add");
addItem.Invoke(tagsToDelete, [tagStatus]);
object deleteError = Activator.CreateInstance(errorType)!;
MethodInfo deleteMethod = accessType.GetMethods().First(m =>
m.Name == "DeleteTags" && m.GetParameters().Length == 2);
object?[] deleteArgs = [tagsToDelete, deleteError];
bool deleteSuccess = (bool)deleteMethod.Invoke(access, deleteArgs)!;
deleteError = deleteArgs[1]!;
rows.Add(new
{
Kind = "DeleteTags",
Success = deleteSuccess,
ErrorDescription = GetPropertyText(deleteError, "ErrorDescription"),
});
}
}
else if (openSuccess && status.ConnectedToServer && IsTagScenario(scenario))
{
object query = accessType.GetMethod("CreateTagQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
Type queryType = query.GetType();
snapshots["TagQueryAfterCreate"] = SnapshotObject(query);
object queryArgs = Activator.CreateInstance(tagQueryArgsType)!;
SetProperty(queryArgs, "TagFilter", tagName);
SetProperty(queryArgs, "CacheTagInfo", true);
SetProperty(queryArgs, "RetrieveTagExtendedPropertyInfo", retrieveExtendedProperties);
snapshots["TagQueryArgsBeforeStart"] = SnapshotObject(queryArgs);
startError = Activator.CreateInstance(errorType)!;
MethodInfo startMethod = queryType.GetMethods().First(method =>
method.Name == "StartQuery" && method.GetParameters().Length == 3);
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-tag-start");
if (preStartSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preStartSleepSeconds));
}
object?[] startArgs = [queryArgs, 0u, startError];
try
{
startSuccess = (bool)startMethod.Invoke(query, startArgs)!;
}
catch (TargetInvocationException ex)
{
startQueryException = FormatException(ex.InnerException ?? ex);
}
uint tagCount = startArgs[1] is uint count ? count : 0;
startError = startArgs[2];
snapshots["TagQueryAfterStart"] = SnapshotObject(query);
snapshots["TagQueryArgsAfterStart"] = SnapshotObject(queryArgs);
if (startSuccess)
{
uint requestedRows = checked((uint)Math.Max(maxRows, 1));
MethodInfo? getTagNamesMethod = queryType.GetMethods().FirstOrDefault(method =>
method.Name == "GetTagNames" && method.GetParameters().Length == 4);
if (getTagNamesMethod is not null)
{
object tagNamesError = Activator.CreateInstance(errorType)!;
object?[] tagNameArgs = [0u, requestedRows, null, tagNamesError];
bool namesSuccess = (bool)getTagNamesMethod.Invoke(query, tagNameArgs)!;
tagNamesError = tagNameArgs[3]!;
rows.Add(new
{
Kind = "TagNames",
Success = namesSuccess,
ErrorDescription = GetPropertyText(tagNamesError, "ErrorDescription"),
Names = ToSerializableValue(tagNameArgs[2])
});
}
MethodInfo? getTagInfoMethod = queryType.GetMethods().FirstOrDefault(method =>
method.Name == "GetTagInfo" && method.GetParameters().Length == 4);
if (getTagInfoMethod is not null)
{
object tagInfoError = Activator.CreateInstance(errorType)!;
object?[] tagInfoArgs = [0u, requestedRows, null, tagInfoError];
bool infoSuccess = (bool)getTagInfoMethod.Invoke(query, tagInfoArgs)!;
tagInfoError = tagInfoArgs[3]!;
rows.Add(new
{
Kind = "TagInfo",
Success = infoSuccess,
ErrorDescription = GetPropertyText(tagInfoError, "ErrorDescription"),
Tags = SummarizeTagList(tagInfoArgs[2])
});
}
// R1.5 capture: explicitly pull extended properties so the native client issues
// the GetTepByNm WCF op (only fires when --retrieve-extended-properties is set,
// which flips RetrieveTagExtendedPropertyInfo on the query args above).
if (retrieveExtendedProperties)
{
MethodInfo? getTepMethod = queryType.GetMethods().FirstOrDefault(method =>
method.Name == "GetTagExtendedPropertyInfo" && method.GetParameters().Length == 4);
if (getTepMethod is not null)
{
object tepError = Activator.CreateInstance(errorType)!;
object?[] tepArgs = [0u, requestedRows, null, tepError];
bool tepSuccess = false;
try
{
tepSuccess = (bool)getTepMethod.Invoke(query, tepArgs)!;
}
catch (TargetInvocationException ex)
{
rows.Add(new { Kind = "TagExtendedPropertyException", Detail = FormatException(ex.InnerException ?? ex) });
}
tepError = tepArgs[3]!;
rows.Add(new
{
Kind = "TagExtendedProperties",
Success = tepSuccess,
ErrorDescription = GetPropertyText(tepError, "ErrorDescription"),
ErrorCode = GetPropertyText(tepError, "ErrorCode"),
Groups = ToSerializableValue(tepArgs[2])
});
if (tepArgs[2] is not null)
{
snapshots["TagExtendedPropertyGroups"] = SnapshotObject(tepArgs[2]!);
}
}
}
}
MethodInfo? endMethod = queryType.GetMethod("EndQuery", new[] { errorType.MakeByRefType() });
if (endMethod is not null)
{
object endError = Activator.CreateInstance(errorType)!;
object?[] endArgs = [endError];
_ = endMethod.Invoke(query, endArgs);
}
if (query is IDisposable disposableQuery)
{
disposableQuery.Dispose();
}
}
else if (openSuccess && status.ConnectedToServer)
{
object query = accessType.GetMethod("CreateHistoryQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
Type queryType = query.GetType();
snapshots["QueryAfterCreate"] = SnapshotObject(query);
object queryArgs = Activator.CreateInstance(historyQueryArgsType)!;
StringCollection tags = [];
tags.Add(tagName);
SetProperty(queryArgs, "TagNames", tags);
SetProperty(queryArgs, "StartDateTime", startUtc);
SetProperty(queryArgs, "EndDateTime", endUtc);
SetProperty(queryArgs, "BatchSize", checked((uint)Math.Max(maxRows, 1)));
SetProperty(queryArgs, "RetrievalMode", Enum.Parse(retrievalModeType, retrievalModeName, ignoreCase: true));
if (resolutionTicks > 0)
{
SetProperty(queryArgs, "Resolution", resolutionTicks);
}
// Summary knobs — only set when explicitly supplied so plain reads are untouched.
if (valueSelectorName is not null)
{
Type valueSelectorType = GetType(assembly, "ArchestrA.HistorianValueSelector");
SetProperty(queryArgs, "ValueSelector", Enum.Parse(valueSelectorType, valueSelectorName, ignoreCase: true));
}
if (aggregationTypeName is not null)
{
Type aggregationType = GetType(assembly, "ArchestrA.HistorianAggregationType");
SetProperty(queryArgs, "AggregationType", Enum.Parse(aggregationType, aggregationTypeName, ignoreCase: true));
}
if (maxStates > 0)
{
// HistoryQueryArgs.MaxStates is a UInt16 on the native wrapper.
SetProperty(queryArgs, "MaxStates", checked((ushort)maxStates));
}
if (historyFilter is not null)
{
SetProperty(queryArgs, "Filter", historyFilter);
}
snapshots["QueryArgsBeforeStart"] = SnapshotObject(queryArgs);
startError = Activator.CreateInstance(errorType)!;
MethodInfo startMethod = queryType.GetMethod("StartQuery", new[] { historyQueryArgsType, errorType.MakeByRefType() })
?? throw new MissingMethodException("HistoryQuery.StartQuery");
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-history-start");
if (preStartSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preStartSleepSeconds));
}
object?[] startArgs = [queryArgs, startError];
try
{
startSuccess = (bool)startMethod.Invoke(query, startArgs)!;
}
catch (TargetInvocationException ex)
{
startQueryException = FormatException(ex.InnerException ?? ex);
}
startError = startArgs[1];
snapshots["QueryAfterStart"] = SnapshotObject(query);
snapshots["QueryArgsAfterStart"] = SnapshotObject(queryArgs);
if (startSuccess)
{
MethodInfo moveMethod = queryType.GetMethod("MoveNext", new[] { errorType.MakeByRefType() })
?? throw new MissingMethodException("HistoryQuery.MoveNext");
for (int i = 0; i < maxRows; i++)
{
object moveError = Activator.CreateInstance(errorType)!;
object?[] moveArgs = [moveError];
bool hasRow = (bool)moveMethod.Invoke(query, moveArgs)!;
moveError = moveArgs[0]!;
if (!hasRow)
{
moveTerminalDescription = GetPropertyText(moveError, "ErrorDescription");
break;
}
object result = GetPropertyValue(query, "QueryResult")!;
snapshots["QueryAfterFirstMove"] = SnapshotObject(query);
snapshots["QueryResultAfterFirstMove"] = SnapshotObject(result);
rows.Add(new
{
StartDateTime = ((DateTime)GetPropertyValue(result, "StartDateTime")!).ToString("O"),
EndDateTime = ((DateTime)GetPropertyValue(result, "EndDateTime")!).ToString("O"),
Quality = GetPropertyValue(result, "Quality"),
OpcQuality = GetPropertyValue(result, "OpcQuality"),
QualityDetail = GetPropertyValue(result, "QualityDetail"),
Value = GetPropertyValue(result, "Value"),
PercentGood = GetPropertyValue(result, "PercentGood")
});
}
}
MethodInfo? endMethod = queryType.GetMethod("EndQuery", new[] { errorType.MakeByRefType() });
if (endMethod is not null)
{
object endError = Activator.CreateInstance(errorType)!;
object?[] endArgs = [endError];
_ = endMethod.Invoke(query, endArgs);
}
if (query is IDisposable disposableQuery)
{
disposableQuery.Dispose();
}
}
if (openSuccess)
{
object closeError = Activator.CreateInstance(errorType)!;
MethodInfo? closeMethod = accessType.GetMethod("CloseConnection", new[] { errorType.MakeByRefType() });
if (closeMethod is not null)
{
object?[] closeArgs = [closeError];
_ = closeMethod.Invoke(access, closeArgs);
}
}
if (access is IDisposable disposableAccess)
{
disposableAccess.Dispose();
}
Trace.Flush();
string tracePath = Path.Combine(repoRoot, "docs", "reverse-engineering", "native-wcf-message-log.svclog");
var output = new
{
Operation = "NativeTraceHarness.IntegratedRead",
Scenario = scenario,
ServerName = serverName,
DirectConnection = directConnection,
ProxyServer = proxyServer,
TagName = tagName,
LookbackMinutes = lookbackMinutes,
RetrievalMode = retrievalModeName,
ResolutionTicks = resolutionTicks,
ValueSelector = valueSelectorName,
AggregationType = aggregationTypeName,
MaxStates = maxStates,
HistoryFilter = historyFilter,
StartUtc = startUtc.ToString("O"),
EndUtc = endUtc.ToString("O"),
OpenSuccess = openSuccess,
OpenErrorType = GetPropertyText(openError, "ErrorType"),
OpenErrorCode = GetPropertyText(openError, "ErrorCode"),
OpenErrorDescription = GetPropertyText(openError, "ErrorDescription"),
status.ConnectedToServer,
status.Pending,
status.ErrorOccurred,
StartQuerySuccess = startSuccess,
StartQueryErrorType = GetPropertyText(startError, "ErrorType"),
StartQueryErrorCode = GetPropertyText(startError, "ErrorCode"),
StartQueryErrorDescription = GetPropertyText(startError, "ErrorDescription"),
StartQueryException = startQueryException,
MoveTerminalDescription = moveTerminalDescription,
RowCount = rows.Count,
Rows = rows,
Snapshots = snapshots,
TracePath = tracePath,
TraceExists = File.Exists(tracePath),
TraceBytes = File.Exists(tracePath) ? new FileInfo(tracePath).Length : 0
};
Console.WriteLine(Serialize(output));
return openSuccess && startSuccess ? 0 : 1;
}
private static ConnectionStatusSnapshot WaitForConnection(object access, Type accessType, Type statusType, int waitSeconds)
{
MethodInfo method = accessType.GetMethod("GetConnectionStatus", new[] { statusType.MakeByRefType() })
?? throw new MissingMethodException("HistorianAccess.GetConnectionStatus");
DateTime deadline = DateTime.UtcNow.AddSeconds(Math.Max(waitSeconds, 1));
ConnectionStatusSnapshot snapshot;
do
{
object status = Activator.CreateInstance(statusType)!;
object?[] args = [status];
_ = method.Invoke(access, args);
status = args[0]!;
snapshot = new ConnectionStatusSnapshot(
(bool)GetPropertyValue(status, "ConnectedToServer")!,
(bool)GetPropertyValue(status, "Pending")!,
(bool)GetPropertyValue(status, "ErrorOccurred")!);
if ((snapshot.ConnectedToServer && !snapshot.Pending) || snapshot.ErrorOccurred || (!snapshot.ConnectedToServer && !snapshot.Pending))
{
return snapshot;
}
Thread.Sleep(250);
} while (DateTime.UtcNow < deadline);
return snapshot;
}
private static IReadOnlyList<object> DumpRuntimeMethodPointers(Assembly assembly, string filter)
{
List<object> results = [];
BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance;
LoadedModuleInfo? moduleInfo = FindLoadedModule(Path.GetFileName(assembly.Location));
foreach (MethodInfo method in assembly.ManifestModule.GetMethods(flags))
{
AddRuntimeMethodPointer(results, "<Module>", method, filter, moduleInfo);
}
foreach (Type type in assembly.GetTypes())
{
foreach (MethodInfo method in type.GetMethods(flags))
{
AddRuntimeMethodPointer(results, type.FullName ?? type.Name, method, filter, moduleInfo);
}
}
return results;
}
private static void WriteRuntimeMethodPointerSnapshot(
Assembly assembly,
string? outputPath,
string filtersText,
string repoRoot,
string scenario,
string phase)
{
if (string.IsNullOrWhiteSpace(outputPath))
{
return;
}
string resolvedPath = Path.IsPathRooted(outputPath!)
? outputPath!
: Path.Combine(repoRoot, outputPath!);
string? directory = Path.GetDirectoryName(resolvedPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
List<object> methodPointers = [];
foreach (string rawFilter in filtersText.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries))
{
string filter = rawFilter.Trim();
if (filter.Length == 0)
{
continue;
}
methodPointers.Add(new
{
Filter = filter,
Methods = DumpRuntimeMethodPointers(assembly, filter)
});
}
var snapshot = new
{
Operation = "NativeTraceHarness.RuntimeMethodPointerSnapshot",
ProcessId = Process.GetCurrentProcess().Id,
Scenario = scenario,
Phase = phase,
TimestampUtc = DateTime.UtcNow.ToString("O"),
AssemblyPath = assembly.Location,
MethodPointers = methodPointers
};
File.WriteAllText(resolvedPath, Serialize(snapshot));
}
private static void AddRuntimeMethodPointer(List<object> results, string declaringType, MethodInfo method, string filter, LoadedModuleInfo? moduleInfo)
{
string fullName = declaringType + "." + method.Name;
if (fullName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
{
return;
}
string? pointer = null;
string? prepareError = null;
try
{
RuntimeHelpers.PrepareMethod(method.MethodHandle);
long pointerValue = method.MethodHandle.GetFunctionPointer().ToInt64();
pointer = "0x" + pointerValue.ToString("X");
bool pointerInModule = moduleInfo is not null && pointerValue >= moduleInfo.BaseAddress && pointerValue < moduleInfo.EndAddress;
long? pointerRva = pointerInModule ? pointerValue - moduleInfo!.BaseAddress : null;
results.Add(new
{
DeclaringType = declaringType,
method.Name,
MetadataToken = "0x" + method.MetadataToken.ToString("X8"),
IsStatic = method.IsStatic,
IsPublic = method.IsPublic,
ModuleBase = moduleInfo is not null ? "0x" + moduleInfo.BaseAddress.ToString("X") : null,
ModuleSize = moduleInfo is not null ? "0x" + moduleInfo.Size.ToString("X") : null,
FunctionPointer = pointer,
FunctionPointerInModule = pointerInModule,
FunctionPointerRva = pointerRva.HasValue ? "0x" + pointerRva.Value.ToString("X") : null,
PrepareError = prepareError
});
return;
}
catch (Exception ex)
{
prepareError = FormatException(ex);
}
results.Add(new
{
DeclaringType = declaringType,
method.Name,
MetadataToken = "0x" + method.MetadataToken.ToString("X8"),
IsStatic = method.IsStatic,
IsPublic = method.IsPublic,
ModuleBase = moduleInfo is not null ? "0x" + moduleInfo.BaseAddress.ToString("X") : null,
ModuleSize = moduleInfo is not null ? "0x" + moduleInfo.Size.ToString("X") : null,
FunctionPointer = pointer,
FunctionPointerInModule = false,
FunctionPointerRva = (string?)null,
PrepareError = prepareError
});
}
private static LoadedModuleInfo? FindLoadedModule(string moduleName)
{
foreach (ProcessModule module in Process.GetCurrentProcess().Modules)
{
if (string.Equals(module.ModuleName, moduleName, StringComparison.OrdinalIgnoreCase))
{
return new LoadedModuleInfo(module.BaseAddress.ToInt64(), module.ModuleMemorySize);
}
}
return null;
}
private sealed class LoadedModuleInfo
{
public LoadedModuleInfo(long baseAddress, int size)
{
BaseAddress = baseAddress;
Size = size;
}
public long BaseAddress { get; }
public int Size { get; }
public long EndAddress => BaseAddress + Size;
}
private static string Serialize(object value)
{
return new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(value);
}
private static string FindRepoRoot()
{
string? directory = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(directory))
{
if (File.Exists(Path.Combine(directory, "Histsdk.slnx")))
{
return directory!;
}
directory = Directory.GetParent(directory)?.FullName;
}
return Directory.GetCurrentDirectory();
}
private static string? GetArg(string[] args, string name)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
private static bool HasFlag(string[] args, string name)
{
foreach (string arg in args)
{
if (arg.Equals(name, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static DateTime? TryParseUtc(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (!DateTime.TryParse(
value,
null,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out DateTime parsed))
{
throw new ArgumentException("Invalid UTC timestamp: " + value);
}
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
private static Type GetType(Assembly assembly, string name)
{
return assembly.GetType(name, throwOnError: true)!;
}
private static void SetProperty(object target, string name, object value)
{
PropertyInfo? property = target.GetType().GetProperty(name);
if (property is not null && property.CanWrite)
{
MethodInfo? setter = property.GetSetMethod(nonPublic: true);
if (setter is not null)
{
setter.Invoke(target, [value]);
}
}
}
private static void SetField(object target, string name, object value)
{
FieldInfo? field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (field is not null)
{
field.SetValue(target, value);
}
}
private static object? GetPropertyValue(object target, string name)
{
PropertyInfo? property = target.GetType().GetProperty(name);
return property is null || !property.CanRead ? null : property.GetValue(target);
}
private static object? TryGetPropertyValue(object target, string name)
{
return TryRead(() => GetPropertyValue(target, name));
}
private static string? FormatDateProperty(object target, string name)
{
object? value = TryGetPropertyValue(target, name);
return value is DateTime dateTime ? dateTime.ToString("O") : value?.ToString();
}
private static string? GetPropertyText(object? target, string name)
{
if (target is null)
{
return null;
}
return GetPropertyValue(target, name)?.ToString();
}
private static string FormatException(Exception ex)
{
return ex.GetType().Name + ": " + ex.Message;
}
private static bool IsEventScenario(string scenario)
{
return scenario.Equals("event", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("events", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Event-SEND scenario (R2.1 capture): opens an Event connection in write mode
/// (ReadOnly=false) and drives <c>AddStreamedValue(HistorianEvent)</c> so the outgoing
/// event delivery can be captured. Distinct from the read-only event-query scenario.
/// </summary>
private static bool IsEventSendScenario(string scenario)
{
return scenario.Equals("event-send", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("send-event", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Both event-query and event-send require an Event-type connection.</summary>
/// <summary>
/// Runtime-parameter scenario (R1.2 capture): opens a normal authenticated process
/// connection and calls <c>GetRuntimeParameter</c> so the WCF op + buffer format can be
/// captured. Read-only; not an event or write connection.
/// </summary>
private static bool IsRuntimeParamScenario(string scenario)
{
return scenario.Equals("runtime-param", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("runtime-parameter", 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
/// can be captured. Read-only benign query.
/// </summary>
private static bool IsExecSqlScenario(string scenario)
{
return scenario.Equals("exec-sql", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("sql", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Tag extended-properties scenario (R1.5 capture): opens a normal authenticated process
/// connection and calls the NAME-based <c>GetTagExtendedPropertiesByName</c> so the GetTepByNm
/// WCF op + tagNames request / extended-property response buffers can be captured. This
/// bypasses the QTB (StartTagQuery) path, which fails server-side here.
/// </summary>
private static bool IsTagExtendedPropertiesScenario(string scenario)
{
return scenario.Equals("tag-extended-properties", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tag-tep", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Extended-property WRITE scenario (R1.11 capture): opens a write-enabled connection and calls
/// <c>AddTagExtendedProperties(TagExtendedPropertyGroupList, out err)</c> (and optionally
/// <c>DeleteTagExtendedPropertiesByName</c>) so instrument-wcf-writemessage can observe the
/// <c>AddTEx</c>/<c>DelTep</c> inBuff. Sandbox-guarded: the tag must start with RetestSdkWrite.
/// </summary>
private static bool IsAddTagExtendedPropertiesScenario(string scenario)
{
return scenario.Equals("add-tep", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tep-write", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("extended-property-write", StringComparison.OrdinalIgnoreCase);
}
private static bool IsEventConnectionScenario(string scenario)
{
return IsEventScenario(scenario) || IsEventSendScenario(scenario);
}
/// <summary>
/// Adds a string property to a HistorianEvent via the public
/// <c>AddProperty(string name, string value, out HistorianAccessError error)</c> overload.
/// </summary>
private static void AddEventStringProperty(object historianEvent, Type historianEventType, Type errorType, string name, string value)
{
MethodInfo addProperty = historianEventType.GetMethods()
.First(m => m.Name == "AddProperty"
&& m.GetParameters().Length == 3
&& m.GetParameters()[0].ParameterType == typeof(string)
&& m.GetParameters()[1].ParameterType == typeof(string)
&& m.GetParameters()[2].ParameterType.IsByRef);
object propertyError = Activator.CreateInstance(errorType)!;
object?[] propertyArgs = [name, value, propertyError];
addProperty.Invoke(historianEvent, propertyArgs);
}
private static bool IsTagScenario(string scenario)
{
return scenario.Equals("tag", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tags", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tag-query", StringComparison.OrdinalIgnoreCase);
}
private static bool IsWriteScenario(string scenario)
{
return scenario.Equals("write", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("writes", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tag-write", StringComparison.OrdinalIgnoreCase);
}
private static Dictionary<string, object?> SnapshotObject(object target)
{
Dictionary<string, object?> snapshot = new(StringComparer.OrdinalIgnoreCase);
BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
foreach (FieldInfo field in target.GetType().GetFields(flags))
{
if (ShouldSkipMember(field.Name))
{
continue;
}
snapshot["field:" + field.Name] = TryRead(() => ToSerializableValue(field.GetValue(target)));
}
foreach (PropertyInfo property in target.GetType().GetProperties(flags))
{
if (!property.CanRead || property.GetIndexParameters().Length != 0 || ShouldSkipMember(property.Name))
{
continue;
}
snapshot["property:" + property.Name] = TryRead(() => ToSerializableValue(property.GetValue(target)));
}
return snapshot;
}
private static bool ShouldSkipMember(string name)
{
return name.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf("user", StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf("security", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static object? TryRead(Func<object?> read)
{
try
{
return read();
}
catch (Exception ex)
{
return "<unreadable:" + ex.GetType().Name + ">";
}
}
private static object? ToSerializableValue(object? value)
{
if (value is null)
{
return null;
}
Type type = value.GetType();
if (type.IsEnum)
{
return value.ToString();
}
if (value is DateTime dateTime)
{
return dateTime.ToString("O");
}
if (value is string or bool or byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal)
{
return value;
}
if (value is StringCollection strings)
{
List<string> result = [];
foreach (string? item in strings)
{
if (item is not null)
{
result.Add(item);
}
}
return result;
}
if (value is Array array)
{
List<object?> result = [];
int count = Math.Min(array.Length, 8);
for (int i = 0; i < count; i++)
{
result.Add(ToSerializableValue(array.GetValue(i)));
}
if (array.Length > count)
{
result.Add("<truncated:" + array.Length + ">");
}
return result;
}
return "<" + type.FullName + ">";
}
private static List<object?> SummarizeTagList(object? tagList)
{
List<object?> result = [];
if (tagList is null)
{
return result;
}
object? lengthValue = TryGetPropertyValue(tagList, "Length") ?? TryGetPropertyValue(tagList, "Count");
int length = Convert.ToInt32(lengthValue);
int count = Math.Min(length, 8);
MethodInfo? itemMethod = tagList.GetType().GetMethods().FirstOrDefault(method =>
method.Name == "Item" && method.GetParameters().Length == 1);
for (int i = 0; i < count; i++)
{
object? tag = null;
try
{
tag = itemMethod?.Invoke(tagList, [checked((uint)i)]);
}
catch
{
tag = null;
}
if (tag is null)
{
try
{
tag = itemMethod?.Invoke(tagList, [checked((uint)(i + 1))]);
}
catch
{
tag = null;
}
}
result.Add(tag is null ? null : new
{
TagName = TryGetPropertyValue(tag, "TagName"),
TagDescription = TryGetPropertyValue(tag, "TagDescription"),
EngineeringUnit = TryGetPropertyValue(tag, "EngineeringUnit"),
TagKey = TryGetPropertyValue(tag, "TagKey"),
TagDataType = TryGetPropertyValue(tag, "TagDataType"),
TagStorageType = TryGetPropertyValue(tag, "TagStorageType"),
SourceTag = TryGetPropertyValue(tag, "SourceTag"),
SourceServer = TryGetPropertyValue(tag, "SourceServer"),
IsInActive = TryGetPropertyValue(tag, "IsInActive")
});
}
if (length > count)
{
result.Add("<truncated:" + length + ">");
}
return result;
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Best-effort cleanup; WCF may still hold the previous trace file.
}
}
private sealed class ConnectionStatusSnapshot
{
public ConnectionStatusSnapshot(bool connectedToServer, bool pending, bool errorOccurred)
{
ConnectedToServer = connectedToServer;
Pending = pending;
ErrorOccurred = errorOccurred;
}
public bool ConnectedToServer { get; }
public bool Pending { get; }
public bool ErrorOccurred { get; }
}
}