diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index e8520c6..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,55 +0,0 @@
-name: Build Release
-
-on:
- workflow_dispatch:
-
-env:
- packageName: "dev.hai-vr.animator-as-code.v1"
-
-permissions:
- contents: write
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
-
- - name: Checkout
- uses: actions/checkout@v3
-
- - name: get version
- id: version
- uses: notiz-dev/github-action-json-property@7c8cf5cc36eb85d8d287a8086a39dac59628eb31
- with:
- path: "Packages/${{env.packageName}}/package.json"
- prop_path: "version"
-
- - name: Set Environment Variables
- run: |
- echo "zipFile=${{ env.packageName }}-${{ steps.version.outputs.prop }}".zip >> $GITHUB_ENV
- echo "unityPackage=${{ env.packageName }}-${{ steps.version.outputs.prop }}.unitypackage" >> $GITHUB_ENV
-
- - name: Create Zip
- uses: thedoctor0/zip-release@09336613be18a8208dfa66bd57efafd9e2685657
- with:
- type: "zip"
- directory: "Packages/${{env.packageName}}/"
- filename: "../../${{env.zipFile}}" # make the zip file two directories up, since we start two directories in above
-
- - run: find "Packages/${{env.packageName}}/" -name \*.meta >> metaList
-
- - name: Create UnityPackage
- uses: pCYSl5EDgo/create-unitypackage@cfcd3cf0391a5ef1306342794866a9897c32af0b
- with:
- package-path: ${{ env.unityPackage }}
- include-files: metaList
-
-
- - name: Make Release
- uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5
- with:
- tag_name: ${{ steps.version.outputs.prop }}
- files: |
- ${{ env.zipFile }}
- ${{ env.unityPackage }}
- Packages/${{ env.packageName }}/package.json
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c4a847d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/result
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/package.json b/Packages/dev.hai-vr.animator-as-code.v1/package.json
deleted file mode 100644
index 3c04ae5..0000000
--- a/Packages/dev.hai-vr.animator-as-code.v1/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "name": "dev.hai-vr.animator-as-code.v1",
- "displayName": "Animator As Code V1",
- "version": "1.3.0-alpha.1",
- "unity": "2019.4",
- "description": "Base Animator As Code library. This library only requires Unity.",
- "vrchatVersion" : "2022.1.1",
- "author" : {
- "name" : "Haï~"
- },
- "url" : "https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base",
- "documentationUrl": "https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base",
- "changelogUrl": "https://docs.hai-vr.dev/docs/changelogs/animator-as-code",
- "license": "MIT"
-}
diff --git a/README.md b/README.md
index bf00ce4..552cae5 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,82 @@
-## Animation As Crab
+## Animator As Crab
-This is a hard fork of https://github.com/hai-vr/av3-animator-as-code
-We will be using [rhai](https://rhai.rs/) for writing animation controllers instead of C#
+A hard fork of [Animator As Code](https://github.com/hai-vr/av3-animator-as-code) where animator
+controllers are written in [Rhai](https://rhai.rs/) instead of C#.
-the idea is, (rhai + rust) will output a big eval'd json file that the C# side will parse and generate a native unity animation controller from.
+A Rhai script is evaluated by a Rust library (`libaac`), which validates it and emits the whole
+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.
-Original Creator is [hai-vr](https://github.com/hai-vr)
+```
+script.rhai --> libaac (Rust) --JSON--> AacCrabWindow (Unity Editor) --> AnimatorController
+```
+
+## Layout
+
+| Path | Contents |
+| --- | --- |
+| `rust/` | The Rhai DSL, graph model, validator, and the `libaac` cdylib. |
+| `rust/examples/avatar.rhai` | The reference script, exercising every feature of the DSL. |
+| `csharp/dev.doloro.animator-as-crab/` | The Unity package: the untouched Animator As Code V1 library and the bridge in `V1/Editor/Crab/`. |
+| `flake.nix` | Dev shell with the Rust toolchain, `dotnet`, `mono`, and `jq`. |
+
+## Building
+
+```sh
+nix develop
+cargo test --manifest-path rust/Cargo.toml
+cargo run --bin aac-dump -- rust/examples/avatar.rhai # print the generated JSON
+nix build .#aac # libaac.so + aac-dump
+```
+
+Copy `libaac.so` (or `libaac.dll`, `libaac.dylib`) into the Unity project's `Assets/Plugins` folder,
+and add `csharp/dev.doloro.animator-as-crab` to the project's packages.
+
+## Writing a script
+
+```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 walk_clip = aac.clip("walk_anim");
+walk_clip.toggle("Body/Props", true);
+
+walk.set_clip(walk_clip);
+idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
+```
+
+Conditions are written on the parameters themselves (`speed > 0.1`, `is_sitting == true`).
+There is no `&&` or `||` — Rhai's are short-circuit built-ins that cannot be overloaded — so
+use `when_all([speed < 0.05, is_sitting == false])`.
+
+See `rust/examples/avatar.rhai` for the full surface: parameters, clips, curves, blend trees,
+sub-state machines, any-state transitions, and transition settings.
+
+## Generating
+
+Open *Tools > Animator As Crab* and fill in the window:
+
+- the path to the `.rhai` script (it is watched, so saving the script regenerates the controller),
+- the `AnimatorController` to generate into,
+- the animator root, the asset container, and the container mode,
+- whether states should write defaults.
+
+Nothing is defaulted silently, and the controller is rebuilt with Animator As Code's modification
+API: the controller is cleared, and the clips and blend trees of the same asset key are removed
+from the container before being recreated. Animator As Code's own C# API is untouched and still
+usable on the same controller.
+
+## Limitations
+
+- The Unity side has not been run: there is no Unity project in this repository, so the bridge and
+ the generator are verified by reading, not by executing.
+- Blend trees can only nest a blend tree that was declared earlier in the script.
+- The graph carries no avatar masks, layer weights, state behaviours, or parameter drivers yet.
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/.gitignore b/csharp/dev.doloro.animator-as-crab/.gitignore
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/.gitignore
rename to csharp/dev.doloro.animator-as-crab/.gitignore
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1.meta b/csharp/dev.doloro.animator-as-crab/V1.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1.meta
rename to csharp/dev.doloro.animator-as-crab/V1.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/Aac.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Aac.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/Aac.cs
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/Aac.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/Aac.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/Aac.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/Aac.cs.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/Aac.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAnimatorNode.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacAnimatorNode.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAnimatorNode.cs
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacAnimatorNode.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAnimatorNode.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacAnimatorNode.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAnimatorNode.cs.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacAnimatorNode.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAssetContainerProvider.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacAssetContainerProvider.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAssetContainerProvider.cs
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacAssetContainerProvider.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAssetContainerProvider.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacAssetContainerProvider.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAssetContainerProvider.cs.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacAssetContainerProvider.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacDefaultsProvider.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacDefaultsProvider.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacDefaultsProvider.cs
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacDefaultsProvider.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacDefaultsProvider.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacDefaultsProvider.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacDefaultsProvider.cs.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacDefaultsProvider.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlAnimations.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlAnimations.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlAnimations.cs
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlAnimations.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlAnimations.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlAnimations.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlAnimations.cs.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlAnimations.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlBlendTrees.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlBlendTrees.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlBlendTrees.cs
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlBlendTrees.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlBlendTrees.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlBlendTrees.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlBlendTrees.cs.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlBlendTrees.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlConditions.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlConditions.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlConditions.cs
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlConditions.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlConditions.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlConditions.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlConditions.cs.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlConditions.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlModification.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlModification.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlModification.cs
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlModification.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlModification.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlModification.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlModification.cs.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlModification.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlStates.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlStates.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlStates.cs
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlStates.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlStates.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlStates.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlStates.cs.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlStates.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacInternals.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacInternals.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacInternals.cs
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacInternals.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacInternals.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacInternals.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacInternals.cs.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacInternals.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AnimatorAsCode.V1.asmdef b/csharp/dev.doloro.animator-as-crab/V1/Editor/AnimatorAsCode.V1.asmdef
similarity index 82%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AnimatorAsCode.V1.asmdef
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AnimatorAsCode.V1.asmdef
index 80acc17..cd63ead 100644
--- a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AnimatorAsCode.V1.asmdef
+++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/AnimatorAsCode.V1.asmdef
@@ -7,7 +7,9 @@
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
- "precompiledReferences": [],
+ "precompiledReferences": [
+ "Newtonsoft.Json.dll"
+ ],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AnimatorAsCode.V1.asmdef.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AnimatorAsCode.V1.asmdef.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AnimatorAsCode.V1.asmdef.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AnimatorAsCode.V1.asmdef.meta
diff --git a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs
new file mode 100644
index 0000000..cd437a6
--- /dev/null
+++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs
@@ -0,0 +1,407 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AnimatorAsCode.V1;
+using UnityEditor;
+using UnityEditor.Animations;
+using UnityEngine;
+
+namespace AnimatorAsCrab.V1
+{
+ ///
+ /// Turns an into an AnimatorController by driving Animator As Code V1.
+ /// This uses the modification workflow: the controller is cleared and rebuilt, and no layer of the
+ /// controller is touched beforehand.
+ ///
+ public static class AacCrabGenerator
+ {
+ /// Clears the AnimatorController and the assets of the same asset key, then rebuilds them.
+ public static void Generate(AacCrabGraph graph, AacConfiguration configuration, AnimatorController controller)
+ {
+ if (graph == null) throw new ArgumentNullException(nameof(graph));
+ if (controller == null) throw new ArgumentNullException(nameof(controller));
+ if (graph.Controller == null) throw new InvalidOperationException("The graph has no controller.");
+
+ var aac = AacV1.Create(configuration);
+ var modification = aac.Modification();
+
+ aac.ClearPreviousAssets();
+ var aacController = modification.ResetAnimatorController(controller);
+
+ var layers = new Dictionary();
+ foreach (var layer in graph.Controller.Layers)
+ {
+ layers[layer.Name] = aacController.NewLayer(layer.Name);
+ }
+
+ var floatingParameters = CreateParameters(graph, layers, controller);
+
+ var clips = new Dictionary();
+ foreach (var clip in graph.Clips)
+ {
+ clips[clip.Name] = CreateClip(aac, clip);
+ }
+
+ // Blend trees are created in declaration order, so a child motion can only refer to a tree
+ // that was declared before its parent. This also makes cycles impossible.
+ var blendTrees = new Dictionary();
+ foreach (var blendTree in graph.BlendTrees)
+ {
+ blendTrees[blendTree.Name] = CreateBlendTree(aac, blendTree, clips, blendTrees, floatingParameters);
+ }
+
+ foreach (var layer in graph.Controller.Layers)
+ {
+ BuildLayer(layers[layer.Name], layer, clips, blendTrees);
+ }
+
+ modification.SetDirtyAll();
+ EditorUtility.SetDirty(controller);
+ }
+
+ private static IReadOnlyDictionary CreateParameters(AacCrabGraph graph, IReadOnlyDictionary layers, AnimatorController controller)
+ {
+ var floatingParameters = new Dictionary();
+ if (graph.Parameters.Count == 0)
+ {
+ return floatingParameters;
+ }
+
+ if (layers.Count == 0)
+ {
+ throw new InvalidOperationException("The controller declares parameters but no layer to hold them.");
+ }
+
+ // Parameters are controller-wide, so any layer can create them.
+ var layer = layers.First().Value;
+ foreach (var parameter in graph.Parameters)
+ {
+ switch (parameter.Type)
+ {
+ case AacCrabParameterType.Float:
+ floatingParameters[parameter.Name] = layer.FloatParameter(parameter.Name);
+ break;
+ case AacCrabParameterType.Int:
+ layer.IntParameter(parameter.Name);
+ break;
+ case AacCrabParameterType.Bool:
+ layer.BoolParameter(parameter.Name);
+ break;
+ default:
+ throw new InvalidOperationException($"Unknown parameter type {parameter.Type}.");
+ }
+ }
+
+ ApplyParameterDefaults(controller, graph.Parameters);
+ return floatingParameters;
+ }
+
+ // Animator As Code creates parameters with Unity's defaults; the graph's defaults are applied
+ // afterwards by mutating the controller's own parameter list.
+ private static void ApplyParameterDefaults(AnimatorController controller, IEnumerable parameters)
+ {
+ var wanted = parameters.ToDictionary(parameter => parameter.Name);
+ var current = controller.parameters;
+ foreach (var parameter in current)
+ {
+ if (!wanted.TryGetValue(parameter.name, out var declared))
+ {
+ continue;
+ }
+
+ switch (declared.Type)
+ {
+ case AacCrabParameterType.Float:
+ parameter.defaultFloat = declared.DefaultFloat;
+ break;
+ case AacCrabParameterType.Int:
+ parameter.defaultInt = declared.DefaultInt;
+ break;
+ case AacCrabParameterType.Bool:
+ parameter.defaultBool = declared.DefaultBool;
+ break;
+ }
+ }
+
+ controller.parameters = current;
+ }
+
+ private static AacFlClip CreateClip(AacFlBase aac, AacCrabClip graph)
+ {
+ var clip = aac.NewClip(graph.Name);
+ if (graph.Looping)
+ {
+ clip.Looping();
+ }
+ else
+ {
+ clip.NonLooping();
+ }
+
+ return clip.Animating(edit =>
+ {
+ foreach (var curve in graph.Curves)
+ {
+ var keys = curve.Keys
+ .Select(key => new Keyframe(key.Time, key.Value, key.InTangent, key.OutTangent))
+ .ToArray();
+ edit.Animates(curve.Path, UnityType(curve.Target), curve.Property)
+ .WithAnimationCurve(new AnimationCurve(keys));
+ }
+ });
+ }
+
+ private static Type UnityType(AacCrabTargetType target)
+ {
+ switch (target)
+ {
+ case AacCrabTargetType.GameObject:
+ return typeof(GameObject);
+ case AacCrabTargetType.SkinnedMeshRenderer:
+ return typeof(SkinnedMeshRenderer);
+ default:
+ throw new InvalidOperationException($"Unknown curve target {target}.");
+ }
+ }
+
+ private static AacFlBlendTree CreateBlendTree(
+ AacFlBase aac,
+ AacCrabBlendTree graph,
+ IReadOnlyDictionary clips,
+ IReadOnlyDictionary trees,
+ IReadOnlyDictionary floatingParameters)
+ {
+ Motion Resolve(AacCrabMotionRef reference) => MotionOf(reference, clips, trees);
+
+ AacFlFloatParameter FloatParameter(string name)
+ {
+ if (name == null || !floatingParameters.TryGetValue(name, out var parameter))
+ {
+ throw new InvalidOperationException($"Blend tree '{graph.Name}' uses '{name}', which is not a Float parameter.");
+ }
+
+ return parameter;
+ }
+
+ var uninitialized = aac.NewBlendTree(graph.Name);
+ switch (graph.BlendType)
+ {
+ case AacCrabBlendType.Simple1D:
+ {
+ var tree = uninitialized.Simple1D(FloatParameter(graph.ParamX));
+ tree.BlendTree.useAutomaticThresholds = graph.UseAutomaticThresholds;
+ foreach (var child in graph.Children)
+ {
+ tree.WithAnimation(Resolve(child.Motion), child.Threshold ?? 0f);
+ }
+
+ return tree;
+ }
+ case AacCrabBlendType.SimpleDirectional2D:
+ case AacCrabBlendType.FreeformDirectional2D:
+ case AacCrabBlendType.FreeformCartesian2D:
+ {
+ var x = FloatParameter(graph.ParamX);
+ var y = FloatParameter(graph.ParamY);
+ var tree = graph.BlendType == AacCrabBlendType.SimpleDirectional2D
+ ? uninitialized.SimpleDirectional2D(x, y)
+ : graph.BlendType == AacCrabBlendType.FreeformDirectional2D
+ ? uninitialized.FreeformDirectional2D(x, y)
+ : uninitialized.FreeformCartesian2D(x, y);
+ foreach (var child in graph.Children)
+ {
+ tree.WithAnimation(Resolve(child.Motion), child.Threshold ?? 0f, child.ThresholdY ?? 0f);
+ }
+
+ return tree;
+ }
+ case AacCrabBlendType.Direct:
+ {
+ var tree = uninitialized.Direct();
+ foreach (var child in graph.Children)
+ {
+ tree.WithAnimation(Resolve(child.Motion), FloatParameter(child.DirectParam));
+ }
+
+ return tree;
+ }
+ default:
+ throw new InvalidOperationException($"Unknown blend type {graph.BlendType}.");
+ }
+ }
+
+ private static Motion MotionOf(
+ AacCrabMotionRef reference,
+ IReadOnlyDictionary clips,
+ IReadOnlyDictionary trees)
+ {
+ switch (reference.Type)
+ {
+ case AacCrabMotionType.Clip:
+ if (!clips.TryGetValue(reference.Name, out var clip))
+ {
+ throw new InvalidOperationException($"Clip '{reference.Name}' is not declared.");
+ }
+
+ return clip.Clip;
+ case AacCrabMotionType.BlendTree:
+ // Blend trees are built in declaration order, so a tree can only refer to an earlier one.
+ if (!trees.TryGetValue(reference.Name, out var tree))
+ {
+ throw new InvalidOperationException($"Blend tree '{reference.Name}' is not declared yet; declare it before the motion that uses it.");
+ }
+
+ return tree.BlendTree;
+ default:
+ throw new InvalidOperationException($"Unknown motion type {reference.Type}.");
+ }
+ }
+
+ private sealed class MachineScope
+ {
+ public AacFlStateMachine Machine;
+ public AacCrabStateMachine Graph;
+ }
+
+ private static void BuildLayer(
+ AacFlLayer layer,
+ AacCrabLayer graph,
+ IReadOnlyDictionary clips,
+ IReadOnlyDictionary trees)
+ {
+ if (graph.StateMachine == null)
+ {
+ throw new InvalidOperationException($"Layer '{graph.Name}' has no state machine.");
+ }
+
+ Motion Resolve(AacCrabMotionRef reference) => MotionOf(reference, clips, trees);
+
+ // State names are unique within a layer, so a single dictionary resolves every transition,
+ // including the ones that cross state machines.
+ var states = new Dictionary();
+ var scopes = new List();
+
+ AacFlStateMachine CreateMachine(AacFlStateMachine machine, AacCrabStateMachine machineGraph)
+ {
+ scopes.Add(new MachineScope { Machine = machine, Graph = machineGraph });
+ foreach (var state in machineGraph.States)
+ {
+ var aacState = machine.NewState(state.Name, state.Position.X, state.Position.Y);
+ if (state.Motion != null)
+ {
+ aacState.WithAnimation(Resolve(state.Motion));
+ }
+
+ states[state.Name] = aacState;
+ }
+
+ foreach (var subMachine in machineGraph.SubMachines)
+ {
+ CreateMachine(machine.NewSubStateMachine(subMachine.Name, subMachine.Position.X, subMachine.Position.Y), subMachine);
+ }
+
+ return machine;
+ }
+
+ // Create every state first: transitions may point forward.
+ CreateMachine(layer.StateMachine, graph.StateMachine);
+
+ foreach (var scope in scopes)
+ {
+ foreach (var state in scope.Graph.States)
+ {
+ foreach (var transition in state.Transitions)
+ {
+ ApplyTransition(states[state.Name].TransitionsTo(Destination(states, transition)), transition);
+ }
+ }
+
+ foreach (var transition in scope.Graph.AnyStateTransitions)
+ {
+ ApplyTransition(scope.Machine.AnyTransitionsTo(Destination(states, transition)), transition);
+ }
+ }
+ }
+
+ private static AacFlState Destination(IReadOnlyDictionary states, AacCrabTransition transition)
+ {
+ if (!states.TryGetValue(transition.To, out var destination))
+ {
+ throw new InvalidOperationException($"Transition target '{transition.To}' is not a state in this layer.");
+ }
+
+ return destination;
+ }
+
+ private static void ApplyTransition(AacFlTransition transition, AacCrabTransition graph)
+ {
+ transition.WithTransitionDurationSeconds(graph.Duration);
+ if (graph.OrderedInterruption)
+ {
+ transition.WithOrderedInterruption();
+ }
+ else
+ {
+ transition.WithNoOrderedInterruption();
+ }
+
+ if (graph.SourceInterruption)
+ {
+ transition.WithSourceInterruption();
+ }
+
+ if (graph.CanTransitionToSelf)
+ {
+ transition.WithTransitionToSelf();
+ }
+
+ if (graph.HasExitTime)
+ {
+ transition.AfterAnimationIsAtLeastAtNormalized(graph.ExitTime);
+ }
+
+ // Conditions are applied last: Animator As Code forbids configuring a transition afterwards.
+ if (graph.Conditions.Count == 0)
+ {
+ return;
+ }
+
+ var continuation = transition.When(Condition(graph.Conditions[0]));
+ for (var index = 1; index < graph.Conditions.Count; index++)
+ {
+ continuation = continuation.And(Condition(graph.Conditions[index]));
+ }
+ }
+
+ // Animator As Code only exposes typed comparisons for some parameter types, so conditions are
+ // built the same way the library builds them internally.
+ private static IAacFlCondition Condition(AacCrabCondition condition)
+ {
+ var parameter = condition.Parameter;
+ var mode = UnityConditionMode(condition.Mode);
+ var threshold = condition.Threshold;
+ return AacFlConditionSimple.Just(appender => appender.Add(parameter, mode, threshold));
+ }
+
+ private static AnimatorConditionMode UnityConditionMode(AacCrabCondMode mode)
+ {
+ switch (mode)
+ {
+ case AacCrabCondMode.Greater:
+ return AnimatorConditionMode.Greater;
+ case AacCrabCondMode.Less:
+ return AnimatorConditionMode.Less;
+ case AacCrabCondMode.Equals:
+ return AnimatorConditionMode.Equals;
+ case AacCrabCondMode.NotEqual:
+ return AnimatorConditionMode.NotEqual;
+ case AacCrabCondMode.If:
+ return AnimatorConditionMode.If;
+ case AacCrabCondMode.IfNot:
+ return AnimatorConditionMode.IfNot;
+ default:
+ throw new InvalidOperationException($"Unknown condition mode {mode}.");
+ }
+ }
+ }
+}
diff --git a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs
new file mode 100644
index 0000000..a3ce546
--- /dev/null
+++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs
@@ -0,0 +1,192 @@
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Linq;
+using Newtonsoft.Json.Serialization;
+
+namespace AnimatorAsCrab.V1
+{
+ ///
+ /// The wire format produced by the Rust core. Keep in sync with rust/src/graph.rs.
+ /// Property names are snake_cased by the serializer settings, so C# names must only differ
+ /// from the wire format by casing; enum values are explicit.
+ ///
+ public static class AacCrabJson
+ {
+ public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
+ {
+ // A graph the generator does not understand is a bug, not something to ignore.
+ MissingMemberHandling = MissingMemberHandling.Error,
+ ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() },
+ Converters = { new StringEnumConverter() },
+ };
+
+ public static AacCrabGraph Parse(string json)
+ {
+ return JsonConvert.DeserializeObject(json, Settings);
+ }
+ }
+
+ public sealed class AacCrabGraph
+ {
+ public string SystemName { get; set; }
+ public string AssetKey { get; set; }
+ public List Parameters { get; set; }
+ public List Clips { get; set; }
+ public List BlendTrees { get; set; }
+ public AacCrabController Controller { get; set; }
+ }
+
+ public sealed class AacCrabParameter
+ {
+ public AacCrabParameterType Type { get; set; }
+ public string Name { get; set; }
+ public JToken Default { get; set; }
+
+ public float DefaultFloat => Default.Value();
+ public int DefaultInt => Default.Value();
+ public bool DefaultBool => Default.Value();
+ }
+
+ public enum AacCrabParameterType
+ {
+ [EnumMember(Value = "float")] Float,
+ [EnumMember(Value = "int")] Int,
+ [EnumMember(Value = "bool")] Bool,
+ }
+
+ public sealed class AacCrabClip
+ {
+ public string Name { get; set; }
+ public bool Looping { get; set; }
+ public List Curves { get; set; }
+ }
+
+ public sealed class AacCrabCurve
+ {
+ public string Path { get; set; }
+ public AacCrabTargetType Target { get; set; }
+ public string Property { get; set; }
+ public List Keys { get; set; }
+ }
+
+ public enum AacCrabTargetType
+ {
+ [EnumMember(Value = "game_object")] GameObject,
+ [EnumMember(Value = "skinned_mesh_renderer")] SkinnedMeshRenderer,
+ }
+
+ public sealed class AacCrabKeyframe
+ {
+ public float Time { get; set; }
+ public float Value { get; set; }
+ public float InTangent { get; set; }
+ public float OutTangent { get; set; }
+ }
+
+ public sealed class AacCrabBlendTree
+ {
+ public string Name { get; set; }
+ public AacCrabBlendType BlendType { get; set; }
+ public string ParamX { get; set; }
+ public string ParamY { get; set; }
+ public List Children { get; set; }
+ public bool UseAutomaticThresholds { get; set; }
+ }
+
+ public enum AacCrabBlendType
+ {
+ [EnumMember(Value = "simple_1d")] Simple1D,
+ [EnumMember(Value = "simple_directional_2d")] SimpleDirectional2D,
+ [EnumMember(Value = "freeform_directional_2d")] FreeformDirectional2D,
+ [EnumMember(Value = "freeform_cartesian_2d")] FreeformCartesian2D,
+ [EnumMember(Value = "direct")] Direct,
+ }
+
+ public sealed class AacCrabBlendChild
+ {
+ public AacCrabMotionRef Motion { get; set; }
+ public float? Threshold { get; set; }
+ public float? ThresholdY { get; set; }
+ public string DirectParam { get; set; }
+ }
+
+ /// A motion is always a reference: clips and blend trees are declared at the top level.
+ public sealed class AacCrabMotionRef
+ {
+ public AacCrabMotionType Type { get; set; }
+ public string Name { get; set; }
+ }
+
+ public enum AacCrabMotionType
+ {
+ [EnumMember(Value = "clip")] Clip,
+ [EnumMember(Value = "blend_tree")] BlendTree,
+ }
+
+ public sealed class AacCrabController
+ {
+ public List Layers { get; set; }
+ }
+
+ public sealed class AacCrabLayer
+ {
+ public string Name { get; set; }
+ public AacCrabStateMachine StateMachine { get; set; }
+ }
+
+ /// Name is null for the root state machine of a layer.
+ public sealed class AacCrabStateMachine
+ {
+ public string Name { get; set; }
+ public AacCrabGridPos Position { get; set; }
+ public List States { get; set; }
+ public List SubMachines { get; set; }
+ public List AnyStateTransitions { get; set; }
+ }
+
+ public sealed class AacCrabState
+ {
+ public string Name { get; set; }
+ public AacCrabGridPos Position { get; set; }
+ public AacCrabMotionRef Motion { get; set; }
+ public List Transitions { get; set; }
+ }
+
+ public sealed class AacCrabTransition
+ {
+ /// The name of the destination state, within the same layer.
+ public string To { get; set; }
+ public List Conditions { get; set; }
+ public bool HasExitTime { get; set; }
+ public float ExitTime { get; set; }
+ public float Duration { get; set; }
+ public bool OrderedInterruption { get; set; }
+ public bool SourceInterruption { get; set; }
+ public bool CanTransitionToSelf { get; set; }
+ }
+
+ public sealed class AacCrabCondition
+ {
+ public string Parameter { get; set; }
+ public AacCrabCondMode Mode { get; set; }
+ public float Threshold { get; set; }
+ }
+
+ public enum AacCrabCondMode
+ {
+ [EnumMember(Value = "greater")] Greater,
+ [EnumMember(Value = "less")] Less,
+ [EnumMember(Value = "equals")] Equals,
+ [EnumMember(Value = "not_equal")] NotEqual,
+ [EnumMember(Value = "if")] If,
+ [EnumMember(Value = "if_not")] IfNot,
+ }
+
+ public struct AacCrabGridPos
+ {
+ public int X { get; set; }
+ public int Y { get; set; }
+ }
+}
diff --git a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabNative.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabNative.cs
new file mode 100644
index 0000000..0ea7473
--- /dev/null
+++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabNative.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace AnimatorAsCrab.V1
+{
+ ///
+ /// P/Invoke bindings for `libaac` (see rust/src/lib.rs). The native library is expected to live
+ /// in the project's Assets/Plugins folder; place libaac.so, libaac.dll or libaac.dylib there.
+ ///
+ public static class AacCrabNative
+ {
+ private const string Library = "libaac";
+
+ [DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr aac_create();
+
+ [DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
+ private static extern int aac_eval_rhai(IntPtr context, byte[] script);
+
+ [DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr aac_to_json(IntPtr context);
+
+ [DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr aac_last_error(IntPtr context);
+
+ [DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
+ private static extern void aac_free_string(IntPtr value);
+
+ [DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
+ private static extern void aac_destroy(IntPtr context);
+
+ ///
+ /// Evaluate a Rhai script and return the graph as JSON, or null with a message in
+ /// . The context lives only for the duration of this call.
+ ///
+ public static string Evaluate(string script, out string error)
+ {
+ IntPtr context;
+ try
+ {
+ context = aac_create();
+ }
+ catch (DllNotFoundException exception)
+ {
+ error = $"Could not load {Library}: {exception.Message}. Place the native library in Assets/Plugins.";
+ return null;
+ }
+
+ if (context == IntPtr.Zero)
+ {
+ error = "aac_create returned null.";
+ return null;
+ }
+
+ try
+ {
+ var status = aac_eval_rhai(context, Utf8Z(script));
+ if (status != 0)
+ {
+ error = ReadUtf8(aac_last_error(context)) ?? $"The script failed with status {status}.";
+ return null;
+ }
+
+ var json = aac_to_json(context);
+ if (json == IntPtr.Zero)
+ {
+ error = ReadUtf8(aac_last_error(context)) ?? "The graph could not be serialized.";
+ return null;
+ }
+
+ try
+ {
+ error = null;
+ return ReadUtf8(json);
+ }
+ finally
+ {
+ aac_free_string(json);
+ }
+ }
+ finally
+ {
+ aac_destroy(context);
+ }
+ }
+
+ private static byte[] Utf8Z(string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ var terminated = new byte[bytes.Length + 1];
+ Array.Copy(bytes, terminated, bytes.Length);
+ return terminated;
+ }
+
+ private static string ReadUtf8(IntPtr pointer)
+ {
+ if (pointer == IntPtr.Zero)
+ {
+ return null;
+ }
+
+ // Marshal.PtrToStringUTF8 is not available on all Unity runtimes, so the bytes are copied by hand.
+ var length = 0;
+ while (Marshal.ReadByte(pointer, length) != 0)
+ {
+ length++;
+ }
+
+ var bytes = new byte[length];
+ Marshal.Copy(pointer, bytes, 0, length);
+ return Encoding.UTF8.GetString(bytes);
+ }
+ }
+}
diff --git a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabWindow.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabWindow.cs
new file mode 100644
index 0000000..1a61226
--- /dev/null
+++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabWindow.cs
@@ -0,0 +1,201 @@
+using System;
+using System.IO;
+using UnityEditor;
+using UnityEditor.Animations;
+using UnityEngine;
+
+namespace AnimatorAsCrab.V1
+{
+ ///
+ /// Evaluates a Rhai script with libaac and generates the Animator Controller through Animator As Code.
+ /// The Unity-side configuration is never guessed: every field below is supplied by the user, and the
+ /// system name and asset key are declared by the script itself.
+ ///
+ public class AacCrabWindow : EditorWindow
+ {
+ [SerializeField] private string _scriptPath = "";
+ [SerializeField] private bool _generateOnScriptChange = true;
+ [SerializeField] private AnimatorController _controller;
+ [SerializeField] private Transform _animatorRoot;
+ [SerializeField] private UnityEngine.Object _assetContainer;
+ [SerializeField] private AacConfiguration.Container _containerMode = AacConfiguration.Container.Everything;
+ [SerializeField] private bool _writeDefaults;
+ [SerializeField] private long _lastWriteUtcTicks;
+
+ private string _error;
+ private string _status;
+ private bool _scriptChanged;
+
+ [MenuItem("Tools/Animator As Crab")]
+ public static void Open()
+ {
+ var window = GetWindow();
+ window.titleContent = new GUIContent("Animator As Crab");
+ window.Show();
+ }
+
+ private void OnEnable()
+ {
+ EditorApplication.update += OnUpdate;
+ _scriptChanged = _lastWriteUtcTicks != 0 && _lastWriteUtcTicks != LastWriteUtcTicks();
+ _lastWriteUtcTicks = LastWriteUtcTicks();
+ }
+
+ private void OnDisable()
+ {
+ EditorApplication.update -= OnUpdate;
+ }
+
+ private void OnUpdate()
+ {
+ if (string.IsNullOrEmpty(_scriptPath) || !File.Exists(_scriptPath))
+ {
+ return;
+ }
+
+ var ticks = LastWriteUtcTicks();
+ if (ticks == _lastWriteUtcTicks)
+ {
+ return;
+ }
+
+ _lastWriteUtcTicks = ticks;
+ _scriptChanged = true;
+ if (_generateOnScriptChange)
+ {
+ Generate();
+ }
+ else
+ {
+ Repaint();
+ }
+ }
+
+ private void OnGUI()
+ {
+ EditorGUILayout.LabelField("Rhai script", EditorStyles.boldLabel);
+ EditorGUILayout.BeginHorizontal();
+ _scriptPath = EditorGUILayout.TextField(_scriptPath);
+ if (GUILayout.Button("...", GUILayout.Width(28)))
+ {
+ var directory = string.IsNullOrEmpty(_scriptPath) ? null : Path.GetDirectoryName(_scriptPath);
+ var picked = EditorUtility.OpenFilePanel("Rhai script", directory ?? string.Empty, "rhai");
+ if (!string.IsNullOrEmpty(picked))
+ {
+ _scriptPath = picked;
+ _lastWriteUtcTicks = LastWriteUtcTicks();
+ _scriptChanged = false;
+ Generate();
+ }
+ }
+
+ EditorGUILayout.EndHorizontal();
+ _generateOnScriptChange = EditorGUILayout.Toggle("Generate when the script changes", _generateOnScriptChange);
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("Unity side", EditorStyles.boldLabel);
+ _controller = (AnimatorController)EditorGUILayout.ObjectField("Animator Controller", _controller, typeof(AnimatorController), false);
+ _animatorRoot = (Transform)EditorGUILayout.ObjectField("Animator Root", _animatorRoot, typeof(Transform), true);
+ _assetContainer = EditorGUILayout.ObjectField("Asset Container", _assetContainer, typeof(UnityEngine.Object), false);
+ _containerMode = (AacConfiguration.Container)EditorGUILayout.EnumPopup("Container Mode", _containerMode);
+ _writeDefaults = EditorGUILayout.Toggle("Write Defaults", _writeDefaults);
+
+ EditorGUILayout.Space();
+ if (GUILayout.Button("Generate", GUILayout.Height(24)))
+ {
+ Generate();
+ }
+
+ EditorGUILayout.Space();
+ if (_error != null)
+ {
+ EditorGUILayout.HelpBox(_error, MessageType.Error);
+ }
+ else if (_scriptChanged)
+ {
+ EditorGUILayout.HelpBox("The script changed since the last generation.", MessageType.Warning);
+ }
+
+ if (_status != null)
+ {
+ EditorGUILayout.HelpBox(_status, MessageType.Info);
+ }
+ }
+
+ private void Generate()
+ {
+ _error = null;
+ _status = null;
+ _scriptChanged = false;
+ try
+ {
+ _status = GenerateOrThrow();
+ }
+ catch (Exception exception)
+ {
+ _error = exception.Message;
+ Debug.LogException(exception);
+ }
+
+ Repaint();
+ }
+
+ private string GenerateOrThrow()
+ {
+ if (string.IsNullOrEmpty(_scriptPath))
+ {
+ throw new InvalidOperationException("Select a Rhai script.");
+ }
+
+ if (!File.Exists(_scriptPath))
+ {
+ throw new InvalidOperationException($"'{_scriptPath}' does not exist.");
+ }
+
+ if (_controller == null)
+ {
+ throw new InvalidOperationException("Select the Animator Controller to generate into.");
+ }
+
+ if (_animatorRoot == null)
+ {
+ throw new InvalidOperationException("Select the animator root Transform.");
+ }
+
+ if (_assetContainer == null)
+ {
+ throw new InvalidOperationException("Select the asset container that will hold the generated clips and blend trees.");
+ }
+
+ var json = AacCrabNative.Evaluate(File.ReadAllText(_scriptPath), out var nativeError);
+ if (json == null)
+ {
+ throw new InvalidOperationException(nativeError);
+ }
+
+ var graph = AacCrabJson.Parse(json);
+ var configuration = new AacConfiguration
+ {
+ SystemName = graph.SystemName,
+ AssetKey = graph.AssetKey,
+ AnimatorRoot = _animatorRoot,
+ AssetContainer = _assetContainer,
+ ContainerMode = _containerMode,
+ DefaultsProvider = new AacDefaultsProvider(_writeDefaults),
+ };
+
+ AacCrabGenerator.Generate(graph, configuration, _controller);
+ AssetDatabase.SaveAssets();
+
+ return $"Generated '{graph.SystemName}' with asset key '{graph.AssetKey}': " +
+ $"{graph.Controller.Layers.Count} layer(s), {graph.Clips.Count} clip(s), {graph.BlendTrees.Count} blend tree(s).";
+ }
+
+ private long LastWriteUtcTicks()
+ {
+ return string.IsNullOrEmpty(_scriptPath) || !File.Exists(_scriptPath)
+ ? 0
+ : File.GetLastWriteTimeUtc(_scriptPath).Ticks;
+ }
+ }
+}
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/LICENSE b/csharp/dev.doloro.animator-as-crab/V1/Editor/LICENSE
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/LICENSE
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/LICENSE
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/LICENSE.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/LICENSE.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/LICENSE.meta
rename to csharp/dev.doloro.animator-as-crab/V1/Editor/LICENSE.meta
diff --git a/csharp/dev.doloro.animator-as-crab/package.json b/csharp/dev.doloro.animator-as-crab/package.json
new file mode 100644
index 0000000..5deb217
--- /dev/null
+++ b/csharp/dev.doloro.animator-as-crab/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "dev.doloro.animator-as-crab",
+ "displayName": "Animator As Crab",
+ "version": "0.1.0",
+ "unity": "2019.4",
+ "description": "Writes Animator Controllers from a Rhai script, evaluated by the libaac native library.",
+ "vrchatVersion" : "2022.1.1",
+ "dependencies": {
+ "com.unity.nuget.newtonsoft-json": "3.2.1"
+ },
+ "author" : {
+ "name" : "doloro"
+ },
+ "license": "MIT"
+}
diff --git a/Packages/dev.hai-vr.animator-as-code.v1/package.json.meta b/csharp/dev.doloro.animator-as-crab/package.json.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1/package.json.meta
rename to csharp/dev.doloro.animator-as-crab/package.json.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs.meta
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/package.json b/csharp/dev.hai-vr.animator-as-code.v1.base.test/package.json
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/package.json
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/package.json
diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/package.json.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/package.json.meta
similarity index 100%
rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/package.json.meta
rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/package.json.meta
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..452a155
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,64 @@
+{
+ "nodes": {
+ "crane": {
+ "locked": {
+ "lastModified": 1788465171,
+ "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=",
+ "owner": "ipetkov",
+ "repo": "crane",
+ "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54",
+ "type": "github"
+ },
+ "original": {
+ "owner": "ipetkov",
+ "repo": "crane",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1789546076,
+ "narHash": "sha256-zVxLZiSnmaaPLwnhj7pwmqe3axBg/C6nG5JZsJMh2g4=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "b1b875982b17dabde9b4a37f3e229e74913e6db3",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "crane": "crane",
+ "nixpkgs": "nixpkgs",
+ "rust-overlay": "rust-overlay"
+ }
+ },
+ "rust-overlay": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1789715798,
+ "narHash": "sha256-hWw9vlrsFm9kOw8MT5k6IX/7xCzgROuJ6ZvQOrkq66Q=",
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "rev": "fbdb2de9e7619d660ae7e8f752f38b261020b701",
+ "type": "github"
+ },
+ "original": {
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..806fecc
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,77 @@
+{
+ description = "animator-as-crab: Animator Controllers described in Rhai, generated from Rust into Unity";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ crane.url = "github:ipetkov/crane";
+ rust-overlay = {
+ url = "github:oxalica/rust-overlay";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs =
+ {
+ self,
+ nixpkgs,
+ crane,
+ rust-overlay,
+ }:
+ let
+ systems = [
+ "x86_64-linux"
+ "aarch64-linux"
+ "x86_64-darwin"
+ "aarch64-darwin"
+ ];
+ forAllSystems = nixpkgs.lib.genAttrs systems;
+
+ perSystem =
+ system:
+ let
+ pkgs = import nixpkgs {
+ inherit system;
+ overlays = [ (import rust-overlay) ];
+ };
+ rustToolchain = pkgs.rust-bin.stable.latest.default.override {
+ extensions = [
+ "rust-src"
+ "rust-analyzer"
+ "clippy"
+ "rustfmt"
+ ];
+ };
+ craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
+
+ # The Rust core: evaluates the Rhai DSL and emits the graph as JSON for Unity.
+ aac = craneLib.buildPackage {
+ src = ./rust;
+ strictDeps = true;
+ doCheck = true;
+ };
+ in
+ {
+ packages = {
+ inherit aac;
+ default = aac;
+ };
+
+ devShells.default = craneLib.devShell {
+ inputsFrom = [ aac ];
+ packages = [
+ rustToolchain
+ pkgs.dotnet-sdk
+ pkgs.mono
+ pkgs.jq
+ ];
+ };
+
+ formatter = pkgs.nixfmt-rfc-style;
+ };
+ in
+ {
+ packages = forAllSystems (system: (perSystem system).packages);
+ devShells = forAllSystems (system: (perSystem system).devShells);
+ formatter = forAllSystems (system: (perSystem system).formatter);
+ };
+}
diff --git a/rust/.gitignore b/rust/.gitignore
new file mode 100644
index 0000000..2f7896d
--- /dev/null
+++ b/rust/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
new file mode 100644
index 0000000..c6c7486
--- /dev/null
+++ b/rust/Cargo.lock
@@ -0,0 +1,463 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aac"
+version = "0.1.0"
+dependencies = [
+ "rhai",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "const-random",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "tiny-keccak",
+]
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rhai"
+version = "1.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0334639972c0ea5a3fd366aa36116754a11431b619fec3ed559b3f73bcbcebf5"
+dependencies = [
+ "ahash",
+ "bitflags",
+ "num-traits",
+ "once_cell",
+ "rhai_codegen",
+ "smallvec",
+ "smartstring",
+ "thin-vec",
+ "web-time",
+]
+
+[[package]]
+name = "rhai_codegen"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
+
+[[package]]
+name = "smartstring"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
+dependencies = [
+ "autocfg",
+ "static_assertions",
+ "version_check",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thin-vec"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748"
+
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
new file mode 100644
index 0000000..aff6fa9
--- /dev/null
+++ b/rust/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "aac"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+name = "aac"
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+rhai = "1.26"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
diff --git a/rust/examples/avatar.rhai b/rust/examples/avatar.rhai
new file mode 100644
index 0000000..8670f27
--- /dev/null
+++ b/rust/examples/avatar.rhai
@@ -0,0 +1,58 @@
+// An Animator Controller described as Rhai.
+//
+// cargo run --bin aac-dump -- rust/examples/avatar.rhai
+//
+// Combine conditions with `when_all([...])`: Rhai's `&&` cannot be overloaded, so
+// `when(a > 0.1 && b)` is not valid.
+
+let aac = AnimatorAsCode();
+aac.system_name("MyAvatar");
+aac.asset_key("AAC_");
+
+let speed = aac.float_param("Speed", 0.0);
+let is_sitting = aac.bool_param("IsSitting", false);
+let gesture = aac.int_param("Gesture", 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 run = base.state("Run", 2, 0);
+let sit = base.state("Sit", 0, 1);
+
+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);
+
+idle.set_clip(idle_clip);
+walk.set_clip(walk_clip);
+
+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);
+
+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);
+
+base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2);
+
+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);
diff --git a/rust/src/bin/aac-dump.rs b/rust/src/bin/aac-dump.rs
new file mode 100644
index 0000000..14b2ebe
--- /dev/null
+++ b/rust/src/bin/aac-dump.rs
@@ -0,0 +1,26 @@
+//! Evaluate a `.rhai` script and print the generated JSON.
+//!
+//! ```text
+//! cargo run --bin aac-dump -- rust/examples/avatar.rhai
+//! ```
+
+fn main() {
+ let Some(path) = std::env::args().nth(1) else {
+ eprintln!("usage: aac-dump ");
+ std::process::exit(2);
+ };
+ let script = match std::fs::read_to_string(&path) {
+ Ok(script) => script,
+ Err(error) => {
+ eprintln!("cannot read {path}: {error}");
+ std::process::exit(2);
+ }
+ };
+ match aac::evaluate_to_json(&script) {
+ Ok(json) => println!("{json}"),
+ Err(error) => {
+ eprintln!("{error}");
+ std::process::exit(1);
+ }
+ }
+}
diff --git a/rust/src/builder.rs b/rust/src/builder.rs
new file mode 100644
index 0000000..b5b40be
--- /dev/null
+++ b/rust/src/builder.rs
@@ -0,0 +1,234 @@
+use crate::graph::*;
+use std::sync::{Arc, Mutex, MutexGuard};
+
+/// Shared, thread-safe handle to the graph being built. Every builder carries a clone.
+#[derive(Clone)]
+pub struct Aac {
+ pub(crate) graph: Arc>,
+}
+
+impl Default for Aac {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Aac {
+ pub fn new() -> Self {
+ Self {
+ graph: Arc::new(Mutex::new(ControllerGraph::default())),
+ }
+ }
+
+ /// A poisoned lock still holds a usable graph; recovering avoids panicking across the FFI boundary.
+ fn lock(&self) -> MutexGuard<'_, ControllerGraph> {
+ self.graph.lock().unwrap_or_else(|e| e.into_inner())
+ }
+
+ pub fn write(&self, f: impl FnOnce(&mut ControllerGraph) -> R) -> R {
+ f(&mut self.lock())
+ }
+}
+
+/// Parameter handles only carry the name; conditions read nothing else from them.
+#[derive(Clone)]
+pub struct FloatParam {
+ pub(crate) name: String,
+}
+
+#[derive(Clone)]
+pub struct IntParam {
+ pub(crate) name: String,
+}
+
+#[derive(Clone)]
+pub struct BoolParam {
+ pub(crate) name: String,
+}
+
+#[derive(Clone)]
+pub struct ControllerBuilder {
+ pub(crate) aac: Aac,
+}
+
+#[derive(Clone)]
+pub struct LayerBuilder {
+ pub(crate) aac: Aac,
+ pub(crate) layer: usize,
+}
+
+/// A state machine. `machine` is the chain of sub-machine indices from the layer root, so the empty
+/// chain is the layer's root machine and indices stay stable because machines are only ever appended.
+#[derive(Clone)]
+pub struct MachineRef {
+ pub(crate) aac: Aac,
+ pub(crate) layer: usize,
+ pub(crate) machine: Vec,
+}
+
+#[derive(Clone)]
+pub struct StateRef {
+ pub(crate) aac: Aac,
+ pub(crate) layer: usize,
+ pub(crate) machine: Vec,
+ pub(crate) state: usize,
+}
+
+#[derive(Clone)]
+pub struct AnyStateRef {
+ pub(crate) aac: Aac,
+ pub(crate) layer: usize,
+ pub(crate) machine: Vec,
+}
+
+#[derive(Clone)]
+pub struct ClipBuilder {
+ pub(crate) aac: Aac,
+ pub(crate) name: String,
+}
+
+#[derive(Clone)]
+pub struct BlendTreeBuilder {
+ pub(crate) aac: Aac,
+ pub(crate) name: String,
+}
+
+/// `source` is the originating state, or `None` for an any-state transition.
+#[derive(Clone)]
+pub struct TransitionRef {
+ pub(crate) aac: Aac,
+ pub(crate) layer: usize,
+ pub(crate) machine: Vec,
+ pub(crate) source: Option,
+ pub(crate) index: usize,
+}
+
+pub(crate) fn machine_mut<'a>(
+ graph: &'a mut ControllerGraph,
+ layer: usize,
+ path: &[usize],
+) -> &'a mut StateMachine {
+ let mut machine = &mut graph.controller.layers[layer].state_machine;
+ for &index in path {
+ machine = &mut machine.sub_machines[index];
+ }
+ machine
+}
+
+pub(crate) fn clip_mut<'a>(graph: &'a mut ControllerGraph, name: &str) -> Option<&'a mut ClipData> {
+ graph.clips.iter_mut().find(|clip| clip.name == name)
+}
+
+pub(crate) fn blend_tree_mut<'a>(
+ graph: &'a mut ControllerGraph,
+ name: &str,
+) -> Option<&'a mut BlendTreeData> {
+ graph.blend_trees.iter_mut().find(|tree| tree.name == name)
+}
+
+impl StateRef {
+ pub fn state_name(&self) -> String {
+ self.aac
+ .write(|graph| machine_mut(graph, self.layer, &self.machine).states[self.state].name.clone())
+ }
+
+ pub fn set_motion(&self, motion: MotionRef) {
+ self.aac.write(|graph| {
+ machine_mut(graph, self.layer, &self.machine).states[self.state].motion = Some(motion);
+ });
+ }
+
+ pub fn transition_to(&self, target: &StateRef) -> TransitionRef {
+ let to = target.state_name();
+ let index = self.aac.write(|graph| {
+ let state = &mut machine_mut(graph, self.layer, &self.machine).states[self.state];
+ state.transitions.push(Transition::new(to));
+ state.transitions.len() - 1
+ });
+ TransitionRef {
+ aac: self.aac.clone(),
+ layer: self.layer,
+ machine: self.machine.clone(),
+ source: Some(self.state),
+ index,
+ }
+ }
+}
+
+impl AnyStateRef {
+ pub fn transition_to(&self, target: &StateRef) -> TransitionRef {
+ let to = target.state_name();
+ let index = self.aac.write(|graph| {
+ let machine = machine_mut(graph, self.layer, &self.machine);
+ machine.any_state_transitions.push(Transition::new(to));
+ machine.any_state_transitions.len() - 1
+ });
+ TransitionRef {
+ aac: self.aac.clone(),
+ layer: self.layer,
+ machine: self.machine.clone(),
+ source: None,
+ index,
+ }
+ }
+}
+
+impl TransitionRef {
+ /// Apply a mutation to the transition this handle points at.
+ pub fn update(&self, f: impl FnOnce(&mut Transition)) {
+ self.aac.write(|graph| {
+ let machine = machine_mut(graph, self.layer, &self.machine);
+ let transitions = match self.source {
+ Some(state) => &mut machine.states[state].transitions,
+ None => &mut machine.any_state_transitions,
+ };
+ f(&mut transitions[self.index]);
+ });
+ }
+
+ pub fn add_conditions(&self, conditions: impl IntoIterator- ) {
+ self.update(|transition| transition.conditions.extend(conditions));
+ }
+}
+
+impl ClipBuilder {
+ pub fn data_mut(&self, f: impl FnOnce(&mut ClipData) -> R) -> Option {
+ let name = self.name.clone();
+ self.aac.write(|graph| clip_mut(graph, &name).map(f))
+ }
+
+ /// Append one keyframe to the curve for (path, property), creating the curve if needed.
+ pub fn push_key(&self, path: &str, property: &str, key: Keyframe) -> Result<(), String> {
+ let target = infer_target(property).ok_or_else(|| {
+ format!(
+ "cannot tell which component `{property}` belongs to; \
+ known properties are `m_IsActive` and `blendShape.*`"
+ )
+ })?;
+ self.data_mut(|clip| {
+ clip.curve_mut(path, target, property).keys.push(key);
+ })
+ .ok_or_else(|| format!("unknown clip `{}`", self.name))
+ }
+}
+
+impl BlendTreeBuilder {
+ pub fn data_mut(&self, f: impl FnOnce(&mut BlendTreeData) -> R) -> Option {
+ let name = self.name.clone();
+ self.aac.write(|graph| blend_tree_mut(graph, &name).map(f))
+ }
+
+ pub fn configure(&self, blend_type: BlendType, param_x: &str, param_y: Option<&str>) -> Result<(), String> {
+ self.data_mut(|tree| {
+ tree.blend_type = blend_type;
+ tree.param_x = param_x.to_string();
+ tree.param_y = param_y.map(str::to_string);
+ })
+ .ok_or_else(|| format!("unknown blend tree `{}`", self.name))
+ }
+
+ pub fn push_child(&self, child: BlendChild) -> Result<(), String> {
+ self.data_mut(|tree| tree.children.push(child))
+ .ok_or_else(|| format!("unknown blend tree `{}`", self.name))
+ }
+}
diff --git a/rust/src/export.rs b/rust/src/export.rs
new file mode 100644
index 0000000..7d2e96c
--- /dev/null
+++ b/rust/src/export.rs
@@ -0,0 +1,325 @@
+use crate::graph::*;
+use std::collections::{HashMap, HashSet};
+
+/// Sort keyframes into time order, then validate, then serialize.
+pub fn to_json(graph: &ControllerGraph) -> Result {
+ let mut graph = graph.clone();
+ normalize(&mut graph);
+ validate(&graph)?;
+ serde_json::to_string_pretty(&graph).map_err(|e| format!("serialization failed: {e}"))
+}
+
+fn normalize(graph: &mut ControllerGraph) {
+ for clip in &mut graph.clips {
+ for curve in &mut clip.curves {
+ curve
+ .keys
+ .sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap_or(std::cmp::Ordering::Equal));
+ }
+ }
+}
+
+fn validate(graph: &ControllerGraph) -> Result<(), String> {
+ let mut errors = Vec::new();
+
+ if graph.system_name.trim().is_empty() {
+ errors.push("system_name is not set (call `aac.system_name(\"...\")`)".to_string());
+ }
+ if graph.asset_key.trim().is_empty() {
+ errors.push("asset_key is not set (call `aac.asset_key(\"...\")`)".to_string());
+ }
+
+ let parameters = parameter_types(graph, &mut errors);
+ validate_clips(graph, &mut errors);
+ validate_blend_trees(graph, ¶meters, &mut errors);
+ validate_layers(graph, ¶meters, &mut errors);
+
+ if errors.is_empty() {
+ Ok(())
+ } else {
+ Err(format!(
+ "graph is invalid ({} problem{}):\n- {}",
+ errors.len(),
+ if errors.len() == 1 { "" } else { "s" },
+ errors.join("\n- ")
+ ))
+ }
+}
+
+#[derive(Clone, Copy, PartialEq)]
+enum ParamType {
+ Float,
+ Int,
+ Bool,
+}
+
+fn parameter_types(graph: &ControllerGraph, errors: &mut Vec) -> HashMap {
+ let mut seen = HashSet::new();
+ let mut map = HashMap::new();
+ for parameter in &graph.parameters {
+ let name = parameter.name();
+ if name.trim().is_empty() {
+ errors.push("a parameter has an empty name".to_string());
+ continue;
+ }
+ if !seen.insert(name.to_string()) {
+ errors.push(format!("parameter `{name}` is declared more than once"));
+ }
+ let ty = match parameter {
+ Parameter::Float { .. } => ParamType::Float,
+ Parameter::Int { .. } => ParamType::Int,
+ Parameter::Bool { .. } => ParamType::Bool,
+ };
+ map.insert(name.to_string(), ty);
+ }
+ map
+}
+
+fn validate_clips(graph: &ControllerGraph, errors: &mut Vec) {
+ let mut seen = HashSet::new();
+ for clip in &graph.clips {
+ if clip.name.trim().is_empty() {
+ errors.push("a clip has an empty name".to_string());
+ } else if !seen.insert(clip.name.clone()) {
+ errors.push(format!("clip `{}` is declared more than once", clip.name));
+ }
+ for curve in &clip.curves {
+ if curve.path.trim().is_empty() {
+ errors.push(format!("clip `{}` has a curve with an empty path", clip.name));
+ }
+ if curve.keys.is_empty() {
+ errors.push(format!(
+ "clip `{}` curve `{}` on `{}` has no keyframes",
+ clip.name, curve.property, curve.path
+ ));
+ }
+ for key in &curve.keys {
+ if !key.time.is_finite() || !key.value.is_finite() {
+ errors.push(format!(
+ "clip `{}` curve `{}` on `{}` has a non-finite keyframe",
+ clip.name, curve.property, curve.path
+ ));
+ }
+ }
+ }
+ }
+}
+
+fn validate_blend_trees(
+ graph: &ControllerGraph,
+ parameters: &HashMap,
+ errors: &mut Vec,
+) {
+ let mut seen = HashSet::new();
+ for tree in &graph.blend_trees {
+ if tree.name.trim().is_empty() {
+ errors.push("a blend tree has an empty name".to_string());
+ } else if !seen.insert(tree.name.clone()) {
+ errors.push(format!("blend tree `{}` is declared more than once", tree.name));
+ }
+ let has_y = tree.param_y.as_ref().is_some_and(|y| !y.trim().is_empty());
+ if tree.blend_type == BlendType::Direct {
+ if !tree.param_x.trim().is_empty() || tree.param_y.is_some() {
+ errors.push(format!(
+ "blend tree `{}` is Direct and must not have blend parameters",
+ tree.name
+ ));
+ }
+ } else {
+ if tree.param_x.trim().is_empty() {
+ errors.push(format!("blend tree `{}` has no x parameter", tree.name));
+ } else {
+ expect_param(parameters, &tree.param_x, ParamType::Float, &tree.name, "x", errors);
+ }
+ if tree.blend_type.is_2d() && !has_y {
+ errors.push(format!(
+ "blend tree `{}` is 2D and needs a y parameter",
+ tree.name
+ ));
+ }
+ if !tree.blend_type.is_2d() && has_y {
+ errors.push(format!(
+ "blend tree `{}` is {:?} and must not have a y parameter",
+ tree.name, tree.blend_type
+ ));
+ }
+ if tree.blend_type.is_2d() {
+ if let Some(y) = &tree.param_y {
+ expect_param(parameters, y, ParamType::Float, &tree.name, "y", errors);
+ }
+ }
+ }
+
+ if tree.children.is_empty() {
+ errors.push(format!("blend tree `{}` has no children", tree.name));
+ }
+ for child in &tree.children {
+ expect_motion(&child.motion, graph, errors);
+ if tree.blend_type == BlendType::Direct {
+ match &child.direct_param {
+ None => errors.push(format!(
+ "blend tree `{}` is Direct and a child has no direct parameter",
+ tree.name
+ )),
+ Some(p) => expect_param(parameters, p, ParamType::Float, &tree.name, "direct", errors),
+ }
+ } else if child.direct_param.is_some() {
+ errors.push(format!(
+ "blend tree `{}` is {:?} and thresholds are used, so a child must not have a direct parameter",
+ tree.name, tree.blend_type
+ ));
+ }
+ }
+ }
+}
+
+fn validate_layers(
+ graph: &ControllerGraph,
+ parameters: &HashMap,
+ errors: &mut Vec,
+) {
+ let mut layer_names = HashSet::new();
+ for layer in &graph.controller.layers {
+ let label = if layer.name.trim().is_empty() {
+ errors.push("a layer has an empty name".to_string());
+ "".to_string()
+ } else {
+ if !layer_names.insert(layer.name.clone()) {
+ errors.push(format!("layer `{}` is declared more than once", layer.name));
+ }
+ layer.name.clone()
+ };
+
+ // Transitions cannot cross layers, so resolution is per-layer.
+ let mut machines = Vec::new();
+ walk_machines(&layer.state_machine, &label, &mut machines);
+
+ let mut state_names = HashSet::new();
+ for (machine_label, machine) in &machines {
+ for state in &machine.states {
+ if state.name.trim().is_empty() {
+ errors.push(format!("machine `{machine_label}` has a state with an empty name"));
+ } else if !state_names.insert(state.name.clone()) {
+ errors.push(format!(
+ "state `{}` is declared more than once in layer `{label}`",
+ state.name
+ ));
+ }
+ }
+ }
+
+ for (machine_label, machine) in &machines {
+ for state in &machine.states {
+ if let Some(motion) = &state.motion {
+ expect_motion(motion, graph, errors);
+ }
+ for transition in &state.transitions {
+ validate_transition(
+ transition,
+ &format!("state `{}`", state.name),
+ &state_names,
+ parameters,
+ errors,
+ );
+ }
+ }
+ for transition in &machine.any_state_transitions {
+ validate_transition(
+ transition,
+ &format!("machine `{machine_label}` any-state"),
+ &state_names,
+ parameters,
+ errors,
+ );
+ }
+ }
+ }
+}
+
+/// State names are collected in a first pass so that forward references resolve.
+fn walk_machines<'a>(machine: &'a StateMachine, label: &str, out: &mut Vec<(String, &'a StateMachine)>) {
+ out.push((label.to_string(), machine));
+ for sub in &machine.sub_machines {
+ let name = sub.name.as_deref().unwrap_or("");
+ walk_machines(sub, &format!("{label}/{name}"), out);
+ }
+}
+
+fn validate_transition(
+ transition: &Transition,
+ origin: &str,
+ state_names: &HashSet,
+ parameters: &HashMap,
+ errors: &mut Vec,
+) {
+ if !state_names.contains(&transition.to) {
+ errors.push(format!(
+ "{origin} transitions to `{}`, which is not a state in that layer",
+ transition.to
+ ));
+ }
+ if !transition.duration.is_finite() || !transition.exit_time.is_finite() {
+ errors.push(format!("{origin} has a non-finite duration or exit time"));
+ }
+ if !transition.conditions.is_empty() && transition.has_exit_time {
+ errors.push(format!(
+ "{origin} has both conditions and an exit time; Unity will ignore the conditions"
+ ));
+ }
+ for condition in &transition.conditions {
+ let Some(ty) = parameters.get(&condition.parameter) else {
+ errors.push(format!(
+ "{origin} uses parameter `{}`, which is not declared",
+ condition.parameter
+ ));
+ continue;
+ };
+ if !condition.threshold.is_finite() {
+ errors.push(format!("{origin} has a non-finite condition threshold"));
+ }
+ let ok = match condition.mode {
+ CondMode::If | CondMode::IfNot => *ty == ParamType::Bool,
+ CondMode::Greater | CondMode::Less => *ty != ParamType::Bool,
+ CondMode::Equals | CondMode::NotEqual => true,
+ };
+ if !ok {
+ errors.push(format!(
+ "{origin} uses {:?} on `{}`, but that parameter cannot be compared that way",
+ condition.mode, condition.parameter
+ ));
+ }
+ }
+}
+
+fn expect_motion(motion: &MotionRef, graph: &ControllerGraph, errors: &mut Vec) {
+ let (kind, name, exists) = match motion {
+ MotionRef::Clip { name } => ("clip", name, graph.clips.iter().any(|c| &c.name == name)),
+ MotionRef::BlendTree { name } => (
+ "blend tree",
+ name,
+ graph.blend_trees.iter().any(|t| &t.name == name),
+ ),
+ };
+ if !exists {
+ errors.push(format!("reference to {kind} `{name}`, which is not declared"));
+ }
+}
+
+fn expect_param(
+ parameters: &HashMap,
+ name: &str,
+ expected: ParamType,
+ owner: &str,
+ role: &str,
+ errors: &mut Vec,
+) {
+ match parameters.get(name) {
+ None => errors.push(format!(
+ "blend tree `{owner}` uses `{name}` as its {role} parameter, which is not declared"
+ )),
+ Some(actual) if *actual != expected => errors.push(format!(
+ "blend tree `{owner}` uses `{name}` as its {role} parameter, which is not a float parameter"
+ )),
+ Some(_) => {}
+ }
+}
diff --git a/rust/src/graph.rs b/rust/src/graph.rs
new file mode 100644
index 0000000..4666cac
--- /dev/null
+++ b/rust/src/graph.rs
@@ -0,0 +1,263 @@
+use serde::{Deserialize, Serialize};
+
+/// The complete description of one animator controller, produced entirely by a Rhai script.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct ControllerGraph {
+ pub system_name: String,
+ pub asset_key: String,
+ pub parameters: Vec,
+ pub clips: Vec,
+ pub blend_trees: Vec,
+ pub controller: Controller,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct Controller {
+ pub layers: Vec,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct Layer {
+ /// The layer suffix as written in the script. Unity's layer name is derived from it by the C# side.
+ pub name: String,
+ pub state_machine: StateMachine,
+}
+
+/// Grid position in Unity's animator graph. Serialized as an object so C# can bind it directly.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
+pub struct GridPos {
+ pub x: i32,
+ pub y: i32,
+}
+
+impl GridPos {
+ pub fn new(x: i32, y: i32) -> Self {
+ Self { x, y }
+ }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct StateMachine {
+ /// `None` for a layer's root machine, `Some` for a sub-state machine.
+ pub name: Option,
+ pub position: GridPos,
+ pub states: Vec,
+ pub sub_machines: Vec,
+ pub any_state_transitions: Vec,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct State {
+ pub name: String,
+ pub position: GridPos,
+ pub motion: Option,
+ pub transitions: Vec,
+}
+
+/// References to entries in `ControllerGraph::clips` / `ControllerGraph::blend_trees`.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum MotionRef {
+ Clip { name: String },
+ BlendTree { name: String },
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Transition {
+ pub to: String,
+ pub conditions: Vec,
+ pub has_exit_time: bool,
+ pub exit_time: f32,
+ pub duration: f32,
+ pub ordered_interruption: bool,
+ pub source_interruption: bool,
+ pub can_transition_to_self: bool,
+}
+
+impl Transition {
+ /// Mirrors `AacDefaultsProvider.ConfigureTransition`, so unspecified fields match library defaults.
+ pub fn new(to: impl Into) -> Self {
+ Self {
+ to: to.into(),
+ conditions: Vec::new(),
+ has_exit_time: false,
+ exit_time: 0.0,
+ duration: 0.0,
+ ordered_interruption: true,
+ source_interruption: false,
+ can_transition_to_self: false,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Condition {
+ pub parameter: String,
+ pub mode: CondMode,
+ pub threshold: f32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum CondMode {
+ Greater,
+ Less,
+ Equals,
+ NotEqual,
+ If,
+ IfNot,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ClipData {
+ pub name: String,
+ pub looping: bool,
+ pub curves: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Curve {
+ pub path: String,
+ pub target: TargetType,
+ pub property: String,
+ pub keys: Vec,
+}
+
+/// The Unity component a curve binds to. Closed set: an unknown property is an error, not a guess.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum TargetType {
+ GameObject,
+ SkinnedMeshRenderer,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct Keyframe {
+ pub time: f32,
+ pub value: f32,
+ pub in_tangent: f32,
+ pub out_tangent: f32,
+}
+
+impl Keyframe {
+ pub fn linear(time: f32, value: f32) -> Self {
+ Self {
+ time,
+ value,
+ in_tangent: 0.0,
+ out_tangent: 0.0,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct BlendTreeData {
+ pub name: String,
+ pub blend_type: BlendType,
+ pub param_x: String,
+ pub param_y: Option,
+ pub children: Vec,
+ pub use_automatic_thresholds: bool,
+}
+
+/// `rename_all = "snake_case"` would turn `SimpleDirectional2D` into `simple_directional2_d`,
+/// so every variant is renamed explicitly.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub enum BlendType {
+ #[serde(rename = "simple_1d")]
+ Simple1D,
+ #[serde(rename = "simple_directional_2d")]
+ SimpleDirectional2D,
+ #[serde(rename = "freeform_directional_2d")]
+ FreeformDirectional2D,
+ #[serde(rename = "freeform_cartesian_2d")]
+ FreeformCartesian2D,
+ #[serde(rename = "direct")]
+ Direct,
+}
+
+impl BlendType {
+ pub fn is_2d(self) -> bool {
+ matches!(
+ self,
+ BlendType::SimpleDirectional2D
+ | BlendType::FreeformDirectional2D
+ | BlendType::FreeformCartesian2D
+ )
+ }
+
+ /// Blend trees that place children by threshold rather than by a direct blend parameter.
+ pub fn uses_thresholds(self) -> bool {
+ self != BlendType::Direct
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct BlendChild {
+ pub motion: MotionRef,
+ pub threshold: f32,
+ pub threshold_y: Option,
+ /// Only meaningful on `Direct` blend trees.
+ pub direct_param: Option,
+}
+
+impl BlendChild {
+ pub fn motion(motion: MotionRef) -> Self {
+ Self {
+ motion,
+ threshold: 0.0,
+ threshold_y: None,
+ direct_param: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum Parameter {
+ Float { name: String, default: f32 },
+ Int { name: String, default: i32 },
+ Bool { name: String, default: bool },
+}
+
+impl Parameter {
+ pub fn name(&self) -> &str {
+ match self {
+ Parameter::Float { name, .. } | Parameter::Int { name, .. } | Parameter::Bool { name, .. } => name,
+ }
+ }
+}
+
+/// Property-name -> component-type inference. Deliberately strict; an unmapped property is an error.
+pub fn infer_target(property: &str) -> Option {
+ if property.starts_with("blendShape.") {
+ return Some(TargetType::SkinnedMeshRenderer);
+ }
+ match property {
+ "m_IsActive" => Some(TargetType::GameObject),
+ _ => None,
+ }
+}
+
+impl ClipData {
+ /// Find-or-create the curve for one (path, target, property) binding.
+ pub fn curve_mut(&mut self, path: &str, target: TargetType, property: &str) -> &mut Curve {
+ let index = match self
+ .curves
+ .iter()
+ .position(|c| c.path == path && c.target == target && c.property == property)
+ {
+ Some(index) => index,
+ None => {
+ self.curves.push(Curve {
+ path: path.to_string(),
+ target,
+ property: property.to_string(),
+ keys: Vec::new(),
+ });
+ self.curves.len() - 1
+ }
+ };
+ &mut self.curves[index]
+ }
+}
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
new file mode 100644
index 0000000..2d652cd
--- /dev/null
+++ b/rust/src/lib.rs
@@ -0,0 +1,167 @@
+//! Evaluates a Rhai script that describes an Animator Controller, and hands the result to C# as JSON.
+//!
+//! The C# side never sees Rhai: it receives a `ControllerGraph` and drives the Animator As Code
+//! modification API with it.
+
+pub mod builder;
+pub mod export;
+pub mod graph;
+pub mod motion_api;
+pub mod rhai_api;
+
+use builder::Aac;
+use rhai::{Dynamic, Engine, Scope};
+use std::ffi::{CStr, CString};
+use std::os::raw::c_char;
+use std::panic::{catch_unwind, AssertUnwindSafe};
+use std::ptr;
+
+pub use graph::ControllerGraph;
+
+/// Opaque to C#. Owns the graph, the Rhai engine, and the last error message.
+pub struct AacContext {
+ aac: Aac,
+ engine: Engine,
+ last_error: Option,
+}
+
+impl AacContext {
+ fn set_error(&mut self, message: String) {
+ // Interior NUL bytes would make CString::new fail; they can only come from a script string.
+ self.last_error = Some(CString::new(message.replace('\0', "\\0")).unwrap_or_default());
+ }
+}
+
+/// Evaluate a script and serialize the resulting graph. The testable core of the FFI.
+pub fn evaluate_to_json(script: &str) -> Result {
+ let aac = Aac::new();
+ let engine = rhai_api::engine(aac.clone());
+ let mut scope = Scope::new();
+ let _ = engine
+ .eval_with_scope::(&mut scope, script)
+ .map_err(|error| error.to_string())?;
+ let graph = aac.write(|graph| graph.clone());
+ export::to_json(&graph)
+}
+
+/// Create a context. The returned pointer owns everything; release it with `aac_destroy`.
+#[no_mangle]
+pub extern "C" fn aac_create() -> *mut AacContext {
+ let aac = Aac::new();
+ let engine = rhai_api::engine(aac.clone());
+ Box::into_raw(Box::new(AacContext {
+ aac,
+ engine,
+ last_error: None,
+ }))
+}
+
+/// Evaluate a Rhai script. Returns 0 on success; on failure, call `aac_last_error`.
+#[no_mangle]
+pub extern "C" fn aac_eval_rhai(handle: *mut AacContext, script: *const c_char) -> i32 {
+ if handle.is_null() || script.is_null() {
+ return 1;
+ }
+ // SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed.
+ let context = unsafe { &mut *handle };
+ let script = match unsafe { CStr::from_ptr(script) }.to_str() {
+ Ok(script) => script,
+ Err(error) => {
+ context.set_error(format!("script is not valid UTF-8: {error}"));
+ return 1;
+ }
+ };
+
+ let outcome = {
+ let engine = &context.engine;
+ catch_unwind(AssertUnwindSafe(|| {
+ let mut scope = Scope::new();
+ engine.eval_with_scope::(&mut scope, script)
+ }))
+ };
+
+ match outcome {
+ Ok(Ok(_)) => {
+ context.last_error = None;
+ 0
+ }
+ Ok(Err(error)) => {
+ context.set_error(error.to_string());
+ 1
+ }
+ Err(_) => {
+ context.set_error("internal error: the evaluator panicked".to_string());
+ 2
+ }
+ }
+}
+
+/// Serialize the graph to JSON. Returns null on error; release the result with `aac_free_string`.
+#[no_mangle]
+pub extern "C" fn aac_to_json(handle: *mut AacContext) -> *mut c_char {
+ if handle.is_null() {
+ return ptr::null_mut();
+ }
+ // SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed.
+ let context = unsafe { &mut *handle };
+ let outcome = catch_unwind(AssertUnwindSafe(|| {
+ let graph = context.aac.write(|graph| graph.clone());
+ export::to_json(&graph)
+ }));
+
+ match outcome {
+ Ok(Ok(json)) => match CString::new(json) {
+ Ok(json) => {
+ context.last_error = None;
+ json.into_raw()
+ }
+ Err(_) => {
+ context.set_error("internal error: serialized JSON contained a NUL byte".to_string());
+ ptr::null_mut()
+ }
+ },
+ Ok(Err(error)) => {
+ context.set_error(error);
+ ptr::null_mut()
+ }
+ Err(_) => {
+ context.set_error("internal error: serialization panicked".to_string());
+ ptr::null_mut()
+ }
+ }
+}
+
+/// The last error message, or null if the last call succeeded. Borrowed: valid until the next call
+/// on this context, and must not be freed.
+#[no_mangle]
+pub extern "C" fn aac_last_error(handle: *mut AacContext) -> *const c_char {
+ if handle.is_null() {
+ return ptr::null();
+ }
+ // SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed.
+ let context = unsafe { &*handle };
+ match &context.last_error {
+ Some(message) => message.as_ptr(),
+ None => ptr::null(),
+ }
+}
+
+/// Free a string returned by `aac_to_json`.
+#[no_mangle]
+pub extern "C" fn aac_free_string(string: *mut c_char) {
+ if string.is_null() {
+ return;
+ }
+ // SAFETY: `string` must come from `aac_to_json`, which uses `CString::into_raw`.
+ unsafe { drop(CString::from_raw(string)) };
+}
+
+/// Destroy a context created by `aac_create`.
+#[no_mangle]
+pub extern "C" fn aac_destroy(handle: *mut AacContext) {
+ if handle.is_null() {
+ return;
+ }
+ // SAFETY: `handle` must come from `aac_create`, which uses `Box::into_raw`.
+ unsafe { drop(Box::from_raw(handle)) };
+}
diff --git a/rust/src/motion_api.rs b/rust/src/motion_api.rs
new file mode 100644
index 0000000..d41a31e
--- /dev/null
+++ b/rust/src/motion_api.rs
@@ -0,0 +1,188 @@
+use crate::builder::*;
+use crate::graph::*;
+use crate::rhai_api::err;
+use rhai::{Engine, EvalAltResult};
+
+pub fn register(engine: &mut Engine) {
+ register_clips(engine);
+ register_blend_trees(engine);
+ register_state_motion(engine);
+}
+
+fn clip_ref(clip: &ClipBuilder) -> MotionRef {
+ MotionRef::Clip {
+ name: clip.name.clone(),
+ }
+}
+
+fn tree_ref(tree: &BlendTreeBuilder) -> MotionRef {
+ MotionRef::BlendTree {
+ name: tree.name.clone(),
+ }
+}
+
+fn register_clips(engine: &mut Engine) {
+ engine.register_fn("clip", |a: &mut Aac, name: &str| -> Result> {
+ if name.trim().is_empty() {
+ return Err(err("clip name is empty".to_string()));
+ }
+ if a.write(|graph| graph.clips.iter().any(|clip| clip.name == name)) {
+ return Err(err(format!("clip `{name}` is already declared")));
+ }
+ a.write(|graph| {
+ graph.clips.push(ClipData {
+ name: name.to_string(),
+ looping: false,
+ curves: Vec::new(),
+ })
+ });
+ Ok(ClipBuilder {
+ aac: a.clone(),
+ name: name.to_string(),
+ })
+ });
+
+ engine.register_fn("looping", |clip: ClipBuilder, value: bool| {
+ clip.data_mut(|data| data.looping = value);
+ clip
+ });
+
+ engine.register_fn(
+ "keyframe",
+ |clip: ClipBuilder, path: &str, property: &str, time: f64, value: f64| -> Result> {
+ clip.push_key(path, property, Keyframe::linear(time as f32, value as f32))
+ .map_err(err)?;
+ Ok(clip)
+ },
+ );
+
+ engine.register_fn("toggle", |clip: ClipBuilder, path: &str, value: bool| -> Result> {
+ let value = if value { 1.0 } else { 0.0 };
+ // A one-frame constant, matching AAC's toggling semantics.
+ clip.push_key(path, "m_IsActive", Keyframe::linear(0.0, value))
+ .map_err(err)?;
+ clip.push_key(path, "m_IsActive", Keyframe::linear(1.0 / 60.0, value))
+ .map_err(err)?;
+ Ok(clip)
+ });
+
+ engine.register_fn(
+ "blend_shape",
+ |clip: ClipBuilder, path: &str, shape: &str, time: f64, value: f64| -> Result> {
+ let property = format!("blendShape.{shape}");
+ clip.push_key(path, &property, Keyframe::linear(time as f32, value as f32))
+ .map_err(err)?;
+ Ok(clip)
+ },
+ );
+}
+
+fn register_blend_trees(engine: &mut Engine) {
+ engine.register_fn("blend_tree", |a: &mut Aac, name: &str| -> Result> {
+ if name.trim().is_empty() {
+ return Err(err("blend tree name is empty".to_string()));
+ }
+ if a.write(|graph| graph.blend_trees.iter().any(|tree| tree.name == name)) {
+ return Err(err(format!("blend tree `{name}` is already declared")));
+ }
+ a.write(|graph| {
+ graph.blend_trees.push(BlendTreeData {
+ name: name.to_string(),
+ blend_type: BlendType::Simple1D,
+ param_x: String::new(),
+ param_y: None,
+ children: Vec::new(),
+ use_automatic_thresholds: false,
+ })
+ });
+ Ok(BlendTreeBuilder {
+ aac: a.clone(),
+ name: name.to_string(),
+ })
+ });
+
+ engine.register_fn("simple_1d", |tree: BlendTreeBuilder, x: FloatParam| {
+ tree.configure(BlendType::Simple1D, &x.name, None).map_err(err)?;
+ Ok::>(tree)
+ });
+ engine.register_fn("simple_directional_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| {
+ tree.configure(BlendType::SimpleDirectional2D, &x.name, Some(&y.name))
+ .map_err(err)?;
+ Ok::>(tree)
+ });
+ engine.register_fn("freeform_directional_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| {
+ tree.configure(BlendType::FreeformDirectional2D, &x.name, Some(&y.name))
+ .map_err(err)?;
+ Ok::>(tree)
+ });
+ engine.register_fn("freeform_cartesian_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| {
+ tree.configure(BlendType::FreeformCartesian2D, &x.name, Some(&y.name))
+ .map_err(err)?;
+ Ok::>(tree)
+ });
+ engine.register_fn("direct", |tree: BlendTreeBuilder| {
+ tree.configure(BlendType::Direct, "", None).map_err(err)?;
+ Ok::>(tree)
+ });
+ engine.register_fn("automatic_thresholds", |tree: BlendTreeBuilder, value: bool| {
+ tree.data_mut(|data| data.use_automatic_thresholds = value);
+ tree
+ });
+
+ engine.register_fn("add_motion", |tree: BlendTreeBuilder, clip: ClipBuilder, threshold: f64| {
+ push_child(&tree, clip_ref(&clip), threshold, None, None)?;
+ Ok::>(tree)
+ });
+ engine.register_fn("add_motion", |tree: BlendTreeBuilder, child: BlendTreeBuilder, threshold: f64| {
+ push_child(&tree, tree_ref(&child), threshold, None, None)?;
+ Ok::>(tree)
+ });
+ engine.register_fn("add_motion", |tree: BlendTreeBuilder, clip: ClipBuilder, x: f64, y: f64| {
+ push_child(&tree, clip_ref(&clip), x, Some(y), None)?;
+ Ok::>(tree)
+ });
+ engine.register_fn("add_motion", |tree: BlendTreeBuilder, child: BlendTreeBuilder, x: f64, y: f64| {
+ push_child(&tree, tree_ref(&child), x, Some(y), None)?;
+ Ok::>(tree)
+ });
+
+ engine.register_fn("add_motion_direct", |tree: BlendTreeBuilder, clip: ClipBuilder, parameter: FloatParam| {
+ push_child(&tree, clip_ref(&clip), 0.0, None, Some(¶meter.name))?;
+ Ok::>(tree)
+ });
+ engine.register_fn("add_motion_direct", |tree: BlendTreeBuilder, child: BlendTreeBuilder, parameter: FloatParam| {
+ push_child(&tree, tree_ref(&child), 0.0, None, Some(¶meter.name))?;
+ Ok::>(tree)
+ });
+}
+
+fn register_state_motion(engine: &mut Engine) {
+ engine.register_fn("set_clip", |state: StateRef, clip: ClipBuilder| {
+ state.set_motion(clip_ref(&clip));
+ state
+ });
+ engine.register_fn("set_motion", |state: StateRef, clip: ClipBuilder| {
+ state.set_motion(clip_ref(&clip));
+ state
+ });
+ engine.register_fn("set_motion", |state: StateRef, tree: BlendTreeBuilder| {
+ state.set_motion(tree_ref(&tree));
+ state
+ });
+}
+
+fn push_child(
+ tree: &BlendTreeBuilder,
+ motion: MotionRef,
+ threshold: f64,
+ threshold_y: Option,
+ direct_param: Option<&str>,
+) -> Result<(), Box> {
+ let child = BlendChild {
+ motion,
+ threshold: threshold as f32,
+ threshold_y: threshold_y.map(|y| y as f32),
+ direct_param: direct_param.map(str::to_string),
+ };
+ tree.push_child(child).map_err(err)
+}
diff --git a/rust/src/rhai_api.rs b/rust/src/rhai_api.rs
new file mode 100644
index 0000000..7dca468
--- /dev/null
+++ b/rust/src/rhai_api.rs
@@ -0,0 +1,271 @@
+use crate::builder::*;
+use crate::graph::*;
+use rhai::{Array, Engine, EvalAltResult, Position};
+
+pub(crate) fn err(message: String) -> Box {
+ Box::new(EvalAltResult::ErrorRuntime(message.into(), Position::NONE))
+}
+
+fn condition(parameter: &str, mode: CondMode, threshold: f32) -> Condition {
+ Condition {
+ parameter: parameter.to_string(),
+ mode,
+ threshold,
+ }
+}
+
+fn grid(value: i64) -> Result> {
+ i32::try_from(value).map_err(|_| err(format!("grid position {value} does not fit in a 32-bit integer")))
+}
+
+fn add_state(aac: &Aac, layer: usize, machine: &[usize], name: &str, x: i64, y: i64) -> Result> {
+ let position = GridPos::new(grid(x)?, grid(y)?);
+ let state = aac.write(|graph| {
+ let states = &mut machine_mut(graph, layer, machine).states;
+ states.push(State {
+ name: name.to_string(),
+ position,
+ ..Default::default()
+ });
+ states.len() - 1
+ });
+ Ok(StateRef {
+ aac: aac.clone(),
+ layer,
+ machine: machine.to_vec(),
+ state,
+ })
+}
+
+fn add_sub_machine(
+ aac: &Aac,
+ layer: usize,
+ machine: &[usize],
+ name: &str,
+ x: i64,
+ y: i64,
+) -> Result> {
+ let position = GridPos::new(grid(x)?, grid(y)?);
+ let index = aac.write(|graph| {
+ let sub_machines = &mut machine_mut(graph, layer, machine).sub_machines;
+ sub_machines.push(StateMachine {
+ name: Some(name.to_string()),
+ position,
+ ..Default::default()
+ });
+ sub_machines.len() - 1
+ });
+ let mut path = machine.to_vec();
+ path.push(index);
+ Ok(MachineRef {
+ aac: aac.clone(),
+ layer,
+ machine: path,
+ })
+}
+
+fn assert_new_parameter(aac: &Aac, name: &str) -> Result<(), Box> {
+ if name.trim().is_empty() {
+ return Err(err("parameter name is empty".to_string()));
+ }
+ let duplicate = aac.write(|graph| graph.parameters.iter().any(|p| p.name() == name));
+ if duplicate {
+ return Err(err(format!("parameter `{name}` is already declared")));
+ }
+ Ok(())
+}
+
+pub fn engine(aac: Aac) -> Engine {
+ let mut engine = Engine::new();
+
+ engine.register_type_with_name::("Aac");
+ engine.register_type_with_name::("FloatParam");
+ engine.register_type_with_name::("IntParam");
+ engine.register_type_with_name::("BoolParam");
+ engine.register_type_with_name::("Condition");
+ engine.register_type_with_name::("Controller");
+ engine.register_type_with_name::("Layer");
+ engine.register_type_with_name::("Machine");
+ engine.register_type_with_name::("State");
+ engine.register_type_with_name::("Transition");
+ engine.register_type_with_name::("Clip");
+ engine.register_type_with_name::("BlendTree");
+
+ register_conditions(&mut engine);
+ register_setup(&mut engine, aac);
+ register_navigation(&mut engine);
+ register_transitions(&mut engine);
+ crate::motion_api::register(&mut engine);
+
+ engine
+}
+
+/// Leaf conditions come from overloaded comparison operators, because Rhai's `&&`/`||` cannot be
+/// overloaded. Combine several conditions with `when_all([...])`.
+fn register_conditions(engine: &mut Engine) {
+ engine.register_fn(">", |p: FloatParam, v: f64| condition(&p.name, CondMode::Greater, v as f32));
+ engine.register_fn("<", |p: FloatParam, v: f64| condition(&p.name, CondMode::Less, v as f32));
+ engine.register_fn("==", |p: FloatParam, v: f64| condition(&p.name, CondMode::Equals, v as f32));
+ engine.register_fn("!=", |p: FloatParam, v: f64| condition(&p.name, CondMode::NotEqual, v as f32));
+ engine.register_fn(">", |p: IntParam, v: i64| condition(&p.name, CondMode::Greater, v as f32));
+ engine.register_fn("<", |p: IntParam, v: i64| condition(&p.name, CondMode::Less, v as f32));
+ engine.register_fn("==", |p: IntParam, v: i64| condition(&p.name, CondMode::Equals, v as f32));
+ engine.register_fn("!=", |p: IntParam, v: i64| condition(&p.name, CondMode::NotEqual, v as f32));
+ engine.register_fn("==", |p: BoolParam, v: bool| {
+ condition(&p.name, if v { CondMode::If } else { CondMode::IfNot }, 0.0)
+ });
+ engine.register_fn("!=", |p: BoolParam, v: bool| {
+ condition(&p.name, if v { CondMode::IfNot } else { CondMode::If }, 0.0)
+ });
+}
+
+fn register_setup(engine: &mut Engine, aac: Aac) {
+ engine.register_fn("AnimatorAsCode", move || aac.clone());
+
+ engine.register_fn("system_name", |a: &mut Aac, name: &str| {
+ a.write(|graph| graph.system_name = name.to_string());
+ });
+ engine.register_fn("asset_key", |a: &mut Aac, key: &str| {
+ a.write(|graph| graph.asset_key = key.to_string());
+ });
+
+ engine.register_fn("float_param", |a: &mut Aac, name: &str, default: f64| -> Result> {
+ assert_new_parameter(a, name)?;
+ a.write(|graph| {
+ graph.parameters.push(Parameter::Float {
+ name: name.to_string(),
+ default: default as f32,
+ })
+ });
+ Ok(FloatParam {
+ name: name.to_string(),
+ })
+ });
+ engine.register_fn("int_param", |a: &mut Aac, name: &str, default: i64| -> Result> {
+ assert_new_parameter(a, name)?;
+ a.write(|graph| {
+ graph.parameters.push(Parameter::Int {
+ name: name.to_string(),
+ default: default as i32,
+ })
+ });
+ Ok(IntParam {
+ name: name.to_string(),
+ })
+ });
+ engine.register_fn(
+ "bool_param",
+ |a: &mut Aac, name: &str, default: bool| -> Result> {
+ assert_new_parameter(a, name)?;
+ a.write(|graph| {
+ graph.parameters.push(Parameter::Bool {
+ name: name.to_string(),
+ default,
+ })
+ });
+ Ok(BoolParam {
+ name: name.to_string(),
+ })
+ },
+ );
+
+ engine.register_fn("new_controller", |a: &mut Aac| ControllerBuilder { aac: a.clone() });
+}
+
+fn register_navigation(engine: &mut Engine) {
+ // There is exactly one controller, so this is the only place layers are attached.
+ engine.register_fn("layer", |c: ControllerBuilder, name: &str| -> LayerBuilder {
+ let layer = c.aac.write(|graph| {
+ graph.controller.layers.push(Layer {
+ name: name.to_string(),
+ ..Default::default()
+ });
+ graph.controller.layers.len() - 1
+ });
+ LayerBuilder {
+ aac: c.aac.clone(),
+ layer,
+ }
+ });
+
+ engine.register_fn("state", |l: LayerBuilder, name: &str, x: i64, y: i64| {
+ add_state(&l.aac, l.layer, &[], name, x, y)
+ });
+ engine.register_fn("state", |m: MachineRef, name: &str, x: i64, y: i64| {
+ add_state(&m.aac, m.layer, &m.machine, name, x, y)
+ });
+
+ engine.register_fn("sub_machine", |l: LayerBuilder, name: &str, x: i64, y: i64| {
+ add_sub_machine(&l.aac, l.layer, &[], name, x, y)
+ });
+ engine.register_fn("sub_machine", |m: MachineRef, name: &str, x: i64, y: i64| {
+ add_sub_machine(&m.aac, m.layer, &m.machine, name, x, y)
+ });
+
+ engine.register_fn("any_state", |l: LayerBuilder| AnyStateRef {
+ aac: l.aac.clone(),
+ layer: l.layer,
+ machine: Vec::new(),
+ });
+ engine.register_fn("any_state", |m: MachineRef| AnyStateRef {
+ aac: m.aac.clone(),
+ layer: m.layer,
+ machine: m.machine.clone(),
+ });
+}
+
+fn register_transitions(engine: &mut Engine) {
+ engine.register_fn("transition_to", |from: StateRef, to: StateRef| from.transition_to(&to));
+ engine.register_fn("transition_to", |from: AnyStateRef, to: StateRef| from.transition_to(&to));
+
+ engine.register_fn("when", |t: TransitionRef, c: Condition| {
+ t.add_conditions([c]);
+ t
+ });
+ engine.register_fn(
+ "when_all",
+ |t: TransitionRef, conditions: Array| -> Result> {
+ let mut collected = Vec::with_capacity(conditions.len());
+ for value in conditions {
+ collected.push(
+ value
+ .try_cast::()
+ .ok_or_else(|| err("when_all expects a list of conditions".to_string()))?,
+ );
+ }
+ t.add_conditions(collected);
+ Ok(t)
+ },
+ );
+
+ engine.register_fn("duration", |t: TransitionRef, seconds: f64| {
+ t.update(|transition| transition.duration = seconds as f32);
+ t
+ });
+ engine.register_fn("no_exit_time", |t: TransitionRef| {
+ t.update(|transition| {
+ transition.has_exit_time = false;
+ transition.exit_time = 0.0;
+ });
+ t
+ });
+ engine.register_fn("exit_time", |t: TransitionRef, normalized: f64| {
+ t.update(|transition| {
+ transition.has_exit_time = true;
+ transition.exit_time = normalized as f32;
+ });
+ t
+ });
+ engine.register_fn("ordered_interruption", |t: TransitionRef, value: bool| {
+ t.update(|transition| transition.ordered_interruption = value);
+ t
+ });
+ engine.register_fn("source_interruption", |t: TransitionRef| {
+ t.update(|transition| transition.source_interruption = true);
+ t
+ });
+ engine.register_fn("to_self", |t: TransitionRef| {
+ t.update(|transition| transition.can_transition_to_self = true);
+ t
+ });
+}
diff --git a/rust/tests/dsl.rs b/rust/tests/dsl.rs
new file mode 100644
index 0000000..16827ac
--- /dev/null
+++ b/rust/tests/dsl.rs
@@ -0,0 +1,321 @@
+use aac::graph::*;
+use serde_json::Value;
+
+const AVATAR: &str = include_str!("../examples/avatar.rhai");
+
+fn json(script: &str) -> Value {
+ serde_json::from_str(&aac::evaluate_to_json(script).expect("script should evaluate")).expect("valid json")
+}
+
+fn error(script: &str) -> String {
+ aac::evaluate_to_json(script).expect_err("script should fail")
+}
+
+/// A minimal but valid graph, for mutating into invalid shapes.
+fn valid_graph() -> ControllerGraph {
+ let script = r#"
+ let aac = AnimatorAsCode();
+ aac.system_name("S");
+ aac.asset_key("K");
+ let speed = aac.float_param("Speed", 0.0);
+ let ctrl = aac.new_controller();
+ let base = ctrl.layer("Base");
+ let a = base.state("A", 0, 0);
+ let b = base.state("B", 1, 0);
+ let clip = aac.clip("clip");
+ clip.looping(true);
+ a.set_clip(clip);
+ a.transition_to(b).when(speed > 0.5).duration(0.1);
+ "#;
+ serde_json::from_str(&aac::evaluate_to_json(script).expect("valid")).expect("deserializes")
+}
+
+#[test]
+fn example_script_describes_the_whole_controller() {
+ let value = json(AVATAR);
+
+ assert_eq!(value["system_name"], "MyAvatar");
+ assert_eq!(value["asset_key"], "AAC_");
+ assert_eq!(value["parameters"].as_array().unwrap().len(), 3);
+ assert_eq!(value["clips"].as_array().unwrap().len(), 3);
+ assert_eq!(value["blend_trees"].as_array().unwrap().len(), 1);
+
+ let layers = value["controller"]["layers"].as_array().unwrap();
+ assert_eq!(layers.len(), 1);
+ assert_eq!(layers[0]["name"], "Base");
+
+ let states = layers[0]["state_machine"]["states"].as_array().unwrap();
+ assert_eq!(states.len(), 4);
+ assert_eq!(states[0]["name"], "Idle");
+ assert_eq!(states[0]["position"]["x"], 0);
+
+ // A clip used both as a state motion and as a blend tree child.
+ assert_eq!(states[0]["motion"]["type"], "clip");
+ assert_eq!(states[0]["motion"]["name"], "idle_anim");
+ assert_eq!(states[2]["motion"]["type"], "blend_tree");
+ assert_eq!(states[2]["motion"]["name"], "locomotion");
+
+ // `speed > 0.1` becomes a greater-than condition.
+ let transition = &states[0]["transitions"][0];
+ assert_eq!(transition["conditions"][0]["parameter"], "Speed");
+ assert_eq!(transition["conditions"][0]["mode"], "greater");
+ assert_eq!(transition["conditions"][0]["threshold"], 0.1);
+ assert_eq!(transition["duration"], 0.25);
+ assert_eq!(transition["has_exit_time"], false);
+
+ // `when_all([...])` produces two conditions on one transition.
+ let both = states[1]["transitions"][1]["conditions"].as_array().unwrap();
+ assert_eq!(both.len(), 2);
+ assert_eq!(both[1]["parameter"], "IsSitting");
+ assert_eq!(both[1]["mode"], "if_not");
+
+ let any_state = layers[0]["state_machine"]["any_state_transitions"].as_array().unwrap();
+ assert_eq!(any_state.len(), 1);
+ assert_eq!(any_state[0]["to"], "Sit");
+
+ let sub = layers[0]["state_machine"]["sub_machines"].as_array().unwrap();
+ assert_eq!(sub.len(), 1);
+ assert_eq!(sub[0]["name"], "Gestures");
+ assert_eq!(sub[0]["states"].as_array().unwrap().len(), 2);
+
+ // Blend shape keys landed in time order on one curve.
+ let clip = find_clip(&value, "idle_anim");
+ let curve = curve_of(clip, "blendShape.Smile");
+ assert_eq!(curve["target"], "skinned_mesh_renderer");
+ assert_eq!(curve["keys"].as_array().unwrap().len(), 2);
+ assert_eq!(curve["keys"][0]["time"], 0.0);
+ assert_eq!(curve["keys"][1]["time"], 1.0);
+
+ // A toggle is a one-frame constant: two keys with the same value.
+ let toggle = curve_of(find_clip(&value, "walk_anim"), "m_IsActive");
+ assert_eq!(toggle["target"], "game_object");
+ let keys = toggle["keys"].as_array().unwrap();
+ assert_eq!(keys.len(), 2);
+ assert_eq!(keys[0]["value"], keys[1]["value"]);
+}
+
+fn find_clip<'a>(value: &'a Value, name: &str) -> &'a Value {
+ value["clips"].as_array().unwrap().iter().find(|c| c["name"] == name).unwrap()
+}
+
+fn curve_of<'a>(clip: &'a Value, property: &str) -> &'a Value {
+ clip["curves"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|c| c["property"] == property)
+ .unwrap()
+}
+
+#[test]
+fn keyframes_are_sorted_by_time() {
+ let value = json(
+ r#"
+ let aac = AnimatorAsCode();
+ aac.system_name("S");
+ aac.asset_key("K");
+ let c = aac.clip("c");
+ c.keyframe("Body", "m_IsActive", 2.0, 1.0);
+ c.keyframe("Body", "m_IsActive", 0.5, 0.0);
+ c.keyframe("Body", "m_IsActive", 1.0, 1.0);
+ "#,
+ );
+ let times: Vec = value["clips"][0]["curves"][0]["keys"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|k| k["time"].as_f64().unwrap())
+ .collect();
+ assert_eq!(times, vec![0.5, 1.0, 2.0]);
+}
+
+#[test]
+fn blend_tree_shapes() {
+ let value = json(
+ r#"
+ let aac = AnimatorAsCode();
+ aac.system_name("S");
+ aac.asset_key("K");
+ let x = aac.float_param("X", 0.0);
+ let y = aac.float_param("Y", 0.0);
+ let direct = aac.float_param("Direct", 0.0);
+ let ctrl = aac.new_controller();
+ let base = ctrl.layer("Base");
+ let s = base.state("S", 0, 0);
+ let one = aac.clip("one");
+ let two = aac.clip("two");
+ let inner = aac.blend_tree("inner");
+ inner.simple_1d(x);
+ inner.add_motion(one, 0.0);
+ let outer = aac.blend_tree("outer");
+ outer.simple_directional_2d(x, y);
+ outer.add_motion(two, 0.0, 1.0);
+ outer.add_motion(inner, 2.0, 3.0);
+ outer.automatic_thresholds(true);
+ s.set_motion(outer);
+ let flat = aac.blend_tree("flat");
+ flat.direct();
+ flat.add_motion_direct(one, direct);
+ "#,
+ );
+ let trees = value["blend_trees"].as_array().unwrap();
+ let outer = trees.iter().find(|t| t["name"] == "outer").unwrap();
+ assert_eq!(outer["blend_type"], "simple_directional_2d");
+ assert_eq!(outer["param_x"], "X");
+ assert_eq!(outer["param_y"], "Y");
+ assert_eq!(outer["use_automatic_thresholds"], true);
+ let children = outer["children"].as_array().unwrap();
+ assert_eq!(children.len(), 2);
+ assert_eq!(children[0]["threshold_y"], 1.0);
+ assert_eq!(children[1]["motion"]["type"], "blend_tree");
+ assert_eq!(children[1]["motion"]["name"], "inner");
+
+ let flat = trees.iter().find(|t| t["name"] == "flat").unwrap();
+ assert_eq!(flat["blend_type"], "direct");
+ assert_eq!(flat["param_x"], "");
+ assert_eq!(flat["children"][0]["direct_param"], "Direct");
+}
+
+#[test]
+fn json_round_trips_through_the_graph_type() {
+ // Compare text, not `Value`: `serde_json::Value` widens f32 to f64, which changes the digits.
+ let json = aac::evaluate_to_json(AVATAR).expect("evaluates");
+ let graph: ControllerGraph = serde_json::from_str(&json).expect("deserializes");
+ let reserialized = serde_json::to_string_pretty(&graph).expect("serializes");
+ assert_eq!(reserialized, json);
+}
+
+#[test]
+fn missing_configuration_is_reported() {
+ assert!(error("let aac = AnimatorAsCode(); aac.asset_key(\"K\");").contains("system_name is not set"));
+ assert!(error("let aac = AnimatorAsCode(); aac.system_name(\"S\");").contains("asset_key is not set"));
+}
+
+#[test]
+fn duplicate_names_are_rejected_at_the_call_site() {
+ assert!(error(
+ r#"
+ let aac = AnimatorAsCode();
+ aac.system_name("S");
+ aac.asset_key("K");
+ let a = aac.float_param("Speed", 0.0);
+ let b = aac.float_param("Speed", 0.0);
+ "#
+ )
+ .contains("already declared"));
+
+ assert!(error(
+ r#"
+ let aac = AnimatorAsCode();
+ aac.system_name("S");
+ aac.asset_key("K");
+ let a = aac.clip("c");
+ let b = aac.clip("c");
+ "#
+ )
+ .contains("already declared"));
+}
+
+#[test]
+fn unknown_curve_property_is_rejected() {
+ let message = error(
+ r#"
+ let aac = AnimatorAsCode();
+ aac.system_name("S");
+ aac.asset_key("K");
+ let c = aac.clip("c");
+ c.keyframe("Body", "m_NoSuchThing", 0.0, 1.0);
+ "#,
+ );
+ assert!(message.contains("cannot tell which component"), "unexpected: {message}");
+}
+
+#[test]
+fn and_operator_is_not_available() {
+ // The one DSL limitation: Rhai's `&&` cannot be overloaded, so use `when_all([...])`.
+ let message = error(
+ r#"
+ let aac = AnimatorAsCode();
+ aac.system_name("S");
+ aac.asset_key("K");
+ let a = aac.float_param("A", 0.0);
+ let b = aac.bool_param("B", false);
+ let ctrl = aac.new_controller();
+ let base = ctrl.layer("Base");
+ let s = base.state("S", 0, 0);
+ let t = base.state("T", 1, 0);
+ s.transition_to(t).when(a > 0.0 && b == true);
+ "#,
+ );
+ assert!(message.contains("bool"), "unexpected: {message}");
+}
+
+/// The validator is a safety net for graphs that do not come from the typed Rhai DSL.
+#[test]
+fn validator_rejects_broken_graphs() {
+ let mut graph = valid_graph();
+ graph.controller.layers[0].state_machine.states[0].transitions[0].to = "Nowhere".to_string();
+ let message = aac::export::to_json(&graph).expect_err("dangling target");
+ assert!(message.contains("not a state in that layer"), "unexpected: {message}");
+
+ let mut graph = valid_graph();
+ graph.controller.layers[0].state_machine.states[0].transitions[0].conditions[0].parameter = "Ghost".to_string();
+ let message = aac::export::to_json(&graph).expect_err("undeclared parameter");
+ assert!(message.contains("not declared"), "unexpected: {message}");
+
+ let mut graph = valid_graph();
+ graph.clips.push(graph.clips[0].clone());
+ let message = aac::export::to_json(&graph).expect_err("duplicate clip");
+ assert!(message.contains("declared more than once"), "unexpected: {message}");
+
+ let mut graph = valid_graph();
+ graph.controller.layers[0].state_machine.states[0].motion = Some(MotionRef::Clip { name: "ghost".into() });
+ let message = aac::export::to_json(&graph).expect_err("dangling motion");
+ assert!(message.contains("not declared"), "unexpected: {message}");
+
+ // Forward references across states must resolve.
+ let graph = valid_graph();
+ aac::export::to_json(&graph).expect("a valid graph stays valid");
+}
+
+/// The Unity side deserializes this JSON with `MissingMemberHandling.Error`, so a field added or
+/// renamed here breaks it at runtime, where no test could catch it. Pin every object's key set.
+#[test]
+fn json_keys_are_the_ones_the_unity_dtos_expect() {
+ let value = json(AVATAR);
+
+ fn keys(value: &Value) -> Vec<&str> {
+ value.as_object().expect("object").keys().map(String::as_str).collect()
+ }
+
+ assert_eq!(keys(&value), ["asset_key", "blend_trees", "clips", "controller", "parameters", "system_name"]);
+ assert_eq!(keys(&value["parameters"][0]), ["default", "name", "type"]);
+ assert_eq!(keys(&value["clips"][0]), ["curves", "looping", "name"]);
+ assert_eq!(keys(&value["clips"][0]["curves"][0]), ["keys", "path", "property", "target"]);
+ assert_eq!(keys(&value["clips"][0]["curves"][0]["keys"][0]), ["in_tangent", "out_tangent", "time", "value"]);
+
+ let tree = &value["blend_trees"][0];
+ assert_eq!(keys(tree), ["blend_type", "children", "name", "param_x", "param_y", "use_automatic_thresholds"]);
+ assert_eq!(keys(&tree["children"][0]), ["direct_param", "motion", "threshold", "threshold_y"]);
+ assert_eq!(keys(&tree["children"][0]["motion"]), ["name", "type"]);
+
+ let layer = &value["controller"]["layers"][0];
+ assert_eq!(keys(layer), ["name", "state_machine"]);
+ assert_eq!(keys(&layer["state_machine"]), ["any_state_transitions", "name", "position", "states", "sub_machines"]);
+ assert_eq!(keys(&layer["state_machine"]["position"]), ["x", "y"]);
+ assert_eq!(keys(&layer["state_machine"]["states"][0]), ["motion", "name", "position", "transitions"]);
+ assert_eq!(
+ keys(&layer["state_machine"]["states"][0]["transitions"][0]),
+ [
+ "can_transition_to_self",
+ "conditions",
+ "duration",
+ "exit_time",
+ "has_exit_time",
+ "ordered_interruption",
+ "source_interruption",
+ "to"
+ ]
+ );
+ assert_eq!(keys(&layer["state_machine"]["states"][0]["transitions"][0]["conditions"][0]), ["mode", "parameter", "threshold"]);
+}