Files
mxaccess/docs/ASB-Variant-Wire-Format.md
T
Joseph Doherty fe2a6db786
rust / build / test / clippy / fmt (push) Has been cancelled
Initial project state: .NET reference, design, Rust port (M0+M1), evidence
Layout:
- src/                    .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
                          MxAsbClient, probes, tests, harnesses. Executable spec.
- design/                 Architectural plan for the Rust port (M0–M6), error
                          model, protocol invariants, risks (R1–R16), adversarial
                          review log (review.md).
- rust/                   Rust workspace. M0 skeleton + M1 codec parity.
                          mxaccess-codec: 215 unit tests + 2 cross-implementation
                          parity tests (byte-identical against .NET reference).
                          Other crates are M0 stubs awaiting M2+.
- captures/               Frida + netsh + pcap evidence per CLAUDE.md
                          ("captures are evidence, not throwaway logs").
- analysis/               Decompiled C# (frida/proxy/decompiled-*),
                          Ghidra exports for native DLLs (`exports/` only —
                          working state at `projects/` and AVEVA's input
                          binaries at `input/` are gitignored).
- docs/                   Reverse-engineering reference docs.
- tools/                  Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
                          Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/      Rust CI: fmt + build + test + clippy on Windows.
- LICENSE                 MIT (Joseph Doherty, 2026).

Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly

Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 06:21:00 -04:00

223 lines
11 KiB
Markdown

