Skip to main content

animsmith_engine/
registry.rs

1use crate::canonical::facts_identity;
2use crate::{
3    AnimationAddressability, ConversionControl, CoordinateBasis, DefaultStatus, EngineProfile,
4    FactId, FactState, FactValue, ForwardAxis, Handedness, LinearUnit, PrimarySource, ProfileFact,
5    ProfileSelection, RegistryValidationError, RootMotionAddressability, SettingApplicability,
6    SettingDescriptor, SettingDomain, SettingId, SettingScope, TargetAddressability, UpAxis,
7};
8use animsmith_core::{InputIdentity, SourceFormatV1};
9use std::collections::BTreeSet;
10use std::sync::OnceLock;
11
12const VERIFIED_ON: &str = "2026-08-20";
13const ALL_FACT_IDS: [FactId; 14] = [
14    FactId::AcceptedInputs,
15    FactId::AnimationAddressability,
16    FactId::TargetCoordinateBasis,
17    FactId::TargetLinearUnit,
18    FactId::UnitConversionControl,
19    FactId::AxisConversionControl,
20    FactId::ExactAxisConversion,
21    FactId::ResultingHierarchyScale,
22    FactId::WholeEndFrameRequired,
23    FactId::AnimationChannelHandling,
24    FactId::ExtensionHandling,
25    FactId::ConstructHandling,
26    FactId::AnimationTargetAddressability,
27    FactId::RootMotionAddressability,
28];
29
30/// Enumerate the five immutable V1 engine profiles in tuple order.
31pub fn profiles_v1() -> &'static [EngineProfile] {
32    static PROFILES: OnceLock<Vec<EngineProfile>> = OnceLock::new();
33    PROFILES.get_or_init(|| {
34        let mut profiles = vec![unity_generic(), unity_humanoid(), unreal(), godot(), bevy()];
35        profiles.sort_by(|left, right| left.selection.cmp(&right.selection));
36        profiles
37    })
38}
39
40/// Validate all author-owned registry declarations and cross references.
41///
42/// This checks tuple and URN uniqueness, complete fact inventories, the
43/// canonical accepted-input fact, descriptor/default consistency, and every
44/// source's stable fact/setting references.
45///
46/// # Errors
47///
48/// Returns a typed invariant error naming the affected profile and field.
49pub fn validate_registry_v1() -> Result<(), RegistryValidationError> {
50    validate_profiles(profiles_v1())
51}
52
53fn validate_profiles(profiles: &[EngineProfile]) -> Result<(), RegistryValidationError> {
54    if profiles.len() != 5 {
55        return Err(RegistryValidationError::ProfileCount {
56            found: profiles.len(),
57        });
58    }
59    let mut selections = BTreeSet::new();
60    let mut urns = BTreeSet::new();
61    for profile in profiles {
62        if !selections.insert(profile.selection.clone()) {
63            return Err(RegistryValidationError::DuplicateSelection {
64                selection: profile.selection.clone(),
65            });
66        }
67        if !urns.insert(profile.fact_bundle_urn) {
68            return Err(RegistryValidationError::DuplicateFactBundleUrn {
69                urn: profile.fact_bundle_urn,
70            });
71        }
72        let fact_ids: BTreeSet<_> = profile.facts.iter().map(ProfileFact::id).collect();
73        if fact_ids.len() != profile.facts.len()
74            || fact_ids != ALL_FACT_IDS.into_iter().collect::<BTreeSet<_>>()
75        {
76            return Err(RegistryValidationError::InvalidFactInventory {
77                selection: profile.selection.clone(),
78            });
79        }
80        let accepted_inputs = profile.accepted_inputs();
81        if accepted_inputs.is_empty()
82            || !accepted_inputs.windows(2).all(|pair| {
83                crate::canonical::format_name(pair[0]) < crate::canonical::format_name(pair[1])
84            })
85        {
86            return Err(RegistryValidationError::InvalidAcceptedInputFact {
87                selection: profile.selection.clone(),
88            });
89        }
90        let setting_ids: BTreeSet<_> = profile.settings.iter().map(SettingDescriptor::id).collect();
91        if setting_ids.len() != profile.settings.len() {
92            return Err(RegistryValidationError::DuplicateSettingDescriptor {
93                selection: profile.selection.clone(),
94            });
95        }
96        for descriptor in &profile.settings {
97            let valid_default = matches!(
98                (descriptor.applicability(), descriptor.default_status()),
99                (
100                    SettingApplicability::Applicable,
101                    DefaultStatus::RequiredWithoutDefault
102                ) | (
103                    SettingApplicability::NotApplicable,
104                    DefaultStatus::NotApplicable
105                )
106            );
107            if !valid_default {
108                return Err(RegistryValidationError::InvalidDescriptorDefault {
109                    selection: profile.selection.clone(),
110                    setting: descriptor.id(),
111                });
112            }
113        }
114        let mut source_ids = BTreeSet::new();
115        for source in &profile.sources {
116            if !source_ids.insert(source.id()) {
117                return Err(RegistryValidationError::DuplicateSourceId {
118                    selection: profile.selection.clone(),
119                    source_id: source.id(),
120                });
121            }
122            for fact in source.supported_facts() {
123                if !fact_ids.contains(fact) {
124                    return Err(RegistryValidationError::UnknownSourceFact {
125                        selection: profile.selection.clone(),
126                        source_id: source.id(),
127                        fact: *fact,
128                    });
129                }
130                if !matches!(
131                    profile.fact(*fact).map(ProfileFact::state),
132                    Some(FactState::Known(_))
133                ) {
134                    return Err(RegistryValidationError::SourceReferencesNonKnownFact {
135                        selection: profile.selection.clone(),
136                        source_id: source.id(),
137                        fact: *fact,
138                    });
139                }
140            }
141            for setting in source.supported_settings() {
142                if !setting_ids.contains(setting) {
143                    return Err(RegistryValidationError::UnknownSourceSetting {
144                        selection: profile.selection.clone(),
145                        source_id: source.id(),
146                        setting: *setting,
147                    });
148                }
149            }
150        }
151        for fact in &profile.facts {
152            if matches!(fact.state(), FactState::Known(_))
153                && !profile
154                    .sources
155                    .iter()
156                    .any(|source| source.supported_facts().contains(&fact.id()))
157            {
158                return Err(RegistryValidationError::UnreferencedKnownFact {
159                    selection: profile.selection.clone(),
160                    fact: fact.id(),
161                });
162            }
163        }
164        for descriptor in &profile.settings {
165            if !profile
166                .sources
167                .iter()
168                .any(|source| source.supported_settings().contains(&descriptor.id()))
169            {
170                return Err(RegistryValidationError::UnreferencedSetting {
171                    selection: profile.selection.clone(),
172                    setting: descriptor.id(),
173                });
174            }
175        }
176        if facts_identity(profile) != profile.facts_identity {
177            return Err(RegistryValidationError::FactsIdentityMismatch {
178                selection: profile.selection.clone(),
179            });
180        }
181    }
182    Ok(())
183}
184
185fn unity_generic() -> EngineProfile {
186    let accepted = vec![SourceFormatV1::Fbx];
187    let facts = base_facts(
188        &accepted,
189        vec![
190            (
191                FactId::UnitConversionControl,
192                FactValue::ConversionControl(ConversionControl::ProfileSetting(
193                    SettingId::ConvertUnits,
194                )),
195            ),
196            (
197                FactId::AxisConversionControl,
198                FactValue::ConversionControl(ConversionControl::ProfileSetting(
199                    SettingId::BakeAxisConversion,
200                )),
201            ),
202            (
203                FactId::RootMotionAddressability,
204                FactValue::RootMotionAddressability(
205                    RootMotionAddressability::ExactSourceTransformPath,
206                ),
207            ),
208        ],
209    );
210    finish_profile(
211        ProfileSelection::new("unity-generic", 1, "6000.3", "fbx-model-importer"),
212        "urn:animsmith:engine-profile:unity-generic:1",
213        facts,
214        unity_settings(true),
215        unity_sources(true),
216    )
217}
218
219fn unity_humanoid() -> EngineProfile {
220    let accepted = vec![SourceFormatV1::Fbx];
221    let facts = base_facts(
222        &accepted,
223        vec![
224            (
225                FactId::UnitConversionControl,
226                FactValue::ConversionControl(ConversionControl::ProfileSetting(
227                    SettingId::ConvertUnits,
228                )),
229            ),
230            (
231                FactId::AxisConversionControl,
232                FactValue::ConversionControl(ConversionControl::ProfileSetting(
233                    SettingId::BakeAxisConversion,
234                )),
235            ),
236            (
237                FactId::RootMotionAddressability,
238                FactValue::RootMotionAddressability(RootMotionAddressability::HumanoidAvatarBody),
239            ),
240        ],
241    );
242    finish_profile(
243        ProfileSelection::new("unity-humanoid", 1, "6000.3", "fbx-model-importer"),
244        "urn:animsmith:engine-profile:unity-humanoid:1",
245        facts,
246        unity_settings(false),
247        unity_sources(false),
248    )
249}
250
251fn unreal() -> EngineProfile {
252    let accepted = vec![SourceFormatV1::Fbx];
253    let facts = base_facts(
254        &accepted,
255        vec![
256            (
257                FactId::TargetCoordinateBasis,
258                FactValue::CoordinateBasis(CoordinateBasis {
259                    handedness: Handedness::Left,
260                    up_axis: UpAxis::Z,
261                    forward_axis: ForwardAxis::PositiveX,
262                }),
263            ),
264            (
265                FactId::TargetLinearUnit,
266                FactValue::LinearUnit(LinearUnit::Centimetre),
267            ),
268            (
269                FactId::UnitConversionControl,
270                FactValue::ConversionControl(ConversionControl::ImporterOption),
271            ),
272            (
273                FactId::AxisConversionControl,
274                FactValue::ConversionControl(ConversionControl::ImporterOption),
275            ),
276            (FactId::WholeEndFrameRequired, FactValue::Boolean(true)),
277        ],
278    );
279    finish_profile(
280        ProfileSelection::new("unreal", 1, "5.8", "fbx-importer"),
281        "urn:animsmith:engine-profile:unreal:1",
282        facts,
283        vec![],
284        vec![
285            source(
286                "unreal-animation-sequences-5.8",
287                "5.8",
288                "https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-sequences-in-unreal-engine?application_version=5.8",
289                &[FactId::WholeEndFrameRequired],
290                &[],
291            ),
292            source(
293                "unreal-coordinate-system-5.8",
294                "5.8",
295                "https://dev.epicgames.com/documentation/en-us/unreal-engine/coordinate-system-and-spaces-in-unreal-engine?application_version=5.8",
296                &[FactId::TargetCoordinateBasis],
297                &[],
298            ),
299            source(
300                "unreal-fbx-import-options-5.8",
301                "5.8",
302                "https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-import-options-reference-in-unreal-engine?application_version=5.8",
303                &[
304                    FactId::AcceptedInputs,
305                    FactId::UnitConversionControl,
306                    FactId::AxisConversionControl,
307                ],
308                &[],
309            ),
310            source(
311                "unreal-units-5.8",
312                "5.8",
313                "https://dev.epicgames.com/documentation/en-us/unreal-engine/units-of-measurement-in-unreal-engine?application_version=5.8",
314                &[FactId::TargetLinearUnit],
315                &[],
316            ),
317        ],
318    )
319}
320
321fn godot() -> EngineProfile {
322    let accepted = vec![
323        SourceFormatV1::GltfJson,
324        SourceFormatV1::Glb,
325        SourceFormatV1::Fbx,
326    ];
327    let facts = base_facts(&accepted, vec![]);
328    finish_profile(
329        ProfileSelection::new("godot", 1, "4.7", "resource-importer-scene"),
330        "urn:animsmith:engine-profile:godot:1",
331        facts,
332        vec![],
333        vec![source(
334            "godot-resource-importer-scene-4.7",
335            "4.7",
336            "https://docs.godotengine.org/en/4.7/classes/class_resourceimporterscene.html",
337            &[FactId::AcceptedInputs],
338            &[],
339        )],
340    )
341}
342
343fn bevy() -> EngineProfile {
344    let accepted = vec![SourceFormatV1::GltfJson, SourceFormatV1::Glb];
345    let facts = base_facts(
346        &accepted,
347        vec![
348            (
349                FactId::AnimationAddressability,
350                FactValue::AnimationAddressability(AnimationAddressability::GltfAssetLabel),
351            ),
352            (
353                FactId::AnimationTargetAddressability,
354                FactValue::TargetAddressability(TargetAddressability::NamePathDerivedId),
355            ),
356        ],
357    );
358    finish_profile(
359        ProfileSelection::new("bevy", 1, "0.19.0", "gltf-asset-loader"),
360        "urn:animsmith:engine-profile:bevy:1",
361        facts,
362        vec![],
363        vec![
364            source(
365                "bevy-animation-target-id-0.19.0",
366                "0.19.0",
367                "https://docs.rs/bevy/0.19.0/bevy/animation/struct.AnimationTargetId.html",
368                &[FactId::AnimationTargetAddressability],
369                &[],
370            ),
371            source(
372                "bevy-gltf-asset-label-0.19.0",
373                "0.19.0",
374                "https://docs.rs/bevy/0.19.0/bevy/gltf/enum.GltfAssetLabel.html",
375                &[FactId::AcceptedInputs, FactId::AnimationAddressability],
376                &[],
377            ),
378            source(
379                "khronos-gltf-2.0",
380                "2.0",
381                "https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html",
382                &[FactId::AcceptedInputs],
383                &[],
384            ),
385        ],
386    )
387}
388
389fn base_facts(accepted: &[SourceFormatV1], known: Vec<(FactId, FactValue)>) -> Vec<ProfileFact> {
390    let mut accepted = accepted.to_vec();
391    accepted.sort_by_key(|format| crate::canonical::format_name(*format));
392    let known: std::collections::BTreeMap<_, _> = known.into_iter().collect();
393    ALL_FACT_IDS
394        .into_iter()
395        .map(|id| {
396            let state = if id == FactId::AcceptedInputs {
397                FactState::Known(FactValue::AcceptedFormats(accepted.clone()))
398            } else if let Some(value) = known.get(&id) {
399                FactState::Known(value.clone())
400            } else {
401                FactState::Unknown
402            };
403            ProfileFact::new(id, state)
404        })
405        .collect()
406}
407
408fn unity_settings(generic: bool) -> Vec<SettingDescriptor> {
409    use DefaultStatus::{NotApplicable, RequiredWithoutDefault};
410    use SettingApplicability::{Applicable, NotApplicable as DoesNotApply};
411    vec![
412        SettingDescriptor::new(
413            SettingId::ConvertUnits,
414            SettingScope::Document,
415            SettingDomain::Boolean,
416            Applicable,
417            RequiredWithoutDefault,
418        ),
419        SettingDescriptor::new(
420            SettingId::BakeAxisConversion,
421            SettingScope::Document,
422            SettingDomain::Boolean,
423            Applicable,
424            RequiredWithoutDefault,
425        ),
426        SettingDescriptor::new(
427            SettingId::RootMotionSource,
428            SettingScope::Document,
429            SettingDomain::SourceTransformPath,
430            if generic { Applicable } else { DoesNotApply },
431            if generic {
432                RequiredWithoutDefault
433            } else {
434                NotApplicable
435            },
436        ),
437        SettingDescriptor::new(
438            SettingId::RootRotation,
439            SettingScope::Clip,
440            SettingDomain::BakeOrExtract,
441            Applicable,
442            RequiredWithoutDefault,
443        ),
444        SettingDescriptor::new(
445            SettingId::RootPositionY,
446            SettingScope::Clip,
447            SettingDomain::BakeOrExtract,
448            Applicable,
449            RequiredWithoutDefault,
450        ),
451        SettingDescriptor::new(
452            SettingId::RootPositionXz,
453            SettingScope::Clip,
454            SettingDomain::BakeOrExtract,
455            Applicable,
456            RequiredWithoutDefault,
457        ),
458    ]
459}
460
461fn unity_sources(generic: bool) -> Vec<PrimarySource> {
462    let mut sources = vec![
463        source(
464            "unity-animation-clip-import-6000.3",
465            "6000.3",
466            "https://docs.unity3d.com/6000.3/Documentation/Manual/class-AnimationClip.html",
467            &[],
468            &[
469                SettingId::RootRotation,
470                SettingId::RootPositionY,
471                SettingId::RootPositionXz,
472            ],
473        ),
474        source(
475            "unity-model-import-6000.3",
476            "6000.3",
477            "https://docs.unity3d.com/6000.3/Documentation/Manual/FBXImporter-Model.html",
478            &[
479                FactId::AcceptedInputs,
480                FactId::UnitConversionControl,
481                FactId::AxisConversionControl,
482            ],
483            &[SettingId::ConvertUnits, SettingId::BakeAxisConversion],
484        ),
485        source(
486            "unity-model-importer-clip-animation-6000.3",
487            "6000.3",
488            "https://docs.unity3d.com/6000.3/Documentation/ScriptReference/ModelImporterClipAnimation.html",
489            &[],
490            &[
491                SettingId::RootRotation,
492                SettingId::RootPositionY,
493                SettingId::RootPositionXz,
494            ],
495        ),
496        source(
497            "unity-rig-import-6000.3",
498            "6000.3",
499            "https://docs.unity3d.com/6000.3/Documentation/Manual/FBXImporter-Rig.html",
500            &[FactId::RootMotionAddressability],
501            if generic {
502                &[]
503            } else {
504                &[SettingId::RootMotionSource]
505            },
506        ),
507    ];
508    if generic {
509        sources.push(source(
510            "unity-model-importer-motion-node-name-6000.3",
511            "6000.3",
512            "https://docs.unity3d.com/6000.3/Documentation/ScriptReference/ModelImporter-motionNodeName.html",
513            &[FactId::RootMotionAddressability],
514            &[SettingId::RootMotionSource],
515        ));
516    }
517    sources
518}
519
520fn source(
521    id: &'static str,
522    target_version: &'static str,
523    url: &'static str,
524    facts: &[FactId],
525    settings: &[SettingId],
526) -> PrimarySource {
527    PrimarySource::new(
528        id,
529        target_version,
530        url,
531        VERIFIED_ON,
532        facts.to_vec(),
533        settings.to_vec(),
534    )
535}
536
537fn finish_profile(
538    selection: ProfileSelection,
539    fact_bundle_urn: &'static str,
540    mut facts: Vec<ProfileFact>,
541    mut settings: Vec<SettingDescriptor>,
542    mut sources: Vec<PrimarySource>,
543) -> EngineProfile {
544    facts.sort_by_key(|fact| fact.id().as_str());
545    settings.sort_by_key(|descriptor| descriptor.id().as_str());
546    sources.sort_by_key(PrimarySource::id);
547    let mut profile = EngineProfile {
548        selection,
549        fact_bundle_urn,
550        facts,
551        settings,
552        sources,
553        facts_identity: InputIdentity::from_bytes(&[]),
554    };
555    profile.facts_identity = facts_identity(&profile);
556    profile
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    fn without_fact(source: &PrimarySource, removed: FactId) -> PrimarySource {
564        PrimarySource::new(
565            source.id(),
566            source.target_version(),
567            source.url(),
568            source.verified_on(),
569            source
570                .supported_facts()
571                .iter()
572                .copied()
573                .filter(|id| *id != removed)
574                .collect(),
575            source.supported_settings().to_vec(),
576        )
577    }
578
579    fn without_setting(source: &PrimarySource, removed: SettingId) -> PrimarySource {
580        PrimarySource::new(
581            source.id(),
582            source.target_version(),
583            source.url(),
584            source.verified_on(),
585            source.supported_facts().to_vec(),
586            source
587                .supported_settings()
588                .iter()
589                .copied()
590                .filter(|id| *id != removed)
591                .collect(),
592        )
593    }
594
595    #[test]
596    fn validation_refuses_known_fact_without_source_coverage() {
597        let mut profiles = profiles_v1().to_vec();
598        let bevy = profiles
599            .iter_mut()
600            .find(|profile| profile.selection.family() == "bevy")
601            .unwrap();
602        bevy.sources = bevy
603            .sources
604            .iter()
605            .map(|source| without_fact(source, FactId::AnimationTargetAddressability))
606            .collect();
607        assert!(matches!(
608            validate_profiles(&profiles),
609            Err(RegistryValidationError::UnreferencedKnownFact {
610                fact: FactId::AnimationTargetAddressability,
611                ..
612            })
613        ));
614    }
615
616    #[test]
617    fn validation_refuses_not_applicable_descriptor_without_source_coverage() {
618        let mut profiles = profiles_v1().to_vec();
619        let humanoid = profiles
620            .iter_mut()
621            .find(|profile| profile.selection.family() == "unity-humanoid")
622            .unwrap();
623        humanoid.sources = humanoid
624            .sources
625            .iter()
626            .map(|source| without_setting(source, SettingId::RootMotionSource))
627            .collect();
628        assert!(matches!(
629            validate_profiles(&profiles),
630            Err(RegistryValidationError::UnreferencedSetting {
631                setting: SettingId::RootMotionSource,
632                ..
633            })
634        ));
635    }
636
637    #[test]
638    fn validation_refuses_every_noncanonical_accepted_input_fact_shape() {
639        for state in [
640            FactState::Unknown,
641            FactState::Known(FactValue::AcceptedFormats(vec![])),
642            FactState::Known(FactValue::AcceptedFormats(vec![
643                SourceFormatV1::GltfJson,
644                SourceFormatV1::Fbx,
645            ])),
646        ] {
647            let mut profiles = profiles_v1().to_vec();
648            let godot = profiles
649                .iter_mut()
650                .find(|profile| profile.selection.family() == "godot")
651                .unwrap();
652            let accepted = godot
653                .facts
654                .iter_mut()
655                .find(|fact| fact.id() == FactId::AcceptedInputs)
656                .unwrap();
657            *accepted = ProfileFact::new(FactId::AcceptedInputs, state);
658            assert!(matches!(
659                validate_profiles(&profiles),
660                Err(RegistryValidationError::InvalidAcceptedInputFact { .. })
661            ));
662        }
663    }
664}