simengine 0.2.2

A plugin-based simulation engine runtime and plugin API
<table>
  <tr>
    <td>

<img src="icon.ico" width="128"/>
    </td>
    <td>

# SimEngine

SimEngine is a plugin-based simulation runtime for composing independent simulations through typed instruments, instrument flows, and runtime state transitions.
    </td>
  </tr>
</table>

This repository is packaged as a single Rust crate named `simengine`. It includes:

- CLI/runtime
- Manifest, instrument, and flow validation
- Plugin ABI/API
- TCP instrument flow between simulations
- Engine and simulation state management


## Install

```bash
cargo install simengine
```

## Commands

Run a manifest:

```bash
simengine run config.json
```

Validate a manifest without running it:

```bash
simengine check config.json
```

When `framework.max_frames` is omitted, the engine runs indefinitely unless it enters a terminal state.

## Runtime States

SimEngine has fixed engine states:

```text
START
READY
RUNNING
FINISHED
ERROR
```

Each simulation has fixed internal states:

```text
_START
_READY
_RUNNING
_FINISHED
_ERROR
```

These states are built into the engine. They are not declared or customized in `config.json`.

The engine starts in `START`. Each simulation starts in `_START`. During `Simulation::create`, a plugin should report whether it initialized successfully:

```rust
ctx.set_state("_READY");
```

or:

```rust
ctx.set_state("_ERROR");
```

The engine evaluates `state_transitions` after simulations are loaded and during the run loop. `pre_step`, `step`, and `post_step` are called only while the engine state is `RUNNING`. `ERROR` and `FINISHED` are terminal runtime states.

## Minimum One-Machine Example

SimEngine loads three files from the same directory:

```text
config.json
instruments.json
instrument_flows.json
```

This example runs two simulations on one machine:

- `temperature-reader` produces `temperature_c`
- `fan-controller` consumes `temperature_c`

`config.json` declares the runtime, simulations, plugins, and state transitions:

```json
{
  "framework": {
    "fps": 60
  },
  "simulations": [
    {
      "name": "temperature-reader",
      "endpoint": "127.0.0.1:7001",
      "plugin": "temperature_reader.dll"
    },
    {
      "name": "fan-controller",
      "endpoint": "127.0.0.1:7002",
      "plugin": "fan_controller.dll"
    }
  ],
  "state_transitions": [
    {
      "when": {
        "all": [
          { "sim": "temperature-reader", "state": "_READY" },
          { "sim": "fan-controller", "state": "_READY" }
        ]
      },
      "engine": "RUNNING"
    },
    {
      "when": {
        "any": [
          { "sim": "temperature-reader", "state": "_ERROR" },
          { "sim": "fan-controller", "state": "_ERROR" }
        ]
      },
      "engine": "ERROR"
    }
  ]
}
```

`instruments.json` declares typed values once:

```json
[
  {
    "value": "temperature_c",
    "type": "float32"
  }
]
```

`instrument_flows.json` declares where instrument values move:

```json
[
  {
    "instrument": "temperature_c",
    "from": "127.0.0.1:7001",
    "to": "127.0.0.1:7002"
  }
]
```

Currently supported primitive types:

```text
float32
```

## State Transitions

`state_transitions` is a top-level manifest field. Each rule has:

- `when`: a condition using either `all` or `any`
- `engine`: the engine state to enter when the condition matches

Example:

```json
{
  "when": {
    "all": [
      { "sim": "producer", "state": "_READY" },
      { "sim": "consumer", "state": "_READY" }
    ]
  },
  "engine": "RUNNING"
}
```

Validation rejects:

- unknown simulation names in state transitions
- empty transition conditions
- transition conditions that use both `all` and `any`
- unknown engine or simulation states

State transitions are one-way orchestration rules:

```text
simulation states -> engine state
```

The engine uses its state to decide whether lifecycle methods should run. It does not normally force simulation states.

## Plugin API v2

SimEngine plugin ABI version is currently:

```rust
pub const SIMENGINE_API_VERSION: u32 = 2;
```

Plugins implement `Simulation` and are exported with `simengine_plugin!`.

Example simulation: XPlane DataRef Sender

