# Animator As Crab A hard fork of [Animator As Code](https://github.com/hai-vr/av3-animator-as-code) where Animator Controllers are described in [Rhai](https://rhai.rs/) instead of C#. A Rust library (`libaac`) evaluates the script, validates it, and emits the controller as JSON. A Unity Editor window deserializes that JSON and builds a real `AnimatorController` through the original Animator As Code V1 library, which is left untouched. ``` script.rhai → libaac (Rust) ──JSON──→ AacCrabWindow (Unity Editor) → AnimatorController ``` ## Table of Contents - [Why](#why) - [Architecture](#architecture) - [Layout](#layout) - [Building](#building) - [Installation](#installation) - [Usage](#usage) - [DSL Reference](#dsl-reference) - [Setup](#setup) - [Parameters](#parameters) - [Controller and Layers](#controller-and-layers) - [States](#states) - [Clips](#clips) - [Animation Store](#animation-store) - [Blend Trees](#blend-trees) - [Transitions](#transitions) - [Any-State Transitions](#any-state-transitions) - [Sub-State Machines](#sub-state-machines) - [Transition Settings](#transition-settings) - [JSON Wire Format](#json-wire-format) - [Native FFI API](#native-ffi-api) - [C# Bridge](#c-bridge) - [VPM Distribution](#vpm-distribution) - [Nix Development](#nix-development) - [Limitations](#limitations) - [License](#license) ## Why Animator As Code V1 lets you build Animator Controllers from a fluent C# API. That works, but every change requires recompilation. Animator As Crab replaces the C# builder with a Rhai script that is evaluated at edit time: - **No recompilation.** Save the `.rhai` file and the controller rebuilds automatically. - **Version control friendly.** A `.rhai` script is a single readable file; no binary assets. - **Validates before generation.** The Rust core checks the graph for dangling references, duplicate names, mismatched parameter types, and missing configuration before Unity ever sees it. - **Untouched upstream.** The original Animator As Code V1 C# library ships as-is; the generator drives its public API. ## Architecture The system is a pipeline with three stages: 1. **Rhai evaluation** (`libaac`). The engine registers builder types (`Aac`, `ClipBuilder`, `StateRef`, etc.) and Rhai-callable functions. The script mutates a shared `ControllerGraph` through these builders. 2. **Validation and serialization** (`libaac`). After the script runs, keyframes are sorted by time, then the graph is validated: every name is unique, every reference resolves, every parameter type matches its usage, every transition targets a real state, and required fields (`system_name`, `asset_key`) are non-empty. The validated graph is serialized to JSON. 3. **C# generation** (Unity Editor). `AacCrabWindow` reads the JSON, deserializes it into `AacCrabGraph` DTOs, and drives Animator As Code V1's modification API: clears the controller, creates parameters, builds clips (cloning store assets via `AssetDatabase`), builds blend trees in declaration order (so forward references are impossible), and wires up states and transitions. The Rust and C# sides share no runtime dependency. The contract is a JSON document whose shape is pinned by a test (`json_keys_are_the_ones_the_unity_dtos_expect` in `rust/tests/dsl.rs`). ## Layout | Path | Contents | | --- | --- | | `rust/` | The Rhai DSL, graph model, validator, FFI exports, and the `libaac` cdylib. | | `rust/examples/avatar.rhai` | Reference script exercising every feature of the DSL. | | `rust/examples/animation_store.rhai` | Reference script for the animation store feature. | | `rust/tests/dsl.rs` | Integration tests: round-trip JSON, validation errors, API surface. | | `csharp/dev.doloro.animator-as-crab/` | Unity package: the untouched Animator As Code V1 library and the bridge in `V1/Editor/Crab/`. | | `csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabNative.cs` | P/Invoke bindings for `libaac`. | | `csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs` | JSON DTOs matching `rust/src/graph.rs`. | | `csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs` | Turns the graph into an `AnimatorController` via AAC V1. | | `csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabWindow.cs` | Unity Editor window with file watching. | | `vpm/` | VPM packaging scripts and the package index. | | `flake.nix` | Dev shell with Rust, `dotnet`, `jq`, `zip`, and `just`. | | `justfile` | Shortcuts for importing the VPM package into a Unity project. | ## Building ### With Nix ```sh nix develop # enter the dev shell cargo test --manifest-path rust/Cargo.toml # run tests nix build .#aac # produces libaac.so + aac-dump nix build .#bundle # produces a Unity-importable zip ``` ### Without Nix You need Rust (stable) and a C-compatible linker. ```sh cd rust cargo build --release # Output: target/release/libaac.so (Linux), libaac.dylib (macOS), or aac.dll (Windows) # Binary: target/release/aac-dump ``` ### Standalone dump tool ```sh cargo run --bin aac-dump -- rust/examples/avatar.rhai # prints the generated JSON ``` ## Installation ### Unity package (VPM) Add the VPM repository URL to the VRChat Creator Companion or ALCOM: ``` https://git.scug.io/doloro/av3-animation-as-crab/raw/branch/main/vpm/index.json ``` Or import manually: ```sh # Build the package zip first ./vpm/package.sh # Then import into a Unity project just import /path/to/unity-project ``` ### Manual 1. Copy `libaac.so` / `libaac.dll` / `libaac.dylib` into your Unity project's `Assets/Plugins/`. 2. Add the `csharp/dev.doloro.animator-as-crab` folder to your project's `Packages/` directory. 3. The package depends on `com.unity.nuget.newtonsoft-json` (3.2.1); Unity's Package Manager resolves this automatically. ## Usage 1. Write a `.rhai` script (see [DSL Reference](#dsl-reference)). 2. Open **Tools > Animator As Crab** in the Unity Editor. 3. Fill in the window: - **Rhai script** — path to your `.rhai` file. - **Animator Controller** — the controller asset to generate into. - **Animator Root** — the `Transform` that owns the `Animator` component. - **Asset Container** — the folder/object that will hold generated clips and blend trees. - **Container Mode** — how assets are organized (`Everything`, `ChildrenOfRoot`, etc.). - **Write Defaults** — whether states write defaults. 4. Click **Generate**, or enable **Generate when the script changes** for automatic regeneration on save. ## DSL Reference ### Setup Every script starts by creating an `Aac` context and configuring the system name and asset key: ```rhai let aac = AnimatorAsCode(); aac.system_name("MyAvatar"); // required — identifies the system aac.asset_key("AAC_"); // required — prefix for generated assets ``` The system name and asset key are written by the script, not guessed by the Unity side. The validator rejects empty values. ### Parameters Parameters are controller-wide. Declare them before building layers. | Function | Return type | Default | | --- | --- | --- | | `aac.float_param("Name", 0.0)` | `FloatParam` | `0.0` | | `aac.int_param("Name", 0)` | `IntParam` | `0` | | `aac.bool_param("Name", false)` | `BoolParam` | `false` | Duplicate parameter names are rejected at the call site. ### Controller and Layers ```rhai let ctrl = aac.new_controller(); // exactly one controller let base = ctrl.layer("Base"); // adds a layer; returns a LayerBuilder let props = ctrl.layer("Props"); ``` ### States States live on a layer or inside a sub-state machine. The last two arguments are the grid position in the Unity animator graph: ```rhai let idle = base.state("Idle", 0, 0); let walk = base.state("Walk", 1, 0); ``` ### Clips Generated clips are declared on the `Aac` context: ```rhai let clip = aac.clip("idle_anim"); // name must be unique clip.looping(true); // optional override clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0); clip.blend_shape("Body", "Smile", 0.0, 0.0); clip.blend_shape("Body", "Smile", 1.0, 0.8); clip.toggle("Body/Props", true); // shortcut for a one-frame m_IsActive constant ``` Keyframes are sorted by time automatically. All keyframes are linear (tangents are 0). **Supported curve targets** (inferred from the property name): | Property | Component type | | --- | --- | | `m_IsActive` | `GameObject` | | `blendShape.*` | `SkinnedMeshRenderer` | Any other property is rejected with a clear error. **Assigning clips to states:** ```rhai idle.set_clip(clip); // same as set_motion walk.set_motion(clip); ``` ### Animation Store An animation store seeds clips from `.anim` assets that already exist in the project. The generator loads the asset with `AssetDatabase.LoadAssetAtPath`, clones it into the asset container, and applies the script's keyframes and looping on the clone. The pre-made asset is never modified. ```rhai let store = aac.AnimationStore("Assets/Doloro/Clips"); let walk = store.clip("Walk"); // resolves to Assets/Doloro/Clips/Walk.anim walk.looping(true); // override on the clone walk.keyframe("Body/Props", "m_IsActive", 0.0, 0.0); ``` A store clip with no `looping(...)` call keeps the source asset's own looping setting. Requesting the same asset twice (with or without `.anim`) reuses the same reference. A missing asset is a clear error at generation time. Store clips are usable anywhere a generated clip is: ```rhai walk_state.set_clip(walk); locomotion.add_motion(walk, 0.0); ``` ### Blend Trees Blend trees are declared on the `Aac` context and configured with a blend type and parameters: ```rhai let tree = aac.blend_tree("locomotion"); tree.simple_1d(speed); // 1D blend tree.add_motion(walk_clip, 0.0); // (clip, threshold) tree.add_motion(run_clip, 5.0); ``` **Blend types:** | Function | Parameters | Use case | | --- | --- | --- | | `tree.simple_1d(x)` | 1 float param | Walk speed, locomotion blends | | `tree.simple_directional_2d(x, y)` | 2 float params | Strafe direction | | `tree.freeform_directional_2d(x, y)` | 2 float params | Complex directional | | `tree.freeform_cartesian_2d(x, y)` | 2 float params | Full 2D control | | `tree.direct()` | none | Per-parameter clip addressing | **Adding children:** ```rhai // 1D / 2D threshold-based: tree.add_motion(clip, threshold); tree.add_motion(clip, x, y); // 2D also works with blend trees as children: tree.add_motion(inner_tree, 0.0, 1.0); // Direct blend: tree.add_motion_direct(clip, parameter); ``` **Automatic thresholds:** ```rhai tree.automatic_thresholds(true); ``` Blend trees can nest other blend trees that were declared earlier in the script. Cycles are impossible because trees are created in declaration order. **Assigning blend trees to states:** ```rhai run.set_motion(tree); ``` ### Transitions Transitions are created by calling `transition_to` on a state (or any-state ref) and chaining condition and setting methods: ```rhai idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25); ``` **Conditions** are written on the parameter objects using comparison operators: | Operator | Float/Int | Bool | | --- | --- | --- | | `>` | Greater | — | | `<` | Less | — | | `==` | Equals | If / IfNot | | `!=` | NotEqual | IfNot / If | Combine multiple conditions with `when_all([...])`: ```rhai walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]).duration(0.25); ``` > **Why not `&&`?** Rhai's `&&` and `||` are language built-ins that cannot be overloaded. > `speed > 0.1 && is_sitting == true` will not compile. Use `when_all`. ### Any-State Transitions Any-state transitions fire from every state in a machine: ```rhai base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2); ``` Any-state can also be used inside a sub-state machine: ```rhai let gestures = base.sub_machine("Gestures", 2, 0); gestures.any_state().transition_to(reset).when(reset_all == true); ``` ### Sub-State Machines ```rhai let gestures = base.sub_machine("Gestures", 2, 0); let wave = gestures.state("Wave", 0, 0); let point = gestures.state("Point", 1, 0); wave.transition_to(point).when(gesture == 1).duration(0.1); point.transition_to(wave).when(gesture == 0).duration(0.1); ``` States and transitions inside a sub-state machine work exactly like the root machine. ### Transition Settings All settings are chainable on a `TransitionRef`: | Method | Effect | Default | | --- | --- | --- | | `.duration(seconds)` | Cross-fade duration | `0.0` | | `.no_exit_time()` | Transition fires immediately | — | | `.exit_time(normalized)` | Transition fires after this normalized time | — | | `.to_self()` | Allow self-transitions | `false` | | `.ordered_interruption(bool)` | Ordered interruption | `true` | | `.source_interruption()` | Source interruption | `false` | Default transition values match Animator As Code V1's `AacDefaultsProvider.ConfigureTransition`. ## JSON Wire Format The Rust core serializes the graph as JSON. The C# side deserializes it with `MissingMemberHandling.Error`, so the shape is a strict contract. Every object's key set is pinned by the test `json_keys_are_the_ones_the_unity_dtos_expect` in `rust/tests/dsl.rs`. Top-level keys: `system_name`, `asset_key`, `parameters`, `clips`, `blend_trees`, `controller`. Example (trimmed): ```json { "system_name": "MyAvatar", "asset_key": "AAC_", "parameters": [ { "type": "float", "name": "Speed", "default": 0.0 } ], "clips": [ { "name": "idle_anim", "looping": true, "curves": [ { "path": "Body/Hand", "target": "game_object", "property": "m_IsActive", "keys": [{ "time": 0.0, "value": 1.0, "in_tangent": 0.0, "out_tangent": 0.0 }] } ] } ], "blend_trees": [ { "name": "locomotion", "blend_type": "simple_1d", "param_x": "Speed", "param_y": null, "children": [ { "motion": { "type": "clip", "name": "walk_anim" }, "threshold": 0.0, "threshold_y": null, "direct_param": null } ], "use_automatic_thresholds": false } ], "controller": { "layers": [ { "name": "Base", "state_machine": { "name": null, "position": { "x": 0, "y": 0 }, "states": [ { "name": "Idle", "position": { "x": 0, "y": 0 }, "motion": { "type": "clip", "name": "idle_anim" }, "transitions": [ { "to": "Walk", "conditions": [{ "parameter": "Speed", "mode": "greater", "threshold": 0.1 }], "has_exit_time": false, "exit_time": 0.0, "duration": 0.25, "ordered_interruption": true, "source_interruption": false, "can_transition_to_self": false } ] } ], "sub_machines": [], "any_state_transitions": [] } } ] } } ``` Store clips include a `"source"` field with the project-relative asset path; generated clips omit it. ## Native FFI API `libaac` exposes a C ABI for Unity's P/Invoke: | Function | Signature | Description | | --- | --- | --- | | `aac_create` | `() → *mut AacContext` | Create a context. Owns the graph and engine. | | `aac_eval_rhai` | `(handle, script) → i32` | Evaluate a script. Returns 0 on success. | | `aac_to_json` | `(handle) → *mut c_char` | Serialize the graph to JSON. Free with `aac_free_string`. | | `aac_last_error` | `(handle) → *const c_char` | Last error message, or null. Borrowed until next call. | | `aac_free_string` | `(string)` | Free a string returned by `aac_to_json`. | | `aac_destroy` | `(handle)` | Destroy a context. | The context is single-threaded. Panics across the FFI boundary are caught and reported as errors. **Standalone dump tool** (`aac-dump`): ```sh cargo run --bin aac-dump -- path/to/script.rhai ``` Prints the generated JSON to stdout. Exits with a non-zero status and prints the error to stderr on failure. ## C# Bridge The Unity side lives in `csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/`: - **`AacCrabNative`** — P/Invoke wrapper around `libaac`. Creates a context, evaluates the script, reads the JSON, and destroys the context in a single `Evaluate` call. Handles `DllNotFoundException` with a clear message. - **`AacCrabGraph`** — DTO classes mirroring `rust/src/graph.rs`. Deserialized with Newtonsoft.Json using `snake_case` naming and `StringEnumConverter`. Missing fields are an error, not silently ignored. - **`AacCrabGenerator`** — The core generator. Clears the controller via AAC V1's modification API, creates parameters (applying defaults by mutating the controller's parameter list), builds clips (cloning store assets via `AssetDatabase`), builds blend trees in declaration order, and wires states and transitions. Forward references within a layer resolve because all states are created before any transitions. - **`AacCrabWindow`** — Editor window (Tools > Animator As Crab). Watches the script file for changes and regenerates automatically. All Unity-side configuration (controller, root, container, container mode, write defaults) is supplied by the user; nothing is defaulted silently. ## VPM Distribution The `vpm/` directory contains the packaging and publishing pipeline: - **`package.sh`** — Builds a VPM release zip containing the C# package and the native library, then updates `vpm/index.json` with the new version's URL and SHA-256. Environment variables override the host, project, and version. - **`publish.sh`** — Creates a Gitea release for a given tag and attaches the VPM zip. Requires `TOKEN`, `SERVER`, `REPO`, and `TAG` environment variables. - **`index.json`** — The VPM repository index read by VCC and ALCOM. To add the repository to VCC/ALCOM, paste the index URL: ``` https://git.scug.io/doloro/av3-animation-as-crab/raw/branch/main/vpm/index.json ``` ## Nix Development The `flake.nix` provides: - **Dev shell** (`nix develop`): Rust stable with clippy, rustfmt, rust-analyzer; `dotnet`, `mono`, `jq`, `zip`, `unzip`, `just`, `curl`. - **Packages**: `nix build .#aac` (native library + dump binary), `nix build .#bundle` (Unity-importable zip). - **Formatter**: `nix fmt` (nixfmt-rfc-style). The dev shell also provides `.direnv` integration via `.envrc`. ## Limitations - **No Unity project in this repo.** The bridge and generator are verified by reading and testing the Rust side, not by running in Unity. - **Blend tree nesting.** A blend tree can only reference a tree declared earlier in the script. - **No avatar masks, layer weights, state behaviours, or parameter drivers.** The graph model does not carry them yet. - **Linear keyframes only.** All tangent values are 0; no easing curves. - **Two curve target types.** Only `GameObject` (`m_IsActive`) and `SkinnedMeshRenderer` (`blendShape.*`). Unknown properties are rejected. - **Single-threaded FFI.** The `AacContext` is not safe to share across threads. ## License MIT. See [LICENSE](LICENSE). Originally created by [@hai-vr](https://github.com/hai-vr) with major contributions from [@galister](https://github.com/galister). Forked and extended by doloro.