fix(ablegacy): encode array writes at the data file's element width

Review r0 I3. The array encoder used the AUTHORED dataType's width while both
runtimes size their buffer from the FILE: an Int array on F8 wrote four shorts
into a 16-byte float buffer and sent it all (garbage neighbours, answered
Good); Long/Float on N overran it.

- The write path resolves the element width from the data file the address
  names (N/B/A/I/O/S 16-bit word, L 32-bit, F float) and encodes at THAT width.
- An authored type that does not match its file is refused BadTypeMismatch
  before any runtime exists (warned once, no health write).
- The same rule (AbLegacyTagDefinitionFactory.ArrayFileMismatch) backs an
  Inspect deploy warning and the AdminUI model's Validate error; FromTagConfig
  still accepts the tag so persisted configs keep loading.
- M6: the skipped F1 parity test gains the shapes the write path refuses
  (/bit, .sub, indirect, type/file mismatch, PLC-5 native).

Tests: one case per file type, a mismatch per pairing with nothing created,
and a positive control.

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:20:10 -04:00
parent 17ae2eea0b
commit d758dd1e16
6 changed files with 175 additions and 5 deletions
@@ -177,6 +177,9 @@ public static class AbLegacyTagDefinitionFactory
{
if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError)
warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown.");
else if (accepted.ArrayLength is not null
&& ArrayFileMismatch(accepted.DataType, accepted.Address) is { } widthError)
warnings.Add(widthError);
return warnings;
}
@@ -252,6 +255,54 @@ public static class AbLegacyTagDefinitionFactory
$"AbLegacy {type} is not supported as an array element (String, Timer, Counter and Control elements are "
+ "structured); author a scalar tag per element instead. The driver skips this tag and it reads BadNodeIdUnknown.";
/// <summary>
/// The element width an array on <paramref name="address"/>'s data file has, as the data type that decodes /
/// encodes it: N, B, A and the I/O/S word files are 16-bit words (<see cref="AbLegacyDataType.Int"/>), L is
/// 32-bit (<see cref="AbLegacyDataType.Long"/>), F is IEEE float (<see cref="AbLegacyDataType.Float"/>).
/// <see langword="null"/> for a file with no flat array layout (ST, T, C, R, function files) or an address that
/// does not parse.
/// </summary>
/// <param name="address">The authored address.</param>
/// <returns>The file's element type, or <see langword="null"/>.</returns>
public static AbLegacyDataType? ArrayFileElementType(string address) =>
AbLegacyAddress.TryParse(address) is { } a ? ArrayFileElementType(a) : null;
/// <inheritdoc cref="ArrayFileElementType(string)"/>
public static AbLegacyDataType? ArrayFileElementType(AbLegacyAddress a) => a.FileLetter switch
{
"N" or "B" or "A" or "I" or "O" or "S" => AbLegacyDataType.Int,
"L" => AbLegacyDataType.Long,
"F" => AbLegacyDataType.Float,
_ => null,
};
/// <summary>
/// Why an ARRAY authored as <paramref name="type"/> on <paramref name="address"/> does not match its data
/// file's element width (review 07 r0 I3), or <see langword="null"/> when it does. Int / AnalogInt need a 16-bit
/// word file (N/B/A/I/O/S), Long an L file, Float an F file; Bit arrays are bit-packed words and fit any 16-bit
/// word file. A mismatch reads the file at the wrong width (garbage) and the driver refuses to WRITE it
/// (<c>BadTypeMismatch</c>), because encoding at the authored width would corrupt the neighbouring elements.
/// </summary>
/// <param name="type">The authored element data type.</param>
/// <param name="address">The authored address.</param>
/// <returns>The mismatch message, or <see langword="null"/>.</returns>
public static string? ArrayFileMismatch(AbLegacyDataType type, string address)
{
if (!IsArrayElementTypeSupported(type)) return null; // structured arrays have their own refusal
if (AbLegacyAddress.TryParse(address) is not { } a || a.IsIndirect) return null;
var file = ArrayFileElementType(a);
var ok = type switch
{
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit => file == AbLegacyDataType.Int,
_ => file == type,
};
return ok
? null
: $"AbLegacy {type} array on '{address}' does not match the {a.FileLetter} file's element width "
+ "(Int/AnalogInt/Bit need an N/B/A word file, Long an L file, Float an F file): reads decode the wrong width "
+ "and writes are refused BadTypeMismatch.";
}
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
&& e.TryGetInt32(out var v) ? v : 0;
@@ -885,12 +885,29 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
// Array writes (review 07 U10): convert and length-check BEFORE any runtime exists, then encode every
// element. A bit / sub-element array has no flat encoding.
// The encode width is the DATA FILE's element width (review 07 r0 I3), never the authored type's: an
// authored type that does not match its file is refused BadTypeMismatch before anything is encoded.
Array? arrayValue = null;
AbLegacyDataType arrayFileType = default;
if (IsArrayTag(def))
{
var conversion = parsed.BitIndex is not null || parsed.SubElement is not null
? AbLegacyStatusMapper.BadNotSupported
: AbLegacyArrayWriteEncoder.TryConvert(def.DataType, w.Value, EffectiveArrayLength(def), out arrayValue);
var fileType = AbLegacyTagDefinitionFactory.ArrayFileElementType(parsed);
var widthRefusal = AbLegacyTagDefinitionFactory.ArrayFileMismatch(def.DataType, def.Address)
?? (fileType is null ? $"{def.Address} has no flat array layout." : null);
uint conversion;
if (parsed.BitIndex is not null || parsed.SubElement is not null || def.DataType == AbLegacyDataType.Bit)
conversion = AbLegacyStatusMapper.BadNotSupported;
else if (widthRefusal is not null || fileType is null)
{
WarnRefusalOnce(device, def, "write", widthRefusal ?? "no flat array layout", AbLegacyStatusMapper.BadTypeMismatch);
results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch);
continue;
}
else
{
arrayFileType = fileType.Value;
conversion = AbLegacyArrayWriteEncoder.TryConvert(arrayFileType, w.Value, EffectiveArrayLength(def), out arrayValue);
}
if (conversion != AbLegacyStatusMapper.Good)
{
if (conversion == AbLegacyStatusMapper.BadNotSupported)
@@ -925,7 +942,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (arrayValue is not null) runtime.EncodeArray(def.DataType, arrayValue);
if (arrayValue is not null) runtime.EncodeArray(arrayFileType, arrayValue);
else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
status = runtime.GetStatus();
@@ -92,6 +92,11 @@ public sealed class AbLegacyTagConfigModel
if (isArray && arrayLength >= 1 && !AbLegacyTagDefinitionFactory.IsArrayElementTypeSupported(DataType))
return AbLegacyTagDefinitionFactory.ArrayElementTypeError(DataType);
// An array must match its data file's element width (review 07 r0 I3): the driver refuses to write a
// mismatched one, and reads it at the wrong width.
if (isArray && arrayLength >= 1 && AbLegacyTagDefinitionFactory.ArrayFileMismatch(DataType, Address.Trim()) is { } widthError)
return widthError;
// Parse-gate backstop: the checks above mirror the reader's whole rejection surface today, so this is
// expected to be silent for AbLegacy. It is here so a future reader rule cannot become a gap.
return TagConfigParseGate.Describe(