Extend HistorianTagMetadata with Description, EngineeringUnit, MinEU/MaxEU
Decoded the GetTagInfoFromName response shape across multiple tag types via captured raw bytes (sanitized decoder script in scripts/decode-taginfo-bytes.py): - Compact-ASCII string slot count varies by tag origin: 2 strings for MDAS-routed external tags (TagName + MetadataProvider), 4 strings for local Sys tags (TagName + Description + ItemName + CreatedBy). Parser now walks strings dynamically until the next byte isn't the 0x09 marker. - Trailing region after the 4-byte fixed block holds (for analog tags) two doubles for MinEU/MaxEU plus an optional EngineeringUnit compact ASCII string and other fields whose exact positions vary. Parser uses a tolerant scan: tries each 8-byte alignment 0..7, picks the first sane (Min ≤ Max, finite, not all-zeros, |x| ≤ 1e15) double pair as MinEU/MaxEU, and finds the first plausible compact ASCII string (1..32 ASCII bytes, not numeric) as EngineeringUnit. HistorianTagMetadata.Description / EngineeringUnit / MinRaw / MaxRaw nullable slots already existed; they're now populated. Live verification: SysTimeSec returns Description="System Time : Seconds", MaxRaw=59.0, EngineeringUnit ="Seconds". Tests: 109 → 114 (+4 synthetic-fixture parser tests + 1 live integration test for the populated analog metadata path). Bulk descriptor probe helper (GetTagInfoRawBytesForProbe) added for future layout work; raw bytes never committed because they contain CreatedBy DOMAIN\user identity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""Decode a hex-encoded GetTagInfoFromName response into a sanitized field map.
|
||||
|
||||
USE: pipe one tag's hex on stdin (no spaces), one per line. Identity-bearing string
|
||||
values are reported only as length + non-ASCII flags so the output stays commit-safe.
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
import binascii
|
||||
|
||||
|
||||
def is_printable_ascii(b: bytes) -> bool:
|
||||
return all(0x20 <= c < 0x7F for c in b)
|
||||
|
||||
|
||||
def decode_one(hex_str: str) -> None:
|
||||
raw = binascii.unhexlify(hex_str)
|
||||
n = len(raw)
|
||||
cur = 0
|
||||
|
||||
# Fixed prefix
|
||||
desc = raw[cur:cur+4]; cur += 4
|
||||
type_id = raw[cur:cur+16]; cur += 16
|
||||
tag_key = struct.unpack_from('<I', raw, cur)[0]; cur += 4
|
||||
|
||||
print(f" total_bytes = {n}")
|
||||
print(f" [00..03] descriptor = {desc.hex().upper()}")
|
||||
print(f" [04..19] typeId(GUID) = (16 bytes)")
|
||||
print(f" [20..23] tagKey(uint32)= {tag_key}")
|
||||
|
||||
# Walk compact ASCII strings until we hit a byte != 0x09
|
||||
string_idx = 1
|
||||
while cur < n and raw[cur] == 0x09:
|
||||
marker_off = cur
|
||||
cur += 1
|
||||
length = struct.unpack_from('<H', raw, cur)[0]; cur += 2
|
||||
value = raw[cur:cur + length]
|
||||
cur += length
|
||||
is_ascii = is_printable_ascii(value)
|
||||
print(f" [{marker_off:02X}..{cur-1:02X}] string{string_idx} = "
|
||||
f"compactAscii(len={length}, ascii={is_ascii})")
|
||||
string_idx += 1
|
||||
|
||||
# 4-byte fixed block (nativeTagClass, storageType, deadbandType, interpolationType)
|
||||
if cur + 4 <= n:
|
||||
block = raw[cur:cur+4]
|
||||
print(f" [{cur:02X}..{cur+3:02X}] fixedBlock4 = "
|
||||
f"class={block[0]} storage={block[1]} deadband={block[2]} interp={block[3]}")
|
||||
cur += 4
|
||||
|
||||
# Try to identify trailing fields by structure
|
||||
print(f" trailing bytes from offset 0x{cur:02X} ({n - cur} bytes):")
|
||||
trailing = raw[cur:]
|
||||
# Look for FILETIME pattern (year-2025 = 0x01DCxxxxxxxxxxxx, byte 7 = 0x01)
|
||||
for off in range(min(len(trailing) - 7, 16)):
|
||||
if trailing[off + 7] == 0x01 and trailing[off + 6] in (0xDB, 0xDC, 0xDD):
|
||||
ft_bytes = trailing[off:off+8]
|
||||
ft_value = struct.unpack_from('<q', ft_bytes)[0]
|
||||
# FILETIME → year approximation
|
||||
seconds = ft_value / 10_000_000
|
||||
years_since_1601 = seconds / 31_557_600
|
||||
year = 1601 + int(years_since_1601)
|
||||
print(f" candidate FILETIME at trailing+0x{off:X}: year~{year} "
|
||||
f"(absolute offset 0x{cur + off:02X})")
|
||||
|
||||
# Look for double 10.0 (0x4024000000000000) and other doubles
|
||||
for off in range(0, len(trailing) - 7, 1):
|
||||
try:
|
||||
d = struct.unpack_from('<d', trailing, off)[0]
|
||||
if d != 0 and abs(d) < 1e10 and abs(d) > 1e-5:
|
||||
print(f" double @trailing+0x{off:X} (abs 0x{cur+off:02X}) = {d}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Look for plausible uint32s (small, non-zero)
|
||||
for off in range(0, len(trailing) - 3, 1):
|
||||
v = struct.unpack_from('<I', trailing, off)[0]
|
||||
if 0 < v < 1_000_000:
|
||||
print(f" uint32 @trailing+0x{off:X} (abs 0x{cur+off:02X}) = {v}")
|
||||
|
||||
# Look for trailing marker (FE 00 or 00)
|
||||
if len(trailing) >= 2:
|
||||
last2 = trailing[-2:]
|
||||
last1 = trailing[-1:]
|
||||
print(f" last 2 bytes: {last2.hex().upper()}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Allow optional "TagName: HEX" prefix
|
||||
if ':' in line:
|
||||
label, hex_str = line.split(':', 1)
|
||||
print(f"=== {label.strip()} ===")
|
||||
decode_one(hex_str.strip())
|
||||
else:
|
||||
decode_one(line)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -136,8 +136,24 @@ internal static class HistorianTagQueryProtocol
|
||||
cursor += 16;
|
||||
uint tagKey = ReadUInt32(response, ref cursor);
|
||||
|
||||
string tagName = ReadCompactAsciiString(response, ref cursor);
|
||||
string metadataProvider = ReadCompactAsciiString(response, ref cursor);
|
||||
// The compact-ASCII string slot count varies by tag origin (decoded from
|
||||
// GetTagInfoFromName captures across multiple tag types):
|
||||
// 1 string : TagName only (degenerate / unknown shape)
|
||||
// 2 strings : TagName + MetadataProvider (e.g., MDAS-routed external tags)
|
||||
// 4 strings : TagName + Description + ItemName + CreatedBy (local Sys tags)
|
||||
// Walk strings dynamically until the next byte isn't the 0x09 marker.
|
||||
List<string> strings = new(4);
|
||||
while (cursor < response.Length && response[cursor] == 0x09)
|
||||
{
|
||||
strings.Add(ReadCompactAsciiString(response, ref cursor));
|
||||
}
|
||||
|
||||
string tagName = strings.Count > 0 ? strings[0] : string.Empty;
|
||||
// String at position 1 is Description for full-shape tags or MetadataProvider
|
||||
// for MDAS-routed tags. Both are useful; expose under MetadataProvider for back-compat
|
||||
// and Description for new semantics.
|
||||
string metadataProvider = strings.Count > 1 ? strings[1] : string.Empty;
|
||||
string? description = strings.Count >= 4 ? strings[1] : null;
|
||||
|
||||
EnsureAvailable(response, cursor, 4);
|
||||
byte nativeTagClass = response[cursor++];
|
||||
@@ -145,6 +161,21 @@ internal static class HistorianTagQueryProtocol
|
||||
byte deadbandType = response[cursor++];
|
||||
byte interpolationType = response[cursor++];
|
||||
|
||||
// Trailing region after the fixed 4-byte block holds:
|
||||
// - some alignment / int32 fields (StorageRate, AcquisitionRate, TimeDeadband)
|
||||
// - Int64 FILETIME (DateCreated)
|
||||
// - For analog tags: pair of doubles (MinEU/MaxEU and/or MinRaw/MaxRaw)
|
||||
// - Optional compact-ASCII EngineeringUnit string
|
||||
// - Optional double RolloverValue
|
||||
// - Trailer marker (often FE 00 or 00)
|
||||
// The exact layout varies by tag type and storage mode; rather than commit fragile
|
||||
// positional parsing, scan the trailing region for the first two consecutive
|
||||
// 8-byte-aligned doubles and treat them as a (MinEU, MaxEU) pair. Both must be
|
||||
// finite and the EU range must be sane (Min ≤ Max).
|
||||
ReadOnlySpan<byte> trailing = response[cursor..];
|
||||
(double? min, double? max, string? engineeringUnit) = TryReadAnalogTrailing(trailing);
|
||||
cursor = response.Length;
|
||||
|
||||
return new HistorianTagInfoResponse(
|
||||
tagName,
|
||||
tagKey,
|
||||
@@ -154,7 +185,71 @@ internal static class HistorianTagQueryProtocol
|
||||
nativeTagClass,
|
||||
storageType,
|
||||
deadbandType,
|
||||
interpolationType);
|
||||
interpolationType,
|
||||
description,
|
||||
min,
|
||||
max,
|
||||
engineeringUnit);
|
||||
}
|
||||
|
||||
private static (double? min, double? max, string? engineeringUnit) TryReadAnalogTrailing(ReadOnlySpan<byte> trailing)
|
||||
{
|
||||
double? foundMin = null;
|
||||
double? foundMax = null;
|
||||
string? foundEu = null;
|
||||
|
||||
// Look for an EngineeringUnit compact-ASCII string anywhere in the trailing region.
|
||||
for (int i = 0; i < trailing.Length - 3; i++)
|
||||
{
|
||||
if (trailing[i] != 0x09) continue;
|
||||
ushort len = BitConverter.ToUInt16(trailing.Slice(i + 1, 2));
|
||||
// Accept 1-32 byte ASCII strings as plausible EUs. Range chosen to filter false
|
||||
// positives (most engineering units are short — "kPa", "Seconds", "RPM", etc.).
|
||||
if (len < 1 || len > 32) continue;
|
||||
int payloadStart = i + 3;
|
||||
if (payloadStart + len > trailing.Length) continue;
|
||||
// All bytes must be printable ASCII.
|
||||
ReadOnlySpan<byte> payload = trailing.Slice(payloadStart, len);
|
||||
bool allAscii = true;
|
||||
foreach (byte b in payload)
|
||||
{
|
||||
if (b < 0x20 || b > 0x7E) { allAscii = false; break; }
|
||||
}
|
||||
if (!allAscii) continue;
|
||||
string candidate = Encoding.ASCII.GetString(payload);
|
||||
// Skip implausible values (numerics, mostly-special-chars).
|
||||
if (double.TryParse(candidate, out _)) continue;
|
||||
foundEu = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
// Look for two consecutive 8-byte-aligned doubles forming a sane EU range.
|
||||
// Try each plausible alignment relative to the trailing-region start.
|
||||
for (int alignOffset = 0; alignOffset < 8; alignOffset++)
|
||||
{
|
||||
for (int i = alignOffset; i + 16 <= trailing.Length; i += 8)
|
||||
{
|
||||
if (!TryReadDouble(trailing, i, out double a)) continue;
|
||||
if (!TryReadDouble(trailing, i + 8, out double b)) continue;
|
||||
// Both finite, both within sane EU range, a ≤ b.
|
||||
if (!double.IsFinite(a) || !double.IsFinite(b)) continue;
|
||||
if (Math.Abs(a) > 1e15 || Math.Abs(b) > 1e15) continue;
|
||||
if (a > b) continue;
|
||||
// Reject the all-zeros pair (uninformative).
|
||||
if (a == 0 && b == 0) continue;
|
||||
foundMin = a;
|
||||
foundMax = b;
|
||||
return (foundMin, foundMax, foundEu);
|
||||
}
|
||||
}
|
||||
return (foundMin, foundMax, foundEu);
|
||||
}
|
||||
|
||||
private static bool TryReadDouble(ReadOnlySpan<byte> buffer, int offset, out double value)
|
||||
{
|
||||
if (offset + 8 > buffer.Length) { value = 0; return false; }
|
||||
value = BitConverter.ToDouble(buffer.Slice(offset, 8));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ushort ReadUInt16(ReadOnlySpan<byte> response, ref int cursor)
|
||||
@@ -195,4 +290,8 @@ internal sealed record HistorianTagInfoResponse(
|
||||
byte NativeTagClass,
|
||||
byte StorageType,
|
||||
byte DeadbandType,
|
||||
byte InterpolationType);
|
||||
byte InterpolationType,
|
||||
string? Description = null,
|
||||
double? MinEU = null,
|
||||
double? MaxEU = null,
|
||||
string? EngineeringUnit = null);
|
||||
|
||||
@@ -91,9 +91,13 @@ internal static class HistorianWcfTagClient
|
||||
|
||||
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(tagMetadata);
|
||||
return new HistorianTagMetadata(
|
||||
parsed.TagName,
|
||||
parsed.TagKey,
|
||||
MapDataType(parsed.NativeDataTypeDescriptor));
|
||||
Name: parsed.TagName,
|
||||
Key: parsed.TagKey,
|
||||
DataType: MapDataType(parsed.NativeDataTypeDescriptor),
|
||||
Description: parsed.Description,
|
||||
EngineeringUnit: parsed.EngineeringUnit,
|
||||
MinRaw: parsed.MinEU,
|
||||
MaxRaw: parsed.MaxEU);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -108,6 +112,25 @@ internal static class HistorianWcfTagClient
|
||||
return GetTagInfoForDescriptorProbe(session, tag);
|
||||
}
|
||||
|
||||
/// <summary>Bulk variant: probes many tags and returns the raw response bytes alongside the parsed record (for byte-layout reverse engineering).</summary>
|
||||
internal static IReadOnlyDictionary<string, byte[]?> GetTagInfoRawBytesForProbe(
|
||||
HistorianClientOptions options,
|
||||
IEnumerable<string> tags)
|
||||
{
|
||||
Dictionary<string, byte[]?> results = new(StringComparer.Ordinal);
|
||||
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
|
||||
foreach (string tag in tags)
|
||||
{
|
||||
try
|
||||
{
|
||||
uint rc = session.RetrievalChannel.GetTagInfoFromName(session.Handle, tag, out _, out byte[] bytes);
|
||||
results[tag] = (rc == 0 && bytes.Length > 0) ? bytes : null;
|
||||
}
|
||||
catch { results[tag] = null; }
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Bulk variant: probes many tags through a single session.</summary>
|
||||
internal static IReadOnlyDictionary<string, HistorianTagInfoResponse?> GetTagInfosForDescriptorProbe(
|
||||
HistorianClientOptions options,
|
||||
|
||||
@@ -300,4 +300,34 @@ public sealed class HistorianClientIntegrationTests
|
||||
Assert.Equal(testTag, metadata.Name);
|
||||
Assert.NotNull(metadata.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTagMetadataAsync_PopulatesDescriptionAndEuRangeForAnalogTag()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// SysTimeSec is a built-in analog UInt16 tag with non-empty Description, MaxEU,
|
||||
// and an EngineeringUnit. Verifies the parser populates those new fields end-to-end.
|
||||
const string analogTag = "SysTimeSec";
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
|
||||
await client.GetTagMetadataAsync(analogTag, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(analogTag, metadata.Name);
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.Description));
|
||||
Assert.NotNull(metadata.MaxRaw);
|
||||
Assert.True(metadata.MaxRaw is > 0 and <= 1e15);
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.EngineeringUnit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,26 @@ public sealed class TagMetadataDescriptorProbeTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DumpRawTagInfoBytesForLayoutDecoding()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string[] sampleTags = (Environment.GetEnvironmentVariable("HISTORIAN_RAW_TAGINFO_TAGS") ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (string.IsNullOrWhiteSpace(host) || sampleTags.Length == 0 || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClientOptions options = new() { Host = host, IntegratedSecurity = true };
|
||||
var results = HistorianWcfTagClient.GetTagInfoRawBytesForProbe(options, sampleTags);
|
||||
foreach (var (tag, bytes) in results)
|
||||
{
|
||||
if (bytes is null) { _output.WriteLine($" {tag}: <null>"); continue; }
|
||||
_output.WriteLine($" {tag} ({bytes.Length} bytes): {Convert.ToHexString(bytes)}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumerateAllTagDescriptorsAcrossOneSession()
|
||||
{
|
||||
|
||||
@@ -146,6 +146,114 @@ public sealed class WcfTagQueryProtocolTests
|
||||
HistorianWcfTagClient.MapDataType([0x04, 0xCF, 0x00, 0x09]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_FullShape4Strings_PopulatesDescription()
|
||||
{
|
||||
byte[] response = BuildSyntheticTagInfo(
|
||||
descriptor: [0x03, 0xCF, 0x00, 0x09],
|
||||
tagKey: 42,
|
||||
strings: ["TAG", "Tag description here", "TAG", "DOMAIN\\user"],
|
||||
fixedBlock: [0x03, 0x02, 0x01, 0x00],
|
||||
trailingDoubles: null,
|
||||
trailingEu: null);
|
||||
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
|
||||
Assert.Equal("TAG", parsed.TagName);
|
||||
Assert.Equal(42u, parsed.TagKey);
|
||||
Assert.Equal("Tag description here", parsed.Description);
|
||||
// 4-string shape uses position 1 as Description AND MetadataProvider for back-compat.
|
||||
Assert.Equal("Tag description here", parsed.MetadataProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TwoStringShape_DoesNotMisinterpretMetadataProviderAsDescription()
|
||||
{
|
||||
byte[] response = BuildSyntheticTagInfo(
|
||||
descriptor: [0x03, 0xC3, 0x00, 0x31],
|
||||
tagKey: 99,
|
||||
strings: ["EXT.TAG.NAME", "MDAS"],
|
||||
fixedBlock: [0x02, 0x03, 0x01, 0x02],
|
||||
trailingDoubles: null,
|
||||
trailingEu: null);
|
||||
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
|
||||
Assert.Equal("EXT.TAG.NAME", parsed.TagName);
|
||||
Assert.Equal("MDAS", parsed.MetadataProvider);
|
||||
// 2-string shape: don't conflate MetadataProvider with Description.
|
||||
Assert.Null(parsed.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TrailingDoublesAndEu_PopulatesMinMaxAndUnit()
|
||||
{
|
||||
byte[] response = BuildSyntheticTagInfo(
|
||||
descriptor: [0x03, 0xCF, 0x00, 0x09],
|
||||
tagKey: 12,
|
||||
strings: ["TAG", "desc", "TAG", "DOMAIN\\u"],
|
||||
fixedBlock: [0x03, 0x02, 0x01, 0x00],
|
||||
trailingDoubles: (0.0, 59.0),
|
||||
trailingEu: "Seconds");
|
||||
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
|
||||
Assert.Equal(0.0, parsed.MinEU);
|
||||
Assert.Equal(59.0, parsed.MaxEU);
|
||||
Assert.Equal("Seconds", parsed.EngineeringUnit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TrailingNoDoubles_LeavesMinMaxNull()
|
||||
{
|
||||
byte[] response = BuildSyntheticTagInfo(
|
||||
descriptor: [0x03, 0xCF, 0x00, 0x02],
|
||||
tagKey: 97,
|
||||
strings: ["DiscreteTag", "Description", "DiscreteTag", "DOMAIN\\u"],
|
||||
fixedBlock: [0x03, 0x02, 0x01, 0x00],
|
||||
trailingDoubles: null,
|
||||
trailingEu: null);
|
||||
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(response);
|
||||
Assert.Null(parsed.MinEU);
|
||||
Assert.Null(parsed.MaxEU);
|
||||
Assert.Null(parsed.EngineeringUnit);
|
||||
}
|
||||
|
||||
private static byte[] BuildSyntheticTagInfo(
|
||||
byte[] descriptor,
|
||||
uint tagKey,
|
||||
string[] strings,
|
||||
byte[] fixedBlock,
|
||||
(double Min, double Max)? trailingDoubles,
|
||||
string? trailingEu)
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using BinaryWriter w = new(ms);
|
||||
w.Write(descriptor); // 4 bytes
|
||||
w.Write(System.Guid.NewGuid().ToByteArray()); // 16 bytes
|
||||
w.Write(tagKey); // 4 bytes
|
||||
foreach (string s in strings)
|
||||
{
|
||||
byte[] ascii = System.Text.Encoding.ASCII.GetBytes(s);
|
||||
w.Write((byte)0x09);
|
||||
w.Write((ushort)ascii.Length);
|
||||
w.Write(ascii);
|
||||
}
|
||||
w.Write(fixedBlock); // 4 bytes
|
||||
// Trailing region: padding + (optional doubles aligned to 8) + (optional EU compact ASCII).
|
||||
// To keep doubles 8-byte aligned within the trailing region, pad to next 8-byte boundary.
|
||||
long trailingStart = ms.Length;
|
||||
// Plain alignment: add zero padding so doubles start at a stable 8-byte aligned offset
|
||||
// within the trailing region — the parser scans alignments 0..7 so any padding works.
|
||||
if (trailingDoubles is { } d)
|
||||
{
|
||||
w.Write(d.Min);
|
||||
w.Write(d.Max);
|
||||
}
|
||||
if (trailingEu is not null)
|
||||
{
|
||||
byte[] euAscii = System.Text.Encoding.ASCII.GetBytes(trailingEu);
|
||||
w.Write((byte)0x09);
|
||||
w.Write((ushort)euAscii.Length);
|
||||
w.Write(euAscii);
|
||||
}
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesManagedWcfLikeTagNamesResponse()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user