Files
secs-gem/docs/34_codec_and_sml.md
T
raphael cae98d9a7d docs: chapters 30–36 — the codebase (Part 3 complete)
Seven chapters walking the implementation top-to-bottom.

30 — Repository tour.  Top-level layout, directory by directory.
The eight built binaries.  The dependency graph from TCP socket
up through EquipmentDataModel.  CMake's role.  Test layout.

31 — Spec-as-data and codegen.  Why the design choice fits SECS/
GEM specifically.  The five YAML files: messages catalog,
control/PJ/CJ transition tables, equipment dictionary.  How
tools/gen_messages.py turns messages.yaml into typed C++ at build
time.  The --validate-config multi-error validator.  How to add a
new SVID / CEID / host command / state / message without C++.

32 — Stores and the data model.  What a store IS (records + API +
change handler + optional persistence).  Every store in the
codebase mapped to the SEMI standard it serves (table of 21).
EquipmentDataModel as plain composition + cross-store convenience
methods (vid_value, compose_reports_for).  The no-locks single-
threaded contract.  How to add a new store.

33 — Transport.  hsms::Connection read path (length+payload async
chain), write path (queue + one outstanding write), timer model
(5 steady_timers + per-request T3).  The asio executor / strand
model and why it's the right shape.  secsi::Protocol as the IO-
free FSM with Action / Event variants; secsi::TcpTransport as the
asio adapter.  Pattern repeats for E84 + GEM comm-state.

34 — Codec and SML.  The four files (170 + 30 + 52 + 32 lines of
header, 229 + 220 lines of impl).  Item variant storage layout
(11 alternatives, 16 formats, shared storage where E5 permits).
encode_into recursion; decode_at with bounds checks throwing
CodecError.  Message wrapper.  SML printer + try_parse_sml +
why SML round-trips Items but not necessarily bytes.

35 — State machines and dispatch.  gem::Router as a typed
(stream, function) dispatch table.  How an S2F41 round-trip walks
through parser → store dispatch → side-effect → CEID emission →
S6F11 build → spool-aware deliver.  The 11 FSMs all sharing the
same three-property shape (pure data table + pure FSM + observer
pattern).  CEID cascading from FSM transitions to wire bytes.

36 — Persistence, validation, metrics.  Which 7 stores have file
journals + why the others don't.  Per-record file pattern (atomic
rename, partial-write safe).  Schema versioning + multi-version
read.  Multi-error YAML validator (--validate-config) + cross-file
reference checks.  Prometheus registry + HTTP exporter + worked
metric patterns from the PVD example.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 20:23:05 +02:00

9.7 KiB

34 — Codec and SML

33 Transport | Back to index | Next: 35 State machines and dispatch

We covered the SECS-II encoding rules in chapter 10. This chapter is the implementation walk — the four files that make up secsgem::secs2, how the encoder/decoder are structured, why the variant-based Item works, and how the SML printer/parser fits in.

Four files, 733 lines total. The codec is the most-tested layer in the codebase.


The four files

include/secsgem/secs2/
├── item.hpp     (170 lines)  Item variant + Format enum + factories.
├── codec.hpp    ( 30 lines)  encode / decode declarations.
├── message.hpp  ( 52 lines)  Message wrapper (header fields + body Item).
└── sml.hpp      ( 32 lines)  to_sml / try_parse_sml declarations.

src/secs2/
├── codec.cpp    (229 lines)  encode_into / decode_at implementations.
└── sml.cpp      (220 lines)  SML printer + parser.

item.hpp and message.hpp are header-only. codec.cpp and sml.cpp carry the heavy lifting.


The Item variant

Already covered in chapter 10; quick recap of the storage:

// include/secsgem/secs2/item.hpp:85
class Item {
 public:
  using List = std::vector<Item>;
  using Storage = std::variant<
      List,                   // List
      std::string,            // ASCII, JIS-8
      std::vector<uint8_t>,   // Binary, Boolean, U1
      std::vector<int8_t>,    // I1
      std::vector<int16_t>,   // I2
      ...
      std::vector<float>,     // F4
      std::vector<double>>;   // F8

 private:
  Format format_;
  Storage data_;
};

Eleven variant alternatives serving 16 SECS-II formats — some formats share storage (Binary/Boolean/U1 all use std::vector<uint8_t>, ASCII/JIS-8 share std::string, U2/C2 share std::vector<uint16_t>). Disambiguation is via format_.

Factories

The intended way to build an Item is the named factories:

Item::list({Item::ascii("Hi"), Item::u4(42)});
Item::ascii("Hello, world");
Item::u4(std::vector<uint32_t>{1, 2, 3});
Item::u4(42);   // scalar convenience overload
Item::f4(1.0f);

Each takes ownership of the storage (or constructs from a scalar overload). No exceptions; no validity checks; trusts the caller.


encode_into — the recursive encoder

void encode_into(const Item& item, std::vector<uint8_t>& out);

src/secs2/codec.cpp:71. Two paths — List and not-List:

void encode_into(const Item& item, std::vector<uint8_t>& out) {
  const Format fmt = item.format();

  if (fmt == Format::List) {
    const auto& children = item.as_list();
    write_header(out, fmt, children.size());
    for (const auto& child : children) encode_into(child, out);
    return;
  }

  // Scalar/array: write_header(byte count), then bytes.
  switch (fmt) {
    case Format::ASCII: {
      const auto& s = item.as_ascii();
      write_header(out, fmt, s.size());
      out.insert(out.end(), s.begin(), s.end());
      return;
    }
    case Format::U4: {
      const auto& v = std::get<std::vector<uint32_t>>(item.storage());
      write_header(out, fmt, v.size() * 4);
      for (auto x : v) put_scalar_be(out, x);
      return;
    }
    // ... one case per format
  }
}

write_header picks the smallest length-byte-count and emits the format byte + length bytes. put_scalar_be is the templated big-endian writer using std::bit_cast for floats and std::make_unsigned_t for integers (chapter 10).

encode(item) is a thin wrapper:

std::vector<uint8_t> encode(const Item& item) {
  std::vector<uint8_t> out;
  encode_into(item, out);
  return out;
}

decode_at — the recursive decoder

Item decode_at(const uint8_t* data, std::size_t len, std::size_t& pos);

Mirror image:

Item decode_at(const uint8_t* data, std::size_t len, std::size_t& pos) {
  // 1. Format byte + length bytes.
  if (pos >= len) throw CodecError("truncated");
  const uint8_t  fb   = data[pos++];
  const Format   fmt  = static_cast<Format>(fb >> 2);
  const int      nlen = fb & 0x03;
  if (pos + nlen > len) throw CodecError("truncated length bytes");
  std::size_t length = 0;
  for (int i = 0; i < nlen; ++i) length = (length << 8) | data[pos++];

  // 2. List recursion.
  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));
  }

  // 3. Scalar/array: dispatch on element size + signedness/floatness.
  if (pos + length > len) throw CodecError("truncated body");
  const uint8_t* body = data + pos;
  pos += length;
  switch (fmt) {
    case Format::ASCII: return Item::ascii(std::string((const char*)body, length));
    case Format::U4:    return Item::u4(read_array<uint32_t>(body, length));
    // ... one case per format
  }
  throw CodecError("unknown format code");
}

Item decode(const std::vector<uint8_t>& bytes) {
  std::size_t pos = 0;
  Item it = decode_at(bytes.data(), bytes.size(), pos);
  if (pos != bytes.size()) throw CodecError("trailing bytes");
  return it;
}

The _at variant is useful when an outer protocol carries a SECS-II item embedded in a larger frame — the caller passes the buffer and a position, and gets back the item plus the new position.

