> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crucihil.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom protocols

> Simulate proprietary protocols with a declarative wire-format YAML — no codec code, full sim/expect/fault/verify support

Proprietary protocols get the same sim/expect/record/fault/verify machinery as CAN — for most protocols **without writing a single line of codec code**. The protocol grammar is data, not code: a declarative wire-format YAML (endianness, integer/float fields, magic bytes, length fields, CRCs, scale/offset) interpreted at load time by the built-in `DeclarativeCodec`. CruciHiL never generates codec code from your spec — it interprets the spec directly. **Interpret, don't codegen.**

## The codec × transport matrix

A custom interface is a *(codec, transport)* pair:

* The **codec** defines what bytes mean — implemented against the `SignalCodec` ABC (`definitions()`, `encode()`, `decode()`). Built-ins: `DeclarativeCodec` (interprets a wire-format YAML) and `DBCCodec` (cantools, for DBC-described payloads on non-CAN links).
* The **transport** defines how bytes move — datagram, stream, or transactional.

Any codec composes with any compatible transport. One `CustomBusEngine` is created per `[rig.custom.<iface>]` section: it owns the codec, the transport, its own signal store, and a scheduler with the same absolute-time cyclic-transmission semantics and mutation hooks as the CAN engine.

## The three transport shapes

| Shape         | Examples          | Contract                                                                                                  |
| ------------- | ----------------- | --------------------------------------------------------------------------------------------------------- |
| Datagram      | CAN, UDP, ISO-TP  | Message boundaries preserved — one `send()` = one delivered datagram                                      |
| Stream        | UART, TCP, RS-485 | No boundaries — a framer stage (magic scan + length + checksum) delimits frames and resyncs after garbage |
| Transactional | SPI, I2C          | Master/slave register semantics — the rig emulates a peripheral register map the DUT reads and writes     |

For stream transports, the `MagicLengthFramer` scans for the spec's magic bytes, reads the length field, verifies the checksum, and emits complete frames. Line noise or a torn frame is skipped byte-by-byte until the next magic — the framer never wedges. A stream interface therefore requires a declarative spec with `framing.kind: magic_length`.

### Virtual twins and fault hooks

Every transport shape ships with a virtual twin — this is a hard rule, so virtual-first development and mutation verification work on every bench bus shape:

| Backend name       | Shape                                 |
| ------------------ | ------------------------------------- |
| `virtual_datagram` | Datagram (loopback)                   |
| `virtual_stream`   | Stream (loopback byte pipe)           |
| `virtual_spi`      | Transactional (emulated register map) |
| `virtual_i2c`      | Transactional (emulated register map) |

Datagram and stream twins implement the standard fault-hook contract — `block`, `corrupt`, and `delay` (with `unblock` / `clear_corrupt` / `clear_delay`) — selected by a bytes prefix. The transactional twin supports `block`/`unblock` as a bus-level fault (every transaction errors while blocked). These hooks are what fault injection and mutation verification use, so [`crucihil verify`](/ai-features/overview) can prove your tests catch regressions on custom buses too.

## The wire-format YAML

A real spec (from the CruciHiL test suite — motor telemetry over a datagram link):

```yaml theme={null}
protocol: motor_telemetry
version: 1
endianness: little

framing:
  kind: magic_length          # magic bytes, then a length field, then payload
  magic: [0xAA, 0x55]
  length_field: { size: 1, includes_header: false }
  checksum: { kind: crc8, covers: payload }

messages:
  - name: MotorStatus
    id: 0x10
    cycle_time: 20            # ms
    fields:
      - { name: RPM,      type: uint16, scale: 0.25, offset: 0,   min: 0,    max: 16000, unit: rpm }
      - { name: Current,  type: int16,  scale: 0.01, offset: 0,   min: -320, max: 320,   unit: A }
      - { name: TempC,    type: uint8,  scale: 1,    offset: -40, min: -40,  max: 215,   unit: degC }
      - { name: Fault,    type: uint8,  enum: { 0: NONE, 1: OVERCURRENT, 2: OVERTEMP, 3: STALL } }

  - name: MotorCommand
    id: 0x20
    cycle_time: 10
    fields:
      - { name: TorqueRequest, type: int16, scale: 0.1, offset: 0, min: -3000, max: 3000, unit: Nm }
      - { name: Enable,        type: uint8, min: 0, max: 1 }
```

