fix(ablegacy): refuse every libplctag write the #659 translation changes (fix round 2)

Review M1: round 1 refused only the rewritten status bits, so S:/O:/I: writes - which
never worked on libplctag because tag creation failed - would have started reaching
the PLC (outputs, status-file words) on the default transport. WriteRefusal now keys
on LibplctagLegacyName.IsTranslated: file number supplied (S/O/I, incl. bit writes
like O:0/3, refused ahead of the parent-word RMW) or status bit rewritten. Reads
unaffected. Docs/comments now true; gate doc notes R bit-number wire evidence is
non-discriminating (L1). 12 new cases fail with the round-1 predicate.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 05:43:50 -04:00
parent 7dbb060f92
commit 7ba86f7857
6 changed files with 88 additions and 45 deletions
@@ -46,17 +46,18 @@ internal static class LibplctagLegacyName
public static string Translate(string canonical) => TranslateCore(canonical).Name;
/// <summary>
/// True when <paramref name="canonical"/> is a status bit this translator rewrites to control word + bit
/// (<c>R6:0.DN</c> → <c>R6:0.CON/13</c>; R <c>EN/EU/DN/EM/ER/UL/IN/FD</c>, counter <c>UA</c>). A WRITE to one is
/// refused (<see cref="LibplctagLegacyTagRuntime.WriteRefusal"/>): before the translation libplctag could not
/// even create such a tag, so a write never reached the PLC, and the rewrite must not turn it into a new masked
/// write to a production controller's control word (the native transport refuses status-bit writes too).
/// True when <see cref="Translate"/> changes <paramref name="canonical"/>: a file number supplied for
/// <c>S:</c>/<c>O:</c>/<c>I:</c> (incl. bit forms such as <c>O:0/3</c>) or a status bit rewritten to control word +
/// bit (<c>R6:0.DN</c> → <c>R6:0.CON/13</c>; R <c>EN/EU/DN/EM/ER/UL/IN/FD</c>, counter <c>UA</c>). A WRITE to such an
/// address is refused (<see cref="LibplctagLegacyTagRuntime.WriteRefusal"/>): before #659 libplctag could not
/// even create these tags, so no write ever reached the PLC, and the read fix must not turn libplctag — the
/// default transport — into a new way to drive outputs, status-file words or control words.
/// </summary>
/// <param name="canonical">The canonical address.</param>
/// <returns><see langword="true"/> for a rewritten status bit.</returns>
public static bool IsRewrittenStatusBit(string canonical) => TranslateCore(canonical).RewrittenStatusBit;
/// <returns><see langword="true"/> when the libplctag name differs from the canonical one.</returns>
public static bool IsTranslated(string canonical) => TranslateCore(canonical).Changed;
private static (string Name, bool RewrittenStatusBit) TranslateCore(string canonical)
private static (string Name, bool Changed) TranslateCore(string canonical)
{
// Family-neutral parse is correct: the driver hands the runtime AbLegacyAddress.ToLibplctagName(), already
// decimal-normalised (PLC-5 octal I/O included) — the same reasoning as NativeAbLegacyTagRuntime.ToPcccAddress.
@@ -65,6 +66,7 @@ internal static class LibplctagLegacyName
var fileNumber = a.FileNumber ?? PcccFileType.ImplicitFileNumber(a.FileLetter);
if (fileNumber is null) return (canonical, false);
var fileNumberSupplied = a.FileNumber is null; // S:/O:/I: — libplctag needs S2/O0/I1
var name = $"{a.FileLetter}{fileNumber}:{a.WordNumber}";
if (a.SubElement is { } sub)
{
@@ -91,6 +93,6 @@ internal static class LibplctagLegacyName
}
if (a.BitIndex is int b) name += $"/{b}";
return (name, false);
return (name, fileNumberSupplied);
}
}
@@ -71,18 +71,20 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
: RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
/// <summary>
/// The writes the libplctag runtime refuses before the wire, or <see langword="null"/>: a status bit that
/// <see cref="LibplctagLegacyName"/> rewrites to control word + bit (<c>R6:0.DN</c> → <c>R6:0.CON/13</c>, R
/// <c>EN/EU/DN/EM/ER/UL/IN/FD</c>, counter <c>UA</c>). Before #659 libplctag could not create those tags at all,
/// so a write to one never reached the controller; the read-side translation must not open a new masked write
/// to a control word. T/C status bits libplctag itself parses (<c>T4:0.DN</c>, <c>C5:0.CU</c>…) keep their
/// pre-existing behaviour — a masked bit write (documented hazard in <c>docs/drivers/AbLegacy.md</c>).
/// The writes the libplctag runtime refuses before the wire, or <see langword="null"/>: any address whose
/// libplctag name <see cref="LibplctagLegacyName"/> had to change (<see cref="LibplctagLegacyName.IsTranslated"/>)
/// — file-number-less <c>S:</c>/<c>O:</c>/<c>I:</c> (incl. bit writes such as <c>O:0/3</c>, refused before the
/// parent-word read-modify-write) and the status bits rewritten to control word + bit (<c>R6:0.DN</c>, counter
/// <c>UA</c>). Before #659 libplctag could not create those tags at all, so no write to one ever reached the
/// controller; the read fix must not widen the write surface of the default transport. T/C status bits
/// libplctag parses itself (<c>T4:0.DN</c>, <c>C5:0.CU</c>…) keep their pre-existing behaviour — a masked bit
/// write (documented hazard in <c>docs/drivers/AbLegacy.md</c>).
/// </summary>
/// <param name="p">The tag's create parameters.</param>
/// <returns>The refusal reason, or <see langword="null"/>.</returns>
internal static string? WriteRefusal(AbLegacyTagCreateParams p) =>
LibplctagLegacyName.IsRewrittenStatusBit(p.TagName)
? $"Writes to the status bit '{p.TagName}' are not supported on the libplctag transport (libplctag has no mnemonic for it; it is read as control word + bit)."
LibplctagLegacyName.IsTranslated(p.TagName)
? $"Writes to '{p.TagName}' are not supported on the libplctag transport (its name is translated for libplctag; reads only)."
: null;
/// <summary>