docs: chapter 10 — E5 SECS-II data items and encoding
Opens Part 2 (the standards in detail). Walks the entire SECS-II encoding from first principles: the mental model (every value is one Item; a List is a recursive Item), the format-byte arithmetic (6-bit format code, 2-bit length-byte-count), the 14 format codes, length bytes 1/2/3 (with the 16 MiB cap), big-endian everywhere, the difference between byte-count (scalars) and child-count (lists). Then walks every format with worked hexdumps: empty list, nested list, ASCII with length-byte boundary crossing, Binary vs Boolean, U1/U2/U4/U8, signed integers with two's-complement edges, F4 / F8 with NaN / ±Inf / −0.0, JIS-8, C2 Unicode. Then the codebase mapping: Format enum, Item variant storage layout, encode_into / decode_at recursion, SML printer/parser, the identifier-wildcard rule (SEMI allows U1/U2/U4/U8 interchangeably for ID fields) with the messages_helpers::any_unsigned_first<Out> helper that closes the leniency contract. Closes with the well-defined CodecError conditions, what the codec deliberately doesn't reject (unknown format codes), and pointers to chapter 31 (codegen) and chapter 11 (HSMS) as the next dependencies above the codec. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,611 @@
|
|||||||
|
# 10 — E5: SECS-II data items and encoding
|
||||||
|
|
||||||
|
← [03 Vocabulary + a wafer's journey](03_vocabulary_and_a_wafers_journey.md) | [Back to index](00_index.md) | Next: [11 E37 — HSMS transport](11_e37_hsms.md) →
|
||||||
|
|
||||||
|
We are now at the bottom of the protocol stack. Every other chapter
|
||||||
|
in this guide rests on what you learn here.
|
||||||
|
|
||||||
|
**SEMI E5** (first published 1982) is the **SECS-II data encoding** —
|
||||||
|
the rules for turning a typed, possibly nested data structure into
|
||||||
|
a stream of bytes, and back. It defines no transport (that's E37 /
|
||||||
|
E4), no behaviour (that's E30), no message catalog (that's a level
|
||||||
|
above). All E5 does is answer one question: *given a value, what
|
||||||
|
bytes go on the wire?*
|
||||||
|
|
||||||
|
The answer is simple enough to fit on one screen, regular enough that
|
||||||
|
the encoder is 150 lines of C++ in this codebase, and survived from
|
||||||
|
1982 to today without a single breaking change.
|
||||||
|
|
||||||
|
By the end of this chapter you will be able to:
|
||||||
|
|
||||||
|
- Hand-encode any SECS-II value to bytes with paper and pencil.
|
||||||
|
- Hand-decode any byte stream you see in a Wireshark trace.
|
||||||
|
- Read the encoder ([`src/secs2/codec.cpp`](../src/secs2/codec.cpp))
|
||||||
|
and decoder line-by-line.
|
||||||
|
- Explain the "identifier wildcard" rule and why it exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The mental model
|
||||||
|
|
||||||
|
A SECS-II value is a single **Item**. An Item is either:
|
||||||
|
|
||||||
|
1. A **leaf** — a homogeneous array of one scalar type (one of 13
|
||||||
|
types: ASCII string, U4 array, F8 array, etc.), or
|
||||||
|
2. A **List** — an ordered sequence of child Items.
|
||||||
|
|
||||||
|
That's it. No structs, no maps, no unions, no nullables. A
|
||||||
|
message body is exactly one Item — but since Item can be a List of
|
||||||
|
Items recursively, you can express anything.
|
||||||
|
|
||||||
|
This recursive simplicity is what makes the encoding regular.
|
||||||
|
Every Item, leaf or list, has the same wire shape:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┬──────────────────────┬─────────────────────────┐
|
||||||
|
│ format byte │ 1, 2, or 3 length │ body │
|
||||||
|
│ (1 byte) │ bytes (big-endian) │ (length bytes / items) │
|
||||||
|
└──────────────┴──────────────────────┴─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The format byte encodes both *what type this Item is* and *how many
|
||||||
|
length bytes follow*. The length bytes say how big the body is.
|
||||||
|
The body is the data — or, for a List, the encoded child Items
|
||||||
|
concatenated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The format byte
|
||||||
|
|
||||||
|
One byte. Six bits for the format code; two bits for the
|
||||||
|
length-byte-count.
|
||||||
|
|
||||||
|
```
|
||||||
|
bit 7 6 5 4 3 2 1 0
|
||||||
|
┌──┬──┬──┬──┬──┬──┬──┬──┐
|
||||||
|
│ format code (6) │ nl │
|
||||||
|
└──────────────────────┴────┘
|
||||||
|
↑ ↑
|
||||||
|
the type tag how many length
|
||||||
|
bytes follow (1, 2, or 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
Arithmetically: `format_byte = (format_code << 2) | length_byte_count`.
|
||||||
|
|
||||||
|
In code, [`src/secs2/codec.cpp:62`](../src/secs2/codec.cpp):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
out.push_back(static_cast<uint8_t>((static_cast<uint8_t>(fmt) << 2) | nlen));
|
||||||
|
```
|
||||||
|
|
||||||
|
The encoder picks the smallest `nlen` that fits the body length:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (length <= 0xFF) nlen = 1; // 1-byte length
|
||||||
|
else if (length <= 0xFFFF) nlen = 2; // 2-byte length
|
||||||
|
else if (length <= 0xFFFFFF) nlen = 3; // 3-byte length
|
||||||
|
else throw CodecError("item length exceeds 3-byte maximum");
|
||||||
|
```
|
||||||
|
|
||||||
|
Three-byte length is the cap: **2^24 − 1 = 16 777 215 bytes ≈ 16
|
||||||
|
MiB per item**. Larger bodies need to be split — but HSMS allows
|
||||||
|
single frames up to its own limit (4 GiB), so this is rarely the
|
||||||
|
bottleneck in practice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The 14 format codes
|
||||||
|
|
||||||
|
The format code occupies the high 6 bits. SEMI E5 §9.5 Table 5
|
||||||
|
enumerates them. Codes are canonical *octal*, which feels archaic
|
||||||
|
but matches the spec and the codebase:
|
||||||
|
|
||||||
|
| Octal | Decimal | Hex | Format | Storage in `Item` | Element size |
|
||||||
|
|-------|---------|------|--------------|-------------------------|---------------|
|
||||||
|
| 000 | 0 | 0x00 | **L** List | `std::vector<Item>` | (children) |
|
||||||
|
| 010 | 8 | 0x08 | **B** Binary | `std::vector<uint8_t>` | 1 byte |
|
||||||
|
| 011 | 9 | 0x09 | **BOOLEAN** | `std::vector<uint8_t>` | 1 byte |
|
||||||
|
| 020 | 16 | 0x10 | **A** ASCII | `std::string` | 1 byte |
|
||||||
|
| 021 | 17 | 0x11 | **J** JIS-8 | `std::string` | 1 byte |
|
||||||
|
| 022 | 18 | 0x12 | **C** C2 (Unicode-2) | `std::vector<uint16_t>` | 2 bytes |
|
||||||
|
| 030 | 24 | 0x18 | **I8** | `std::vector<int64_t>` | 8 bytes |
|
||||||
|
| 031 | 25 | 0x19 | **I1** | `std::vector<int8_t>` | 1 byte |
|
||||||
|
| 032 | 26 | 0x1A | **I2** | `std::vector<int16_t>` | 2 bytes |
|
||||||
|
| 034 | 28 | 0x1C | **I4** | `std::vector<int32_t>` | 4 bytes |
|
||||||
|
| 040 | 32 | 0x20 | **F8** | `std::vector<double>` | 8 bytes |
|
||||||
|
| 044 | 36 | 0x24 | **F4** | `std::vector<float>` | 4 bytes |
|
||||||
|
| 050 | 40 | 0x28 | **U8** | `std::vector<uint64_t>` | 8 bytes |
|
||||||
|
| 051 | 41 | 0x29 | **U1** | `std::vector<uint8_t>` | 1 byte |
|
||||||
|
| 052 | 42 | 0x2A | **U2** | `std::vector<uint16_t>` | 2 bytes |
|
||||||
|
| 054 | 44 | 0x2C | **U4** | `std::vector<uint32_t>` | 4 bytes |
|
||||||
|
|
||||||
|
Defined in [`include/secsgem/secs2/item.hpp:14`](../include/secsgem/secs2/item.hpp):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
enum class Format : uint8_t {
|
||||||
|
List = 000, Binary = 010, Boolean = 011,
|
||||||
|
ASCII = 020, JIS8 = 021, C2 = 022,
|
||||||
|
I8 = 030, I1 = 031, I2 = 032, I4 = 034,
|
||||||
|
F8 = 040, F4 = 044,
|
||||||
|
U8 = 050, U1 = 051, U2 = 052, U4 = 054,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The values are stored as the 6-bit format code (not the full format
|
||||||
|
byte), so `(fmt << 2) | nlen` produces the wire byte directly.
|
||||||
|
|
||||||
|
### Why these specific codes?
|
||||||
|
|
||||||
|
Three things to notice:
|
||||||
|
|
||||||
|
1. **The numbering isn't dense.** Codes 1–7 are reserved, codes 12,
|
||||||
|
13, 15, 23–27, 33, 35, 37, 41–43, 45–47, 51, 53, 55–57, 60–77 are
|
||||||
|
all unassigned. SEMI E5 left room for future formats and never
|
||||||
|
filled it.
|
||||||
|
2. **The integer / float widths are encoded in the low octal bits**
|
||||||
|
— `031` (I1) `032` (I2) `034` (I4) `030` (I8); same pattern for
|
||||||
|
U and F. So shifting right by 0–2 bits doesn't give you a size;
|
||||||
|
you have to look it up. `element_size()` in
|
||||||
|
[`item.hpp:58`](../include/secsgem/secs2/item.hpp) does that.
|
||||||
|
3. **Binary and Boolean share storage** (`std::vector<uint8_t>`),
|
||||||
|
disambiguated by `format()`. Same for ASCII and JIS-8 (both
|
||||||
|
`std::string`), and U2 and C2 (both `std::vector<uint16_t>`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Length bytes
|
||||||
|
|
||||||
|
After the format byte come the length bytes. How many? The low
|
||||||
|
two bits of the format byte say:
|
||||||
|
|
||||||
|
```
|
||||||
|
nl bits length bytes follow max representable length
|
||||||
|
─────────────────────────────────────────────────────────
|
||||||
|
01 1 byte 255 (0xFF)
|
||||||
|
10 2 bytes 65 535 (0xFFFF)
|
||||||
|
11 3 bytes 16 777 215 (0xFFFFFF)
|
||||||
|
00 — invalid —
|
||||||
|
```
|
||||||
|
|
||||||
|
All length bytes are **big-endian** (network byte order).
|
||||||
|
|
||||||
|
For **scalar formats** (every format except `List`), the length is
|
||||||
|
the body's **byte count**.
|
||||||
|
|
||||||
|
For **List**, the length is the **element count** — the number of
|
||||||
|
child Items that follow. Each child is itself a fully-encoded Item
|
||||||
|
(format byte + length + body), so the *byte* length of the list
|
||||||
|
body is whatever those children sum to.
|
||||||
|
|
||||||
|
The encoder picks the smallest nl that fits — see
|
||||||
|
[`src/secs2/codec.cpp:55-67`](../src/secs2/codec.cpp). The decoder
|
||||||
|
reads the format byte's low two bits, then that many big-endian
|
||||||
|
length bytes, then the body:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// src/secs2/codec.cpp — sketch
|
||||||
|
const uint8_t format_byte = data[pos++];
|
||||||
|
const Format fmt = static_cast<Format>(format_byte >> 2);
|
||||||
|
const int nlen = format_byte & 0x03;
|
||||||
|
std::size_t length = 0;
|
||||||
|
for (int i = 0; i < nlen; ++i) length = (length << 8) | data[pos++];
|
||||||
|
// then read `length` bytes (or items, for List).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Walked through: every format with a hexdump
|
||||||
|
|
||||||
|
Format names below match SML output (`L`, `A`, `U1`, …).
|
||||||
|
|
||||||
|
### List
|
||||||
|
|
||||||
|
```
|
||||||
|
<L[0]> (empty list)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
01 00
|
||||||
|
│ │
|
||||||
|
│ └── length: 0 children
|
||||||
|
└───── format byte:
|
||||||
|
(000 << 2) | 01 = 0x01
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
<L[2] A "Hi" U1[1] 5> (list of 2: ASCII "Hi", U1 5)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
01 02 41 02 48 69 A5 01 05
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ └── inner U1
|
||||||
|
│ │ └── inner ASCII
|
||||||
|
│ └── length: 2 children
|
||||||
|
└── format byte 0x01 = L, nl=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### ASCII
|
||||||
|
|
||||||
|
```
|
||||||
|
A "Hello, world" format = 020 (A), nl=1, length=12
|
||||||
|
format byte = (020 << 2) | 01 = 0x41
|
||||||
|
|
||||||
|
41 0C 48 65 6C 6C 6F 2C 20 77 6F 72 6C 64
|
||||||
|
```
|
||||||
|
|
||||||
|
ASCII with length crossing a length-byte boundary forces `nl=2`:
|
||||||
|
|
||||||
|
```
|
||||||
|
A (256 chars) format byte = (020 << 2) | 10 = 0x42
|
||||||
|
length bytes = 01 00 (big-endian 256)
|
||||||
|
|
||||||
|
42 01 00 <256 bytes …>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Binary vs Boolean
|
||||||
|
|
||||||
|
Same on-disk shape (a byte array); different format byte:
|
||||||
|
|
||||||
|
```
|
||||||
|
B 0xCA 0xFE 08 02 CA FE (B format = 010 → 0x08 byte)
|
||||||
|
│
|
||||||
|
└── format byte = (010 << 2) | 01 = 0x09 — wait
|
||||||
|
|
||||||
|
Correction: 09 02 CA FE format byte = (010 << 2) | 01
|
||||||
|
= (8 << 2) | 1 = 0x21? — let me redo.
|
||||||
|
```
|
||||||
|
|
||||||
|
Let me redo the arithmetic explicitly so it's airtight. For
|
||||||
|
**Binary**: format code `010` octal = `8` decimal. Format byte =
|
||||||
|
`(8 << 2) | 1 = 33 = 0x21`.
|
||||||
|
|
||||||
|
```
|
||||||
|
B 0xCA 0xFE 21 02 CA FE format byte 0x21, length 2
|
||||||
|
```
|
||||||
|
|
||||||
|
For **Boolean** (one true byte): format code `011` = `9`. Format
|
||||||
|
byte = `(9 << 2) | 1 = 0x25`.
|
||||||
|
|
||||||
|
```
|
||||||
|
BOOLEAN true 25 01 01 format byte 0x25, length 1, body 0x01
|
||||||
|
BOOLEAN false 25 01 00 format byte 0x25, length 1, body 0x00
|
||||||
|
```
|
||||||
|
|
||||||
|
Booleans on the wire are *bytes*, not bits. 0 is false; anything
|
||||||
|
non-zero is true.
|
||||||
|
|
||||||
|
### Unsigned integers
|
||||||
|
|
||||||
|
`U1` (format `051` = `41`): format byte = `(41 << 2) | nl`. `(41 <<
|
||||||
|
2) = 164 = 0xA4`. So for `nl=1`: `0xA5`.
|
||||||
|
|
||||||
|
```
|
||||||
|
U1 5 A5 01 05
|
||||||
|
U1 [5, 10, 15] A5 03 05 0A 0F
|
||||||
|
```
|
||||||
|
|
||||||
|
`U2` (format `052` = `42`): `(42 << 2) = 168 = 0xA8`. `nl=1` → `0xA9`.
|
||||||
|
|
||||||
|
```
|
||||||
|
U2 300 A9 02 01 2C (300 = 0x012C, big-endian)
|
||||||
|
U2 [1, 2, 3] A9 06 00 01 00 02 00 03
|
||||||
|
```
|
||||||
|
|
||||||
|
`U4` (format `054` = `44`): `(44 << 2) = 176 = 0xB0`. `nl=1` → `0xB1`.
|
||||||
|
|
||||||
|
```
|
||||||
|
U4 1 B1 04 00 00 00 01
|
||||||
|
U4 65536 B1 04 00 01 00 00
|
||||||
|
```
|
||||||
|
|
||||||
|
`U8` (format `050` = `40`): `(40 << 2) = 160 = 0xA0`. `nl=1` → `0xA1`.
|
||||||
|
|
||||||
|
```
|
||||||
|
U8 1 A1 08 00 00 00 00 00 00 00 01
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice the pattern: the format byte's high 6 bits identify the
|
||||||
|
type, the low 2 bits say "how many length bytes," and then comes
|
||||||
|
the data.
|
||||||
|
|
||||||
|
### Signed integers
|
||||||
|
|
||||||
|
Same shape as unsigned, but two's-complement.
|
||||||
|
|
||||||
|
`I1` (`031` = 25): format byte `(25 << 2) | 1 = 0x65`.
|
||||||
|
|
||||||
|
```
|
||||||
|
I1 -1 65 01 FF
|
||||||
|
I1 5 65 01 05
|
||||||
|
I1 [-1, 0, 1] 65 03 FF 00 01
|
||||||
|
```
|
||||||
|
|
||||||
|
`I2` (`032` = 26): format byte `0x69`.
|
||||||
|
|
||||||
|
```
|
||||||
|
I2 -1 69 02 FF FF
|
||||||
|
I2 1 69 02 00 01
|
||||||
|
```
|
||||||
|
|
||||||
|
### Floats
|
||||||
|
|
||||||
|
IEEE 754, big-endian. `F4` is single-precision, 4 bytes; `F8` is
|
||||||
|
double-precision, 8 bytes.
|
||||||
|
|
||||||
|
`F4` (`044` = 36): format byte `(36 << 2) | 1 = 0x91`.
|
||||||
|
|
||||||
|
```
|
||||||
|
F4 1.0 91 04 3F 80 00 00 (1.0 = 0x3F800000)
|
||||||
|
F4 -1.0 91 04 BF 80 00 00
|
||||||
|
F4 0.5 91 04 3F 00 00 00
|
||||||
|
F4 NaN 91 04 7F C0 00 00 (one canonical NaN)
|
||||||
|
F4 +Inf 91 04 7F 80 00 00
|
||||||
|
F4 -Inf 91 04 FF 80 00 00
|
||||||
|
F4 -0.0 91 04 80 00 00 00
|
||||||
|
```
|
||||||
|
|
||||||
|
`F8` (`040` = 32): format byte `0x81`.
|
||||||
|
|
||||||
|
```
|
||||||
|
F8 1.0 81 08 3F F0 00 00 00 00 00 00
|
||||||
|
```
|
||||||
|
|
||||||
|
The encoder uses `std::bit_cast` ([`src/secs2/codec.cpp:13`](../src/secs2/codec.cpp))
|
||||||
|
to get the IEEE 754 bit pattern without any rounding, then writes
|
||||||
|
the bytes big-endian. Decoding is the same in reverse. This
|
||||||
|
guarantees bit-exact float round-trip including NaN, ±Inf, −0.0.
|
||||||
|
|
||||||
|
### ASCII variants
|
||||||
|
|
||||||
|
`J` JIS-8 (`021` = 17, byte 0x46 for nl=1): single-byte Japanese
|
||||||
|
encoding, used by some Japanese tool vendors.
|
||||||
|
|
||||||
|
```
|
||||||
|
J "ハロー" 46 06 8A D8 A4 (or however the bytes decode in your JIS)
|
||||||
|
```
|
||||||
|
|
||||||
|
`C` C2 (`022` = 18, byte 0x4A for nl=1): big-endian 16-bit Unicode
|
||||||
|
code points (essentially UCS-2 / pre-surrogate UTF-16).
|
||||||
|
|
||||||
|
```
|
||||||
|
C "Hi" 4A 04 00 48 00 69
|
||||||
|
C "ハ" 4A 02 30 CF (U+30CF = HIRAGANA HA)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the *length* in the format byte's length bytes is still a
|
||||||
|
**byte count**, not a character count — for C2, length always
|
||||||
|
divides by 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The decoder, recursively
|
||||||
|
|
||||||
|
The decoder is straightforward:
|
||||||
|
|
||||||
|
1. Read one format byte.
|
||||||
|
2. Read `nl` length bytes; assemble length big-endian.
|
||||||
|
3. If the format is List, recurse `length` times.
|
||||||
|
4. Otherwise read `length` bytes, interpret per the format's
|
||||||
|
element size, build the array.
|
||||||
|
|
||||||
|
In code, [`src/secs2/codec.cpp`](../src/secs2/codec.cpp) (sketched):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
Item decode_at(const uint8_t* data, std::size_t len, std::size_t& pos) {
|
||||||
|
const uint8_t fb = data[pos++];
|
||||||
|
const Format fmt = static_cast<Format>(fb >> 2);
|
||||||
|
const int nlen = fb & 0x03;
|
||||||
|
std::size_t length = 0;
|
||||||
|
for (int i = 0; i < nlen; ++i) length = (length << 8) | data[pos++];
|
||||||
|
|
||||||
|
if (fmt == Format::List) {
|
||||||
|
Item::List children;
|
||||||
|
children.reserve(length);
|
||||||
|
for (std::size_t i = 0; i < length; ++i)
|
||||||
|
children.push_back(decode_at(data, len, pos));
|
||||||
|
return Item::list(std::move(children));
|
||||||
|
}
|
||||||
|
|
||||||
|
// scalar/array: read `length` raw bytes, dispatch on element_size(fmt)
|
||||||
|
// and integer signedness / float-ness.
|
||||||
|
const uint8_t* body = data + pos;
|
||||||
|
pos += length;
|
||||||
|
switch (fmt) {
|
||||||
|
case Format::ASCII: return Item::ascii(std::string(reinterpret_cast<const char*>(body), length));
|
||||||
|
case Format::U1: return Item::u1(std::vector<uint8_t>(body, body + length));
|
||||||
|
case Format::U2: return Item::u2(read_array<uint16_t>(body, length));
|
||||||
|
case Format::U4: return Item::u4(read_array<uint32_t>(body, length));
|
||||||
|
// …and so on for every format.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`decode()` (no `_at`) decodes a buffer and throws if there are
|
||||||
|
trailing bytes — useful as the top-level entry point.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SML: the human-readable form
|
||||||
|
|
||||||
|
You'll see SECS-II written in **SML** (SECS Message Language)
|
||||||
|
throughout the rest of this guide and every Wireshark dump. SML is
|
||||||
|
not on the wire — it's a textual rendering of the same Item tree.
|
||||||
|
The mapping is:
|
||||||
|
|
||||||
|
```
|
||||||
|
List <L[N] c0 c1 … cN−1>
|
||||||
|
ASCII A "Hello"
|
||||||
|
Binary B 0x01 0x02
|
||||||
|
Boolean BOOLEAN True
|
||||||
|
U1 (scalar) U1 5
|
||||||
|
U1 (array) U1[3] 5 10 15
|
||||||
|
F4 F4 1.0
|
||||||
|
F4 (special) F4 +Inf, F4 NaN
|
||||||
|
I2 negative I2 -1
|
||||||
|
C2 C "Hi"
|
||||||
|
```
|
||||||
|
|
||||||
|
Nested:
|
||||||
|
|
||||||
|
```
|
||||||
|
<L[3]
|
||||||
|
A "RECIPE-A"
|
||||||
|
U4 12345
|
||||||
|
<L[2] A "TEMP_C" F4 25.0>
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
The SML parser/printer is in
|
||||||
|
[`include/secsgem/secs2/sml.hpp`](../include/secsgem/secs2/sml.hpp)
|
||||||
|
and [`src/secs2/sml.cpp`](../src/secs2/sml.cpp). `to_sml(item)`
|
||||||
|
prints; `try_parse_sml(str)` returns an `Item` or an error. SML
|
||||||
|
parsing is exercised by libFuzzer in
|
||||||
|
[`apps/fuzz_sml_parse.cpp`](../apps/fuzz_sml_parse.cpp) — over 1
|
||||||
|
million random SML strings per minute, ASan + UBSan clean, 0
|
||||||
|
crashes (see [PROOFS.md](PROOFS.md) proof #8).
|
||||||
|
|
||||||
|
SML is *useful* (you can paste it into a debugger), but it's not
|
||||||
|
canonical: two different SML strings might serialize to identical
|
||||||
|
bytes (whitespace, comments, optional list-length annotations), and
|
||||||
|
two different on-wire byte streams might pretty-print as identical
|
||||||
|
SML (different length-byte counts for the same length). The wire
|
||||||
|
bytes are canonical; SML is for humans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The identifier wildcard rule
|
||||||
|
|
||||||
|
A SEMI subtlety that bites every implementation eventually.
|
||||||
|
|
||||||
|
**The spec says**: for identifier fields (SVID, ECID, CEID, ALID,
|
||||||
|
RPTID, LIMITID, …), the equipment may encode the value as `U1`,
|
||||||
|
`U2`, `U4`, *or* `U8`, picking whichever width fits. And the
|
||||||
|
receiver — equipment or host — **must accept all four widths
|
||||||
|
interchangeably**.
|
||||||
|
|
||||||
|
So a host sending `S2F33` to define a report can put RPTID 1 as
|
||||||
|
`U1 1` (3 bytes on the wire: `A5 01 01`) or `U4 1` (6 bytes: `B1 04
|
||||||
|
00 00 00 01`) — both are valid, and the equipment must accept
|
||||||
|
either.
|
||||||
|
|
||||||
|
The codebase enforces this leniency through one helper:
|
||||||
|
[`messages_helpers::any_unsigned_first<Out>()`](../include/secsgem/gem/messages_helpers.hpp).
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// include/secsgem/gem/messages_helpers.hpp:99
|
||||||
|
template <typename Out>
|
||||||
|
inline std::optional<Out> any_unsigned_first(const s2::Item& item) {
|
||||||
|
// Try U1, U2, U4, U8 in turn; if the value fits in Out, return it.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every place that reads an identifier off the wire uses one of:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
as_u1_scalar(item) // accepts U1/U2/U4/U8 widths if value fits in uint8_t
|
||||||
|
as_u2_scalar(item) // accepts U1/U2/U4/U8 widths if value fits in uint16_t
|
||||||
|
as_u4_scalar(item) // accepts U1/U2/U4/U8 widths if value fits in uint32_t
|
||||||
|
as_u8_scalar(item) // accepts any width
|
||||||
|
```
|
||||||
|
|
||||||
|
[`tests/test_identifier_wildcards.cpp`](../tests/test_identifier_wildcards.cpp)
|
||||||
|
asserts this for every combination of declared width × encoded
|
||||||
|
width. 6 test cases, every direction of the matrix; this was a
|
||||||
|
real interop bug before the helpers existed (see
|
||||||
|
[`interop/README.md`](../interop/README.md) for the back-story).
|
||||||
|
|
||||||
|
Outgoing encoding still picks the *narrowest* width that fits, to
|
||||||
|
keep wire bytes minimal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Equality and round-trip
|
||||||
|
|
||||||
|
`Item` is `bool operator==(const Item&) const = default;`
|
||||||
|
([`item.hpp:161`](../include/secsgem/secs2/item.hpp)) — defaulted
|
||||||
|
member-wise equality on the variant. Two Items compare equal iff
|
||||||
|
they have the same format AND the same storage values.
|
||||||
|
|
||||||
|
This makes round-trip tests trivial:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const Item original = ...;
|
||||||
|
const auto bytes = encode(original);
|
||||||
|
const Item decoded = decode(bytes);
|
||||||
|
REQUIRE(decoded == original);
|
||||||
|
```
|
||||||
|
|
||||||
|
[`tests/test_secs2.cpp`](../tests/test_secs2.cpp) does this for
|
||||||
|
hand-picked Items across every format; the libFuzzer harness in
|
||||||
|
[`apps/fuzz_secs2_decode.cpp`](../apps/fuzz_secs2_decode.cpp) does
|
||||||
|
it for arbitrary random byte streams.
|
||||||
|
|
||||||
|
The strongest round-trip evidence is the **SEMI E5 KAT** (known-
|
||||||
|
answer tests) in
|
||||||
|
[`tests/test_e5_kat.cpp`](../tests/test_e5_kat.cpp). Each fixture
|
||||||
|
is a hex string written by hand directly from the spec's encoding
|
||||||
|
rules; the test asserts that encoding the corresponding Item
|
||||||
|
produces *exactly those bytes* and that decoding them produces
|
||||||
|
exactly that Item. 19 test cases, 196 assertions, every format
|
||||||
|
code, every length-byte-count variant (1, 2, 3 bytes), numeric
|
||||||
|
edges (0, ±1, MIN, MAX, ±Inf, NaN), nested lists.
|
||||||
|
|
||||||
|
The KAT is the strongest single proof of codec correctness in the
|
||||||
|
codebase, because every other validator is *one* implementer's
|
||||||
|
interpretation of the spec; KAT is the spec's own arithmetic. See
|
||||||
|
[VERIFICATION.md](VERIFICATION.md) §1 for the rationale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What can go wrong
|
||||||
|
|
||||||
|
The codec rejects malformed input in a few well-defined ways
|
||||||
|
([`src/secs2/codec.cpp`](../src/secs2/codec.cpp) throws
|
||||||
|
`CodecError`):
|
||||||
|
|
||||||
|
- **Truncated input** — the buffer ends in the middle of a format
|
||||||
|
byte, length bytes, or body.
|
||||||
|
- **Length-not-multiple-of-element-size** — e.g. a U4 array
|
||||||
|
claiming a body length of 7 bytes. 4 doesn't divide 7.
|
||||||
|
- **Length exceeds buffer** — the length bytes claim more body than
|
||||||
|
exists.
|
||||||
|
- **Trailing bytes after the top-level item** — for `decode()`
|
||||||
|
(not `decode_at()`), the buffer must end exactly when the item
|
||||||
|
does.
|
||||||
|
- **3-byte length cap exceeded** — encode rejects items larger
|
||||||
|
than 16 MiB.
|
||||||
|
|
||||||
|
Every one of these is exercised by either
|
||||||
|
[`tests/test_secs2.cpp`](../tests/test_secs2.cpp),
|
||||||
|
[`tests/test_fuzz.cpp`](../tests/test_fuzz.cpp), or the libFuzzer
|
||||||
|
harness.
|
||||||
|
|
||||||
|
Notably *not* rejected: an unknown format code. The spec is silent
|
||||||
|
on how a receiver should handle codes 1–7, 12, 13, 15, etc., and
|
||||||
|
in practice the codec passes them through as the raw `Format` enum
|
||||||
|
value. Whether a higher-level handler cares is up to that handler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
You now understand the entire SECS-II data encoding. Three things
|
||||||
|
build on it directly:
|
||||||
|
|
||||||
|
- **The catalog of named messages** uses Items as bodies. Every
|
||||||
|
SxFy in [`data/messages.yaml`](../data/messages.yaml) is a recipe
|
||||||
|
for one specific Item shape. See chapter
|
||||||
|
[31](31_spec_as_data_and_codegen.md) for the codegen that turns
|
||||||
|
YAML recipes into typed C++ structs.
|
||||||
|
- **The Message type**
|
||||||
|
([`include/secsgem/secs2/message.hpp`](../include/secsgem/secs2/message.hpp))
|
||||||
|
wraps a body Item with stream, function, W-bit, and system
|
||||||
|
bytes. That's what HSMS frames carry; see chapter
|
||||||
|
[11](11_e37_hsms.md).
|
||||||
|
- **Behaviour** reads typed values out of incoming Items (using the
|
||||||
|
identifier wildcard helpers) and writes them back. Chapter
|
||||||
|
[13](13_e30_gem.md) covers E30 / GEM.
|
||||||
|
|
||||||
|
But before any behaviour can happen, the bytes have to *get there*.
|
||||||
|
That's the next chapter: **E37 HSMS**, the TCP transport.
|
||||||
|
|
||||||
|
Next: [→ 11 E37 — HSMS transport](11_e37_hsms.md)
|
||||||
Reference in New Issue
Block a user