Skip to content

Rust

Load the runtime with libloading and model messages with serde_json, or link statically for a single-file binary.

Install

bash
tar xzf guido-0.1.0-linux-x86_64.tar.gz
./guido-0.1.0-linux-x86_64/install.sh     # installs ~/.local/lib/libguido.so
cargo add libloading serde_json

Example

A button that responds to clicks:

rust
use libloading::{Library, Symbol};
use serde_json::{json, Value};
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let lib_path = std::env::var("GUIDO_LIB")
        .unwrap_or_else(|_| format!("{}/.local/lib/libguido.so", std::env::var("HOME").unwrap()));
    let lib = unsafe { Library::new(&lib_path)? };
    let start: Symbol<unsafe extern "C" fn(c_int, *const *const c_char) -> c_int> =
        unsafe { lib.get(b"guido_start")? };
    let iteration: Symbol<unsafe extern "C" fn() -> c_int> = unsafe { lib.get(b"guido_iteration")? };
    let send_raw: Symbol<unsafe extern "C" fn(*const c_char) -> c_int> = unsafe { lib.get(b"guido_send")? };
    let recv: Symbol<unsafe extern "C" fn() -> *const c_char> = unsafe { lib.get(b"guido_recv")? };
    let stop: Symbol<unsafe extern "C" fn()> = unsafe { lib.get(b"guido_stop")? };

    let args = [CString::new("--no-project")?];
    let argv: Vec<*const c_char> = args.iter().map(|a| a.as_ptr()).collect();
    unsafe { start(argv.len() as c_int, argv.as_ptr()) };

    let send = |v: Value| {
        let line = CString::new(v.to_string()).unwrap();
        unsafe { send_raw(line.as_ptr()) };
    };
    send(json!({"jsonrpc":"2.0","id":1,"method":"handshake",
                "params":{"clientInfo":{"name":"rust","version":"0"}}}));
    send(json!({"jsonrpc":"2.0","id":2,"method":"instantiate","params":{"className":"Button"}}));

    let (mut root, mut button) = (Value::Null, Value::Null);
    loop {
        if unsafe { iteration() } != 0 { break; } // ends when the window closes
        loop {
            let p = unsafe { recv() };
            if p.is_null() { break; }
            // The buffer is reused on the next recv; take ownership now.
            let line = unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned();
            let msg: Value = serde_json::from_str(&line)?;
            match msg["id"].as_i64() {
                Some(1) => root = msg["result"]["rootId"].clone(),
                Some(2) => {
                    button = msg["result"]["oid"].clone();
                    send(json!({"jsonrpc":"2.0","method":"batch","params":{"ops":[
                        {"method":"set","params":{"oid":button,"property":"text","value":"Click me"}},
                        {"method":"addChild","params":{"parent":root,"child":button}}]}}));
                    send(json!({"jsonrpc":"2.0","id":3,"method":"connect",
                                "params":{"oid":button,"signal":"pressed"}}));
                }
                None if msg["method"] == "signal" => println!("clicked"),
                _ => {}
            }
        }
    }
    unsafe { stop() };
    Ok(())
}

A version with request routing and error handling is in the repo at examples/embedding/rust/.

Scenes from the Godot editor

The message shapes are documented on the C page and are identical here:

rust
send(json!({"jsonrpc":"2.0","id":10,"method":"loadPack",
            "params":{"path":"/abs/app.pck"}}));          // absolute path
send(json!({"jsonrpc":"2.0","id":11,"method":"instantiateScene",
            "params":{"path":"res://main.tscn"}}));
send(json!({"jsonrpc":"2.0","id":12,"method":"call",
            "params":{"oid":scene,"method":"get_node","args":["%SaveButton"]}}));
// reply: {"result":{"result":{"$oid":"..."}}}; connect "pressed" on that oid

Deserializing object references:

rust
#[derive(serde::Deserialize)]
struct ObjRef { #[serde(rename = "$oid")] oid: String }

Static linking

Linking libguido.a removes the dlopen step and produces one ~22 MB self-contained binary. In build.rs:

rust
println!("cargo:rustc-link-search=native=/path/to/guido/lib");
println!("cargo:rustc-link-lib=static=guido");
println!("cargo:rustc-link-lib=dylib=stdc++");
// plus: m, pthread, dl

Declare the five functions as extern "C" (signatures in guido.h). The archive must be built with lto=none; see Building from source.

Structuring a larger app

  • Run the engine on a dedicated thread: the thread that calls guido_start must also run guido_iteration and guido_stop. Bridge to the rest of the program with channels, for example HashMap<u64, oneshot::Sender<Value>> for replies and an mpsc channel for signals. guido_send is thread-safe, so any thread can send directly.
  • Keep the Library alive for the whole process (for example with Box::leak); the engine cannot be unloaded.
  • Batch fire-and-forget operations into one batch message per frame; send a sync request when a read must observe earlier writes.
  • Route signal notifications by subId from the connect reply.
  • Shutdown: run two more iteration calls after the last sends, then stop(), then drain recv() once.
  • To avoid FFI entirely, spawn guido (on PATH) with --bridge-stdio via std::process::Command and exchange the same JSON over its pipes.