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() .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 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 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 names, // out List 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 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(); 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(); // 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())!; 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)"" : analogReader.GetDouble(0), MaxEU = analogReader.IsDBNull(1) ? (object)"" : analogReader.GetDouble(1), MinRaw = analogReader.IsDBNull(2) ? (object)"" : analogReader.GetDouble(2), MaxRaw = analogReader.IsDBNull(3) ? (object)"" : analogReader.GetDouble(3), Scaling = analogReader.IsDBNull(4) ? (object)"" : 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().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(), ConnectionIndex = Enum.GetValues(connectionIndexEnum).Cast().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().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())!; 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())!; 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 DumpRuntimeMethodPointers(Assembly assembly, string filter) { List 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, "", 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 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 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); } /// /// Event-SEND scenario (R2.1 capture): opens an Event connection in write mode /// (ReadOnly=false) and drives AddStreamedValue(HistorianEvent) so the outgoing /// event delivery can be captured. Distinct from the read-only event-query scenario. /// private static bool IsEventSendScenario(string scenario) { return scenario.Equals("event-send", StringComparison.OrdinalIgnoreCase) || scenario.Equals("send-event", StringComparison.OrdinalIgnoreCase); } /// Both event-query and event-send require an Event-type connection. /// /// Runtime-parameter scenario (R1.2 capture): opens a normal authenticated process /// connection and calls GetRuntimeParameter so the WCF op + buffer format can be /// captured. Read-only; not an event or write connection. /// private static bool IsRuntimeParamScenario(string scenario) { return scenario.Equals("runtime-param", StringComparison.OrdinalIgnoreCase) || scenario.Equals("runtime-parameter", StringComparison.OrdinalIgnoreCase); } /// /// SQL-command scenario (R1.1 capture): opens a normal authenticated process connection and /// calls ExecuteSqlCommand (Retr.ExeC + Retr.GetR) so the string-handle SQL surface /// can be captured. Read-only benign query. /// private static bool IsExecSqlScenario(string scenario) { return scenario.Equals("exec-sql", StringComparison.OrdinalIgnoreCase) || scenario.Equals("sql", StringComparison.OrdinalIgnoreCase); } /// /// Tag extended-properties scenario (R1.5 capture): opens a normal authenticated process /// connection and calls the NAME-based GetTagExtendedPropertiesByName 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. /// private static bool IsTagExtendedPropertiesScenario(string scenario) { return scenario.Equals("tag-extended-properties", StringComparison.OrdinalIgnoreCase) || scenario.Equals("tag-tep", StringComparison.OrdinalIgnoreCase); } /// /// Extended-property WRITE scenario (R1.11 capture): opens a write-enabled connection and calls /// AddTagExtendedProperties(TagExtendedPropertyGroupList, out err) (and optionally /// DeleteTagExtendedPropertiesByName) so instrument-wcf-writemessage can observe the /// AddTEx/DelTep inBuff. Sandbox-guarded: the tag must start with RetestSdkWrite. /// 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); } /// /// Adds a string property to a HistorianEvent via the public /// AddProperty(string name, string value, out HistorianAccessError error) overload. /// 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 SnapshotObject(object target) { Dictionary 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 read) { try { return read(); } catch (Exception ex) { return ""; } } 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 result = []; foreach (string? item in strings) { if (item is not null) { result.Add(item); } } return result; } if (value is Array array) { List 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(""); } return result; } return "<" + type.FullName + ">"; } private static List SummarizeTagList(object? tagList) { List 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(""); } 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; } } }