diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..99fd522 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,557 @@ +# 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. diff --git a/WRITING_SCRIPTS.md b/WRITING_SCRIPTS.md new file mode 100644 index 0000000..94309f9 --- /dev/null +++ b/WRITING_SCRIPTS.md @@ -0,0 +1,480 @@ +# Writing Animator Scripts in Rhai + +A practical guide to writing `.rhai` files that generate Unity Animator Controllers. + +## Quick Start + +Create a file called `avatar.rhai`: + +```rhai +let aac = AnimatorAsCode(); +aac.system_name("MyAvatar"); +aac.asset_key("AAC_"); + +let speed = aac.float_param("Speed", 0.0); + +let ctrl = aac.new_controller(); +let base = ctrl.layer("Base"); + +let idle = base.state("Idle", 0, 0); +let walk = base.state("Walk", 1, 0); + +let idle_clip = aac.clip("idle_anim"); +idle_clip.looping(true); +idle_clip.toggle("Body/Props", true); + +idle.set_clip(idle_clip); +idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25); +``` + +Open **Tools > Animator As Crab** in Unity, point it at this file, pick your controller and +animator root, and hit Generate. + +## How Scripts Are Structured + +Every script follows the same pattern: + +1. **Create the context and set metadata** — `AnimatorAsCode()`, `system_name`, `asset_key`. +2. **Declare parameters** — floats, ints, bools. These become the controller's parameters. +3. **Create the controller and layers** — one controller, one or more layers. +4. **Declare clips and blend trees** — the motions your states will use. +5. **Create states and assign motions** — place them on the grid. +6. **Wire up transitions** — conditions, timing, interruption settings. + +The order within each group matters (parameters before layers, clips before states that use +them), but the groups themselves can be interleaved freely — you can declare a clip right before +the state that uses it, or batch all clips at the top. + +## Parameters + +Parameters are the knobs your animator exposes at runtime. Declare them early — layers and +transitions reference them by name. + +```rhai +let speed = aac.float_param("Speed", 0.0); // FloatParam, default 0.0 +let gesture = aac.int_param("Gesture", 0); // IntParam, default 0 +let is_sitting = aac.bool_param("IsSitting", false); // BoolParam, default false +``` + +You get typed handles back. The same handle is used both in transition conditions and as blend +tree parameters — the type is enforced at validation time, not at declaration time. + +Duplicate names are rejected immediately with a clear error. + +## States and Grid Position + +States live on a layer. The last two arguments are the `(x, y)` position in Unity's animator +graph editor — purely cosmetic, but useful for keeping things organized: + +```rhai +let idle = base.state("Idle", 0, 0); // top-left +let walk = base.state("Walk", 1, 0); // one column right +let run = base.state("Run", 2, 0); // further right +``` + +State names must be unique within a layer (including across sub-state machines). + +## Clips + +### Generated Clips + +Create a clip, configure it, then assign it to a state: + +```rhai +let idle_clip = aac.clip("idle_anim"); +idle_clip.looping(true); +idle_clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0); +idle_clip.blend_shape("Face", "Smile", 0.0, 0.0); +idle_clip.blend_shape("Face", "Smile", 1.0, 0.8); + +idle.set_clip(idle_clip); +``` + +Clip names must be unique across the entire script. + +### Keyframes + +Keyframes are always linear (tangents are zero). You add them by specifying a path, a property, +a time, and a value: + +```rhai +clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0); +clip.keyframe("Body/Hand", "m_IsActive", 1.0, 0.0); +``` + +Multiple keyframes on the same curve are sorted by time automatically — the order you write +them doesn't matter. + +### Toggles + +The most common pattern: turning a GameObject on or off. `toggle` is a shortcut that writes a +two-keyframe constant on `m_IsActive`: + +```rhai +clip.toggle("Body/Props", true); // enable at frame 0, hold +clip.toggle("Body/Props", false); // disable +``` + +### Blend Shapes + +Animate a blend shape weight: + +```rhai +clip.blend_shape("Face", "Smile", 0.0, 0.0); // start at 0 +clip.blend_shape("Face", "Smile", 1.0, 0.8); // end at 0.8 +``` + +### Curve Targets + +The generator infers the Unity component type from the property name: + +| Property | Component | +| --- | --- | +| `m_IsActive` | `GameObject` | +| `blendShape.*` | `SkinnedMeshRenderer` | + +Any other property name is rejected. If you need a different component type, extend +`infer_target` in `rust/src/graph.rs`. + +## Animation Store + +If you already have `.anim` files in your project, you can use them instead of generating clips +from scratch: + +```rhai +let store = aac.AnimationStore("Assets/Doloro/Clips"); +let walk = store.clip("Walk"); // loads Assets/Doloro/Clips/Walk.anim +``` + +The `.anim` extension is optional — `store.clip("Walk")` and `store.clip("Walk.anim")` resolve +to the same asset and produce the same clip. + +### Editing Store Clips + +Store clips are starting points. Anything you write on them — looping, keyframes, toggles — is +applied to a **clone**. The original asset is never modified: + +```rhai +let walk = store.clip("Walk"); +walk.looping(true); // override the clone's looping +walk.toggle("Body/Props/Umbrella", true); // add a keyframe on the clone +``` + +A store clip with no `looping(...)` call keeps the source asset's own looping setting. + +### Using Store Clips + +Store clips work exactly like generated clips everywhere: + +```rhai +walk_state.set_clip(walk); // assign to a state +locomotion.add_motion(walk, 0.0); // add to a blend tree +``` + +### Subfolders + +Store paths can contain subfolders. The folder argument to `AnimationStore` and the clip name +are joined directly: + +```rhai +let props = aac.AnimationStore("Assets/Doloro/Clips/Props"); +let umbrella = props.clip("Weapons/Umbrella"); +// → Assets/Doloro/Clips/Props/Weapons/Umbrella.anim +``` + +### Missing Assets + +If a store clip references an asset that doesn't exist, you'll get a clear error at generation +time — not a silent failure. + +## Blend Trees + +Blend trees mix multiple clips (or other blend trees) based on parameter values. + +### 1D Blend Tree + +The simplest kind — one parameter controls which clip plays: + +```rhai +let locomotion = aac.blend_tree("locomotion"); +locomotion.simple_1d(speed); +locomotion.add_motion(idle_clip, 0.0); // speed 0 → idle +locomotion.add_motion(walk_clip, 2.0); // speed 2 → walk +locomotion.add_motion(run_clip, 5.0); // speed 5 → run +``` + +The thresholds define the blend points. Unity interpolates between them. + +### 2D Blend Trees + +Two parameters control a 2D blend space: + +```rhai +let strafe = aac.blend_tree("strafe"); +strafe.freeform_directional_2d(speed, vertical); +strafe.add_motion(forward_clip, 0.0, 1.0); // (x, y) threshold +strafe.add_motion(back_clip, 0.0, -1.0); +strafe.add_motion(left_clip, -1.0, 0.0); +strafe.add_motion(right_clip, 1.0, 0.0); +``` + +Three 2D types are available: + +| Type | Behavior | +| --- | --- | +| `simple_directional_2d` | Clips are evenly distributed around a circle | +| `freeform_directional_2d` | Clips can be at any angle, but magnitudes are normalized | +| `freeform_cartesian_2d` | Clips can be at any position in the (x, y) plane | + +### Direct Blend Tree + +Each child is addressed by its own float parameter — useful for layered motion: + +```rhai +let hold = aac.blend_tree("hold"); +hold.direct(); +hold.add_motion_direct(umbrella_clip, grip); // grip parameter drives this child +hold.add_motion_direct(sword_clip, sword_param); +``` + +### Nesting Blend Trees + +A blend tree can reference another blend tree that was declared **earlier** in the script: + +```rhai +let movement = aac.blend_tree("movement"); +movement.simple_1d(speed); +movement.add_motion(walk_clip, 0.0); +movement.add_motion(run_clip, 5.0); + +let strafe = aac.blend_tree("strafe"); +strafe.freeform_directional_2d(speed, vertical); +strafe.add_motion(forward_clip, 0.0, 1.0); + +let full_body = aac.blend_tree("full_body"); +full_body.simple_1d(speed); +full_body.add_motion(movement, 0.0); // nests the movement tree +full_body.add_motion(strafe, 5.0); // nests the strafe tree +``` + +Cycles are impossible — you can only reference trees that exist at the point you reference them. + +### Automatic Thresholds + +Let Unity figure out the blend thresholds: + +```rhai +tree.automatic_thresholds(true); +``` + +## Transitions + +Transitions connect states. You create one by calling `transition_to` on the source state: + +```rhai +idle.transition_to(walk); // creates a transition from idle to walk +``` + +### Adding Conditions + +Chain `.when(...)` to add conditions. Conditions are written as comparisons on parameter +handles: + +```rhai +idle.transition_to(walk).when(speed > 0.1); +walk.transition_to(run).when(speed > 4.0); +``` + +The comparison operators (`>`, `<`, `==`, `!=`) are overloaded on parameter handles to produce +conditions, not booleans. This is the core of the DSL — you write conditions exactly the way +you'd think about them. + +**Bool parameters** use `==` and `!=`: + +```rhai +any_state.transition_to(sit).when(is_sitting == true); +any_state.transition_to(stand).when(is_sitting != true); +``` + +### Combining Conditions with when_all + +Rhai's `&&` and `||` cannot be overloaded — they're language built-ins. To combine multiple +conditions on one transition, use `when_all`: + +```rhai +walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]); +``` + +This is the only DSL limitation with no workaround. Every multi-condition transition uses +`when_all`. + +### Transition Timing + +```rhai +// Instant transition, no blending: +idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.0); + +// 250ms cross-fade, fires immediately: +idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25); + +// Transition fires after 80% of the source animation plays: +idle.transition_to(walk).when(speed > 0.1).exit_time(0.8).duration(0.25); +``` + +`no_exit_time()` and `exit_time(normalized)` are mutually exclusive — the last one called wins. + +### Self-Transitions + +By default, a transition to the same state is ignored. Enable it explicitly: + +```rhai +walk.transition_to(walk).when(speed > 4.0).to_self().no_exit_time().duration(0.0); +``` + +### Interruption Settings + +```rhai +transition.ordered_interruption(false); // disable ordered interruption +transition.source_interruption(); // enable source interruption +``` + +## Any-State Transitions + +Any-state transitions fire from every state in a machine, regardless of which state is active: + +```rhai +base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2); +``` + +This is the standard way to implement global interrupts — sit down from any animation, reset +from any gesture, etc. + +Any-state can also be scoped to a sub-state machine: + +```rhai +let gestures = base.sub_machine("Gestures", 2, 0); +gestures.any_state().transition_to(reset).when(gesture == 99); +``` + +## Sub-State Machines + +Group related states into a sub-state machine: + +```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); +``` + +Sub-state machines work exactly like the root machine — you can add states, transitions, +any-state, and even nested sub-state machines to them. + +## Assigning Motions to States + +States can hold a clip or a blend tree as their motion: + +```rhai +// From a generated clip: +idle.set_clip(idle_clip); +walk.set_motion(walk_clip); + +// From a blend tree: +run.set_motion(locomotion); + +// From a store clip: +sit.set_clip(store_clip); +``` + +`set_clip` and `set_motion` are interchangeable — use whichever reads better. + +## Complete Example + +Here's a full script that demonstrates every feature: + +```rhai +let aac = AnimatorAsCode(); +aac.system_name("MyAvatar"); +aac.asset_key("AAC_"); + +// Parameters +let speed = aac.float_param("Speed", 0.0); +let is_sitting = aac.bool_param("IsSitting", false); +let gesture = aac.int_param("Gesture", 0); + +// Controller and layer +let ctrl = aac.new_controller(); +let base = ctrl.layer("Base"); + +// States +let idle = base.state("Idle", 0, 0); +let walk = base.state("Walk", 1, 0); +let run = base.state("Run", 2, 0); +let sit = base.state("Sit", 0, 1); + +// Generated clips +let idle_clip = aac.clip("idle_anim"); +idle_clip.looping(true); +idle_clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0); +idle_clip.blend_shape("Body", "Smile", 0.0, 0.0); +idle_clip.blend_shape("Body", "Smile", 1.0, 0.8); + +let walk_clip = aac.clip("walk_anim"); +walk_clip.looping(true); +walk_clip.toggle("Body/Props", true); + +let run_clip = aac.clip("run_anim"); +run_clip.looping(true); +run_clip.toggle("Body/Props", false); + +// Store clip +let store = aac.AnimationStore("Assets/Doloro/Clips"); +let crouch_clip = store.clip("Crouch"); +crouch_clip.looping(true); + +// Assign clips +idle.set_clip(idle_clip); +walk.set_clip(walk_clip); +sit.set_clip(crouch_clip); + +// Blend tree +let locomotion = aac.blend_tree("locomotion"); +locomotion.simple_1d(speed); +locomotion.add_motion(walk_clip, 0.0); +locomotion.add_motion(run_clip, 5.0); +run.set_motion(locomotion); + +// Transitions +idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25); +walk.transition_to(run).when(speed > 4.0).no_exit_time().duration(0.25); +run.transition_to(walk).when(speed < 4.0).no_exit_time().duration(0.25); +walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]).duration(0.25); + +// Any-state interrupt +base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2); + +// Sub-state machine +let gestures = base.sub_machine("Gestures", 2, 0); +let peace = gestures.state("Peace", 0, 0); +let point = gestures.state("Point", 1, 0); +peace.transition_to(point).when(gesture == 1).duration(0.1); +point.transition_to(peace).when(gesture == 0).duration(0.1); +``` + +## Tips + +- **Start minimal.** One layer, two states, one transition. Get it generating. Then add + complexity incrementally. +- **Use the grid positions.** `(0, 0)` is top-left. Spread states horizontally for logical + flow, vertically for alternative branches. +- **Name clips descriptively.** They become the asset names in Unity. `"walk_anim"` is clearer + than `"clip1"`. +- **Store clips for reused animations.** If multiple avatars share the same `.anim` assets, a + store avoids duplicating keyframes in every script. +- **`when_all` is your `&&`.** Every multi-condition transition uses it. There's no way around + this — it's a Rhai language limitation. +- **Test with `aac-dump`.** Run `cargo run --bin aac-dump -- your_script.rhai` to see the JSON + output without opening Unity. Catches script errors instantly.