simengine 0.2.3

A plugin-based simulation engine runtime and plugin API
docs.rs failed to build simengine-0.2.3
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
Visit the last successful build: simengine-0.2.7

SimEngine

SimEngine is a plugin-based simulation runtime for composing independent simulations through typed instruments, instrument flows, and runtime state transitions.

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

cargo install simengine

Commands

Run a manifest:

simengine run config.json

Validate a manifest without running it:

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:

START
READY
RUNNING
FINISHED
ERROR

Each simulation has fixed internal states:

_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:

ctx.set_state("_READY");

or:

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:

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:

{
  "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:

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

instrument_flows.json declares where instrument values move:

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

Currently supported primitive types:

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:

{
  "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:

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:

pub const SIMENGINE_API_VERSION: u32 = 2;

Plugins implement Simulation and are exported with simengine_plugin!.

Example simulation: XPlane DataRef Sender

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:

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:

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

Remote flow:

{
  "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.