```rust
use simengine::plugin_api::{Simulation, SimulationContext};
use simengine::simengine_plugin;

use std::net::{SocketAddr, UdpSocket};

const DREF_HEADER: &[u8; 5] = b"DREF\0";
const DATAREF_NAME_LEN: usize = 500;

const XPLANE_ADDR: &str = "127.0.0.1:49000";
const SENDER_BIND_ADDR: &str = "0.0.0.0:49006";

struct WritableDatarefSpec {
    input_name: &'static str,
    dataref: &'static str,
    min_value: f32,
    max_value: f32,
}

struct XplaneDatarefsSender {
    ctx: SimulationContext,
    socket: UdpSocket,
    xplane_addr: SocketAddr,
    has_entered_running: bool,
}

impl XplaneDatarefsSender {
    fn writable_datarefs() -> Vec<WritableDatarefSpec> {
        vec![
            WritableDatarefSpec {
                input_name: "yoke_pitch_ratio",
                dataref: "sim/cockpit2/controls/yoke_pitch_ratio",
                min_value: -1.0,
                max_value: 1.0,
            },
            WritableDatarefSpec {
                input_name: "yoke_roll_ratio",
                dataref: "sim/cockpit2/controls/yoke_roll_ratio",
                min_value: -1.0,
                max_value: 1.0,
            },
            WritableDatarefSpec {
                input_name: "throttle_ratio",
                dataref: "sim/cockpit2/engine/actuators/throttle_ratio_all",
                min_value: 0.0,
                max_value: 1.0,
            },
        ]
    }

    fn clamp(value: f32, min_value: f32, max_value: f32) -> f32 {
        value.max(min_value).min(max_value)
    }

    fn build_dref_packet(value: f32, dataref: &str) -> Vec<u8> {
        let mut packet = Vec::with_capacity(5 + 4 + DATAREF_NAME_LEN);

        packet.extend_from_slice(DREF_HEADER);
        packet.extend_from_slice(&value.to_le_bytes());

        let mut name = [0u8; DATAREF_NAME_LEN];
        let bytes = dataref.as_bytes();
        let len = bytes.len().min(DATAREF_NAME_LEN - 1);
        name[..len].copy_from_slice(&bytes[..len]);

        packet.extend_from_slice(&name);
        packet
    }

    fn send_dataref(&self, dataref: &str, value: f32) -> bool {
        let packet = Self::build_dref_packet(value, dataref);

        match self.socket.send_to(&packet, self.xplane_addr) {
            Ok(_) => true,
            Err(err) => {
                self.ctx.info(format!(
                    "failed to send DREF {} value={}: {}",
                    dataref, value, err
                ));
                false
            }
        }
    }
}

impl Simulation for XplaneDatarefsSender {
    fn create(ctx: SimulationContext, _config_json: &str) -> Self {
        let xplane_addr: SocketAddr = match XPLANE_ADDR.parse() {
            Ok(addr) => addr,
            Err(err) => {
                ctx.set_state("_ERROR");
                panic!("invalid hardcoded XPLANE_ADDR: {err}");
            }
        };

        let socket = match UdpSocket::bind(SENDER_BIND_ADDR) {
            Ok(socket) => socket,
            Err(err) => {
                ctx.set_state("_ERROR");
                panic!("failed to bind X-Plane DREF sender UDP socket: {err}");
            }
        };

        ctx.info(format!(
            "xplane-datarefs-sender created: bind={} xplane={}",
            SENDER_BIND_ADDR, XPLANE_ADDR
        ));

        ctx.set_state("_READY");

        Self {
            ctx,
            socket,
            xplane_addr,
            has_entered_running: false,
        }
    }

    fn step(&mut self, _dt_seconds: f64) {
        if !self.has_entered_running {
            self.ctx.set_state("_RUNNING");
            self.has_entered_running = true;
        }

        for spec in Self::writable_datarefs() {
            let Some(value) = self.ctx.get_input_f32(spec.input_name) else {
                continue;
            };

            let value = Self::clamp(value, spec.min_value, spec.max_value);

            if !self.send_dataref(spec.dataref, value) {
                self.ctx.set_state("_ERROR");
                return;
            }
        }
    }

    fn destroy(&mut self) {
        self.send_dataref("sim/cockpit2/controls/yoke_pitch_ratio", 0.0);
        self.send_dataref("sim/cockpit2/controls/yoke_roll_ratio", 0.0);
        self.send_dataref("sim/cockpit2/engine/actuators/throttle_ratio_all", 0.0);
        self.ctx.info("xplane-datarefs-sender destroyed");
    }
}

simengine_plugin!(XplaneDatarefsSender);
```

Available context helpers include:

```rust
ctx.info("message");
ctx.set_state("_READY");
ctx.set_output_f32("output_name", value);
ctx.get_input_f32("input_name");
```

## Instrument Flows

Instrument flows can target simulations in the same engine process or endpoints hosted by another `simengine` process.

Local flow:

```json
{
  "instrument": "temperature_c",
  "from": "127.0.0.1:7001",
  "to": "127.0.0.1:7002"
}
```

Remote flow:

```json
{
  "instrument": "temperature_c",
  "from": "127.0.0.1:7001",
  "to": "127.0.0.2:7001"
}
```

Flow sources must be local to `config.json`. Flow targets may be local or remote. The instrument must exist in `instruments.json`, and the same instrument name is used at both ends of the flow.