Bounds checks throw CodecError at every step — a CodecError on the receive side closes the connection (chapter 11's S9F7 path).


The Message wrapper

// include/secsgem/secs2/message.hpp
class Message {
 public:
  uint8_t  stream() const;
  uint8_t  function() const;
  bool     w_bit() const;
  uint32_t system_bytes() const;
  const Item& body() const;
  std::vector<uint8_t> body_bytes() const;  // encoded body
};

A Message is just a small struct: stream + function + W-bit + system_bytes + body Item. No encoder lives here — encoding is done by secs2::encode(message.body()) when the transport layer serializes a frame. The Message exists so the Router can dispatch on (stream, function) without re-decoding bytes.


SML — the human-readable form

to_sml(item) walks the Item recursively and emits SML:

// src/secs2/sml.cpp — sketch
std::string to_sml(const Item& item) {
  switch (item.format()) {
    case Format::List: {
      std::string s = "<L[" + std::to_string(item.size()) + "]";
      for (const auto& child : item.as_list()) {
        s += ' ' + to_sml(child);
      }
      s += '>';
      return s;
    }
    case Format::ASCII: return "A \"" + escape(item.as_ascii()) + "\"";
    case Format::U4: {
      const auto& v = std::get<std::vector<uint32_t>>(item.storage());
      std::string s = "U4";
      if (v.size() > 1) s += "[" + std::to_string(v.size()) + "]";
      for (auto x : v) s += " " + std::to_string(x);
      return s;
    }
    // ... per format
  }
}

try_parse_sml(text) is the inverse — a hand-written recursive- descent parser that returns std::optional<Item>. Returns nullopt on any parse error (no exceptions; this is what libFuzzer feeds garbage into and expects it not to crash).

Tests: tests/test_sml.cpp (10 cases — every format round-trips through to_smltry_parse_sml → identical Item).

Why SML doesn't round-trip bytes

A subtle point: decode(encode(item)) round-trips exactly, but try_parse_sml(to_sml(item)) also round-trips the Item — except encoding the round-tripped Item may produce different bytes than the original. Why?

  • The original might use a 2-byte length encoding; the round-tripped Item is a fresh Item and the encoder will pick the smallest length encoding (1 byte).
  • SML doesn't preserve "which list-length encoding the encoder chose."

If you need bit-exact round-trip of bytes, use decode(encode). For semantic round-trip of values, use SML.


Testing — every layer in isolation

Layer Test file Cases Focus
Item factories tests/test_secs2.cpp 14 Construction, equality, format dispatch.
Codec tests/test_e5_kat.cpp 19 Known-answer tests — bit-exact bytes per SEMI E5 §9.
Codec tests/test_secs2.cpp (overlap) encode/decode round-trip + truncation rejection.
Identifier wildcards tests/test_identifier_wildcards.cpp 6 U1/U2/U4/U8 leniency for ID fields.
SML tests/test_sml.cpp 10 to_sml + try_parse_sml round-trip.
Catalog tests/test_messages.cpp 82 Every named SxFy builder + parser round-trip.
Random/structural tests/test_fuzz.cpp 8 Random bytes, truncation, oversize lengths, nested.
libFuzzer apps/fuzz_secs2_decode.cpp (CI) 200 k+ random inputs per minute, ASan + UBSan clean.
libFuzzer apps/fuzz_sml_parse.cpp (CI) 1.4 M+ random SML strings per minute, ASan + UBSan.

The codec alone has 139 test cases / 196+ assertions for E5 KAT. This is intentional: every other layer trusts the codec is correct. If it isn't, nothing above works.


Where to go next

You've now seen the codec and SML implementation in detail. Next chapter covers the dispatch layer that sits between the transport (which delivers raw Messages) and the stores (which hold state): gem::Router, the state-machine wiring, and the generated builder/parser glue from the message catalog.

Next: → 35 State machines and dispatch