# Worker Conversion Layer The conversion layer in `ZB.MOM.WW.MxGateway.Worker.Conversion` projects COM `VARIANT` payloads, `HRESULT` codes, and `MXSTATUS_PROXY` records into the protobuf wire types in `ZB.MOM.WW.MxGateway.Contracts.Proto`. The design is parity-first: every projection preserves enough raw metadata that the original COM observation can be reconstructed downstream. ## Overview `gateway.md` (sections "Value Model" and "Status Model") requires that the wire format use a value union capable of representing COM `VARIANT` values and arrays, that lossy conversions retain both the typed projection and raw diagnostic metadata, and that `MXSTATUS_PROXY` arrays never collapse to a single success flag. The types in `src/ZB.MOM.WW.MxGateway.Worker/Conversion/` are the worker-side enforcement of those rules. The layer is split into three concerns: - Value projection: `VariantConverter` - HRESULT projection: `HResultConverter`, `HResultConversion` - Status projection: `MxStatusProxyConverter`, `MxStatusDetailText`, `MxStatusConversionException` ## VariantConverter `VariantConverter` projects scalar and array `VARIANT` payloads delivered by COM interop into `MxValue` and `MxArray`. It accepts an optional `expectedDataType` so that an MXAccess attribute hint (for example `MxDataType.Time` for a 64-bit FILETIME) overrides the default CLR-driven projection. ### Scalar projection Scalars dispatch on `Type.GetTypeCode` and populate the matching field in the `MxValue` `oneof`. Each branch records both the typed value and the source `VARIANT` tag in `VariantType`, so consumers can distinguish a `short` from an `int` even after both project to `MxDataType.Integer`. ```csharp case TypeCode.Boolean: return new MxValue { DataType = MxDataType.Boolean, VariantType = variantType, BoolValue = (bool)value, }; case TypeCode.Byte: case TypeCode.SByte: case TypeCode.Int16: case TypeCode.UInt16: case TypeCode.Int32: return new MxValue { DataType = MxDataType.Integer, VariantType = variantType, Int32Value = System.Convert.ToInt32(value, CultureInfo.InvariantCulture), }; ``` `Decimal` and 64-bit unsigned integers that exceed `long.MaxValue` cannot project losslessly. The converter still emits a typed best-effort value but attaches a `RawDiagnostic` so the loss is recorded on the wire rather than silently absorbed. ```csharp case TypeCode.Decimal: return new MxValue { DataType = MxDataType.Double, VariantType = variantType, DoubleValue = System.Convert.ToDouble(value, CultureInfo.InvariantCulture), RawDiagnostic = "Decimal value projected to double.", }; ``` Time-typed integers use the `expectedDataType` hint. `ConvertInt64Scalar` interprets a `long` as a Windows FILETIME when the caller asks for `MxDataType.Time`, otherwise it stays an integer. `ConvertUInt64Scalar` falls through to a raw projection if the value exceeds `long.MaxValue`. ### Null and missing values `VARIANT` distinguishes `VT_EMPTY` from `VT_NULL`. The converter preserves both by inspecting whether the input is `DBNull` or a CLR null reference, and tags the result with `IsNull = true` so consumers do not misread a default value as data. ```csharp private static MxValue CreateNullValue( object? value, MxDataType expectedDataType) { return new MxValue { DataType = expectedDataType == MxDataType.Unspecified ? MxDataType.NoData : expectedDataType, VariantType = value is DBNull ? "VT_NULL" : "VT_EMPTY", IsNull = true, }; } ``` ### Array projection `ConvertArray` records the rank and per-dimension lengths so multi-dimensional `SAFEARRAY` shapes survive the round trip. The element type is resolved from the caller-supplied hint or the CLR element type via `ResolveArrayElementDataType`, then dispatched to the matching typed builder (`ConvertBoolArray`, `ConvertInt64Array`, `ConvertTimestampArray`, and so on). ```csharp for (int dimension = 0; dimension < array.Rank; dimension++) { mxArray.Dimensions.Add((uint)array.GetLength(dimension)); } System.Type? elementType = array.GetType().GetElementType(); MxDataType elementDataType = ResolveArrayElementDataType(elementType, expectedElementDataType); mxArray.ElementDataType = elementDataType; ``` When the element type cannot be classified, `ConvertArray` does not throw. It downgrades the result to `MxDataType.Unknown`, records the original expected type in `RawElementDataType`, and serializes each element via `ConvertRawArray` as a UTF-8 byte string. This satisfies the `gateway.md` requirement to keep both the best typed projection and the raw diagnostic metadata. ```csharp default: mxArray.ElementDataType = MxDataType.Unknown; mxArray.RawElementDataType = (int)expectedElementDataType; mxArray.RawDiagnostic = CreateRawDiagnostic(array); mxArray.RawValues = ConvertRawArray(array); return mxArray; ``` ### Variant type names `GetVariantTypeName` maps CLR types to canonical `VT_*` strings (`VT_BOOL`, `VT_I4`, `VT_BSTR`, `VT_DATE`, and so on). Unmapped CLR types fall back to `CLR:` so the wire format never silently invents a `VT_*` tag it cannot justify. Array element tags are wrapped as `SAFEARRAY()` by `CreateArrayVariantType`. ### Why lossless preservation matters The MXAccess engine returns values whose semantic type only fully resolves after consulting the engine's own attribute metadata. Clients that round-trip these values through the gateway (replay, parity fixtures, diagnostics) need the original `VT_*` tag, the engine-declared `MxDataType`, and any conversion diagnostic; otherwise edge cases such as decimal-to-double rounding, ulong overflow, or an unknown SAFEARRAY element type become invisible bugs. Storing both the typed projection and the raw fields in the same `MxValue`/`MxArray` lets cross-language clients recover the original observation byte-for-byte where possible and detect lossy cases where it is not. ### Inverse projection for COM writes The conversions above run on the read path, turning COM values into `MxValue`. The write path runs the same `VariantConverter` in reverse: `ConvertToComValue` takes an `MxValue` from a `Write` command and returns a CLR object that the COM marshaler boxes into the matching VARIANT, so it is the inverse of `Convert`. - A null `MxValue` argument throws; an `MxValue` whose `IsNull` flag is set returns `null` (the MXAccess null), keeping the read/write null semantics symmetric. - Each `KindCase` maps to its CLR scalar (`bool`, `int`, `long`, `float`, `double`, `string`). A `TimestampValue` becomes a `DateTime`, which the marshaler renders as `VT_DATE` — the form MXAccess accepts for the timestamped-write argument. - An array kind delegates to `ConvertToComArray`, which projects each `MxArray.ValuesCase` to a typed CLR array (for example `int[]`, `string[]`, or a `DateTime[]` for timestamp arrays) so the marshaler produces the corresponding SAFEARRAY. - `RawValue` payloads are intentionally rejected on both the scalar and array paths. Raw bytes are preserved on the read path for diagnostics, but there is no safe way to reconstruct the original VARIANT from them, so a write that carries a raw value throws rather than guessing. An `MxValue` with no value kind set throws for the same reason — there is nothing to write. ## HResultConverter and HResultConversion `HResultConverter.Convert` wraps any `Exception` thrown across the COM boundary. It prefers `COMException.ErrorCode` over `Exception.HResult` because the runtime sometimes overwrites `Exception.HResult` while marshalling, and the `ErrorCode` field is the value the COM call actually returned. ```csharp public HResultConversion Convert(Exception exception) { if (exception is null) { throw new ArgumentNullException(nameof(exception)); } int hresult = exception is COMException comException ? comException.ErrorCode : exception.HResult; return new HResultConversion( hresult, CreateProtocolStatus(hresult, exception), CreateSafeDiagnosticMessage(exception)); } ``` `CreateProtocolStatus` maps `0` (`S_OK`) to `ProtocolStatusCode.Ok` and any non-zero HRESULT to `ProtocolStatusCode.MxaccessFailure`. The status `Message` includes the exception type name and the formatted HRESULT (`HRESULT 0x<8 hex digits>`), giving downstream operators the same identifier they would see in COM event logs. `HResultConversion` is an immutable carrier with three fields: | Field | Purpose | |-------|---------| | `HResult` | Raw signed 32-bit HRESULT, suitable for inclusion in a wire reply | | `ProtocolStatus` | The `ProtocolStatus` projection used in command replies | | `DiagnosticMessage` | A safe, fully-qualified diagnostic string for logs | The diagnostic message is built from the exception type's `FullName` and the formatted HRESULT only. It deliberately omits `Exception.Message` so user-supplied or localized strings do not leak into worker log channels that may be shipped to less trusted destinations. ## MxStatusProxyConverter `MxStatusProxyConverter` projects MXAccess `MXSTATUS_PROXY` records into the `MxStatusProxy` proto message. Because the COM struct is exposed via interop reflection rather than a typed binding, the converter reads the `success`, `category`, `detectedBy`, and `detail` fields by name through `FieldInfo`. ```csharp public MxStatusProxy Convert(object status) { if (status is null) { throw new ArgumentNullException(nameof(status)); } Type statusType = status.GetType(); int success = ReadInt32Field(status, statusType, "success"); int rawCategory = ReadInt32Field(status, statusType, "category"); int rawDetectedBy = ReadInt32Field(status, statusType, "detectedBy"); int detail = ReadInt32Field(status, statusType, "detail"); return new MxStatusProxy { Success = success, Category = MapCategory(rawCategory), DetectedBy = MapSource(rawDetectedBy), Detail = detail, RawCategory = rawCategory, RawDetectedBy = rawDetectedBy, DiagnosticText = MxStatusDetailText.Lookup(detail), }; } ``` `MapCategory` and `MapSource` translate the integer codes documented for `MXSTATUS_PROXY` (for example `0 = Ok`, `3 = CommunicationError`, `0 = RequestingLmx`, `5 = RespondingAutomationObject`) into typed enums. The original integers are preserved alongside the typed projection in `RawCategory` and `RawDetectedBy`, so any code the runtime emits outside the documented range is still observable. ### Status arrays `ConvertMany` walks an inbound `Array` of status structs and emits `IReadOnlyList`. Null entries become explicit `Unknown`/`Unknown` placeholders with a diagnostic text rather than being dropped, so the index of each status still aligns with the index of the value or item handle it describes. ```csharp foreach (object? status in statuses) { if (status is null) { converted.Add(new MxStatusProxy { Category = MxStatusCategory.Unknown, DetectedBy = MxStatusSource.Unknown, DiagnosticText = "Null MXSTATUS_PROXY entry.", }); continue; } converted.Add(Convert(status)); } ``` ### Why arrays are not collapsed A single MXAccess command (notably `Read`, `Write`, and event callbacks) can return one status per item handle. `gateway.md` requires that the wire format represent each entry independently, because collapsing them to a Boolean success flag hides partial failures: a 50-item write where one item fails would be indistinguishable from a 50-item write where every item failed. Preserving the array per-position lets clients correlate each `MxStatusProxy` with its item handle and `MxValue`. ### Completion-only status fallback When MXAccess returns a status payload that is not a recognizable `MXSTATUS_PROXY` struct (for example a completion-only byte buffer from older runtimes), `PreserveCompletionOnlyStatusBytes` hex-encodes the raw bytes so they survive transport even though they cannot be typed. ```csharp public string PreserveCompletionOnlyStatusBytes(byte[] statusBytes) { if (statusBytes is null) { throw new ArgumentNullException(nameof(statusBytes)); } return $"completion_only_status_hex={BitConverter.ToString(statusBytes).Replace("-", string.Empty)}"; } ``` ## MxStatusDetailText `MxStatusDetailText` is an internal lookup that maps known `MXSTATUS_PROXY.detail` codes to short human-readable strings (for example `28 = "Index out of range"`, `42 = "Unable to convert string"`, `8017 = "Object must be offscan to modify attributes that have an MxSecurityConfigure security classification"`). `MxStatusProxyConverter.Convert` calls `Lookup` and writes the result to `DiagnosticText`. Unknown codes return `string.Empty`, leaving the numeric `Detail` field as the authoritative identifier. The mapping covers selected detail codes in the MXAccess engine-error ranges (16-50, 56-61, 541-542, 8017). The ranges are not contiguous: codes that the runtime does not assign a distinct meaning are omitted (for example 35, 45, and 46 in the 16-50 range and 58-59 in the 56-61 range), so only codes with a known text appear. Adding entries here is the supported way to enrich wire-level diagnostics without changing the proto schema. ## MxStatusConversionException `MxStatusConversionException` is the worker-internal signal that a status struct could not be projected at all - for example, the interop type is missing one of the required fields, or the field is null. `MxStatusProxyConverter.ReadInt32Field` raises it with a message that names both the offending CLR type and the missing field. ```csharp private static int ReadInt32Field( object value, Type valueType, string fieldName) { FieldInfo? field = valueType.GetField(fieldName, BindingFlags.Instance | BindingFlags.Public); if (field is null) { throw new MxStatusConversionException( $"Status object type '{valueType.FullName}' does not expose required field '{fieldName}'."); } object? fieldValue = field.GetValue(value); if (fieldValue is null) { throw new MxStatusConversionException( $"Status object field '{fieldName}' on type '{valueType.FullName}' is null."); } return System.Convert.ToInt32(fieldValue, CultureInfo.InvariantCulture); } ``` The exception is distinct from `COMException` so the worker's command pipeline can route it through a dedicated handler. Callers typically translate it into a `ProtocolStatus` with `ProtocolStatusCode.MxaccessFailure` and a synthetic HRESULT, then attach the exception message as a `RawDiagnostic` rather than letting the failure surface as a generic worker error. ## Related Documentation - [MXAccess Worker Instance Design](./MxAccessWorkerInstanceDesign.md) - [Contracts](./Contracts.md) - [Worker STA Threading](./WorkerSta.md)