The spec is validated with Pydantic at load time (Rule R9) — a spec that cannot describe a real wire format never reaches a codec.

### Top-level fields

<ParamField path="protocol" type="string" required>
  Protocol name.
</ParamField>

<ParamField path="version" type="integer" default="1">
  Spec version.
</ParamField>

<ParamField path="endianness" type="string" default="&#x22;little&#x22;">
  Byte order for all fields, length fields, and checksums. `"little"` or `"big"`.
</ParamField>

<ParamField path="framing" type="object">
  Frame delimiting. `kind` is `"none"` (default — for datagram transports where boundaries are preserved) or `"magic_length"` (required for stream transports). For `magic_length`: `magic` is a list of byte values (at least one), `length_field` takes `size` (`1` or `2`) and `includes_header` (boolean), and `checksum` takes `kind` (`crc8`, `crc16`, `crc32`, `sum8`, or `"none"`) and `covers` (`"payload"` or `"frame"`).
</ParamField>

<ParamField path="messages" type="list" required>
  At least one message. Message names and ids must be unique across the spec.
</ParamField>

### Message fields

<ParamField path="name" type="string" required>
  Message name — the first half of the `"MessageName.SignalName"` signal identifier.
</ParamField>

<ParamField path="id" type="integer" required>
  Message id used for decode dispatch.
</ParamField>

<ParamField path="cycle_time" type="integer" default="0">
  Cyclic transmission period in milliseconds when the message is started with `rig.bus.start()`.
</ParamField>

<ParamField path="fields" type="list" required>
  At least one field per message. Each field accepts `name`, `type` (one of `uint8`, `int8`, `uint16`, `int16`, `uint32`, `int32`, `float32`, `float64`), `scale` (non-zero, default `1.0`), `offset` (default `0.0`), `min`, `max`, `unit`, `initial` (default `0.0`), `enum` (raw value → label map), and `transform`.
</ParamField>

### The `transform` escape hatch

One weird field — a lookup table, a bit-packed hack — doesn't force you to abandon the declarative spec. A field can name a pair of Python hooks (`'pkg.module:function'` form), where `decode` maps raw wire value → physical and `encode` is the inverse:

```yaml theme={null}
- name: OddField
  type: uint16
  transform: { encode: "myorg.hooks:to_wire", decode: "myorg.hooks:from_wire" }
```

Hooks are resolved via `importlib` when the spec loads, so a missing module fails at startup, never mid-test.

## `[rig.custom.<iface>]` — TOML configuration

```toml theme={null}
[rig.custom.telemetry]
backend     = "virtual_datagram"       # transport: registry name or "module:Class"
definitions = "defs/telemetry.yaml"    # declarative wire-format spec (or a .dbc)
# codec = "myorg.codecs:MyCodec"       # Tier 3 escape hatch — rarely needed
```

<ParamField path="backend" type="string" required>
  The transport. Built-in names: `virtual_datagram`, `virtual_stream`, `virtual_spi`, `virtual_i2c` — or a dotted module path (`"module.path:ClassName"`) to your own class implementing `DatagramTransport` or `StreamTransport`.
</ParamField>

<ParamField path="definitions" type="string" required>
  Path to the protocol grammar — a declarative wire-format YAML, or a `.dbc` file for the built-in DBC codec. Relative to the directory containing the TOML file.
</ParamField>

