use crate::finding::Severity;
use crate::metrics::MIN_STRIDE_STEP_M;
use crate::profile::Role;
use serde::{Deserialize, Deserializer};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct Pinned {
pub value: f64,
pub tolerance: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SeveritySetting {
Off,
Note,
#[serde(alias = "warning")]
Warn,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MovementOwner {
Gameplay,
Animation,
}
impl MovementOwner {
pub const fn from_in_place(in_place: bool) -> Self {
if in_place {
Self::Gameplay
} else {
Self::Animation
}
}
}
impl SeveritySetting {
pub fn as_severity(self) -> Option<Severity> {
match self {
SeveritySetting::Off => None,
SeveritySetting::Note => Some(Severity::Note),
SeveritySetting::Warn => Some(Severity::Warning),
SeveritySetting::Error => Some(Severity::Error),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CheckSettings {
pub severity: Option<SeveritySetting>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_ratio: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub min_stride_step_m: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_position_delta_m: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_rotation_delta_deg: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_velocity_delta_mps: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_angular_velocity_delta_degps: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub min_rotation_deg: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_mean_rest_delta_deg: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub contact_height_m: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_slide_mps: Option<f64>,
pub node_selectors: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_positive_finite_option")]
pub expected_uniform_scale: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub uniform_scale_tolerance: Option<f64>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClipExpectations {
#[serde(rename = "loop")]
pub looping: Option<bool>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_loop_position_delta_m: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_loop_rotation_delta_deg: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_loop_velocity_delta_mps: Option<f64>,
#[serde(default, deserialize_with = "deserialize_nonnegative_finite_option")]
pub max_loop_angular_velocity_delta_degps: Option<f64>,
pub duration_s: Option<Pinned>,
pub speed_mps: Option<Pinned>,
pub movement_owner_xz: Option<MovementOwner>,
pub movement_owner_y: Option<MovementOwner>,
pub movement_owner_yaw: Option<MovementOwner>,
pub in_place: Option<bool>,
pub fps: Option<f64>,
pub animates_bones: Option<Vec<String>>,
}
impl ClipExpectations {
pub fn normalized_movement_owner_xz(&self) -> Option<MovementOwner> {
self.movement_owner_xz
.or_else(|| self.in_place.map(MovementOwner::from_in_place))
}
fn merged_with(&self, other: &ClipExpectations) -> ClipExpectations {
ClipExpectations {
looping: other.looping.or(self.looping),
max_loop_position_delta_m: other
.max_loop_position_delta_m
.or(self.max_loop_position_delta_m),
max_loop_rotation_delta_deg: other
.max_loop_rotation_delta_deg
.or(self.max_loop_rotation_delta_deg),
max_loop_velocity_delta_mps: other
.max_loop_velocity_delta_mps
.or(self.max_loop_velocity_delta_mps),
max_loop_angular_velocity_delta_degps: other
.max_loop_angular_velocity_delta_degps
.or(self.max_loop_angular_velocity_delta_degps),
duration_s: other.duration_s.or(self.duration_s),
speed_mps: other.speed_mps.or(self.speed_mps),
movement_owner_xz: other
.normalized_movement_owner_xz()
.or_else(|| self.normalized_movement_owner_xz()),
movement_owner_y: other.movement_owner_y.or(self.movement_owner_y),
movement_owner_yaw: other.movement_owner_yaw.or(self.movement_owner_yaw),
in_place: None,
fps: other.fps.or(self.fps),
animates_bones: other
.animates_bones
.clone()
.or_else(|| self.animates_bones.clone()),
}
}
}
fn deserialize_nonnegative_finite_option<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<f64>::deserialize(deserializer)?;
if let Some(value) = value
&& !is_nonnegative_finite(value)
{
return Err(serde::de::Error::custom(
"must be a finite non-negative number",
));
}
Ok(value)
}
fn deserialize_positive_finite_option<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<f64>::deserialize(deserializer)?;
if value.is_some_and(|value| !is_positive_finite(value)) {
return Err(serde::de::Error::custom(
"must be a finite number greater than zero",
));
}
Ok(value)
}
fn is_nonnegative_finite(value: f64) -> bool {
value.is_finite() && value >= 0.0
}
fn is_positive_finite(value: f64) -> bool {
value.is_finite() && value > 0.0
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GaitGroup {
pub clips: Vec<String>,
pub max_gait_phase_spread: f64,
#[serde(default)]
pub min_lr_amplitude_m: f64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TimeComplementSettings {
#[serde(deserialize_with = "deserialize_unit_interval")]
pub min_reflected_time_advantage: f64,
#[serde(deserialize_with = "deserialize_nonnegative_finite")]
pub min_lr_amplitude_m: f64,
}
fn deserialize_unit_interval<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
let value = f64::deserialize(deserializer)?;
if !is_unit_interval(value) {
return Err(serde::de::Error::custom(
"must be a finite number in the range [0, 1]",
));
}
Ok(value)
}
fn deserialize_nonnegative_finite<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
let value = f64::deserialize(deserializer)?;
if !is_nonnegative_finite(value) {
return Err(serde::de::Error::custom(
"must be a finite non-negative number",
));
}
Ok(value)
}
fn is_unit_interval(value: f64) -> bool {
value.is_finite() && (0.0..=1.0).contains(&value)
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SyncGroup {
pub clips: Vec<String>,
pub max_duration_delta_s: f64,
pub max_frame_count_delta: u32,
pub max_fps_delta: f64,
#[serde(default)]
pub time_complement: Option<TimeComplementSettings>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RigConfig {
#[serde(default = "default_profile")]
pub profile: String,
#[serde(default)]
pub roles: BTreeMap<Role, String>,
pub required_bones: Option<Vec<String>>,
}
fn default_profile() -> String {
"auto".into()
}
impl Default for RigConfig {
fn default() -> Self {
Self {
profile: default_profile(),
roles: BTreeMap::new(),
required_bones: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeNodesConfig {
#[serde(default)]
pub selectors: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeNodeSelectors {
selectors: Vec<String>,
}
impl RuntimeNodeSelectors {
fn new(selectors: &[String]) -> Self {
let mut seen = std::collections::BTreeSet::new();
Self {
selectors: selectors
.iter()
.filter(|selector| seen.insert(selector.as_str()))
.cloned()
.collect(),
}
}
pub fn selectors(&self) -> &[String] {
&self.selectors
}
pub fn resolve<'a, T: Clone>(
&self,
candidates: impl IntoIterator<Item = (&'a str, T)>,
) -> Vec<RuntimeNodeSelectorResolution<T>> {
let candidates = candidates.into_iter().collect::<Vec<_>>();
self.selectors
.iter()
.map(|selector| {
let matches = candidates
.iter()
.filter(|(name, _)| glob_match(selector, name))
.map(|(_, candidate)| candidate.clone())
.collect::<Vec<_>>();
match matches.as_slice() {
[] => RuntimeNodeSelectorResolution::NoMatch {
selector: selector.clone(),
},
[node] => RuntimeNodeSelectorResolution::ExactlyOne {
selector: selector.clone(),
node: node.clone(),
},
_ => RuntimeNodeSelectorResolution::Ambiguous {
selector: selector.clone(),
nodes: matches,
},
}
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuntimeNodeSelectorResolution<T> {
NoMatch {
selector: String,
},
ExactlyOne {
selector: String,
node: T,
},
Ambiguous {
selector: String,
nodes: Vec<T>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigValidationError {
#[error("check {check_id:?} field {field:?} must be finite and within its documented range")]
InvalidCheckSetting {
check_id: String,
field: &'static str,
},
#[error("clip selector {selector:?} field {field:?} must be a finite non-negative number")]
InvalidClipLoopCap {
selector: String,
field: &'static str,
},
#[error(
"clip selector {selector:?} cannot declare both \"movement_owner_xz\" and \"in_place\""
)]
ConflictingClipMovementOwner {
selector: String,
},
#[error(
"cannot declare both \"runtime_nodes.selectors\" and \"checks.rest-world-scale.node_selectors\""
)]
ConflictingRuntimeNodeSelectors,
#[error("sync group {group:?} field {field:?} must be a finite non-negative number")]
InvalidSyncGroupTolerance {
group: String,
field: &'static str,
},
#[error(
"sync group {group:?} time-complement field {field:?} must be finite and within its documented range"
)]
InvalidTimeComplementSetting {
group: String,
field: &'static str,
},
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub rig: RigConfig,
#[serde(default)]
pub checks: BTreeMap<String, CheckSettings>,
#[serde(default)]
pub runtime_nodes: RuntimeNodesConfig,
#[serde(default)]
pub clips: BTreeMap<String, ClipExpectations>,
#[serde(default)]
pub gait_groups: BTreeMap<String, GaitGroup>,
#[serde(default)]
pub sync_groups: BTreeMap<String, SyncGroup>,
}
impl Config {
pub fn validate(&self) -> Result<(), ConfigValidationError> {
if self.runtime_nodes.selectors.is_some()
&& self
.checks
.get("rest-world-scale")
.is_some_and(|settings| settings.node_selectors.is_some())
{
return Err(ConfigValidationError::ConflictingRuntimeNodeSelectors);
}
for (check_id, settings) in &self.checks {
for (field, valid) in [
(
"max_ratio",
settings.max_ratio.is_none_or(is_nonnegative_finite),
),
(
"min_stride_step_m",
settings.min_stride_step_m.is_none_or(is_nonnegative_finite),
),
(
"max_position_delta_m",
settings
.max_position_delta_m
.is_none_or(is_nonnegative_finite),
),
(
"max_rotation_delta_deg",
settings
.max_rotation_delta_deg
.is_none_or(is_nonnegative_finite),
),
(
"max_velocity_delta_mps",
settings
.max_velocity_delta_mps
.is_none_or(is_nonnegative_finite),
),
(
"max_angular_velocity_delta_degps",
settings
.max_angular_velocity_delta_degps
.is_none_or(is_nonnegative_finite),
),
(
"min_rotation_deg",
settings.min_rotation_deg.is_none_or(is_nonnegative_finite),
),
(
"max_mean_rest_delta_deg",
settings
.max_mean_rest_delta_deg
.is_none_or(is_nonnegative_finite),
),
(
"contact_height_m",
settings.contact_height_m.is_none_or(is_nonnegative_finite),
),
(
"max_slide_mps",
settings.max_slide_mps.is_none_or(is_nonnegative_finite),
),
(
"expected_uniform_scale",
settings
.expected_uniform_scale
.is_none_or(is_positive_finite),
),
(
"uniform_scale_tolerance",
settings
.uniform_scale_tolerance
.is_none_or(is_nonnegative_finite),
),
] {
if !valid {
return Err(ConfigValidationError::InvalidCheckSetting {
check_id: check_id.clone(),
field,
});
}
}
}
for (selector, expectations) in &self.clips {
if expectations.movement_owner_xz.is_some() && expectations.in_place.is_some() {
return Err(ConfigValidationError::ConflictingClipMovementOwner {
selector: selector.clone(),
});
}
for (field, value) in [
(
"max_loop_position_delta_m",
expectations.max_loop_position_delta_m,
),
(
"max_loop_rotation_delta_deg",
expectations.max_loop_rotation_delta_deg,
),
(
"max_loop_velocity_delta_mps",
expectations.max_loop_velocity_delta_mps,
),
(
"max_loop_angular_velocity_delta_degps",
expectations.max_loop_angular_velocity_delta_degps,
),
] {
if value.is_some_and(|value| !is_nonnegative_finite(value)) {
return Err(ConfigValidationError::InvalidClipLoopCap {
selector: selector.clone(),
field,
});
}
}
}
for (group, sync) in &self.sync_groups {
for (field, value) in [
("max_duration_delta_s", sync.max_duration_delta_s),
("max_fps_delta", sync.max_fps_delta),
] {
if !is_nonnegative_finite(value) {
return Err(ConfigValidationError::InvalidSyncGroupTolerance {
group: group.clone(),
field,
});
}
}
if let Some(settings) = &sync.time_complement {
for (field, valid) in [
(
"min_reflected_time_advantage",
is_unit_interval(settings.min_reflected_time_advantage),
),
(
"min_lr_amplitude_m",
is_nonnegative_finite(settings.min_lr_amplitude_m),
),
] {
if !valid {
return Err(ConfigValidationError::InvalidTimeComplementSetting {
group: group.clone(),
field,
});
}
}
}
}
Ok(())
}
pub fn expectations_for(&self, clip: &str) -> ClipExpectations {
let mut out = ClipExpectations::default();
for (pattern, exp) in &self.clips {
if pattern != clip && glob_match(pattern, clip) {
out = out.merged_with(exp);
}
}
if let Some(exact) = self.clips.get(clip) {
out = out.merged_with(exact);
}
out
}
pub fn check_settings(&self, id: &str) -> CheckSettings {
self.checks.get(id).cloned().unwrap_or_default()
}
pub fn runtime_node_selectors(&self) -> Option<RuntimeNodeSelectors> {
let selectors = self.runtime_nodes.selectors.as_ref().or_else(|| {
self.checks
.get("rest-world-scale")
.and_then(|settings| settings.node_selectors.as_ref())
})?;
(!selectors.is_empty()).then(|| RuntimeNodeSelectors::new(selectors))
}
pub fn loop_seam_min_stride_step_m(&self) -> f64 {
self.check_settings("loop-seam")
.min_stride_step_m
.unwrap_or(MIN_STRIDE_STEP_M)
}
}
pub fn glob_match(pattern: &str, name: &str) -> bool {
let pattern = pattern.as_bytes();
let name = name.as_bytes();
let Some(first_star) = pattern.iter().position(|&byte| byte == b'*') else {
return pattern == name;
};
let last_star = pattern
.iter()
.rposition(|&byte| byte == b'*')
.unwrap_or(first_star);
let prefix = &pattern[..first_star];
let suffix = &pattern[last_star + 1..];
if !name.starts_with(prefix) || !name.ends_with(suffix) {
return false;
}
let mut name_index = prefix.len();
let search_end = name.len() - suffix.len();
if name_index > search_end {
return false;
}
if first_star < last_star {
let mut failure = Vec::new();
for literal in pattern[first_star + 1..last_star]
.split(|&byte| byte == b'*')
.filter(|literal| !literal.is_empty())
{
let Some(offset) =
find_subslice_linear(&name[name_index..search_end], literal, &mut failure)
else {
return false;
};
name_index += offset + literal.len();
}
}
true
}
fn find_subslice_linear(haystack: &[u8], needle: &[u8], failure: &mut Vec<usize>) -> Option<usize> {
debug_assert!(!needle.is_empty());
if needle.len() > haystack.len() {
return None;
}
failure.clear();
failure.resize(needle.len(), 0);
let mut prefix_len = 0;
for index in 1..needle.len() {
while prefix_len > 0 && needle[index] != needle[prefix_len] {
prefix_len = failure[prefix_len - 1];
}
if needle[index] == needle[prefix_len] {
prefix_len += 1;
}
failure[index] = prefix_len;
}
let mut matched = 0;
for (index, &byte) in haystack.iter().enumerate() {
while matched > 0 && byte != needle[matched] {
matched = failure[matched - 1];
}
if byte == needle[matched] {
matched += 1;
if matched == needle.len() {
return Some(index + 1 - needle.len());
}
}
}
None
}