diff --git a/CHANGELOG.md b/CHANGELOG.md
index b49b2f9..ddca178 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,12 @@
### 新增
+- 增加独立 SecsFrame.Sml 程序集,为动态消息和全部 Item 类型
+ 提供确定性 SML 调试文本写出与严格读取。
+- 增加 ASCII 可逆转义、JIS-8 原始十六进制字节、InvariantCulture 数字、
+ 无 Body/空 List 区分,以及深度、Item、值和文本长度资源边界。
+- 增加 SML 黄金向量、全类型往返、非法语法、源位置和资源限制测试;该
+ profile 不声明标准 SML 合规。
- 初始化 SecsFrame 仓库、工程规范和 CI。
- 增加 HSMS 帧头、严格长度定界与编解码基础实现。
- 增加 SEMI E5-0725 的完整 SECS-II Item 动态数据模型与严格二进制编解码。
diff --git a/README.md b/README.md
index 042a0e3..3f0c492 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@
|---|---|
| `SecsFrame` | SECS-II 数据模型、HSMS-SS 会话与事务基础 |
| `SecsFrame.Gem` | GEM 通用状态模型与能力服务 |
+| `SecsFrame.Sml` | 动态消息的确定性 SML 调试读写 |
| `SecsFrame.Smn` | E173 SMN 日志、文档与消息表示 |
| `SecsFrame.Sedd` | E172 SEDD 设备接口数据字典 |
@@ -36,6 +37,9 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor
Equipment 应用发送策略的 Collection Event、报警目录查询、单报警发送
启停,以及最小报警通知、Equipment 报警通知发送策略、远程命令运行期精确
目录/逐项可用性/全局回退和应用状态接受策略。
+独立 SecsFrame.Sml 为相同动态消息模型提供确定性文本写出、
+严格读取、全部 Item 类型往返和显式资源边界;它是非规范性调试 profile,
+不进入线上通讯路径。
Item 用法与边界见
[docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见
[docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见
@@ -53,6 +57,8 @@ Host/Equipment 端点见
[docs/SECS-ENDPOINT-ROLES.md](docs/SECS-ENDPOINT-ROLES.md),
独立 GEM 基础行为见
[docs/GEM-FOUNDATION.md](docs/GEM-FOUNDATION.md),
+SML 调试文本见
+[docs/SML.md](docs/SML.md),
官方 secs4net 跨实现证据见
[docs/SECS4NET-INTEROP.md](docs/SECS4NET-INTEROP.md),
整体路线图见
diff --git a/SecsFrame.slnx b/SecsFrame.slnx
index 8580fd5..0e90820 100644
--- a/SecsFrame.slnx
+++ b/SecsFrame.slnx
@@ -1,6 +1,7 @@
+
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index afed159..40e1cbd 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -11,6 +11,8 @@ StreamFrame TCP、Pipe、帧边界、连接生命周期
```
E172 SEDD 与 E173 SMN 是可选元数据/表示层,不进入核心通讯路径。
+SecsFrame.Sml 同样是只依赖核心动态消息模型的可选调试表示层;
+核心包不反向依赖它。
## 核心原则
@@ -157,5 +159,6 @@ HSMS 头 Session ID 与 System Bytes。没有 W-Bit 的出站消息在写出后
- `SecsFrame` 可以依赖 `StreamFrame`。
- `SecsFrame.Gem` 依赖 `SecsFrame`。
+- `SecsFrame.Sml` 依赖 `SecsFrame`,不得依赖 GEM 或线上传输实现。
- 核心包不得依赖 GEM、SML/SMN、依赖注入容器或具体日志实现。
- 状态机计时使用可替换的时间抽象,测试不得依赖真实长时间等待。
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 3ce874c..feba74b 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -92,14 +92,15 @@ Collection Event 发送策略沿用相同的默认不注册模式;拒绝发生
串行化决策,库不引入后台循环或未经 E30 核对的状态矩阵。
阶段 2 其余报警历史/批量控制和完整命令权限/调度都需要先取得授权
-E30-0526 材料。下一可执行优先级转入阶段 3 的 SML 调试读写;最小垂直切片
-先核对可公开、可许可的语法来源,再为现有动态 SecsMessage 和
-全部 SecsItem 类型提供确定性文本写出、读取回环与严格向量,
-不宣称标准 SML 合规。
+E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写已经基于许可
+清晰的公开实现完成非规范性 profile,为现有动态 SecsMessage
+和全部 SecsItem 类型提供确定性文本写出、严格读取、资源边界与
+自主协议向量,不宣称标准 SML 合规。下一切片在该稳定文本表示上增加 Trace
+导出、结构化脱敏与受控重放。
## 阶段 3:元数据与工具
-- SML 调试读写。
+- [x] SML 调试读写。
- E173 SMN 日志与消息文档。
- E172 SEDD 设备字典导入、校验与发现。
- Trace 重放、脱敏和互操作诊断工具。
diff --git a/docs/SML.md b/docs/SML.md
new file mode 100644
index 0000000..1e282d8
--- /dev/null
+++ b/docs/SML.md
@@ -0,0 +1,74 @@
+# SML 调试读写
+
+## 范围
+
+SecsFrame.Sml 为现有动态 SecsMessage 与全部
+SecsItem 类型提供确定性文本写出和严格读取。它是独立的可选
+表示层,不进入 HSMS 会话、事务或线上二进制编解码路径。
+
+当前实现是 SecsFrame 自主定义并验证的非规范性 SML 调试 profile,不声明
+符合某个 SEMI 标准、厂商方言或一致性测试。消息模型没有名称字段,因此
+profile 只表示 Stream、Function、W-Bit 和可选单根 Item。
+
+## 使用
+
+~~~csharp
+using SecsFrame;
+using SecsFrame.Sml;
+
+var message = new SecsMessage(
+ stream: 6,
+ function: 11,
+ replyExpected: true,
+ rootItem: SecsItem.List(
+ SecsItem.Ascii("LOT-001"),
+ SecsItem.U4(1001)));
+
+var codec = new SmlMessageCodec();
+var text = codec.Encode(message);
+var decoded = codec.Decode(text);
+~~~
+
+写出固定使用 LF、InvariantCulture 数字、明确的 Item 数量和稳定缩进:
+
+~~~text
+'S6F11'W
+
+
+>
+.
+~~~
+
+读取允许 token 之间存在空白,但格式名、Boolean 和十六进制 token 的大小写
+保持严格;完整消息之后的尾随文本会被拒绝。无 Body 使用消息头后直接跟
+句点,空 List 使用 <L [0] ... >,两者不会混淆。
+
+## 文本约定
+
+- Item 格式名为 L、B、Boolean、
+ A、J、I1/I2/I4/I8、
+ U1/U2/U4/U8 和 F4/F8。
+- Binary 与 JIS-8 使用 0xHH 原始字节;JIS-8 不猜测现场代码页。
+- Boolean 使用 True 或 False。
+- ASCII 位于单引号内,反斜线、单引号、回车、换行、制表符和不可打印
+ 七位字符分别使用可逆转义;\xHH 仍必须位于七位范围。
+- 浮点数使用 .NET round-trip 格式,保留有限值、非数和正负无穷的往返。
+- 读取严格验证声明数量、数值宽度、单根 Item、消息终止符和尾随文本。
+
+SmlMessageCodec 默认沿用核心 Item codec 的最大嵌套深度和
+Item 总数,并另外限制单个 Item 的值数量及输入/输出文本长度。调用方可以
+在构造时收紧这些限制;解析失败使用带字符偏移、行和列的
+SmlParseException。
+
+## 参考与版权边界
+
+实现前只读交叉核对了 MIT 许可的
+[secs4net](https://github.com/mkjeff/secs4net) 中公开的
+Secs4Net.Sml 项目,以确认常见的消息头、Item 格式名和 Boolean
+拼写。本实现围绕 SecsFrame 的不可变模型和严格资源边界独立编写,没有复制
+其源码;测试向量也在本仓库自主构造。JIS-8 原始字节与 ASCII 转义是本
+profile 为无损调试增加的明确约定。
+
+该参考不参与构建。官方 secs4net 互操作项目仍只使用 nuget.org 发布并固定
+为 3.1.0 的 NuGet 包。
diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md
index 8270b46..cdb23c6 100644
--- a/docs/STANDARDS.md
+++ b/docs/STANDARDS.md
@@ -65,6 +65,11 @@
未来如引入实质性开源代码,必须保留对应版权与许可证文本,并在变更说明
中记录来源。
+SecsFrame.Sml 的调试 profile 不是规范性标准实现。开发前只读
+参考了 MIT 许可的 secs4net 公开 SML 项目以交叉核对常见文本拼写;当前
+实现和测试向量均独立编写,JIS-8 原始字节与 ASCII 转义边界见
+[SML.md](SML.md)。
+
与官方 Secs4Net 3.1.0 的 Select、Linktest 和动态消息往返是独立软件实现
之间的工程互操作证据,不代表任何一方通过 SEMI 一致性认证。具体矩阵见
[SECS4NET-INTEROP.md](SECS4NET-INTEROP.md)。
diff --git a/src/SecsFrame.Sml/SecsFrame.Sml.csproj b/src/SecsFrame.Sml/SecsFrame.Sml.csproj
new file mode 100644
index 0000000..a137945
--- /dev/null
+++ b/src/SecsFrame.Sml/SecsFrame.Sml.csproj
@@ -0,0 +1,23 @@
+
+
+ netstandard2.0;net8.0;net10.0
+ Deterministic SML debug text support for SecsFrame messages.
+ SecsFrame.Sml
+ MIT
+ README.md
+ secs;secs-ii;sml;debug;serialization;secsframe
+ 0.1.0-alpha.1
+ true
+ false
+ true
+ snupkg
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SecsFrame.Sml/SmlMessageCodec.cs b/src/SecsFrame.Sml/SmlMessageCodec.cs
new file mode 100644
index 0000000..f193a63
--- /dev/null
+++ b/src/SecsFrame.Sml/SmlMessageCodec.cs
@@ -0,0 +1,87 @@
+namespace SecsFrame.Sml;
+
+///
+/// Encodes and decodes the deterministic SecsFrame SML debug profile.
+///
+///
+/// This is a non-normative diagnostic representation. It does not claim SEMI
+/// SML conformance.
+///
+public sealed class SmlMessageCodec
+{
+ /// The default number of spaces added for each nested Item.
+ public const int DefaultIndentSize = 4;
+
+ /// The default maximum root-inclusive Item depth.
+ public const int DefaultMaxNestingDepth = SecsItemCodec.DefaultMaxNestingDepth;
+
+ /// The default maximum number of Items in one message.
+ public const int DefaultMaxItemCount = SecsItemCodec.DefaultMaxItemCount;
+
+ /// The default maximum primitive values in one Item.
+ public const int DefaultMaxValueCount = 1_000_000;
+
+ /// The default maximum input or output text length.
+ public const int DefaultMaxTextLength = 16 * 1024 * 1024;
+
+ /// Creates a deterministic SML debug codec with explicit resource limits.
+ public SmlMessageCodec(
+ int indentSize = DefaultIndentSize,
+ int maxNestingDepth = DefaultMaxNestingDepth,
+ int maxItemCount = DefaultMaxItemCount,
+ int maxValueCount = DefaultMaxValueCount,
+ int maxTextLength = DefaultMaxTextLength)
+ {
+ if (indentSize <= 0 || indentSize > 16)
+ throw new ArgumentOutOfRangeException(nameof(indentSize), indentSize, "The indent size must be between 1 and 16.");
+ if (maxNestingDepth <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxNestingDepth), maxNestingDepth, "The maximum depth must be positive.");
+ if (maxItemCount <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxItemCount), maxItemCount, "The maximum Item count must be positive.");
+ if (maxValueCount <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxValueCount), maxValueCount, "The maximum value count must be positive.");
+ if (maxTextLength <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxTextLength), maxTextLength, "The maximum text length must be positive.");
+
+ IndentSize = indentSize;
+ MaxNestingDepth = maxNestingDepth;
+ MaxItemCount = maxItemCount;
+ MaxValueCount = maxValueCount;
+ MaxTextLength = maxTextLength;
+ }
+
+ /// Gets the number of spaces added for each nested Item.
+ public int IndentSize { get; }
+
+ /// Gets the maximum root-inclusive Item depth.
+ public int MaxNestingDepth { get; }
+
+ /// Gets the maximum number of Items in one message.
+ public int MaxItemCount { get; }
+
+ /// Gets the maximum primitive values in one Item.
+ public int MaxValueCount { get; }
+
+ /// Gets the maximum accepted or produced text length.
+ public int MaxTextLength { get; }
+
+ /// Encodes one dynamic message to deterministic LF-terminated text.
+ public string Encode(SecsMessage message)
+ {
+ if (message is null)
+ throw new ArgumentNullException(nameof(message));
+
+ return new SmlMessageWriter(this).Write(message);
+ }
+
+ /// Strictly decodes one complete SML message.
+ public SecsMessage Decode(string text)
+ {
+ if (text is null)
+ throw new ArgumentNullException(nameof(text));
+ if (text.Length > MaxTextLength)
+ throw new ArgumentOutOfRangeException(nameof(text), text.Length, $"The SML text length cannot exceed {MaxTextLength} characters.");
+
+ return new SmlMessageParser(text, this).Parse();
+ }
+}
diff --git a/src/SecsFrame.Sml/SmlMessageParser.cs b/src/SecsFrame.Sml/SmlMessageParser.cs
new file mode 100644
index 0000000..8e0f040
--- /dev/null
+++ b/src/SecsFrame.Sml/SmlMessageParser.cs
@@ -0,0 +1,427 @@
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace SecsFrame.Sml;
+
+internal sealed class SmlMessageParser
+{
+ private readonly string _text;
+ private readonly SmlMessageCodec _options;
+ private int _index;
+ private int _line = 1;
+ private int _column = 1;
+ private int _itemCount;
+
+ public SmlMessageParser(string text, SmlMessageCodec options)
+ {
+ _text = text;
+ _options = options;
+ }
+
+ public SecsMessage Parse()
+ {
+ SkipWhitespace();
+ var (stream, function, replyExpected) = ParseHeader();
+ SkipWhitespace();
+
+ SecsItem? rootItem = null;
+ if (!IsAt('.'))
+ rootItem = ParseItem(depth: 1);
+
+ SkipWhitespace();
+ Expect('.', "the message terminator");
+ SkipWhitespace();
+ if (!IsEnd)
+ throw Error("A complete SML message was followed by trailing text");
+
+ return new SecsMessage(stream, function, replyExpected, rootItem);
+ }
+
+ private (byte Stream, byte Function, bool ReplyExpected) ParseHeader()
+ {
+ Expect('\'', "the opening message quote");
+ Expect('S', "an uppercase S in the message header");
+ var stream = ReadDecimal("the stream number", byte.MaxValue);
+ if (stream > 0x7F)
+ throw Error("The stream number must be between 0 and 127");
+
+ Expect('F', "an uppercase F in the message header");
+ var function = ReadDecimal("the function number", byte.MaxValue);
+ Expect('\'', "the closing message quote");
+ SkipWhitespace();
+
+ var replyExpected = false;
+ if (IsAt('W'))
+ {
+ Advance();
+ replyExpected = true;
+ }
+
+ return ((byte)stream, (byte)function, replyExpected);
+ }
+
+ private SecsItem ParseItem(int depth)
+ {
+ if (depth > _options.MaxNestingDepth)
+ throw Error($"The Item nesting depth exceeds the configured maximum {_options.MaxNestingDepth}");
+ if (++_itemCount > _options.MaxItemCount)
+ throw Error($"The Item count exceeds the configured maximum {_options.MaxItemCount}");
+
+ SkipWhitespace();
+ Expect('<', "an Item opening delimiter");
+ SkipWhitespace();
+ var formatToken = ReadToken("an Item format", stopAtOpenBracket: true);
+ var format = ParseFormat(formatToken);
+ SkipWhitespace();
+ Expect('[', "an Item count opening bracket");
+ var count = ReadDecimal("the Item count", SecsItem.MaxEncodedLength);
+ SkipWhitespace();
+ Expect(']', "an Item count closing bracket");
+
+ if (format == SecsItemFormat.List)
+ return ParseList(count, depth);
+ if (count > _options.MaxValueCount)
+ throw Error($"The Item value count exceeds the configured maximum {_options.MaxValueCount}");
+
+ return ParsePrimitive(format, count);
+ }
+
+ private SecsItem ParseList(int count, int depth)
+ {
+ if (count > _options.MaxItemCount - _itemCount)
+ throw Error($"The declared List count exceeds the configured maximum Item count {_options.MaxItemCount}");
+
+ var items = new SecsItem[count];
+ for (var index = 0; index < items.Length; index++)
+ {
+ SkipWhitespace();
+ items[index] = ParseItem(depth + 1);
+ }
+
+ SkipWhitespace();
+ Expect('>', "the List closing delimiter");
+ return SecsItem.List(items);
+ }
+
+ private SecsItem ParsePrimitive(SecsItemFormat format, int count)
+ {
+ SkipWhitespace();
+ SecsItem item = format switch
+ {
+ SecsItemFormat.Binary => SecsItem.Binary(ReadValues(count, ParseHexByte)),
+ SecsItemFormat.Boolean => SecsItem.Boolean(ReadValues(count, ParseBoolean)),
+ SecsItemFormat.Ascii => ParseAscii(count),
+ SecsItemFormat.Jis8 => SecsItem.Jis8(ReadValues(count, ParseHexByte)),
+ SecsItemFormat.I8 => SecsItem.I8(ReadValues(count, ParseInt64)),
+ SecsItemFormat.I1 => SecsItem.I1(ReadValues(count, ParseSByte)),
+ SecsItemFormat.I2 => SecsItem.I2(ReadValues(count, ParseInt16)),
+ SecsItemFormat.I4 => SecsItem.I4(ReadValues(count, ParseInt32)),
+ SecsItemFormat.F8 => SecsItem.F8(ReadValues(count, ParseDouble)),
+ SecsItemFormat.F4 => SecsItem.F4(ReadValues(count, ParseSingle)),
+ SecsItemFormat.U8 => SecsItem.U8(ReadValues(count, ParseUInt64)),
+ SecsItemFormat.U1 => SecsItem.U1(ReadValues(count, ParseByte)),
+ SecsItemFormat.U2 => SecsItem.U2(ReadValues(count, ParseUInt16)),
+ SecsItemFormat.U4 => SecsItem.U4(ReadValues(count, ParseUInt32)),
+ _ => throw Error($"Unsupported Item format {format}"),
+ };
+
+ SkipWhitespace();
+ Expect('>', "the Item closing delimiter");
+ return item;
+ }
+
+ private SecsItem ParseAscii(int expectedCount)
+ {
+ Expect('\'', "an ASCII opening quote");
+ var value = new StringBuilder(expectedCount);
+ while (!IsEnd && !IsAt('\''))
+ {
+ var character = Current;
+ if (character == '\r' || character == '\n')
+ throw Error("ASCII text cannot contain an unescaped line break");
+
+ Advance();
+ if (character != '\\')
+ {
+ if (character > 0x7F)
+ throw Error("ASCII text contains a character outside the seven-bit range");
+ value.Append(character);
+ continue;
+ }
+
+ value.Append(ParseAsciiEscape());
+ }
+
+ Expect('\'', "an ASCII closing quote");
+ if (value.Length != expectedCount)
+ throw Error($"The ASCII value count {value.Length} does not match the declared count {expectedCount}");
+
+ return SecsItem.Ascii(value.ToString());
+ }
+
+ private char ParseAsciiEscape()
+ {
+ if (IsEnd)
+ throw Error("An ASCII escape sequence is incomplete");
+
+ var escape = Current;
+ Advance();
+ return escape switch
+ {
+ '\\' => '\\',
+ '\'' => '\'',
+ 'r' => '\r',
+ 'n' => '\n',
+ 't' => '\t',
+ 'x' => ParseAsciiHexEscape(),
+ _ => throw Error($"Unsupported ASCII escape sequence \\{escape}"),
+ };
+ }
+
+ private char ParseAsciiHexEscape()
+ {
+ var high = ReadHexDigit("the first ASCII hexadecimal escape digit");
+ var low = ReadHexDigit("the second ASCII hexadecimal escape digit");
+ var value = (high << 4) | low;
+ if (value > 0x7F)
+ throw Error("An ASCII hexadecimal escape must remain in the seven-bit range");
+ return (char)value;
+ }
+
+ private T[] ReadValues(int count, Func parse)
+ {
+ var values = new T[count];
+ for (var index = 0; index < values.Length; index++)
+ {
+ SkipWhitespace();
+ values[index] = parse(ReadToken("an Item value", stopAtOpenBracket: false));
+ }
+ return values;
+ }
+
+ private byte ParseHexByte(Token token)
+ {
+ if (token.Value.Length != 4 || token.Value[0] != '0' || token.Value[1] != 'x')
+ throw ErrorAt(token, "A Binary or JIS-8 value must use the exact form 0xHH");
+
+ var high = ParseHexDigit(token.Value[2]);
+ var low = ParseHexDigit(token.Value[3]);
+ if (high < 0 || low < 0)
+ throw ErrorAt(token, "A Binary or JIS-8 value contains an invalid hexadecimal digit");
+ return (byte)((high << 4) | low);
+ }
+
+ private bool ParseBoolean(Token token)
+ => token.Value switch
+ {
+ "True" => true,
+ "False" => false,
+ _ => throw ErrorAt(token, "A Boolean value must be True or False"),
+ };
+
+ private sbyte ParseSByte(Token token)
+ => sbyte.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : throw ErrorAt(token, "The I1 value is outside its signed 8-bit range");
+
+ private short ParseInt16(Token token)
+ => short.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : throw ErrorAt(token, "The I2 value is outside its signed 16-bit range");
+
+ private int ParseInt32(Token token)
+ => int.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : throw ErrorAt(token, "The I4 value is outside its signed 32-bit range");
+
+ private long ParseInt64(Token token)
+ => long.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : throw ErrorAt(token, "The I8 value is outside its signed 64-bit range");
+
+ private byte ParseByte(Token token)
+ => byte.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : throw ErrorAt(token, "The U1 value is outside its unsigned 8-bit range");
+
+ private ushort ParseUInt16(Token token)
+ => ushort.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : throw ErrorAt(token, "The U2 value is outside its unsigned 16-bit range");
+
+ private uint ParseUInt32(Token token)
+ => uint.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : throw ErrorAt(token, "The U4 value is outside its unsigned 32-bit range");
+
+ private ulong ParseUInt64(Token token)
+ => ulong.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : throw ErrorAt(token, "The U8 value is outside its unsigned 64-bit range");
+
+ private float ParseSingle(Token token)
+ => float.TryParse(token.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : throw ErrorAt(token, "The F4 value is not a valid invariant single-precision number");
+
+ private double ParseDouble(Token token)
+ => double.TryParse(token.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : throw ErrorAt(token, "The F8 value is not a valid invariant double-precision number");
+
+ private SecsItemFormat ParseFormat(Token token)
+ => token.Value switch
+ {
+ "L" => SecsItemFormat.List,
+ "B" => SecsItemFormat.Binary,
+ "Boolean" => SecsItemFormat.Boolean,
+ "A" => SecsItemFormat.Ascii,
+ "J" => SecsItemFormat.Jis8,
+ "I8" => SecsItemFormat.I8,
+ "I1" => SecsItemFormat.I1,
+ "I2" => SecsItemFormat.I2,
+ "I4" => SecsItemFormat.I4,
+ "F8" => SecsItemFormat.F8,
+ "F4" => SecsItemFormat.F4,
+ "U8" => SecsItemFormat.U8,
+ "U1" => SecsItemFormat.U1,
+ "U2" => SecsItemFormat.U2,
+ "U4" => SecsItemFormat.U4,
+ _ => throw ErrorAt(token, $"Unknown Item format {token.Value}"),
+ };
+
+ private int ReadDecimal(string description, int maximum)
+ {
+ SkipWhitespace();
+ var start = CapturePosition();
+ var value = 0;
+ var digitCount = 0;
+ while (!IsEnd && Current >= '0' && Current <= '9')
+ {
+ var digit = Current - '0';
+ if (value > (maximum - digit) / 10)
+ throw ErrorAt(start, $"{description} exceeds {maximum}");
+ value = (value * 10) + digit;
+ digitCount++;
+ Advance();
+ }
+
+ if (digitCount == 0)
+ throw ErrorAt(start, $"Expected {description}");
+ return value;
+ }
+
+ private Token ReadToken(string description, bool stopAtOpenBracket)
+ {
+ var start = CapturePosition();
+ while (!IsEnd && !char.IsWhiteSpace(Current) && Current != '>' && (!stopAtOpenBracket || Current != '['))
+ Advance();
+ if (_index == start.Offset)
+ throw ErrorAt(start, $"Expected {description}");
+ return new Token(_text.Substring(start.Offset, _index - start.Offset), start.Offset, start.Line, start.Column);
+ }
+
+ private int ReadHexDigit(string description)
+ {
+ if (IsEnd)
+ throw Error($"Expected {description}");
+ var value = ParseHexDigit(Current);
+ if (value < 0)
+ throw Error($"Expected {description}");
+ Advance();
+ return value;
+ }
+
+ private void Expect(char expected, string description)
+ {
+ if (IsEnd || Current != expected)
+ throw Error($"Expected {description}");
+ Advance();
+ }
+
+ private void SkipWhitespace()
+ {
+ while (!IsEnd && char.IsWhiteSpace(Current))
+ Advance();
+ }
+
+ private void Advance()
+ {
+ if (Current == '\n')
+ {
+ _line++;
+ _column = 1;
+ }
+ else
+ {
+ _column++;
+ }
+ _index++;
+ }
+
+ private Position CapturePosition()
+ => new(_index, _line, _column);
+
+ private SmlParseException Error(string message)
+ => new(message, _index, _line, _column);
+
+ private static SmlParseException ErrorAt(Position position, string message)
+ => new(message, position.Offset, position.Line, position.Column);
+
+ private static SmlParseException ErrorAt(Token token, string message)
+ => new(message, token.Offset, token.Line, token.Column);
+
+ private bool IsAt(char value)
+ => !IsEnd && Current == value;
+
+ private bool IsEnd => _index >= _text.Length;
+
+ private char Current => _text[_index];
+
+ private static int ParseHexDigit(char value)
+ {
+ if (value >= '0' && value <= '9')
+ return value - '0';
+ if (value >= 'A' && value <= 'F')
+ return value - 'A' + 10;
+ return -1;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private readonly struct Position
+ {
+ public Position(int offset, int line, int column)
+ {
+ Offset = offset;
+ Line = line;
+ Column = column;
+ }
+
+ public int Offset { get; }
+
+ public int Line { get; }
+
+ public int Column { get; }
+ }
+
+ [StructLayout(LayoutKind.Auto)]
+ private readonly struct Token
+ {
+ public Token(string value, int offset, int line, int column)
+ {
+ Value = value;
+ Offset = offset;
+ Line = line;
+ Column = column;
+ }
+
+ public string Value { get; }
+
+ public int Offset { get; }
+
+ public int Line { get; }
+
+ public int Column { get; }
+ }
+}
diff --git a/src/SecsFrame.Sml/SmlMessageWriter.cs b/src/SecsFrame.Sml/SmlMessageWriter.cs
new file mode 100644
index 0000000..ef69638
--- /dev/null
+++ b/src/SecsFrame.Sml/SmlMessageWriter.cs
@@ -0,0 +1,242 @@
+using System.Globalization;
+using System.Text;
+
+namespace SecsFrame.Sml;
+
+internal sealed class SmlMessageWriter
+{
+ private readonly SmlMessageCodec _options;
+ private readonly StringBuilder _text = new();
+ private int _itemCount;
+
+ public SmlMessageWriter(SmlMessageCodec options)
+ => _options = options;
+
+ public string Write(SecsMessage message)
+ {
+ _text.Append("'S");
+ _text.Append(message.Stream.ToString(CultureInfo.InvariantCulture));
+ _text.Append('F');
+ _text.Append(message.Function.ToString(CultureInfo.InvariantCulture));
+ _text.Append('\'');
+ if (message.ReplyExpected)
+ _text.Append('W');
+ _text.Append('\n');
+
+ if (message.RootItem is not null)
+ WriteItem(message.RootItem, depth: 1);
+
+ _text.Append(".\n");
+ EnsureTextLength();
+ return _text.ToString();
+ }
+
+ private void WriteItem(SecsItem item, int depth)
+ {
+ if (depth > _options.MaxNestingDepth)
+ throw new InvalidOperationException($"The Item nesting depth exceeds the configured maximum {_options.MaxNestingDepth}.");
+ if (++_itemCount > _options.MaxItemCount)
+ throw new InvalidOperationException($"The Item count exceeds the configured maximum {_options.MaxItemCount}.");
+ if (item.Format != SecsItemFormat.List && item.Count > _options.MaxValueCount)
+ throw new InvalidOperationException($"The Item value count exceeds the configured maximum {_options.MaxValueCount}.");
+
+ AppendIndent(depth - 1);
+ _text.Append('<');
+ _text.Append(GetFormatName(item.Format));
+ _text.Append(" [");
+ _text.Append(item.Count.ToString(CultureInfo.InvariantCulture));
+ _text.Append(']');
+
+ if (item.Format == SecsItemFormat.List)
+ {
+ _text.Append('\n');
+ foreach (var child in item.Items)
+ WriteItem(child, depth + 1);
+ AppendIndent(depth - 1);
+ }
+ else if (item.Format == SecsItemFormat.Ascii || item.Count != 0)
+ {
+ _text.Append(' ');
+ WriteValues(item);
+ }
+
+ _text.Append(">\n");
+ EnsureTextLength();
+ }
+
+ private void WriteValues(SecsItem item)
+ {
+ switch (item.Format)
+ {
+ case SecsItemFormat.Binary:
+ case SecsItemFormat.Jis8:
+ WriteHexValues(item.GetValues());
+ break;
+ case SecsItemFormat.Boolean:
+ WriteBooleanValues(item.GetValues());
+ break;
+ case SecsItemFormat.Ascii:
+ WriteAscii(item.GetString());
+ break;
+ case SecsItemFormat.I8:
+ WriteIntegerValues(item.GetValues());
+ break;
+ case SecsItemFormat.I1:
+ WriteIntegerValues(item.GetValues());
+ break;
+ case SecsItemFormat.I2:
+ WriteIntegerValues(item.GetValues());
+ break;
+ case SecsItemFormat.I4:
+ WriteIntegerValues(item.GetValues());
+ break;
+ case SecsItemFormat.F8:
+ WriteFloatValues(item.GetValues());
+ break;
+ case SecsItemFormat.F4:
+ WriteFloatValues(item.GetValues());
+ break;
+ case SecsItemFormat.U8:
+ WriteIntegerValues(item.GetValues());
+ break;
+ case SecsItemFormat.U1:
+ WriteIntegerValues(item.GetValues());
+ break;
+ case SecsItemFormat.U2:
+ WriteIntegerValues(item.GetValues());
+ break;
+ case SecsItemFormat.U4:
+ WriteIntegerValues(item.GetValues());
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(item), item.Format, "Unknown SECS-II Item format.");
+ }
+ }
+
+ private void WriteAscii(string value)
+ {
+ _text.Append('\'');
+ foreach (var character in value)
+ {
+ switch (character)
+ {
+ case '\\':
+ _text.Append("\\\\");
+ break;
+ case '\'':
+ _text.Append("\\'");
+ break;
+ case '\r':
+ _text.Append("\\r");
+ break;
+ case '\n':
+ _text.Append("\\n");
+ break;
+ case '\t':
+ _text.Append("\\t");
+ break;
+ default:
+ if (character >= 0x20 && character <= 0x7E)
+ {
+ _text.Append(character);
+ }
+ else
+ {
+ _text.Append("\\x");
+ AppendHexByte((byte)character);
+ }
+ break;
+ }
+ }
+ _text.Append('\'');
+ }
+
+ private void WriteHexValues(ReadOnlySpan values)
+ {
+ for (var index = 0; index < values.Length; index++)
+ {
+ if (index != 0)
+ _text.Append(' ');
+ _text.Append("0x");
+ AppendHexByte(values[index]);
+ }
+ }
+
+ private void AppendHexByte(byte value)
+ {
+ const string hex = "0123456789ABCDEF";
+ _text.Append(hex[value >> 4]);
+ _text.Append(hex[value & 0x0F]);
+ }
+
+ private void WriteBooleanValues(ReadOnlySpan values)
+ {
+ for (var index = 0; index < values.Length; index++)
+ {
+ if (index != 0)
+ _text.Append(' ');
+ _text.Append(values[index] ? "True" : "False");
+ }
+ }
+
+ private void WriteIntegerValues(ReadOnlySpan values)
+ where T : struct, IFormattable
+ {
+ for (var index = 0; index < values.Length; index++)
+ {
+ if (index != 0)
+ _text.Append(' ');
+ _text.Append(values[index].ToString(null, CultureInfo.InvariantCulture));
+ }
+ }
+
+ private void WriteFloatValues(ReadOnlySpan values)
+ {
+ for (var index = 0; index < values.Length; index++)
+ {
+ if (index != 0)
+ _text.Append(' ');
+ _text.Append(values[index].ToString("R", CultureInfo.InvariantCulture));
+ }
+ }
+
+ private void WriteFloatValues(ReadOnlySpan values)
+ {
+ for (var index = 0; index < values.Length; index++)
+ {
+ if (index != 0)
+ _text.Append(' ');
+ _text.Append(values[index].ToString("R", CultureInfo.InvariantCulture));
+ }
+ }
+
+ private void AppendIndent(int level)
+ => _text.Append(' ', checked(level * _options.IndentSize));
+
+ private void EnsureTextLength()
+ {
+ if (_text.Length > _options.MaxTextLength)
+ throw new InvalidOperationException($"The SML text length exceeds the configured maximum {_options.MaxTextLength}.");
+ }
+
+ private static string GetFormatName(SecsItemFormat format)
+ => format switch
+ {
+ SecsItemFormat.List => "L",
+ SecsItemFormat.Binary => "B",
+ SecsItemFormat.Boolean => "Boolean",
+ SecsItemFormat.Ascii => "A",
+ SecsItemFormat.Jis8 => "J",
+ SecsItemFormat.I8 => "I8",
+ SecsItemFormat.I1 => "I1",
+ SecsItemFormat.I2 => "I2",
+ SecsItemFormat.I4 => "I4",
+ SecsItemFormat.F8 => "F8",
+ SecsItemFormat.F4 => "F4",
+ SecsItemFormat.U8 => "U8",
+ SecsItemFormat.U1 => "U1",
+ SecsItemFormat.U2 => "U2",
+ SecsItemFormat.U4 => "U4",
+ _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown SECS-II Item format."),
+ };
+}
diff --git a/src/SecsFrame.Sml/SmlParseException.cs b/src/SecsFrame.Sml/SmlParseException.cs
new file mode 100644
index 0000000..43544f0
--- /dev/null
+++ b/src/SecsFrame.Sml/SmlParseException.cs
@@ -0,0 +1,22 @@
+namespace SecsFrame.Sml;
+
+/// Reports a strict SML parsing failure with source coordinates.
+public sealed class SmlParseException : FormatException
+{
+ internal SmlParseException(string message, int offset, int line, int column)
+ : base($"{message} (line {line}, column {column}, offset {offset}).")
+ {
+ Offset = offset;
+ Line = line;
+ Column = column;
+ }
+
+ /// Gets the zero-based character offset.
+ public int Offset { get; }
+
+ /// Gets the one-based line number.
+ public int Line { get; }
+
+ /// Gets the one-based column number.
+ public int Column { get; }
+}
diff --git a/test/SecsFrame.Tests/SecsFrame.Tests.csproj b/test/SecsFrame.Tests/SecsFrame.Tests.csproj
index e4e6ce3..2a9e300 100644
--- a/test/SecsFrame.Tests/SecsFrame.Tests.csproj
+++ b/test/SecsFrame.Tests/SecsFrame.Tests.csproj
@@ -15,6 +15,7 @@
+
diff --git a/test/SecsFrame.Tests/SmlMessageCodecTests.cs b/test/SecsFrame.Tests/SmlMessageCodecTests.cs
new file mode 100644
index 0000000..07f6bf4
--- /dev/null
+++ b/test/SecsFrame.Tests/SmlMessageCodecTests.cs
@@ -0,0 +1,167 @@
+using SecsFrame.Sml;
+
+namespace SecsFrame.Tests;
+
+public sealed class SmlMessageCodecTests
+{
+ [Fact]
+ public void Encode_produces_a_deterministic_nested_vector()
+ {
+ var codec = new SmlMessageCodec(indentSize: 2);
+ var message = new SecsMessage(
+ 1,
+ 2,
+ replyExpected: true,
+ SecsItem.List(
+ SecsItem.Ascii("LOT"),
+ SecsItem.Binary(0x00, 0xFF),
+ SecsItem.List()));
+
+ var text = codec.Encode(message);
+
+ Assert.Equal(
+ "'S1F2'W\n" +
+ "\n" +
+ " \n" +
+ " \n" +
+ ">\n" +
+ ".\n",
+ text);
+ }
+
+ [Fact]
+ public void All_item_types_round_trip_without_culture_or_width_loss()
+ {
+ var root = SecsItem.List(
+ SecsItem.Binary(0x00, 0x80, 0xFF),
+ SecsItem.Boolean(true, false),
+ SecsItem.Ascii("A'\\\r\n\t\0Z"),
+ SecsItem.Jis8(0x00, 0x7F, 0x80, 0xFF),
+ SecsItem.I8(long.MinValue, 0, long.MaxValue),
+ SecsItem.I1(sbyte.MinValue, 0, sbyte.MaxValue),
+ SecsItem.I2(short.MinValue, 0, short.MaxValue),
+ SecsItem.I4(int.MinValue, 0, int.MaxValue),
+ SecsItem.F8(double.NegativeInfinity, -0d, double.Epsilon, double.NaN, double.PositiveInfinity),
+ SecsItem.F4(float.NegativeInfinity, -0f, float.Epsilon, float.NaN, float.PositiveInfinity),
+ SecsItem.U8(0, ulong.MaxValue),
+ SecsItem.U1(0, byte.MaxValue),
+ SecsItem.U2(0, ushort.MaxValue),
+ SecsItem.U4(0, uint.MaxValue),
+ SecsItem.List());
+ var expected = new SecsMessage(127, 255, true, root);
+ var codec = new SmlMessageCodec();
+
+ var encoded = codec.Encode(expected);
+ var actual = codec.Decode(encoded);
+
+ AssertMessageEqual(expected, actual);
+ Assert.Contains("", encoded, StringComparison.Ordinal);
+ Assert.Contains("", encoded, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void No_body_and_empty_list_remain_distinct()
+ {
+ var codec = new SmlMessageCodec();
+
+ var noBody = codec.Decode(codec.Encode(new SecsMessage(1, 0)));
+ var emptyList = codec.Decode(codec.Encode(new SecsMessage(1, 0, rootItem: SecsItem.List())));
+
+ Assert.Null(noBody.RootItem);
+ Assert.NotNull(emptyList.RootItem);
+ Assert.Equal(SecsItemFormat.List, emptyList.RootItem.Format);
+ Assert.Empty(emptyList.RootItem.Items);
+ }
+
+ [Fact]
+ public void Decode_accepts_insignificant_whitespace_but_canonicalizes_output()
+ {
+ const string input = " 'S6F11' W < L [ 2 ] < U4 [1] 42 > < Boolean [2] True False > > . ";
+ var codec = new SmlMessageCodec(indentSize: 2);
+
+ var message = codec.Decode(input);
+
+ Assert.Equal(
+ "'S6F11'W\n" +
+ "\n" +
+ " \n" +
+ ">\n" +
+ ".\n",
+ codec.Encode(message));
+ }
+
+ [Theory]
+ [InlineData("'S128F1'\n.\n")]
+ [InlineData("'S1F1'\n\n.\n")]
+ [InlineData("'S1F1'\n\n.\n")]
+ [InlineData("'S1F1'\n\n.\n")]
+ [InlineData("'S1F1'\n\n.\n")]
+ [InlineData("'S1F1'\n\n. trailing")]
+ public void Decode_rejects_non_canonical_or_malformed_vectors(string text)
+ {
+ var error = Assert.Throws(() => new SmlMessageCodec().Decode(text));
+
+ Assert.True(error.Line > 0);
+ Assert.True(error.Column > 0);
+ Assert.True(error.Offset >= 0);
+ }
+
+ [Fact]
+ public void Decode_reports_the_source_location()
+ {
+ const string text = "'S1F1'\n\n>\n.\n";
+
+ var error = Assert.Throws(() => new SmlMessageCodec().Decode(text));
+
+ Assert.Equal(3, error.Line);
+ Assert.Equal(13, error.Column);
+ Assert.Contains("U1", error.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Decode_enforces_depth_item_value_and_text_limits()
+ {
+ const string nested = "'S1F1'\n\n>\n.\n";
+ const string twoItems = "'S1F1'\n\n>\n.\n";
+ const string twoValues = "'S1F1'\n\n.\n";
+
+ Assert.Throws(() => new SmlMessageCodec(maxNestingDepth: 1).Decode(nested));
+ Assert.Throws(() => new SmlMessageCodec(maxItemCount: 1).Decode(twoItems));
+ Assert.Throws(() => new SmlMessageCodec(maxValueCount: 1).Decode(twoValues));
+ Assert.Throws(() => new SmlMessageCodec(maxTextLength: 4).Decode("12345"));
+ }
+
+ [Fact]
+ public void Encode_enforces_the_same_structural_limits()
+ {
+ var nested = new SecsMessage(1, 1, rootItem: SecsItem.List(SecsItem.List()));
+ var values = new SecsMessage(1, 1, rootItem: SecsItem.U1(1, 2));
+
+ Assert.Throws(() => new SmlMessageCodec(maxNestingDepth: 1).Encode(nested));
+ Assert.Throws(() => new SmlMessageCodec(maxItemCount: 1).Encode(nested));
+ Assert.Throws(() => new SmlMessageCodec(maxValueCount: 1).Encode(values));
+ Assert.Throws(() => new SmlMessageCodec(maxTextLength: 4).Encode(values));
+ }
+
+ [Fact]
+ public void Constructor_rejects_invalid_limits()
+ {
+ Assert.Throws(() => new SmlMessageCodec(indentSize: 0));
+ Assert.Throws(() => new SmlMessageCodec(indentSize: 17));
+ Assert.Throws(() => new SmlMessageCodec(maxNestingDepth: 0));
+ Assert.Throws(() => new SmlMessageCodec(maxItemCount: 0));
+ Assert.Throws(() => new SmlMessageCodec(maxValueCount: 0));
+ Assert.Throws(() => new SmlMessageCodec(maxTextLength: 0));
+ }
+
+ private static void AssertMessageEqual(SecsMessage expected, SecsMessage actual)
+ {
+ Assert.Equal(expected.Stream, actual.Stream);
+ Assert.Equal(expected.Function, actual.Function);
+ Assert.Equal(expected.ReplyExpected, actual.ReplyExpected);
+ Assert.Equal(expected.RootItem, actual.RootItem);
+ }
+}