use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const MAX_MASK_GROUP: u32 = 64;
pub const RIGS_VERSION: u32 = 3;
pub const DEFAULT_ROOT_NODE: &str = "Root";
pub const DEFAULT_CONTACT_JOINT: &str = "foot_l";
pub const BENCH_TOOL_VERSION: u32 = 1;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Provenance {
pub glb_fnv1a: String,
pub clips: usize,
pub clip_names: Vec<String>,
pub tool: u32,
pub date: String,
}
pub fn fingerprint_string(hash: u64) -> String {
format!("0x{hash:016x}")
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
pub enum Playback {
Free { speed: f32 },
Gait {
duration: f32,
phase_offset: f32,
cycle_distance: f32,
},
OneShot { speed: f32 },
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct SlotDef {
pub clip: usize,
pub playback: Playback,
#[serde(default)]
pub mask: Option<u32>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub keep: Option<String>,
#[serde(default)]
pub tolerance: Option<f32>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct Rig {
pub mesh: String,
pub scale: f32,
#[serde(default)]
pub drive_speed: Option<(f32, f32)>,
#[serde(default)]
pub root_node: Option<String>,
#[serde(default)]
pub contact_joints: Vec<String>,
#[serde(default)]
pub contact_eps: Option<f32>,
#[serde(default)]
pub provenance: Option<Provenance>,
pub slots: Vec<SlotDef>,
}
impl Rig {
pub fn root_node(&self) -> &str {
self.root_node.as_deref().unwrap_or(DEFAULT_ROOT_NODE)
}
pub fn contact_joint(&self) -> &str {
self.contact_joints
.first()
.map(String::as_str)
.unwrap_or(DEFAULT_CONTACT_JOINT)
}
pub fn has_gaits(&self) -> bool {
self.slots
.iter()
.any(|s| matches!(s.playback, Playback::Gait { .. }))
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct Rigs {
pub version: u32,
#[serde(default)]
pub note: Option<String>,
pub rigs: BTreeMap<String, Rig>,
}
impl Rigs {
pub fn parse(text: &str) -> Result<Rigs, String> {
let parsed: Rigs = ron::from_str(text).map_err(|e| format!("rigs.ron is not valid: {e}"))?;
parsed.validate()?;
Ok(parsed)
}
pub fn to_ron(&self) -> Result<String, String> {
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
.map_err(|e| format!("cannot write rigs.ron: {e}"))
}
pub fn get(&self, name: &str) -> Option<&Rig> {
self.rigs.get(name)
}
pub fn validate(&self) -> Result<(), String> {
if self.version != RIGS_VERSION {
return Err(format!(
"rigs.ron is version {}, this build reads {RIGS_VERSION}",
self.version
));
}
for (name, rig) in &self.rigs {
for slot in &rig.slots {
if let Some(group) = slot.mask
&& group >= MAX_MASK_GROUP
{
return Err(format!(
"`{name}`'s slot masks to group {group}; a mask group is a bit index into \
a 64-bit animation mask, so it must be below {MAX_MASK_GROUP}"
));
}
}
if rig.mesh.is_empty() {
return Err(format!("rig `{name}` names no mesh"));
}
if !matches!(rig.scale.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
return Err(format!(
"rig `{name}` has scale {}; the manifest's cycle distances are world units = \
file units × scale, so a non-positive scale makes every measurement \
meaningless",
rig.scale
));
}
if rig.root_node.as_deref() == Some("") {
return Err(format!("rig `{name}`'s root_node names no node"));
}
if rig.contact_joints.iter().any(String::is_empty) {
return Err(format!("rig `{name}`'s contact_joints include an empty name"));
}
for (i, j) in rig.contact_joints.iter().enumerate() {
if rig.contact_joints[..i].contains(j) {
return Err(format!("rig `{name}` lists contact joint `{j}` twice"));
}
}
if !rig.has_gaits() && (rig.root_node.is_some() || !rig.contact_joints.is_empty()) {
return Err(format!(
"rig `{name}` configures measurement anchors but declares no gait — nothing \
reads them, and configuration nothing reads is configuration that lies"
));
}
if let Some(eps) = rig.contact_eps {
if !rig.has_gaits() {
return Err(format!(
"rig `{name}` declares a contact_eps but no gait — only contact labelling \
reads it, and configuration nothing reads is configuration that lies"
));
}
if !(eps > 0.0 && eps < 1.0) {
return Err(format!(
"rig `{name}`'s contact_eps {eps} is not a fraction of the stance speed \
in (0, 1) — at 1.0 every bin within one stance-speed of the stance \
cluster would label planted, which is every bin"
));
}
}
if let Some((lo, hi)) = rig.drive_speed {
if !rig.has_gaits() {
return Err(format!(
"rig `{name}` declares a drive_speed but no gait — only the gait cadence \
scales with ground speed, so nothing reads it, and configuration nothing \
reads is configuration that lies"
));
}
if !matches!(lo.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) || !matches!(hi.partial_cmp(&lo), Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)) {
return Err(format!(
"rig `{name}`'s drive_speed ({lo}, {hi}) is not a positive (min, max) \
range — the skate check sweeps it, and an empty or negative range \
predicts nothing"
));
}
}
if let Some(p) = &rig.provenance {
let hex_ok = p.glb_fnv1a.len() == 18
&& p.glb_fnv1a.starts_with("0x")
&& p.glb_fnv1a.as_bytes()[2..]
.iter()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b));
if !hex_ok {
return Err(format!(
"rig `{name}`'s provenance fingerprint `{}` is not 0x + 16 lowercase hex \
digits — the stamp is tool-owned; do not hand-edit it",
p.glb_fnv1a
));
}
if p.clip_names.len() != p.clips {
return Err(format!(
"rig `{name}`'s provenance records {} clips but {} names — the stamp is \
tool-owned; do not hand-edit it",
p.clips,
p.clip_names.len()
));
}
}
if rig.slots.is_empty() {
return Err(format!("rig `{name}` has no slots, so nothing could animate it"));
}
for (i, s) in rig.slots.iter().enumerate() {
match &s.keep {
Some(reason) if reason.is_empty() => {
return Err(format!(
"rig `{name}` slot {i} is kept with no reason — a kept number is a \
decision, and a decision needs its why recorded"
));
}
Some(_) if !matches!(s.playback, Playback::Gait { .. }) => {
return Err(format!(
"rig `{name}` slot {i} is kept but is not a gait — adopt writes only \
gait numbers, so this keep is configuration nothing reads"
));
}
_ => {}
}
if let Some(t) = s.tolerance {
if !matches!(s.playback, Playback::Gait { .. }) {
return Err(format!(
"rig `{name}` slot {i} carries a tolerance but is not a gait — only \
the cycle-distance check reads it, so this is configuration nothing \
reads"
));
}
if !(t > 0.0 && t <= 1.0) {
return Err(format!(
"rig `{name}` slot {i} has tolerance {t}; it is a fraction of the \
declared cycle distance and must be within (0, 1]"
));
}
}
match s.playback {
Playback::Gait {
duration,
phase_offset,
cycle_distance,
} => {
if !matches!(duration.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
return Err(format!(
"rig `{name}` slot {i} is a gait with duration {duration}; a gait \
with no length has no phase to seek"
));
}
if !matches!(cycle_distance.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
return Err(format!(
"rig `{name}` slot {i} is a gait covering {cycle_distance} m per \
cycle; cadence is speed / cycle_distance, so this would divide by \
nothing. Measure it with `bevy_grimoire_core::clips::cycle_distance`."
));
}
if !(-1.0..=1.0).contains(&phase_offset) {
return Err(format!(
"rig `{name}` slot {i} has phase offset {phase_offset}; it is a \
fraction of one cycle and must be within -1.0..=1.0"
));
}
}
Playback::Free { speed } | Playback::OneShot { speed } => {
if !matches!(speed.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
return Err(format!(
"rig `{name}` slot {i} plays at speed {speed}"
));
}
}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gait() -> SlotDef {
SlotDef {
clip: 5,
playback: Playback::Gait {
duration: 1.417,
phase_offset: 0.0,
cycle_distance: 1.388,
},
mask: None,
note: Some("walk".into()),
state: None,
keep: None,
tolerance: None,
}
}
fn one(slot: SlotDef) -> Rigs {
let mut rigs = BTreeMap::new();
rigs.insert(
"valkyrie".to_owned(),
Rig {
mesh: "characters/valkyrie.glb".to_owned(),
scale: 1.13,
drive_speed: None,
contact_eps: None,
root_node: None,
contact_joints: Vec::new(),
provenance: None,
slots: vec![slot],
},
);
Rigs {
version: RIGS_VERSION,
note: None,
rigs,
}
}
#[test]
fn a_manifest_survives_a_round_trip() {
let before = one(gait());
let text = before.to_ron().unwrap_or_else(|e| panic!("{e}"));
let after = Rigs::parse(&text).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(before, after);
}
#[test]
fn a_gait_that_covers_no_ground_is_refused() {
let mut slot = gait();
slot.playback = Playback::Gait {
duration: 1.0,
phase_offset: 0.0,
cycle_distance: 0.0,
};
let e = one(slot).validate().err().unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("cycle"), "{e}");
}
#[test]
fn a_phase_offset_outside_one_cycle_is_refused() {
let mut slot = gait();
slot.playback = Playback::Gait {
duration: 1.0,
phase_offset: 1.5,
cycle_distance: 1.0,
};
let e = one(slot).validate().err().unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("phase offset"), "{e}");
}
#[test]
fn a_rig_with_no_slots_is_refused() {
let mut m = one(gait());
if let Some(r) = m.rigs.get_mut("valkyrie") {
r.slots.clear();
}
let e = m.validate().err().unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("no slots"), "{e}");
}
#[test]
fn an_older_version_is_refused_rather_than_guessed_at() {
let mut m = one(gait());
m.version = RIGS_VERSION + 1;
assert!(m.validate().is_err());
}
#[test]
fn a_rig_with_no_positive_scale_is_refused() {
let mut m = one(gait());
if let Some(r) = m.rigs.get_mut("valkyrie") {
r.scale = 0.0;
}
let e = m.validate().err().unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("scale"), "{e}");
}
#[test]
fn anchors_on_a_gaitless_rig_are_refused_as_config_nothing_reads() {
let mut m = one(SlotDef {
clip: 0,
playback: Playback::Free { speed: 1.0 },
mask: None,
note: None,
state: None,
keep: None,
tolerance: None,
});
if let Some(r) = m.rigs.get_mut("valkyrie") {
r.contact_joints = vec!["foot_l".to_owned()];
}
let e = m.validate().err().unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("no gait"), "{e}");
}
#[test]
fn a_tolerance_outside_the_unit_interval_or_off_a_gait_is_refused() {
let mut m = one(gait());
if let Some(r) = m.rigs.get_mut("valkyrie")
&& let Some(s) = r.slots.get_mut(0)
{
s.tolerance = Some(1.5);
}
let e = m.validate().err().unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("tolerance"), "{e}");
let mut m = one(SlotDef {
clip: 0,
playback: Playback::Free { speed: 1.0 },
mask: None,
note: None,
state: None,
keep: None,
tolerance: Some(0.1),
});
let e = m.validate().err().unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("nothing"), "{e}");
let _ = &mut m;
}
#[test]
fn a_drive_speed_on_a_gaitless_rig_is_refused_as_config_nothing_reads() {
let mut m = one(SlotDef {
clip: 0,
playback: Playback::Free { speed: 1.0 },
mask: None,
note: None,
state: None,
keep: None,
tolerance: None,
});
if let Some(r) = m.rigs.get_mut("valkyrie") {
r.drive_speed = Some((0.9, 6.0));
}
let e = m.validate().err().unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("no gait"), "{e}");
}
#[test]
fn a_degenerate_drive_speed_range_is_refused() {
for bad in [(0.0, 6.0), (-1.0, 6.0), (6.0, 0.9)] {
let mut m = one(gait());
if let Some(r) = m.rigs.get_mut("valkyrie") {
r.drive_speed = Some(bad);
}
let e = m.validate().err().unwrap_or_else(|| panic!("accepted {bad:?}"));
assert!(e.contains("drive_speed"), "{e}");
}
}
#[test]
fn a_drive_speed_survives_a_round_trip() {
let mut m = one(gait());
if let Some(r) = m.rigs.get_mut("valkyrie") {
r.drive_speed = Some((0.9, 6.0));
}
let text = m.to_ron().unwrap_or_else(|e| panic!("{e}"));
let back = Rigs::parse(&text).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(m, back);
}
fn stamp() -> Provenance {
Provenance {
glb_fnv1a: fingerprint_string(0xdead_beef_0000_0001),
clips: 2,
clip_names: vec!["idle".to_owned(), "walk".to_owned()],
tool: BENCH_TOOL_VERSION,
date: "2026-08-06".to_owned(),
}
}
#[test]
fn a_provenance_stamp_survives_a_round_trip() {
let mut m = one(gait());
if let Some(r) = m.rigs.get_mut("valkyrie") {
r.provenance = Some(stamp());
}
let text = m.to_ron().unwrap_or_else(|e| panic!("{e}"));
let back = Rigs::parse(&text).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(m, back);
}
#[test]
fn a_hand_mangled_provenance_is_refused() {
let mut m = one(gait());
if let Some(r) = m.rigs.get_mut("valkyrie") {
let mut p = stamp();
p.glb_fnv1a = "0xNOTHEX".to_owned();
r.provenance = Some(p);
}
let e = m.validate().err().unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("tool-owned"), "{e}");
let mut m = one(gait());
if let Some(r) = m.rigs.get_mut("valkyrie") {
let mut p = stamp();
p.clip_names.pop();
r.provenance = Some(p);
}
let e = m.validate().err().unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("2 clips but 1 names"), "{e}");
}
}