Cross-platform NegotiateAuthentication; StorageType field; docs polish
HistorianSspiClient rewritten on top of System.Net.Security.NegotiateAuthentication
in place of P/Invoke into secur32.dll's InitializeSecurityContextW. The class
keeps the same Next() / Dispose() / two-constructor surface so callers don't
change. RequiredProtectionLevel=EncryptAndSign + AllowedImpersonationLevel=
Identification produces a request-flag set equivalent to the captured native
0x2081C / 0x81C bitmasks (still preserved as constants for the existing unit
tests). Removes the only Windows P/Invoke in the production SDK; the
[SupportedOSPlatform("windows")] gating elsewhere stays in place pending a
separate sweep.
HistorianStorageType (Cyclic = 1, Delta = 2):
Captured 2026-05-04 via --write-storage-type on the 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. Server persists Tag.StorageType=1/2 accordingly. Plumbed
through HistorianTagDefinition.StorageType + serializer + orchestrator + 2
new tests (golden bytes + live SQL persistence verification).
Docs polish:
CLAUDE.md no longer claims "no P/Invoke" (HistorianSspiClient is the one
allowed P/Invoke surface); updated test count to 169+; AGENTS.md Required
SDK Surface and Repository Layout brought up to date with the live state
including the write surface; handoff.md "not a git working tree" obsolete
note removed.
171/171 tests pass with the NegotiateAuthentication replacement (was 169;
+2 new tests for StorageType).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace AVEVA.Historian.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Storage strategy for historized samples. Maps to <c>Tag.StorageType</c> in the
|
||||
/// Runtime DB. Values match the captured native enum and the server-persisted
|
||||
/// integer column.
|
||||
/// </summary>
|
||||
public enum HistorianStorageType
|
||||
{
|
||||
/// <summary>
|
||||
/// Sample on a fixed cadence (see <c>HistorianTagDefinition.StorageRateMs</c>).
|
||||
/// </summary>
|
||||
Cyclic = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Sample only on value change (with optional value/time/rate deadbands).
|
||||
/// </summary>
|
||||
Delta = 2,
|
||||
}
|
||||
@@ -61,4 +61,12 @@ public sealed record HistorianTagDefinition
|
||||
/// return false.
|
||||
/// </summary>
|
||||
public uint StorageRateMs { get; init; } = 1000u;
|
||||
|
||||
/// <summary>
|
||||
/// Storage strategy. Default <see cref="HistorianStorageType.Cyclic"/> samples
|
||||
/// on the configured <see cref="StorageRateMs"/> cadence. <see cref="HistorianStorageType.Delta"/>
|
||||
/// samples only on value change. The server persists this to <c>Tag.StorageType</c>
|
||||
/// (Cyclic = 1, Delta = 2).
|
||||
/// </summary>
|
||||
public HistorianStorageType StorageType { get; init; } = HistorianStorageType.Cyclic;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <remarks>
|
||||
/// 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 <see cref="NegotiateAuthentication"/> under the hood
|
||||
/// (Windows: SSPI; Linux/macOS: GSSAPI via <c>libgssapi_krb5</c> / <c>gss-ntlmssp</c>).
|
||||
///
|
||||
/// The native AVEVA wrapper passes specific request flags to
|
||||
/// <c>InitializeSecurityContextW</c>: <c>IDENTIFY | CONNECTION | CONFIDENTIALITY |
|
||||
/// SEQUENCE_DETECT | REPLAY_DETECT</c> 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. <c>RequiredProtectionLevel.EncryptAndSign</c> in
|
||||
/// NegotiateAuthentication implicitly requests SEQUENCE + REPLAY +
|
||||
/// CONFIDENTIALITY, and <c>AllowedImpersonationLevel = Identification</c> 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 <c>HistorianSspiClientTests</c> — they document the
|
||||
/// captured native flag values rather than driving the underlying API today.
|
||||
/// </remarks>
|
||||
[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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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 <c>pAuthData</c> to <c>AcquireCredentialsHandleW</c>. Untested against
|
||||
/// a live remote Historian; reserved for the explicit-creds path that the orchestrator
|
||||
/// will gate when <see cref="HistorianClientOptions.IntegratedSecurity"/> 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
|
||||
/// <see cref="NetworkCredential"/>.
|
||||
/// </remarks>
|
||||
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<SecWinntAuthIdentity>());
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call will use.</summary>
|
||||
/// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call corresponds to.</summary>
|
||||
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<SecBuffer>(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<SecBuffer>());
|
||||
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<SecBuffer>());
|
||||
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<SecBuffer>(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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,16 +39,24 @@ internal static class HistorianTagWriteProtocol
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 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];
|
||||
/// <summary>Compact "use defaults" scaling marker — emitted when MinEU/MaxEU/MinRaw/MaxRaw are 0/100/0/100.</summary>
|
||||
private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
|
||||
/// <summary>Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.</summary>
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user