diff --git a/AGENTS.md b/AGENTS.md
index 5965f96..510994d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,12 +27,21 @@ a P/Invoke shim as the primary solution; it is useful only as an analysis aid.
## Repository Layout
-This workspace is an SDK investigation folder, not a full application repo.
+This workspace is a full Git repo (origin: gitea.dohertylan.com) with the
+shipping SDK under `src/`, tests under `tests/`, RE tooling under `tools/`,
+and decoded protocol notes under `docs/`. See `CLAUDE.md` for the
+authoritative architecture overview.
- `instructions.md` - source planning document and decision record.
+- `src\AVEVA.Historian.Client\` - the production managed SDK (pure .NET 10,
+ no native AVEVA references).
+- `tests\AVEVA.Historian.Client.Tests\` - unit + gated live integration tests.
+- `tools\` - reverse-engineering tooling (CLI, native trace harness,
+ WCF capture server, IL-rewrite instrumentation helper).
+- `docs\reverse-engineering\` - sanitized RE evidence and decoded notes.
- `current\` - the seven DLLs the existing sidecar links against today.
-- `aveva-install-x64\` - full 64-bit AVEVA Historian client-side DLL set.
-- `aveva-install-x86\` - full 32-bit AVEVA Historian client-side DLL set.
+- `aveva-install-x64\` and `aveva-install-x86\` - full AVEVA Historian
+ client-side DLL sets for cross-version reference.
Use `current\` first because it represents the deployed sidecar dependency set.
Use `aveva-install-*` to compare architecture-specific behavior and locate
@@ -40,22 +49,44 @@ adjacent client APIs.
## Required SDK Surface
-Keep the managed SDK narrowly scoped to the operations used in production:
+The shipping public surface (all live-verified against `localhost` —
+see `CLAUDE.md` "Required SDK Surface" for the authoritative list and
+caveats):
-- `ReadRawAsync(tag, startUtc, endUtc, maxValues)`
-- `ReadAggregateAsync(tag, startUtc, endUtc, mode, interval)`
-- `ReadAtTimeAsync(tag, timestampsUtc)`
-- `ReadEventsAsync(startUtc, endUtc)`
-- `ProbeAsync()`
+Reads:
-The existing alarm-event write path is dormant. Do not implement write-back
-unless a new requirement is supplied.
+- `ProbeAsync`
+- `ReadRawAsync`
+- `ReadAggregateAsync`
+- `ReadAtTimeAsync`
+- `ReadEventsAsync`
+- `BrowseTagNamesAsync`
+- `GetTagMetadataAsync`
+- Status helpers: `GetConnectionStatusAsync`, `GetStoreForwardStatusAsync`,
+ `GetSystemParameterAsync`
+
+Writes (added 2026-05-04 by explicit request):
+
+- `EnsureTagAsync` for analog Float / Double / Int2 / Int4 / UInt4
+ (with optional `ApplyScaling=true` for distinct MinRaw/MaxRaw and
+ optional `StorageRateMs` for non-default sampling).
+- `DeleteTagAsync`.
+
+`AddS2` (write samples) is architecturally blocked — the server's
+runtime cache only ingests from configured IOServer / Application Server
+pipelines. Do not extend write support without an explicit new request.
## Reverse-Engineering Workflow
+The bulk of the original RE workflow has been executed and is now backed
+by `docs/reverse-engineering/` evidence. The notes below are the durable
+process in case new captures are needed (e.g., for a new Historian version
+or a new write op).
+
### 1. Managed Wrapper Analysis
-Use dnSpy or ILSpy on `current\aahClientManaged.dll`.
+Use dnSpy / ILSpy / the in-repo `dnlib-method` CLI on
+`current\aahClientManaged.dll`.
Document:
@@ -66,8 +97,8 @@ Document:
- Returned sample/event models, quality fields, timestamp handling, and error
propagation.
-Prefer producing small Markdown notes under a future `docs\reverse-engineering\`
-folder rather than relying on memory.
+Sanitized notes go under `docs\reverse-engineering\` (the folder exists and
+is the canonical home for committed RE evidence).
### 2. Native ABI Mapping
@@ -137,34 +168,12 @@ newer Historian versions.
### 5. Managed Implementation Shape
-When implementation starts, use this project shape unless the real repo dictates
-otherwise:
+The implementation has landed and is the authoritative reference. See
+`CLAUDE.md` "Code Architecture" for the actual layout. The original
+abstract shape is preserved as historical context only.
-```text
-src/AVEVA.Historian.Client/
- AVEVA.Historian.Client.csproj
- HistorianClient.cs
- HistorianClientOptions.cs
- Models/
- HistorianSample.cs
- HistorianAggregateSample.cs
- HistorianEvent.cs
- RetrievalMode.cs
- Protocol/
- HistorianConnection.cs
- HistorianFrame.cs
- HistorianMessageType.cs
- HistorianProtocolReader.cs
- HistorianProtocolWriter.cs
- Transport/
- TcpHistorianTransport.cs
- ClusterEndpointPicker.cs
- Internal/
- BackoffPolicy.cs
-```
-
-Keep protocol parsing isolated from transport I/O so captured frames can be
-tested without a live Historian.
+Key design rule still in force: keep protocol parsing isolated from transport
+I/O so captured frames can be tested without a live Historian.
## Testing Expectations
@@ -188,27 +197,34 @@ Integration tests must skip cleanly when these values are not configured.
## Constraints
-- Keep the final SDK pure managed .NET 10.
-- Avoid adding native runtime dependencies to the production SDK.
-- Avoid broad API design. Implement only the operations listed above.
-- Treat AVEVA protocol details as version-sensitive; document assumptions.
+- Keep the final SDK managed .NET 10. The single P/Invoke surface allowed
+ in production is `HistorianSspiClient` (Windows SSPI for integrated
+ auth); do not add unrelated P/Invokes.
+- Avoid adding native runtime dependencies to the production SDK. No
+ reference to `aahClientManaged.dll` / `aahClient.dll` from `src/`.
+- Avoid broad API design. Implement only the operations listed in
+ "Required SDK Surface".
+- Treat AVEVA protocol details as version-sensitive; document assumptions
+ in `docs/reverse-engineering/`.
- Do not redistribute AVEVA binaries.
- Do not commit credentials, proprietary captures, or customer data.
- Do not delete or overwrite DLLs in `current\` or `aveva-install-*`.
## Definition of Done
-For the reverse-engineering phase:
+Both the RE phase and the SDK phase are **met** as of 2026-05-04:
-- Managed wrapper public surface and native entry points are documented.
-- Required query flows have sanitized captures or byte-level notes.
-- Message framing, request fields, response fields, and error frames are
- described well enough to implement parser tests.
+- Managed wrapper public surface and native entry points are documented in
+ `docs/reverse-engineering/`.
+- Required query flows have sanitized captures + byte-level notes; golden
+ fixtures live under `fixtures/protocol/`.
+- Message framing, request/response/error layouts are decoded sufficiently
+ for round-trip parser tests.
+- The shipping SDK implements the Required SDK Surface (reads + writes).
+- 169 unit + live integration tests pass.
+- Local consumers can replace the sidecar without `aahClientManaged.dll` or
+ `aahClient.dll` at runtime.
-For the SDK phase:
-
-- The managed client implements the required read-only surface.
-- Unit tests cover protocol parse/build behavior.
-- Integration tests can validate against a configured live Historian.
-- The SDK can replace the existing sidecar call sites without requiring
- `aahClientManaged.dll` or `aahClient.dll` at runtime.
+Future RE work (e.g., new Historian version, additional write ops) should
+follow the same workflow above; new evidence updates `docs/reverse-engineering/`
+and the relevant plan file under `docs/plans/`.
diff --git a/CLAUDE.md b/CLAUDE.md
index 713fee7..66fc3d1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Mission
-Build a fully managed .NET 10 replacement for AVEVA Historian's `aahClientManaged` / `aahClient.dll` stack by reverse-engineering the proprietary binary protocol. The production SDK under `src/AVEVA.Historian.Client/` must remain pure managed .NET 10 — no P/Invoke, no native AVEVA runtime dependency, no REST. Tools under `tools/` and scripts under `scripts/` are reverse-engineering aids only.
+Build a fully managed .NET 10 replacement for AVEVA Historian's `aahClientManaged` / `aahClient.dll` stack by reverse-engineering the proprietary binary protocol. The production SDK under `src/AVEVA.Historian.Client/` has no native AVEVA runtime dependency and no REST surface. The one P/Invoke is into Windows SSPI (`HistorianSspiClient` → `InitializeSecurityContextW`) for integrated-auth NTLM/Negotiate token generation; this gates the SDK to Windows-only execution today. See the `RemoteTcpCertificate` transport for a Windows-free auth path. Tools under `tools/` and scripts under `scripts/` are reverse-engineering aids only.
Read `AGENTS.md` (standing constraints), `instructions.md` (decision record), and `docs/reverse-engineering/handoff.md` (current evidence + active blocker) before starting non-trivial work. The handoff doc is the entry point — it tracks the live blocker, next pickup steps, and the canonical list of primary reference docs.
@@ -83,7 +83,7 @@ The original blocker — `Open2` reaching server logic but `Retr.StartQuery2` re
2. Native SSPI request flags — round 0 = `0x2081C` (adds `IDENTIFY` + `REPLAY_DETECT` + `SEQUENCE_DETECT`); rounds 1+ = `0x81C`. Without `REPLAY_DETECT|SEQUENCE_DETECT`, NTLM MIC generation is skipped and `AcceptSecurityContext` rejects round 1. Implemented in `HistorianSspiClient` via P/Invoke `InitializeSecurityContextW`.
3. Cross-service version probes (`Trx/GetV`, `Stat/GetV`, `Retr/GetV`) between RTag2 and EnsT2 in the event flow — required to register the client with each service's session table.
-End-to-end chain working from a pure managed .NET 10 client: `Hist.GetV → Hist.ValCl × N → Hist.Open2 → /Retr.GetV → Retr.IsOriginalAllowed → Retr.StartQuery2 → loop Retr.GetNextQueryResultBuffer2`. 23 live integration tests against `localhost` cover all required reads + the two write ops.
+End-to-end chain working from a pure managed .NET 10 client: `Hist.GetV → Hist.ValCl × N → Hist.Open2 → /Retr.GetV → Retr.IsOriginalAllowed → Retr.StartQuery2 → loop Retr.GetNextQueryResultBuffer2`. 169 unit + live integration tests against `localhost` cover all required reads, the two write ops, and the `RemoteTcpIntegrated` / `RemoteTcpCertificate` transports.
### Write-path notes (added 2026-05-04)
@@ -121,5 +121,5 @@ Unit tests are golden-byte and round-trip oriented — `WcfDataQueryProtocolTest
- Never commit credentials, hostnames, user names, customer tag names, or raw packet captures. Use placeholders in docs.
- Run a sanitization scan after touching auth/capture docs (the rg pattern is in handoff.md "Next Pickup Steps").
-- Production code under `src/` must remain pure managed .NET 10 with no native AVEVA reference. Reverse-engineering harnesses under `tools/` may reference native binaries.
+- Production code under `src/` must remain pure managed .NET 10 with no native AVEVA reference. The one allowed P/Invoke is into the Windows SSPI surface (`HistorianSspiClient`) for integrated-auth tokens; do not add unrelated P/Invokes. Reverse-engineering harnesses under `tools/` may reference native binaries.
- This workspace IS a Git working tree (origin: gitea.dohertylan.com). Use normal git workflow; the prior note about "no working tree, track via timestamps" is obsolete.
diff --git a/docs/reverse-engineering/handoff.md b/docs/reverse-engineering/handoff.md
index a195a27..f4561e8 100644
--- a/docs/reverse-engineering/handoff.md
+++ b/docs/reverse-engineering/handoff.md
@@ -78,8 +78,9 @@ Current known-good result:
- Build succeeds.
- Unit tests pass: 55/55.
-The repository folder is not currently a Git working tree in this checkout, so
-use file timestamps or your own external backup if you need change tracking.
+The workspace is a Git working tree (origin: gitea.dohertylan.com). Use
+normal git workflow for change tracking; the prior "no working tree, use
+timestamps" note is obsolete.
## Environment Variables
diff --git a/src/AVEVA.Historian.Client/Models/HistorianStorageType.cs b/src/AVEVA.Historian.Client/Models/HistorianStorageType.cs
new file mode 100644
index 0000000..b135f3d
--- /dev/null
+++ b/src/AVEVA.Historian.Client/Models/HistorianStorageType.cs
@@ -0,0 +1,19 @@
+namespace AVEVA.Historian.Client.Models;
+
+///
+/// Storage strategy for historized samples. Maps to Tag.StorageType in the
+/// Runtime DB. Values match the captured native enum and the server-persisted
+/// integer column.
+///
+public enum HistorianStorageType
+{
+ ///
+ /// Sample on a fixed cadence (see HistorianTagDefinition.StorageRateMs).
+ ///
+ Cyclic = 1,
+
+ ///
+ /// Sample only on value change (with optional value/time/rate deadbands).
+ ///
+ Delta = 2,
+}
diff --git a/src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs b/src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
index 65e42b5..6e49e75 100644
--- a/src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
+++ b/src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
@@ -61,4 +61,12 @@ public sealed record HistorianTagDefinition
/// return false.
///
public uint StorageRateMs { get; init; } = 1000u;
+
+ ///
+ /// Storage strategy. Default samples
+ /// on the configured cadence.
+ /// samples only on value change. The server persists this to Tag.StorageType
+ /// (Cyclic = 1, Delta = 2).
+ ///
+ public HistorianStorageType StorageType { get; init; } = HistorianStorageType.Cyclic;
}
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianSspiClient.cs b/src/AVEVA.Historian.Client/Wcf/HistorianSspiClient.cs
index f3d0090..56d07fb 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianSspiClient.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianSspiClient.cs
@@ -1,16 +1,30 @@
-using System.ComponentModel;
-using System.Runtime.InteropServices;
-using System.Runtime.Versioning;
+using System.Net;
+using System.Net.Security;
+using System.Security.Authentication.ExtendedProtection;
+using System.Security.Principal;
namespace AVEVA.Historian.Client.Wcf;
///
-/// Mirrors the request flags the AVEVA wrapper passes to InitializeSecurityContextW: 0x2081C round 0,
-/// 0x81C subsequent. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation; without it
-/// AcceptSecurityContext rejects the type-3 token with SEC_E_INVALID_TOKEN. ALLOCATE_MEMORY is added
-/// for output-buffer convenience and the server tolerates it.
+/// Cross-platform Negotiate / NTLM token producer for the Historian's `Hist.ValCl`
+/// authentication exchange. Uses under the hood
+/// (Windows: SSPI; Linux/macOS: GSSAPI via libgssapi_krb5 / gss-ntlmssp).
+///
+/// The native AVEVA wrapper passes specific request flags to
+/// InitializeSecurityContextW: IDENTIFY | CONNECTION | CONFIDENTIALITY |
+/// SEQUENCE_DETECT | REPLAY_DETECT on round 0 and the same minus IDENTIFY on
+/// rounds 1+. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation;
+/// without it AcceptSecurityContext rejects the type-3 token with
+/// SEC_E_INVALID_TOKEN. RequiredProtectionLevel.EncryptAndSign in
+/// NegotiateAuthentication implicitly requests SEQUENCE + REPLAY +
+/// CONFIDENTIALITY, and AllowedImpersonationLevel = Identification requests
+/// IDENTIFY — together these produce a request flag set that AcceptSecurityContext
+/// accepts on the server side.
+///
+/// The constants and request-flag selection helpers below are preserved for the
+/// existing unit tests in HistorianSspiClientTests — they document the
+/// captured native flag values rather than driving the underlying API today.
///
-[SupportedOSPlatform("windows")]
internal sealed class HistorianSspiClient : IDisposable
{
public const int IscReqReplayDetect = 0x4;
@@ -23,15 +37,7 @@ internal sealed class HistorianSspiClient : IDisposable
public const int NativeFlagsRound0 = IscReqIdentify | IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
public const int NativeFlagsRoundSubsequent = IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
- private const int SecpkgCredOutbound = 2;
- private const int SecbufferToken = 2;
- private const int SecEOk = 0;
- private const int SecIContinueNeeded = 0x00090312;
-
- private readonly string _targetName;
- private SecHandle _credential;
- private SecHandle _context;
- private bool _haveContext;
+ private readonly NegotiateAuthentication _auth;
private int _roundIndex;
private bool _disposed;
@@ -39,78 +45,39 @@ internal sealed class HistorianSspiClient : IDisposable
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(package);
- _targetName = targetName;
- _credential = default;
- int status = AcquireCredentialsHandle(null, package, SecpkgCredOutbound, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, ref _credential, out _);
- ThrowIfFailed(status, "AcquireCredentialsHandle");
+ _auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
+ {
+ Package = package,
+ TargetName = targetName,
+ RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
+ AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
+ RequireMutualAuthentication = false,
+ });
}
///
- /// Acquires Negotiate credentials for an explicit user/domain/password instead of the
- /// calling thread's Windows identity. Builds a SEC_WINNT_AUTH_IDENTITY (Unicode) and
- /// passes it as pAuthData to AcquireCredentialsHandleW. Untested against
- /// a live remote Historian; reserved for the explicit-creds path that the orchestrator
- /// will gate when is false.
+ /// Acquires Negotiate credentials for an explicit user/domain/password instead
+ /// of the calling thread's identity. On Linux this routes through GSSAPI's
+ /// credential acquisition; the supplied credential is wrapped in a
+ /// .
///
- public HistorianSspiClient(string targetName, string domain, string userName, string password, string package = "Negotiate")
+ public HistorianSspiClient(string targetName, string? domain, string userName, string? password, string package = "Negotiate")
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
ArgumentException.ThrowIfNullOrWhiteSpace(package);
- _targetName = targetName;
- _credential = default;
-
- IntPtr userPtr = IntPtr.Zero;
- IntPtr domainPtr = IntPtr.Zero;
- IntPtr passwordPtr = IntPtr.Zero;
- IntPtr authDataPtr = IntPtr.Zero;
- try
+ _auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
{
- userPtr = Marshal.StringToCoTaskMemUni(userName);
- domainPtr = string.IsNullOrEmpty(domain) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(domain);
- passwordPtr = string.IsNullOrEmpty(password) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(password);
-
- SecWinntAuthIdentity authIdentity = new()
- {
- User = userPtr,
- UserLength = userName.Length,
- Domain = domainPtr,
- DomainLength = domain?.Length ?? 0,
- Password = passwordPtr,
- PasswordLength = password?.Length ?? 0,
- Flags = SecWinntAuthIdentityUnicode
- };
-
- authDataPtr = Marshal.AllocCoTaskMem(Marshal.SizeOf());
- Marshal.StructureToPtr(authIdentity, authDataPtr, false);
-
- int status = AcquireCredentialsHandle(null, package, SecpkgCredOutbound, IntPtr.Zero, authDataPtr, IntPtr.Zero, IntPtr.Zero, ref _credential, out _);
- ThrowIfFailed(status, "AcquireCredentialsHandle");
- }
- finally
- {
- if (authDataPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(authDataPtr);
- if (passwordPtr != IntPtr.Zero) Marshal.ZeroFreeCoTaskMemUnicode(passwordPtr);
- if (domainPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(domainPtr);
- if (userPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(userPtr);
- }
+ Package = package,
+ TargetName = targetName,
+ Credential = new NetworkCredential(userName, password ?? string.Empty, domain ?? string.Empty),
+ RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
+ AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
+ RequireMutualAuthentication = false,
+ });
}
- private const int SecWinntAuthIdentityUnicode = 0x2;
-
- [StructLayout(LayoutKind.Sequential)]
- private struct SecWinntAuthIdentity
- {
- public IntPtr User;
- public int UserLength;
- public IntPtr Domain;
- public int DomainLength;
- public IntPtr Password;
- public int PasswordLength;
- public int Flags;
- }
-
- /// Internal accessor for tests; returns the request flag bitmask the next Next call will use.
+ /// Internal accessor for tests; returns the request flag bitmask the next Next call corresponds to.
internal int NextRequestFlags => SelectRequestFlags(_roundIndex) | IscReqAllocateMemory;
public static int SelectRequestFlags(int roundIndex) => roundIndex == 0 ? NativeFlagsRound0 : NativeFlagsRoundSubsequent;
@@ -120,68 +87,17 @@ internal sealed class HistorianSspiClient : IDisposable
ArgumentNullException.ThrowIfNull(incoming);
ObjectDisposedException.ThrowIf(_disposed, this);
- SecBufferDesc outDesc = CreateOutputBufferDesc();
- SecBufferDesc? inDesc = incoming.Length == 0 ? null : CreateInputBufferDesc(incoming);
- try
+ byte[]? outgoing = _auth.GetOutgoingBlob(incoming.Length == 0 ? null : incoming, out NegotiateAuthenticationStatusCode status);
+ _roundIndex++;
+
+ bool completed = status switch
{
- int requirements = NextRequestFlags;
- SecHandle newContext = default;
- int status;
- uint contextAttributes;
- long expiry;
+ NegotiateAuthenticationStatusCode.Completed => true,
+ NegotiateAuthenticationStatusCode.ContinueNeeded => false,
+ _ => throw new InvalidOperationException($"Negotiate handshake failed: {status}"),
+ };
- if (inDesc.HasValue)
- {
- SecBufferDesc input = inDesc.Value;
- status = InitializeSecurityContext(
- ref _credential,
- ref _context,
- _targetName,
- requirements,
- 0,
- 0,
- ref input,
- 0,
- ref newContext,
- ref outDesc,
- out contextAttributes,
- out expiry);
- }
- else
- {
- status = InitializeSecurityContext(
- ref _credential,
- IntPtr.Zero,
- _targetName,
- requirements,
- 0,
- 0,
- IntPtr.Zero,
- 0,
- ref newContext,
- ref outDesc,
- out contextAttributes,
- out expiry);
- }
-
- if (!_haveContext)
- {
- _context = newContext;
- _haveContext = true;
- }
-
- ThrowIfFailed(status, "InitializeSecurityContext", allowContinue: true);
- byte[] token = ReadTokenAndFree(outDesc);
- _roundIndex++;
- return new HistorianSspiStepResult(token, status == SecEOk);
- }
- finally
- {
- if (inDesc.HasValue)
- {
- FreeBufferDesc(inDesc.Value, freeToken: true);
- }
- }
+ return new HistorianSspiStepResult(outgoing ?? [], completed);
}
public void Dispose()
@@ -192,154 +108,7 @@ internal sealed class HistorianSspiClient : IDisposable
}
_disposed = true;
- if (_haveContext)
- {
- DeleteSecurityContext(ref _context);
- }
-
- FreeCredentialsHandle(ref _credential);
- }
-
- private static byte[] ReadTokenAndFree(SecBufferDesc desc)
- {
- try
- {
- SecBuffer buffer = Marshal.PtrToStructure(desc.pBuffers);
- if (buffer.cbBuffer == 0 || buffer.pvBuffer == IntPtr.Zero)
- {
- return [];
- }
-
- byte[] bytes = new byte[buffer.cbBuffer];
- Marshal.Copy(buffer.pvBuffer, bytes, 0, bytes.Length);
- FreeContextBuffer(buffer.pvBuffer);
- return bytes;
- }
- finally
- {
- FreeBufferDesc(desc, freeToken: false);
- }
- }
-
- private static SecBufferDesc CreateOutputBufferDesc()
- {
- SecBuffer buffer = new() { BufferType = SecbufferToken, cbBuffer = 0, pvBuffer = IntPtr.Zero };
- IntPtr bufferPtr = Marshal.AllocHGlobal(Marshal.SizeOf());
- Marshal.StructureToPtr(buffer, bufferPtr, false);
- return new SecBufferDesc { ulVersion = 0, cBuffers = 1, pBuffers = bufferPtr };
- }
-
- private static SecBufferDesc CreateInputBufferDesc(byte[] token)
- {
- IntPtr tokenPtr = Marshal.AllocHGlobal(token.Length);
- Marshal.Copy(token, 0, tokenPtr, token.Length);
- SecBuffer buffer = new() { BufferType = SecbufferToken, cbBuffer = token.Length, pvBuffer = tokenPtr };
- IntPtr bufferPtr = Marshal.AllocHGlobal(Marshal.SizeOf());
- Marshal.StructureToPtr(buffer, bufferPtr, false);
- return new SecBufferDesc { ulVersion = 0, cBuffers = 1, pBuffers = bufferPtr };
- }
-
- private static void FreeBufferDesc(SecBufferDesc desc, bool freeToken)
- {
- if (desc.pBuffers == IntPtr.Zero)
- {
- return;
- }
-
- if (freeToken)
- {
- SecBuffer buffer = Marshal.PtrToStructure(desc.pBuffers);
- if (buffer.pvBuffer != IntPtr.Zero)
- {
- Marshal.FreeHGlobal(buffer.pvBuffer);
- }
- }
-
- Marshal.FreeHGlobal(desc.pBuffers);
- }
-
- private static void ThrowIfFailed(int status, string operation, bool allowContinue = false)
- {
- if (status == SecEOk || (allowContinue && status == SecIContinueNeeded))
- {
- return;
- }
-
- throw new Win32Exception(status, operation + " failed with 0x" + status.ToString("X8"));
- }
-
- [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
- private static extern int AcquireCredentialsHandle(
- string? pszPrincipal,
- string pszPackage,
- int fCredentialUse,
- IntPtr pvLogonId,
- IntPtr pAuthData,
- IntPtr pGetKeyFn,
- IntPtr pvGetKeyArgument,
- ref SecHandle phCredential,
- out long ptsExpiry);
-
- [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
- private static extern int InitializeSecurityContext(
- ref SecHandle phCredential,
- ref SecHandle phContext,
- string pszTargetName,
- int fContextReq,
- int reserved1,
- int targetDataRep,
- ref SecBufferDesc pInput,
- int reserved2,
- ref SecHandle phNewContext,
- ref SecBufferDesc pOutput,
- out uint pfContextAttr,
- out long ptsExpiry);
-
- [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
- private static extern int InitializeSecurityContext(
- ref SecHandle phCredential,
- IntPtr phContext,
- string pszTargetName,
- int fContextReq,
- int reserved1,
- int targetDataRep,
- IntPtr pInput,
- int reserved2,
- ref SecHandle phNewContext,
- ref SecBufferDesc pOutput,
- out uint pfContextAttr,
- out long ptsExpiry);
-
- [DllImport("secur32.dll", SetLastError = false)]
- private static extern int FreeCredentialsHandle(ref SecHandle phCredential);
-
- [DllImport("secur32.dll", SetLastError = false)]
- private static extern int DeleteSecurityContext(ref SecHandle phContext);
-
- [DllImport("secur32.dll", SetLastError = false)]
- private static extern int FreeContextBuffer(IntPtr pvContextBuffer);
-
- [StructLayout(LayoutKind.Sequential)]
- private struct SecHandle
- {
- public IntPtr dwLower;
- public IntPtr dwUpper;
- }
-
- [StructLayout(LayoutKind.Sequential)]
- private struct SecBuffer
- {
- public int cbBuffer;
- public int BufferType;
- public IntPtr pvBuffer;
- }
-
- [StructLayout(LayoutKind.Sequential)]
- private struct SecBufferDesc
- {
- public int ulVersion;
- public int cBuffers;
- public IntPtr pBuffers;
+ _auth.Dispose();
}
}
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
index 4d42b0d..caa08a2 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
@@ -39,16 +39,24 @@ internal static class HistorianTagWriteProtocol
///
/// 11 bytes preceding the data-type discriminator. Byte 0 is the leading 0x4E
- /// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is `0x02`
- /// (sub-marker preceding the type code).
+ /// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is the
+ /// storage-type sub-marker (`0x02` for Cyclic, `0x06` for Delta — captured
+ /// 2026-05-04 by toggling --write-storage-type on the harness).
///
- private static readonly byte[] AnalogHeaderUpToTypeCode =
+ private static readonly byte[] AnalogHeaderUpToTypeCodeCyclic =
[
0x4E,
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
0x02,
];
+ private static readonly byte[] AnalogHeaderUpToTypeCodeDelta =
+ [
+ 0x4E,
+ 0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
+ 0x06,
+ ];
+
///
/// Native CDataType wire codes per data type — captured 2026-05-04 by probing
/// every type via instrument-wcf-writemessage. Matches the codes already documented
@@ -78,11 +86,14 @@ internal static class HistorianTagWriteProtocol
}
}
- // After MDAS, the captured layout is:
- // `02 01 01 00 00 00` (6 bytes — flag block, observed constant)
- // `01` (1 byte — observed constant; purpose unclear)
- // uint32 storage rate (4 bytes)
- private static readonly byte[] AnalogFlagBlock = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
+ // After MDAS, the captured layout is a 7-byte flag block followed by uint32
+ // storage rate. The flag block's second byte is the StorageType (1 = Cyclic,
+ // 2 = Delta — captured 2026-05-04). When StorageType=Delta, an additional
+ // 4 zero bytes are inserted between the storage rate and the FILETIME (likely
+ // a placeholder for Delta-specific deadband / threshold config).
+ private static readonly byte[] AnalogFlagBlockCyclic = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
+ private static readonly byte[] AnalogFlagBlockDelta = [0x02, 0x02, 0x01, 0x00, 0x00, 0x00, 0x01];
+ private static readonly byte[] AnalogDeltaPostStorageRatePadding = new byte[4];
/// Compact "use defaults" scaling marker — emitted when MinEU/MaxEU/MinRaw/MaxRaw are 0/100/0/100.
private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
/// Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.
@@ -133,7 +144,8 @@ internal static class HistorianTagWriteProtocol
double minRaw = DefaultMinRaw,
double maxRaw = DefaultMaxRaw,
uint storageRateMs = DefaultStorageRateMs,
- bool applyScaling = false)
+ bool applyScaling = false,
+ Models.HistorianStorageType storageType = Models.HistorianStorageType.Cyclic)
{
if (storageRateMs == 0)
{
@@ -141,19 +153,24 @@ internal static class HistorianTagWriteProtocol
}
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
byte typeCode = GetAnalogDataTypeCode(dataType);
+ bool isDelta = storageType == Models.HistorianStorageType.Delta;
using MemoryStream ms = new();
using BinaryWriter w = new(ms);
- w.Write(AnalogHeaderUpToTypeCode); // 11 bytes (incl 0x4E leading marker, ends at sub-marker 0x02)
+ w.Write(isDelta ? AnalogHeaderUpToTypeCodeDelta : AnalogHeaderUpToTypeCodeCyclic); // 11 bytes
w.Write(typeCode); // 1 byte data-type discriminator
w.Write(AnalogPadding16); // 16 bytes (all zero — placeholder GUID + 2)
WriteCompactAscii(w, tagName); // var
w.Write(AnalogPostNamePadding); // 16 bytes of 0xFF
WriteCompactAscii(w, description ?? string.Empty); // var
WriteCompactAscii(w, MetadataProvider); // 7 bytes ("MDAS")
- w.Write(AnalogFlagBlock); // 7 bytes
+ w.Write(isDelta ? AnalogFlagBlockDelta : AnalogFlagBlockCyclic); // 7 bytes
w.Write(storageRateMs); // uint32
+ if (isDelta)
+ {
+ w.Write(AnalogDeltaPostStorageRatePadding); // 4 bytes (Delta-only)
+ }
w.Write(dateCreatedUtc.ToUniversalTime().ToFileTimeUtc()); // int64
if (minEU == DefaultMinEU && maxEU == DefaultMaxEU && minRaw == DefaultMinRaw && maxRaw == DefaultMaxRaw)
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
index b559f14..0f17a45 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
@@ -110,7 +110,8 @@ internal sealed class HistorianWcfTagWriteOrchestrator
minRaw: definition.MinRaw,
maxRaw: definition.MaxRaw,
storageRateMs: definition.StorageRateMs,
- applyScaling: definition.ApplyScaling);
+ applyScaling: definition.ApplyScaling,
+ storageType: definition.StorageType);
bool ok = historyChannel.EnsureTags2(
handle: handle,
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
index 0604d1e..f906675 100644
--- a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
@@ -488,6 +488,50 @@ public sealed class HistorianClientIntegrationTests
}
}
+ [Fact]
+ public async Task EnsureTagAsync_StorageTypeDelta_PersistsToTagTableAsTwo()
+ {
+ string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
+ if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ const string sandboxTag = "RetestSdkWriteStorageTypeDeltaRT";
+ HistorianClient client = new(new HistorianClientOptions
+ {
+ Host = host,
+ IntegratedSecurity = true,
+ Transport = HistorianTransport.LocalPipe,
+ });
+
+ try
+ {
+ bool ok = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
+ {
+ TagName = sandboxTag,
+ Description = "SDK Delta round-trip",
+ EngineeringUnit = "test",
+ DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
+ StorageType = AVEVA.Historian.Client.Models.HistorianStorageType.Delta,
+ }, CancellationToken.None);
+ Assert.True(ok, "EnsureTagAsync(Delta) returned false");
+
+ using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
+ sql.Open();
+ using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
+ cmd.CommandText = "SELECT StorageType FROM Tag WHERE TagName = @t";
+ cmd.Parameters.AddWithValue("@t", sandboxTag);
+ object? st = cmd.ExecuteScalar();
+ Assert.NotNull(st);
+ Assert.Equal((int)AVEVA.Historian.Client.Models.HistorianStorageType.Delta, Convert.ToInt32(st));
+ }
+ finally
+ {
+ await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
+ }
+ }
+
[Fact]
public async Task EnsureTagAsync_NonDefaultStorageRate_PersistsToTagTable()
{
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs
index d057dfc..41b5625 100644
--- a/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs
@@ -153,6 +153,34 @@ public sealed class HistorianTagWriteProtocolTests
storageRateMs: 0u));
}
+ [Fact]
+ public void SerializeAnalogCTagMetadata_StorageTypeDelta_FlipsHeaderByte10AndFlagBlockByte1AndAddsFourBytePadding()
+ {
+ // Captured 2026-05-04 by toggling --write-storage-type on the native harness:
+ // Delta differs from Cyclic in three places — header byte 10 (0x02 -> 0x06),
+ // flag-block byte 1 (0x01 -> 0x02), and 4 zero bytes inserted after StorageRate
+ // before the FILETIME. Net length difference is +4 bytes for Delta.
+ byte[] cyclic = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
+ tagName: "RetestSdkWriteStorageTypeRT",
+ description: "x",
+ engineeringUnit: "test",
+ dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
+ storageType: AVEVA.Historian.Client.Models.HistorianStorageType.Cyclic);
+ byte[] delta = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
+ tagName: "RetestSdkWriteStorageTypeRT",
+ description: "x",
+ engineeringUnit: "test",
+ dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
+ storageType: AVEVA.Historian.Client.Models.HistorianStorageType.Delta);
+
+ Assert.Equal(cyclic.Length + 4, delta.Length);
+ // Header byte 10 (storage-type sub-marker before the data-type code).
+ Assert.Equal(0x02, cyclic[10]);
+ Assert.Equal(0x06, delta[10]);
+ // The data-type code at byte 11 is unchanged.
+ Assert.Equal(cyclic[11], delta[11]);
+ }
+
[Fact]
public void SerializeAnalogCTagMetadata_ApplyScalingTrue_FlipsTrailerSecondByte()
{
diff --git a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
index ad274b4..2800dbd 100644
--- a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
+++ b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
@@ -238,6 +238,7 @@ internal static class Program
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
@@ -255,7 +256,7 @@ internal static class Program
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, "Cyclic", ignoreCase: true));
+ SetProperty(tag, "TagStorageType", Enum.Parse(tagStorageTypeEnum, writeStorageTypeName, ignoreCase: true));
SetProperty(tag, "MinEU", writeMinEu);
SetProperty(tag, "MaxEU", writeMaxEu);
SetProperty(tag, "MinRaw", writeMinRaw);