<ParamField path="codec" type="string" default="&#x22;declarative&#x22;">
  Codec selection: `"declarative"` (the built-in interpreter; automatically uses the DBC codec when `definitions` ends in `.dbc`), `"dbc"`, or a `"module:Class"` path to a hand-written `SignalCodec` (Tier 3).
</ParamField>

<ParamField path="kind" type="string" default="&#x22;datagram&#x22;">
  Transport shape: `"datagram"` or `"stream"`. Stream interfaces require a declarative spec with `framing.kind: magic_length` — the rig refuses to start otherwise, because a stream transport cannot delimit frames without one.
</ParamField>

<ParamField path="options" type="table" default="{}">
  Passed as keyword arguments to the transport's `connect()` — connection parameters belong in TOML, never in test code (Rule R1).
</ParamField>

## Writing tests — `rig.bus`

Tests use `rig.bus` with semantics identical to `rig.can`. Signals are `"MessageName.SignalName"`, and the interface is resolved from the signal name, so tests stay hardware-free (Rule R1) — the same test runs whether the bytes ride UDP, UART, or a proprietary link.

```python theme={null}
async def test_motor_reports_speed(rig: Rig):
    await rig.bus.set("MotorStatus.RPM", 1500.0)   # takes effect on the next scheduled frame
    rig.bus.start("MotorStatus")                    # begin cyclic transmission

    result = await rig.bus.expect(
        signal="MotorStatus.RPM",
        condition=lambda v: v > 1000,
        timeout=1.0,
    )
    assert result.passed, result.fail_msg

    rig.bus.stop("MotorStatus")
```

The namespace also supports `send(message, fields)` for one-shot transmission and `interfaces()` to list configured custom interfaces. `expect()` never raises on condition failure — it returns an `ExpectResult`; assert `result.passed`.

## The round-trip verification harness

A spec is proven, never trusted. At `rig.connect()`, every custom-interface codec must pass the round-trip verification harness before it loads: `decode(encode(x)) ≈ x` is checked across each signal's range (min, max, midpoint, plus random samples snapped to the field's quantization grid), the decoded field set must match the spec, and a second round trip must be an exact fixed point. Optional sample captures are replayed through `decode()` as well.

A codec that fails raises `ConfigurationError` — the rig never starts, and any test run against it is marked `blocked`, not `fail`. A lying spec can never silently corrupt test results.

## AI import — `crucihil author --protocol`

You usually already have the protocol written down somewhere — a C packet-struct header, a protobuf file, or a prose document. Import it:

```bash theme={null}
crucihil author protocol_header.h --protocol
```

The AI writes the declarative wire-format YAML — never code — and the result must pass the round-trip verification harness before any rig loads it.

## Escape hatches

When the declarative spec can't express your protocol:

* **Tier 3 codec** — set `codec = "myorg.codecs:MyCodec"` to a class implementing the `SignalCodec` ABC (`definitions()`, `encode()`, `decode()`). It is still gated by the verification harness.
* **Custom transport** — set `backend = "module.path:ClassName"` to a class implementing the datagram, stream, or transactional transport ABC. Resolved via `importlib` at connect time.
* **Scaffold stubs** — generate a starting point for any of these:

```bash theme={null}
crucihil scaffold --adapter codec         --name MyTelemetry     # SignalCodec (Tier 3)
crucihil scaffold --adapter datagram      --name MyRadioLink     # datagram transport
crucihil scaffold --adapter stream        --name MyUARTBridge    # stream transport + framer
crucihil scaffold --adapter transactional --name MySPIPeripheral # SPI/I2C register map
```

## See also

* [Rig Configuration](/rig-config/overview) — the full rig TOML reference
* [Rig Backends](/rig-config/backends) — built-in backends and the module-path extension point
* [`crucihil scaffold`](/cli/scaffold) — generate adapter stubs
* [Python test API](/test-authoring/python-api) — the full `rig` fixture surface