# ASB Variant Wire Format
This note documents the ASB `Variant` and related value/status payloads that
`MxAsbClient` currently understands. It is based on
`src/MxAsbClient/AsbContracts.cs`, `MxAsbDataClient.DecodeVariant`,
`MxAsbDataClient.FormatVariant`, `src/MxAsbClient.Tests/Program.cs`, and the
remaining-work notes.
## ASBIData Framing
ASB custom data-contract values are serialized as an `ASBIData` XML element
containing base64-encoded binary. The binary uses the .NET `BinaryWriter` /
`BinaryReader` primitive layout used by the client on Windows:
| Field kind | Binary layout |
| --- | --- |
| `ushort` | 2-byte little-endian unsigned integer |
| `int` / `uint` | 4-byte little-endian integer |
| `long` / `ulong` | 8-byte little-endian integer |
| `float` / `double` | 4-byte / 8-byte IEEE payload from `BitConverter` |
| `bool` fields in custom structs | one `BinaryWriter.Write(bool)` byte |
| UTF-16 strings in custom structs | 4-byte byte length, then UTF-16LE bytes |
| custom arrays | 4-byte element count, then each element's custom binary body |
The `Variant` body itself is:
| Offset | Size | Field | Notes |
| --- | ---: | --- | --- |
| 0 | 2 | `Type` | `AsbDataType` numeric ID. |
| 2 | 4 | `Length` | Logical payload length. Factory-created variants set this to `Payload.Length`. |
| 6 | 4 | `PayloadLength` | Actual byte count emitted before `Payload`. |
| 10 | `PayloadLength` | `Payload` | Type-specific bytes. Empty or missing payload is treated as empty. |
`DecodeVariant` trusts the actual payload bytes for parsing. Unsupported type IDs
with non-empty payloads are returned as raw `byte[]`; unsupported empty variants
return `null`, except known empty string/array types, which return empty values.
## Declared Type IDs
These IDs are declared by `AsbDataType`. Only the layouts listed as
decode-supported below should be considered implemented behavior.
| ID | Name | Current handling |
| ---: | --- | --- |
| 0 | `TypeByte` | Declared only; raw bytes if received. |
| 1 | `TypeChar` | Declared only; raw bytes if received. |
| 2 | `TypeInt16` | Declared only; raw bytes if received. |
| 3 | `TypeUInt16` | Declared only; raw bytes if received. |
| 4 | `TypeInt32` | Decode/write supported and live-proven. |
| 5 | `TypeUInt32` | Declared only; raw bytes if received. |
| 6 | `TypeInt64` | Declared only; raw bytes if received. |
| 7 | `TypeUInt64` | Declared only; raw bytes if received. |
| 8 | `TypeFloat` | Decode/write supported and live-proven. |
| 9 | `TypeDouble` | Decode/write supported and live-proven. |
| 10 | `TypeString` | Decode/write supported and live-proven. |
| 11 | `TypeDateTime` | Decode/write supported and live-proven. |
| 12 | `TypeDuration` | Decode/write supported and live-proven. |
| 13 | `TypeGuid` | Declared only; raw bytes if received. |
| 14 | `TypeByteString` | Declared only; raw bytes if received. |
| 15 | `TypeLocaleId` | Declared only; raw bytes if received. |
| 16 | `TypeLocalizedText` | Declared only; raw bytes if received. |
| 17 | `TypeBool` | Decode/write supported and live-proven. |
| 18 | `TypeSByte` | Declared only; raw bytes if received. |
| 19 | `TypeErrorStatus` | Declared only; raw bytes if received. |
| 20 | `TypeEnum` | Declared only; raw bytes if received. |
| 21 | `TypeDataType` | Declared only; raw bytes if received. |
| 22 | `TypeSecurityClassification` | Declared only; raw bytes if received. |
| 23 | `TypeDataQuality` | Declared only; raw bytes if received. |
| 40 | `TypeByteArray` | Declared only; raw bytes if received. |
| 41 | `TypeCharArray` | Declared only; raw bytes if received. |
| 42 | `TypeInt16Array` | Declared only; raw bytes if received. |
| 43 | `TypeUInt16Array` | Declared only; raw bytes if received. |
| 44 | `TypeInt32Array` | Decode/write supported and live-proven for deployed array tags. |
| 45 | `TypeUInt32Array` | Declared only; raw bytes if received. |
| 46 | `TypeInt64Array` | Declared only; raw bytes if received. |
| 47 | `TypeUInt64Array` | Declared only; raw bytes if received. |
| 48 | `TypeFloatArray` | Decode/write supported and live-proven for deployed array tags. |
| 49 | `TypeDoubleArray` | Decode/write supported and live-proven for deployed array tags. |
| 50 | `TypeStringArray` | Decode/write supported and live-proven for deployed array tags. |
| 51 | `TypeDateTimeArray` | Decode/write supported and live-proven for deployed array tags. |
| 52 | `TypeDurationArray` | Decode/write supported by tests; live coverage depends on deployed tags. |
| 53 | `TypeGuidArray` | Declared only; raw bytes if received. |
| 54 | `TypeByteStringArray` | Declared only; raw bytes if received. |
| 55 | `TypeLocaleIdArray` | Declared only; raw bytes if received. |
| 56 | `TypeLocalizedTextArray` | Declared only; raw bytes if received. |
| 57 | `TypeBoolArray` | Decode/write supported and live-proven for deployed array tags. |
| 58 | `TypeSByteArray` | Declared only; raw bytes if received. |
| 60 | `TypeEnumArray` | Declared only; raw bytes if received. |
| 61 | `TypeDataTypeArray` | Declared only; raw bytes if received. |
| 62 | `TypeSecurityClassificationArray` | Declared only; raw bytes if received. |
| 63 | `TypeDataQualityArray` | Declared only; raw bytes if received. |
| 65535 | `TypeUnknown` | Used for empty/user-data placeholders. |
## Supported Scalar Payloads
| Type | ID | Payload layout | Empty-payload decode |
| --- | ---: | --- | --- |
| `TypeBool` | 17 | one byte; non-zero is `true`, zero is `false` | `null` |
| `TypeInt32` | 4 | 4-byte little-endian signed integer | `null` |
| `TypeFloat` | 8 | 4-byte IEEE single from `BitConverter` | `null` |
| `TypeDouble` | 9 | 8-byte IEEE double from `BitConverter` | `null` |
| `TypeString` | 10 | UTF-16LE bytes, no per-string length inside the variant payload | empty string |
| `TypeDateTime` | 11 | 8-byte signed Windows FILETIME from `DateTime.ToFileTimeUtc()` | `null` |
| `TypeDuration` | 12 | 8-byte signed .NET tick count from `TimeSpan.Ticks` | `null` |
`FormatVariant` renders scalar values as invariant strings. `DateTime` values
format with the round-trip `O` pattern after `DateTime.FromFileTimeUtc()`;
`Duration` values format with the constant `c` `TimeSpan` pattern.
## Supported Array Payloads
Variant arrays do not carry an element count in a common header. The element
count is inferred from payload byte length for fixed-width arrays, or by walking
length-prefixed records for string arrays.
| Type | ID | Payload layout | Empty-payload decode |
| --- | ---: | --- | --- |
| `TypeInt32Array` | 44 | packed 4-byte little-endian signed integers | empty `int[]` |
| `TypeBoolArray` | 57 | one byte per element; non-zero is `true` | empty `bool[]` |
| `TypeFloatArray` | 48 | packed 4-byte IEEE singles | empty `float[]` |
| `TypeDoubleArray` | 49 | packed 8-byte IEEE doubles | empty `double[]` |
| `TypeStringArray` | 50 | repeated 4-byte byte length, then UTF-16LE bytes; zero length is empty string | empty `string[]` |
| `TypeDateTimeArray` | 51 | packed 8-byte Windows FILETIME values | empty `DateTime[]` |
| `TypeDurationArray` | 52 | packed 8-byte .NET tick counts | empty `TimeSpan[]` |
For malformed string-array payloads, decoding stops when the next declared
string byte length is negative or extends beyond the remaining payload. The
partial values decoded before that point are preserved.
## Timestamp and Duration Handling
There are two different timestamp encodings:
| Location | Encoding |
| --- | --- |
| `Variant` `TypeDateTime` / `TypeDateTimeArray` payloads | Windows FILETIME UTC values (`DateTime.ToFileTimeUtc()` / `DateTime.FromFileTimeUtc()`). |
| `RuntimeValue.Timestamp` in read/publish wrappers | 8-byte `DateTime.ToBinary()` value followed by a one-byte `TimestampSpecified` flag. Publish mapping normalizes the timestamp to UTC for `AsbPublishedValue.TimestampUtc`. |
`TypeDuration` and `TypeDurationArray` payloads are 8-byte `TimeSpan.Ticks`
values. No alternate duration units are inferred.
## String Encoding
Scalar `TypeString` payloads are raw UTF-16LE bytes without an inner length.
The variant `Length` and `PayloadLength` fields provide the byte count.
`TypeStringArray` payloads are a sequence of string records. Each record starts
with a 4-byte little-endian byte length followed by that many UTF-16LE bytes.
`null` and empty strings are both emitted as a zero-length string by
`AsbVariantFactory.FromStringArray`, and decode as `string.Empty`.
The same 4-byte byte-length plus UTF-16LE convention is also used by custom
struct string fields such as `ItemIdentity.Name`, but that is outside the
variant payload.
## Runtime Value, Quality, and Status
Quality is not part of a `Variant` payload. Reads and publishes wrap the variant
inside `RuntimeValue`, whose binary layout is:
| Order | Field | Binary layout |
| ---: | --- | --- |
| 1 | `Timestamp` | 8-byte `DateTime.ToBinary()` value |
| 2 | `TimestampSpecified` | one boolean byte |
| 3 | `Value` | nested `Variant` body |
| 4 | `Status` | nested `AsbStatus` body |
`AsbStatus` is:
| Order | Field | Binary layout |
| ---: | --- | --- |
| 1 | `Count` | signed byte |
| 2 | payload byte length | 4-byte integer |
| 3 | `Payload` | status element bytes |
`AsbPublishMapper.DecodeStatus` treats `Count` as the number of payload bytes
to inspect when it is positive, capped at the actual payload length. If `Count`
is zero or negative, it inspects the full payload.
Status element bytes are parsed as repeated records:
| Record part | Meaning |
| --- | --- |
| marker byte bit 0-6 | `AsbStatusElementType` ID |
| marker byte bit 7 set | element value is implicitly zero and no value bytes follow |
| marker byte bit 7 clear | next two bytes are a little-endian `ushort` value |
Known status element type IDs are:
| ID | Name |
| ---: | --- |
| 1 | `OpcDaStatus` |
| 2 | `OpcUaStatus` |
| 3 | `OpcUaVendorStatus` |
| 4 | `ScadaStatus` |
| 5 | `MxStatusCategory` |
| 6 | `MxStatusDetail` |
| 7 | `MxQuality` |
| 125 | `Reserved1Status` |
| 126 | `Reserved2Status` |
| 127 | `Reserved3Status` |
`MxQuality` is summarized by masking with `0x00C0`: `0x00C0` is good,
`0x0040` is uncertain, and `0x0000` is bad. Unknown quality values and unknown
status element types are preserved rather than guessed.
## Proven Versus Intentionally Open
Live/read/write coverage is complete for Boolean, Int32, Float, Double, String,
DateTime, Duration, and the deployed array tags called out in the remaining-work
notes. The non-live test suite also round-trips the supported factories through
`DecodeVariant`, including the duration array path.
The client intentionally does not infer layouts for declared-but-unsupported ASB
types such as GUID, byte strings, localized text, enum/data-type/security/data
quality variants, and their array forms. Those types stay as raw bytes until a
real deployed tag, capture, or contract evidence proves the payload shape.
Buffered publish batches are also intentionally not described here. The current
ASB buffered flag is exposed, but observed publish responses still carry a
single current `RuntimeValue` per item rather than a proven multi-sample batch
payload.