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(_) => {} } }